From 8ad9a1c8adad3986f097c70e15ad48d66afe143c Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 11 Jul 2026 12:29:02 -0400 Subject: [PATCH 001/165] Initial commit: Skill Asset Protocol corpus as of 2026-07-11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot of all design docs (CONTEXT.md, PRD, 6 ADRs, feasibility report + red-team), the executed fork-economics prototype, and the phase0 Story registration implementation (write path not yet run). Committed immediately after an adversarial premise review so all future edits to the evidence corpus are attributable — the red-team vs findings.json provenance question can never recur. Co-Authored-By: Claude Fable 5 --- .../optimizing-claude-code-prompts/SKILL.md | 160 ++++ .../references/claude-code-prompting-guide.md | 168 ++++ .archive/claude-code-best-practices.md | 608 ++++++++++++ .archive/prompting-best-practices.md | 904 ++++++++++++++++++ .../optimizing-claude-code-prompts/SKILL.md | 160 ++++ .../references/claude-code-prompting-guide.md | 168 ++++ .gitignore | 13 + CONTEXT.md | 104 ++ docs/PRD.md | 731 ++++++++++++++ ...0001-skills-as-hosted-invocation-rights.md | 31 + ...nize-skills-as-programmable-ip-on-story.md | 53 + docs/adr/0003-payment-gated-execution.md | 46 + docs/adr/0004-compete-on-moats-not-secrecy.md | 49 + .../0005-two-leg-cross-chain-settlement.md | 74 ++ .../0006-phased-rollout-closed-modes-first.md | 37 + docs/feasibility/findings.json | 664 +++++++++++++ docs/feasibility/prebuild-spikes.md | 63 ++ docs/feasibility/report.md | 164 ++++ docs/prd-redteam.json | 93 ++ phase0/.env.example | 16 + phase0/.gitignore | 4 + phase0/README.md | 65 ++ phase0/package-lock.json | 894 +++++++++++++++++ phase0/package.json | 24 + phase0/src/client.ts | 40 + phase0/src/index.ts | 152 +++ phase0/src/metadata.ts | 46 + phase0/tsconfig.json | 14 + prototype/README.md | 65 ++ prototype/settlement-engine.mjs | 200 ++++ prototype/settlement-tui.mjs | 112 +++ prototype/spike-cma-latency.mjs | 195 ++++ prototype/spike-fork-economics.mjs | 49 + 33 files changed, 6166 insertions(+) create mode 100644 .agents/skills/optimizing-claude-code-prompts/SKILL.md create mode 100644 .agents/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md create mode 100644 .archive/claude-code-best-practices.md create mode 100644 .archive/prompting-best-practices.md create mode 100644 .claude/skills/optimizing-claude-code-prompts/SKILL.md create mode 100644 .claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md create mode 100644 .gitignore create mode 100644 CONTEXT.md create mode 100644 docs/PRD.md create mode 100644 docs/adr/0001-skills-as-hosted-invocation-rights.md create mode 100644 docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md create mode 100644 docs/adr/0003-payment-gated-execution.md create mode 100644 docs/adr/0004-compete-on-moats-not-secrecy.md create mode 100644 docs/adr/0005-two-leg-cross-chain-settlement.md create mode 100644 docs/adr/0006-phased-rollout-closed-modes-first.md create mode 100644 docs/feasibility/findings.json create mode 100644 docs/feasibility/prebuild-spikes.md create mode 100644 docs/feasibility/report.md create mode 100644 docs/prd-redteam.json create mode 100644 phase0/.env.example create mode 100644 phase0/.gitignore create mode 100644 phase0/README.md create mode 100644 phase0/package-lock.json create mode 100644 phase0/package.json create mode 100644 phase0/src/client.ts create mode 100644 phase0/src/index.ts create mode 100644 phase0/src/metadata.ts create mode 100644 phase0/tsconfig.json create mode 100644 prototype/README.md create mode 100644 prototype/settlement-engine.mjs create mode 100644 prototype/settlement-tui.mjs create mode 100644 prototype/spike-cma-latency.mjs create mode 100644 prototype/spike-fork-economics.mjs diff --git a/.agents/skills/optimizing-claude-code-prompts/SKILL.md b/.agents/skills/optimizing-claude-code-prompts/SKILL.md new file mode 100644 index 0000000..66af0c7 --- /dev/null +++ b/.agents/skills/optimizing-claude-code-prompts/SKILL.md @@ -0,0 +1,160 @@ +--- +name: optimizing-Codex-prompts +description: Turn a rough or vague request to Codex into a precise, repo-grounded, high-performing prompt. Use when the user wants help phrasing, drafting, improving, optimizing, tightening, or "making better" a prompt/request/instruction for Codex; when the user pastes a draft and asks how to word it; when a prompt already ran and Codex did the wrong thing (diagnose and fix it); or when Codex keeps missing the mark — doing too much, ignoring constraints, solving the wrong problem, over-engineering, or needing many back-and-forth rounds. Triggers include "optimize this prompt", "help me ask Codex to…", "rewrite my request", "why did Codex do that", "how should I word this", "make this prompt clearer". +--- + +# Optimizing Codex Prompts + +## Overview + +Rewrite a user's rough request into a prompt that Codex can execute correctly on the +first pass. The current models (Opus 4.8) follow instructions **literally** and run +**autonomously**, so the highest-leverage move is to front-load intent, constraints, and a +runnable check in the first message. Vague asks spread across many turns waste tokens and +build the wrong thing. + +**Core principle:** A strong Codex prompt names the **goal**, the **context**, the +**constraints**, the **files/patterns to follow**, and **a check Codex can run to know it's +done**. Optimizing means supplying whichever of these the user left out — and resolving each +to a *real artifact in this repo*, not a placeholder the user must fill in later. + +## The one rule that makes this skill worth invoking: ground in the repo + +A prompt full of `@[your-file-here]` and "run the test suite" is just a template — it hands the +hard part back to the user. Before writing the optimized prompt, **resolve every reference to a +real thing** using the tools you have: + +| Reference | How to resolve it | Don't emit | +|---|---|---| +| The target file(s) | `Glob`/`Grep` for the actual path | `@[src/whatever]` | +| "Done when…" check | Read `package.json` scripts / `Makefile` / `pyproject.toml` / CI config for the real test/build/lint command | "run the tests" | +| "Follow the pattern in…" | `Grep` for a sibling that already does the thing; name that file | "the existing pattern" | +| The symptom's likely location | `Grep` the error string / feature name to the directory | "somewhere in the code" | + +**Resolve, don't guess.** If a genuine look can't resolve something, ask **one** surgical +question — never paper over it with a bracketed guess. + +**Red flags that you skipped grounding** (STOP and go look): the output prompt contains `[...]`, +"the relevant file", "your test command", "the appropriate", or any path you didn't verify exists. + +## Pick the mode + +| The user… | Mode | What you do | +|---|---|---| +| Pasted a draft prompt | **Optimize** | Ground it, fill missing ingredients, return the rewrite | +| Gave a bare goal ("add auth") | **Generate** | Ground it, build the prompt from scratch | +| Says a prompt already failed ("Codex did X not Y") | **Diagnose** | Map the failure to the missing ingredient, fix it, add the session-hygiene step | +| Wants a large/multi-file feature | **Spec** | Don't hand-write a mega-prompt — route to the interview→SPEC.md pattern (see reference) | + +## Workflow + +1. **Capture** the raw request verbatim. Pick the mode. +2. **Ground** in the repo — resolve real paths, the real verification command, the real pattern + file (table above). Do this with parallel `Glob`/`Grep`/`Read` calls; it's fast and it's the + whole point. +3. **Diagnose + score** the request against the seven ingredients. Show the scorecard. +4. **Resolve gaps:** correctness-blocking gaps that grounding couldn't settle → up to **3** + `AskUserQuestion` questions. If the user wants speed ("just optimize it"), proceed and label + any remaining assumption explicitly. +5. **Write** the optimized prompt as a copy-paste block, with real values throughout. +6. **Hand back + offer to run it.** Note the one assumption most worth confirming, if any. + +Don't pad the prompt with obvious instructions ("write clean code"). Opus 4.8 is literal and +smart — filler dilutes the real constraints. + +## The seven ingredients + +| Ingredient | Answers | Weak → Strong | +|---|---|---| +| **Goal** | What outcome, concretely? | "improve the dashboard" → "add date-range filtering to the dashboard" | +| **Context** | Why / where does this live? | — → "endpoint is `@src/api/orders.ts`; read-heavy, data changes hourly" | +| **Constraints** | What must NOT change / limits? | — → "keep the JSON shape backward-compatible; no new deps" | +| **References** | What pattern to follow? | — → "mirror `@src/api/users.ts`" | +| **Acceptance / check** | How do we know it's done? | "make it work" → "`npm test src/api/orders.test.ts` passes; show output" | +| **Approach / mode** | How should Codex work? | — → "plan first" / "use TDD" / "just do it" | +| **Output** | What should Codex return? | — → "show the diff and the test results" | + +The **acceptance check** is the highest-value ingredient — it's the difference between a session +the user babysits and one Codex closes on its own. Always try to supply a real one. + +## Scorecard (show this — it teaches the pattern) + +```text +Goal ✓ Context ✗ → added Constraints ✗ → added +References ✗→added Check ✗ → added Approach ~ → set Output ✓ +Before: 2/7 After: 7/7 +``` + +## Output template + +```text + + +Context: +Constraints: +Follow: +Done when: +Approach: +``` + +Drop any line that genuinely doesn't apply. Prefer natural prose for tiny tasks; use the labeled +lines when there are real constraints. + +## Example (grounded — note: no brackets) + +**Raw:** `make the checkout page faster` + +**Grounding moves:** +- `Glob **/checkout*` → `src/checkout/CheckoutPage.tsx` +- `package.json` scripts → `"test": "vitest run"`, `"build": "vite build"` +- `Grep "useMemo\|React.memo" src` → `src/cart/CartPage.tsx` already memoizes its list + +**Scorecard:** Before 1/7 → After 7/7 + +**Optimized:** + +```text +Speed up the checkout page in @src/checkout/CheckoutPage.tsx — its product list re-renders on +every keystroke in the promo-code field. + +Context: the list isn't memoized, so typing recomputes and re-renders all rows. +Constraints: don't change checkout behavior or the order-submit payload; no new dependencies. +Follow: the memoization pattern already in @src/cart/CartPage.tsx (React.memo on rows + useMemo +on the derived list). +Done when: `npx vitest run src/checkout` passes, `npm run build` succeeds, and typing in the +promo field no longer re-renders product rows (verify with a render count or React DevTools). +Show me the diff and the test output. +Approach: plan first, then implement. +``` + +## Mode specifics + +- **Diagnose** a failed prompt: name the failure → the missing ingredient it maps to → the fix. + "Codex refactored the whole file" = missing **Constraints** (add "only change X; no refactors"). + "Codex solved the wrong thing" = missing **Goal/Context** (name the file + symptom). Also tell + the user the session fix: after two bad corrections, `/clear` and resend the optimized prompt; + use `/rewind` to undo Codex's changes. +- **Generate** from a bare goal: ground first, then if scope is still ambiguous ask the 3 + questions before writing — don't generate a confident prompt on top of unknowns. +- **Spec** a big feature: see the interview→SPEC.md→fresh-session pattern in the reference. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Emitting `[bracketed placeholders]` | Ground in the repo; resolve to real paths/commands, or ask one question | +| Stacking unrelated tasks in one prompt | One task per prompt; `/clear` between them | +| "Make it better" with no check | Name a real verification: a test command, a build, a screenshot to compare | +| Describing the fix instead of the symptom | Give symptom + likely location; let Codex find the cause | +| Over-specifying the obvious | Cut filler; keep only constraints Codex can't infer | +| Dribbling context over many turns | Front-load intent + constraints in the first message — Opus 4.8 rewards this | + +## Deeper guidance + +For the full strategy tables, model-specific behavior (literalism, autonomy, over-eagerness), +the verification-gating ladder, rich-context input (`@files`, images, URLs, piping), plan-mode +decisions, mid-task course-correction phrasing, the interview→spec pattern, and reusable prompt +snippets, read [references/Codex-prompting-guide.md](references/Codex-prompting-guide.md). + +Source material lives at the repo root: `Codex-best-practices.md` and +`prompting-best-practices.md`. diff --git a/.agents/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md b/.agents/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md new file mode 100644 index 0000000..90110f1 --- /dev/null +++ b/.agents/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md @@ -0,0 +1,168 @@ +# Claude Code Prompting Guide (deep reference) + +Distilled from `claude-code-best-practices.md` and `prompting-best-practices.md` (repo root). +Load this when the basic seven-ingredient pass in SKILL.md isn't enough — large features, +model-behavior tuning, or diagnosing why Claude Code keeps going off track. + +## Contents +- [1. The seven ingredients, expanded](#1-the-seven-ingredients-expanded) +- [2. Verification: give Claude a check it can run](#2-verification-give-claude-a-check-it-can-run) +- [3. Plan first vs. just do it](#3-plan-first-vs-just-do-it) +- [4. Model behavior to prompt around (Opus 4.8)](#4-model-behavior-to-prompt-around-opus-48) +- [5. Feed rich context](#5-feed-rich-context) +- [6. Big features: interview → spec → fresh session](#6-big-features-interview--spec--fresh-session) +- [7. Session hygiene the optimizer should recommend](#7-session-hygiene-the-optimizer-should-recommend) +- [8. Anti-patterns to rewrite away](#8-anti-patterns-to-rewrite-away) +- [9. Reusable prompt snippets](#9-reusable-prompt-snippets) + +## 1. The seven ingredients, expanded + +Each "before → after" shows the kind of rewrite the optimizer performs. + +| Strategy | Before | After | +|---|---|---| +| **Scope the task** | "add tests for foo.py" | "write a test for foo.py covering the case where the user is logged out. avoid mocks." | +| **Point to sources** | "why does ExecutionFactory have such a weird api?" | "look through ExecutionFactory's git history and summarize how its api came to be" | +| **Reference patterns** | "add a calendar widget" | "look at how widgets are built on the home page — HotDogWidget.php is a good example. follow that pattern to add a calendar widget. build from scratch without new libraries." | +| **Describe the symptom** | "fix the login bug" | "users report login fails after session timeout. check token refresh in src/auth/. write a failing test that reproduces it, then fix it." | + +Vague prompts still have a place: when *exploring*, `"what would you improve in this file?"` +surfaces things the user wouldn't have thought to ask. Optimize for precision when the user +wants a specific outcome; leave room when they're fishing. + +## 2. Verification: give Claude a check it can run + +Without a runnable check, "looks done" is the only stop signal and the user becomes the +verification loop. A check is anything returning pass/fail in the conversation: a test suite, +a build exit code, a linter, a diff against a fixture, or a screenshot compared to a design. + +Gating ladder, from lightest to strongest — pick based on how much the user is watching: + +1. **In one prompt** — "run the tests after implementing and fix failures." Works today on any task. +2. **Across a session** — set the check as a `/goal` condition; an evaluator re-checks every turn. +3. **Deterministic gate** — a Stop hook runs the check as a script and blocks the turn until it passes. +4. **Second opinion** — a verification subagent / `/code-review` re-checks in fresh context. + +Always ask for **evidence, not assertion**: the command run and its output, or a screenshot. + +UI work: "[paste screenshot] implement this design. take a screenshot of the result, compare +to the original, list differences, and fix them." + +Bugs: "fix it and verify the build succeeds. address the root cause, don't suppress the error." + +## 3. Plan first vs. just do it + +Recommend **plan mode** when the approach is uncertain, the change spans multiple files, or +the user is unfamiliar with the code. Skip it when the diff fits in one sentence (typo, log +line, rename). The four-phase loop: **Explore → Plan → Implement → Commit**, with exploration +and planning done in plan mode (read-only) before any edits. + +## 4. Model behavior to prompt around (Opus 4.8) + +- **Literal instruction following.** It won't generalize an instruction from one item to the + rest. If something should apply broadly, say so: "apply this to every section, not just the first." +- **Autonomy.** It reasons more after user turns and works long-horizon. Specify task, intent, + and constraints **upfront in the first message** to maximize autonomy and token efficiency. + Ambiguous prompts dribbled across turns reduce both performance and efficiency. +- **Over-eagerness / over-engineering.** It may add files, abstractions, and flexibility nobody + asked for. When the user wants minimalism, add: "Only make changes directly requested or + clearly necessary. Don't add features, abstractions, docstrings, or defensive code beyond + what's needed for this task." +- **Action vs. suggestion.** "Can you suggest changes…" often yields only suggestions. For + action, use imperatives: "Change this function to…", "Make these edits to…". +- **Risky actions.** For autonomous runs, add: "Take local, reversible actions freely, but ask + before anything hard to reverse (force-push, deleting files/branches, dropping tables) or + visible to others (pushing, commenting on PRs)." +- **Hallucination guard.** "Never make claims about code you haven't opened. Read referenced + files before answering." + +## 5. Feed rich context + +- **`@path`** references a file so Claude reads it before responding — better than describing where code lives. +- **Paste images** (screenshots, mockups) directly into the prompt. +- **Give URLs** for docs/APIs; allowlist frequent domains with `/permissions`. +- **Pipe data**: `cat error.log | claude` sends contents straight in. +- **Let Claude fetch**: tell it to pull context itself via Bash, MCP tools, or file reads. + +## 6. Big features: interview → spec → fresh session + +For larger work, don't write the mega-prompt by hand. Have Claude interview the user first: + +```text +I want to build [brief description]. Interview me in detail using the AskUserQuestion tool. +Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs. Don't ask +obvious questions — dig into the hard parts I might not have considered. Keep interviewing +until we've covered everything, then write a complete spec to SPEC.md. +``` + +The best specs are self-contained: they name the files and interfaces involved, state what's +out of scope, and end with an end-to-end verification step. Then start a **fresh session** to +execute the spec with clean context. + +## 7. Session hygiene the optimizer should recommend + +When a user is frustrated with results, the fix is often the *session*, not the prompt: + +- **Course-correct early** with `Esc`; `Esc Esc` or `/rewind` to restore prior state. +- **`/clear` between unrelated tasks** — the "kitchen sink session" pollutes context. +- **After two failed corrections, `/clear` and rewrite** the initial prompt with what was learned. + A clean session with a better prompt beats a long one full of failed attempts. +- **Subagents for investigation** — "use subagents to investigate X" keeps the main context clean. + +### Mid-task course-correction phrasing + +When Claude is *actively* going wrong, the user doesn't need a new prompt — they need a sharp +redirect. Hand them phrasing like: + +- Scope creep: "Stop. Revert the changes outside `@target.ts` and do only the one change I asked for." +- Wrong root cause: "That treats the symptom. Find why `` happens before changing anything; show me the cause first." +- Over-engineering: "Too much. Remove the abstraction/helper you added and inline the simplest version that passes the test." +- Unverified 'done': "Don't tell me it works — run `` and paste the output." +- Going in circles: "`/clear` and start fresh — here's a tighter prompt: ." + +### Grounding checklist (run before writing any optimized prompt) + +1. Target file(s) resolved to real paths via `Glob`/`Grep`? +2. The "Done when" check is a real command (from `package.json`/`Makefile`/`pyproject.toml`/CI)? +3. "Follow the pattern" names a real sibling file that already does it? +4. The symptom's likely location grep'd to a real directory? +5. Zero `[brackets]` left in the output? If not, go look or ask one question. + +## 8. Anti-patterns to rewrite away + +- **Kitchen-sink prompt** — multiple unrelated tasks at once → split, one per prompt. +- **Trust-then-verify gap** — plausible code, no edge-case check → always attach verification. +- **Infinite exploration** — unscoped "investigate" → scope narrowly or delegate to a subagent. +- **Over-prompting** — "CRITICAL: you MUST…" everywhere → on current models, normal phrasing + ("Use this when…") avoids over-triggering. Reserve emphasis for the one rule that matters most. +- **"Don't do X" formatting rules** — prefer telling Claude what TO do ("respond in flowing prose") + over what to avoid ("don't use markdown"). + +## 9. Reusable prompt snippets + +Drop these into an optimized prompt when the matching behavior is needed. + +**Coverage over filtering (review/bug-finding):** +```text +Report every issue you find, including low-severity or uncertain ones. Don't filter for +importance yet — coverage is the goal. Include a confidence level and severity for each. +``` + +**Minimal / no over-engineering:** +```text +Only make changes directly requested or clearly necessary. No new features, abstractions, or +defensive code beyond what this task needs. Don't add comments or types to code you didn't change. +``` + +**General, non-overfit solution:** +```text +Implement a correct general solution for all valid inputs, not just the test cases. Don't +hard-code to the tests or add workaround scripts. If a test seems wrong, tell me instead of +working around it. +``` + +**Persist across context compaction (long autonomous runs):** +```text +Your context will be compacted automatically as it fills, so don't stop early for token +reasons. Save progress and state to disk before the window refreshes, and complete the task fully. +``` diff --git a/.archive/claude-code-best-practices.md b/.archive/claude-code-best-practices.md new file mode 100644 index 0000000..8937278 --- /dev/null +++ b/.archive/claude-code-best-practices.md @@ -0,0 +1,608 @@ +> ## Documentation Index +> Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt +> Use this file to discover all available pages before exploring further. + +# Best practices for Claude Code + +> Tips and patterns for getting the most out of Claude Code, from configuring your environment to scaling across parallel sessions. + +Claude Code is an agentic coding environment. Unlike a chatbot that answers questions and waits, Claude Code can read your files, run commands, make changes, and autonomously work through problems while you watch, redirect, or step away entirely. + +This changes how you work. Instead of writing code yourself and asking Claude to review it, you describe what you want and Claude figures out how to build it. Claude explores, plans, and implements. + +But this autonomy still comes with a learning curve. Claude works within certain constraints you need to understand. + +This guide covers patterns that have proven effective across Anthropic's internal teams and for engineers using Claude Code across various codebases, languages, and environments. For how the agentic loop works under the hood, see [How Claude Code works](/en/how-claude-code-works). + +*** + +Most best practices are based on one constraint: Claude's context window fills up fast, and performance degrades as it fills. + +Claude's context window holds your entire conversation, including every message, every file Claude reads, and every command output. However, this can fill up fast. A single debugging session or codebase exploration might generate and consume tens of thousands of tokens. + +This matters since LLM performance degrades as context fills. When the context window is getting full, Claude may start "forgetting" earlier instructions or making more mistakes. The context window is the most important resource to manage. To see how a session fills up in practice, [watch an interactive walkthrough](/en/context-window) of what loads at startup and what each file read costs. Track context usage continuously with a [custom status line](/en/statusline), and see [Reduce token usage](/en/costs#reduce-token-usage) for strategies on reducing token usage. + +*** + +## Give Claude a way to verify its work + + + Give Claude a check it can run: tests, a build, a screenshot to compare. It's the difference between a session you watch and one you walk away from. + + +Claude stops when the work looks done. Without a check it can run, "looks done" is the only signal available, and you become the verification loop: every mistake waits for you to notice it. Give Claude something that produces a pass or fail, and the loop closes on its own. Claude does the work, runs the check, reads the result, and iterates until the check passes. + +The check is anything that returns a signal Claude can read in the conversation: a test suite, a build exit code, a linter, a script that diffs output against a fixture, or a [browser screenshot](/en/chrome) compared against a design. + +| Strategy | Before | After | +| ------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Provide verification criteria** | *"implement a function that validates email addresses"* | *"write a validateEmail function. example test cases: [user@example.com](mailto:user@example.com) is true, invalid is false, [user@.com](mailto:user@.com) is false. run the tests after implementing"* | +| **Verify UI changes visually** | *"make the dashboard look better"* | *"\[paste screenshot] implement this design. take a screenshot of the result and compare it to the original. list differences and fix them"* | +| **Address root causes, not symptoms** | *"the build is failing"* | *"the build fails with this error: \[paste error]. fix it and verify the build succeeds. address the root cause, don't suppress the error"* | + +Once the check exists, decide how hard it gates the stop: + +* **In one prompt**: ask Claude to run the check and iterate in the same message, as in the table above. +* **Across a session**: set the check as a [`/goal` condition](/en/goal). A separate evaluator re-checks it after every turn and Claude keeps working until it holds. +* **As a deterministic gate**: a [Stop hook](/en/hooks#stop) runs your check as a script and blocks the turn from ending until it passes. Claude Code overrides the hook and ends the turn after 8 consecutive blocks. +* **By a second opinion**: a [verification subagent](/en/sub-agents) or a [dynamic workflow](/en/workflows) that checks its own findings has a fresh model try to refute the result, so the agent doing the work isn't the one grading it. + +Each step trades setup for attention. The prompt version works on any task today. The `/goal` and Stop hook versions are what let an unattended run finish correctly without you. + +Have Claude show evidence rather than asserting success: the test output, the command it ran and what it returned, or a screenshot of the result. Reviewing evidence is faster than re-running the verification yourself, and it works for sessions you weren't watching. + +*** + +## Explore first, then plan, then code + + + Separate research and planning from implementation to avoid solving the wrong problem. + + +Letting Claude jump straight to coding can produce code that solves the wrong problem. Use [plan mode](/en/permission-modes#analyze-before-you-edit-with-plan-mode) to separate exploration from execution. + +The recommended workflow has four phases: + + + + Enter plan mode. Claude reads files and answers questions without making changes. + + ```txt claude (plan mode) theme={null} + read /src/auth and understand how we handle sessions and login. + also look at how we manage environment variables for secrets. + ``` + + + + Ask Claude to create a detailed implementation plan. + + ```txt claude (plan mode) theme={null} + I want to add Google OAuth. What files need to change? + What's the session flow? Create a plan. + ``` + + Press `Ctrl+G` to open the plan in your text editor for direct editing before Claude proceeds. + + + + Switch out of plan mode and let Claude code, verifying against its plan. + + ```txt claude (default mode) theme={null} + implement the OAuth flow from your plan. write tests for the + callback handler, run the test suite and fix any failures. + ``` + + + + Ask Claude to commit with a descriptive message and create a PR. + + ```txt claude (default mode) theme={null} + commit with a descriptive message and open a PR + ``` + + + + + Plan mode is useful, but also adds overhead. + + For tasks where the scope is clear and the fix is small (like fixing a typo, adding a log line, or renaming a variable) ask Claude to do it directly. + + Planning is most useful when you're uncertain about the approach, when the change modifies multiple files, or when you're unfamiliar with the code being modified. If you could describe the diff in one sentence, skip the plan. + + +*** + +## Provide specific context in your prompts + + + The more precise your instructions, the fewer corrections you'll need. + + +Claude can infer intent, but it can't read your mind. Reference specific files, mention constraints, and point to example patterns. + +| Strategy | Before | After | +| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Scope the task.** Specify which file, what scenario, and testing preferences. | *"add tests for foo.py"* | *"write a test for foo.py covering the edge case where the user is logged out. avoid mocks."* | +| **Point to sources.** Direct Claude to the source that can answer a question. | *"why does ExecutionFactory have such a weird api?"* | *"look through ExecutionFactory's git history and summarize how its api came to be"* | +| **Reference existing patterns.** Point Claude to patterns in your codebase. | *"add a calendar widget"* | *"look at how existing widgets are implemented on the home page to understand the patterns. HotDogWidget.php is a good example. follow the pattern to implement a new calendar widget that lets the user select a month and paginate forwards/backwards to pick a year. build from scratch without libraries other than the ones already used in the codebase."* | +| **Describe the symptom.** Provide the symptom, the likely location, and what "fixed" looks like. | *"fix the login bug"* | *"users report that login fails after session timeout. check the auth flow in src/auth/, especially token refresh. write a failing test that reproduces the issue, then fix it"* | + +Vague prompts can be useful when you're exploring and can afford to course-correct. A prompt like `"what would you improve in this file?"` can surface things you wouldn't have thought to ask about. + +### Provide rich content + + + Use `@` to reference files, paste screenshots/images, or pipe data directly. + + +You can provide rich data to Claude in several ways: + +* **Reference files with `@`** instead of describing where code lives. Claude reads the file before responding. +* **Paste images directly**. Copy/paste or drag and drop images into the prompt. +* **Give URLs** for documentation and API references. Use `/permissions` to allowlist frequently-used domains. +* **Pipe in data** by running `cat error.log | claude` to send file contents directly. +* **Let Claude fetch what it needs**. Tell Claude to pull context itself using Bash commands, MCP tools, or by reading files. + +*** + +## Configure your environment + +A few setup steps make Claude Code significantly more effective across all your sessions. For a full overview of extension features and when to use each one, see [Extend Claude Code](/en/features-overview). + +### Write an effective CLAUDE.md + + + Run `/init` to generate a starter CLAUDE.md file based on your current project structure, then refine over time. + + +CLAUDE.md is a special file that Claude reads at the start of every conversation. Include Bash commands, code style, and workflow rules. This gives Claude persistent context it can't infer from code alone. + +The `/init` command analyzes your codebase to detect build systems, test frameworks, and code patterns, giving you a solid foundation to refine. + +There's no required format for CLAUDE.md files, but keep it short and human-readable. For example: + +```markdown CLAUDE.md theme={null} +# Code style +- Use ES modules (import/export) syntax, not CommonJS (require) +- Destructure imports when possible (eg. import { foo } from 'bar') + +# Workflow +- Be sure to typecheck when you're done making a series of code changes +- Prefer running single tests, and not the whole test suite, for performance +``` + +CLAUDE.md is loaded every session, so only include things that apply broadly. For domain knowledge or workflows that are only relevant sometimes, use [skills](/en/skills) instead. Claude loads them on demand without bloating every conversation. + +Keep it concise. For each line, ask: *"Would removing this cause Claude to make mistakes?"* If not, cut it. Bloated CLAUDE.md files cause Claude to ignore your actual instructions! + +| ✅ Include | ❌ Exclude | +| ---------------------------------------------------- | -------------------------------------------------- | +| Bash commands Claude can't guess | Anything Claude can figure out by reading code | +| Code style rules that differ from defaults | Standard language conventions Claude already knows | +| Testing instructions and preferred test runners | Detailed API documentation (link to docs instead) | +| Repository etiquette (branch naming, PR conventions) | Information that changes frequently | +| Architectural decisions specific to your project | Long explanations or tutorials | +| Developer environment quirks (required env vars) | File-by-file descriptions of the codebase | +| Common gotchas or non-obvious behaviors | Self-evident practices like "write clean code" | + +If Claude keeps doing something you don't want despite having a rule against it, the file is probably too long and the rule is getting lost. If Claude asks you questions that are answered in CLAUDE.md, the phrasing might be ambiguous. Treat CLAUDE.md like code: review it when things go wrong, prune it regularly, and test changes by observing whether Claude's behavior actually shifts. + +You can tune instructions by adding emphasis (e.g., "IMPORTANT" or "YOU MUST") to improve adherence. Check CLAUDE.md into git so your team can contribute. The file compounds in value over time. + +CLAUDE.md files can import additional files using `@path/to/import` syntax: + +```markdown CLAUDE.md theme={null} +See @README.md for project overview and @package.json for available npm commands. + +# Additional Instructions +- Git workflow: @docs/git-instructions.md +- Personal overrides: @~/.claude/my-project-instructions.md +``` + +You can place CLAUDE.md files in several locations: + +* **Home folder (`~/.claude/CLAUDE.md`)**: applies to all Claude sessions +* **Project root (`./CLAUDE.md`)**: check into git to share with your team +* **Project root (`./CLAUDE.local.md`)**: personal project-specific notes; add this file to your `.gitignore` so it isn't shared with your team +* **Parent directories**: useful for monorepos where both `root/CLAUDE.md` and `root/foo/CLAUDE.md` are pulled in automatically +* **Child directories**: Claude pulls in child CLAUDE.md files on demand when it reads a file in those directories + +### Configure permissions + + + Use [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) to let a classifier handle approvals, `/permissions` to allowlist specific commands, or `/sandbox` for OS-level isolation. Each reduces interruptions while keeping you in control. + + +By default, Claude Code requests permission for actions that might modify your system: file writes, Bash commands, MCP tools, etc. This is safe but tedious. After the tenth approval you're not really reviewing anymore, you're just clicking through. There are three ways to reduce these interruptions: + +* **Auto mode**: a separate classifier model reviews commands and blocks only what looks risky: scope escalation, unknown infrastructure, or hostile-content-driven actions. Best when you trust the general direction of a task but don't want to click through every step +* **Permission allowlists**: permit specific tools you know are safe, like `npm run lint` or `git commit` +* **Sandboxing**: enable OS-level isolation that restricts filesystem and network access, allowing Claude to work more freely within defined boundaries + +Read more about [permission modes](/en/permission-modes), [permission rules](/en/permissions), and [sandboxing](/en/sandboxing). + +### Use CLI tools + + + Tell Claude Code to use CLI tools like `gh`, `aws`, `gcloud`, and `sentry-cli` when interacting with external services. + + +CLI tools are the most context-efficient way to interact with external services. If you use GitHub, install the `gh` CLI. Claude knows how to use it for creating issues, opening pull requests, and reading comments. Without `gh`, Claude can still use the GitHub API, but unauthenticated requests often hit rate limits. + +Claude is also effective at learning CLI tools it doesn't already know. Try prompts like `Use 'foo-cli-tool --help' to learn about foo tool, then use it to solve A, B, C.` + +### Connect MCP servers + + + Run `claude mcp add` to connect external tools like Notion, Figma, or your database. + + +With [MCP servers](/en/mcp), you can ask Claude to implement features from issue trackers, query databases, analyze monitoring data, integrate designs from Figma, and automate workflows. + +### Set up hooks + + + Use hooks for actions that must happen every time with zero exceptions. + + +[Hooks](/en/hooks-guide) run scripts automatically at specific points in Claude's workflow. Unlike CLAUDE.md instructions which are advisory, hooks are deterministic and guarantee the action happens. + +Claude can write hooks for you. Try prompts like *"Write a hook that runs eslint after every file edit"* or *"Write a hook that blocks writes to the migrations folder."* Edit `.claude/settings.json` directly to configure hooks by hand, and run `/hooks` to browse what's configured. + +### Create skills + + + Create `SKILL.md` files in `.claude/skills/` to give Claude domain knowledge and reusable workflows. + + +[Skills](/en/skills) extend Claude's knowledge with information specific to your project, team, or domain. Claude applies them automatically when relevant, or you can invoke them directly with `/skill-name`. + +Create a skill by adding a directory with a `SKILL.md` to `.claude/skills/`: + +```markdown .claude/skills/api-conventions/SKILL.md theme={null} +--- +name: api-conventions +description: REST API design conventions for our services +--- +# API Conventions +- Use kebab-case for URL paths +- Use camelCase for JSON properties +- Always include pagination for list endpoints +- Version APIs in the URL path (/v1/, /v2/) +``` + +Skills can also define repeatable workflows you invoke directly: + +```markdown .claude/skills/fix-issue/SKILL.md theme={null} +--- +name: fix-issue +description: Fix a GitHub issue +disable-model-invocation: true +--- +Analyze and fix the GitHub issue: $ARGUMENTS. + +1. Use `gh issue view` to get the issue details +2. Understand the problem described in the issue +3. Search the codebase for relevant files +4. Implement the necessary changes to fix the issue +5. Write and run tests to verify the fix +6. Ensure code passes linting and type checking +7. Create a descriptive commit message +8. Push and create a PR +``` + +Run `/fix-issue 1234` to invoke it. Use `disable-model-invocation: true` for workflows with side effects that you want to trigger manually. + +### Create custom subagents + + + Define specialized assistants in `.claude/agents/` that Claude can delegate to for isolated tasks. + + +[Subagents](/en/sub-agents) run in their own context with their own set of allowed tools. They're useful for tasks that read many files or need specialized focus without cluttering your main conversation. + +```markdown .claude/agents/security-reviewer.md theme={null} +--- +name: security-reviewer +description: Reviews code for security vulnerabilities +tools: Read, Grep, Glob, Bash +model: opus +--- +You are a senior security engineer. Review code for: +- Injection vulnerabilities (SQL, XSS, command injection) +- Authentication and authorization flaws +- Secrets or credentials in code +- Insecure data handling + +Provide specific line references and suggested fixes. +``` + +Tell Claude to use subagents explicitly: *"Use a subagent to review this code for security issues."* + +### Install plugins + + + Run `/plugin` to browse the marketplace. Plugins add skills, tools, and integrations without configuration. + + +[Plugins](/en/plugins) bundle skills, hooks, subagents, and MCP servers into a single installable unit from the community and Anthropic. If you work with a typed language, install a [code intelligence plugin](/en/discover-plugins#code-intelligence) to give Claude precise symbol navigation and automatic error detection after edits. + +For guidance on choosing between skills, subagents, hooks, and MCP, see [Extend Claude Code](/en/features-overview#match-features-to-your-goal). + +*** + +## Communicate effectively + +The way you communicate with Claude Code significantly impacts the quality of results. + +### Ask codebase questions + + + Ask Claude questions you'd ask a senior engineer. + + +When onboarding to a new codebase, use Claude Code for learning and exploration. You can ask Claude the same sorts of questions you would ask another engineer: + +* How does logging work? +* How do I make a new API endpoint? +* What does `async move { ... }` do on line 134 of `foo.rs`? +* What edge cases does `CustomerOnboardingFlowImpl` handle? +* Why does this code call `foo()` instead of `bar()` on line 333? + +Using Claude Code this way is an effective onboarding workflow, improving ramp-up time and reducing load on other engineers. No special prompting required: ask questions directly. + +### Let Claude interview you + + + For larger features, have Claude interview you first. Start with a minimal prompt and ask Claude to interview you using the `AskUserQuestion` tool. + + +Claude asks about things you might not have considered yet, including technical implementation, UI/UX, edge cases, and tradeoffs. + +```text theme={null} +I want to build [brief description]. Interview me in detail using the AskUserQuestion tool. + +Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs. Don't ask obvious questions, dig into the hard parts I might not have considered. + +Keep interviewing until we've covered everything, then write a complete spec to SPEC.md. +``` + +Once the spec is complete, start a fresh session to execute it. The new session has clean context focused entirely on implementation, and you have a written spec to reference. + +The most useful specs are self-contained: they name the files and interfaces involved, state what is out of scope, and end with an end-to-end verification step that proves the feature works. Time spent making the spec precise pays off more than time spent watching the implementation. + +*** + +## Manage your session + +Conversations are persistent and reversible. Use this to your advantage! + +### Course-correct early and often + + + Correct Claude as soon as you notice it going off track. + + +The best results come from tight feedback loops. Though Claude occasionally solves problems perfectly on the first attempt, correcting it quickly generally produces better solutions faster. + +* **`Esc`**: stop Claude mid-action with the `Esc` key. Context is preserved, so you can redirect. +* **`Esc + Esc` or `/rewind`**: press `Esc` twice or run `/rewind` to open the rewind menu and restore previous conversation and code state, or summarize from a selected message. +* **`"Undo that"`**: have Claude revert its changes. +* **`/clear`**: reset context between unrelated tasks. Long sessions with irrelevant context can reduce performance. + +If you've corrected Claude more than twice on the same issue in one session, the context is cluttered with failed approaches. Run `/clear` and start fresh with a more specific prompt that incorporates what you learned. A clean session with a better prompt almost always outperforms a long session with accumulated corrections. + +### Manage context aggressively + + + Run `/clear` between unrelated tasks to reset context. + + +Claude Code automatically compacts conversation history when you approach context limits, which preserves important code and decisions while freeing space. + +During long sessions, Claude's context window can fill with irrelevant conversation, file contents, and commands. This can reduce performance and sometimes distract Claude. + +* Use `/clear` frequently between tasks to reset the context window entirely +* When auto compaction triggers, Claude summarizes what matters most, including code patterns, file states, and key decisions +* For more control, run `/compact `, like `/compact Focus on the API changes` +* To compact only part of the conversation, use `Esc + Esc` or `/rewind`, select a message checkpoint, and choose **Summarize from here** or **Summarize up to here**. The first condenses messages from that point forward while keeping earlier context intact; the second condenses earlier messages while keeping recent ones in full. See [Restore vs. summarize](/en/checkpointing#restore-vs-summarize). +* Customize compaction behavior in CLAUDE.md with instructions like `"When compacting, always preserve the full list of modified files and any test commands"` to ensure critical context survives summarization +* For quick questions that don't need to stay in context, use [`/btw`](/en/interactive-mode#side-questions-with-%2Fbtw). The answer appears in a dismissible overlay and never enters conversation history, so you can check a detail without growing context. + +### Use subagents for investigation + + + Delegate research with `"use subagents to investigate X"`. They explore in a separate context, keeping your main conversation clean for implementation. + + +Since context is your fundamental constraint, subagents are one of the most powerful tools available. When Claude researches a codebase it reads lots of files, all of which consume your context. Subagents run in separate context windows and report back summaries: + +```text theme={null} +Use subagents to investigate how our authentication system handles token +refresh, and whether we have any existing OAuth utilities I should reuse. +``` + +The subagent explores the codebase, reads relevant files, and reports back with findings, all without cluttering your main conversation. + +You can also use subagents for verification after Claude implements something: + +```text theme={null} +use a subagent to review this code for edge cases +``` + +### Rewind with checkpoints + + + Every prompt you send creates a checkpoint. You can restore conversation, code, or both to any previous checkpoint. + + +Claude automatically snapshots files before each change so a checkpoint can restore them. Double-tap `Escape` or run `/rewind` to open the rewind menu. You can restore conversation only, restore code only, restore both, or summarize from a selected message. See [Checkpointing](/en/checkpointing) for details. + +Instead of carefully planning every move, you can tell Claude to try something risky. If it doesn't work, rewind and try a different approach. Checkpoints persist across sessions, so you can close your terminal and still rewind later. + + + Checkpoints only track changes made *by Claude*, not external processes. This isn't a replacement for git. + + +### Resume conversations + + + Name sessions with `/rename` and treat them like branches: each workstream gets its own persistent context. + + +Claude Code saves conversations locally, so when a task spans multiple sittings you don't have to re-explain the context. Run `claude --continue` to pick up the most recent session, or `claude --resume` to choose from a list. Give sessions descriptive names like `oauth-migration` so you can find them later. See [Manage sessions](/en/sessions) for the full set of resume, branch, and naming controls. + +*** + +## Automate and scale + +Once you're effective with one Claude, multiply your output with parallel sessions, non-interactive mode, and fan-out patterns. + +Everything so far assumes one human, one Claude, and one conversation. But Claude Code scales horizontally. The techniques in this section show how you can get more done. + +### Run non-interactive mode + + + Use `claude -p "prompt"` in CI, pre-commit hooks, or scripts. Add `--output-format stream-json --verbose` for streaming JSON output. + + +With `claude -p "your prompt"`, you can run Claude non-interactively, without a session. [Non-interactive mode](/en/headless) is how you integrate Claude into CI pipelines, pre-commit hooks, or any automated workflow. The output formats let you parse results programmatically: plain text, JSON, or streaming JSON. + +```bash theme={null} +# One-off queries +claude -p "Explain what this project does" + +# Structured output for scripts +claude -p "List all API endpoints" --output-format json + +# Streaming for real-time processing +claude -p "Analyze this log file" --output-format stream-json --verbose +``` + +### Run multiple Claude sessions + + + Run multiple Claude sessions in parallel to speed up development, run isolated experiments, or start complex workflows. + + +Pick the parallel approach that fits how much coordination you want to do yourself: + +* [Worktrees](/en/worktrees): run separate CLI sessions in isolated git checkouts so edits don't collide +* [Desktop app](/en/desktop#work-in-parallel-with-sessions): manage multiple local sessions visually, each in its own worktree +* [Claude Code on the web](/en/claude-code-on-the-web): run sessions on Anthropic-managed cloud infrastructure in isolated VMs +* [Agent teams](/en/agent-teams): automated coordination of multiple sessions with shared tasks, messaging, and a team lead + +Beyond parallelizing work, multiple sessions enable quality-focused workflows. A fresh context improves code review since Claude won't be biased toward code it just wrote. + +For example, use a Writer/Reviewer pattern: + +| Session A (Writer) | Session B (Reviewer) | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Implement a rate limiter for our API endpoints` | | +| | `Review the rate limiter implementation in @src/middleware/rateLimiter.ts. Look for edge cases, race conditions, and consistency with our existing middleware patterns.` | +| `Here's the review feedback: [Session B output]. Address these issues.` | | + +You can do something similar with tests: have one Claude write tests, then another write code to pass them. + +### Fan out across files + + + Loop through tasks calling `claude -p` for each. Use `--allowedTools` to scope permissions for batch operations. + + +For large migrations or analyses, you can distribute work across many parallel Claude invocations: + + + + Have Claude list all files that need migrating (e.g., `list all 2,000 Python files that need migrating`) + + + + ```bash theme={null} + for file in $(cat files.txt); do + claude -p "Migrate $file from React to Vue. Return OK or FAIL." \ + --allowedTools "Edit,Bash(git commit *)" + done + ``` + + + + Refine your prompt based on what goes wrong with the first 2-3 files, then run on the full set. The `--allowedTools` flag restricts what Claude can do, which matters when you're running unattended. + + + +You can also integrate Claude into existing data/processing pipelines: + +```bash theme={null} +claude -p "" --output-format json | your_command +``` + +Use `--verbose` for debugging during development, and turn it off in production. + +### Run autonomously with auto mode + +For uninterrupted execution with background safety checks, use [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode). A classifier model reviews commands before they run, blocking scope escalation, unknown infrastructure, and hostile-content-driven actions while letting routine work proceed without prompts. + +```bash theme={null} +claude --permission-mode auto -p "fix all lint errors" +``` + +For non-interactive runs with the `-p` flag, auto mode aborts if the classifier repeatedly blocks actions, since there is no user to fall back to. See [when auto mode falls back](/en/permission-modes#when-auto-mode-falls-back) for thresholds. + +### Add an adversarial review step + + + Before treating a task as done, have a subagent review the diff in a fresh context and report gaps. + + +The longer Claude works unattended, the more an independent check matters before you count the work as done. A reviewer running in a fresh [subagent](/en/sub-agents) context sees only the diff and the criteria you give it, not the reasoning that produced the change, so it evaluates the result on its own terms. + +For a correctness check, run the bundled [`/code-review` skill](/en/commands), which reviews the current diff for bugs in a fresh subagent and returns findings to the session. To check the diff against your plan instead, write the review prompt yourself. Name the work to check, the plan to check it against, and what counts as a finding: + +```text theme={null} +Use a subagent to review the rate limiter diff against PLAN.md. Check that +every requirement is implemented, the listed edge cases have tests, and +nothing outside the task's scope changed. Report gaps, not style preferences. +``` + +Because the reviewer runs as a subagent, the implementing session receives the gaps directly and can fix them and re-review without you copying findings between windows. For longer autonomous runs, an [agent team](/en/agent-teams) can keep this loop going across many tasks while you spot-check the recorded findings. + + + A reviewer prompted to find gaps will usually report some, even when the work is sound, because that is what it was asked to do. Chasing every finding leads to over-engineering: extra abstraction layers, defensive code, and tests for cases that can't happen. Tell the reviewer to flag only gaps that affect correctness or the stated requirements, and treat the rest as optional. + + +*** + +## Avoid common failure patterns + +These are common mistakes. Recognizing them early saves time: + +* **The kitchen sink session.** You start with one task, then ask Claude something unrelated, then go back to the first task. Context is full of irrelevant information. + > **Fix**: `/clear` between unrelated tasks. +* **Correcting over and over.** Claude does something wrong, you correct it, it's still wrong, you correct again. Context is polluted with failed approaches. + > **Fix**: After two failed corrections, `/clear` and write a better initial prompt incorporating what you learned. +* **The over-specified CLAUDE.md.** If your CLAUDE.md is too long, Claude ignores half of it because important rules get lost in the noise. + > **Fix**: Ruthlessly prune. If Claude already does something correctly without the instruction, delete it or convert it to a hook. +* **The trust-then-verify gap.** Claude produces a plausible-looking implementation that doesn't handle edge cases. + > **Fix**: Always provide verification (tests, scripts, screenshots). If you can't verify it, don't ship it. +* **The infinite exploration.** You ask Claude to "investigate" something without scoping it. Claude reads hundreds of files, filling the context. + > **Fix**: Scope investigations narrowly or use subagents so the exploration doesn't consume your main context. + +*** + +## Develop your intuition + +The patterns in this guide aren't set in stone. They're starting points that work well in general, but might not be optimal for every situation. + +Sometimes you *should* let context accumulate because you're deep in one complex problem and the history is valuable. Sometimes you should skip planning and let Claude figure it out because the task is exploratory. Sometimes a vague prompt is exactly right because you want to see how Claude interprets the problem before constraining it. + +Pay attention to what works. When Claude produces great output, notice what you did: the prompt structure, the context you provided, the mode you were in. When Claude struggles, ask why. Was the context too noisy? The prompt too vague? The task too big for one pass? + +Over time, you'll develop intuition that no guide can capture. You'll know when to be specific and when to be open-ended, when to plan and when to explore, when to clear context and when to let it accumulate. + +## Related resources + +* [How Claude Code works](/en/how-claude-code-works): the agentic loop, tools, and context management +* [Extend Claude Code](/en/features-overview): skills, hooks, MCP, subagents, and plugins +* [Common workflows](/en/common-workflows): step-by-step recipes for debugging, testing, PRs, and more +* [CLAUDE.md](/en/memory): store project conventions and persistent context diff --git a/.archive/prompting-best-practices.md b/.archive/prompting-best-practices.md new file mode 100644 index 0000000..81206ba --- /dev/null +++ b/.archive/prompting-best-practices.md @@ -0,0 +1,904 @@ +# Prompting best practices + +Comprehensive guide to prompt engineering techniques for Claude's latest models, covering clarity, examples, XML structuring, thinking, and agentic systems. + +--- + +This is the single reference for prompt engineering with Claude's latest models, including Claude Opus 4.8, Claude Opus 4.7, Claude Opus 4.6, Claude Sonnet 4.6, and Claude Haiku 4.5. It covers foundational techniques, output control, tool use, thinking, and agentic systems. Jump to the section that matches your situation. + + + For an overview of model capabilities, see the [models overview](/docs/en/about-claude/models/overview). For details on what's new in Claude Opus 4.8, see [What's new in Claude Opus 4.8](/docs/en/about-claude/models/whats-new-claude-4-8). For migration guidance, see the [Migration guide](/docs/en/about-claude/models/migration-guide). + + +## Prompting Claude Opus 4.8 + +Claude Opus 4.8 has particular strengths in long-horizon agentic work, knowledge work, vision, and memory tasks. It performs well out of the box on existing Claude Opus 4.7 prompts. The patterns below cover the behaviors that most often require tuning. + + +For API parameter changes when migrating from Claude Opus 4.7 (sampling parameters, effort default, 1M context window default (200k on Microsoft Foundry), mid-conversation system messages, and refusal stop details), see the [migration guide](/docs/en/about-claude/models/migration-guide#migrating-from-claude-opus-47). + + +### Response length and verbosity + +Claude Opus 4.8 calibrates response length to how complex it judges the task to be, rather than defaulting to a fixed verbosity. This usually means shorter answers on simple lookups and much longer ones on open-ended analysis. + +If your product depends on a certain style or verbosity of output, you may need to tune your prompts. As an example, to decrease verbosity, you might add: + +```text +Provide concise, focused responses. Skip non-essential context, and keep examples minimal. +``` + +If you see specific examples of kinds of verbosity (i.e. over-explaining), you can add additional instructions in your prompt to prevent them. Positive examples showing how Claude can communicate with the appropriate level of concision tend to be more effective than negative examples or instructions that tell the model what not to do. + +### Calibrating effort and thinking depth + +The [effort parameter](/docs/en/build-with-claude/effort) allows you to tune Claude's intelligence vs. token spend, trading off capability for faster speed and lower costs. Start with the `xhigh` effort level for coding and agentic use cases, and use a minimum of `high` effort for most intelligence-sensitive use cases. Experiment with other effort levels to further tune token usage and intelligence: + +- **`max`:** Max effort can deliver performance gains in some use cases, but may show diminishing returns from increased token usage. This setting can also sometimes be prone to overthinking. Test max effort for intelligence-demanding tasks. +- **`xhigh`:** Extra high effort is the best setting for most coding and agentic use cases. +- **`high`:** This setting balances token usage and intelligence. For most intelligence-sensitive use cases, use a minimum of `high` effort. +- **`medium`:** Good for cost-sensitive use cases that need to reduce token usage while trading off intelligence. +- **`low`:** Reserve for short, scoped tasks and latency-sensitive workloads that are not intelligence-sensitive. + +Claude Opus 4.8 respects effort levels strictly, especially at the low end. At `low` and `medium`, the model scopes its work to what was asked rather than going above and beyond. This is good for latency and cost, but on moderately complex tasks running at `low` effort there is some risk of under-thinking. + +If you observe shallow reasoning on complex problems, raise effort to `high` or `xhigh` rather than prompting around it. If you need to keep effort at `low` for latency, add targeted guidance: + +```text +This task involves multi-step reasoning. Think carefully through the problem before responding. +``` + +Effort is likely to be more important for this model than for any prior Opus, so experiment with it actively when you upgrade. + +On Claude Opus 4.8, thinking is off unless you explicitly set `thinking: {type: "adaptive"}`. The triggering behavior for adaptive thinking is steerable. If you find the model thinking more often than you'd like, which can happen with large or complex system prompts, add guidance to steer it. As always, measure the effect of any prompting changes on performance. Example: + +```text +Thinking adds latency and should only be used when it will meaningfully improve answer quality — typically for problems that require multi-step reasoning. When in doubt, respond directly. +``` + +Conversely, if you're running hard workloads at `medium` and seeing under-thinking, the first lever is to raise effort. If you need finer control, prompt for it directly. + + +If you are running Claude Opus 4.8 at `max` or `xhigh` effort, set a large max output token budget so the model has room to think and act across its subagents and tool calls. Start at 64k tokens and tune from there. + + +### Tool use triggering + +Claude Opus 4.8 has a tendency to favor reasoning over tool calls. This produces better results in most cases. However, increasing the effort setting is a useful lever to increase the level of tool usage, especially in knowledge work. `high` or `xhigh` effort settings show substantially more tool usage in agentic search and coding. For scenarios where you want more tool use, you can also adjust your prompt to explicitly instruct the model about when and how to properly use its tools. For instance, if you find that the model is not using your web search tools, clearly describe why and how it should. + +### User-facing progress updates + +Claude Opus 4.8 provides more regular, higher-quality updates to the user throughout long agentic traces. If you've added scaffolding to force interim status messages ("After every 3 tool calls, summarize progress"), try removing it. If you find that the length or contents of Claude Opus 4.8's user-facing updates are not well-calibrated to your use case, explicitly describe what these updates should look like in the prompt and provide examples. + +### More literal instruction following + +Claude Opus 4.8 interprets prompts literally and explicitly, particularly at lower effort levels. It does not silently generalize an instruction from one item to another, and it does not infer requests you didn't make. The upside of this literalism is precision and less thrash, and it generally performs better for API use cases with carefully tuned prompts, structured extraction, and pipelines where you want predictable behavior. If you need Claude to apply an instruction broadly, state the scope explicitly (for example, "Apply this formatting to every section, not just the first one"). + +### Tone and writing style + +As with any new model, prose style on long-form writing may shift. Claude Opus 4.8 tends toward a direct, opinionated style with minimal validation-forward phrasing and sparing emoji use. If your product relies on a specific voice, re-evaluate style prompts against the new baseline. + +For instance, if your product voice is warmer or more conversational, add: + +```text +Use a warm, collaborative tone. Acknowledge the user's framing before answering. +``` + +### Controlling subagent spawning + +Claude Opus 4.8 tends to spawn fewer subagents by default. However, this behavior is steerable through prompting; give Claude Opus 4.8 explicit guidance around when subagents are desirable. A toy example for a coding use case: + +```text +Do not spawn a subagent for work you can complete directly in a single response (e.g. refactoring a function you can already see). + +Spawn multiple subagents in the same turn when fanning out across items or reading multiple files. +``` + +### Design and frontend defaults + +Claude Opus 4.8 has strong design instincts, with a consistent default house style: warm cream/off-white backgrounds (~`#F4F1EA`), serif display type (Georgia, Fraunces, Playfair), italic word-accents, and a terracotta/amber accent. This reads well for editorial, hospitality, and portfolio briefs, but will feel off for dashboards, dev tools, fintech, healthcare, or enterprise apps. The default appears in slide decks as well as web UIs. + +This default is persistent. Generic instructions ("don't use cream," "make it clean and minimal") tend to shift the model to a different fixed palette rather than producing variety. Two approaches work reliably: + +**1. Specify a concrete alternative.** The model follows explicit specs precisely: + +```text +Design a desktop landing page for a supplement brand called AEFRM. + +The visual direction should come from a cold monochrome atmosphere using pale silver-gray tones that gradually deepen into blue-gray and near-black, similar to a misted metallic surface. + +The page should feel sharp and controlled, with a strong sense of structure and restraint. + +Use this tonal system across the full page instead of introducing bright accent colors. + +Use the uploaded image on the hero design in black and white. + +The layout should be built with clear horizontal sections and a centered max-width container. Use 4px corner radius consistently across cards, buttons, inputs, and media frames. Margins should feel generous, with enough empty space around each section so the page breathes. + +Typography should use a square, angular sans-serif with wider letter spacing than usual, especially in headings and navigation, so the text feels more engineered and less compressed. Headline text can be large and uppercase, while supporting copy remains short and sparse. The sub texts should be written with Alumni Sans SC in 4-6px like tiny little texts on corners bottom centre like that. + +For the structure, start with a hero section containing a strong product statement, one short supporting paragraph, and a clean product placeholder or packshot frame. Below that, add a benefit grid with three or four blocks, then a formulation or ingredients section, and finally a cta. + +Buttons should be flat and precise, with subtle hover changes using transition: all 160ms ease out where brightness and border contrast shift slightly rather than using dramatic motion. + +Color palette should stay within this range: +#E9ECEC, #C9D2D4, #8C9A9E, #44545B, #11171B. +``` + +**2. Have the model propose options before building.** This breaks the default and gives users control. If you previously relied on `temperature` for design variety, use this approach; it produces meaningfully different directions across runs. Example prompt: + +```text +Before building, propose 4 distinct visual directions tailored to this brief (each as: bg hex / accent hex / typeface — one-line rationale). Ask the user to pick one, then implement only that direction. +``` + +Additionally, Claude Opus 4.8 requires less frontend design prompting than previous models to avoid generic patterns that users call the "AI slop" aesthetic. With earlier models, Anthropic recommended a lengthier prompt snippet in the [frontend-design skill](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md). However, Claude Opus 4.8 generates distinctive, creative frontends with more minimal prompting guidance. This prompt snippet works well with the above prompting advice for variety: + +```text + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white or dark backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. Use unique fonts, cohesive colors and themes, and animations for effects and micro-interactions. + +``` + +### Interactive coding products + +Claude Opus 4.8's token usage and behavior can differ between autonomous, asynchronous coding agents with a single user turn and interactive, synchronous coding agents with multiple user turns. Specifically, it tends to use more tokens in interactive settings, primarily because it reasons more after user turns. This can improve long-horizon coherence, instruction following, and coding capabilities in long, interactive coding sessions, but also comes with more token usage. To maximize both performance and token efficiency in coding products, use `xhigh` or `high` effort, add autonomous features like an auto mode, and reduce the number of human interactions required from your users. + +Of course, when limiting the number of required user interactions, it's important to specify the task, intent, and relevant constraints upfront in the first human turn. Providing well-specified, clear, and accurate task descriptions upfront can help maximize autonomy and intelligence while minimizing extra token usage after user turns. Because Claude Opus 4.8 is more autonomous than prior models, this usage pattern helps to maximize performance. In contrast, ambiguous or underspecified prompts conveyed progressively over multiple user turns tend to relatively reduce token efficiency and sometimes performance. + +### Code review harnesses + +Claude Opus 4.8 is meaningfully better at finding bugs than prior models, and has both higher recall and precision in internal evals. However, if your code-review harness was tuned for an earlier model, you may initially see lower recall. This is likely a harness effect, not a capability regression. When a review prompt says things like "only report high-severity issues," "be conservative," or "don't nitpick," Claude Opus 4.8 may follow that instruction more faithfully than earlier models did: it may investigate the code just as thoroughly, identify the bugs, and then not report findings it judges to be below your stated bar. This can show up as the model doing the same depth of investigation but converting fewer investigations into reported findings, especially on lower-severity bugs. Precision typically rises, but measured recall can fall even though the model's underlying bug-finding ability has improved. + +Some recommended prompt language: + +```text +Report every issue you find, including ones you are uncertain about or consider low-severity. Do not filter for importance or confidence at this stage - a separate verification step will do that. Your goal here is coverage: it is better to surface a finding that later gets filtered out than to silently drop a real bug. For each finding, include your confidence level and an estimated severity so a downstream filter can rank them. +``` + +This prompt can be used without having an actual second step, but moving confidence filtering out of the finding step often helps. If your harness has a separate verification, deduplication, or ranking stage, tell the model explicitly that its job at the finding stage is coverage rather than filtering. + +If you do want the model to self-filter in a single pass, be concrete about where the bar is rather than using qualitative terms like "important": for example, "report any bugs that could cause incorrect behavior, a test failure, or a misleading result; only omit nits like pure style or naming preferences." + +Iterate on prompts against a subset of your evals or test cases to validate recall or F1 score gains. + +### Computer use + +[Computer use](/docs/en/agents-and-tools/tool-use/computer-use-tool) capability works across resolutions, up to a maximum resolution of 2576px / 3.75MP. Internal computer use testing shows that sending images at 1080p provides a good balance of performance and cost. + +For particularly cost-sensitive workloads, 720p or 1366×768 are lower-cost options with strong performance. Conduct your own testing to find the ideal settings for your use case; experimenting with effort settings can also help tune the model's behavior. + +## General principles + +### Be clear and direct + +Claude responds well to clear, explicit instructions. Being specific about your desired output can help enhance results. If you want "above and beyond" behavior, explicitly request it rather than relying on the model to infer this from vague prompts. + +Think of Claude as a brilliant but new employee who lacks context on your norms and workflows. The more precisely you explain what you want, the better the result. + +**Golden rule:** Show your prompt to a colleague with minimal context on the task and ask them to follow it. If they'd be confused, Claude will be too. + +- Be specific about the desired output format and constraints. +- Provide instructions as sequential steps using numbered lists or bullet points when the order or completeness of steps matters. + +
+ +**Less effective:** +```text +Create an analytics dashboard +``` + +**More effective:** +```text +Create an analytics dashboard. Include as many relevant features and interactions as possible. Go beyond the basics to create a fully-featured implementation. +``` + +
+ +### Add context to improve performance + +Providing context or motivation behind your instructions, such as explaining to Claude why such behavior is important, can help Claude better understand your goals and deliver more targeted responses. + +
+ +**Less effective:** +```text +NEVER use ellipses +``` + +**More effective:** +```text +Your response will be read aloud by a text-to-speech engine, so never use ellipses since the text-to-speech engine will not know how to pronounce them. +``` + +
+ +Claude is smart enough to generalize from the explanation. + +### Use examples effectively + +Examples are one of the most reliable ways to steer Claude's output format, tone, and structure. A few well-crafted examples (known as few-shot or multishot prompting) can dramatically improve accuracy and consistency. + +When adding examples, make them: +- **Relevant:** Mirror your actual use case closely. +- **Diverse:** Cover edge cases and vary enough that Claude doesn't pick up unintended patterns. +- **Structured:** Wrap examples in `` tags (multiple examples in `` tags) so Claude can distinguish them from instructions. + +Include 3–5 examples for best results. You can also ask Claude to evaluate your examples for relevance and diversity, or to generate additional ones based on your initial set. + +### Structure prompts with XML tags + +XML tags help Claude parse complex prompts unambiguously, especially when your prompt mixes instructions, context, examples, and variable inputs. Wrapping each type of content in its own tag (e.g. ``, ``, ``) reduces misinterpretation. + +Best practices: +- Use consistent, descriptive tag names across your prompts. +- Nest tags when content has a natural hierarchy (documents inside ``, each inside ``). + +### Give Claude a role + +Setting a role in the system prompt focuses Claude's behavior and tone for your use case. Even a single sentence makes a difference: + +```python Python +import anthropic + +client = anthropic.Anthropic() + +message = client.messages.create( + model="claude-opus-4-8", + max_tokens=1024, + system="You are a helpful coding assistant specializing in Python.", + messages=[ + {"role": "user", "content": "How do I sort a list of dictionaries by key?"} + ], +) +print(message.content) +``` + +### Long context prompting + +When working with large documents or data-rich inputs (20k+ tokens), structure your prompt carefully to get the best results: + +- **Put longform data at the top:** Place your long documents and inputs near the top of your prompt, above your query, instructions, and examples. This can significantly improve performance across all models. + + Queries at the end can improve response quality by up to 30% in tests, especially with complex, multi-document inputs. + +- **Structure document content and metadata with XML tags:** When using multiple documents, wrap each document in `` tags with `` and `` (and other metadata) subtags for clarity. + +
+ + ```xml + + + annual_report_2023.pdf + + {{ANNUAL_REPORT}} + + + + competitor_analysis_q2.xlsx + + {{COMPETITOR_ANALYSIS}} + + + + + Analyze the annual report and competitor analysis. Identify strategic advantages and recommend Q3 focus areas. + ``` + +
+ +- **Ground responses in quotes:** For long document tasks, ask Claude to quote relevant parts of the documents first before carrying out its task. This helps Claude cut through the noise of the rest of the document's contents. + +
+ + ```xml + You are an AI physician's assistant. Your task is to help doctors diagnose possible patient illnesses. + + + + patient_symptoms.txt + + {{PATIENT_SYMPTOMS}} + + + + patient_records.txt + + {{PATIENT_RECORDS}} + + + + patient01_appt_history.txt + + {{PATIENT01_APPOINTMENT_HISTORY}} + + + + + Find quotes from the patient records and appointment history that are relevant to diagnosing the patient's reported symptoms. Place these in tags. Then, based on these quotes, list all information that would help the doctor diagnose the patient's symptoms. Place your diagnostic information in tags. + ``` + +
+ +### Model self-knowledge + +If you would like Claude to identify itself correctly in your application or use specific API strings: + +```text Sample prompt for model identity +The assistant is Claude, created by Anthropic. The current model is Claude Opus 4.8. +``` + +For LLM-powered apps that need to specify model strings: + +```text Sample prompt for model string +When an LLM is needed, please default to Claude Opus 4.8 unless the user requests otherwise. The exact model string for Claude Opus 4.8 is claude-opus-4-8. +``` + +## Output and formatting + +### Communication style and verbosity + +Claude's latest models have a more concise and natural communication style compared to previous models: + +- **More direct and grounded:** Provides fact-based progress reports rather than self-celebratory updates +- **More conversational:** Slightly more fluent and colloquial, less machine-like +- **Less verbose:** May skip detailed summaries for efficiency unless prompted otherwise + +This means Claude may skip verbal summaries after tool calls, jumping directly to the next action. If you prefer more visibility into its reasoning: + +```text Sample prompt +After completing a task that involves tool use, provide a quick summary of the work you've done. +``` + +### Control the format of responses + +There are a few particularly effective ways to steer output formatting: + +1. **Tell Claude what to do instead of what not to do** + + - Instead of: "Do not use markdown in your response" + - Try: "Your response should be composed of smoothly flowing prose paragraphs." + +2. **Use XML format indicators** + + - Try: "Write the prose sections of your response in \ tags." + +3. **Match your prompt style to the desired output** + + The formatting style used in your prompt may influence Claude's response style. If you are still experiencing steerability issues with output formatting, try matching your prompt style to your desired output style as closely as possible. For example, removing markdown from your prompt can reduce the volume of markdown in the output. + +4. **Use detailed prompts for specific formatting preferences** + + For more control over markdown and formatting usage, provide explicit guidance: + +```text Sample prompt to minimize markdown + +When writing reports, documents, technical explanations, analyses, or any long-form content, write in clear, flowing prose using complete paragraphs and sentences. Use standard paragraph breaks for organization and reserve markdown primarily for `inline code`, code blocks (```...```), and simple headings (###, and ###). Avoid using **bold** and *italics*. + +DO NOT use ordered lists (1. ...) or unordered lists (*) unless : a) you're presenting truly discrete items where a list format is the best option, or b) the user explicitly requests a list or ranking + +Instead of listing items with bullets or numbers, incorporate them naturally into sentences. This guidance applies especially to technical writing. Using prose instead of excessive formatting will improve user satisfaction. NEVER output a series of overly short bullet points. + +Your goal is readable, flowing text that guides the reader naturally through ideas rather than fragmenting information into isolated points. + +``` + +### LaTeX output + +Claude's latest models default to LaTeX for mathematical expressions, equations, and technical explanations. If you prefer plain text, add the following instructions to your prompt: + +```text Sample prompt +Format your response in plain text only. Do not use LaTeX, MathJax, or any markup notation such as \( \), $, or \frac{}{}. Write all math expressions using standard text characters (e.g., "/" for division, "*" for multiplication, and "^" for exponents). +``` + +### Document creation + +Claude's latest models excel at creating presentations, animations, and visual documents with impressive creative flair and strong instruction following. The models produce polished, usable output on the first try in most cases. + +For best results with document creation: + +```text Sample prompt +Create a professional presentation on [topic]. Include thoughtful design elements, visual hierarchy, and engaging animations where appropriate. +``` + +### Migrating away from prefilled responses + +Starting with Claude 4.6 models and [Claude Mythos Preview](https://anthropic.com/glasswing), prefilled responses on the last assistant turn are no longer supported. Requests with prefilled assistant messages to these models return a 400 error. Model intelligence and instruction following have advanced such that most use cases of prefill no longer require it. Earlier models continue to support prefills, and adding assistant messages elsewhere in the conversation is not affected. + +Here are common prefill scenarios and how to migrate away from them: + +
+ +Prefills have been used to force specific output formats like JSON/YAML, classification, and similar patterns where the prefill constrains Claude to a particular structure. + +**Migration:** The [Structured Outputs](/docs/en/build-with-claude/structured-outputs) feature is designed specifically to constrain Claude's responses to follow a given schema. Try simply asking the model to conform to your output structure first, as newer models can reliably match complex schemas when told to, especially if implemented with retries. For classification tasks, use either tools with an enum field containing your valid labels or structured outputs. + +
+ +
+ +Prefills like `Here is the requested summary:\n` were used to skip introductory text. + +**Migration:** Use direct instructions in the system prompt: "Respond directly without preamble. Do not start with phrases like 'Here is...', 'Based on...', etc." Alternatively, direct the model to output within XML tags, use structured outputs, or use tool calling. If the occasional preamble slips through, strip it in post-processing. + +
+ +
+ +Prefills were used to steer around unnecessary refusals. + +**Migration:** Claude is much better at appropriate refusals now. Clear prompting within the `user` message without prefill should be sufficient. + +
+ +
+ +Prefills were used to continue partial completions, resume interrupted responses, or pick up where a previous generation left off. + +**Migration:** Move the continuation to the user message, and include the final text from the interrupted response: "Your previous response was interrupted and ended with \`[previous_response]\`. Continue from where you left off." If this is part of error-handling or incomplete-response-handling and there is no UX penalty, retry the request. + +
+ +
+ +Prefills were used to periodically ensure refreshed or injected context. + +**Migration:** For very long conversations, inject what were previously prefilled-assistant reminders into the user turn. If context hydration is part of a more complex agentic system, consider hydrating via tools (expose or encourage use of tools containing context based on heuristics such as number of turns) or during context compaction. + +
+ +## Tool use + +### Tool usage + +Claude's latest models are trained for precise instruction following and benefit from explicit direction to use specific tools. If you say "can you suggest some changes," Claude will sometimes provide suggestions rather than implementing them, even if making changes might be what you intended. + +For Claude to take action, be more explicit: + +
+ +**Less effective (Claude will only suggest):** +```text +Can you suggest some changes to improve this function? +``` + +**More effective (Claude will make the changes):** +```text +Change this function to improve its performance. +``` + +Or: +```text +Make these edits to the authentication flow. +``` + +
+ +To make Claude more proactive about taking action by default, you can add this to your system prompt: + +```text Sample prompt for proactive action + +By default, implement changes rather than only suggesting them. If the user's intent is unclear, infer the most useful likely action and proceed, using tools to discover any missing details instead of guessing. Try to infer the user's intent about whether a tool call (e.g., file edit or read) is intended or not, and act accordingly. + +``` + +On the other hand, if you want the model to be more hesitant by default, less prone to jumping straight into implementations, and only take action if requested, you can steer this behavior with a prompt like the below: + +```text Sample prompt for conservative action + +Do not jump into implementation or change files unless clearly instructed to make changes. When the user's intent is ambiguous, default to providing information, doing research, and providing recommendations rather than taking action. Only proceed with edits, modifications, or implementations when the user explicitly requests them. + +``` + +Claude Opus 4.5 and Claude Opus 4.6 are also more responsive to the system prompt than previous models. If your prompts were designed to reduce undertriggering on tools or skills, these models may now overtrigger. The fix is to dial back any aggressive language. Where you might have said "CRITICAL: You MUST use this tool when...", you can use more normal prompting like "Use this tool when...". + +### Optimize parallel tool calling + +Claude's latest models excel at parallel tool execution. These models will: + +- Run multiple speculative searches during research +- Read several files at once to build context faster +- Execute bash commands in parallel (which can even bottleneck system performance) + +This behavior is easily steerable. While the model has a high success rate in parallel tool calling without prompting, you can boost this to ~100% or adjust the aggression level: + +```text Sample prompt for maximum parallel efficiency + +If you intend to call multiple tools and there are no dependencies between the tool calls, make all of the independent tool calls in parallel. Prioritize calling tools simultaneously whenever the actions can be done in parallel rather than sequentially. For example, when reading 3 files, run 3 tool calls in parallel to read all 3 files into context at the same time. Maximize use of parallel tool calls where possible to increase speed and efficiency. However, if some tool calls depend on previous calls to inform dependent values like the parameters, do NOT call these tools in parallel and instead call them sequentially. Never use placeholders or guess missing parameters in tool calls. + +``` + +```text Sample prompt to reduce parallel execution +Execute operations sequentially with brief pauses between each step to ensure stability. +``` + +## Thinking and reasoning + +### Overthinking and excessive thoroughness + +Claude Opus 4.6 does significantly more upfront exploration than previous models, especially at higher `effort` settings. This initial work often helps to optimize the final results, but the model may gather extensive context or pursue multiple threads of research without being prompted. If your prompts previously encouraged the model to be more thorough, you should tune that guidance for Claude Opus 4.6: + +- **Replace blanket defaults with more targeted instructions.** Instead of "Default to using \[tool\]," add guidance like "Use \[tool\] when it would enhance your understanding of the problem." +- **Remove over-prompting.** Tools that undertriggered in previous models are likely to trigger appropriately now. Instructions like "If in doubt, use \[tool\]" will cause overtriggering. +- **Use effort as a fallback.** If Claude continues to be overly aggressive, use a lower setting for `effort`. + +In some cases, Claude Opus 4.6 may think extensively, which can inflate thinking tokens and slow down responses. If this behavior is undesirable, you can add explicit instructions to constrain its reasoning, or you can lower the `effort` setting to reduce overall thinking and token usage. + +```text Sample prompt +When you're deciding how to approach a problem, choose an approach and commit to it. Avoid revisiting decisions unless you encounter new information that directly contradicts your reasoning. If you're weighing two approaches, pick one and see it through. You can always course-correct later if the chosen approach fails. +``` + +If you need a hard ceiling on thinking costs, extended thinking with a `budget_tokens` cap is still functional on Opus 4.6 and Sonnet 4.6 but is deprecated. Prefer lowering the [effort](/docs/en/build-with-claude/effort) setting or using `max_tokens` as a hard limit with [adaptive thinking](/docs/en/build-with-claude/adaptive-thinking). + +### Leverage thinking & interleaved thinking capabilities + +Claude's latest models offer thinking capabilities that can be especially helpful for tasks involving reflection after tool use or complex multi-step reasoning. You can guide its initial or interleaved thinking for better results. + +Claude Opus 4.6 and Claude Sonnet 4.6 use [adaptive thinking](/docs/en/build-with-claude/adaptive-thinking) (`thinking: {type: "adaptive"}`), where Claude dynamically decides when and how much to think. Claude calibrates its thinking based on two factors: the `effort` parameter and query complexity. Higher effort elicits more thinking, and more complex queries do the same. On easier queries that don't require thinking, the model responds directly. In internal evaluations, adaptive thinking reliably drives better performance than extended thinking. Consider moving to adaptive thinking to get the most intelligent responses. + +Use adaptive thinking for workloads that require agentic behavior such as multi-step tool use, complex coding tasks, and long-horizon agent loops. Older models use manual thinking mode with `budget_tokens`. + +You can guide Claude's thinking behavior: + +```text Example prompt +After receiving tool results, carefully reflect on their quality and determine optimal next steps before proceeding. Use your thinking to plan and iterate based on this new information, and then take the best next action. +``` + +The triggering behavior for adaptive thinking is promptable. If you find the model thinking more often than you'd like, which can happen with large or complex system prompts, add guidance to steer it: + +```text Sample prompt +Extended thinking adds latency and should only be used when it will meaningfully improve answer quality - typically for problems that require multi-step reasoning. When in doubt, respond directly. +``` + +If you are migrating from [extended thinking](/docs/en/build-with-claude/extended-thinking) with `budget_tokens`, replace your thinking configuration and move budget control to `effort`: + +**Before (extended thinking, older models):** + +```python Python nocheck +client.messages.create( + model="claude-sonnet-4-5-20250929", + max_tokens=64000, + thinking={"type": "enabled", "budget_tokens": 32000}, + messages=[{"role": "user", "content": "..."}], +) +``` + +**After (adaptive thinking):** + +```python Python nocheck +client.messages.create( + model="claude-opus-4-8", + max_tokens=64000, + thinking={"type": "adaptive"}, + output_config={"effort": "high"}, # or "max", "xhigh", "medium", "low" + messages=[{"role": "user", "content": "..."}], +) +``` + +If you are not using extended thinking, no changes are required. Thinking is off by default when you omit the `thinking` parameter. + +- **Prefer general instructions over prescriptive steps.** A prompt like "think thoroughly" often produces better reasoning than a hand-written step-by-step plan. Claude's reasoning frequently exceeds what a human would prescribe. +- **Multishot examples work with thinking.** Use `` tags inside your few-shot examples to show Claude the reasoning pattern. It will generalize that style to its own extended thinking blocks. +- **Manual CoT as a fallback.** When thinking is off, you can still encourage step-by-step reasoning by asking Claude to think through the problem. Use structured tags like `` and `` to cleanly separate reasoning from the final output. +- **Ask Claude to self-check.** Append something like "Before you finish, verify your answer against [test criteria]." This catches errors reliably, especially for coding and math. + +When extended thinking is disabled, Claude Opus 4.5 is particularly sensitive to the word "think" and its variants. Consider using alternatives like "consider," "evaluate," or "reason through" in those cases. + + + For more information on thinking capabilities, see [Extended thinking](/docs/en/build-with-claude/extended-thinking) and [Adaptive thinking](/docs/en/build-with-claude/adaptive-thinking). + + +## Agentic systems + +### Long-horizon reasoning and state tracking + +Claude's latest models excel at long-horizon reasoning tasks with exceptional state tracking capabilities. Claude maintains orientation across extended sessions by focusing on incremental progress, making steady advances on a few things at a time rather than attempting everything at once. This capability especially emerges over multiple context windows or task iterations, where Claude can work on a complex task, save the state, and continue with a fresh context window. + +#### Context awareness and multi-window workflows + +Claude 4.6 and Claude 4.5 models feature [context awareness](/docs/en/build-with-claude/context-windows#context-awareness-in-claude-sonnet-4-6-sonnet-4-5-and-haiku-4-5), enabling the model to track its remaining context window (i.e. "token budget") throughout a conversation. This enables Claude to execute tasks and manage context more effectively by understanding how much space it has to work. + +**Managing context limits:** + +If you are using Claude in an agent harness that compacts context or allows saving context to external files (like in Claude Code), consider adding this information to your prompt so Claude can behave accordingly. Otherwise, Claude may sometimes naturally try to wrap up work as it approaches the context limit. Below is an example prompt: + +```text Sample prompt +Your context window will be automatically compacted as it approaches its limit, allowing you to continue working indefinitely from where you left off. Therefore, do not stop tasks early due to token budget concerns. As you approach your token budget limit, save your current progress and state to memory before the context window refreshes. Always be as persistent and autonomous as possible and complete tasks fully, even if the end of your budget is approaching. Never artificially stop any task early regardless of the context remaining. +``` + +The [memory tool](/docs/en/agents-and-tools/tool-use/memory-tool) pairs naturally with context awareness for seamless context transitions. + +#### Multi-context window workflows + +For tasks spanning multiple context windows: + +1. **Use a different prompt for the very first context window:** Use the first context window to set up a framework (write tests, create setup scripts), then use future context windows to iterate on a todo-list. + +2. **Have the model write tests in a structured format:** Ask Claude to create tests before starting work and keep track of them in a structured format (e.g., `tests.json`). This leads to better long-term ability to iterate. Remind Claude of the importance of tests: "It is unacceptable to remove or edit tests because this could lead to missing or buggy functionality." + +3. **Set up quality of life tools:** Encourage Claude to create setup scripts (e.g., `init.sh`) to gracefully start servers, run test suites, and linters. This prevents repeated work when continuing from a fresh context window. + +4. **Starting fresh vs compacting:** When a context window is cleared, consider starting with a brand new context window rather than using compaction. Claude's latest models are extremely effective at discovering state from the local filesystem. In some cases, you may want to take advantage of this over compaction. Be prescriptive about how it should start: + - "Call pwd; you can only read and write files in this directory." + - "Review progress.txt, tests.json, and the git logs." + - "Manually run through a fundamental integration test before moving on to implementing new features." + +5. **Provide verification tools:** As the length of autonomous tasks grows, Claude needs to verify correctness without continuous human feedback. Tools like Playwright MCP server or computer use capabilities for testing UIs are helpful. + +6. **Encourage complete usage of context:** Prompt Claude to efficiently complete components before moving on: + +```text Sample prompt +This is a very long task, so it may be beneficial to plan out your work clearly. It's encouraged to spend your entire output context working on the task - just make sure you don't run out of context with significant uncommitted work. Continue working systematically until you have completed this task. +``` + +#### State management best practices + +- **Use structured formats for state data:** When tracking structured information (like test results or task status), use JSON or other structured formats to help Claude understand schema requirements +- **Use unstructured text for progress notes:** Freeform progress notes work well for tracking general progress and context +- **Use git for state tracking:** Git provides a log of what's been done and checkpoints that can be restored. Claude's latest models perform especially well in using git to track state across multiple sessions. +- **Emphasize incremental progress:** Explicitly ask Claude to keep track of its progress and focus on incremental work + +
+ +```json +// Structured state file (tests.json) +{ + "tests": [ + { "id": 1, "name": "authentication_flow", "status": "passing" }, + { "id": 2, "name": "user_management", "status": "failing" }, + { "id": 3, "name": "api_endpoints", "status": "not_started" } + ], + "total": 200, + "passing": 150, + "failing": 25, + "not_started": 25 +} +``` + +```text +// Progress notes (progress.txt) +Session 3 progress: +- Fixed authentication token validation +- Updated user model to handle edge cases +- Next: investigate user_management test failures (test #2) +- Note: Do not remove tests as this could lead to missing functionality +``` + +
+ +### Balancing autonomy and safety + +Without guidance, Claude Opus 4.6 may take actions that are difficult to reverse or affect shared systems, such as deleting files, force-pushing, or posting to external services. If you want Claude Opus 4.6 to confirm before taking potentially risky actions, add guidance to your prompt: + +```text Sample prompt +Consider the reversibility and potential impact of your actions. You are encouraged to take local, reversible actions like editing files or running tests, but for actions that are hard to reverse, affect shared systems, or could be destructive, ask the user before proceeding. + +Examples of actions that warrant confirmation: +- Destructive operations: deleting files or branches, dropping database tables, rm -rf +- Hard to reverse operations: git push --force, git reset --hard, amending published commits +- Operations visible to others: pushing code, commenting on PRs/issues, sending messages, modifying shared infrastructure + +When encountering obstacles, do not use destructive actions as a shortcut. For example, don't bypass safety checks (e.g. --no-verify) or discard unfamiliar files that may be in-progress work. +``` + +### Research and information gathering + +Claude's latest models demonstrate exceptional agentic search capabilities and can find and synthesize information from multiple sources effectively. For optimal research results: + +1. **Provide clear success criteria:** Define what constitutes a successful answer to your research question + +2. **Encourage source verification:** Ask Claude to verify information across multiple sources + +3. **For complex research tasks, use a structured approach:** + +```text Sample prompt for complex research +Search for this information in a structured way. As you gather data, develop several competing hypotheses. Track your confidence levels in your progress notes to improve calibration. Regularly self-critique your approach and plan. Update a hypothesis tree or research notes file to persist information and provide transparency. Break down this complex research task systematically. +``` + +This structured approach allows Claude to find and synthesize virtually any piece of information and iteratively critique its findings, no matter the size of the corpus. + +### Subagent orchestration + +Claude's latest models demonstrate significantly improved native subagent orchestration capabilities. These models can recognize when tasks would benefit from delegating work to specialized subagents and do so proactively without requiring explicit instruction. + +To take advantage of this behavior: + +1. **Ensure well-defined subagent tools:** Have subagent tools available and described in tool definitions +2. **Let Claude orchestrate naturally:** Claude will delegate appropriately without explicit instruction +3. **Watch for overuse:** Claude Opus 4.6 has a strong predilection for subagents and may spawn them in situations where a simpler, direct approach would suffice. For example, the model may spawn subagents for code exploration when a direct grep call is faster and sufficient. + +If you're seeing excessive subagent use, add explicit guidance about when subagents are and aren't warranted: + +```text Sample prompt for subagent usage +Use subagents when tasks can run in parallel, require isolated context, or involve independent workstreams that don't need to share state. For simple tasks, sequential operations, single-file edits, or tasks where you need to maintain context across steps, work directly rather than delegating. +``` + +### Chain complex prompts + +With adaptive thinking and subagent orchestration, Claude handles most multi-step reasoning internally. Explicit prompt chaining (breaking a task into sequential API calls) is still useful when you need to inspect intermediate outputs or enforce a specific pipeline structure. + +The most common chaining pattern is **self-correction:** generate a draft → have Claude review it against criteria → have Claude refine based on the review. Each step is a separate API call so you can log, evaluate, or branch at any point. + +### Reduce file creation in agentic coding + +Claude's latest models may sometimes create new files for testing and iteration purposes, particularly when working with code. This approach allows Claude to use files, especially python scripts, as a 'temporary scratchpad' before saving its final output. Using temporary files can improve outcomes particularly for agentic coding use cases. + +If you'd prefer to minimize net new file creation, you can instruct Claude to clean up after itself: + +```text Sample prompt +If you create any temporary new files, scripts, or helper files for iteration, clean up these files by removing them at the end of the task. +``` + +### Overeagerness + +Claude Opus 4.5 and Claude Opus 4.6 have a tendency to overengineer by creating extra files, adding unnecessary abstractions, or building in flexibility that wasn't requested. If you're seeing this undesired behavior, add specific guidance to keep solutions minimal. + +For example: + +```text Sample prompt to minimize overengineering +Avoid over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused: + +- Scope: Don't add features, refactor code, or make "improvements" beyond what was asked. A bug fix doesn't need surrounding code cleaned up. A simple feature doesn't need extra configurability. + +- Documentation: Don't add docstrings, comments, or type annotations to code you didn't change. Only add comments where the logic isn't self-evident. + +- Defensive coding: Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). + +- Abstractions: Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task. +``` + +### Avoid focusing on passing tests and hard-coding + +Claude can sometimes focus too heavily on making tests pass at the expense of more general solutions, or may use workarounds like helper scripts for complex refactoring instead of using standard tools directly. To prevent this behavior and ensure robust, generalizable solutions: + +```text Sample prompt +Please write a high-quality, general-purpose solution using the standard tools available. Do not create helper scripts or workarounds to accomplish the task more efficiently. Implement a solution that works correctly for all valid inputs, not just the test cases. Do not hard-code values or create solutions that only work for specific test inputs. Instead, implement the actual logic that solves the problem generally. + +Focus on understanding the problem requirements and implementing the correct algorithm. Tests are there to verify correctness, not to define the solution. Provide a principled implementation that follows best practices and software design principles. + +If the task is unreasonable or infeasible, or if any of the tests are incorrect, please inform me rather than working around them. The solution should be robust, maintainable, and extendable. +``` + +### Minimizing hallucinations in agentic coding + +Claude's latest models are less prone to hallucinations and give more accurate, grounded, intelligent answers based on the code. To encourage this behavior even more and minimize hallucinations: + +```text Sample prompt + +Never speculate about code you have not opened. If the user references a specific file, you MUST read the file before answering. Make sure to investigate and read relevant files BEFORE answering questions about the codebase. Never make any claims about code before investigating unless you are certain of the correct answer - give grounded and hallucination-free answers. + +``` + +## Capability-specific tips + +### Improved vision capabilities + +Claude Opus 4.5 and Claude Opus 4.6 have improved vision capabilities compared to previous Claude models. They perform better on image processing and data extraction tasks, particularly when there are multiple images present in context. These improvements carry over to computer use, where the models can more reliably interpret screenshots and UI elements. You can also use these models to analyze videos by breaking them up into frames. + +One technique that has proven effective to further boost performance is to give Claude a crop tool or [skill](/docs/en/agents-and-tools/agent-skills/overview). Testing has shown consistent uplift on image evaluations when Claude is able to "zoom" in on relevant regions of an image. Anthropic has created a [cookbook for the crop tool](https://platform.claude.com/cookbook/multimodal-crop-tool). + +### Frontend design + +Claude Opus 4.5 and Claude Opus 4.6 excel at building complex, real-world web applications with strong frontend design. However, without guidance, models can default to generic patterns that create what users call the "AI slop" aesthetic. To create distinctive, creative frontends that surprise and delight: + + +For a detailed guide on improving frontend design, see the blog post on [improving frontend design through skills](https://www.claude.com/blog/improving-frontend-design-through-skills). + + +Here's a system prompt snippet you can use to encourage better frontend design: + +```text Sample prompt for frontend aesthetics + +You tend to converge toward generic, "on distribution" outputs. In frontend design, this creates what users call the "AI slop" aesthetic. Avoid this: make creative, distinctive frontends that surprise and delight. + +Focus on: +- Typography: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics. +- Color & Theme: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Draw from IDE themes and cultural aesthetics for inspiration. +- Motion: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. +- Backgrounds: Create atmosphere and depth rather than defaulting to solid colors. Layer CSS gradients, use geometric patterns, or add contextual effects that match the overall aesthetic. + +Avoid generic AI-generated aesthetics: +- Overused font families (Inter, Roboto, Arial, system fonts) +- Clichéd color schemes (particularly purple gradients on white backgrounds) +- Predictable layouts and component patterns +- Cookie-cutter design that lacks context-specific character + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. Vary between light and dark themes, different fonts, different aesthetics. You still tend to converge on common choices (Space Grotesk, for example) across generations. Avoid this: it is critical that you think outside the box! + +``` + +You can also refer to the [full skill definition](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md). + +## Migration considerations + +When migrating to Claude 4.6 models from earlier generations: + +1. **Be specific about desired behavior:** Consider describing exactly what you'd like to see in the output. + +2. **Frame your instructions with modifiers:** Adding modifiers that encourage Claude to increase the quality and detail of its output can help better shape Claude's performance. For example, instead of "Create an analytics dashboard", use "Create an analytics dashboard. Include as many relevant features and interactions as possible. Go beyond the basics to create a fully-featured implementation." + +3. **Request specific features explicitly:** Animations and interactive elements should be requested explicitly when desired. + +4. **Update thinking configuration:** Claude 4.6 models use [adaptive thinking](/docs/en/build-with-claude/adaptive-thinking) (`thinking: {type: "adaptive"}`) instead of manual thinking with `budget_tokens`. Use the [effort parameter](/docs/en/build-with-claude/effort) to control thinking depth. + +5. **Migrate away from prefilled responses:** Prefilled responses on the last assistant turn are no longer supported starting with Claude 4.6 models. See [Migrating away from prefilled responses](#migrating-away-from-prefilled-responses) for detailed guidance on alternatives. + +6. **Tune anti-laziness prompting:** If your prompts previously encouraged the model to be more thorough or use tools more aggressively, dial back that guidance. Claude 4.6 models are significantly more proactive and may overtrigger on instructions that were needed for previous models. + +For detailed migration steps, see the [Migration guide](/docs/en/about-claude/models/migration-guide). + +### Migrating from Claude Sonnet 4.5 to Claude Sonnet 4.6 + +Claude Sonnet 4.6 defaults to an effort level of `high`, in contrast to Claude Sonnet 4.5 which had no effort parameter. Consider adjusting the effort parameter as you migrate from Claude Sonnet 4.5 to Claude Sonnet 4.6. If not explicitly set, you may experience higher latency with the default effort level. + +**Recommended effort settings:** +- **Medium** for most applications +- **Low** for high-volume or latency-sensitive workloads +- Set a large max output token budget (64k tokens recommended) at medium or high effort to give the model room to think and act + +**When to use Opus 4.8 instead:** For the hardest, longest-horizon problems (large-scale code migrations, deep research, extended autonomous work), Opus 4.8 remains the right choice. Sonnet 4.6 is optimized for workloads where fast turnaround and cost efficiency matter most. + +#### If you're not using extended thinking + +If you're not using extended thinking on Claude Sonnet 4.5, you can continue without it on Claude Sonnet 4.6. You should explicitly set effort to the level appropriate for your use case. At `low` effort with thinking disabled, you can expect similar or better performance relative to Claude Sonnet 4.5 with no extended thinking. + +```python Python +client.messages.create( + model="claude-sonnet-4-6", + max_tokens=8192, + thinking={"type": "disabled"}, + output_config={"effort": "low"}, + messages=[{"role": "user", "content": "..."}], +) +``` + +#### If you're using extended thinking + +If you're using extended thinking with `budget_tokens` on Claude Sonnet 4.5, it is still functional on Claude Sonnet 4.6 but is deprecated. Migrate to [adaptive thinking](/docs/en/build-with-claude/adaptive-thinking) with the [effort parameter](/docs/en/build-with-claude/effort). + +##### Migrating to adaptive thinking + +Adaptive thinking is particularly well suited to the following workload patterns: + +- **Autonomous multi-step agents:** coding agents that turn requirements into working software, data analysis pipelines, and bug finding where the model runs independently across many steps. Adaptive thinking lets the model calibrate its reasoning per step, staying on path over longer trajectories. For these workloads, start at `high` effort. If latency or token usage is a concern, scale down to `medium`. +- **Computer use agents:** Claude Sonnet 4.6 achieved best-in-class accuracy on computer use evaluations using adaptive mode. +- **Bimodal workloads:** a mix of easy and hard tasks where adaptive skips thinking on simple queries and reasons deeply on complex ones. + +When using adaptive thinking, evaluate `medium` and `high` effort on your tasks. The right level depends on your workload's tradeoff between quality, latency, and token usage. + +```python Python nocheck +client.messages.create( + model="claude-sonnet-4-6", + max_tokens=64000, + thinking={"type": "adaptive"}, + output_config={"effort": "high"}, + messages=[{"role": "user", "content": "..."}], +) +``` + +##### Keeping budget_tokens during migration + +If you need to keep `budget_tokens` temporarily while migrating, a budget around 16k tokens provides headroom for harder problems without risk of runaway token usage. This configuration is deprecated and will be removed in a future model release. + +**For coding use cases** (agentic coding, tool-heavy workflows, code generation), start with `medium` effort: + +```python Python nocheck +client.messages.create( + model="claude-sonnet-4-6", + max_tokens=16384, + thinking={"type": "enabled", "budget_tokens": 16384}, + output_config={"effort": "medium"}, + messages=[{"role": "user", "content": "..."}], +) +``` + +**For chat and non-coding use cases** (chat, content generation, search, classification), start with `low` effort: + +```python Python nocheck +client.messages.create( + model="claude-sonnet-4-6", + max_tokens=8192, + thinking={"type": "enabled", "budget_tokens": 16384}, + output_config={"effort": "low"}, + messages=[{"role": "user", "content": "..."}], +) +``` \ No newline at end of file diff --git a/.claude/skills/optimizing-claude-code-prompts/SKILL.md b/.claude/skills/optimizing-claude-code-prompts/SKILL.md new file mode 100644 index 0000000..54250be --- /dev/null +++ b/.claude/skills/optimizing-claude-code-prompts/SKILL.md @@ -0,0 +1,160 @@ +--- +name: optimizing-claude-code-prompts +description: Turn a rough or vague request to Claude Code into a precise, repo-grounded, high-performing prompt. Use when the user wants help phrasing, drafting, improving, optimizing, tightening, or "making better" a prompt/request/instruction for Claude Code; when the user pastes a draft and asks how to word it; when a prompt already ran and Claude did the wrong thing (diagnose and fix it); or when Claude Code keeps missing the mark — doing too much, ignoring constraints, solving the wrong problem, over-engineering, or needing many back-and-forth rounds. Triggers include "optimize this prompt", "help me ask Claude to…", "rewrite my request", "why did Claude do that", "how should I word this", "make this prompt clearer". +--- + +# Optimizing Claude Code Prompts + +## Overview + +Rewrite a user's rough request into a prompt that Claude Code can execute correctly on the +first pass. The current models (Opus 4.8) follow instructions **literally** and run +**autonomously**, so the highest-leverage move is to front-load intent, constraints, and a +runnable check in the first message. Vague asks spread across many turns waste tokens and +build the wrong thing. + +**Core principle:** A strong Claude Code prompt names the **goal**, the **context**, the +**constraints**, the **files/patterns to follow**, and **a check Claude can run to know it's +done**. Optimizing means supplying whichever of these the user left out — and resolving each +to a *real artifact in this repo*, not a placeholder the user must fill in later. + +## The one rule that makes this skill worth invoking: ground in the repo + +A prompt full of `@[your-file-here]` and "run the test suite" is just a template — it hands the +hard part back to the user. Before writing the optimized prompt, **resolve every reference to a +real thing** using the tools you have: + +| Reference | How to resolve it | Don't emit | +|---|---|---| +| The target file(s) | `Glob`/`Grep` for the actual path | `@[src/whatever]` | +| "Done when…" check | Read `package.json` scripts / `Makefile` / `pyproject.toml` / CI config for the real test/build/lint command | "run the tests" | +| "Follow the pattern in…" | `Grep` for a sibling that already does the thing; name that file | "the existing pattern" | +| The symptom's likely location | `Grep` the error string / feature name to the directory | "somewhere in the code" | + +**Resolve, don't guess.** If a genuine look can't resolve something, ask **one** surgical +question — never paper over it with a bracketed guess. + +**Red flags that you skipped grounding** (STOP and go look): the output prompt contains `[...]`, +"the relevant file", "your test command", "the appropriate", or any path you didn't verify exists. + +## Pick the mode + +| The user… | Mode | What you do | +|---|---|---| +| Pasted a draft prompt | **Optimize** | Ground it, fill missing ingredients, return the rewrite | +| Gave a bare goal ("add auth") | **Generate** | Ground it, build the prompt from scratch | +| Says a prompt already failed ("Claude did X not Y") | **Diagnose** | Map the failure to the missing ingredient, fix it, add the session-hygiene step | +| Wants a large/multi-file feature | **Spec** | Don't hand-write a mega-prompt — route to the interview→SPEC.md pattern (see reference) | + +## Workflow + +1. **Capture** the raw request verbatim. Pick the mode. +2. **Ground** in the repo — resolve real paths, the real verification command, the real pattern + file (table above). Do this with parallel `Glob`/`Grep`/`Read` calls; it's fast and it's the + whole point. +3. **Diagnose + score** the request against the seven ingredients. Show the scorecard. +4. **Resolve gaps:** correctness-blocking gaps that grounding couldn't settle → up to **3** + `AskUserQuestion` questions. If the user wants speed ("just optimize it"), proceed and label + any remaining assumption explicitly. +5. **Write** the optimized prompt as a copy-paste block, with real values throughout. +6. **Hand back + offer to run it.** Note the one assumption most worth confirming, if any. + +Don't pad the prompt with obvious instructions ("write clean code"). Opus 4.8 is literal and +smart — filler dilutes the real constraints. + +## The seven ingredients + +| Ingredient | Answers | Weak → Strong | +|---|---|---| +| **Goal** | What outcome, concretely? | "improve the dashboard" → "add date-range filtering to the dashboard" | +| **Context** | Why / where does this live? | — → "endpoint is `@src/api/orders.ts`; read-heavy, data changes hourly" | +| **Constraints** | What must NOT change / limits? | — → "keep the JSON shape backward-compatible; no new deps" | +| **References** | What pattern to follow? | — → "mirror `@src/api/users.ts`" | +| **Acceptance / check** | How do we know it's done? | "make it work" → "`npm test src/api/orders.test.ts` passes; show output" | +| **Approach / mode** | How should Claude work? | — → "plan first" / "use TDD" / "just do it" | +| **Output** | What should Claude return? | — → "show the diff and the test results" | + +The **acceptance check** is the highest-value ingredient — it's the difference between a session +the user babysits and one Claude closes on its own. Always try to supply a real one. + +## Scorecard (show this — it teaches the pattern) + +```text +Goal ✓ Context ✗ → added Constraints ✗ → added +References ✗→added Check ✗ → added Approach ~ → set Output ✓ +Before: 2/7 After: 7/7 +``` + +## Output template + +```text + + +Context: +Constraints: +Follow: +Done when:
+Approach: +``` + +Drop any line that genuinely doesn't apply. Prefer natural prose for tiny tasks; use the labeled +lines when there are real constraints. + +## Example (grounded — note: no brackets) + +**Raw:** `make the checkout page faster` + +**Grounding moves:** +- `Glob **/checkout*` → `src/checkout/CheckoutPage.tsx` +- `package.json` scripts → `"test": "vitest run"`, `"build": "vite build"` +- `Grep "useMemo\|React.memo" src` → `src/cart/CartPage.tsx` already memoizes its list + +**Scorecard:** Before 1/7 → After 7/7 + +**Optimized:** + +```text +Speed up the checkout page in @src/checkout/CheckoutPage.tsx — its product list re-renders on +every keystroke in the promo-code field. + +Context: the list isn't memoized, so typing recomputes and re-renders all rows. +Constraints: don't change checkout behavior or the order-submit payload; no new dependencies. +Follow: the memoization pattern already in @src/cart/CartPage.tsx (React.memo on rows + useMemo +on the derived list). +Done when: `npx vitest run src/checkout` passes, `npm run build` succeeds, and typing in the +promo field no longer re-renders product rows (verify with a render count or React DevTools). +Show me the diff and the test output. +Approach: plan first, then implement. +``` + +## Mode specifics + +- **Diagnose** a failed prompt: name the failure → the missing ingredient it maps to → the fix. + "Claude refactored the whole file" = missing **Constraints** (add "only change X; no refactors"). + "Claude solved the wrong thing" = missing **Goal/Context** (name the file + symptom). Also tell + the user the session fix: after two bad corrections, `/clear` and resend the optimized prompt; + use `/rewind` to undo Claude's changes. +- **Generate** from a bare goal: ground first, then if scope is still ambiguous ask the 3 + questions before writing — don't generate a confident prompt on top of unknowns. +- **Spec** a big feature: see the interview→SPEC.md→fresh-session pattern in the reference. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Emitting `[bracketed placeholders]` | Ground in the repo; resolve to real paths/commands, or ask one question | +| Stacking unrelated tasks in one prompt | One task per prompt; `/clear` between them | +| "Make it better" with no check | Name a real verification: a test command, a build, a screenshot to compare | +| Describing the fix instead of the symptom | Give symptom + likely location; let Claude find the cause | +| Over-specifying the obvious | Cut filler; keep only constraints Claude can't infer | +| Dribbling context over many turns | Front-load intent + constraints in the first message — Opus 4.8 rewards this | + +## Deeper guidance + +For the full strategy tables, model-specific behavior (literalism, autonomy, over-eagerness), +the verification-gating ladder, rich-context input (`@files`, images, URLs, piping), plan-mode +decisions, mid-task course-correction phrasing, the interview→spec pattern, and reusable prompt +snippets, read [references/claude-code-prompting-guide.md](references/claude-code-prompting-guide.md). + +Source material lives at the repo root: `claude-code-best-practices.md` and +`prompting-best-practices.md`. diff --git a/.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md b/.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md new file mode 100644 index 0000000..90110f1 --- /dev/null +++ b/.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md @@ -0,0 +1,168 @@ +# Claude Code Prompting Guide (deep reference) + +Distilled from `claude-code-best-practices.md` and `prompting-best-practices.md` (repo root). +Load this when the basic seven-ingredient pass in SKILL.md isn't enough — large features, +model-behavior tuning, or diagnosing why Claude Code keeps going off track. + +## Contents +- [1. The seven ingredients, expanded](#1-the-seven-ingredients-expanded) +- [2. Verification: give Claude a check it can run](#2-verification-give-claude-a-check-it-can-run) +- [3. Plan first vs. just do it](#3-plan-first-vs-just-do-it) +- [4. Model behavior to prompt around (Opus 4.8)](#4-model-behavior-to-prompt-around-opus-48) +- [5. Feed rich context](#5-feed-rich-context) +- [6. Big features: interview → spec → fresh session](#6-big-features-interview--spec--fresh-session) +- [7. Session hygiene the optimizer should recommend](#7-session-hygiene-the-optimizer-should-recommend) +- [8. Anti-patterns to rewrite away](#8-anti-patterns-to-rewrite-away) +- [9. Reusable prompt snippets](#9-reusable-prompt-snippets) + +## 1. The seven ingredients, expanded + +Each "before → after" shows the kind of rewrite the optimizer performs. + +| Strategy | Before | After | +|---|---|---| +| **Scope the task** | "add tests for foo.py" | "write a test for foo.py covering the case where the user is logged out. avoid mocks." | +| **Point to sources** | "why does ExecutionFactory have such a weird api?" | "look through ExecutionFactory's git history and summarize how its api came to be" | +| **Reference patterns** | "add a calendar widget" | "look at how widgets are built on the home page — HotDogWidget.php is a good example. follow that pattern to add a calendar widget. build from scratch without new libraries." | +| **Describe the symptom** | "fix the login bug" | "users report login fails after session timeout. check token refresh in src/auth/. write a failing test that reproduces it, then fix it." | + +Vague prompts still have a place: when *exploring*, `"what would you improve in this file?"` +surfaces things the user wouldn't have thought to ask. Optimize for precision when the user +wants a specific outcome; leave room when they're fishing. + +## 2. Verification: give Claude a check it can run + +Without a runnable check, "looks done" is the only stop signal and the user becomes the +verification loop. A check is anything returning pass/fail in the conversation: a test suite, +a build exit code, a linter, a diff against a fixture, or a screenshot compared to a design. + +Gating ladder, from lightest to strongest — pick based on how much the user is watching: + +1. **In one prompt** — "run the tests after implementing and fix failures." Works today on any task. +2. **Across a session** — set the check as a `/goal` condition; an evaluator re-checks every turn. +3. **Deterministic gate** — a Stop hook runs the check as a script and blocks the turn until it passes. +4. **Second opinion** — a verification subagent / `/code-review` re-checks in fresh context. + +Always ask for **evidence, not assertion**: the command run and its output, or a screenshot. + +UI work: "[paste screenshot] implement this design. take a screenshot of the result, compare +to the original, list differences, and fix them." + +Bugs: "fix it and verify the build succeeds. address the root cause, don't suppress the error." + +## 3. Plan first vs. just do it + +Recommend **plan mode** when the approach is uncertain, the change spans multiple files, or +the user is unfamiliar with the code. Skip it when the diff fits in one sentence (typo, log +line, rename). The four-phase loop: **Explore → Plan → Implement → Commit**, with exploration +and planning done in plan mode (read-only) before any edits. + +## 4. Model behavior to prompt around (Opus 4.8) + +- **Literal instruction following.** It won't generalize an instruction from one item to the + rest. If something should apply broadly, say so: "apply this to every section, not just the first." +- **Autonomy.** It reasons more after user turns and works long-horizon. Specify task, intent, + and constraints **upfront in the first message** to maximize autonomy and token efficiency. + Ambiguous prompts dribbled across turns reduce both performance and efficiency. +- **Over-eagerness / over-engineering.** It may add files, abstractions, and flexibility nobody + asked for. When the user wants minimalism, add: "Only make changes directly requested or + clearly necessary. Don't add features, abstractions, docstrings, or defensive code beyond + what's needed for this task." +- **Action vs. suggestion.** "Can you suggest changes…" often yields only suggestions. For + action, use imperatives: "Change this function to…", "Make these edits to…". +- **Risky actions.** For autonomous runs, add: "Take local, reversible actions freely, but ask + before anything hard to reverse (force-push, deleting files/branches, dropping tables) or + visible to others (pushing, commenting on PRs)." +- **Hallucination guard.** "Never make claims about code you haven't opened. Read referenced + files before answering." + +## 5. Feed rich context + +- **`@path`** references a file so Claude reads it before responding — better than describing where code lives. +- **Paste images** (screenshots, mockups) directly into the prompt. +- **Give URLs** for docs/APIs; allowlist frequent domains with `/permissions`. +- **Pipe data**: `cat error.log | claude` sends contents straight in. +- **Let Claude fetch**: tell it to pull context itself via Bash, MCP tools, or file reads. + +## 6. Big features: interview → spec → fresh session + +For larger work, don't write the mega-prompt by hand. Have Claude interview the user first: + +```text +I want to build [brief description]. Interview me in detail using the AskUserQuestion tool. +Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs. Don't ask +obvious questions — dig into the hard parts I might not have considered. Keep interviewing +until we've covered everything, then write a complete spec to SPEC.md. +``` + +The best specs are self-contained: they name the files and interfaces involved, state what's +out of scope, and end with an end-to-end verification step. Then start a **fresh session** to +execute the spec with clean context. + +## 7. Session hygiene the optimizer should recommend + +When a user is frustrated with results, the fix is often the *session*, not the prompt: + +- **Course-correct early** with `Esc`; `Esc Esc` or `/rewind` to restore prior state. +- **`/clear` between unrelated tasks** — the "kitchen sink session" pollutes context. +- **After two failed corrections, `/clear` and rewrite** the initial prompt with what was learned. + A clean session with a better prompt beats a long one full of failed attempts. +- **Subagents for investigation** — "use subagents to investigate X" keeps the main context clean. + +### Mid-task course-correction phrasing + +When Claude is *actively* going wrong, the user doesn't need a new prompt — they need a sharp +redirect. Hand them phrasing like: + +- Scope creep: "Stop. Revert the changes outside `@target.ts` and do only the one change I asked for." +- Wrong root cause: "That treats the symptom. Find why `` happens before changing anything; show me the cause first." +- Over-engineering: "Too much. Remove the abstraction/helper you added and inline the simplest version that passes the test." +- Unverified 'done': "Don't tell me it works — run `` and paste the output." +- Going in circles: "`/clear` and start fresh — here's a tighter prompt: ." + +### Grounding checklist (run before writing any optimized prompt) + +1. Target file(s) resolved to real paths via `Glob`/`Grep`? +2. The "Done when" check is a real command (from `package.json`/`Makefile`/`pyproject.toml`/CI)? +3. "Follow the pattern" names a real sibling file that already does it? +4. The symptom's likely location grep'd to a real directory? +5. Zero `[brackets]` left in the output? If not, go look or ask one question. + +## 8. Anti-patterns to rewrite away + +- **Kitchen-sink prompt** — multiple unrelated tasks at once → split, one per prompt. +- **Trust-then-verify gap** — plausible code, no edge-case check → always attach verification. +- **Infinite exploration** — unscoped "investigate" → scope narrowly or delegate to a subagent. +- **Over-prompting** — "CRITICAL: you MUST…" everywhere → on current models, normal phrasing + ("Use this when…") avoids over-triggering. Reserve emphasis for the one rule that matters most. +- **"Don't do X" formatting rules** — prefer telling Claude what TO do ("respond in flowing prose") + over what to avoid ("don't use markdown"). + +## 9. Reusable prompt snippets + +Drop these into an optimized prompt when the matching behavior is needed. + +**Coverage over filtering (review/bug-finding):** +```text +Report every issue you find, including low-severity or uncertain ones. Don't filter for +importance yet — coverage is the goal. Include a confidence level and severity for each. +``` + +**Minimal / no over-engineering:** +```text +Only make changes directly requested or clearly necessary. No new features, abstractions, or +defensive code beyond what this task needs. Don't add comments or types to code you didn't change. +``` + +**General, non-overfit solution:** +```text +Implement a correct general solution for all valid inputs, not just the test cases. Don't +hard-code to the tests or add workaround scripts. If a test seems wrong, tell me instead of +working around it. +``` + +**Persist across context compaction (long autonomous runs):** +```text +Your context will be compacted automatically as it fills, so don't stop early for token +reasons. Save progress and state to disk before the window refreshes, and complete the task fully. +``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3c23ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Secrets — never commit (phase0/.env holds the Story wallet private key) +.env +*.env +!.env.example + +# Dependencies & build output +node_modules/ +dist/ +out/ + +# Local machine state +.DS_Store +.claude/settings.local.json diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..44cd961 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,104 @@ +# Skill Asset Protocol (working name) + +A protocol for tokenizing and monetizing the long-term value of authored **Skills** +(natively-digital work artifacts such as Claude Code skills, plugins, and agents) so +their **Creators** retain a durable economic claim each time others use them — instead +of handing the value over once and capturing none of the upside. + +## Language + +**Skill**: +A natively-digital, reusable work artifact that encodes specialized capability — e.g. a +Claude Code skill, plugin, or agent definition. The unit of value in this system. +_Avoid_: tool, script, prompt, asset (too generic) + +**Creator**: +The party that authors a **Skill** and holds the residual economic claim on its use. +_Avoid_: author, developer, owner (ownership becomes ambiguous once tokenized) + +**Wielder**: +The party that invokes a **Skill** to perform productive work. Need not be the party +that ultimately profits from that work. +_Avoid_: user (overloaded), operator, consumer + +**Beneficiary**: +The party that realizes downstream economic value from a **Wielder**'s use of a **Skill** +(typically the employer). May or may not be the same party as the **Wielder**. +_Avoid_: employer, buyer, customer (each is just one instance of this role) + +**Marketplace**: +The open venue where **Invocation-rights** to **Skills** are offered to any **Wielder** and +**Royalty claims** trade. The **Intra-org** and **Education** modes route the same primitives +privately; the Marketplace is the public one. +_Avoid_: store, exchange, platform + +## Archetypes + +The three distribution modes are all the same Creator → Wielder → Beneficiary shape, +collapsed differently: + +- **Marketplace**: independent **Creator** → any **Wielder** (Wielder and Beneficiary are the same person) +- **Intra-org**: employee-**Creator** and employer **co-hold the Royalty claim** (replacing work-for-hire's 100/0); shared upside comes from *external* **Wielders** invoking the Skill across the **Marketplace** +- **Education**: institution-**Creator** authors a base **Skill**; the student forks it into a **Derivative** they own (becoming a **Creator** themselves) and wields it at work; the employer-**Beneficiary** pays per **Invocation**, which splits to the student's **Derivative** and flows through to the school + +## Relationships + +- A **Skill** has exactly one **Creator** at origin (co-authorship is an open question). +- A **Wielder** invokes a **Skill**; that invocation is the event that should generate value flowing back to the **Creator**. +- A **Beneficiary** profits from the **Wielder**'s output and is the natural source of the funds that settle to the **Creator**. +- A **Derivative** **Skill** owes royalties to every **Skill** in its ancestry — credited per **Invocation**, claimable on demand (composable royalty flow-through). +- A **Royalty claim** attaches to a **Skill** and entitles its holder(s) to a share of that Skill's **Invocation** revenue. +- A **Royalty claim** may be co-held by multiple parties (e.g. an employee-**Creator** and their employer), so both earn from every external **Invocation**. + +**Invocation**: +A single metered use of a **Skill** via its hosted execution. The billable event. The +Wielder receives the *output* of the Skill, never the Skill's content. +_Avoid_: call, request, run (use Invocation for the billable unit specifically) + +**Invocation-right**: +What a Wielder acquires — permission to trigger **Invocations** of a **Skill**, priced +per use. This (not the artifact) is the thing that gets tokenized and traded. +_Avoid_: license (too broad), ownership + +**Derivative**: +A **Skill** created by forking or building upon one or more existing **Skills**. Each +**Invocation** of a Derivative owes royalties to its ancestor Skills (credited per Invocation, +claimable on demand — see ADR-0005). +_Avoid_: fork, copy (copy implies the unauthorized duplication this system exists to prevent) + +**Royalty claim**: +An entitlement to a share of a **Skill**'s future **Invocation** revenue. Co-holdable and +fractional. Transferability is mode-dependent: **non-transferable** in **Intra-org** / +**Education**; a permissioned, regulated security when tradeable in the open **Marketplace** +(see ADR-0006). +_Avoid_: dividend, share, equity + +**Execution credential**: +A single-use authorization, minted by a **Wielder**'s per-**Invocation** payment, that the +hosted runtime requires before it will run a **Skill**. The link between payment and execution: +no credential, no run. +_Avoid_: token (overloaded), license, key + +## Example dialogue + +> **Dev:** "When a student runs the school's **Skill** at their employer, who pays?" +> **Domain expert:** "The employer — they're the **Beneficiary**. Each run is an **Invocation**, and the payment mints an **Execution credential** the runtime needs before it will run." +> **Dev:** "And the school gets all of it?" +> **Domain expert:** "No. The student forked the school's Skill into a **Derivative** they own, so the payment splits to the student and *flows through* to the school as the ancestor. The student holds a **Royalty claim** on their own Derivative — that's the asset they graduate with." +> **Dev:** "What stops the employer from copying the Skill and skipping payment?" +> **Domain expert:** "They never receive the Skill — only its output. And even an approximate clone can't claim provenance or tap the **Derivative** graph, so building one isn't worth it." + +## Flagged ambiguities + +- "tokenized asset" — RESOLVED in principle: the tradeable asset is the **Invocation-right** / + royalty stream, NOT the Skill artifact. The artifact itself is never sold or handed over. +- "skill" — colloquially means a human ability; here it strictly means the digital artifact. + Human capability is the *thing the artifact encodes*, not the Skill itself. +- Who **pays** vs. who **benefits** may be different parties; not yet pinned down. +- Hiding the Skill from the *host* (e.g. Anthropic) is NOT solved in v1 — the host processes the + Skill in plaintext (no TEE), and `GET /v1/agents` echoes it to the key-holder. Accepted as a + deferred trust boundary per ADR-0004; TEE is the tabled future hardening. The Skill IS hidden + from the **Wielder**, who sees only the output. +- Settlement is **eventually-consistent**, not atomic: the per-**Invocation** payment gate and the + on-chain royalty settlement are decoupled legs (ADR-0005). The gate is trust-minimized; batched + settlement is an "auditable accumulator" (ADR-0003 Update). diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..18249d7 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,731 @@ +# Skill Asset Protocol -- Product Requirements & Feasibility-Grounded Plan + +*A settlement protocol that lets the Creators of authored AI Skills keep a durable, monetizable claim on every future use -- instead of handing the value over once.* + +> **Status:** Derived from a structured design interview and an adversarial feasibility validation; see `CONTEXT.md`, `docs/adr/`, `docs/feasibility/report.md`, `docs/feasibility/findings.json`. +> **Feasibility verdict:** GO-WITH-CAVEATS. Every component is real and live on mid-2026 APIs; the idealized atomic loop does not compose and is rebuilt here as a decoupled, two-leg, batched, eventually-consistent settlement. **Confidence is high on every technical component and *medium* on regulatory**, and several load-bearing facts remain unmeasured -- see [What we have NOT validated](#what-we-have-not-validated). + +## Table of Contents + +1. [Executive Summary & Thesis](#executive-summary) +2. [Problem & Market](#problem--market) +3. [Product & User Experience](#product--user-experience) +4. [Technical Architecture (v1, honest)](#technical-architecture) +5. [Economic Design & Tokenomics](#economic-design) +6. [Regulatory & Compliance Strategy](#regulatory--compliance-strategy) +7. [Competitive Landscape & Moat](#competitive-landscape--moat) +8. [Go-to-Market & Rollout](#go-to-market--rollout) +9. [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria) +10. [Risks & Open Questions](#risks--open-questions) +11. [What we have NOT validated](#what-we-have-not-validated) +12. [Roadmap & Milestones](#roadmap--milestones) + +--- + +## Executive Summary + +**Knowledge workers who build reusable AI Skills hand them to employers once and capture none of the recurring value — automating themselves out of the very upside they created. This protocol lets a Creator keep a durable, monetizable claim on every future use of a Skill instead of surrendering it for a one-time wage.** An authored Skill (a Claude Code skill, plugin, or agent definition) is a natively-digital asset whose value can be retained rather than transferred: it runs behind a hosted runtime that meters each use and hands the user only the *output*, never the Skill itself, while on-chain royalty tokens route a share of that metered revenue back to the Creator and through to any ancestors it was forked from. + +What we are building is a settlement protocol around the **Invocation** — the billable, metered use of a Skill. A **Creator** authors a Skill; a **Wielder** invokes it for productive work; a **Beneficiary** (typically the employer) pays per Invocation and profits from the output. Each payment gates execution (no payment, no run) and accrues a royalty that splits among the holders of the Skill's **Royalty claim** — which can be co-held (e.g. employee plus employer) — and flows through the **Derivative** ancestry to every Skill a fork was built on. The Skill is registered as an IP Asset on Story Protocol with declared lineage, so provenance and the derivative-royalty graph are on-chain and auditable. + +The protocol ships in **three modes**: **Marketplace** (independent Creator to any Wielder), **Intra-org** (employee-Creator and employer co-hold the claim, replacing work-for-hire 100/0, sharing upside from external invocations), and **Education** (a school authors a base Skill, a student forks it into a Derivative they own and wields it at work, the employer pays per Invocation, royalties split to the student and flow through to the school). + +**Honest architecture:** the vision validates as real but does *not* compose into one atomic action. It is a **decoupled two-leg settlement**. Leg 1 is the gate — x402 settles gasless USDC on Base (chainId 8453); the replay-proof txHash is the single-use execution credential, checked off-chain so the Wielder pays first and the agent runs after. Leg 2 is royalties — an off-chain worker batches payments, bridges and swaps USDC into WIP on Story (chainId 1514), calls `payRoyaltyOnBehalf`, and runs a keeper that pull-claims for ancestors. Royalty flow-through is therefore **eventually-consistent and claimable**, not atomic per call. Do not attempt to make x402 settle directly to Story (wrong chain, wrong token, wrong primitive — see `docs/feasibility/report.md` §4.1). + +**Go-to-market is closed modes first.** Intra-org and Education face the least cloning pressure, are on-platform by construction, and can keep Royalty claims **non-transferable**, which is the best available route to staying outside securities law — though not a guaranteed safe harbor, and one counsel must bless before Phase 1 ships. Phase 0 ships provenance immediately (register Skills as Story IP Assets and Derivatives); Phase 1 adds the gate, run, and an off-chain metered ledger; Phase 2 adds on-chain batched royalty settlement; Phase 3 opens the tradeable Marketplace — but only permissioned (ATS, transfer agent, exemption, KYC), with securities counsel engaged first, because tradeable claims are securities under Howey. + +**The single most important strategic risk is off-platform behavioral cloning, and it sits below the chain.** Because the Wielder receives the output, and for most Skills the output *is* the value, a high-volume Skill is the cheapest thing to clone — its own paid input/output pairs are a roughly 30x-cheaper distillation set (`report.md` §5; ADR-0004 Update). Watermarking is a forensic tripwire, not a moat. The protocol defends the *marketplace* (liquidity, provenance, declared-derivative royalties), not an individual breakout Skill. The recommended response — **price below amortized clone cost, out-evolve via live updates, bind value to live tool and data access** — is load-bearing but rests on an *unmeasured* assumption: no source quantifies how fast a Skill must change to keep a distilled clone economically stale (`report.md` §7.7). We launch in the closed modes, where the pressure is lowest, partly to buy time to measure this. + +--- + +## Problem & Market + +### The displacement thesis + +The premise is a specific, near-term labor shock, not a generic "AI is coming for jobs" claim. Knowledge workers are now encoding their hard-won expertise into **Skills** — Claude Code skills, plugins, agent definitions: natively-digital, reusable work artifacts that perform the work the human used to do. A Skill is plaintext (a `SKILL.md`, a plugin manifest, an agent prompt) and therefore trivially copyable (ADR-0001). The moment an employee hands one to an employer, the prevailing legal default — work-for-hire — assigns 100% of the value to the employer and 0% to the author. The worker has, in effect, paid to automate their own role and captured none of the resulting productivity gain. The faster and better they encode their expertise, the faster they erase their own bargaining position. + +This is the asymmetry the protocol attacks. Today a Skill's value transfers **once** (a salary, a one-time sale, or simply unpaid output of employment) and then compounds for whoever holds the artifact. The protocol's thesis is that the durable, recurring value of an authored Skill should accrue, in part, to its **Creator** every time it is **wielded** — by converting "I built this and gave it away" into "I built this and hold a **Royalty claim** on its invocations." The unit of economic value is the *Invocation* (the metered, billable run), and the tradeable thing is the *Invocation-right* / royalty stream — never the artifact, which is never handed over (ADR-0001). + +A necessary honesty caveat, because it bounds the whole market: the protocol defends the **marketplace and the provenance graph**, not any single breakout Skill. For most Skills the *output is the value*, and ADR-0001 hands the Wielder the output. A high-volume Skill is therefore the cheapest thing in the world to behaviorally clone — its own paid input/output pairs are a roughly 30x-cheaper distillation set. The moat is liquidity, declared-derivative royalties, live evolution, and binding value to live tool/data access — not secrecy or watermarking (which is a forensic tripwire, removable by paraphrase, not a moat; ADR-0004). This is why the displacement thesis is strongest in **closed populations** first, where cloning pressure is lowest. + +### The three customer segments and their pain + +The three modes are one shape — Creator → Wielder → Beneficiary — collapsed three ways (CONTEXT.md). Each has a distinct payer and a distinct pain. + +**1. Independent Creators → any Wielder (Marketplace mode).** +A skilled author writes a genuinely valuable Skill and has exactly two bad options today: sell the file once (buyer copies it infinitely, author earns nothing further) or self-host it as a SaaS (build billing, auth, infra, and a metering pipeline from scratch). Their pain is the absence of a per-use monetization rail for a copyable artifact. *Who pays:* the **Wielder is the Beneficiary** — the person invoking the Skill profits directly from its output and pays per Invocation. This is the highest-pain, highest-incentive-to-clone, and most regulated segment (tradeable claims are securities), so it is sequenced **last** (ADR-0006). + +**2. Employee-Creators + Employers (Intra-org mode).** +The employee who builds a Skill at work today gets work-for-hire's 100/0 split and watches their leverage evaporate. The employer's reciprocal pain is retention and incentive: their best people have every reason to hoard expertise, build Skills on the side, or leave. The mode replaces 100/0 with a **co-held Royalty claim** — employee and employer both hold a fractional, co-holdable claim on the Skill, and both earn from *external* invocations across the marketplace. The prototype makes this concrete (`recon`: Sam 50% + MegaCorp 50%, both paid on an external invocation). *Who pays:* an **external Wielder/Beneficiary** outside the org; the internal split is the alignment mechanism, not the revenue source. Claims here are kept **non-transferable**, the best available route to keeping the mode outside securities law (ADR-0006) — a reason it ships before the marketplace. **Assumption flagged:** whether mid-size employers will actually restructure work-for-hire IP terms into a co-held claim is *unvalidated* (R12, rated Medium-High); the design-partner LOIs that would prove it do not yet exist and are a Phase-0/1 gate (see [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria)). + +**3. Schools + Students + Employers (Education mode).** +A school teaches a capability but captures none of its graduates' downstream economic value; a student graduates with debt and a credential but no durable, owned, income-producing asset; an employer wants the capability but has no clean per-use rail to pay for it. The mode threads all three: the **school authors a base Skill**, the **student forks it into a Derivative they own** (becoming a Creator), and wields that Derivative at work. *Who pays:* the **employer is the Beneficiary** and pays per Invocation; royalties **split to the student's Derivative and flow through to the school** as an ancestor. The prototype's `biofin` (forks `finmod` at 30% inherit) shows the split. The asset the student graduates with is a real Royalty claim. **Open economics question:** the prototype flags a "fork-killing threshold" — at high ancestor-royalty rates forking stops being worth it; the right inherit-bps is *unresolved* and is the key economics experiment in `prototype/README.md` (verdict currently **TBD**). See [Economic Design](#economic-design). + +### Why now + +Four independent enablers crossed the line into "real and usable" in mid-2026 — the protocol was not buildable even a year earlier: + +- **Managed agents let the collar hide the Skill from the Wielder — because the collar holds the sole API key, not because the platform keeps it secret.** Anthropic Managed Agents (CMA, beta `managed-agents-2026-04-01`) persist the system prompt and skills on the Agent object; the session *output* stream (`agent.message`/`thinking`/`tool_*`/`span.*`) never carries them, so a party who only sees output gets only the output (`report.md` §3 step 2; `findings.json` `/verdict/confirmed[1]`). **This is hiding from the WIELDER only.** The hiding property is a function of *who holds the platform API key*, not a platform secrecy feature: `GET /v1/agents` echoes the full system prompt verbatim to the key-holder, and Anthropic processes the Skill in plaintext (no TEE). It holds solely because the collar is the sole key-holder and never proxies `GET /v1/agents` (accepted per ADR-0004; CONTEXT.md lines 98–101). What this *enables* is monetizing a copyable plaintext artifact per-use — not because it is secret, but because the Wielder pays for outputs without receiving the artifact. +- **x402 is mature.** Coinbase's HTTP-402 stablecoin rail became a Linux Foundation / x402 Foundation standard (donated 2026-04-02), reached ~165M transactions and ~$50M cumulative volume by April 2026, and is backed by Stripe (x402 USDC-on-Base live Feb 2026), AWS Bedrock AgentCore, and Cloudflare (`findings.json` `/verdict/confirmed[2]`). It gives a clean per-invocation gate whose settled `txHash` is a replay-proof, single-use execution credential ("no credential, no run"). +- **Story Protocol is live.** chainId 1514, SDK v1.4.4 — registers a Skill as an IP Asset with declared Derivative ancestry, mints co-holdable fractional royalty tokens, and supports derivative flow-through. The on-chain provenance and royalty graph the whole model depends on exists today (Phase 0 ships immediately, ADR-0006). +- **The supply side exploded.** Authored AI Skills (Claude Code skills, plugins, agent definitions) went from niche to a fast-growing artifact class — meaning there is now a large, growing population of Creators with real expertise to encode and a real fear of giving it away. + +One honest caveat on timing: the literal vision — a single payment that both gates execution and atomically pays ancestors on Story — does **not** compose, because x402 settles USDC on Base while Story royalties need WIP on Story, with no facilitator bridging the two. It must be rebuilt as a decoupled, two-leg, batched, eventually-consistent settlement (ADR-0005). The components are real; the wiring is harder than the pitch. + +### Directional market framing (ranges and logic, no false precision) + +We deliberately avoid a fabricated TAM. The honest framing is a chain of logic plus a few hard, sourced numbers: + +- **Demand for per-call machine payment exists and is growing.** x402's ~165M transactions / ~$50M cumulative volume by April 2026, across multiple facilitators since it became a Linux Foundation / x402 Foundation standard, demonstrates that per-call machine/agent payments are real and growing, not theoretical (`findings.json` `/validated[x402]/evidence` note: "protocol ~165M tx / $50M cumulative by Apr 2026; ~49% volume on non-Coinbase facilitators"). **Read this as evidence the *rail* is real and multi-vendor, not as a demand signal for skill-royalty payments specifically** — the bulk of x402 volume today is agent-infrastructure micropayments, not Skill royalties. Per-call economics are micropayment-grade (~200ms settle target, sub-cent Base fees) — the rail can carry cents-level invocations. +- **The serviceable wedge is the closed modes first.** Intra-org and education are bounded by the count of organizations and schools willing to restructure compensation/IP terms — an *assumption we have not validated* (R12) — not by a consumer funnel. This is a smaller, slower, enterprise-sales-shaped market, but it is the one with aligned incentives, lowest cloning pressure, and no securities overhead. It is where revenue is realistic in year one *if* the willingness-to-co-hold assumption holds. +- **The large, uncertain upside is the open marketplace**, which scales with the size of the authored-Skill economy and the willingness of Wielders to pay per use. This is genuinely large in principle and genuinely unproven in practice — and it is gated behind the securities stack (ATS, transfer agent, exemption, KYC) and the thinnest moat, so it should be underwritten as optionality, not as the base case. +- **No demand-side evidence yet.** The thesis that Beneficiaries will pay per-invocation for outputs they could in principle clone has **no pilot LOI, no pricing research, and no committed design partner today**. The only quantitative demand signal we cite is x402 aggregate volume, which is not skill-royalty demand. Treat demand validation as an explicit Phase-0/1 gate, not an established fact. +- **Known headwinds to discount into any model:** royalties accrue in WIP, and $IP traded around $0.37–0.60 in June 2026, ~97.5% below its $14.78 ATH, on thin ~$35–47M daily volume. Enterprises will not hold WIP, so FX/liquidity risk and a fiat/USDC→WIP on-ramp are costs the model must carry. Per-hop settlement fees force mandatory batching, which sets a price floor below which a micro-royalty is uneconomic. + +The defensible claim is therefore *not* a dollar figure. It is: there is a real, painful, three-sided problem; the rails to address it became real in mid-2026; demand for per-call payment is empirically growing *in adjacent agent-infra use*; and the right way to size this is to win the closed modes (a tractable enterprise/education market, **subject to validating willingness-to-co-hold**) before betting the company on the open marketplace's larger-but-unproven upside. + +### Who pays, in one view + +| Mode | Creator | Wielder | Who pays (Beneficiary) | Royalty flow | Claim transferability | +|---|---|---|---|---|---| +| Marketplace | Independent author | Any buyer | The Wielder (Wielder = Beneficiary) | All to Creator (less protocol fee) | Tradeable → security (Phase 3) | +| Intra-org | Employee | External party | An external Wielder/Beneficiary | Split to co-held employee + employer | Non-transferable (best route outside securities law) | +| Education | School (base) + student (Derivative) | The student, at work | The student's employer | Split to student's Derivative, flows through to school | Non-transferable (best route outside securities law) | + +### Co-authorship (open design question) + +CONTEXT.md (line 46) states that a Skill has **exactly one Creator at origin** and explicitly flags **co-authorship as an open question**. v1 assumes single-Creator origin everywhere in this document. Teams that build a Skill jointly are not yet modeled: the co-held *Royalty claim* mechanic (employee+employer, student+school) can express multiple *holders* of a claim, but the question of multiple *originating authors* — how they split the claim at registration, how disputes resolve, and how a multi-author Skill declares ancestry — is unresolved and must be designed before Marketplace (where independent multi-Creator teams are likeliest). Until then, multi-author teams should designate a single registering Creator and split via the co-hold mechanic as a stopgap. + +--- + +## Product & User Experience + +This section walks the lived experience of each role in each of the three modes, then shows the one settlement loop they all share and where it visibly differs. The throughline: nobody touches a blockchain directly, nobody types "WIP," and no Wielder ever sees a Skill. The chain is plumbing; the product is a registration flow, a metered endpoint, and a balance that goes up. + +Concrete cast, taken from the seeded prototype (`prototype/settlement-engine.mjs`) so every dollar figure below is one the engine actually produces: + +| Skill | Mode | Price/Invocation | Ancestry | Royalty claim | +|---|---|---|---|---| +| `pdf-extract` | Marketplace | $10 | root | Dana (indie) 100% | +| `ledger-recon` | Intra-org | $20 | root | Sam 50% + MegaCorp 50% (co-held) | +| `fin-modeling` | Education | $5 | root | State U 100% | +| `biotech-fin-modeling` | Education | $25 | ↳ forks `fin-modeling` @ 30% flow-through | Mia 100% | + +### The shape everyone shares: register → host → invoke+pay → output → accrue → claim + +Before splitting by mode, here is the loop in plain terms, because all three modes are the same steps collapsed differently (CONTEXT.md, Archetypes). + +1. **Register.** A Creator signs in, names a Skill, uploads the `SKILL.md` / plugin / agent definition, sets a per-Invocation price, and — if it's a fork — picks the parent and a flow-through rate. One click writes a Story IP Asset (and, for a fork, a declared Derivative with on-chain ancestry). The Creator sees a provenance card: "Registered. IP Asset 0x… · lineage: you → State U." This is **Phase 0** and ships first (ADR-0006); it is the soundest step in the whole loop (`report.md` §3, step 1). +2. **Host.** The artifact is pushed into a hosted runtime (an Anthropic Managed Agent) behind the collar, which is the sole API-key holder. From this moment the Skill is **never handed over to the Wielder** (ADR-0001). The Creator gets an Invocation endpoint and a price; the artifact is now a metered service, not a file. (**Phase 1.**) +3. **Invoke + pay.** A Wielder hits the endpoint. It returns `402 Payment Required`. The Wielder's client signs a gasless USDC payment on Base (x402, EIP-3009 `transferWithAuthorization`); the settled `txHash` is the single-use execution credential. The collar checks it off-chain — *no credential, no run* (ADR-0003) — and only then starts the agent. (**Phase 1.**) +4. **Receive output.** Payment settles in well under a second; the collar releases the credential, runs the agent asynchronously, and streams back **only the output** (ADR-0005 Leg 1). The Wielder never sees the system prompt or skill body; the session stream simply doesn't carry them (`report.md` §3, step 2). To the Wielder it feels exactly like calling any paid API. (**Phase 1.**) +5. **Accrue.** Each payment lands in an auditable off-chain ledger as a credited royalty balance, split by the claim table and flowed through the Derivative ancestry minus a protocol fee (the engine's `distribute()`, default 2.5%). (**Phase 1 — this is the off-chain ledger; nothing is on-chain yet.**) +6. **Claim / settle.** **In Phase 1, "claim" is a withdrawal against the off-chain auditable ledger, not an on-chain settlement.** On-chain settlement is **deferred to Phase 2**: a batched worker bridges/swaps USDC→WIP and calls `payRoyaltyOnBehalf` on Story, and a permissionless keeper auto-claims for ancestors so their balance never silently piles up (ADR-0005 Leg 2). Under the hood **flow-through is pull, not push**; the keeper hides the mechanic, but the *lag* between credited and on-chain-settled is real and surfaced (see caveat below). + +The honest caveat the UX must absorb: settlement is **eventually-consistent, not atomic** (ADR-0005). Step 3 (the gate) is instant and trust-minimized; steps 5–6 (the on-chain split, Phase 2) are batched and lag. The keeper hides the *pull-not-push* mechanic, but it does **not** hide the *gap* between "credited" and "settled on-chain" — that gap is a real reconciliation surface that the protocol monitors as an operational alarm ("royalties credited vs. claimed"), and sophisticated buyers will notice it (R14). The product's job is to make the lag *tolerable and legible*: show a credited balance immediately, settle on-chain quietly in the background, and surface the on-chain batch as a reconciliation receipt — not to pretend the gap does not exist. + +### Mode (a) — Marketplace: independent Creator → any Wielder + +Here the Wielder and Beneficiary are the **same party** (CONTEXT.md). Cast: **Dana** (indie creator), **Acme** (wields `pdf-extract` for its own benefit). + +- **Dana (Creator).** Registers `pdf-extract`, sets $10/Invocation, holds 100% of its royalty claim. Her dashboard shows: invocations today, gross, the 2.5% protocol fee, net to her, and a provenance badge proving she's the registered original. She does nothing per-call; she watches a balance climb and a "claim available" figure she can withdraw. Her real lever is iteration — she ships improvements, because a static clone of her outputs rots while the hosted original keeps evolving (ADR-0004). The product nudges this: changelog, "last updated," version-pinned provenance. +- **Acme (Wielder + Beneficiary).** Drops an API key / wallet into its tooling, calls the endpoint, pays $10, gets the extracted data. That's the entire experience — a paid API with a provenance link it can click to verify the Skill is the genuine, royalty-bearing original rather than an orphan clone. +- **Settlement Acme/Dana see.** Acme: a line item, "$10.00 — pdf-extract — txHash 0x…". Dana: "+$9.75 credited" (the engine routes $0.25 to treasury, $9.75 to Dana). No ancestry, so no flow-through — the simplest split in the system. + +The honest framing for Marketplace, surfaced in the product's own positioning rather than hidden: this mode faces the **most** cloning pressure (a high-volume Skill's paid outputs are the cheapest distillation set, `report.md` §5; ADR-0004 Update). So the moat the UX sells here is the *marketplace itself* — liquidity, provenance, declared-derivative royalties, routing to the proven Creator — **not** a guarantee that any one breakout Skill is uncopyable. This is why Marketplace launches **last** (ADR-0006), and why its claims are the only ones that are tradeable. + +### Mode (b) — Intra-org: employee-Creator and employer co-hold the claim + +This replaces work-for-hire's 100/0 split. Sam builds `ledger-recon` on company time; instead of MegaCorp owning it outright, **Sam and MegaCorp co-hold the royalty claim 50/50**, and the upside they share comes from *external* Wielders invoking the Skill across the Marketplace (CONTEXT.md; ADR-0006). Cast: **Sam** (employee-Creator), **MegaCorp** (employer co-holder), **OtherCo** (external Wielder/Beneficiary). + +- **Sam (Creator).** Registers `ledger-recon` at $20 inside the org workspace, and instead of the default 100%-to-creator claim, sets a co-held claim: `sam:5000, megacorp:5000` (the engine's `setRoyalty`, enforcing the split summing to 100%). Sam's view: "Your share 50% · Employer 50% · this is your durable claim, not a one-time deliverable." The emotional payload of the whole project lives here. +- **MegaCorp (employer / co-holder).** Sees the other half of the same claim plus governance: who can fork internal Skills, which are exposed externally, audit logs of every external Invocation. MegaCorp's incentive flips from "lock the work away" to "expose it so external invocations pay us both." +- **OtherCo (Wielder + Beneficiary).** Identical to Marketplace: `402` → pay $20 USDC → output. OtherCo neither knows nor cares that the claim behind the Skill is co-held. +- **Settlement Sam/MegaCorp see.** On OtherCo's $20 Invocation: $0.50 fee, $19.50 net, split $9.75 / $9.75. **Both** balances tick up on every external call (the prototype's `invoke recon otherco` makes this concrete). + +Two product consequences that shape the UX heavily: +- **Claims are non-transferable here** (ADR-0006). Sam cannot sell his half on an open market; it's a contractual / deferred-comp right. This is deliberate — non-transferability is the best available route to keeping the claim outside securities law (no ATS, transfer agent, or KYC allow-list needed). The UX reflects it: a "claim" panel but **no "sell"/"list" button**. (Note the tax wrinkle: a co-held royalty claim structured as deferred comp carries 409A / constructive-receipt implications — see [Regulatory & Compliance Strategy](#regulatory--compliance-strategy) — that counsel must structure before launch.) +- A self-hosted sandbox is offered for regulated internal data, because the managed runtime is not ZDR/HIPAA-eligible and the host still sees the Skill in plaintext (`report.md` §3 step 2; ADR-0004). The product surfaces this as a data-residency toggle. + +Closed population, aligned incentives, lowest cloning pressure — which is why Intra-org ships **first** in Phase 1 (ADR-0006). + +### Mode (c) — Education: school authors a base Skill, student forks and owns the Derivative + +The richest journey, because it spans years and produces an asset the student literally graduates with (CONTEXT.md example dialogue). Cast: **State U** (school-Creator of the base Skill), **Mia** (student → graduate, who forks it into a Derivative she owns and wields at work), **BioCorp** (Mia's employer, the Beneficiary who pays per Invocation). + +- **State U (Creator of the base).** Registers `fin-modeling` at $5, holds 100% of its claim, and publishes it as forkable with a flow-through rate. State U's view is a *lineage* dashboard: every student Derivative descending from its base Skill, and royalties flowing up from all of them. +- **Mia (student → Creator).** Forks `fin-modeling` into `biotech-fin-modeling`, priced $25, with flow-through to her parent (the engine's `forkSkill(... inheritBps)`). The moment she forks, she becomes a Creator and holds 100% of *her* Derivative's claim. Her view: "You own biotech-fin-modeling · a share of each Invocation flows to State U · the rest is yours." This is the asset she walks out of school with. +- **BioCorp (Wielder + Beneficiary).** Mia uses her own Skill at work; **BioCorp pays per Invocation** because BioCorp is the Beneficiary. Same flow: `402` → pay $25 USDC → output. BioCorp sees a normal paid endpoint plus a provenance trail (Mia → State U) it can audit. +- **Settlement everyone sees (at the seeded 30% flow-through, which is *illustrative, not a recommended default* — see Economic Design).** On BioCorp's $25 Invocation, the engine produces: $0.625 protocol fee; of the $24.375 net, 30% ($7.31) flows up to State U as the ancestor, and Mia keeps $17.06. So **Mia ~$17, State U ~$7, per call** — the "does that split feel right?" experiment from `prototype/README.md`. Mia's dashboard: "+$17.06 (your claim)." State U's: "+$7.31 (flow-through from biotech-fin-modeling)." + +Education is closed-population like Intra-org, so its claims are likewise **non-transferable** and ship in Phase 1 after Intra-org (ADR-0006). The open design question the UX must eventually answer is the **fork-killing threshold** (`prototype/README.md`, experiment 5, verdict **TBD**): if State U sets flow-through too high, forking stops being worth it for students and the lineage never grows. The product should expose flow-through as a *visible, tuned* dial with a live "what the student keeps vs. what flows up" preview at fork time. + +### The UX gap: a non-transferable closed-mode claim vs. a (later) tradeable Marketplace claim + +This is the single biggest experiential fork in the product, and it maps directly to a legal boundary (ADR-0006, CONTEXT.md). + +**Closed-mode claim (Intra-org, Education — Phase 1, ships first).** The claim is a **balance and an entitlement, not an instrument.** What the holder sees and does: +- A claim panel: your %, co-holders or ancestors, accrued balance, claim/withdraw (**in Phase 1, withdraw against the off-chain ledger; on-chain settlement arrives in Phase 2**). +- **No "sell," no "list," no order book, no price chart.** Transfer is structurally disabled. +- Onboarding is just login — **no KYC, no accreditation gate, no securities disclosures**, on the basis that a non-transferable revenue-share right is the best available route outside Howey. (This is *medium-confidence*, not settled law — see Regulatory.) +- The mental model offered to Sam and Mia: "this is your durable, personal claim on future use — like deferred comp or a license fee that keeps paying," explicitly *not* "a tradeable security." + +**Tradeable Marketplace claim (Phase 3 — ships last, only when warranted).** The same underlying royalty stream, but now an instrument that can change hands — which makes it **almost certainly a security** under Howey, and the live-evolution moat *strengthens* the "efforts of others" prong (ADR-0006; `report.md` §5). The UX is heavier by necessity: +- A **gated onboarding wall**: KYC, accreditation/eligibility checks, and an ERC-3643 allow-list. +- A **list-for-sale / transfer** flow that routes through a **registered ATS (e.g. Securitize) + transfer agent**, under a Reg D 506(c) / Reg A+ / CF exemption — *not* a one-click permissionless transfer. +- Secondary-market surfaces the closed mode deliberately omits: holders of record, transfer history, eligibility status per counterparty. + +The crisp product line: **the derivative-royalty *mechanic* (fork, flow-through, co-hold, accrue, claim) is identical across all modes from Phase 1.** Only **tradeability** flips the experience from "a balance you withdraw" to "a regulated instrument you transfer through an ATS." The build sequence follows the risk: ship the simple, non-transferable, no-KYC closed-mode claim first; add the heavy, permissioned, KYC-gated tradeable claim last, with securities counsel engaged before that phase (ADR-0006). + +--- + +## Technical Architecture + +This is the **honest v1 design**: not the idealized loop where one payment both gates execution and atomically pays ancestors on Story, but the buildable one the feasibility validation supports — a **decoupled, two-leg, batched, eventually-consistent** settlement, fronted by a single trusted component (the *collar*) and anchored to two chains that each do the one job they are good at. Every claim below is grounded in `docs/feasibility/report.md` and the ADRs it produced (`docs/adr/0001`–`0006`). + +The single load-bearing fact that shapes everything: **the gating payment cannot also be the on-chain royalty payment.** x402 settles a USDC transfer to an EOA on Base (eip155:8453); Story's Royalty Module needs `payRoyaltyOnBehalf(ipId, amount, token)` — a *contract call*, denominated in *WIP*, on *eip155:1514* — and no x402 facilitator supports chain 1514. Four independent mismatches (wrong chain, wrong token, wrong primitive, wrong EIP-3009 variant — `transferWithAuthorization` vs `receiveWithAuthorization`), each fatal on its own (`report.md` §4.1; ADR-0005). So: **two legs, always.** + +### The collar + +The collar is the one piece you build and the one piece everyone must trust. It is, simultaneously: + +- **The sole Anthropic API-key holder.** This is forced, not chosen. There is no native "no credential, no run" gate on CMA, OpenAI, or Google; CMA's only mid-run control (`permission_policy: always_ask`) is a tool-approval gate keyed to the key-holder and cannot stop a turn from starting or spending tokens. Anthropic keys are workspace-scoped (full / read-only only), so you cannot mint a Wielder a key that permits `sessions.create` but forbids `GET /v1/agents`. The collar must therefore be the only key-holder and the entire gate (`report.md` §3 step 4; ADR-0003). **A direct economic consequence: the collar pays Anthropic for every run** — see [who bears the inference cost](#who-bears-the-inference-cost) in Economic Design. +- **The x402 resource server.** It issues the `402`, verifies and settles the payment, and treats the settled txHash as the execution credential (Leg 1). +- **The off-chain meter / ledger.** It accrues each settled invocation into an auditable ledger, then drives the batched Story settlement (Leg 2). + +Because the collar is the sole key-holder and never proxies `GET /v1/agents`, the **Skill stays hidden from the Wielder (not from the host)** — the session output stream never carries the system prompt or skill bodies, only the output (ADR-0001; `report.md` §3 step 2). The Skill is **not** hidden from Anthropic, which processes it in plaintext (no TEE); that is an accepted v1 trust boundary, not a solved problem (ADR-0004; CONTEXT.md lines 98–101). + +### Leg 1 — synchronous gate (Base, sub-second) + +The per-invocation gate. Trust-minimized: no payment, no run, enforced on every call. + +1. Wielder requests an invocation; collar returns **`HTTP 402` + `PAYMENT-REQUIRED`**. +2. Wielder signs **EIP-3009 `transferWithAuthorization`** (gasless USDC on Base, bytes32 nonce). +3. Collar calls the facilitator's **`/verify`** then **`/settle`**; gets back **`PAYMENT-RESPONSE {success, txHash, networkId}`**. +4. The settled **`txHash` is the single-use, replay-proof execution credential**, checked **off-chain** by the collar. Do **not** mint a per-call on-chain Story License Token — each mint drags an IP→WIP wrap + ERC-20 approve + block latency for a credential gating a call worth cents (`report.md` §3 step 3c; ADR-0005). + +**Critical ordering: settle first, then run async.** The collar settles the payment (sub-second), releases the credential, and *then* invokes the agent asynchronously and streams the output. It must **never hold the x402 handshake open across the agent run** — x402's `maxTimeoutSeconds` is ~60s, shorter than a cold `sessions.create` plus the agent loop (ADR-0005; `report.md` Phase 1). The collar owns its own nonce/txHash bookkeeping; x402 does **not** safely resubmit after a settled-but-failed run, so accept-payment-but-fail-to-run is handled by the collar via refund / reputation, not by the protocol (`report.md` §3 step 3a). **Because x402/USDC payments are irreversible and have no chargebacks, a failed run after settled payment is a refund the collar must fund out of treasury** — see the reliability/refund target in [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria). + +### Leg 2 — asynchronous settlement (Story, batched, eventually-consistent) — **Phase 2, deferred** + +The royalty path. This is where "the fork pays its ancestors" actually happens — **in accounting terms, credited and claimable on demand, not as one atomic on-chain action per invocation** (ADR-0002 Update; CONTEXT.md line 49). **This entire leg is Phase-2 work and is NOT in v1**; in v1 (Phase 1) royalties are credited and withdrawn against the off-chain ledger only. + +An off-chain **settlement worker**: + +1. Accrues each settled payment into the auditable ledger and **batches** by threshold/interval. Batching is mandatory: stacked per-hop fees (facilitator $0.001/tx after 1k/mo + Base gas + bridge fee + USDC→WIP swap slippage + Story gas + claim gas) can each exceed a cents-level micro-royalty (`report.md` §4.2; ADR-0005). +2. **Bridges/swaps USDC(Base) → WIP(Story)** via a licensed bridge (e.g. Stargate / Across / deBridge). WIP is the only mainnet-whitelisted royalty currency, and it is volatile and illiquid ($IP down ~97.5% from ATH), so fold the conversion into the bridge and fast-claim to limit FX exposure (`report.md` §4.1, risk register; ADR-0002 Update). +3. Calls **`payRoyaltyOnBehalf(ipId, amount, token)`** on Story's Royalty Module. +4. Runs a **permissionless keeper that auto-claims `claimAllRevenue`** for ancestors. Flow-through on Story is **PULL, not PUSH** — ancestors accrue a claimable balance and must claim it, or (e.g.) the school's revenue silently piles up. The keeper claims on their behalf (`report.md` §3 step 5; ADR-0002 Update; ADR-0005). +5. **Publishes the settlement batch on-chain** for reconciliation. + +A bridge stall leaves "execution done, ancestor unpaid" — a reconciliation surface handled by the auditable ledger plus retry, with conservative batching windows (ADR-0005 Consequences). **The real USDC(Base)→WIP(Story) bridge cost and confirmation time are unmeasured** (`report.md` §7.4) and must be spiked before Phase 2. + +### Story objects (the on-chain primitives) + +All four are real, live, and audited on Story Protocol (chainId 1514, SDK v1.4.4); this is the soundest part of the design (`report.md` §3 step 1, §2): + +- **IP Asset** — each registered Skill, created via `mintAndRegisterIpAssetWithPilTerms`. One real on-chain tx (Phase 0 ships this immediately). +- **PIL terms** — the license, via `PILFlavor.commercialRemix()`. Forks register as **declared Derivatives** with on-chain ancestry. +- **Fractional, co-holdable royalty tokens** — 100 per IP vault (1% granularity floor). Co-holdable by employee + employer (intra-org) or student + school (education) (ADR-0002; CONTEXT.md lines 41–42). +- **Flow-through** — **LAP** (Liquid Absolute Percentage, whole-ancestry) or **LRP** (Liquid Relative Percentage, direct-parents). Composable up the Derivative graph (`report.md` §3 step 5). + +### Trust model + +Two halves, with different guarantees — and being honest about the seam between them is the point (ADR-0003 Update; `report.md` §4.3): + +- **The gate (Leg 1) stays trust-minimized.** No credential, no run, enforced per call. Usage fraud on the money path is structurally impossible at the gate. +- **Settlement (Leg 2) degrades to an auditable accumulator.** The off-chain batched meter that decides which invocations settle *is* the trusted oracle ADR-0003 originally rejected. The "fraud structurally impossible" guarantee held only for the synchronous, single-chain, atomic case that the cross-chain gap forces us to abandon. The collar could in principle mis-report or skim. Mitigations: signed, auditable invocation logs; on-chain published settlement batches for reconciliation; refund + reputation for accept-but-fail; **TEE tabled as the eventual structural fix**, not a v1 feature (ADR-0003 Update; ADR-0004). + +This trust re-centralization is the price of the cross-chain reality. The collar is sole key-holder + in-flight custodian + off-chain meter — and the custody is itself the worst regulatory exposure (likely FinCEN MSB), so v1 minimizes it: keep the collar a non-custodial pass-through riding a hosted facilitator (Coinbase x402, which carries its own KYT/OFAC/licensing) and push in-flight value to a licensed bridge partner — look like a merchant on Stripe, not a money transmitter (`report.md` §4.3, risk register; ADR-0006). The full analysis is in [Regulatory & Compliance Strategy](#regulatory--compliance-strategy). + +### What is v1 vs. deferred + +| Capability | Phase | Status in v1 | +|---|---|---| +| Register Skills as Story IP Assets + declared Derivatives (provenance) | **Phase 0** | **v1** — ships immediately, soundest step | +| Collar as sole key-holder + x402 resource server; Leg 1 gate; off-chain metered ledger; closed modes (intra-org → education); claims **non-transferable** | **Phase 1** | **v1** | +| On-chain batched royalty settlement (Leg 2: bridge/swap → `payRoyaltyOnBehalf` → keeper `claimAllRevenue`) | **Phase 2** | **Deferred** to Phase 2 (the shared-loop "claim" is an off-chain-ledger withdrawal in v1) | +| TEE / confidential execution (hide Skill from host; structural fix for the accumulator) | later | **Deferred — tabled** | +| Open Marketplace + **tradeable** royalty claims (securities: ERC-3643 allow-list + Reg D 506(c)/Reg A+/CF + registered ATS like Securitize + transfer agent + KYC) | **Phase 3** | **Deferred** — counsel-gated; closed-mode claims stay non-transferable | + +The derivative-royalty **mechanic** is available throughout; only the **tradeability** of a claim triggers the securities stack (ADR-0006). Chain split is permanent: **Story for IP / royalty / provenance, Base for the x402 gate.** Do not try to make x402 settle directly to Story (`report.md` §6; ADR-0005). + +### End-to-end sequence (education mode; steps 7–12 are Phase-2-deferred) + +1. **Register (Phase 0, Story).** School registers its base Skill as an IP Asset (`mintAndRegisterIpAssetWithPilTerms` + `commercialRemix`). Student forks it into a **Derivative** they own — a declared derivative with on-chain ancestry (ADR-0002; CONTEXT.md line 42). +2. **Host (Phase 1).** Collar holds the only Anthropic key; the Derivative lives on the persisted Agent object; the employer can invoke it but cannot read it. +3. **Gate — 402 (Phase 1).** Employer requests an Invocation; collar returns `402` + `PAYMENT-REQUIRED`. +4. **Pay (Phase 1, Leg 1, Base).** Employer signs EIP-3009 `transferWithAuthorization` (gasless USDC on Base). +5. **Settle + credential (Phase 1).** Collar runs `/verify` + `/settle`; the settled **txHash is the single-use execution credential**, checked off-chain. +6. **Run async (Phase 1).** Collar releases the handshake, then invokes the agent asynchronously and **streams only the output** to the employer — never the Skill, never holding the 402 open across the run. +7. **Meter (Phase 1).** Collar records the settled invocation in the auditable off-chain ledger (the accumulator); the split is **credited**, withdrawable off-chain. +8. **Batch (Phase 2).** Settlement worker batches accrued payments per threshold/interval. +9. **Bridge/swap (Phase 2).** USDC(Base) → WIP(Story) via the licensed bridge. +10. **Pay royalties on Story (Phase 2).** Worker calls `payRoyaltyOnBehalf` for the Derivative's IP vault; flow-through (LAP/LRP) credits the school as ancestor. +11. **Keeper claims (Phase 2).** Permissionless keeper calls `claimAllRevenue` for the student and the school. +12. **Publish (Phase 2).** Settlement batch is published on-chain for reconciliation. + +Steps 3–7 are synchronous-gate + off-chain meter (Phase 1, v1). Steps 8–12 are asynchronous, batched, eventually-consistent on-chain settlement (Phase 2). That gap is the whole honesty of the v1 design. + +### Component list + +- **Collar** — sole Anthropic API-key holder; x402 resource server; off-chain meter/ledger; the entire execution gate and the single trusted component. Pays Anthropic per run. +- **x402 facilitator** (Coinbase CDP or equivalent) — `/verify` + `/settle` for Leg-1 USDC-on-Base payments; carries its own KYT/OFAC/licensing. +- **Managed agent runtime** (Anthropic CMA, beta; runtime abstracted so it is swappable) — Wielder-hidden hosted execution; returns output only. Not ZDR/HIPAA-eligible — use a self-hosted sandbox for regulated intra-org/education data. +- **Off-chain ledger** — signed, auditable invocation log; source of truth for batching and reconciliation. +- **Settlement worker (Phase 2)** — batches accrued payments; drives the bridge/swap and `payRoyaltyOnBehalf`. +- **Licensed bridge/swap (Phase 2)** (e.g. Stargate / Across / deBridge) — USDC(Base) → WIP(Story); absorbs in-flight custody to keep the collar a pass-through. +- **Permissionless keeper (Phase 2)** — auto-claims `claimAllRevenue` for ancestors. +- **Story Protocol contracts** — IP Asset registry, PIL/License module, Royalty Module (LAP/LRP), fractional co-holdable royalty tokens. +- **(Phase 3 only) Securities stack** — ERC-3643 allow-list token, registered ATS, transfer agent, KYC — required only when royalty claims become tradeable. + +--- + +## Economic Design + +The protocol moves real money on every **Invocation**, so the economics decide whether the loop is viable at all. The settlement topology is fixed by the feasibility verdict: a **decoupled two-leg flow** (ADR-0005). Leg 1 gates execution with x402 USDC on Base; Leg 2 (Phase 2) batches, bridges/swaps to WIP, and settles royalties on Story. Every parameter below is shaped by that topology and by one hard fact: **per-invocation on-chain settlement is fee-dominated, so batching is mandatory and a price floor is non-negotiable.** + +The prototype at `prototype/settlement-engine.mjs` is the reference implementation of the split arithmetic; the numbers here are derived from it and from the per-hop fee schedule in `docs/feasibility/findings.json`. **Several inputs are unmeasured (`report.md` §7.4) — the models below are illustrative, not validated.** + +### Who bears the inference cost + +This is a load-bearing economic fact that must be stated plainly: **the collar is the sole Anthropic API-key holder, so the collar — not the Wielder — pays Anthropic for every run.** CMA runtime billing is **$0.08 per *active* session-hour, measured to the millisecond** (idle/rescheduling free), **plus standard per-token model costs (ITPM/OTPM) on top** (`findings.json` `/validated[managed-agents]/howItWorks` (c), `/evidence` note: "~$0.027 for a 20-min active run + tokens"). A short invocation's runtime cost is small, but the **per-token model cost is the dominant and variable COGS**, and for a verbose Skill it can be non-trivial relative to a $2–25 price. + +The unit economics therefore have **three distinct cost layers the Wielder's USDC must cover**, in order: + +``` +Wielder USDC price ≥ inference COGS (CMA runtime + model tokens, paid by collar to Anthropic) + + settlement cost (Leg-1 facilitator/gas; Leg-2 bridge/swap/gas, amortized) + + protocol fee (the 2.5% feeBps skim) + + net royalty to Creator + ancestors +``` + +The 2.5% protocol fee is computed **on the price**; the inference cost is a **separate COGS the collar funds** and is *not* covered by the fee. **If a Skill is verbose and cheap, the collar can lose money on the run even while the fee looks healthy** — the fee is a percentage of price, but inference is a near-fixed dollar cost per call. The collar must therefore either (a) meter and pass through inference cost as a line item on top of the Creator's price, or (b) enforce a price floor high enough that inference COGS + settlement + fee + royalty all clear. **v1 recommendation: pass inference COGS through transparently** (Wielder pays price + metered inference), so the collar never eats inference and the Creator's price is purely "value of the Skill." The exact pass-through model is an open economic spike (the per-token cost is a function of the Skill's verbosity, which the Creator controls). Until it is modeled against real Skill token profiles, **the business unit economics are undefined** — see [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria). + +### Pricing: creator-set, above an enforced floor + +Price is **set by the Creator** per Skill (`registerSkill({ price })`), denominated in USDC (the Leg-1 currency the Wielder actually pays). Creators know their Skill's value; the protocol does not. + +But the Creator cannot price arbitrarily low, because every Invocation carries a **stack of per-hop fees** that does not shrink with price: + +| Leg | Cost component | Incidence | +|---|---|---| +| Leg 1 (Base) | CDP facilitator fee ($0.001/tx after 1k/mo free tier) + Base settle gas | **per invocation** (unavoidable) | +| Leg 2 (Story, Phase 2) | bridge fee + USDC→WIP swap slippage (~0.5%) + Story gas + `claimAllRevenue` gas | **per batch** (amortizable) | + +Source for the fee components: `findings.json` `/verdict/risks[5]`, `/composition/gaps[6]`. **The $0.001/tx facilitator fee and ~0.5% swap slippage are sourced; the real USDC→WIP bridge cost and confirmation time on Story mainnet are UNMEASURED (`report.md` §7.4).** + +> **Illustrative model (inputs partially unmeasured).** Assuming an avg $5 invocation and slippage at 0.5% of batch notional, the amortized Leg-2 settlement cost per invocation falls as batch size grows: + +| Batch size N | Leg-2 cost amortized / inv | Total settlement cost / inv | +|---|---|---| +| 1 (no batching) | $0.3650 | **$0.368** | +| 10 | $0.0590 | $0.062 | +| 100 | $0.0284 | $0.031 | +| 1000 | $0.0253 | $0.028 | + +> This table is a **model built on partially-unmeasured inputs** (real bridge cost/latency is unspiked, `report.md` §7.4) — treat the numbers as directional, not as committed figures. + +**Two conclusions:** + +1. **Batching is structural, not an optimization.** At N=1 the per-hop fees would consume a cents-level micro-royalty entirely — negative unit economics. Only per-batch Leg-2 costs amortize; the Leg-1 facilitator fee is the irreducible floor. This is *why* ADR-0005 batches Leg 2. +2. **The price floor is the amortized settlement cost, marked up.** With aggressive batching the *settlement* floor sits near the low-cents range per invocation in the model above; **but the binding floor in practice is inference COGS** (see above), which a sub-dollar Skill cannot clear. **Single, coherent policy on the floor (resolving the prior contradiction):** + - **Sub-floor Skills (priced below the live amortized settlement + inference floor) are accepted but stay in the Phase-1 off-chain-metered ledger** — credited and withdrawable off-chain — and are **not** promoted to on-chain Leg-2 settlement until aggregation pulls their effective cost under the floor. They are not rejected. + - **Skills priced above the floor settle on-chain in Phase 2.** + This replaces the earlier muddle ("reject sub-floor" vs. "sub-dollar lives off-chain") with one rule: **below the floor → off-chain ledger; above the floor → on-chain settlement.** A `$2–3` figure was cited previously as a "soft floor"; that number was an artifact of the illustrative table and is **not** a validated threshold — the real floor is computed live from measured inference COGS + amortized settlement cost, once spiked. + +This floor compounds with the cloning economics (ADR-0004): price *below amortized clone cost* but *above amortized (settlement + inference) cost*. That window is the viable pricing band — **but "amortized clone cost" is itself unmeasured** (no source quantifies clone-distill economics or required evolution cadence, `report.md` §7.7), so "price below amortized clone cost" is a *direction*, not yet an actionable number. See [the clone-cost gap](#what-we-have-not-validated). + +### Protocol fee + +A flat **protocol fee in basis points, skimmed off the top before any royalty split** (`feeBps`, default **250 = 2.5%** in the engine). It is taken from the gross Invocation price into the treasury; the **net** flows down the ancestry: + +``` +fee = price × feeBps/10000 → treasury +net = price − fee → distributed to royalty holders + ancestors +``` + +2.5% is the prototype's default, not a benchmarked rate. It is *lower* than typical app-store take rates (Apple/Google 15–30%) and near card-processing rates (Stripe ~3%), which signals "infrastructure, not rent-extractor" — but **we have not shown that 2.5% covers protocol opex at closed-mode volumes.** Critically, the protocol fee is computed on *price* and does **not** cover the collar's inference COGS (which is passed through separately, above). At low volumes, 2.5% of a handful of $2–25 invocations may be a **floor that fails to cover opex, not a ceiling to negotiate down from.** The honest stance: **treat 2.5% as a starting default to validate against real opex, not a positioned competitive rate.** The prototype lets you crank it (`fee 4000`) to probe where the model feels extractive; the answer is that every basis point of fee competes with the royalty and the settlement floor for the same net. Whether 2.5% is sustainable is a Phase-1 unit-economics question, not a settled benchmark. + +### Derivative flow-through and the inherit-bps fork-incentive threshold + +A **Derivative** carries an `inheritBps` — the fraction of its *net* (post-fee) revenue that flows **up** to its parent(s) on every Invocation, recursing through the whole ancestry (`distribute()` in the engine; mirrors Story's LAP/LRP on-chain). The forker keeps `net × (1 − inheritBps)`; the ancestry collectively gets `net × inheritBps`. + +The question the prototype was built to answer (`prototype/README.md` experiment 5) is the **fork-killing threshold**: at what ancestor cut does forking stop being worth it? **This is an OPEN question. The prototype's verdict is explicitly TBD** (`prototype/README.md` NOTES), and the report (§7) and the Phase-1 roadmap spike both list it as unresolved. The following is a **hypothesis to test, not a settled answer.** + +> **Hypothesis (to validate against the engine and real fork behavior).** A first-pass model *suggests* a candidate neutral point at **`i* = p_parent / p_fork`** — the ancestor's price as a share of the fork's price — **under a "recoup-own-uplift" assumption.** The reasoning: a forker adds uplift `U = p_fork − p_parent`; flow-through taxes `inheritBps` of the *whole* fork price including the forker's own added value; the forker recoups exactly their own uplift when `net × (1 − i) = U × (1 − fee)`, which solves to `i* = 1 − U/p_fork = p_parent / p_fork`. + +**The "recoup-own-uplift" premise is one defensible modeling choice, not a derived truth, and it is asserted rather than justified.** It deliberately **ignores** at least three things a real forker weighs: (1) the forker's **ongoing maintenance cost**, (2) the **option value of the parent's live evolution** (a benefit of *not* hiding ancestry), and (3) **demand elasticity** (whether the fork's price even holds at volume). A sophisticated reader should treat `i* = p_parent/p_fork` as a starting hypothesis whose only honest status today is "to be tested in `prototype/` experiment 5 and against observed fork rates." It is **not** a "clean, defensible answer." + +Directionally, the model implies: below `i*`, forking is attractive and ancestors still earn; well above it, the rational move is to author fresh and never declare the parent — incentivizing the ancestry-hiding the graph exists to capture. Worked from the seed (`finmod` $5 → `biofin` $25), the hypothesis would put the neutral point at `i* = 5/25 = 2000 bps`. **The engine's seeded default fork `inheritBps` is 3000 (30%) — above that hypothesized neutral point. 30% is a fine *teaching seed* but should NOT be read as the recommended protocol default**; the GTM metrics track 3000 only because it is the prototype default to measure against, not because it is endorsed. + +**Practical stance until the spike resolves:** rather than ship a flat protocol constant, **anchor the suggested `inheritBps` at the price-ratio hypothesis and let the Creator tune it with a live "student keeps vs. flows up" preview at fork time.** Guardrails: floor the suggestion at Story's 1% granularity (`findings.json` `/verdict/confirmed[4]`); flag anything well above the price ratio as "may discourage forking." The default is *not finalized* — the Phase-1 spike sets it. + +**Deep chains compound the danger.** `inheritBps` applies at *every* hop, so a uniform rate `i` leaves the root creator `i^depth` of net: + +| Depth | i=30% | i=50% | i=70% | +|---|---|---|---| +| 1 | 30.0% | 50.0% | 70.0% | +| 2 | 9.0% | 25.0% | 49.0% | +| 3 | 2.7% | 12.5% | 34.3% | + +At 30% inherit, the original Creator three forks deep gets **2.7% — dust** (prototype experiment 4). **There is no single rate fair at every depth** — an inherent tension of multiplicative flow-through, and the strongest argument for a per-fork, price-ratio-anchored, *tunable* default rather than a flat constant. For Education, bias the school's base-Skill terms toward the higher end of the band, accepting that deep re-forks dilute the school's cut — the correct incentive (rewarding active improvement over rent on a stale base). **All of this is provisional pending the Phase-1 economics spike.** + +### Co-held splits (employee/employer, student/school) + +A **Royalty claim is co-holdable**: the engine models it as `royalty: [{partyId, bps}]` summing to 10000; on-chain this is Story's 100 fractional royalty tokens distributed across wallets (1% granularity, `findings.json` `/verdict/confirmed[4]`). This replaces work-for-hire's 100/0: + +- **Intra-org** (`recon`): employee-Creator and employer co-hold, e.g. **50/50** (`sam` 5000 / `megacorp` 5000). Both earn from every *external* Invocation. The prototype's experiment 7 probes "where's the split an employer would actually sign." There is no protocol-mandated ratio; it is a *negotiated* term. A reasonable seed is 50/50. +- **Education** (`finmod` → `biofin`): the student owns their **Derivative outright** (`mia` 100% of `biofin`'s own claim), and the school earns as the **ancestor via flow-through**, not as a co-holder of the student's claim. The student "graduates with the asset"; the school's return rides the `inheritBps` on the base Skill. + +**Critical constraint binding economics to the legal design (ADR-0006):** in Intra-org and Education, **co-held and forked claims must be NON-TRANSFERABLE.** Non-transferability is the **best available route** to keeping them outside securities treatment (structured as contractual / deferred-comp / license-fee rights, `findings.json` `/verdict/confirmed[7]`) — **but at medium confidence, not as a guaranteed safe harbor** (see Regulatory). The split *mechanics* are identical to the tradeable case; only transferability differs. + +### WIP/FX exposure and mitigation + +Royalties settle on Story in **WIP ($IP), the only mainnet-whitelisted royalty currency** — down ~97.5% from ATH and thinly liquid (`findings.json`, ADR-0002 Update). This injects FX/liquidity risk into the *settlement* leg even though the Wielder paid stable USDC: + +- **The conversion happens inside Leg 2.** The USDC→WIP swap is folded into the bridge step, so the **Wielder and Beneficiary never touch WIP** — they pay USDC. An enterprise forced into involuntary $IP exposure is a dead deal. +- **The exposure window is between accrual and claim**, borne by the royalty holder whose WIP balance swings with $IP price. +- **Mitigation = fast-claim + hedge.** Run the keeper to `claimAllRevenue` promptly; convert claimed WIP back to USDC/stable on a tight cadence; for larger ancestors (a school), consider hedging. Size the batching window to balance fee amortization (wants larger batches) against FX exposure (wants faster settlement) — the same tension that sets the price floor. + +### No native protocol token in v1 + +**Recommendation: do NOT introduce a native protocol token in v1.** The protocol already has the assets it needs, and they are better than a fresh token: + +- **Unit of account is USDC** (what Wielders pay) — stable, liquid, what enterprises transact in. +- **The royalty/ownership asset is Story's native IP-Asset royalty tokens** — co-holdable, fractional, with built-in derivative flow-through. A protocol token would duplicate this with something strictly worse. +- **Settlement value is WIP**, already an FX headache to minimize — a second volatile native asset multiplies the FX surface for zero benefit. + +A native token would add securities burden (a freely-traded value-accruing token is a textbook Howey security — the exact trap ADR-0006 routes around), bootstrapping burden (liquidity, market-making, distribution), and fills **no mechanism gap** (fee capture works as a USDC skim; Story provides chain security; Base provides the gate). **The moat is the marketplace, provenance graph, and declared-derivative royalties (ADR-0004) — not a token.** If a protocol-level incentive asset is ever warranted, it belongs no earlier than the open Marketplace phase, evaluated then. + +### Deferred to Phase 3 + +All economics of a **tradeable royalty-claim secondary market** — price discovery, AMM vs. order-book, liquidity incentives — are **out of scope for v1** and deferred to Phase 3 (ADR-0006), where they arrive *permissioned*: ERC-3643 allow-list, Reg D 506(c)/Reg A+/CF exemption, SEC-registered ATS (e.g. Securitize) + transfer agent + KYC. Until then, claims are non-transferable and the only money that moves is per-Invocation USDC. Engage securities counsel before designing that market. + +--- + +## Regulatory & Compliance Strategy + +The regulatory verdict is **works-with-caveats at *medium* confidence: no fatal blocker, but the headline feature is the heaviest constraint, and no source squarely analyzes our exact fact pattern.** Two distinct regimes apply and must be analyzed separately — **securities law** (the tradeable royalty claim) and **money-transmission/AML** (the x402 settlement collar) — each with a different trigger and mitigation. The most important framing: **the derivative-royalty *mechanic* is fine; only the *tradeability* of a claim, and only *custody* of in-flight funds, trigger the expensive regimes** — and both are avoidable in the closed modes. **Because regulatory confidence is only medium, treat every "outside securities law" statement below as the best available route, not a settled safe harbor, and get counsel to bless the specific structures BEFORE Phase 1 ships.** + +### Securities posture — the heaviest weight, but mode-dependent + +A **tradeable, fractional royalty claim is almost certainly a security** under *SEC v. W.J. Howey*: (1) investment of money, (2) a common enterprise (a pooled, fractionalized claim on a revenue stream the platform and Creator operate), (3) an expectation of profit, (4) **from the essential efforts of others** — the Creator who maintains/evolves the Skill and the platform that hosts, meters, and settles. The Ninth Circuit's *SEC v. Barry* (2025) is on point: fractional interests in a cash-flow stream were securities *because* investors depended on a manager's ongoing efforts ([Skadden, "Howey's Still Here," Aug 2025](https://www.skadden.com/insights/publications/2025/08/howeys-still-here)). **Uncomfortable corollary: ADR-0004's live-evolution/network-moat thesis — the core competitive strategy — *strengthens* the "efforts of others" prong, not weakens it.** + +The 2026 SEC thaw does **not** rescue these tokens. The March 17, 2026 crypto interpretation carves out only Digital Commodities, Digital Collectibles, and Digital Tools (non-transferable membership/credential/ticket), and states that "all devices and instruments that have the economic characteristics of a security are securities regardless of format or label" ([WilmerHale, Mar 2026](https://www.wilmerhale.com/en/insights/client-alerts/20260324-the-secs-new-framework-for-crypto-assets-under-howey)). A transferable token whose purpose is to pay holders a share of recurring revenue fits none of the three buckets. Putting the claim on Story does not change the analysis (Jan 2026 Corp Fin statement: "the technological format … does not alter its legal characterization"). Two cautions: the March interpretation is *non-binding staff guidance*, rescindable without APA rulemaking; and a globally-traded claim is a MiFID II "financial instrument" in the EU, triggering the Prospectus Regulation, licensed intermediaries, and MAR/CSDR. + +**The best available route outside securities treatment is non-transferability — but it is not a guaranteed safe harbor, and the confidence is medium.** Be precise about what it does and does not buy you: non-transferability **defeats the secondary-market / "investment" narrative** (no resale, no liquidity event, no speculative buyer). It does **not**, on its own, defeat the **"efforts of others" prong**, which is satisfied by the platform's ongoing metering and evolution regardless of whether the claim can be transferred. The plausible — but **not certain** — conclusion is that a non-transferable, purely contractual revenue right, with no resale and structured as deferred comp / a license fee, sits outside securities treatment. **No regulatory source squarely analyzes the per-invocation, agent-to-agent collar fact pattern**, so this is an extrapolation. Counsel must bless the specific deferred-comp / license-fee structure **before Phase 1 ships** — this is a gate, not mere overhead. + +- **Intra-org:** structure the employee+employer co-hold as **non-transferable deferred-compensation / contractual rights.** **But deferred comp is itself heavily regulated:** a co-held royalty claim that pays out over time implicates **IRC §409A** (deferred-compensation rules) and **constructive-receipt** doctrine — i.e., when the employee is taxed, and whether the structure triggers 409A penalties. The PRD's earlier "like deferred comp" framing is correct in spirit but understates that deferred comp is a regulated structure of its own. Counsel must address 409A / constructive receipt for the employee, not just Howey. +- **Education:** structure the student's Derivative-owned claim and the school flow-through as **non-transferable contractual license/royalty splits.** It becomes a security the *moment* those claims are tradeable — so do not make them tradeable in v1. + +### Money-transmission / MSB — custody is the dividing line + +The payment leg is **manageable, but the design of the collar is determinative.** Exposure turns entirely on **custody**: + +- **Non-custodial pass-through riding a hosted facilitator** (Coinbase's x402 facilitator, carrying its own KYT/OFAC/state+federal licensing) → the platform looks like a **merchant using Stripe/PayPal, not a money transmitter, "absent unusual facts"** ([Braumiller/Mondaq, Dec 2025](https://www.braumillerlaw.com/activating-http-402-the-x402-protocol-and-legal-framework-for-internet-native-stablecoin-payments/)). This is the target posture. +- **Custodial collar** — omnibus wallets, fiat↔crypto conversion, or routing third-party payments as a business → "almost certainly" **money transmission requiring FinCEN MSB registration + multi-state money-transmitter licenses + a BSA/AML program** (12–24 months, multi-hundred-thousand-dollar). + +The sharpest tension in the design: **the cross-chain two-leg settlement (ADR-0005) forces *someone* to hold value in-flight** (USDC on Base before WIP on Story), and in-flight holding is the exact MSB fact pattern. Mitigations, in order: (1) minimize custody — settle splits via smart contract / facilitator / issuer; (2) push unavoidable in-flight value to a **licensed bridge / facilitator / BaaS partner**; (3) keep the collar a non-custodial pass-through so the merchant-on-Stripe analogy holds. One clean adjacency: the **execution credential** is a *non-financial access token*, adding no money-transmission exposure **as long as it is never tradeable or redeemable for value** — which the design intends. + +**Honest caveat (this is why confidence is medium):** no regulatory source squarely analyzes the per-invocation, agent-to-agent collar fact pattern; the merchant-vs-MSB conclusion is extrapolated from custody doctrine. A custodial collar *would* block launch; a non-custodial collar on a licensed facilitator is **manageable overhead**. Get counsel to bless the specific architecture before Phase 2 moves real money. + +### KYC/AML and the GENIUS Act allocation + +KYC/AML enters through **two doors**: + +**Payment side — obligations fall on issuers, not merchants.** The GENIUS Act (enacted July 18, 2025; ~3-year transition to ~July 2028) regulates **payment-stablecoin *issuers***; AML/BSA/CIP/SAR/sanctions obligations land on the **issuer** (and any MSB-classified intermediary) — **not on a payer/payee for merely using a compliant stablecoin like USDC** ([Paul Hastings GENIUS Act guide](https://www.paulhastings.com/insights/crypto-policy-tracker/the-genius-act-a-comprehensive-guide-to-us-stablecoin-regulation)). The FinCEN proposed AML/CFT & sanctions rule (Fed. Reg., Apr 10, 2026) likewise targets *issuers* (exact covered-persons wording not directly verified — treat as such). **Net: a non-custodial collar pushes nearly all payment-side KYC/AML onto Coinbase and the issuer** — the single biggest reason the payment leg is not a blocker. + +**Securities side — KYC re-enters because the claims are securities.** Once you reach tradeable claims: Reg D 506(c) requires accredited-investor verification; a transfer agent must hold each holder's real-world name and address (a wallet alone is insufficient); any registered ATS/broker-dealer runs full BSA/AML/CIP. The mechanism is an **allow-list token (ERC-3643 / BlackRock BUIDL model)**: a transfer cannot execute unless the recipient is pre-KYC'd and whitelisted ([Skadden, "Tokenized Securities," Apr 2026](https://www.skadden.com/insights/publications/2026/04/tokenized-securities)). This is why **tradeable claims are *permissioned*, never permissionless.** + +### Phased compliance path + +| Phase | What ships | Securities | MSB/AML | Launch gate? | +|---|---|---|---|---| +| **0 — Provenance** | Register Skills as IP Assets + Derivatives | None (no claim sold) | None (no money moves) | **No** — ships immediately | +| **1 — Intra-org → Education** | Gate + run + off-chain ledger; claims **non-transferable** | **Best route outside securities law (medium confidence)** — counsel must bless deferred-comp / license-fee structure incl. 409A **before launch** | Non-custodial collar on hosted facilitator → merchant-not-MSB | **Yes (soft):** counsel sign-off on the structure is a gate, not just overhead | +| **2 — On-chain batched settlement** | Two-leg USDC(Base)→WIP(Story), keeper auto-claim | Still non-transferable | **The real custody decision** — push in-flight value to a licensed partner; counsel blesses | Custody design is the gate | +| **3 — Open Marketplace + tradeable claims** | Permissioned tradeable claims | **Full securities stack** | Securities-side BSA/AML via ATS/broker-dealer | **Yes — securities counsel before this phase, non-negotiable** | + +For **Phase 3**, the stack is well-trodden ([Skadden roundups](https://www.skadden.com/insights/publications/2026/04/tokenized-securities)): issuance via Reg D 506(c) (accredited, ERC-3643/BUIDL allow-list) or Reg A+ (up to $75M/yr, retail, ATS-tradeable) or Reg CF; secondary trading **only on an SEC-registered ATS** (e.g. Securitize, FINRA-approved May 2026) + a transfer agent. Reg S can reach non-US holders but has flowback problems; tokenization removes neither holding periods nor accredited-investor requirements. + +### Bottom line for the build decision + +**Nothing in the regulatory analysis hard-blocks Phases 0–1, but Phase 1 has a soft gate: counsel must bless the non-transferable deferred-comp / license-fee structure (including 409A) before it ships.** Both expensive regimes are *opt-in* — securities law via tradeability, MSB law via custody — and the recommended path defers both. The single hard gate is **Phase 3**, which requires the full permissioned securities stack and counsel engaged *before* you build it. The strategic implication aligns with the rest of the document: **build and launch the closed modes first** — they are where cloning pressure is lowest, incentives most aligned, and the law lets you move fastest, *provided counsel signs off on the closed-mode claim structure first.* + +--- + +## Competitive Landscape & Moat + +### Where we sit + +We are not competing with the chains we build on. The Skill Asset Protocol is an **application layer** composing existing primitives — Story for IP/royalty/provenance, Base for the x402 gate, Anthropic Managed Agents (CMA) for **Wielder-hidden hosted execution** (hidden from the Wielder, not from the host) — into one product aimed at **monetizing the long-term value of authored Skills**. Nobody else assembles exactly this stack, but several projects occupy adjacent territory, and the closest threats are the platforms we build *on* adding the layer we supply. + +| Project | What it does | Where we differ / the threat | +|---|---|---| +| **Story Protocol** (chainId 1514) | On-chain IP registry: IP Assets, PIL terms, co-holdable fractional royalty tokens, declared-derivative flow-through. | We **build on it** — Story is the ledger of *who owns what and who owes whom*; it has no execution gate, no Wielder-hidden runtime, no per-invocation meter. **Threat (platform-disintermediation):** Story could ship its own monetization/hosting/licensing UX and absorb the metering+gate layer we add. The platform our entire IP/royalty layer depends on is also the most capable disintermediator. Our hedge is the gate + collar + Skill-specific product and closed-mode GTM, none of which Story does today — but this is a real strategic dependency, not a moat. | +| **Agent-payment incumbents** (Skyfire, Payman, Nevermined/Catena, Coinbase's own x402 tooling) | Per-call/agent-to-agent payment rails, metering, and (some) settlement for AI agents. | These are the **nearest adjacents that could bolt royalty + provenance onto an existing rail.** Coinbase already owns the x402 facilitator we depend on; Skyfire/Payman/Nevermined already do agent metering and could add a Story-style royalty graph. **Honest defensibility:** every primitive is open, so the defensible thing is the *assembly* + the *closed-mode wedge* + the accumulated provenance/derivative graph — not a technical monopoly. We must out-execute on the Skill-specific product and lock in the graph before an incumbent generalizes into it. | +| **Virtuals Protocol** | Launchpad/marketplace for tokenized *autonomous agents* — bonding-curve speculation. | We tokenize the **royalty stream of an authored Skill**, not a speculative agent token. Our unit is a metered Invocation-right for human-directed work, settled per use — not a coin priced on sentiment. | +| **Olas / Autonolas** | Registry + staking for composable *autonomous agent services*. | Olas rewards agents for *running services autonomously*; we reward a **Creator each time a human Wielder invokes their Skill**, with declared-derivative flow-through. Different value event. | +| **Bittensor** | Incentive market for *machine intelligence*: subnets pay miners in TAO for scored model outputs. | Bittensor pays for *inference quality* in a competitive subnet; we pay the **author of a specific reusable Skill artifact** with provenance and a derivative graph. No shared base model, no peer-scoring. | +| **Sahara AI** | Provenance + revenue-share marketplace for AI *data and model assets*. | Closest in spirit, but the asset class is **data/models**, not executable authored Skills, and there is no hidden hosted *execution* gate — value flows from selling/licensing the asset, not metering each Wielder-hidden invocation. | +| **Managed-agent platforms** (Anthropic CMA, OpenAI, Google) | Host agents/Skills server-side; the Wielder gets the output, not the Skill. CMA keeps the Skill off the session-output stream — **hidden from the Wielder, not from the host** (the host processes it in plaintext; `GET /v1/agents` echoes it to the key-holder). | **Infrastructure we consume, not competitors.** None ships a native "no credential, no run" gate, a per-invocation meter, or a royalty/provenance layer (the gate is 100% our collar). We add the economic and ownership layer they omit. | + +### The combination we ship + +No single project combines all four — and the combination, not any one piece, is the product: + +1. **Wielder-hidden hosted execution** — the Wielder receives only the output, never the Skill (ADR-0001, validated against CMA). *Hidden from the Wielder, not from the host* — the host (Anthropic) sees it in plaintext (ADR-0004). +2. **Per-invocation metering** with a payment-gated execution credential (x402 on Base; "no credential, no run"). +3. **On-chain composable royalty** with declared-derivative flow-through (Story), co-holdable for the intra-org and education modes. +4. Focused specifically on **authored Skills as a monetizable asset class**, launched **closed-modes-first**. + +That is a genuinely novel assembly. It is **not** a defensible *technical* monopoly — every primitive is open and reusable, and the two nearest threats (Story itself; agent-payment incumbents) could each generalize into it. The defensibility argument is therefore narrower than the pitch-deck version. + +### The honest moat analysis + +**The moat defends the marketplace, not a breakout Skill** (ADR-0004). Provenance (the Story IP Asset), the derivative-royalty graph, reputation/routing, and live evolution are real and compounding — but what they protect is the *network*: liquidity, trustworthy attribution, the fork-and-royalty ecosystem, routing of invocations to proven Creators. They do **not** protect an individual high-value Skill from being reconstructed. + +**Off-platform behavioral cloning is when-not-if, not a risk to be "solved."** ADR-0001 hands the Wielder the output, and for most Skills the output *is* the value. A high-volume Skill is the *cheapest* thing to clone — its own paid I/O pairs are a ~30x-cheaper distillation set — and v1 (no TEE; host sees plaintext) cannot prevent this, only out-evolve it. Watermarking is a forensic tripwire removed by cheap paraphrase, not a moat (OWASP LLM07:2025: the system prompt is not a security control). The defense is **economic and operational**: price below amortized clone cost, ship live updates faster than distill-and-redeploy, and bind value to things an output stream cannot carry — live tool/data access and fresh private context. **Honest caveat: the efficacy of live-evolution as an anti-clone defense is *asserted by analogy and unmeasured* (`report.md` §7.7) — no source quantifies how fast a Skill must change to keep a clone stale.** Provenance gives clones the status of *orphans* (no lineage, no marketplace trust, no derivative royalties), protecting the marketplace's integrity even when it cannot protect a single asset. + +**Launching closed modes first is itself a moat-timing advantage.** Intra-org and education are closed populations with aligned incentives, on-platform by construction, lowest cloning pressure (ADR-0006). Starting there is both the regulatory-safe path and a strategic sequencing of where the moat is weakest vs. strongest — and it buys time to *measure* the unmeasured live-evolution and clone-cost assumptions before facing the open market where cloning is cheapest. + +**What we are not claiming.** Not that a Skill is unclonable, that watermarking deters, that the host cannot see the Skill, or that the royalty graph can be open and permissionlessly tradeable without becoming a regulated securities venue. The defensible position: a focused, novel *assembly* of real primitives; a *marketplace*-level network moat that compounds with provenance and liquidity; a sequencing discipline that meets cloning pressure where it is weakest first — held against the live risk that a platform we depend on, or an agent-payment incumbent, generalizes into the same assembly. + +--- + +## Go-to-Market & Rollout + +> **The wedge is Intra-org — as an assumption to validate, not an established market.** Lead with a company converting its internal Skills into co-owned revenue assets. Education is the second motion, seeded *inside* the intra-org beachhead. **The willingness of employers to co-hold royalty claims is UNVALIDATED (R12, Medium-High); securing design-partner LOIs is an explicit Phase-0/1 gate, not a backdrop.** + +### Why Intra-org wins the wedge (and Education does not, yet) + +Both closed modes are the right *place* to start — closed populations, aligned incentives, on-platform, lowest cloning pressure, and claims structurable as **non-transferable** rights that are the best route outside securities law (ADR-0006; report §6). The question is which closed mode is the sharpest *initial* wedge. Intra-org wins on five counts: + +1. **One signature unlocks the whole loop.** Intra-org has a single decision-maker (the employer) who is simultaneously the **Beneficiary** (funds settlement), the **co-holder** of the claim, and the employer of the **Creator**. Education needs three independent parties (school, student, a different employer) — a three-sided cold start. +2. **The pain is acute, named, and on the buyer's desk.** From the employer's side, "why won't my best people leave the moment they've built the automation?" is a live 2026 retention problem. Intra-org reframes work-for-hire's 100/0 as a co-held claim where the employee keeps upside from *external* invocations. +3. **It exercises the riskiest machinery without the riskiest exposure.** External invocations force you to build and harden the real gate + meter + Leg-1 settlement (ADR-0005) — with claims non-transferable, so no securities stack. You stress the hard parts inside the safest regulatory envelope. +4. **Lower cloning pressure, by construction.** Intra-org Skills bind value to *fresh private context and live internal tool/data access* — exactly the recommended anti-clone posture (ADR-0004; report §5). +5. **It is the natural distribution channel for Education.** Land the employer, prove the co-held claim pays, and the *same* employer becomes the Beneficiary in an Education deal. + +**Decision: ship Intra-org first. Education is Phase-1b, sold into the same accounts.** Both rest on the unvalidated willingness-to-co-hold assumption (R12). + +### Ideal first customer profile (an assumption to test) + +A **mid-size, AI-forward services or product firm (roughly 100–800 people) where authored Skills are the work product, internal mobility/retention is a board-level concern, and at least one team already ships Claude Code skills/plugins/agents internally.** **This profile and the firm-size band are a hypothesis, not validated market structure — there is no evidence yet that such firms will restructure work-for-hire IP terms (R12).** Concretely: + +- **Buyer:** VP Eng / Head of Platform co-sponsored by Head of People/Talent; CFO is a stakeholder. +- **Pre-conditions that de-risk the build:** already an Anthropic API customer (CMA is beta, 300 create-req/min/org, not ZDR/HIPAA — non-regulated data first); comfortable funding settlement; willing to start with **non-transferable** co-held claims. +- **Avoid (for now):** regulated-data shops (CMA gap), Fortune-500 procurement, anyone needing tradeable claims day one, and firms whose Skills are static prompt cleverness (the most cloneable category, ADR-0004). +- **Validation gate:** signed design-partner LOIs confirming willingness-to-co-hold **before** committing to the Phase-1 build (see kill-criteria). + +### Distribution and pricing/packaging + +**Distribution — land via provenance, expand via the meter.** Phase 0 (Provenance) is a no-commitment top of funnel: any team can register Skills as Story IP Assets with declared ancestry today, for free, getting a tamper-proof provenance graph regardless of how settlement evolves. Expansion happens when a registered Skill takes external Invocations and the co-held claim begins paying. + +**Packaging — three priced layers mapped to the phases:** + +| Layer | What the customer gets | Phase | Monetization | +|---|---|---|---| +| **Provenance** | Register Skills + Derivatives on Story; on-chain ancestry; the moat substrate | 0 | Free / per-registration at-cost | +| **Gated runtime + meter** | Collar as sole key-holder; x402 gate (Leg 1); off-chain auditable ledger; co-held **non-transferable** claims; output-only to Wielders; **inference COGS passed through** | 1 | **Protocol take-rate per Invocation** (prototype default ~2.5%) **+ inference cost pass-through** | +| **On-chain settlement** | Batched Leg-2 USDC(Base)→WIP(Story) + keeper auto-claim; published batches | 2 | Same take-rate; settlement-ops/bridge cost passed through | + +**Pricing guardrails dictated by the architecture** (full unit economics in [Economic Design](#economic-design)): + +- **Per-Invocation price must sit above amortized (settlement + inference) cost.** Batching is mandatory; the collar pays Anthropic per run, so inference COGS is passed through. There is a live computed floor (real bridge cost unmeasured, `report.md` §7.4). +- **Per-Invocation price must also sit *below* amortized clone cost** — *but that figure is unmeasured* (`report.md` §7.7), so this is a direction, not a number. +- **Take-rate, not seat licensing**, computed on price; **inference is a separate pass-through, not covered by the take-rate.** +- **Quote and settle in USDC; never ask the buyer to touch $IP** — fold USDC→WIP into the bridge and fast-claim. + +### Phase-to-GTM mapping (ADR-0006) + +| ADR-0006 phase | GTM motion | Goal | +|---|---|---| +| **Phase 0 — Provenance** | Self-serve, free, viral. "Register your Skills, own your lineage." | Build the derivative graph; top-of-funnel; provenance as trust default. | +| **Phase 1 — Intra-org, then Education** | Founder-led design-partner sales to 3–5 ICP accounts **(target; none signed yet — R12)**. Co-held **non-transferable** claims; gate + run + off-chain meter. Education sold into the same accounts once intra-org pays. | Prove the loop pays Creators from external invocations; harden the collar inside the safest regulatory envelope. | +| **Phase 2 — On-chain batched settlement** | Expand within proven accounts. Turn the off-chain accumulator into on-chain settled, published batches; ride a licensed facilitator/bridge (merchant-on-Stripe). | Make settlement auditable; de-risk MSB exposure. | +| **Phase 3 — Open Marketplace + tradeable claims** | Permissioned launch, counsel-gated. ERC-3643 + Reg D 506(c)/Reg A+/CF + registered ATS + transfer agent + KYC. | Open the composable royalty market only when warranted — highest-cloning, full-securities surface, shipped last. | + +The headline "open composable royalty graph" is the **last** thing shipped (ADR-0006). GTM sequences by *safety*, not ambition. + +### Activation and retention metrics + +Instrument register → fork → invoke → settle → claim, and track both sides of every co-held claim. + +**Activation:** Skills registered (Phase-0 funnel); first external Invocation of a co-held Skill (true activation); time-to-first-royalty-credited; credential issuance rate (settled txHash → run, confirming "no credential, no run"). + +**Engagement / graph:** Invocations per Skill per week, external-vs-internal mix; Derivative forks per base Skill and ancestry depth; **realized inherit-bps vs. fork rate** — if forks collapse as inherit-bps rises you have found the (currently TBD) fork-killing threshold and should cap it. (Track the prototype default 3000 only as the *measurement baseline*, not an endorsed rate.) + +**Monetization / settlement health:** royalties paid (Leg-2 settled, Phase 2) vs. royalties credited (off-chain ledger) — **the gap is the eventual-consistency lag and a tracked reconciliation surface, not a hidden detail** (R14); royalties claimed via keeper (unclaimed ancestor balance is an operational alarm); settlement-batch reconciliation rate; **per-invocation contribution margin = price − inference COGS − settlement cost − royalty** (the metric that tells you whether 2.5% + pass-through actually covers opex); protocol take-rate revenue per account. + +**Retention:** earning Creators (non-zero credited claim in period); Beneficiary net revenue retention; co-held claim survival (both parties earning at 90/180 days); refund / failed-run rate (accept-payment-but-fail-to-run; keep near zero or reputation erodes — and each is a treasury-funded refund since x402 has no chargebacks). + +--- + +## Team, Capital, Timeline & Kill-Criteria + +This section gives the founder/investor-grade numbers the rest of the document implies. **The estimates are planning figures, not commitments; they assume a small senior team and are bounded by the unmeasured items in [What we have NOT validated](#what-we-have-not-validated).** + +### Team & capital, per phase + +| Phase | Core build | Indicative eng-effort | Non-eng spend | What this phase needs funded | +|---|---|---|---|---| +| **0 — Provenance** | Story registration + Derivative declaration + ancestry viewer | ~1–2 eng · ~1–2 months | Story gas (negligible) | A small pre-seed slice; ships on a 2-person team. The cheapest, soundest step. | +| **1 — Gate + run + off-chain meter** (Intra-org → Education) | Collar (sole key-holder, x402 resource server, off-chain signed ledger), pay-first-then-async orchestration, refund path, self-hosted sandbox option | ~2–4 eng · ~3–6 months | **Securities counsel for the non-transferable deferred-comp / 409A structure (a gate)**; design-partner sales | Seed round. The dominant *recurring* cost once live is **Anthropic inference COGS** (collar pays per run) + counsel. | +| **2 — On-chain batched settlement** | Settlement worker, bridge/swap integration, permissionless keeper, reconciliation, fast-claim/hedge | ~2–4 eng · ~3–5 months | MSB/custody counsel; licensed bridge/BaaS partner contracts | Seed-to-A. Custody design is the regulatory gate. | +| **3 — Open Marketplace + tradeable claims** | ERC-3643 allow-list, ATS integration (e.g. Securitize), transfer-agent + KYC wiring, routing/reputation | ~3–6 eng · ~6–9 months | **Securities counsel + exemption filing + ATS/transfer-agent fees (substantial)** | Series A+, and only when closed-mode traction warrants it. | + +**Skills profile:** a backend/protocol engineer (x402 + Story + bridge), an applied-AI engineer (CMA orchestration, anti-extraction wrapper), a product/design hire for the provenance and claim UX, and **early access to securities + money-transmission counsel** (the two non-negotiable advisory lines). Treat counsel as a line item from Phase 1, not Phase 3. + +### Reliability / refund targets + +- **Failed-run-after-settled-payment rate < 0.5%** of paid invocations, with **automatic treasury-funded refund within one settlement cycle** (x402 is irreversible and has no chargebacks, so the collar funds every refund). +- **Gate leak rate (runs without a valid single-use credential) = 0** — a hard invariant, not a target. +- **Credited→on-chain-settled lag (Phase 2)** disclosed to customers and held under a published SLA window; unclaimed ancestor balance alarmed. + +### Kill-criteria (falsifiable go/no-go) + +Stop or restructure if any of these fire — investors should hold us to them: + +1. **No design-partner LOI to co-hold within the Phase-0 window.** If, after the free Provenance funnel runs, **no ICP employer will sign an LOI to co-hold a royalty claim** (restructuring work-for-hire), the Intra-org wedge is invalidated (R12) — do not build Phase 1 on spec. +2. **Cold-start latency makes pay-first-then-async unusable.** If measured `sessions.create` → first-token latency (currently unmeasured, `report.md` §7.3) is so high or variable that the async UX is unacceptable and no pooling fix exists, the gate UX premise fails. +3. **Inference COGS exceeds defensible price.** If, against real Skill token profiles, **inference COGS + settlement cost routinely exceeds what Beneficiaries will pay** (i.e., contribution margin is negative at viable prices), the unit economics do not close. +4. **A breakout closed-mode Skill is cloned within weeks of launch with no economic counter.** If a high-value Skill is behaviorally cloned faster than live-evolution can stay ahead — and the unmeasured evolution-cadence defense (`report.md` §7.7) proves ineffective — even the closed-mode value prop is at risk; re-underwrite before Marketplace. +5. **Counsel cannot bless the non-transferable closed-mode structure.** If securities/409A counsel concludes the closed-mode claim is *not* outside securities treatment (medium-confidence today), the whole "launch closed first" sequencing must be reworked. +6. **Story / $IP existential degradation.** If Story sunsets or $IP liquidity collapses below a usable settlement threshold and no mitigation lands (see R15), the on-chain layer must be re-platformed or the protocol re-scoped. + +--- + +## Risks & Open Questions + +The feasibility study (`docs/feasibility/report.md`, `docs/feasibility/findings.json`) returns **GO-WITH-CAVEATS**: every primitive is real and live, but the idealized atomic loop does not compose, and the buildable version reshapes the risk surface. The single most important framing: **the closed modes dodge or defang most high-severity risks** — cloning pressure lowest, claims non-transferable (best route outside securities law, *medium confidence*), population bounded. + +### Risk register + +| # | Risk | Likelihood | Impact | Mitigation | +|---|---|---|---|---| +| **R1** | **Off-platform behavioral cloning** of a breakout Skill. ADR-0001 hands the Wielder the *output*; thousands of paid I/O pairs are a ~30×-cheaper distillation set. The moat defends the *marketplace*, not an individual Skill. | **High** (when-not-if for any breakout earner) | **High** — undermines the value prop at success; v1 (no TEE) cannot prevent it, only out-evolve it | Manage economically: live evolution (ship faster than distill-and-redeploy); bind value to live tool/data access + fresh context; price below amortized clone cost; anomaly detection; watermark/provenance as forensic backstop. **Caveat: evolution-cadence efficacy is unmeasured (§7.7).** Launch closed modes first. | +| **R2** | **MSB / money-transmitter classification.** The two-leg design makes the collar hold funds in-flight — the FinCEN MSB fact pattern. | **Medium-high** | **High** — 12–24mo, multi-$100K slog gating launch | Minimize/eliminate custody; non-custodial pass-through on a hosted facilitator; push in-flight value to a licensed bridge/BaaS partner; **counsel blesses the specific architecture** (no source squarely analyzes it). | +| **R3** | **Securities classification.** Tradeable royalty claims are securities under Howey; ADR-0004's moat *strengthens* the efforts-of-others prong; the March 2026 SEC interpretation does not carve out revenue-share tokens. | **High** (near-certain for tradeable) | **High for Marketplace; LOW / likely-outside-securities for intra-org/education if non-transferable — but MEDIUM confidence, NOT zero.** Non-transferability defeats the secondary-market/investment narrative, but the efforts-of-others prong is still satisfied by ongoing platform metering/evolution; **counsel sign-off required before Phase 1.** **Sub-risk: 409A / deferred-comp / constructive-receipt** for the employee's co-held claim. | Permissioned stack for Marketplace (ERC-3643 + Reg D/A+/CF + ATS + transfer agent + KYC). Keep closed-mode claims non-transferable; structure as deferred-comp/license-fee **with 409A addressed**; engage counsel before Phase 1, not just Phase 3. | +| **R4** | **Bridge-stall reconciliation** (Phase 2): execution done on Base, ancestors unpaid on Story. | **Medium** | **Medium** — eventually-consistent + reconciliation overhead; not loss-of-funds with sound bookkeeping | Auditable off-chain ledger; on-chain published batches; retry/reconciliation; refund/reputation; conservative batching windows. | +| **R5** | **Trusted-accumulator degradation.** ADR-0003's "fraud structurally impossible" degrades to "auditable accumulator" once batched + cross-chain; the collar could skim/mis-report at settlement. | **High** (structural) | **Medium** — gate stays trust-minimized; settlement trust reintroduced | Explicit in ADR-0003; signed/auditable logs; on-chain batch publication; refund/reputation; TEE as eventual fix. | +| **R6** | **Negative fee economics.** Stacked per-invocation fees can exceed a cents-level micro-royalty. | **High** at literal per-call; **low** once batched | **Medium** — forces batching + price floor | Mandatory batching; price above amortized settlement + inference cost. | +| **R7** | **$IP / WIP volatility + thin liquidity** ($IP ~−97.5% from ATH); WIP-only royalty currency forces involuntary $IP exposure on payers. | **High** (current market) | **Medium** — FX risk + enterprise friction | Fast-claim; hedge; fold USDC→WIP into the bridge; build a fiat/USDC→WIP on-ramp; monitor for USDC whitelisting (Spike 3). | +| **R8** | **Verbatim prompt extraction** via agentic steering. | **Medium** | **Medium** — leaks text, but ADR-0004 abandons secrecy as load-bearing | Taxonomy-aware wrapper cuts extraction ~18% (never eliminates); the moats, not secrecy. Accept residual per ADR-0004. | +| **R9** | **CMA beta churn**; not ZDR/HIPAA-eligible for regulated data. | **Medium** | **Medium** — integration rework; compliance gap | Abstract the runtime behind the collar (swappable host); self-hosted sandbox for regulated data; track release notes. | +| **R10** | **Rate-limit ceiling** (300 create-req/min/org). | **Low** (not a v1 blocker at low volume) | **Low-medium** at scale | Reuse long-lived sessions, queue, or shard. **Unverified:** clean buyer isolation in one long-lived session (Spike 6). | +| **R11** | **Recency risk in load-bearing facts** (a facilitator could add Story 1514; fees/whitelist/License-Token semantics could change). | **Low-medium** | **Low-medium** — could simplify (good) or invalidate (bad) | Re-verify all load-bearing facts immediately before building (Spikes). | + +#### Product / market / adoption risks + +| # | Risk | Likelihood | Impact | Mitigation | +|---|---|---|---|---| +| **R12** | **No-pain-felt adoption / willingness-to-co-hold UNVALIDATED.** Asking an employer to co-hold a royalty claim instead of work-for-hire is a hard sell with no template, and the upside is *external* invocations that may not materialize. | **Medium-high** | **High** — without willing first employers, the recommended wedge has no demand side | Lead with the retention/incentive narrative, not a rights grab. **Validate with design-partner LOIs before building Phase 1 (a kill-criterion).** Education offers a more concrete exchange as the second motion. | +| **R13** | **Wrong-side marketplace cold start** — the open Marketplace needs Creators + Wielders + claim buyers at once, thinnest moat, full securities stack. | **Medium-high** for the open marketplace | **Medium** — a failed marketplace does not kill the closed modes | Sequence it last; bootstrap provenance value first (Phase 0). | +| **R14** | **Value-prop dilution from "eventually consistent."** The buildable reality (credited per invocation, claimable on demand, batched, FX-exposed) is weaker than "automatically pays ancestors every invocation"; sophisticated buyers notice the credited-vs-settled gap. | **Medium** | **Medium** — credibility erosion if oversold | Correct the language (done: "automatically credited, claimable on demand"); **surface the gap as a tracked reconciliation metric, do not hide it**; ship the keeper so ancestor revenue never silently piles up. | +| **R15** | **Single-host + single-IP-chain dependency.** The gate is the collar on CMA; all IP/royalty/provenance is on Story (thin, illiquid). **Story could also disintermediate by adding the monetization/hosting layer itself** (see Competitive Landscape). | **Low-medium** | **High** if it triggers | Keep the runtime swappable behind the collar; treat Story as the IP layer and Base as the gate (do not couple). **Concrete fallback to develop, not just "monitor":** provenance/ancestry is the most portable artifact — design the registration layer so the declared-derivative graph can be mirrored/exported to a more liquid chain or an off-chain notarization if Story sunsets or $IP liquidity collapses; settlement (WIP) is the hardest-coupled piece and would need re-platforming. Track Story health + $IP liquidity against the R15 kill-criterion. | +| **R16** | **Co-authorship unmodeled.** v1 assumes a single Creator at origin; multi-author Skills (CONTEXT.md line 46, flagged open) are unhandled. | **Medium** (likely in Marketplace) | **Medium** — disputes / unclear split for jointly-built Skills | Single-Creator origin in v1; multi-author teams use the co-hold mechanic as a stopgap; design true multi-Creator origin before Marketplace. | + +### Pre-build re-verification spikes + +Run **immediately before committing engineering.** The first four are the priority set (verdict, report §4.3 / §7). + +1. **Benchmark cold `sessions.create` → first-token latency** (unmeasured, §7.3). Sizes the SLA and confirms pay-first-then-async (almost certainly required: x402 `maxTimeoutSeconds` ~60s < a managed-agent run). *Output: latency distribution the async design must absorb.* **Also feeds kill-criterion 2.** +2. **Confirm no x402 facilitator has added Story 1514** ([x402.org/ecosystem](https://www.x402.org/ecosystem)). *Output: confirm two-leg is still mandatory.* +3. **Confirm the CDP fee schedule + Story's WIP-only royalty whitelist** ([Story royalty docs](https://docs.story.foundation/concepts/royalty-module/overview)). USDC whitelisting would remove the swap leg and defang R7. *Output: confirmed unit-economics inputs.* +4. **Decide the credential primitive.** Confirm the x402 settled `txHash` suffices as the off-chain credential and that a Story License Token is *not* needed per call (uneconomic at per-call cadence). *Output: a decision.* + +Secondary spikes (resolve before the relevant phase): + +5. **Inference-COGS / price model** — model per-invocation Anthropic cost (CMA $0.08/active session-hour + per-token ITPM/OTPM) against real Skill token profiles to set the pass-through model and confirm contribution margin (feeds kill-criterion 3). *Currently undefined.* +6. **Leg-2 economics on real Story mainnet** — exact USDC(Base)→WIP(Story) bridge cost + confirmation time vs. generic quotes (Phase 2; §7.4). +7. **Long-lived-session isolation across buyers** — whether one CMA session cleanly isolates distinct buyers; likely one-session-per-buyer, reintroducing the ceiling (R10; §7.5). +8. **Fork-killing threshold (economics)** — run the `prototype/` engine (experiments 4–6) to **test** the `i* = p_parent/p_fork` *hypothesis*, find where forks collapse as inherit-bps rises, and where the fee feels extractive. **This spike exists precisely because the threshold is OPEN (TBD); it sets launch defaults — the closed form is a hypothesis to validate here, not a settled answer (see Economic Design).** +9. **`receiveWithAuthorization` on WIP / `RoyaltyModule.sol`** — contract-level read for any hypothetical direct-to-contract settlement (almost certainly absent; confirm). +10. **Live-evolution anti-clone efficacy** — load-bearing for the no-TEE defense; *asserted by analogy, unmeasured* (§7.7). No source quantifies required change cadence (R1; feeds kill-criterion 4). +11. **Off-platform Story enforcement** — real dispute/takedown outcomes for behavioral clones (on-chain provenance vs. off-chain courts) are undocumented (§7.8). +12. **Counsel sign-off on the collar architecture + closed-mode claim structure** — no regulatory source squarely analyzes the per-invocation agent-to-agent fact pattern; the MSB and the non-transferable-outside-Howey analyses are both extrapolated. **Get counsel to bless the architecture AND the deferred-comp/409A structure before launch** (R2, R3; feeds kill-criterion 5). + +--- + +## What we have NOT validated + +A single honest box, because the verdict is GO-**with-caveats** and several load-bearing facts could change the plan. **Regulatory is rated *medium* confidence overall; everything below is unmeasured or unverified as of mid-2026** (sourced to `report.md` §7 and `findings.json`). + +- **Cold-start latency** (`sessions.create` → first token) — **unmeasured** (§7.3). Sizes the entire pay-first-then-async UX. +- **Real Leg-2 settlement cost & latency** (USDC(Base)→WIP(Story) bridge/swap on Story mainnet) — **unmeasured** (§7.4). The pricing-floor and batch-window models depend on it; the fee table in Economic Design is illustrative. +- **Inference unit economics** — the collar pays Anthropic per run; **no model yet ties Wielder price to inference COGS + settlement + fee + royalty.** Until spiked, **business unit economics are undefined.** +- **Live-evolution anti-clone efficacy** — **asserted by analogy, unmeasured** (§7.7). The load-bearing "price below amortized clone cost / out-evolve the clone" prescription rests on an unquantified assumption. +- **Off-platform clone enforcement** — real dispute/takedown outcomes for behavioral clones are **undocumented** (§7.8). +- **Long-lived-session buyer isolation** — **unverified** (§7.5); likely forces one-session-per-buyer, reintroducing the rate ceiling. +- **License Token as non-burned off-chain credential** — **unverified** (§7.1); treated as off-chain entitlement pending a spike. +- **Regulatory fact pattern** — **medium confidence; no source squarely analyzes the per-invocation agent-to-agent collar** (§7.2). Both the MSB merchant-not-transmitter posture and the non-transferable-outside-Howey conclusion are extrapolations counsel must bless. +- **Demand-side willingness-to-pay and willingness-to-co-hold** — **no LOI, no pilot, no pricing research** (R12). The only demand signal is x402 aggregate volume, which is agent-infra micropayments, not skill royalties. +- **Fork-killing threshold** — **OPEN / TBD** (`prototype/README.md`). The `i* = p_parent/p_fork` closed form is a hypothesis, not a result. +- **Co-authorship / multi-Creator origin** — **open design question** (CONTEXT.md line 46), unmodeled in v1. + +Any of these could change the plan; the phased path is designed so the cheapest, soundest step (Provenance) ships first and the most-uncertain bets (Marketplace, tradeable claims) ship last, after these have been measured. + +--- + +## Roadmap & Milestones + +The build order is **safety-first, not ambition-first** (ADR-0006). Each phase ships a self-contained, defensible product, de-risks the next, and is gated by a small number of pre-build *spikes*. The atomic-payment, permissionless-trading, and hidden-from-host fantasies are out; everything below is the buildable loop (`report.md` §6). + +**Two fixed chain decisions hold across all phases:** Story (chainId 1514, SDK v1.4.4) for IP, royalty, provenance; Base (8453) for the x402 gate. Do **not** make x402 settle directly onto Story — the two-leg split (ADR-0005) is permanent v1 architecture. + +### The MVP (Phase 0 + a single-Skill slice of Phase 1) + +The first shippable thing is **one intra-org Skill, registered for provenance, gated, run, and metered** — the thinnest vertical slice proving the core loop without touching anything regulated. + +- **Provenance:** one Skill as a Story IP Asset (`mintAndRegisterIpAssetWithPilTerms` + `PILFlavor.commercialRemix()`), with at least one declared Derivative. +- **Gate + run:** collar as **sole Anthropic key-holder + x402 resource server**. `402` → EIP-3009 `transferWithAuthorization` (USDC on Base) → `/verify` + `/settle` → settled `txHash` **is** the single-use execution credential, checked **off-chain**. Settle first (sub-second), release credential, **then** run the agent asynchronously and stream only the output — never hold the x402 handshake across the run. +- **Meter:** an auditable off-chain ledger crediting each invocation's split (protocol fee → creator → flow-through), credited but **not** settled on-chain. +- **Claims non-transferable** — co-held employee/employer entitlement as a contractual/deferred-comp right (counsel-blessed structure). + +**Why this slice:** it exercises payment-gating, Wielder-hidden execution, the derivative graph, and the recursive split while deferring the two hardest dependencies — on-chain cross-chain settlement and securities/custody law. **Skill content is hidden from the Wielder (the collar never proxies `GET /v1/agents`); the host (Anthropic) still sees it in plaintext, accepted per ADR-0004** — this is the correct, qualified framing the rest of the document follows. + +### Phase 0 — Provenance (all-Story, ships immediately) + +The soundest step, no broken seam. Register Skills as IP Assets; forks register as declared Derivatives with on-chain ancestry. Establishes the provenance/derivative-graph moat regardless of how settlement evolves; ships before any gating/payment code. + +**Deliverables:** Skill → Story IP Asset registration; Derivative registration with declared ancestry (LAP/LRP per Skill); royalty-token vault per IP (100 tokens = 1%), co-holdable, minted **non-transferable** for closed modes; a read API/viewer over the ancestry graph. + +**Success criteria:** a Skill + at least one multi-level Derivative chain registered on Story mainnet with verifiable ancestry; co-held tokens issued and queryable; registration cost/latency measured and acceptable. + +**Spikes:** *None blocking* — Story registration is confirmed real and audited (§3 step 1). Optionally confirm the `PILFlavor` surface against SDK v1.4.4 before wiring. **GTM gate at this phase: secure at least one design-partner LOI to co-hold (kill-criterion 1) before committing Phase 1 build.** + +### Phase 1 — Intra-org, then Education: gate + run + off-chain meter + +Closed populations, lowest cloning pressure (ADR-0004 Update, ADR-0006). Claims **non-transferable** (best route outside the securities stack, *counsel-blessed*). Intra-org first; Education second. + +**Deliverables:** collar service (sole key-holder, x402 resource server, off-chain credential bookkeeping); pay-first-then-run-async orchestration against CMA; auditable, **signed** off-chain invocation+settlement ledger; intra-org co-held accrual from *external* invocations; education fork-to-Derivative + flow-through-to-school crediting; self-hosted sandbox for regulated data; refund/reputation path for accept-payment-but-fail-to-run; **inference-COGS pass-through metering.** + +**Success criteria:** no run without a valid single-use replay-proof credential; both intra-org co-holders earn from an external invocation; an education chain credits student-Derivative and school-ancestor correctly; signed, independently auditable ledger, no credential double-spend; usable p50/p95 latency; **positive per-invocation contribution margin after inference COGS** at design-partner prices. + +**Spikes (each must pass before main build):** cold-start latency (§7.3); inference-COGS/price model (Spike 5); session isolation across buyers (§7.5); credential semantics (§7.1); **fork-killing threshold — run `prototype/` to *test* the `i* = p_parent/p_fork` hypothesis and pick launch defaults (it is OPEN, not solved).** **Legal gate: counsel sign-off on the non-transferable deferred-comp / 409A structure before launch.** + +### Phase 2 — On-chain batched royalty settlement (ADR-0005 Leg 2) + +Turn the off-chain accumulator into on-chain truth — **closed modes only.** Custody enters here, so engineer to *minimize* custody from day one. + +**Deliverables:** async settlement worker (batches per threshold/interval); bridge/swap pipeline USDC(Base)→WIP(Story) via a licensed partner; `payRoyaltyOnBehalf` + **permissionless keeper** auto-claiming `claimAllRevenue` for ancestors; on-chain published batches; reconciliation for bridge stalls; fast-claim/hedge logic; collar as **non-custodial pass-through** on a hosted facilitator. + +**Success criteria:** a batch settles on-chain to correct ancestors with zero unexplained drift vs. the signed ledger; keeper claims reliably with no manual intervention; per-invocation amortized settlement cost below the price floor; a simulated bridge stall is detected and reconciled without losing funds. + +**Spikes:** Leg-2 economics on real Story mainnet (§7.4); re-verify recency-sensitive facts (§4.3); **MSB/custody legal read — counsel blesses the non-custodial pass-through before any in-flight value moves.** + +### Phase 3 — Open Marketplace + tradeable claims (only when warranted, permissioned) + +The headline "open composable royalty graph" ships **last** — thinnest moat, highest cloning incentive, full securities stack (ADR-0006). Enter only when closed-mode traction and live-evolution/live-data moats justify the open market. + +**Deliverables:** tradeable Royalty claim as a **permissioned** security (ERC-3643 allow-list); an exemption path (Reg D 506(c) / Reg A+/CF); secondary trading only on a registered ATS (e.g. Securitize) + transfer agent + KYC; marketplace routing/reputation; economic anti-clone tooling (price-below-clone-cost guidance, live-evolution cadence, value-binding to live tool/data access). + +**Success criteria:** a claim trades on a registered ATS between KYC'd, allow-listed parties with transfer-agent records; routing demonstrably favors provenance-verified originals over clones; no regulated activity outside the permissioned rails. + +**Spikes / gates:** **engage securities counsel before this phase (hard gate);** off-platform clone-defense efficacy (§7.7, unmeasured — quantify before betting the Marketplace on it); off-platform enforcement reality (§7.8). + +### Cross-phase tabled item + +**TEE / confidential execution** remains tabled (ADR-0003, ADR-0004) as the eventual structural fix for both host-side secrecy and run-without-charging. Not on any phase's critical path; revisit for high-value Skills once the closed modes prove the model. \ No newline at end of file diff --git a/docs/adr/0001-skills-as-hosted-invocation-rights.md b/docs/adr/0001-skills-as-hosted-invocation-rights.md new file mode 100644 index 0000000..d79480e --- /dev/null +++ b/docs/adr/0001-skills-as-hosted-invocation-rights.md @@ -0,0 +1,31 @@ +# Skills are sold as hosted invocation-rights, not as files + +## Context + +A **Skill** is plaintext (a `SKILL.md`, plugin, or agent definition) and therefore trivially +copyable. If we sold the artifact, the Creator earns a one-time fee and the buyer can copy it +infinitely — which is precisely the "hand it over once, capture none of the upside" outcome the +project exists to prevent. + +## Decision + +A Skill is **never handed over**. It executes inside a hosted agent runtime (e.g. an Anthropic +Managed Agent, OpenAI ChatKit workflow, or Vertex Agent Engine), and the **Wielder** acquires a +metered **Invocation-right** — permission to trigger runs and receive only the *output*, paying +per use. The tradeable asset is the royalty stream, not the artifact. + +## Considered options + +- **Artifact ownership (NFT of the file)** — rejected: copyable plaintext yields no per-use + income and does not stop automation-displacement. +- **Attested license (file held locally, usage self-reported)** — rejected for the core flow: + enforcement is honor-system; defeats the scarcity that funds the royalty. + +## Consequences + +- Skills must run as **hosted services**. Fully-local / offline execution is out of scope for the + monetized path. +- The artifact stays scarce, but the residual leakage risk shifts from *file copying* to + *output-channel distillation* (a Wielder reconstructing behavior from outputs). This must be + managed separately — prompt hardening and/or TEE/confidential execution — and is an open + question, not solved by this decision. diff --git a/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md b/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md new file mode 100644 index 0000000..d36682c --- /dev/null +++ b/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md @@ -0,0 +1,53 @@ +# Tokenize Skills as Programmable IP on Story Protocol + +## Context + +The token must do three jobs at once: (1) tamper-proof **provenance** of who authored a Skill, +(2) trustless, programmable **multi-party royalty splits** (school / student / employer / +co-creators), and (3) a tradeable, fractional **royalty claim** on future invocation revenue. +It must also support **composable derivative flow-through** — a fork owes royalties to its whole +ancestry (credited per invocation, claimable on demand; see Update). + +Hidden execution + per-call payment alone do NOT need a blockchain (Anthropic Managed Agents + +x402 already deliver that). But the three jobs above, plus cross-platform neutrality and a royalty +graph no single company adjudicates, do. + +Story Protocol already ships exactly this: on-chain IP Assets, composable license terms, and a +royalty module where derivatives pay ancestors. It is live and audited. + +## Decision + +Represent each registered Skill as a **Story Protocol IP Asset**; use Story's license + royalty +modules for splits and composable derivative royalties; represent the tradeable royalty claim as a +Story royalty token. Build NEW only the genuinely novel layer — hidden hosted execution, +per-invocation metering, and the attestation bridge that feeds on-chain settlement. + +## Considered options + +- **Own contracts on a general L2 (Base / Solana)** — rejected: reinvents the provenance, + licensing, and composable royalty graph Story already audits. +- **Own appchain / rollup** — rejected as premature: consensus, bridges, and liquidity overhead + for a product with no users yet. + +## Consequences + +- We inherit Story's design opinions, roadmap risk, and its chain's liquidity + regulatory surface. +- We accept the regulatory exposure of a tradeable revenue claim (explicitly accepted for now — + the goal is to trailblaze). +- Novel risk concentrates almost entirely in the **attestation bridge** (off-chain invocation → + trusted on-chain settlement), which is the next open question. + +## Update (post-feasibility validation, 2026-06) + +Validated against live Story Protocol (chainId 1514, SDK v1.4.4). Confirmed real, with corrections — +full analysis in `docs/feasibility/report.md`: + +- Flow-through is **pull, not push**: ancestors accrue a claimable balance and must call + `claimAllRevenue`; a permissionless **keeper** auto-claims on their behalf. "Automatically pays" + was wrong — it is "credited, claimable on demand." +- Story mainnet royalties are denominated in **WIP** ($IP), not USDC — volatile and illiquid; payers + take FX exposure. The USDC→WIP conversion is folded into the settlement bridge (ADR-0005). +- The **tradeable** royalty claim is almost certainly a **security** (Howey); a permissionless + composable trading market is not available. Trading must be permissioned (ERC-3643 + ATS + + transfer agent); claims are kept non-transferable in closed modes (ADR-0006). +- The "attestation bridge" is resolved as the **two-leg settlement** of ADR-0005. diff --git a/docs/adr/0003-payment-gated-execution.md b/docs/adr/0003-payment-gated-execution.md new file mode 100644 index 0000000..47b4571 --- /dev/null +++ b/docs/adr/0003-payment-gated-execution.md @@ -0,0 +1,46 @@ +# Payment-gated execution: payment is the meter, not a witness + +## Context + +A Skill runs hidden off-chain; royalties settle on-chain. The intuitive design — "prove to the +chain that the secret Skill ran" — is impractical: you can't reveal the Skill to prove it executed, +outputs are reconstructable, and ZK-proving an LLM inference is not viable today. + +## Decision + +Invert the causality. **Payment is the metered event AND the precondition for execution.** A +per-invocation payment mints a single-use **execution credential** that the hosted runtime requires +before it will run the Skill. The chain witnesses the *payment*, never the Skill. + +(Refined post-feasibility — see Update: the gating payment settles on **Base via x402** and yields a +**txHash credential** checked off-chain; on-chain royalty settlement on **Story** is a separate, +batched leg, not atomic with the gate.) + +## Considered options + +- **TEE-attested execution** — tabled as a future hardening layer (protects the Skill from the host + and prevents run-without-charging), not the core mechanism. +- **Trusted-oracle attestation** — rejected: a central party that signs usage reports can + under-report (skim) or over-report. + +## Consequences + +- Usage fraud on the money path is structurally impossible: no payment → no execution. +- Residual risks (to be handled later, primarily via the tabled TEE layer): a malicious host could + accept payment and fail to run (mitigated near-term by refunds / reputation), and — without a TEE + — the host operator can still see the Skill content. + +## Update (post-feasibility validation, 2026-06) + +The "usage fraud is structurally impossible / no trusted oracle" guarantee holds **only for the +synchronous, single-chain, atomic** case. The real architecture (ADR-0005) decouples the payment +gate (x402/USDC on Base) from on-chain royalty settlement (Story), with **off-chain batching**. +Consequence: + +- The per-invocation **gate** stays trust-minimized — *no credential, no run*, enforced per call. +- **Settlement** degrades from "structurally impossible to defraud" to an **auditable accumulator**: + the collar batches off-chain and could in principle mis-report or skim. Mitigations: signed, + auditable invocation logs; on-chain published settlement batches for reconciliation; refund + + reputation for accept-payment-but-fail-to-run; tabled TEE as the eventual structural fix. +- The **execution credential** is the x402 settled **txHash** (collar-checked off-chain), NOT a + per-call on-chain Story License Token (uneconomic at per-call cadence). diff --git a/docs/adr/0004-compete-on-moats-not-secrecy.md b/docs/adr/0004-compete-on-moats-not-secrecy.md new file mode 100644 index 0000000..5c472fa --- /dev/null +++ b/docs/adr/0004-compete-on-moats-not-secrecy.md @@ -0,0 +1,49 @@ +# Compete on economic & network moats, not Skill secrecy + +## Context + +Without TEE (tabled in ADR 0003), a hidden Skill's *behavior* can be partially reconstructed from +its outputs — prompt-extraction succeeded against ~97% of custom GPTs historically. Perfect secrecy +is unattainable in the near term, so secrecy cannot be the Creator's moat. + +## Decision + +The Creator's moat is **not** "they can't see it" but "even approximated, cloning isn't worth it." +We rely on: + +- **Provenance** (the Story IP Asset) — only the registered original earns marketplace trust. +- **The derivative royalty graph** — the ecosystem of forks and royalties accrues to the original + lineage; clones are orphans. +- **Reputation / routing** — invocations flow to the proven Creator, not an unknown clone. +- **Live evolution** — the hosted Skill keeps improving; a static reconstructed copy rots. + +Ship the prototype without confidential execution. TEE is a later upgrade for high-value Skills. + +## Considered options + +- **Watermark + legal enforcement** — kept as a complement, not a foundation: enforcement is slow, + costly, and jurisdiction-dependent. +- **Un-table TEE for hard secrecy now** — rejected: front-loads heavy infra (confidential GPUs, + attestation) into an MVP with no users. + +## Consequences + +- A clone can capture some value at the margins; accepted. +- The protocol must invest early in what *does* create the moat: provenance UX, the derivative + graph, and reputation/routing. +- The model works best for Skills whose value is in **continuous evolution, proprietary data + hookups, or composability** — not static prompt cleverness, which is the easiest thing to clone. + +## Update (post-feasibility validation, 2026-06) + +The sharpest leakage vector is not prompt extraction but off-platform **behavioral cloning**. For +most Skills the *output* is the value, so a high-volume Skill's own paid invocations become a cheap +(~30×) distillation training set — the more successful the Skill, the more attractive the clone, and +v1 (no TEE) cannot prevent it, only out-evolve it. Watermarking is a forensic tripwire (cheap +paraphrase removes it ~100%), not a moat. Net: the moat defends the **marketplace** (liquidity, +provenance, declared-derivative royalties), **not an individual breakout Skill**. Manage it +economically — price below amortized clone cost, ship faster than the distill-and-redeploy cadence, +and bind value to things outputs can't carry (live tool/data access, fresh private context). +Closed modes (intra-org/education) face the least pressure → launch there first (ADR-0006). This is +externally validated: OWASP LLM07:2025 states the system prompt must not be treated as a security +control. diff --git a/docs/adr/0005-two-leg-cross-chain-settlement.md b/docs/adr/0005-two-leg-cross-chain-settlement.md new file mode 100644 index 0000000..71cf3ba --- /dev/null +++ b/docs/adr/0005-two-leg-cross-chain-settlement.md @@ -0,0 +1,74 @@ +# Two-leg cross-chain settlement; off-chain execution credential + +## Context + +Feasibility validation (`docs/feasibility/report.md`) found the literal ADR-0003 vision — one +payment that both gates execution *and* lands on Story's royalty contract — does **not** compose +with today's APIs: + +- x402 settles **USDC on Base (8453)**; Story's Royalty Module accepts only **WIP on Story (1514)**. +- x402 pays an EOA via `transferWithAuthorization`, not Story's `payRoyaltyOnBehalf`; no x402 + facilitator supports chain 1514. +- Stacked per-hop fees (facilitator + Base gas + bridge + USDC→WIP swap slippage + Story gas + claim + gas) dwarf a cents-level micro-royalty. +- Story flow-through is **pull-based** (ancestors must claim). + +## Decision + +Settle in **two decoupled legs**: + +- **Leg 1 — synchronous gate (Base):** the Wielder pays USDC on Base via x402 (EIP-3009 + `transferWithAuthorization`, gasless). The settled **txHash is the single-use execution + credential**, checked off-chain by the collar. Settle the payment first (sub-second), release the + credential, *then* run the agent asynchronously and stream output — never hold the x402 handshake + open across the agent run (x402 `maxTimeoutSeconds` ~60s < cold agent start + loop). +- **Leg 2 — asynchronous settlement (Story):** an off-chain worker accrues payments in an auditable + ledger and **batches** them per threshold/interval, bridges/swaps USDC(Base)→WIP(Story), calls + `payRoyaltyOnBehalf`, and runs a permissionless **keeper** that auto-claims (`claimAllRevenue`) + for ancestors so their revenue never silently piles up. + +The execution credential is **off-chain**; do NOT mint a per-call on-chain License Token. + +## Considered options + +- **Single atomic on-chain payment-as-royalty** — rejected: physically impossible across Base↔Story. +- **Per-call on-chain License Token credential** — rejected: full tx + IP→WIP wrap + approve + block + latency per cents-level call. +- **Story-side only (no x402)** — rejected: WIP-only, volatile $IP, no clean per-call USDC/fiat gate. + +## Consequences + +- Settlement is **eventually-consistent**, not atomic. +- Batching makes the collar an **in-flight fund custodian** → FinCEN MSB exposure. Minimize custody: + route in-flight value through a licensed facilitator/bridge and keep the collar a non-custodial + pass-through (see ADR-0006 + regulatory section of the report). +- A bridge stall leaves "execution done, ancestor unpaid" — needs reconciliation + sound collar + bookkeeping. +- **Re-verify before building:** that no x402 facilitator has added Story 1514; the CDP fee schedule; + cold `sessions.create`→first-token latency; whether a License Token can serve as a non-burned + off-chain entitlement. — **All four RESOLVED in the pre-build spikes; see Update.** + +## Update (pre-build spikes resolved, 2026-06) + +Spike results in `docs/feasibility/prebuild-spikes.md`. All four confirm this ADR; none changed it. + +- **x402 ↔ Story unchanged:** no facilitator supports chain 1514; x402 V2 "multi-chain" = more + independent single-chain networks, not pay-on-Base-settle-on-Story; x402-exec is Base/X-Layer/BSC + only. Two-leg stays mandatory. CDP facilitator fee: **first 1,000 settled payments/month free, then + $0.001 each** (gas covered). A self-hosted facilitator *can* now dynamically register 1514, but + that is a **non-solution** (still wrong token WIP-vs-USDC, wrong primitive, still custody). Watch + **LayerZero** (joined the x402 Foundation) as a future cross-chain-settlement option. +- **Execution credential = x402 settled txHash (off-chain), confirmed.** A Story License Token is an + ERC-721 that only authorizes *derivative registration* and burns on use; it can be held un-burned + but confers no run right and has no native verification beyond ERC-721 ownership — wrong object, + and per-call minting is on-chain WIP + gas + block latency. **License Tokens are scoped to Phase 0 + provenance / fork declaration only.** +- **Finality caveat (new):** the x402 "~200ms" is a Base **Flashblocks preconfirmation**, not hard + finality; under congestion Base confirmation can take **10–28s**. The collar must own its own + txHash/nonce bookkeeping, set sane timeouts, and fund refunds from treasury (x402 is irreversible, + no resubmit). +- **Latency (Leg 1 + run) is acceptable:** no Anthropic SLA (CMA beta), but the async-after-gate + design holds. Mitigate with: persist `agent_id` (never create on the hot path), a small pre-warmed + **session pool** (skip the ~2.4s `sessions.create`), stream-first, render progress off the **first + event** (not the first answer token), and default interactive turns to `effort=low/medium`. + Benchmark before committing latency budgets: `prototype/spike-cma-latency.mjs` (run with your key). diff --git a/docs/adr/0006-phased-rollout-closed-modes-first.md b/docs/adr/0006-phased-rollout-closed-modes-first.md new file mode 100644 index 0000000..744b9b3 --- /dev/null +++ b/docs/adr/0006-phased-rollout-closed-modes-first.md @@ -0,0 +1,37 @@ +# Phased rollout: closed modes first; tradeable claims are permissioned securities + +## Context + +Feasibility validation (`docs/feasibility/report.md`) found: + +- **Tradeable royalty claims are almost certainly securities** under Howey — and ADR-0004's + live-evolution moat *strengthens* the "efforts of others" prong. The open, permissionless + composable royalty market triggers the full securities stack (ERC-3643 allow-list, Reg D/A+/CF, + SEC-registered ATS, transfer agent, KYC). +- **Off-platform behavioral cloning is the deepest strategic risk**, and it bites hardest in the + open Marketplace, where a high-volume Skill's own paid outputs are the cheapest clone-training set. +- **Intra-org and Education are closed populations** with aligned incentives and the least cloning + pressure; their co-held / forked claims can be structured as **non-transferable** contractual / + deferred-comp / license-fee rights that sidestep securities treatment entirely. + +## Decision + +Launch in order of *safety*, not ambition: + +- **Phase 0 — Provenance (all-Story):** register Skills as IP Assets + declared Derivatives. + Establishes the provenance/derivative-graph moat regardless of how settlement evolves. Soundest + step; ships immediately. +- **Phase 1 — Intra-org, then Education:** gate + run + off-chain metered ledger; claims + **non-transferable** (outside securities law). +- **Phase 2 — On-chain batched royalty settlement** (ADR-0005 Leg 2) for the closed modes. +- **Phase 3 — Open Marketplace + tradeable claims, only when warranted, and permissioned:** + ERC-3643 + a Reg exemption + registered ATS + transfer agent + KYC. Engage securities counsel + *before* this phase. + +## Consequences + +- The headline "open composable royalty graph" is the **last** thing shipped, not the first. +- The derivative-royalty **mechanic** is available throughout; only the **tradeability** of a claim + is gated behind the securities stack. +- Cloning risk is met first where it is weakest (closed modes), buying time to build live-evolution / + live-data moats before facing the open market. diff --git a/docs/feasibility/findings.json b/docs/feasibility/findings.json new file mode 100644 index 0000000..cd4ce00 --- /dev/null +++ b/docs/feasibility/findings.json @@ -0,0 +1,664 @@ +{ + "verdict": { + "overall": "go-with-caveats", + "summary": "The Skill Asset Protocol is feasible to build in mid-2026 with no fatal blocker, but the literal ADR vision — \"a fork automatically pays its ancestors atomically on every invocation, via one payment that both gates execution and lands on Story's royalty contract\" — does NOT compose end-to-end and must be rebuilt as a decoupled two-leg, batched, eventually-consistent settlement. Every component validates at works-with-caveats with high confidence (regulatory at medium): all four primitives the design needs are real, live, audited, on-chain on Story (chainId 1514, SDK v1.4.4 current as of Mar 2026); CMA (beta managed-agents-2026-04-01) genuinely hides the Skill from the Wielder in its session output stream; and x402 is a clean, mature per-invocation gate that yields a replay-proof on-chain receipt. The design holds together precisely because the collar is the sole API-key holder and the entire gate — ADR-0003's \"no credential, no run\" is 100% the collar's responsibility (no native pre-execution gate exists on Anthropic, OpenAI, or Google), and that is fine.\n\nThe compounding tension is that the cross-chain seam, the per-invocation fee math, and the regulatory posture ALL push toward the same uncomfortable place: a collar that batches off-chain and custodies funds in-flight. (1) x402 cannot settle onto Story — wrong chain (settles USDC on Base 8453, Story needs eip155:1514), wrong token (WIP-only on Story mainnet, not USDC), wrong primitive (pays an EOA, not the payRoyaltyOnBehalf function; uses transferWithAuthorization not receiveWithAuthorization; no facilitator supports 1514). So the gating payment physically cannot also be the on-chain royalty payment. (2) Per-hop fees (CDP facilitator $0.001/tx after 1k/mo + Base gas + bridge + USDC->WIP swap slippage + Story gas + claim gas) dwarf a cents-level micro-royalty, forcing mandatory batching. (3) Royalty flow-through on Story is PULL not PUSH (ancestors must call claimAllRevenue), so \"automatically pays ancestors\" is true in accounting terms, claimable on demand — needs a keeper. (4) Tradeable royalty claims are almost certainly securities under Howey (and ADR-0004's live-evolution/moat thesis STRENGTHENS the efforts-of-others prong), forcing permissioned trading (ERC-3643 allow-list + registered ATS + transfer agent), not the implied permissionless composable graph. (5) The batching+custody this all forces re-centralizes the very trust ADR-0003 set out to remove — the off-chain batched meter IS a \"trusted accumulator,\" exactly the oracle ADR-0003 rejected; that guarantee degrades from \"structurally impossible to defraud\" to \"auditable accumulator.\" Finally, the deepest strategic risk sits below the chain entirely: ADR-0001 hands the Wielder the OUTPUT, and for most Skills the output IS the value, so a high-value, high-volume Skill is the cheapest thing to behaviorally clone (thousands of I/O pairs, ~30x-cheaper distillation) — and your revenue volume is the cloner's free training set. The moat defends the marketplace (liquidity, provenance, declared-derivative royalties), NOT an individual breakout Skill from off-platform cloning. Watermarking is a forensic tripwire, not a moat. Net: build it, but build the honest version (two-leg batched settlement, off-chain credential, permissioned claims), launch the closed modes first, and treat ADR-0003's atomicity language and CONTEXT lines 49/95-96 as needing correction.", + "recommendedMvpPath": "Launch INTRA-ORG mode first (then Education), single-chain Story for IP/royalty, with OFF-CHAIN settlement initially and on-chain royalty batching added in phase 2. Rationale: intra-org and education are the safest modes — closed populations, aligned incentives, on-platform by construction, lowest adversarial cloning pressure, and they can structure co-held claims as NON-TRANSFERABLE contractual/deferred-comp/license-fee rights that sidestep securities treatment entirely (no ATS/transfer-agent/allow-list needed for v1). The open Marketplace mode — where the moat is thinnest, cloning incentive highest, and tradeable claims force the full securities stack — should come LAST.\n\nConcrete phased path:\nPHASE 0 (provenance, all-Story, soundest step): Register Skills as Story IP Assets via mintAndRegisterIpAssetWithPilTerms + PILFlavor.commercialRemix(); forks register as declared Derivatives with on-chain ancestry. This is the strongest part of the loop and ships immediately — it establishes the provenance/derivative-graph moat regardless of how settlement evolves.\nPHASE 1 (gate + run + off-chain meter): Collar = sole Anthropic key-holder + x402 resource server. Flow: 402 -> EIP-3009 transferWithAuthorization (USDC on Base, gasless, bytes32 nonce) -> /verify + /settle -> settled txHash IS the single-use execution credential (collar does its own nonce/txHash bookkeeping; do NOT rely on protocol resubmit). Settle payment FIRST (sub-second), release credential, THEN run the agent asynchronously and stream output — never hold the 402 handshake open across the agent run (x402 maxTimeoutSeconds ~60s < cold sessions.create + agent loop). The credential is OFF-CHAIN-checked by the collar; do NOT mint one Story License Token per call (full on-chain tx + IP->WIP wrap + approve + block latency = broken at per-call cadence). Account royalties in an auditable off-chain ledger. Skill is hidden from the Wielder (collar never proxies GET /v1/agents); document that the host (Anthropic) still sees the Skill — accepted per ADR-0004, and note CMA is not ZDR/HIPAA-eligible (use self-hosted sandbox config for regulated intra-org/education data).\nPHASE 2 (on-chain royalty settlement, batched two-leg): Async settlement worker batches accumulated payments per threshold/interval, bridges/swaps USDC(Base)->WIP(Story) via Stargate/Across/deBridge, calls payRoyaltyOnBehalf on Story, and runs a permissionless keeper that auto-claims (claimAllRevenue) on ancestors' behalf so the school's revenue never silently piles up. Publish settlement batches on-chain for reconciliation. Minimize custody: push in-flight value-holding to a licensed bridge/facilitator partner; keep the collar a non-custodial pass-through riding a hosted facilitator (Coinbase x402, which carries its own KYT/OFAC/licensing) so it looks like a merchant-on-Stripe, not a FinCEN MSB.\nPHASE 3 (open Marketplace + tradeable claims, only when warranted): Only here introduce tradeable Royalty claims — and do it permissioned: ERC-3643 allow-list token, Reg D 506(c) to accredited (or Reg A+/CF for retail), secondary trading only on a registered ATS (e.g. Securitize, FINRA-approved May 2026) + transfer agent + KYC. Engage securities counsel before this phase.\n\nChain decision: Story for IP/royalty/provenance (it is the only layer that models any of this); Base for the x402 payment gate. Do NOT attempt to make x402 settle directly to Story — accept the two-leg split as permanent v1 architecture.\nPre-build re-verification (recency-sensitive): confirm no x402 facilitator has added Story 1514 (x402.org/ecosystem), confirm CDP fee schedule, benchmark cold sessions.create->first-token latency, and confirm Story License Token can be used as a non-burned off-chain entitlement (design spike — natively it burns only on derivative registration).\nDoc corrections to make now: CONTEXT.md line 49 / ADR-0002 \"automatically pays\" -> \"automatically credited, claimable on demand (keeper auto-claims)\"; CONTEXT.md line 95-96 \"hidden from host\" -> documented as accepted v1 trust boundary (host sees Skill; TEE tabled); ADR-0003 — make explicit that the \"no trusted oracle / fraud structurally impossible\" guarantee holds only for the synchronous atomic case and degrades to \"auditable accumulator\" once cross-chain batching is introduced.", + "confirmed": [ + "Register a Skill as a Story IP Asset with PIL terms and declared derivative ancestry — mintAndRegisterIpAssetWithPilTerms + PILFlavor.commercialRemix() is one real on-chain tx on Story mainnet (chainId 1514, audited protocol-core-v1 v1.3.2, TS SDK v1.4.4 current as of Mar 2026). Soundest step in the whole loop.", + "Hide the Skill from the Wielder: CMA (beta managed-agents-2026-04-01) holds {system, skills} on the persisted Agent object; the session output stream (agent.message/thinking/tool_*/span.*) never carries the system prompt or skill bodies, so a party seeing only output cannot read the Skill. Verified against live primary docs.", + "Per-invocation payment gating that yields a verifiable receipt: x402 is a textbook fit (402 + PAYMENT-REQUIRED -> EIP-3009 transferWithAuthorization gasless w/ bytes32 nonce -> /verify + /settle -> PAYMENT-RESPONSE {success, txHash, networkId}). The settled txHash is a replay-proof single-use execution credential. Mature: ~165M tx / ~$50M cumulative by Apr 2026, Linux Foundation standard, Stripe + AWS Bedrock AgentCore + Cloudflare backing.", + "ADR-0003 'no credential, no run' is implementable — but 100% as the collar's responsibility. No native pre-execution payment gate exists on Anthropic CMA, OpenAI Responses/AgentKit, or Google Gemini Enterprise Agent Platform; Anthropic keys are workspace-scoped (full/read-only only), so the collar is structurally forced to be the sole key-holder and the entire gate. This matches design intent.", + "Co-held royalty claims: each Story IP vault mints exactly 100 royalty tokens (1% each), splittable/transferable to multiple wallets with pro-rata claim — directly implements employee+employer co-hold and student+school splits (granularity floor 1%).", + "Derivative royalty flow-through exists and is real on-chain (LAP = whole ancestry, LRP = direct parents; up to 1024 ancestors / 8 parents) — but is CLAIMABLE not auto-credited (ancestors call claimAllRevenue; needs a keeper).", + "Per-invocation stablecoin settlement is launchable in the US without a fatal regulatory blocker IF the collar stays non-custodial on a hosted facilitator (merchant-on-Stripe analogy, not a FinCEN MSB). GENIUS Act AML/KYC falls on stablecoin issuers, not payer/payee merchants.", + "Intra-org and Education modes can be kept entirely OUTSIDE securities treatment by structuring co-held/forked claims as non-transferable contractual/deferred-comp/license-fee rights — making them the safest, lowest-compliance modes to launch first.", + "ADR-0004's strategic direction (abandon secrecy; compete on provenance/network/reputation/live-evolution) is correct and externally validated (OWASP LLM07:2025 explicitly states the system prompt must not be treated as a secret or security control)." + ], + "blockers": [ + { + "item": "x402 cannot settle onto Story in one action — wrong chain (Base 8453 vs Story eip155:1514), wrong token (WIP-only on Story mainnet, not USDC), wrong primitive (pays an EOA, not the payRoyaltyOnBehalf contract function; x402 uses transferWithAuthorization not receiveWithAuthorization; no facilitator supports 1514; x402-exec can call functions but is Base/X-Layer/BSC only). The single payment that gates execution physically cannot also be the on-chain royalty payment.", + "severity": "high", + "workaround": "Decoupled TWO-LEG settlement. Leg 1: x402 settles USDC on Base to a treasury, producing the txHash that gates execution sub-second. Leg 2: async worker batches payments, bridges/swaps USDC(Base)->WIP(Story) via Stargate/Across/deBridge, then calls payRoyaltyOnBehalf on Story. Accept that atomic per-invocation flow-through becomes eventually-consistent batched settlement. Re-verify x402.org/ecosystem and CDP fees before building." + }, + { + "item": "The two-leg + batched design makes the collar an in-flight fund custodian (holds Wielder funds on Base after execution, before ancestors are paid on Story). This custody simultaneously (a) creates a reconciliation surface if a bridge stalls (execution happened, school unpaid) AND (b) is the exact fact pattern that 'almost certainly' makes the collar a FinCEN MSB needing multi-state money-transmitter licenses + BSA/AML (a 12-24mo, multi-hundred-K slog). The cross-chain workaround forces the same custody that creates the worst regulatory exposure.", + "severity": "high", + "workaround": "Minimize/eliminate custody: settle splits via smart contract / facilitator / issuer rather than the collar moving others' money; keep the collar a non-custodial pass-through on a hosted facilitator (Coinbase x402 carries its own KYT/OFAC/licensing). Push the unavoidable in-flight value-holding to a licensed bridge/facilitator/BaaS partner to defer state MTLs." + }, + { + "item": "Tradeable royalty claims (the headline marketplace feature) are almost certainly securities under Howey — fractional claim on a revenue stream from the essential ongoing efforts of creator+platform; ADR-0004's live-evolution/moat thesis STRENGTHENS the efforts-of-others prong; the March 2026 SEC interpretation does NOT carve out revenue-share tokens. So the 'open composable royalty graph' cannot be permissionless.", + "severity": "high", + "workaround": "Permissioned trading: ERC-3643 allow-list token, Reg D 506(c) to accredited (or Reg A+/CF for retail), secondary ONLY on an SEC-registered ATS (e.g. Securitize, FINRA-approved May 2026) + transfer agent + KYC. The derivative royalty MECHANIC is fine; only the TRADEABILITY of the claim triggers securities law. Keep intra-org/education claims non-transferable to stay outside this entirely. Not a blocker, a structural constraint — defer to the last phase." + }, + { + "item": "The composition re-centralizes the trust ADR-0003 set out to remove. The collar becomes: sole Anthropic key-holder, in-flight Base<->Story custodian, the off-chain batched meter deciding which invocations get settled, and (with Anthropic) able to see the Skill. The batched off-chain meter IS a trusted accumulator that can skim/mis-report — exactly the oracle ADR-0003 rejected. ADR-0003's 'usage fraud structurally impossible' holds only for the synchronous atomic case the cross-chain gap forces you to abandon.", + "severity": "high", + "workaround": "Acknowledge v1 is trust-minimized-at-the-gate (no payment -> no run enforced per call) but trusted-at-settlement (off-chain batched accounting). Mitigate with signed/auditable invocation logs, on-chain published settlement batches for reconciliation, refund/reputation for accept-payment-but-fail-to-run, and the tabled TEE as eventual fix. Make ADR-0003 explicit that the no-oracle guarantee degrades to 'auditable accumulator' once batching/cross-chain is introduced." + }, + { + "item": "Off-platform behavioral cloning of high-value Skills — the deepest strategic risk, below the chain entirely. ADR-0001 hands the Wielder the OUTPUT, and for most Skills the output IS the value; cloning a narrow behavior needs only thousands of I/O pairs (~30x-cheaper distillation), which your own paid invocations supply for free. The more successful/invoked a Skill, the cheaper and more attractive the clone. Provenance/network moats defend the MARKETPLACE, not an individual breakout Skill. Watermarking is broken by cheap paraphrase (SIRA ~100% removal) — forensic tripwire only.", + "severity": "medium", + "workaround": "Manage economically/operationally, not cryptographically (v1 has no TEE): price below amortized clone-cost; LIVE EVOLUTION as a moving target (ship faster than distill-and-redeploy cadence); bind value to things outputs can't carry (live tool/data access, fresh private context, reputation); account-integrity/anomaly detection on invocation patterns; watermark + provenance graph as legal backstop. Treat a breakout earner's off-platform clone as when-not-if. Closed (intra-org/education) modes face the least pressure — launch there first." + }, + { + "item": "Execution credential as an on-chain Story License Token at per-call cadence is economically/latency broken — every mint is a full on-chain tx dragging an IP->WIP wrap + ERC-20 approve + CometBFT block latency, for a credential gating a single LLM call worth cents.", + "severity": "medium", + "workaround": "Use the x402 settled-txHash (or a collar-issued off-chain token) as the credential, checked off-chain by the collar; settle on Story in batches. 'Payment mints a credential' holds; 'the credential is an on-chain License Token' does not at this cadence. UNVERIFIED: whether a License Token can be repurposed as a non-burned per-call run credential (natively burns only on derivative registration) — treat as off-chain entitlement; needs a design spike." + }, + { + "item": "Per-invocation micro-royalty economics vs stacked fees: each invocation carries CDP facilitator fee ($0.001/tx after 1k/mo) + Base gas + bridge fee + USDC->WIP swap slippage + Story gas + claim gas. For a cents-level invocation these can exceed the royalty, making literal per-invocation on-chain royalty uneconomic.", + "severity": "medium", + "workaround": "Batching is mandatory (already implied by Leg-2): aggregate and settle on Story per threshold/interval; price invocations above amortized settlement cost. Note this reintroduces the trusted-accumulator/custody/MSB tension, so batching-window sizing balances fee economics against custody exposure." + }, + { + "item": "Royalty flow-through is PULL not PUSH — ancestors accrue a claimable balance and must call claimAllRevenue; in Education mode the school's revenue silently piles up unclaimed unless someone claims for it. Contradicts CONTEXT.md line 49 / ADR-0002 'automatically pays its ancestry on each Invocation.'", + "severity": "medium", + "workaround": "Build a permissionless keeper/relayer to auto-claim on ancestors' behalf (anyone can claim FOR an IP). Update CONTEXT.md line 49 / ADR wording to 'automatically credited, claimable on demand.' Low engineering cost; must not be forgotten or the value prop silently fails for ancestors." + }, + { + "item": "Synchronous 402 collar in front of a long-running managed-agent session will time out — x402 maxTimeoutSeconds defaults ~60s; a managed-agent run (sandbox cold-start + agent loop) can exceed that. Cold sessions.create->first-token latency is unmeasured.", + "severity": "medium", + "workaround": "Settle the x402 payment FIRST (sub-second on Base), release the credential, THEN run the agent asynchronously and stream/return output separately. This is the natural pay->credential->run ordering anyway. Benchmark cold-start before sizing SLAs." + }, + { + "item": "Story mainnet Royalty Module whitelists ONLY WIP (wrapped $IP), not USDC. Every payer/Beneficiary effectively takes $IP exposure + a wrap step, and $IP is down ~97.5% from ATH with thin liquidity — the tradeable claim and accrued revenue are denominated in a volatile, illiquid asset; enterprise Beneficiaries won't hold WIP.", + "severity": "medium", + "workaround": "Fold the USDC->WIP wrap into the Leg-2 bridge (the two-leg design already does this conversion). No turnkey fiat/USDC->WIP on-ramp confirmed — collar must build it. Flag FX/liquidity risk: settlement value swings with $IP price between accrual and claim; consider hedging or fast-claiming to limit the WIP exposure window." + }, + { + "item": "The Skill is NOT hidden from the host (Anthropic) — CMA processes the system prompt/Skill in plaintext (no TEE), GET /v1/agents echoes it verbatim to the key-holder, and CMA is not ZDR/HIPAA-BAA-eligible (history/sandbox/outputs stored server-side). CONTEXT.md line 95-96 ('hidden from the host too') is unsolved in v1.", + "severity": "low", + "workaround": "Accept per ADR-0004 (compete on moats, not secrecy) — the host seeing the Skill is the explicitly-accepted v1 trust assumption; TEE is the tabled future hardening. For intra-org/education with regulated data, use self-hosted sandbox config or a different host (CMA is not ZDR/HIPAA). Document the trust boundary; no action needed for marketplace mode." + }, + { + "item": "CMA create-endpoint rate limit is 300 req/min/org (read/stream 600/min); token ITPM/OTPM also apply. A pure one-session-per-invocation marketplace at high frequency hits this org-level ceiling.", + "severity": "low", + "workaround": "Reuse long-lived sessions across a Wielder's invocations, queue, or shard across workspaces/orgs. UNVERIFIED: whether long-lived sessions cleanly isolate distinct buyers (history accumulation, compaction cost) — likely want one session per buyer, which reintroduces the ceiling. Design pooling early; not a v1 blocker at low volume." + } + ], + "risks": [ + { + "risk": "A breakout high-value Skill gets behaviorally cloned off-platform and re-hosted on an attacker's runtime, paying zero royalties and outside Story's enforcement. The most successful Skill generates the cleanest, largest clone-training set from its own paid outputs.", + "likelihood": "high (treat as when-not-if on a months timescale for any breakout earner)", + "impact": "high — undermines the core value proposition for the marketplace mode precisely at success; v1 (no-TEE) cannot prevent it, only out-evolve/out-integrate it", + "mitigation": "Live-evolution moving target + value-binding to non-output-carryable things (live tool/data access, fresh context, reputation) > economic pricing below clone-cost > account-integrity/anomaly detection > provenance-graph detection > watermarking (tripwire only). Launch closed modes (intra-org/education) first where adversarial pressure is lowest." + }, + { + "risk": "Collar custody/netting/routing of third-party funds classifies the platform as a FinCEN MSB, triggering multi-state money-transmitter licensing + BSA/AML program.", + "likelihood": "medium-high (forced by the cross-chain two-leg design unless deliberately architected around)", + "impact": "high — 12-24 month, multi-hundred-K compliance slog that gates launch", + "mitigation": "Keep collar non-custodial on a hosted facilitator (Coinbase x402); settle splits via smart contract/issuer; push in-flight custody to a licensed bridge/BaaS partner. Get counsel to bless the specific per-invocation agent-to-agent collar architecture (no source squarely analyzes this)." + }, + { + "risk": "Tradeable royalty claims are securities, forcing the full permissioned stack (ATS + transfer agent + KYC allow-list + exemption), which contradicts the 'frictionless composable royalty graph' product vision and adds cost/latency to the marketplace.", + "likelihood": "high (near-certain under current Howey doctrine for tradeable revenue-share tokens)", + "impact": "high for marketplace mode; zero for intra-org/education if claims kept non-transferable", + "mitigation": "Permissioned ERC-3643 + Reg D 506(c)/Reg A+ + registered ATS (Securitize). Keep closed modes non-transferable to stay outside securities law. Defer tradeable claims to the last phase; engage securities counsel before." + }, + { + "risk": "Cross-chain bridge stall/failure between Leg-1 (execution gated on Base) and Leg-2 (royalty paid on Story) leaves execution done but ancestors unpaid — reconciliation surface and trust erosion for ancestors (e.g. the school).", + "likelihood": "medium", + "impact": "medium — eventually-consistent settlement with reconciliation overhead; not loss-of-funds if collar bookkeeping is sound", + "mitigation": "Auditable off-chain ledger, on-chain published settlement batches, retry/reconciliation logic, refund/reputation for failures, conservative batching windows." + }, + { + "risk": "ADR-0003's 'usage fraud structurally impossible / no trusted oracle' guarantee silently degrades to 'auditable accumulator' once batching+cross-chain are introduced — the collar can skim or mis-report at the settlement layer.", + "likelihood": "high (a structural consequence of the required architecture, not a chance event)", + "impact": "medium — the per-call gate stays trust-minimized, but settlement trust is reintroduced; reputational/correctness risk", + "mitigation": "Make the degradation explicit in ADR-0003; mitigate with signed/auditable invocation logs and on-chain batch publication; TEE as the eventual cryptographic fix." + }, + { + "risk": "Stacked per-invocation fees (facilitator + Base gas + bridge + swap slippage + Story gas + claim gas) exceed a cents-level micro-royalty, making the unit economics negative without aggressive batching.", + "likelihood": "high at literal per-invocation on-chain settlement; low once batched", + "impact": "medium — forces batching (which feeds the custody/trust risks) and a price floor", + "mitigation": "Mandatory batching; price invocations above amortized settlement cost; size batching window to balance fees against custody exposure." + }, + { + "risk": "$IP/WIP price volatility and thin liquidity mean accrued royalty value swings between accrual and claim, weakening the 'investable asset' thesis and exposing enterprise payers to involuntary $IP exposure.", + "likelihood": "high (current market conditions: $IP down ~97.5% from ATH)", + "impact": "medium — FX risk on settlement value; friction for enterprise Beneficiaries who won't hold WIP", + "mitigation": "Fast-claim to limit WIP exposure window; consider hedging; build a fiat/USDC->WIP on-ramp; monitor whether Story whitelists USDC for royalties (re-check before build)." + }, + { + "risk": "Verbatim system-prompt extraction of the hidden Skill via agentic steering (the runtime IS a steerable agent — 'Just Ask: Curious Code Agents').", + "likelihood": "medium (lower than behavioral cloning, but nonzero and a live attack surface)", + "impact": "medium — leaks Skill text but ADR-0004 already abandons secrecy as load-bearing", + "mitigation": "Taxonomy-aware wrapper cuts extraction quality ~18% (never eliminates); the real defense remains the moats, not secrecy. Accept residual risk per ADR-0004." + }, + { + "risk": "CMA is BETA (managed-agents-2026-04-01, not GA); SDK shapes (beta.agents/sessions) and behaviors may change, and CMA is not ZDR/HIPAA-eligible for regulated intra-org/education data.", + "likelihood": "medium (beta churn is expected)", + "impact": "medium — integration rework risk; compliance gap for regulated data modes", + "mitigation": "Abstract the runtime behind the collar so the host is swappable; use self-hosted sandbox config or alternative host for regulated data; track Anthropic release notes." + }, + { + "risk": "Recency/availability risk in load-bearing facts: a third-party x402 facilitator could add Story 1514; CDP fees, $IP whitelist, and License-Token non-burn semantics could change — several validations are mid-2026 secondary-source dated.", + "likelihood": "low-medium", + "impact": "low-medium — could simplify (good) or invalidate (bad) parts of the two-leg design", + "mitigation": "Re-verify x402.org/ecosystem, CDP network-support/fees, Story royalty-currency whitelist, and License-Token semantics immediately before building; run a Leg-2 latency/fee spike on real Story mainnet." + } + ] + }, + "composition": { + "worksEndToEnd": false, + "sequence": [ + "STEP 1 (Register Skill on Story) - COMPOSES. mintAndRegisterIpAssetWithPilTerms + PILFlavor.commercialRemix() is one real on-chain tx on Story mainnet (chainId 1514, audited core v1.3.2). Forks register as Derivatives with declared ancestry. No live-API blocker. This step is the soundest in the whole loop.", + "STEP 2 (Host behind managed agent, content never leaves host) - COMPOSES WITH ONE CORRECTION. CMA (beta managed-agents-2026-04-01) holds {system, skills} on the persisted Agent object; the session OUTPUT stream (agent.message/thinking/tool_*/span.*) never carries the system prompt or skill bodies, so a party seeing only output (the Wielder) cannot read the Skill. CORRECTION to the design's mental model: hiding is NOT a platform secrecy guarantee - GET /v1/agents/{id} echoes the full system prompt verbatim, and Anthropic processes the Skill in plaintext (no TEE). Hiding holds ONLY because the collar is the sole API-key holder and never proxies GET /v1/agents. 'Content never leaves the host' is false against the host itself (Anthropic sees it) - true only against the Wielder. Consistent with ADR-0004, but CONTEXT.md line 95-96 'hidden from the host too' remains unsolved.", + "STEP 3a (Wielder pays per Invocation, x402 gates + yields receipt) - COMPOSES. Collar is a textbook x402 resource server: 402 + PAYMENT-REQUIRED -> EIP-3009 transferWithAuthorization (gasless, bytes32 nonce) -> /verify + /settle -> 200 + PAYMENT-RESPONSE {success, txHash, networkId}. The settled txHash IS the single-use credential (replay-proof via on-chain nonce). This is exactly ADR-0003. Caveat: the protocol does NOT 'safely resubmit' after a settled-but-failed run - the collar must do its own nonce/txHash bookkeeping to release execution.", + "STEP 3b (settle that SAME payment toward the Story royalty contract) - DOES NOT COMPOSE AS ONE ACTION. This is the load-bearing break. x402 settles a USDC TRANSFER to an EOA on Base (eip155:8453). Story's Royalty Module needs payRoyaltyOnBehalf(ipId, amount, token) - a CONTRACT CALL, in WIP (the only mainnet-whitelisted currency, NOT USDC), on eip155:1514. x402 fails all three: no facilitator supports Story (1514); x402 settles single-chain only; 'exact' pays an address, not a function (x402-exec can call a function but is Base/X-Layer/BSC only, never Story); and even direct-to-contract would need receiveWithAuthorization, which x402's transferWithAuthorization is not. CONSEQUENCE: the single payment that gates execution CANNOT also be the on-chain royalty payment. You MUST split into two legs (Base USDC gate-leg + async bridge/swap to WIP-on-Story royalty-leg).", + "STEP 3c (payment mints the execution credential) - COMPOSES ONLY IF the credential is NOT an on-chain Story License Token at per-call cadence. A License Token IS mintable per call (ERC-721 carrying PIL terms, burnable = single-use) BUT every mint is a full on-chain tx dragging an IP->WIP wrap + ERC-20 approve + CometBFT block latency - economically and latency-wise broken for gating a single LLM call worth cents. The credential that actually works is the x402 settled-txHash (or a collar-issued off-chain token), checked off-chain by the collar. 'Payment mints a credential' holds; 'the credential is an on-chain License Token' does not at this cadence.", + "STEP 4 (Collar verifies credential -> invokes managed agent -> returns only output) - COMPOSES, AND THE GATE MUST BE OUR PROXY. No native 'no credential, no run' gate exists on CMA, OpenAI, or Google. CMA's only mid-run gate (permission_policy: always_ask) is a tool-approval gate keyed to the API-key holder, not a pre-execution payment gate, and cannot stop a turn from starting/spending tokens. CMA outbound webhooks are after-the-fact notifications, not blocking authz. Anthropic keys are workspace-scoped (full/read-only only, no endpoint-level scoping), so a Wielder cannot be given a scoped key that allows sessions.create but forbids GET /v1/agents. NET: the collar is structurally forced to be the sole key-holder and the entire gate - ADR-0003 is 100% the collar's responsibility. This part of the design is sound and matches intent.", + "STEP 5 (Settlement: protocol fee, then recursive royalty split through ancestry; claims tradeable/co-holdable) - COMPOSES ON-CHAIN BUT IS PULL, BATCHED, AND CUSTODIAL - not the advertised atomic per-invocation push. On-chain mechanics are real: LAP (whole ancestry) / LRP (direct parents), 100 royalty tokens/vault = 1% each, co-holdable (employee+employer, student+school). BUT: (i) flow-through is CLAIMABLE not auto-credited - ancestors must call claimAllRevenue (needs a keeper/relayer or the school's revenue silently piles up); (ii) bridge+swap+facilitator fees per hop dwarf a micro-royalty, so Story-side payRoyaltyOnBehalf MUST be batched (hourly/daily) - contradicting literal per-invocation on-chain royalty; (iii) the two-leg design makes the collar an in-flight custodian of funds between Base receipt and Story payout. The ADR-0002/CONTEXT 'a fork automatically pays its ancestors on every invocation' is achievable in ACCOUNTING terms eventually-consistent, NOT as one atomic on-chain action per invocation." + ], + "gaps": [ + { + "where": "Step 3b - cross-chain seam between x402 (Base/USDC) and Story royalties (eip155:1514/WIP)", + "problem": "x402 cannot settle onto Story. No facilitator supports chain 1514; x402 is single-chain by design; 'exact' pays an EOA not the payRoyaltyOnBehalf function; mainnet royalty currency is WIP not USDC; direct-to-contract would need receiveWithAuthorization (x402 uses transferWithAuthorization). The one payment that gates execution physically cannot also be the on-chain royalty payment. This is the single biggest integration break in the loop.", + "severity": "high", + "workaround": "Decoupled TWO-LEG settlement. Leg 1: x402 settles USDC on Base to a collar treasury, producing the txHash that gates execution sub-second. Leg 2: an async settlement worker batches accumulated payments, bridges/swaps USDC(Base)->WIP(Story) via Stargate/Across/deBridge, then calls payRoyaltyOnBehalf on Story. Accept that per-invocation atomic flow-through becomes eventually-consistent batched settlement. Re-verify before building: no third-party x402 facilitator has added Story (check x402.org/ecosystem) and CDP facilitator now charges $0.001/tx after 1k/mo." + }, + { + "where": "Step 5 / trust topology - the collar as in-flight custodian between Leg 1 and Leg 2", + "problem": "Because the two legs are decoupled and batched, the collar holds Wielder funds on Base after execution but before ancestors are paid on Story. A bridge stall means execution already happened but the school/ancestors are not yet paid (reconciliation surface). This custody ALSO triggers the regulatory finding: custodying/netting/routing third-party funds 'almost certainly' makes the collar a FinCEN MSB needing multi-state money-transmitter licenses + BSA/AML - a 12-24mo, multi-hundred-K slog. The custody that the cross-chain workaround forces is the same custody that creates the worst regulatory exposure.", + "severity": "high", + "workaround": "Minimize/eliminate custody: settle royalty splits via smart contract / the facilitator / issuer rather than the collar moving others' money; keep the collar a non-custodial pass-through riding a hosted facilitator (Coinbase x402, which carries its own KYT/OFAC/licensing) so it looks like a merchant-on-Stripe, not an MSB. Tension: the cross-chain bridge inherently needs SOMEONE to hold value in-flight; pushing custody to a licensed bridge/facilitator partner is the realistic path. Defer state MTLs by using a licensed BaaS partner." + }, + { + "where": "Step 3c/4 - execution credential as on-chain Story License Token at per-call cadence", + "problem": "Literal reading of ADR-0003 ('per-invocation payment mints a single-use execution credential the runtime requires') implies minting one on-chain artifact per call. As a Story License Token this is a full on-chain tx + IP->WIP wrap + ERC-20 approve + block latency per LLM call worth cents - economically broken and adds seconds of latency to a gate that must be sub-second.", + "severity": "medium", + "workaround": "Do NOT mint one License Token per invocation. Use the x402 settled-txHash as the credential, OR pre-mint a batch of single-use credentials, OR mint ONE durable License Token = an Invocation-right and meter invocations OFF-CHAIN against it (collar checks credential off-chain), settling on Story in batches. Preserves 'no credential, no run' (checked by collar) while moving only periodic settlement on-chain. UNVERIFIED: whether a License Token can be repurposed as a non-burned per-call run credential - natively it is burned only on derivative registration; treat as off-chain entitlement, needs a design spike." + }, + { + "where": "Step 2 / CONTEXT.md line 95-96 - hiding the Skill from the HOST", + "problem": "The design hides the Skill from the Wielder but NOT from Anthropic. CMA processes the system prompt/Skill in plaintext (no TEE - explicitly tabled in ADR-0004), GET /v1/agents echoes it verbatim to the key-holder, and CMA is not ZDR-eligible (history/sandbox/outputs stored server-side). The open question on CONTEXT.md line 95 ('hidden from the host too') is unsolved in v1.", + "severity": "low", + "workaround": "Accept per ADR-0004 (compete on moats, not secrecy) - the host seeing the Skill is the explicitly-accepted v1 trust assumption; TEE/confidential execution is the tabled future hardening. For intra-org/education modes with regulated data, note CMA is not ZDR/HIPAA-BAA-eligible - those modes may need self-hosted sandbox config or a different host. No action needed for marketplace mode beyond documenting the trust boundary." + }, + { + "where": "Step 5 - royalty flow-through is PULL not PUSH; ancestors must claim", + "problem": "CONTEXT.md line 49 and ADR-0002 say a Derivative 'automatically pays its ancestry on each Invocation.' On Story, payment and claiming are deliberately decoupled to bound gas on deep chains: ancestors accrue a claimable balance and must call claimAllRevenue. In Education mode the school's revenue silently piles up unclaimed unless someone claims for it.", + "severity": "medium", + "workaround": "Build a keeper/relayer to auto-claim on ancestors' behalf (claiming is permissionless - anyone can claim FOR an IP). Update CONTEXT.md line 49 / ADR wording: 'automatically' -> 'automatically credited, claimable on demand.' Low engineering cost; just must not be forgotten or the value proposition silently fails for ancestors." + }, + { + "where": "Step 3a/4 - synchronous 402 collar in front of a long-running managed-agent session", + "problem": "x402 maxTimeoutSeconds defaults ~60s; a managed-agent run (sandbox cold-start + agent loop) can exceed that. A naive synchronous 'hold the 402 open until the agent finishes' will time out. Also unmeasured: real cold sessions.create->first-token latency.", + "severity": "medium", + "workaround": "Settle the x402 payment FIRST (sub-second on Base), release the credential, THEN run the agent asynchronously and stream/return output via the collar separately - do not couple agent runtime to the payment handshake window. This is the natural ordering anyway (pay -> credential -> run). Benchmark cold-start before sizing SLAs." + }, + { + "where": "Step 3a/5 - per-invocation micro-royalty economics vs stacked fees", + "problem": "Each invocation now carries: CDP facilitator fee ($0.001/tx after 1k/mo) + Base gas + bridge fee + USDC->WIP swap slippage + Story gas + claim gas. For a skill invocation worth cents, these fees can exceed the royalty itself, making literal per-invocation on-chain royalty uneconomic.", + "severity": "medium", + "workaround": "Batching is MANDATORY (already implied by Leg-2). Aggregate royalties and settle on Story per threshold/interval. Price invocations above amortized settlement cost. This reintroduces a trusted accumulator (the collar) - the same custody/MSB tension - so batching window sizing must balance fee economics against custody exposure and reconciliation risk." + }, + { + "where": "Step 1/5 - Story currency is WIP-only; payers are on USDC", + "problem": "Mainnet Royalty Module whitelists ONLY WIP (wrapped $IP). Every payer/Beneficiary effectively takes $IP exposure and a wrap step, and $IP is down ~97.5% from ATH with thin liquidity - the tradeable royalty claim and accrued revenue are denominated in a volatile, illiquid asset. Enterprise Beneficiaries paying per invocation will not hold WIP.", + "severity": "medium", + "workaround": "Collar must build a fiat/USDC -> WIP on-ramp (no turnkey one confirmed). The two-leg design already does USDC->WIP at the bridge, so fold the wrap there. Flag the FX/liquidity risk: settlement value swings with $IP price between accrual and claim. Consider hedging or fast-claiming to limit WIP exposure window." + }, + { + "where": "Step 5 - tradeable royalty claim forces permissioned (not permissionless) trading", + "problem": "The headline feature - tradeable/co-held Royalty claims - almost certainly makes them securities under Howey (fractional claim on a revenue stream from the essential ongoing efforts of creator+platform; ADR-0004's 'live evolution/moats' STRENGTHENS the efforts-of-others prong). The March 2026 SEC interpretation does NOT carve out revenue-share tokens. So 'open composable royalty graph' cannot be permissionless: secondary trading needs a registered ATS + transfer agent + KYC allow-list (ERC-3643/BUIDL model).", + "severity": "high", + "workaround": "Permissioned trading via allow-list token (ERC-3643), Reg D 506(c) to accredited (or Reg A+/CF for retail), secondary only on an SEC-registered ATS (e.g. Securitize - FINRA-approved May 2026). Keep Intra-org and Education modes NON-securities by structuring co-held claims as non-transferable contractual/deferred-comp/license-fee rights. The derivative royalty MECHANIC (fork pays ancestors on-chain) is fine; it is the TRADEABILITY of the claim that triggers securities law. Not a blocker, but a structural constraint contradicting the 'frictionless composable' vibe." + }, + { + "where": "Cross-cutting trust topology - the collar re-centralizes the trust ADR-0003 tried to remove", + "problem": "In v1 platform-native topology the collar is simultaneously: (a) sole Anthropic key-holder, (b) in-flight fund custodian Base<->Story, (c) the OFF-CHAIN meter that decides which invocations get settled, and (d) (with Anthropic) able to see the Skill. ADR-0003 rejected a 'trusted oracle' because it could under/over-report - but the batched off-chain meter IS exactly a trusted accumulator that can skim or mis-report. The composition reintroduces the rejected trust assumption. ADR-0003's claim 'usage fraud on the money path is structurally impossible' holds only for the SYNCHRONOUS atomic case that the cross-chain gap forces you to abandon.", + "severity": "high", + "workaround": "Acknowledge v1 is trust-minimized-at-the-gate (no payment->no run is enforced per call) but trusted-at-settlement (off-chain batched accounting). Mitigate with: signed/auditable invocation logs, publishing settlement batches on-chain for reconciliation, refund/reputation for accept-payment-but-fail-to-run (ADR-0003 already notes this residual), and the tabled TEE as the eventual fix. Make ADR-0003 explicit that the 'no trusted oracle' guarantee degrades to 'auditable accumulator' once batching/cross-chain is introduced." + }, + { + "where": "Step 4 / scale - CMA create-endpoint rate limit", + "problem": "CMA caps create endpoints (agents/sessions/environments) at 300 req/min/org. A pure one-session-per-invocation marketplace at high frequency hits this org-level ceiling; token ITPM/OTPM also apply.", + "severity": "low", + "workaround": "Reuse long-lived sessions across a Wielder's invocations, queue, or shard across workspaces/orgs. UNVERIFIED: whether long-lived sessions cleanly isolate distinct buyers (history accumulation, compaction cost) - likely want one session per buyer, which reintroduces the ceiling. Design batching/pooling early; not a v1 blocker at low volume." + } + ], + "criticalBlockers": [] + }, + "validated": [ + { + "component": "managed-agents", + "finding": { + "component": "Skill-as-asset hosted-execution + payment-gating on Anthropic Managed Agents (CMA), cross-checked vs OpenAI Responses/AgentKit and Google Gemini Enterprise Agent Platform / Vertex Agent Engine. Validation as of June 2026.", + "verdict": "works-with-caveats", + "confidence": "high", + "howItWorks": "CONCRETE MECHANICS (Anthropic Managed Agents, beta `managed-agents-2026-04-01`, launched ~April 8-9 2026, public beta enabled by default; NOT GA):\n\nAPI shape is exactly as ADR-0001 assumes. Two-resource model: POST /v1/agents creates a persisted, versioned Agent object carrying {model, system, tools, mcp_servers, skills}. POST /v1/sessions references that agent BY ID ONLY (string = latest version, or {type:\"agent\", id, version:N} to pin) plus an environment_id. You then POST /v1/sessions/{id}/events (user.message) and consume an SSE stream from /v1/sessions/{id}/events/stream. Anthropic runs the agent loop on its orchestration layer; tools execute in a per-session sandbox container. Skills attach to the AGENT, not the session, and load with progressive disclosure.\n\n(a) SKILL-HIDING — PARTIALLY TRUE, with a sharp limit. The session output stream NEVER carries the system prompt or skill bodies: the documented event types are agent.message, agent.thinking, agent.tool_use/result, agent.mcp_tool_use/result, agent.custom_tool_use, session.status_*, span.*. None expose `system`/`skills`. So a party who only ever sees session OUTPUT (your Wielder) cannot read the Skill. HOWEVER, the system prompt is NOT secret from the API-key holder: GET /v1/agents/{id} echoes the full config back verbatim — the docs response literally contains `\"system\": \"You are a helpful coding agent.\"`. OpenAI is worse for hiding: GET /v1/responses/{id} returns the `instructions` (system) field and input items; and OpenAI is actively deprecating server-side reusable prompt objects (v1/prompts de-emphasized 2026-06-03, shutdown 2026-11-30), pushing prompts back into caller code. So the hiding property is a function of WHO holds the platform API key, not a platform secrecy feature. For your topology this is fine — the buyer (Wielder) is NOT given an Anthropic key; they hit YOUR collar. The Skill is hidden from the Wielder because your collar never returns the agent config and never proxies GET /v1/agents. This is consistent with ADR-0004 (no reliance on platform secrecy; compete on moats).\n\n(b) PER-INVOCATION GATING — NOT NATIVE; MUST live in your collar. There is NO pre-execution authorization hook on any of the three platforms. CMA authenticates with the org x-api-key (Authorization bearer) only; there is no field on sessions.create or events.send for an external credential / payment proof that Anthropic validates before running. The closest native primitive — permission_policy: always_ask — gates individual TOOL calls MID-RUN, and the approver is the API-key holder (your collar sends user.tool_confirmation allow/deny). It is a human/operator-in-the-loop tool gate, not a payment gate, and it can't stop the turn from starting/consuming tokens. OpenAI Responses/AgentKit: bearer key only, no per-request authz callback documented. Google (now \"Gemini Enterprise Agent Platform\", Agent Engine runtime; sessions+Memory Bank GA): governed by GCP IAM at the principal level, not a per-invocation credential the platform checks. CONCLUSION: ADR-0003 (\"no credential, no run\") is implementable, but the gate is 100% YOUR proxy. Flow: payment -> collar mints single-use execution credential -> collar verifies it -> ONLY THEN does the collar call sessions.create/events.send with the org key. The chain witnesses the payment; Anthropic witnesses only an authenticated API call from your key. That matches the design intent.\n\n(c) LATENCY/COST — acceptable for v1. CMA runtime billing is $0.08 per session-HOUR of ACTIVE runtime, measured to the millisecond, accruing only while status=running; idle and rescheduling are free; plus standard per-token model costs on top. A 20-min active run ~= $0.027 runtime + tokens. The real throughput ceiling: CREATE endpoints (agents/sessions/environments) are capped at 300 requests/min/org; read/stream at 600/min/org. For a high-frequency per-invocation marketplace, 300 session-creates/min is a hard org-level limit to design around (batch, queue, or reuse long-lived sessions across invocations). Token limits (ITPM/OTPM) still apply to inference inside sessions.", + "evidence": [ + { + "source": "Anthropic docs — Managed Agents overview", + "url": "https://platform.claude.com/docs/en/managed-agents/overview", + "note": "Beta, header managed-agents-2026-04-01, enabled by default for all API accounts; NOT GA. Agent/Environment/Session/Events four-concept model; event history persisted server-side; not ZDR/HIPAA-eligible." + }, + { + "source": "Anthropic docs — Define your agent (agent-setup)", + "url": "https://platform.claude.com/docs/en/managed-agents/agent-setup", + "note": "DECISIVE for hiding limit: GET/create response echoes config including \"system\": \"You are a helpful coding agent.\" and tools/skills. So the system prompt IS readable by the API-key holder; hiding only holds against parties who lack the key." + }, + { + "source": "Anthropic docs — Start a session (sessions)", + "url": "https://platform.claude.com/docs/en/managed-agents/sessions", + "note": "Session references agent by ID only (string=latest, or {type,id,version}); model/system/tools live on the agent, NOT the session. No external-credential/payment-proof field on create or events.send. Auth is x-api-key. vault_ids is for MCP OAuth only." + }, + { + "source": "Anthropic docs — Permission policies", + "url": "https://platform.claude.com/docs/en/managed-agents/permission-policies", + "note": "Only always_allow / always_ask. always_ask pauses for approval via user.tool_confirmation FROM THE API-KEY HOLDER and gates individual tool calls mid-run — NOT a pre-execution payment/credential gate. No platform-side authorization hook exists." + }, + { + "source": "Anthropic docs — Reference (event types, rate limits, pricing)", + "url": "https://platform.claude.com/docs/en/managed-agents/reference", + "note": "Event-type tables: stream emits agent.message/thinking/tool_*/custom_tool_use, session.status_*, span.* — NONE carry the system prompt or skill bodies. Create RPM=300/org, read RPM=600/org." + }, + { + "source": "WebSearch — CMA pricing (multiple 2026 sources incl. Verdent, Tygart, WaveSpeed)", + "note": "$0.08 per session-hour of ACTIVE runtime, billed to the millisecond; idle/rescheduling not billed; standard token costs additional. ~$0.027 for a 20-min active run + tokens." + }, + { + "source": "OpenAI docs — Responses retrieve + prompting/migrate-from-prompt-object", + "url": "https://developers.openai.com/api/reference/resources/responses/methods/retrieve", + "note": "GET /v1/responses/{id} returns the `instructions` (system/developer message) and input items — system prompt is recoverable by the key holder. Reusable prompt objects (v1/prompts) being deprecated (de-emphasized 2026-06-03; shutdown 2026-11-30), pushing prompts into caller code. Bearer-key auth only; no per-request authz callback documented." + }, + { + "source": "WebSearch + Google Cloud docs — Vertex AI Agent Engine / Gemini Enterprise Agent Platform", + "url": "https://cloud.google.com/vertex-ai/generative-ai/docs/agent-engine/overview", + "note": "Vertex AI rebranded to Gemini Enterprise Agent Platform (Cloud Next 2026); Agent Engine managed runtime; sessions + Memory Bank GA. Access governed by GCP IAM at principal level — no per-invocation credential the platform validates before execution. Optional prompt/response logging stores full content server-side." + }, + { + "source": "WebSearch — CMA launch/GA status", + "url": "https://www.roborhythms.com/anthropic-managed-agents-2026/", + "note": "Launched ~April 8-9 2026 as public beta; POST /v1/agents (persistent agent w/ system prompt+tools+permissions) and POST /v1/sessions (execution session) confirmed by independent coverage." + } + ], + "caveats": [ + "Skill-hiding is conditional on key custody, NOT a platform secrecy guarantee. CMA GET /v1/agents/{id} returns the full system prompt; OpenAI GET /v1/responses/{id} returns `instructions`. Hiding works for your design ONLY because the Wielder never gets an org API key and your collar never proxies the agent-read or returns config. If you ever expose the platform key (or let a buyer self-host the agent), the Skill text leaks. This is the concrete realization of ADR-0004: secrecy is not load-bearing.", + "No native 'no credential, no run' gate exists on ANY of the three platforms. ADR-0003 is fully your-collar responsibility. The platform witnesses an authenticated call from your key — it does not witness or enforce the per-invocation payment. always_ask is a MID-RUN tool-approval gate keyed to the API-key holder, not a pre-execution payment gate; it cannot prevent token spend on the turn.", + "CMA is BETA, not GA (managed-agents-2026-04-01). Anthropic states behaviors may be refined between releases. SDK shapes (beta.agents/sessions namespaces) can change. Also: CMA sessions are not ZDR- or HIPAA-BAA-eligible because history/sandbox/outputs are stored server-side — relevant for intra-org and education modes with regulated data.", + "Create-endpoint rate limit is 300 req/min/org. A pure 1-session-per-invocation marketplace at scale will hit this. Mitigations: reuse long-lived sessions across a Wielder's invocations, queue, or shard across workspaces/orgs. Token ITPM/OTPM also still apply inside sessions.", + "The system prompt / Skill is not in the OUTPUT stream, but it IS sent to and processed by Anthropic's infrastructure in plaintext (no TEE/confidential compute — explicitly tabled in ADR-0004). Anthropic, as the host, can see the Skill. The design only hides it from the buyer, not from the platform.", + "Derivative royalty flow-through, IP-Asset provenance, royalty tokens (ADR-0002) are entirely a Story Protocol concern — none of the three agent platforms model royalties, derivative graphs, or co-held claims. CMA's agent 'versioning' is config history, NOT a derivative/fork lineage you can attach royalty splits to. The fork-pays-ancestors economics live 100% in your Story Protocol layer + collar, not the runtime.", + "Custom tools (type:custom) are an escape hatch worth noting: when the agent calls one, the platform emits agent.custom_tool_use and waits for your collar to return user.custom_tool_result. This lets you keep secrets/host-side logic out of the sandbox — useful if a Skill needs to call a paid downstream API metered separately." + ], + "gaps": [ + "Did not verify whether long-lived sessions can be re-driven across many independent buyer invocations cleanly (history accumulation, compaction cost, isolation between buyers in one session) — likely you want one session per buyer, which reintroduces the 300/min create ceiling.", + "Did not measure real end-to-end first-token latency for a cold sessions.create -> first agent.message (sandbox provisioning blocks until resources mount). 'Acceptable' is from pricing/architecture, not a timed benchmark.", + "Did not confirm whether OpenAI AgentKit/ChatKit or Google Agent Engine offer any per-invocation metering hook closer to a gate than IAM (searches did not surface one; appears none, but not exhaustively confirmed).", + "Whether a buyer can be given a SCOPED Anthropic credential that permits sessions.create+events but FORBIDS GET /v1/agents (which would let you skip the collar-proxy for execution while still hiding the Skill) — not confirmed; CMA permissions appear org/workspace-scoped, not per-endpoint, so assume the collar must remain the sole key holder.", + "Self-hosted sandbox mode (config:{type:self_hosted}) moves tool execution to your infra — relevant to intra-org/education compliance and to keeping derivative-side tool logic private — but its interaction with the hiding/gating model was not deep-dived." + ], + "claimUnderTest": "That an Anthropic Managed Agent (or OpenAI/Google hosted agent) can host a Skill/system-prompt server-side that is never returned to the API caller, AND gate per-invocation execution on an externally-supplied credential/payment proof." + }, + "verification": { + "refuted": false, + "correctedVerdict": "works-with-caveats", + "refutationBasis": "Could not refute. Every load-bearing claim was independently corroborated against live primary sources (platform.claude.com docs, OpenAI API reference, Google Cloud). The two items that looked like potential refutation vectors actually reinforce the finding once examined: (1) CMA webhooks exist but are OUTBOUND event/observability notifications (session.status_*, outcome evaluation, whsec_ signed) that fire after events occur — they are NOT a pre-execution authorization hook the platform calls and blocks on, so the \"no native no-credential-no-run gate\" claim stands. (2) A search surfaced a PreToolUse hook with allow/deny/ask/defer, but that belongs to the Claude Agent SDK / Claude Code (self-hosted), NOT the hosted Managed Agents platform; CMA's equivalent is exactly the always_ask + user.tool_confirmation mechanism the finding already describes. Additionally, Anthropic API keys were confirmed to be workspace-scoped with only full-access/read-only granularity (no endpoint-level scoping), which CONFIRMS the finding's conservative gap that the collar must remain the sole key holder.", + "notes": "VERIFIED CLAIMS (all from live primary docs):\n- CMA exists, beta header managed-agents-2026-04-01, launched ~Apr 8 2026, public beta, NOT GA. Two-resource model (agents created via POST /v1/agents; sessions reference agent by ID + environment_id) confirmed verbatim.\n- DECISIVE hiding-limit claim CONFIRMED: agent create/GET response echoes config including \"system\": \"You are a helpful coding agent.\" plus tools/skills/mcp_servers verbatim. So the system prompt IS readable by the API-key holder; hiding holds only against parties who lack the key (the Wielder). Matches finding exactly.\n- Session output event catalog CONFIRMED from reference doc: user.*, agent.message/thinking/tool_use/tool_result/mcp_tool_use/mcp_tool_result/custom_tool_use/thread_*, session.status_*, span.* — NONE carry the system prompt or skill bodies. Matches finding.\n- Permission policies CONFIRMED: only always_allow and always_ask. always_ask pauses MID-RUN for the API-key holder to allow/deny individual tool calls via user.tool_confirmation. NOT a pre-execution payment/credential gate. No platform-side authorization hook. Matches finding.\n- vault_ids is for MCP OAuth credentials only; session auth is x-api-key. No external-credential/payment-proof field on sessions.create or events.send. Confirmed.\n- Rate limits CONFIRMED verbatim: create endpoints 300 req/min/org; read/stream 600 req/min/org.\n- Pricing CONFIRMED across multiple 2026 sources: $0.08 per ACTIVE session-hour, billed to the millisecond; idle/rescheduling free; standard token costs on top. ~$0.027 for a 20-min run + tokens. Accurate.\n- CMA is NOT ZDR-eligible (stateful, persistent session storage) — confirmed. Matches caveat.\n- OpenAI GET /v1/responses/{id} returns the instructions (system/developer) field plus input items — confirmed from OpenAI API reference. Reusable prompt objects deprecated (notified 2026-06-03) — confirmed.\n- Google: Vertex AI rebranded to Gemini Enterprise Agent Platform at Cloud Next 2026; Agent Engine runtime; access governed by GCP IAM at principal level, not a per-invocation credential validated before execution — confirmed.\n\nMINOR INACCURACIES (do not affect verdict):\n- Docs now show model claude-opus-4-8 (and reference claude-opus-4-6/4.5-family support); the finding's example used the older opus-4-6 label. Cosmetic only.\n- Finding pairs \"not ZDR\" with \"not HIPAA-BAA-eligible\" for CMA. ZDR-ineligibility for CMA is firmly confirmed. The narrower HIPAA-for-CMA point is plausible but I did not find an explicit doc statement that CMA specifically is excluded from BAA coverage (the broader Claude API does support HIPAA via BAA). Treat the HIPAA half of that caveat as not independently confirmed, but it is conservative and does not bear on the core claim.\n\nNEW CORROBORATING DETAIL: CMA shipped outbound webhooks (Console-configured HTTPS endpoint, whsec_ signing secret) for session.status_* and outcome-evaluation events. These are notifications, not a blocking pre-execution authz hook — so they do not provide the native gate the finding says is absent. Anthropic API keys are workspace-scoped with only full/read-only granularity (no endpoint-level scoping), confirming the collar must hold the sole key.\n\nCONCLUSION: The core claim — that a CMA-hosted Skill/system-prompt is never returned in the session OUTPUT stream (hidden from a party who only sees output, e.g. the Wielder), AND that per-invocation execution gating on an external payment/credential must be implemented entirely in the caller's proxy (no native platform gate on any of the three platforms) — is accurate and well-supported. The verdict works-with-caveats is correct, with high confidence. The finding's caveats (hiding is key-custody-dependent not a platform secrecy guarantee; gate is 100% the caller's responsibility; beta not GA; 300/min create ceiling; plaintext to Anthropic infra/no TEE; royalty/derivative economics live entirely in Story Protocol layer) are all sound." + } + }, + { + "component": "x402", + "finding": { + "claimUnderTest": "x402 can (a) gate a per-invocation API call and produce a verifiable receipt our collar checks before releasing execution, and (b) settle that payment toward a Story Protocol royalty contract that lives on Story's own L1.", + "component": "x402 payment-gating + cross-chain settlement to Story Protocol royalty module (SKILL ASSET PROTOCOL collar, ADR 0003 + ADR 0002)", + "verdict": "works-with-caveats", + "confidence": "high", + "howItWorks": "PART (a) — GATING + RECEIPT: SOLID, this is exactly what x402 is for. The collar is a textbook x402 resource server sitting in front of the managed-agent endpoint. Flow: wielder calls the collar -> collar returns HTTP 402 + PAYMENT-REQUIRED header (base64 JSON: scheme \"exact\", network CAIP-2, maxAmountRequired in atomic units, payTo, asset, maxTimeoutSeconds) -> wielder's client signs an EIP-3009 TransferWithAuthorization (gasless, EIP-712 typed-data sig, recipient + amount + 32-byte random nonce bound INTO the signature) and resubmits with PAYMENT-SIGNATURE -> collar calls facilitator POST /verify (checks sig, amount, asset, payTo, chainId, time, nonce), then POST /settle (submits the transfer on-chain) -> on success the collar runs the hidden Skill on the managed agent and returns 200 + PAYMENT-RESPONSE header carrying {success, txHash, networkId}. The RECEIPT the collar needs is precisely this: a settled on-chain txHash + the spent EIP-3009 nonce. Replay/idempotency is enforced at the token-contract level — the random bytes32 nonce is permanently recorded on-chain, so a given signed authorization can settle exactly once; if the agent run fails after settlement the same PAYMENT-SIGNATURE can be safely resubmitted (consume-once). This is a clean fit for ADR 0003 'no credential, no run' — the settled txHash IS the single-use execution credential, and the chain witnesses only the payment, never the Skill (satisfies ADR 0001/0003). Maturity is high: x402 is now a Linux Foundation / x402 Foundation standard with ~165M tx and ~$50M cumulative volume by April 2026, V2 SDK (CAIP-2 networks, modular @x402/* packages), Stripe shipped an x402 integration for USDC-on-Base (preview API 2026-03-04, 'purl' CLI) Feb 2026, and AWS Bedrock AgentCore + Cloudflare back it. Per-call economics are micropayment-grade: ~200ms settle target, Base finality with sub-cent fees (Solana ~$0.00025, Stellar near-zero). PART (b) — SETTLE TO STORY ROYALTY CONTRACT: THIS IS THE BROKEN SEAM. Story Protocol is its own EVM L1 (Chain ID 1514 -> CAIP-2 eip155:1514). Its Royalty Module is fed by calling payRoyaltyOnBehalf(ipId, amount, token) ON STORY'S CHAIN, paid in a WHITELISTED token — on mainnet that is WIP (wrapped IP, 0x1514...0000), NOT USDC. So a valid settlement must (i) land on eip155:1514 and (ii) be denominated in WIP and (iii) invoke a contract function, not just pay an EOA. x402 fails all three out of the box: (1) The CDP/Coinbase facilitator supports Base, Polygon, Arbitrum, World, Solana (+ Stellar via OZ relayer) — Story (1514) is NOT supported by any facilitator I could find (no Story facilitator in the x402 ecosystem directory as of mid-2026). x402 settlement is single-chain: the transfer settles on the SAME chain the payment was signed for; there is no native cross-chain settlement. (2) x402 'exact' settles a token TRANSFER to a payTo address. To target a contract FUNCTION you need x402-exec (nuwa-protocol) — a SettlementRouter + Hook framework that DOES atomically run an arbitrary contract call (e.g. a royalty deposit hook) inside the settlement tx — but it is deployed only on Base / X-Layer / BSC (+ testnets), explicitly NOT cross-chain and NOT on Story. (3) Even EIP-3009 to a contract needs receiveWithAuthorization, and WIP/the Royalty Module would have to implement it. NET: you cannot point an x402 payTo at a Story royalty contract on eip155:1514 today. You must bridge. The realistic v1 architecture is a TWO-LEG design: Leg 1 = x402 settles USDC on Base (supported, cheap, fast) to a collar-controlled treasury, producing the txHash receipt that gates execution. Leg 2 = an ASYNCHRONOUS settlement worker bridges/swaps USDC(Base) -> WIP(Story) via Stargate/Across/deBridge (Story supports bridged USDC.e via Stargate) and then calls payRoyaltyOnBehalf on Story to drive ADR 0002's derivative royalty flow-through. Crucially these two legs are DECOUPLED: execution is gated by Leg-1 finality (sub-second), while the Story royalty leg settles later (bridge fills 2–15s for Across, longer + fees for canonical paths) and in batches. Cross-chain failure modes you must own: bridge latency/failure, USDC->WIP swap slippage, per-bridge-hop fees that can dwarf a micro-royalty (batching is mandatory or the royalty is uneconomic), reconciliation if a bridge stalls after execution already happened, and trust in the collar treasury during the in-flight window (you become custodian between the two legs — the atomic 'fork pays its ancestors on every invocation' promise of ADR 0002 becomes eventually-consistent, not per-invocation atomic).", + "evidence": [ + { + "source": "x402 skill protocol-spec.md (bundled, AI-Toolkit/x402-payments 2.1.0)", + "note": "V2 header flow PAYMENT-REQUIRED/PAYMENT-SIGNATURE/PAYMENT-RESPONSE; exact scheme = EIP-3009 TransferWithAuthorization gasless w/ random 32-byte nonce; facilitator /verify, /settle, /supported; PAYMENT-RESPONSE carries {success,txHash,networkId}; CAIP-2 network table lists Base/ETH/Polygon/Arbitrum/Avalanche/Solana — NO Story." + }, + { + "source": "Coinbase CDP docs — x402 Network Support", + "url": "https://docs.cdp.coinbase.com/x402/network-support", + "note": "Authoritative facilitator network list: Base(8453), Base Sepolia, Polygon(137), Arbitrum(42161), World(480), Solana mainnet+devnet. Explicitly NOT Story (1514). EVM token support via EIP-3009 (USDC/EURC) or Permit2; Solana SPL/Token2022." + }, + { + "source": "Story Protocol docs — Royalty Module overview", + "url": "https://docs.story.foundation/concepts/royalty-module/overview", + "note": "Payments via payRoyaltyOnBehalf on Story's chain; mainnet whitelisted token = WIP (0x1514...0000), NOT USDC; revenue flows into per-IP Royalty Vault; token must be whitelisted in RoyaltyModule.sol. Confirms contract-function settlement in WIP on eip155:1514." + }, + { + "source": "Multiple (thirdweb Story Iliad, datawallet, ethereum-lists/chains)", + "note": "Story Protocol = EVM-compatible L1, Chain ID 1514, so CAIP-2 = eip155:1514. EVM-equivalent, Cosmos-SDK base." + }, + { + "source": "nuwa-protocol/x402-exec (GitHub)", + "url": "https://github.com/nuwa-protocol/x402-exec", + "note": "Proves payment->arbitrary-contract-call atomic settlement is real via SettlementRouter + Hooks (revenue split, NFT mint, royalty deposit examples). BUT deployed only Base/X-Layer/BSC(+testnets), 'operates on individual chains — does NOT provide cross-chain settlement.' Not on Story." + }, + { + "source": "Search: EIP-3009 / ERC-7598 contract-recipient best practice", + "url": "https://eips.ethereum.org/EIPS/eip-3009", + "note": "to address is bound into the signature; calling FROM/INTO a contract should use receiveWithAuthorization (checks caller==payee). Confirms settling to a contract is possible but requires the right variant + token support." + }, + { + "source": "Stripe docs — x402 payments + crypto.news/financefeeds coverage", + "url": "https://docs.stripe.com/payments/machine/x402", + "note": "Stripe x402 integration live Feb 2026 for USDC on Base, preview API 2026-03-04, Stripe-Version header, 'purl' CLI, US-businesses-only to accept. Confirms Stripe rail is Base/USDC — same chain gap; does not reach Story." + }, + { + "source": "Stripe+Tempo MPP (WorkOS/Zinc/CCN coverage)", + "url": "https://workos.com/blog/x402-vs-stripe-mpp-how-to-choose-payment-infrastructure-for-ai-agents-and-mcp-tools-in-2026", + "note": "MPP (Mar 18 2026) layers discovery/authorization above x402's wire handshake; relevant if collar wants agent-side discovery, still doesn't add Story settlement." + }, + { + "source": "Search: x402 finality/fees + MEXC/Allium volume", + "url": "https://www.allium.so/blog/x402-explained-the-internet-native-payments-standard-for-apis-data-and-agent-commerce/", + "note": "~200ms settle target; Base/Solana fast finality sub-cent; Base ~119M tx/$35M; protocol ~165M tx/$50M cumulative by Apr 2026; ~49% volume on non-Coinbase facilitators." + }, + { + "source": "Search: Story bridging (datawallet, Stargate, Story docs)", + "url": "https://stargate.finance/", + "note": "Story supports bridged USDC.e via Stargate; deBridge/Orbiter also route to Story; Across intent fills 2–15s. This is the mandatory Leg-2 bridge path to get value from Base-USDC to Story-WIP." + }, + { + "source": "arXiv 2603.01179 — A402: Binding Cryptocurrency Payments to Service Execution", + "url": "https://arxiv.org/pdf/2603.01179", + "note": "Independent 2026 academic confirmation that (i) binding payment receipt to service execution is the right model (matches ADR 0003) and (ii) cross-chain payment/service split is a named open limitation causing settlement delay/complexity — corroborates the seam adversarially." + }, + { + "source": "Search: x402 security mechanisms / receipt verification", + "url": "https://agentpaytrend.com/x402-protocol-security-3-mechanisms/", + "note": "On-chain bytes32 nonce makes replay impossible; /verify validates sig+amount+asset+payTo+chainId+time+nonce before serving; settled-but-failed-response can resubmit same signature (consume-once idempotency). This is the collar's receipt-check primitive." + } + ], + "caveats": [ + "CROSS-CHAIN SETTLEMENT IS THE LOAD-BEARING GAP: x402 cannot settle directly onto Story (eip155:1514). No facilitator (CDP or third-party) supports Story as of mid-2026, and x402 settlement is single-chain by design. The ADR 0002 dream of 'a fork automatically pays its ancestors atomically on every invocation' is NOT achievable as one on-chain action through x402.", + "TOKEN MISMATCH: Story's Royalty Module whitelists WIP (wrapped native IP) on mainnet, not USDC. x402's liquid rails are USDC-on-Base. Every invocation's royalty leg requires a USDC->WIP swap + bridge, adding slippage and fee risk on top of the bridge.", + "FEE ECONOMICS BREAK PER-INVOCATION ROYALTIES: bridge + swap fees per hop can exceed a single micro-royalty. You MUST batch the Story-side payRoyaltyOnBehalf calls (settle royalties in aggregate, e.g. hourly/daily), which contradicts a literal per-invocation on-chain royalty and reintroduces a trusted accumulator (the collar treasury).", + "CUSTODY/TRUST WINDOW: the two-leg design makes the collar a custodian of funds between Leg-1 (USDC received on Base, gates execution) and Leg-2 (bridged to Story, royalties paid). This is a centralized trust + reconciliation surface, and a bridge stall means execution already happened but ancestors are not yet paid.", + "CONTRACT-CALL SETTLEMENT NEEDS x402-exec-class tooling, not vanilla x402: vanilla 'exact' pays an EOA/address. Driving payRoyaltyOnBehalf as part of settlement needs a Hook/router (x402-exec proves the pattern) — but x402-exec is only on Base/X-Layer/BSC, so it can atomically deposit on Base, never on Story. Story-side calls remain a separate post-bridge transaction.", + "RECEIVE-WITH-AUTHORIZATION REQUIREMENT: even ignoring chains, settling EIP-3009 into a contract needs receiveWithAuthorization support; WIP / the Royalty Module would need to support it for any direct-to-contract settlement — unverified, likely not the case.", + "RECENCY: figures (165M tx, $50M, Stripe Feb 2026, MPP Mar 2026, Stellar facilitator Mar 2026) are as of ~Apr–Jun 2026 secondary reporting; Story facilitator support could change — re-check the x402 ecosystem directory and CDP network-support page before building." + ], + "gaps": [ + "Could not confirm from a PRIMARY source (only inferred) that NO third-party x402 facilitator has added Story (eip155:1514). Verify directly at https://www.x402.org/ecosystem and Story's own docs/partners before finalizing.", + "Did not verify whether Story's WIP token or RoyaltyModule.sol implements EIP-3009/ERC-7598 receiveWithAuthorization (needed for any hypothetical direct x402-to-Story-contract settlement). Needs a contract-level read on Story mainnet.", + "Did not find a concrete, documented reference implementation of an x402-collar -> bridge -> Story payRoyaltyOnBehalf pipeline; this two-leg design appears novel and unproven end-to-end — prototype Leg-2 latency/fees on real Story mainnet before committing.", + "Exact per-bridge-hop cost and confirmation time for USDC(Base)->WIP(Story) specifically (vs generic Stargate/Across quotes) not measured; needed to size the batching window and confirm micro-royalty economics.", + "x402 'upto'/'deferred' schemes (usage-based / batched settlement, the latter proposed by Cloudflare) are still proposed/not production — they would be the natural fit for batched royalty settlement and metered per-token Skill pricing, but cannot be relied on yet.", + "Whether Anthropic Managed Agents endpoint can sit cleanly behind a synchronous 402 collar without timeout conflicts (agent runtimes can be long; maxTimeoutSeconds default ~60s) was not validated against Anthropic's actual managed-agent latency/timeout behavior." + ] + }, + "verification": { + "refuted": false, + "correctedVerdict": "works-with-caveats", + "refutationBasis": "Could not refute the core dual claim. Independent mid-2026 sources corroborate both parts. PART (a) gating+receipt is verified: the x402 V2 exact scheme uses EIP-3009 transferWithAuthorization, returns a settlement receipt (X-PAYMENT-RESPONSE header carrying {success, txHash, networkId}), and enforces replay protection via an on-chain bytes32 nonce (confirmed by coinbase/x402 spec scheme_exact_evm.md, Avalanche Builder Hub payment-flow docs, and agentpaytrend security analysis). Maturity figures all check out: ~165M tx / ~$50M cumulative volume by late April 2026, donation to the x402 Foundation at the Linux Foundation on 2026-04-02, Stripe x402 USDC-on-Base live Feb 2026 (preview API 2026-03-04, X-PAYMENT header), Stellar facilitator Mar 2026, AWS Bedrock AgentCore Payments preview May 2026. PART (b) the broken seam is also confirmed: Coinbase CDP facilitator supports only Base/Polygon/Arbitrum/World/Solana (Story 1514 explicitly absent on docs.cdp.coinbase.com/x402/network-support); the x402.org ecosystem directory lists no Story facilitator; Story's Royalty Module uses payRoyaltyOnBehalf, whitelists WIP (not USDC) on mainnet, and runs on Story's own chain; the x402 exact scheme settles a token TRANSFER to a payTo address, not a contract function call; nuwa-protocol/x402-exec is Base/X-Layer/BSC only with no cross-chain support and no Story; and x402 cross-chain is only achievable via separate integrations (Chainlink CCIP, Morpheum, or bridges like deBridge/Stargate), none of which reach Story — the protocol is single-chain by design. The two-leg bridge architecture and Story bridging via deBridge/Stargate are both confirmed realistic. The finding's conclusion stands and is if anything strengthened.", + "notes": "Verdict upheld: works-with-caveats (high confidence). Three corrections / additions that do not refute but should be folded in:\n\n1. HIDDEN COST (new, not in finding): The Coinbase CDP facilitator is no longer free — it charges $0.001/transaction after 1,000 free tx/month, plus on-chain gas separately (docs.cdp.coinbase.com/x402/network-support). For a micropayment/per-invocation collar this facilitator fee stacks on top of bridge+swap fees and reinforces the finding's 'fee economics break per-invocation royalties / batching is mandatory' caveat. The finding cited ~$0.00025-near-zero settle costs (Solana/Stellar) but did not flag the CDP facilitator service fee.\n\n2. IMPRECISE IDEMPOTENCY PHRASING (correction): The finding states that after a settled-but-failed agent run 'the same PAYMENT-SIGNATURE can be safely resubmitted (consume-once).' This is misleading. Once the bytes32 nonce settles on-chain, re-submitting the identical signature is REJECTED by the token contract — it does NOT re-settle and is NOT re-served by the protocol. There is no protocol-level consume-once replay window (unlike Stripe idempotency keys). The collar must implement application-layer logic that recognizes the already-spent nonce/txHash and releases execution from its own records. This does not break the design — the settled txHash IS the durable single-use credential, which actually fits ADR 0003 — but the protocol does not 'safely resubmit' for you; the collar owns that bookkeeping.\n\n3. TRANSFER vs RECEIVE WITH AUTHORIZATION (confirms a gap): Verified the x402 exact EVM scheme uses transferWithAuthorization, and the spec does not mention receiveWithAuthorization at all. Combined with the exact scheme settling to a payTo address (not a function call), this doubly blocks any direct x402->Story-royalty-contract settlement, even ignoring the chain gap. The finding's caveat about needing x402-exec-class tooling and receiveWithAuthorization is correct; the WIP token / RoyaltyModule.sol receiveWithAuthorization support remains unverified (still an open gap, as the finding noted).\n\nNET: Part (a) is solid and should remain 'works.' Part (b) remains a genuine, load-bearing gap requiring a decoupled two-leg (x402 USDC-on-Base -> bridge/swap -> payRoyaltyOnBehalf in WIP on Story) architecture with the collar as in-flight custodian and mandatory batching. The ADR 0002 'fork pays ancestors atomically per invocation' promise is unachievable as one on-chain action via x402 — eventually-consistent at best. No outdated claims, no mainnet/testnet confusion, no API over-reads detected, aside from the two phrasing/cost items above.\n\nPrimary sources verified: docs.cdp.coinbase.com/x402/network-support; github.com/coinbase/x402 spec scheme_exact_evm.md; docs.story.foundation royalty-module overview; github.com/nuwa-protocol/x402-exec; x402.org/ecosystem; Stripe docs.stripe.com/payments/machine/x402; deepbase.org (CCIP cross-chain) and Morpheum cross-chain (single-chain-by-design corroboration); deBridge/Stargate Story bridging." + } + }, + { + "component": "story-protocol", + "finding": { + "component": "Skill Asset Protocol — validation of ADR 0002/0003 against the real Story Protocol SDK + contracts (mid-2026)", + "verdict": "works-with-caveats", + "howItWorks": "Story Protocol is a live, EVM-compatible PoS L1 (CometBFT consensus; mainnet \"Homer\", chainId 1514, live since Feb 13 2025). protocol-core-v1 is at v1.3.2 (Apr 2025, audited, BUSL-1.1) and the TypeScript SDK at v1.4.4 (Mar 2025). All four primitives the design needs exist as real, audited modules, and each is an on-chain transaction.\n\n(a) REGISTER A SKILL AS AN IP ASSET WITH PIL — Yes. registerIpAsset + registerPILTerms + attachLicenseTerms (or one-call mintAndRegisterIpAssetWithPilTerms). PIL is an off-chain legal template mapped on-chain with machine-readable flags (commercial use, derivatives, attribution, royalty %, minting fee). Use PILFlavor.commercialRemix() to get commercial use + derivatives + royalty in one call. This cleanly models a Skill IP Asset.\n\n(b) DERIVATIVE ROYALTY FLOW-THROUGH — Confirmed and real, but CLAIMABLE, not auto-credited. Each IP gets an IP Royalty Vault on creation. Revenue is paid via payRoyaltyOnBehalf in a whitelisted ERC-20 (on MAINNET the ONLY whitelisted currency is WIP = wrapped IP, 0x1514...0000). A \"Royalty Stack\" of ancestor obligations is deducted at the vault, but ancestors must call claimAllRevenue to pull their share — payment and claiming are deliberately decoupled to keep deep-chain transactions from blowing up gas. LAP (Liquid Absolute %) = every ancestor in the chain shares; LRP (Liquid Relative %) = only direct parents. So the \"fork automatically pays all its ancestors on every invocation\" claim is true in accounting terms but NOT in cash-in-hand terms: ancestors receive a claimable balance, they don't get pushed funds. Depth limits are real: up to 1024 ancestors and 8 parents per IP (so the Education chain student->school is trivially within bounds, but a viral derivative graph can hit ceilings).\n\n(c) CO-HELD ROYALTY CLAIMS — Fully supported. Each IP's vault mints exactly 100 Royalty Tokens (ERC-20-compatible), each = 1% of that IP's revenue. They can be split/transferred/sold to multiple wallets; each holder claims pro-rata. This directly implements the employee+employer co-hold (Intra-org) and the tradeable fractional Royalty claim. Note granularity is 1% (100 units) at the vault level — finer splits need an off-chain or wrapper layer.\n\n(d) PER-INVOCATION LICENSE TOKEN AS EXECUTION CREDENTIAL — Mechanically yes, economically the weak point. A License Token IS an ERC-721 NFT minted via mintLicenseTokens(licensorIpId, licenseTermsId, receiver, amount, maxMintingFee...), it carries the PIL terms, and it IS burned when consumed (today: burned to register a derivative; you'd repurpose it as a single-use run credential). But minting is a full on-chain tx, requires a WIP minting fee + ERC-20 approval (SDK auto-wraps IP->WIP and inserts the approval tx), and License Tokens were designed for derivative registration, NOT high-frequency per-call metering. Routing one on-chain mint per LLM invocation is the core risk.\n\n(e) SDK/MAINNET MATURITY — Strong. Mainnet ~16 months live, audited core, mature TS SDK (Python SDK + an MCP hub also exist), Multicall3 batching and auto-IP->WIP wrapping built in. Gas is intentionally low (co-founder: \"we put our chain gas fee pretty low... we're more of an IP chain\").", + "confidence": "high", + "evidence": [ + { + "source": "Story Docs — Royalty Module overview", + "url": "https://docs.story.foundation/concepts/royalty-module/overview", + "note": "100 royalty tokens/vault = 1% each; Royalty Stack deducted upstream; payment vs claiming decoupled to cut gas on deep chains; claimAllRevenue." + }, + { + "source": "Story Docs — IP Royalty Vault", + "url": "https://docs.story.foundation/concepts/royalty-module/ip-royalty-vault", + "note": "Exactly 100 ERC-20-compatible Royalty Tokens per IP; fractional multi-party holding + pro-rata claim explicitly supported (5%/95% example)." + }, + { + "source": "Story Docs — Royalty Module (mainnet currency)", + "url": "https://docs.story.foundation/concepts/royalty-module/overview", + "note": "On mainnet, WIP (0x1514000000000000000000000000000000000000) is the ONLY whitelisted royalty payment currency; payRoyaltyOnBehalf needs a whitelisted ERC-20." + }, + { + "source": "Story Docs — License Token", + "url": "https://docs.story.foundation/concepts/licensing-module/license-token", + "note": "License Token = ERC-721 NFT carrying PIL terms; burned when used to register a derivative (single-use semantics)." + }, + { + "source": "Story Docs — License SDK reference", + "url": "https://docs.story.foundation/sdk-reference/license", + "note": "mintLicenseTokens(licensorIpId, licenseTermsId, receiver, amount, maxMintingFee, maxRevenueShare); may require a minting fee; returns txHash+receipt (on-chain)." + }, + { + "source": "chainflow.io — Royalty & Revenue in Story", + "url": "https://chainflow.io/royalty-revenue-in-story-protocol/", + "note": "Depth limits: up to 1024 ancestors and 8 parents per IP; snapshot not auto-triggered per payment to reduce gas; LAP=all ancestors, LRP=direct parents only." + }, + { + "source": "GitHub — protocol-core-v1", + "url": "https://github.com/storyprotocol/protocol-core-v1", + "note": "v1.3.2 (Apr 23 2025); audits in /audits; deployment-1514.json = Homer mainnet, deployment-1315.json = Aeneid testnet; BUSL-1.1." + }, + { + "source": "GitHub — storyprotocol/sdk releases", + "url": "https://github.com/storyprotocol/sdk/releases", + "note": "TS SDK v1.4.4 (Mar 2 2025): AA-wallet support, mainnet requires WIP; v1.4.3 batch derivative registration; v1.4.2 auto ERC-20 approval; Multicall used automatically." + }, + { + "source": "SDK docs — IP/WIP auto-wrap & Multicall", + "url": "https://github.com/storyprotocol/sdk", + "note": "SDK auto-converts IP->WIP, inserts approval tx, then executes; Multicall3 bundling enabled for supported methods." + }, + { + "source": "Story mainnet launch coverage", + "url": "https://decrypt.co/305730/story-protocol-debuts-mainnet-with-1-billion-ip-tokens-to-claim", + "note": "Homer mainnet live Feb 13 2025; $IP is the native gas token (1B supply)." + }, + { + "source": "Story consensus / fees", + "url": "https://stakely.io/blog/story-the-l1-for-programmable-ips-in-the-ai-era", + "note": "CometBFT PoS, EVM-compatible, fast finality, intentionally low gas ('more of an IP chain')." + }, + { + "source": "CoinGecko / CoinMarketCap — $IP price Jun 2026", + "url": "https://coinmarketcap.com/currencies/story-protocol/", + "note": "$IP ~ $0.37-0.60 in Jun 2026, ~-97.5% from $14.78 ATH; ~$35-47M daily volume — thin liquidity for a tradeable revenue claim." + }, + { + "source": "Local design docs", + "url": "/Users/antonyzaki/Documents/Repo/tokenized-assets/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md", + "note": "ADR 0002/0003 + CONTEXT.md define the Skill->IP Asset / per-invocation execution-credential design under test." + } + ], + "caveats": [ + "ADVERSARIAL CORE FINDING — per-invocation ON-CHAIN minting does NOT match LLM call economics. Every License Token mint is a full on-chain tx that ALSO drags an IP->WIP wrap + ERC-20 approve (the SDK bundles these, but they are still state-changing ops). License Tokens were designed for derivative registration, not sub-second/sub-cent metering. Even on a cheap chain, a write tx + wrap + approve per call, plus block latency (CometBFT finality is fast but not free and not instant), is heavy for a credential that gates a single LLM invocation whose marginal value may be cents. The 'mint a credential per call' literal reading of ADR 0003 is the design's biggest economic risk.", + "FIX PATH (recommended, still all-Story): do NOT mint one License Token per invocation. Pre-mint a batch of single-use credentials, OR mint one durable License Token = an Invocation-right, then meter invocations OFF-CHAIN against it and SETTLE in batches via payRoyaltyOnBehalf (hourly/daily/threshold). This preserves 'no credential, no run' (credential checked off-chain by the collar) while moving only periodic settlement on-chain. ADR 0003's 'payment is the meter' still holds at the batch boundary; it just isn't 1 tx == 1 call.", + "FLOW-THROUGH IS PULL, NOT PUSH. Ancestors (the school in Education mode) do not auto-receive cash on each invocation; they accrue a claimable balance and must call claimAllRevenue. Build a keeper/relayer to auto-claim on their behalf (claiming is permissionless — anyone can claim FOR an IP), or the school's revenue silently piles up unclaimed. Update CONTEXT.md line 49 / ADR wording: 'automatically' should read 'automatically credited, claimable on demand.'", + "MAINNET CURRENCY = WIP ONLY. Wielders/Beneficiaries must hold/wrap WIP (wrapped $IP) to pay. That forces every payer into $IP exposure and a wrap step. For an enterprise Beneficiary paying per invocation this is real friction; a fiat->WIP on-ramp inside the collar is effectively mandatory. You cannot settle in USDC on mainnet today (only WIP is whitelisted).", + "ROYALTY-TOKEN GRANULARITY = 1% (100 tokens/vault). Co-held claims finer than 1% (e.g. 0.5% to a co-author) need a wrapper/escrow contract on top — the native vault cannot express it.", + "DEPTH CEILINGS ARE REAL: 1024 ancestors, 8 parents per IP. Fine for student->school, but a heavily-forked popular Skill could approach the ancestor ceiling, and 'compose from up to 8 parent Skills' is a hard cap on multi-parent derivatives. LAP also accumulates the royalty stack up the whole chain — a deep chain can push the cumulative royalty % high enough that the leaf derivative keeps very little.", + "TOKEN/REGULATORY EXPOSURE: $IP is down ~97.5% from ATH with thin volume. The tradeable Royalty claim is denominated in a volatile, illiquid asset, and revenue accrues in WIP — settlement value swings with $IP price. ADR 0002 already accepts the regulatory surface of a tradeable revenue claim; flag that thin liquidity makes the 'investable asset' thesis weaker in practice today.", + "DOES NOT SOLVE SECRECY (consistent with ADR 0004). Story gives provenance/royalty/derivative-graph moats but the chain never witnesses execution — exactly as ADR 0003 intends. The host still sees the Skill (TEE tabled). Nothing in Story changes that; it is purely the economic/settlement layer." + ], + "gaps": [ + "Could not pull a concrete per-tx fee figure in $IP/USD from storyscan (gas-tracker page returned no live values; stats page 403'd). Estimate from 'intentionally low gas' + EVM CometBFT is low-single-digit-cents per write tx, but this is INFERRED, not measured — verify on storyscan.io/gas-tracker before sizing per-invocation economics.", + "Exact MAX_ANCESTORS/MAX_PARENTS constants (1024/8) come from secondary sources (chainflow); not confirmed against the literal constant in protocol-core-v1 source. Confirm in contracts/modules/royalty before relying on the exact ceiling.", + "Whether a License Token can be repurposed as a single-use RUN credential WITHOUT consuming it for derivative registration is unverified — natively it is burned only on derivative registration. Using it purely as an off-chain-checked entitlement (not burning on-chain per call) is the realistic path and needs a design spike.", + "Story block time / finality in seconds not pinned to an authoritative source (CometBFT typically ~1-6s); confirm for latency budgeting of any synchronous on-chain credential check.", + "No confirmation that any fiat->WIP or USDC->WIP on-ramp exists turnkey; the collar likely has to build it.", + "SDK v1.4.4 is the latest release surfaced (Mar 2025) — no newer release found through mid-2026, which may mean stable OR slowing cadence; worth confirming there isn't a 1.5.x line." + ] + }, + "verification": { + "refuted": false, + "correctedVerdict": "works-with-caveats", + "refutationBasis": "Could not refute the core finding. Every load-bearing technical claim was independently corroborated against authoritative sources (official docs, GitHub API, npm registry, contract source, chainid.network, CoinMarketCap/CoinGecko). The only factual defect found is a date error that runs in the finding's favor, not against it, and does not affect feasibility.\n\nCORROBORATED:\n- Homer mainnet live Feb 13 2025, chainId 1514 (chainid.network), $IP native gas, CometBFT instant finality.\n- protocol-core-v1 v1.3.2 published 2025-04-23 (GitHub API), BUSL-1.1, /audits directory present, deployment-1514.json (mainnet Homer) + deployment-1315.json (Aeneid testnet) — all confirmed.\n- Royalty Vault = exactly 100 royalty tokens = 1% each; claimAllRevenue is permissionless; payment and snapshot/claiming deliberately decoupled to cut gas on deep chains (PULL not PUSH) — confirmed on official docs.\n- On mainnet WIP (0x1514000000000000000000000000000000000000) is the ONLY whitelisted royalty currency — confirmed on the official Deployed Smart Contracts page.\n- License Token = ERC-721 carrying license terms, burned when used to register a derivative — confirmed. mintLicenseTokens is an on-chain tx that may require a minting fee and returns txHash + receipt — confirmed.\n- PILFlavor commercialRemix() and mintAndRegisterIpAssetWithPilTerms (register+create+attach in one tx) exist — confirmed.\n- LAP = whole-ancestry share, LRP = direct parents only; up to 1024 ancestors / 8 parents — confirmed via chainflow. ADDITIONAL CHECK: in protocol-core-v1 RoyaltyModule.sol these are admin-settable storage vars (maxParents / maxAncestors via setters), NOT hardcoded constants — exactly matching the finding's own stated gap.\n- $IP ~ $0.37, ~$133M market cap, ~$46M daily volume (Jun 2026) — corroborates the thin-liquidity caveat.\n\nThe adversarial CORE caveat (one on-chain License Token mint per LLM invocation, dragging an IP->WIP wrap + ERC-20 approve + block latency, mismatches sub-cent/sub-second call economics) is sound and the recommended fix (pre-mint batches or mint one durable invocation-right and settle off-chain in batches via payRoyaltyOnBehalf) is realistic and still all-Story. The finding also correctly flags as UNVERIFIED that a License Token can be repurposed as a non-burned per-call run credential — I likewise could not confirm any native non-burn single-use semantics; natively it is burned only on derivative registration. That open item is honestly disclosed, not over-claimed.", + "notes": "ONE FACTUAL ERROR FOUND (runs in the finding's favor, immaterial to verdict): The finding repeatedly dates TS SDK v1.4.4 to \"Mar 2025\" and worries about a \"slowing cadence / no newer release through mid-2026.\" Authoritative npm registry (registry.npmjs.org/@story-protocol/core-sdk) and GitHub API both show v1.4.4 was published 2026-03-02, NOT March 2025. The real release cadence was steady and continuous: v1.3.2 (2025-06-06), v1.3.3 (2025-07-11), v1.4.0 (2025-09-30), v1.4.1 (2025-10-21), v1.4.2 (2025-11-20), v1.4.3 (2026-01-31), v1.4.4 (2026-03-02). So: (a) v1.4.4 IS the current mid-2026 release, the SDK is actively maintained, and the \"15-month gap / slowing cadence\" gap in the finding is unfounded and should be deleted; (b) dist-tags latest = 1.4.4, no 1.5.x/2.x line yet (the finding's suspicion of a 1.5.x line is unconfirmed — none exists as of Jun 2026). Note the WebSearch on SDK versions initially surfaced a stale v1.3.0 reference and a WebFetch on the GitHub releases page mis-rendered years as 2024/2025 — the npm registry timestamps and GitHub API are the authoritative source and were used to settle this.\n\nNET: All five primitives (a-e) verified real, audited, and on-chain. The economic/UX caveats (pull-not-push royalties needing a keeper, WIP-only mainnet currency forcing an $IP/wrap on-ramp, 1% royalty-token granularity, depth ceilings, per-invocation minting being the core economic risk, thin $IP liquidity, no secrecy solution) are all accurate and appropriately scoped. works-with-caveats is the correct verdict; confidence high is justified.\n\nRelevant local files reviewed: /Users/antonyzaki/Documents/Repo/tokenized-assets/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md, /docs/adr/0003-payment-gated-execution.md, /docs/adr/0004-compete-on-moats-not-secrecy.md, /CONTEXT.md — design under test matches what the finding validates.\n\nAuthoritative sources used: npm registry @story-protocol/core-sdk version/time map; GitHub API releases for storyprotocol/sdk and protocol-core-v1; docs.story.foundation (royalty-module overview, ip-royalty-vault, license-token, sdk-reference/license, deployed-smart-contracts); raw contract source RoyaltyModule.sol@v1.3.2; chainid.network/chain/1514; chainflow.io; CoinMarketCap/CoinGecko $IP price Jun 2026." + } + }, + { + "component": "leakage-moat", + "finding": { + "component": "SKILL ASSET PROTOCOL — IP-leakage threat model and ADR-0004 (\"compete on provenance/derivative-graph/reputation/live-evolution, not secrecy\") defensibility; plus per-call LLM-output watermarking as a clone-detection complement", + "verdict": "works-with-caveats", + "confidence": "high", + "howItWorks": "As of mid-2026 the evidence strongly supports ADR-0004's core premise — secrecy is not a viable moat for any hosted capability — and therefore validates the decision to NOT bet on it. But it also exposes a category error in how the leakage threat is framed.\n\nTHE THREAT IS MIS-NAMED. ADR-0001/0003 defend against the wrong attack. \"The chain witnesses the PAYMENT, never the Skill\" and \"no credential, no run\" stop (a) handing over the skill *file*, and (b) the *blockchain* leaking the prompt. Neither is the real threat. Two distinct attacks matter:\n 1. PROMPT EXTRACTION (recovering the hidden SKILL.md / system prompt verbatim). Real and not fully preventable. Q4-2025/2026 data: \"do not reveal\" instructions give minimal protection; hypothetical-scenario + obfuscation framing remain reliable; code/agent autonomy is a *new* extraction surface (\"Just Ask: Curious Code Agents Reveal System Prompts,\" arXiv 2601.21233) — directly relevant because your runtime IS an agent that can be steered. Embedding attack-taxonomy awareness into the wrapper cuts extraction quality ~18.4% (best documented mitigation), not elimination. System Prompt Leakage is now an OWASP LLM Top-10 item.\n 2. BEHAVIORAL CLONING / OUTPUT DISTILLATION (never recovering the prompt, just reproducing the *behavior* from observed Wielder-facing outputs). THIS is the moat-killer, and your design does nothing against it because by definition the Wielder receives the OUTPUT (ADR-0001). It is architecturally impossible to sell metered outputs while preventing those outputs from being collected as training data. Empirically live at scale: Anthropic's Feb-2026 disclosure — ~24,000 fraudulent accounts, >16M interactions to distill Claude's \"most differentiated\" reasoning/coding/tool-use/agentic behavior (DeepSeek/Moonshot/MiniMax); OpenAI's parallel DeepSeek accusation; Google's >100k-prompt Gemini extraction. For a NARROW skill the economics are far worse for the defender than for a frontier model: frontier-model logit/weight extraction is the expensive case (~400M queries / GPT-3.5 projection matrix), but cloning a *task-specific behavior* needs only thousands of input-output pairs (curated behavior-cloning beats teacher quality at ~30x cheaper inference; \"Compiling Agentic Workflows into LLM Weights\" distills a whole multi-agent pipeline into one fine-tuned model once the pattern stabilizes — \"after a few weeks of operation\"). A valuable, frequently-invoked skill is therefore the EASIEST thing to clone, and high invocation volume (your revenue) is also the attacker's free training-data firehose.\n\nWATERMARKING IS NOT A ROBUST CLONE-DETECTION PRIMITIVE in 2026. Statistical text watermarks are broken by cheap semantic-preserving rewrites: SIRA (Self-Information Rewrite) ~100% removal across 7 schemes; Vaporizer breaks \"publicly detectable\" schemes at 100%, SynthID at moderate rates (Pegasus ~14%, synonym ~11%), only \"provable-robust\" schemes hold (Pegasus 23%); a single ChatGPT paraphrase drops all detectors below 0.3. SynthID is deployed at 10B+ items but DeepMind itself calls it \"not a silver bullet,\" and SRI/ETH show it is black-box detectable and relatively easy to scrub. Net: watermarking has narrow FORENSIC value (cheap, passive, may catch lazy verbatim re-hosters and strengthen a legal/dispute claim on Story) but ZERO value against a competent cloner, who paraphrases or fine-tunes the signal away for free. Treat it as a tripwire, not a moat.\n\nPROVENANCE-AS-MOAT IS PARTIALLY VINDICATED BY DIRECT EVIDENCE. SkillClone (arXiv 2603.22447) is the single most on-point source: across 20k real agent skills, 75.3% participate in a clone relationship, 3.5x concept inflation, and — crucially — derivation IS detectable at scale (F1 0.939; 81% recall on Type-4 semantic clones). This empirically supports the \"derivative graph + provenance\" thesis: you CAN build a credible registry that flags forks/clones even when modified. BUT the same paper is the strongest evidence AGAINST the moat: 96% of clones are MODIFIED variants with undeclared derivation (i.e., the graph only works if derivation is declared or detected, and detection is imperfect and adversarially evadable), 40-71% cross author boundaries, and wholesale mirroring already happens. Story's Royalty/Licensing modules and per-invocation flow-through work flawlessly for ON-PLATFORM, DECLARED derivatives — that part of ADR-0002/0003 is sound and the education/intra-org modes (co-held royalty tokens, school->student->employer flow-through) are economically coherent. The failure mode is the OFF-PLATFORM clone: an attacker who distills the behavior and re-hosts it on their own runtime pays you nothing, is outside Story's enforcement, and your only recourse is off-chain detection + legal action — exactly the regime where watermarks are weak and behavioral fingerprinting is probabilistic.\n\nNET ASSESSMENT: ADR-0004 is correct to abandon secrecy, but \"compete on economic/network moats\" is necessary-not-sufficient. The moat holds for convenience/liquidity/trust/declared-derivative-royalties (the marketplace + intra-org + education flows), and against the long tail of low-effort copiers. It does NOT hold against a motivated competitor distilling a high-value skill from its own paid outputs and re-hosting off-platform — and the more successful/valuable the skill, the more attractive and cheaper that attack becomes. That is the central tension v1 (no TEE) cannot resolve; it can only be managed economically (price below clone-cost, evolve faster than clone-cadence) and legally.", + "evidence": [ + { + "source": "\"Just Ask: Curious Code Agents Reveal System Prompts in Frontier LLMs\" (arXiv 2601.21233)", + "url": "https://arxiv.org/pdf/2601.21233", + "note": "Agent autonomy is a distinct, effective system-prompt extraction surface — directly relevant since the SKILL PROTOCOL runtime is itself a steerable hosted agent. Code agents extract prompts that direct user queries cannot." + }, + { + "source": "Lakera, \"The Year of the Agent: What Recent Attacks Revealed in Q4 2025\" (2026)", + "url": "https://www.lakera.ai/blog/the-year-of-the-agent-what-recent-attacks-revealed-in-q4-2025-and-what-it-means-for-2026", + "note": "Hypothetical-scenario + obfuscation are the reliable extraction techniques; naive 'do not reveal' gives minimal protection; embedding attack-taxonomy awareness cuts extraction quality ~18.4%; indirect/injection attacks need fewer attempts. Agentic blast radius amplifies leakage." + }, + { + "source": "Anthropic / OpenAI Feb-2026 distillation disclosures (reported)", + "url": "https://www.bereaonline.com/blog/ai-companies-data-theft-distillation-2026/", + "note": "~24,000 fraudulent accounts / >16M interactions to distill Claude's differentiated agentic+coding behavior (DeepSeek/Moonshot/MiniMax); OpenAI's DeepSeek accusation; defense was post-hoc behavioral fingerprinting. Demonstrates a hosted output service cannot prevent output-based cloning — only detect/slow it." + }, + { + "source": "\"Compiling Agentic Workflows into LLM Weights\" / workflow-distillation reporting (May 2026)", + "url": "https://www.requesty.ai/blog/ai-agent-techniques-may-2026-self-evolving-managed-compiled", + "note": "A multi-agent pipeline distills into one fine-tuned model from collected input-output pairs at ~2 orders of magnitude lower cost, once the pattern stabilizes 'after a few weeks.' Makes narrow-skill behavioral cloning cheap — the more invoked the skill, the more training data the attacker harvests." + }, + { + "source": "TensorZero, distillation via programmatic data curation", + "url": "https://www.tensorzero.com/blog/distillation-programmatic-data-curation-smarter-llms-5-30x-cheaper-inference/", + "note": "Curated behavior-cloning on a teacher's outputs can match/beat teacher quality at up to 30x cheaper inference — quantifies the attacker's economic incentive to clone." + }, + { + "source": "\"A Survey on Model Extraction Attacks and Defenses for LLMs\" (arXiv 2506.22521)", + "url": "https://arxiv.org/pdf/2506.22521", + "note": "Defense taxonomy: rate-limit/access control, query monitoring, output obfuscation, watermark/fingerprint, extraction detection. Consensus: full prevention impossible for an API/hosted model because the attacker has legitimate query access; defenses trade off against utility. Treats prompt-extraction and skill/behavior-extraction as distinct vectors." + }, + { + "source": "Vaporizer, \"Breaking Watermarking Schemes for LLM Outputs\" (arXiv 2605.07481)", + "url": "https://arxiv.org/html/2605.07481", + "note": "Publicly-detectable watermarks 100% broken; SynthID moderately vulnerable (Pegasus ~14%, synonym ~11%); only provable-robust schemes resist (Pegasus 23%). Semantic-preserving rewrites remove signals while keeping quality — watermark != ownership proof against a competent adversary." + }, + { + "source": "SIRA, \"Revealing Weaknesses in Text Watermarking via Self-Information Rewrite\" (arXiv 2505.05190)", + "url": "https://arxiv.org/pdf/2505.05190", + "note": "~100% watermark-removal across 7 recent methods; a single ChatGPT paraphrase drops all detectors below 0.3. Confirms watermarking is not a robust clone-detection primitive." + }, + { + "source": "Google DeepMind SynthID-Text (Nature / deployment) + SRI/ETH probing", + "url": "https://www.sri.inf.ethz.ch/blog/probingsynthid", + "note": "10B+ items watermarked, but DeepMind calls it 'not a silver bullet'; black-box detectable and easier to scrub than peers. Supports narrow forensic/tripwire use only, not as a moat." + }, + { + "source": "SkillClone, \"Multi-Modal Clone Detection and Clone Propagation in the Agent Skill Ecosystem\" (arXiv 2603.22447)", + "url": "https://arxiv.org/html/2603.22447v1", + "note": "Most on-point source. 20k skills: 75.3% in a clone relationship, 3.5x concept inflation, wholesale mirroring exists. Derivation IS detectable (F1 0.939, 81% Type-4 semantic recall) — supports provenance moat. BUT 96% of clones are modified variants with UNDECLARED derivation and 40-71% cross author boundaries — the moat needs detection that is imperfect and evadable, and only covers on-registry artifacts." + }, + { + "source": "AI-defensibility discourse 2026 (NFX, WHU, et al.)", + "url": "https://www.nfx.com/post/ai-defensibility", + "note": "Consensus that technical/feature/secrecy moats are dead and that durable moats are network effects, liquidity, trust/brand, switching costs, and auditability — corroborates ADR-0004's strategic direction while noting these must be deliberately engineered, not assumed." + } + ], + "caveats": [ + "LEAKAGE-RISK QUANTIFICATION (the asked-for number): For a high-value, high-volume hidden skill, treat behavioral clone-ability as a near-certainty on a months timescale, not a maybe. Reasoning: (a) cloning a narrow behavior needs ~thousands of I/O pairs, which your own paid invocations supply for free; (b) real-world precedent shows attackers run 16M+ extraction interactions across tens of thousands of sock-puppet accounts; (c) curated behavior-cloning matches teacher quality at up to 30x cheaper inference. Verbatim system-prompt extraction is a separate, lower-but-nonzero risk (mitigable ~18% via taxonomy-aware wrapper, never eliminated). The low-effort verbatim re-hoster long tail IS catchable (SkillClone F1 0.939); the motivated distiller is NOT.", + "Watermarking should be scoped to a forensic tripwire + dispute-evidence role on Story, explicitly NOT a clone-prevention or robust clone-detection mechanism. Budget for it being defeated for free by any adversary who paraphrases or fine-tunes. Its realistic ROI is catching careless verbatim copying and adding weight to an on-chain/legal dispute, not deterring competent clones.", + "Story's royalty flow-through is airtight only for ON-PLATFORM, DECLARED derivatives. Every economic guarantee (fork pays ancestors per invocation, co-held royalty tokens, education flow-through) silently assumes the derivative is registered on Story and executed on your collar. An off-platform clone re-hosted on the attacker's own runtime is invisible to the Royalty Module and pays zero — this is the dominant leakage path and the modules do nothing about it.", + "The strongest realistic mitigations short of TEE are ECONOMIC and OPERATIONAL, not cryptographic: (1) price per-invocation below the amortized clone-cost so cloning is never worth it for a given demand level; (2) LIVE EVOLUTION as a moving target — ship the skill faster than a clone's distill-and-redeploy cadence so any captured snapshot is stale (this is the one ADR-0004 pillar that actively degrades the clone attack rather than just detecting it); (3) anti-distillation operational controls from the survey/Anthropic playbook: behavioral fingerprinting, sock-puppet/anomaly detection on invocation patterns, KYC/account-integrity to raise the cost of mass harvesting, per-Wielder rate limits; (4) bind value to things outputs alone cannot carry — live tool/data access, up-to-date private context, reputation/attestation — so a static clone is strictly inferior; (5) watermark + provenance graph as forensic + legal backstop. Rank order of efficacy: live-evolution + value-binding > economic pricing > account-integrity/anomaly detection > provenance-graph detection > watermarking.", + "Adversarial conclusion — WHERE THE MOAT THESIS FAILS: It fails precisely at success. The thesis implicitly assumes value lives in the skill's logic, which the graph can protect. But ADR-0001 gives the buyer the OUTPUT, and for most skills the output IS the value — so the most valuable, most-invoked skills generate the cleanest, largest clone-training set and have the highest clone incentive. Provenance/reputation/network effects defend the MARKETPLACE (liquidity, trust, discovery, declared-royalty flows) but do NOT defend an individual high-value SKILL from off-platform behavioral cloning. The intra-org and education modes are the safest (closed populations, aligned incentives, lower adversarial pressure, on-platform by construction); the open marketplace mode is where the moat is thinnest. If a single skill becomes a breakout earner, expect an off-platform clone within weeks and plan for it as a when, not an if — the v1 (no-TEE) answer can only be to out-evolve and out-integrate it, not to stop it." + ], + "gaps": [ + "No public, peer-reviewed benchmark yet isolates the exact query/dollar cost to behaviorally clone a *single Claude-Code-style skill* (as opposed to a whole model or a generic agent workflow). The thousands-of-pairs / 30x-cheaper figures are from adjacent distillation work; a skill-specific replication study would tighten the leakage quantification.", + "Effectiveness of 'live evolution' as an anti-clone tactic is asserted by analogy (moving target) but unmeasured — no source quantifies how fast a skill must change to keep a distilled clone economically stale. This is the load-bearing assumption of the v1 (no-TEE) defense and is currently unvalidated.", + "Story Protocol enforcement against OFF-platform clones is untested at scale in public reporting — the modules demonstrably handle on-chain declared derivatives, but real dispute/takedown outcomes for an off-platform behavioral clone (where on-chain provenance meets off-chain courts) are not documented.", + "Whether anti-distillation account-integrity/behavioral-fingerprinting (Anthropic's post-hoc defense) can be replicated by a small platform on top of Anthropic Managed Agents — vs. only by the frontier lab that owns the model — is unclear and matters for whether the v1 collar can even deploy the strongest operational mitigation.", + "Spoofing risk of watermarks (an adversary stamping YOUR watermark onto THEIR output to frame you or poison disputes) is flagged in the literature (DualGuard, SynthID spoof-clue work) but not quantified for this use case; relevant if watermarking is used as dispute evidence on Story." + ], + "claimUnderTest": "ADR-0004: a hosted Skill marketplace can be defended by competing on provenance + derivative graph + reputation + live evolution rather than secrecy; and (implicit) that hiding the skill behind a payment-gated runtime (ADR-0001/0003) meaningfully protects the IP. Plus: is per-call LLM-output watermarking a viable clone-detection complement in mid-2026?" + }, + "verification": { + "refuted": false, + "correctedVerdict": "works-with-caveats", + "refutationBasis": "I could not refute the finding. Every load-bearing empirical claim was independently corroborated against primary or multiple credible mid-2026 sources. All five cited arXiv papers exist with the exact IDs given and are accurately characterized: SkillClone (2603.22447), Just Ask: Curious Code Agents (2601.21233), Vaporizer (2605.07481), SIRA (2505.05190), and Compiling Agentic Workflows into LLM Weights (2605.22502). SkillClone's quantitative claims verified to the decimal (75.3% in clone relationship, 3.5x concept inflation, F1 0.939, 81% Type-4 semantic recall, 96% modified-variant undeclared derivatives, 40.0% cross-author with 71% in the security-propagation subset — the finding's \"40-71%\" is a fair compression of both numbers). The Anthropic Feb-2026 distillation disclosure was confirmed against Anthropic's own news page plus CNBC/Bloomberg/CNN/Fortune and Anthropic's official X post: 24,000 fraudulent accounts, 16M+ exchanges, DeepSeek/Moonshot/MiniMax, targeting \"most differentiated capabilities: agentic reasoning, tool use, and coding,\" defense being post-hoc behavioral fingerprinting. OpenAI's DeepSeek accusation (Feb-12 House memo) and Google's >100k-prompt Gemini extraction are both confirmed. Watermark-breaking claims hold: SIRA ~100% removal across 7 schemes at $0.88/M tokens; Vaporizer breaks publicly-detectable schemes; SynthID \"not a silver bullet,\" black-box binary-detectable and scrubbable. OWASP LLM07:2025 System Prompt Leakage is a real Top-10 item and OWASP's own guidance (\"the system prompt should not be considered a secret nor used as a security control\") directly reinforces the finding's central thesis. TensorZero up-to-30x cheaper behavior cloning and the workflow-distillation \"two orders of magnitude less cost\" ($0.50→$0.005) economics are confirmed. The finding's claimUnderTest — that a hosted Skill marketplace can be defended on provenance/derivative-graph/reputation/live-evolution rather than secrecy, with watermarking only as a forensic tripwire and off-platform behavioral cloning as the unresolved threat — survives adversarial scrutiny and is, if anything, conservatively framed.", + "notes": "Verdict stands at works-with-caveats. Two citation-hygiene defects found, neither overturns the conclusion:\n\n1) MIS-ATTRIBUTED FIGURE: The \"~18.4% extraction-quality reduction (best documented mitigation)\" is attributed in-line to the Lakera Q4-2025 report, but the Lakera page does NOT contain it. The number actually comes from the \"Just Ask\" paper (arXiv 2601.21233): aware-defense reduces extraction quality 18.4% vs. 6.0% for naive \"do not reveal.\" The number and substance are correct and \"Just Ask\" is independently cited in the finding, so this is a wrong source label, not a fabrication. Recommend re-attributing.\n\n2) QUESTIONABLE CONTRAST FIGURE: The \"~400M queries / GPT-3.5 projection matrix\" used as the \"expensive case\" for frontier-model extraction appears to misread the actual source (Carlini et al., \"Stealing Part of a Production Language Model,\" arXiv 2403.06634), which reports GPT-3.5-turbo projection-matrix recovery at \"under $2,000 in queries\" (and full Ada/Babbage projection matrices for under $20). 400M queries is not in that paper. This is a throwaway rhetorical contrast; its direction (full-model logit/weight extraction is costlier than narrow behavioral cloning) is independently supported by TensorZero and the Compiling Agentic Workflows distillation economics, so the argument is unaffected, but the specific number should be corrected or dropped.\n\nCORROBORATION STRENGTH: The strongest external support for the core thesis is OWASP's own LLM07:2025 language explicitly stating the system prompt must not be treated as a secret or a security control — this is independent, authoritative validation of ADR-0004's \"abandon secrecy\" decision. The Anthropic disclosure is the strongest empirical support that a hosted output service cannot prevent output-based cloning, only detect/slow it.\n\nGAPS HONESTLY STATED: The finding's self-declared gaps are real and not papered over — no skill-specific clone-cost benchmark exists, live-evolution anti-clone efficacy is unmeasured, off-platform Story enforcement is untested at scale, and replicability of frontier-lab behavioral-fingerprinting by a small platform is unclear. These are accurately characterized as the load-bearing unvalidated assumptions of the v1 (no-TEE) defense.\n\nNET: Confidence \"high\" is justified. The finding is well-sourced and arguably understates rather than overstates the risk it identifies. Caller should fix the two attribution issues but can rely on the verdict." + } + }, + { + "component": "regulatory", + "finding": { + "component": "Regulatory validation (US-focused, EU/global noted) of the Skill Asset Protocol: tokenized IP-royalty claims on Story Protocol + per-invocation x402 stablecoin settlement collar in front of Anthropic Managed Agents", + "verdict": "works-with-caveats", + "claimUnderTest": "That (a) tradeable/fractional/co-held tokenized royalty claims on Skill-invocation revenue, (b) per-invocation stablecoin settlement via x402, and (c) the associated KYC/AML posture can be launched in the US in mid-2026 without a fatal regulatory blocker, given known compliance overhead.", + "confidence": "medium", + "howItWorks": "Mechanics as of mid-2026, by sub-question.\n\n(a) ARE THE ROYALTY TOKENS SECURITIES? Almost certainly yes, under current US doctrine — and this is the single heaviest regulatory weight, though not a blocker. ADR 0002's \"royalty tokens for tradeable AND co-held claims\" maps cleanly onto an investment contract under Howey: (1) investment of money (buying/holding the royalty token), (2) common enterprise (a pooled, fractionalized claim on a revenue stream the platform/creator runs), (3) expectation of profit (the royalty/derivative flow-through) from the ESSENTIAL EFFORTS OF OTHERS (the creator who maintains/evolves the Skill and the platform that hosts/meters/settles invocations). The Ninth Circuit's SEC v. Barry (2025) is on point: fractional interests in a cash-flow stream (life-insurance payouts) were securities BECAUSE investors depended on a manager's ongoing, non-administrative efforts — directly analogous to royalty tokens whose value depends on the creator keeping the Skill performant and the platform driving invocation volume (\"live evolution\" + \"network moats\" in ADR 0004 actually STRENGTHEN the 'efforts of others' prong). The SEC's March 17 2026 crypto interpretation does NOT rescue these: it carves out only Digital Commodities (Bitcoin/ETH-style), Digital Collectibles (art/music NFTs valued for cultural significance), and Digital Tools (nontransferable membership/credential/ticket). A transferable token whose explicit purpose is to pay holders a share of recurring revenue fits NONE of those buckets and falls in \"Digital Securities\" (\"all devices and instruments that have the economic characteristics of a security are securities regardless of format or label\"). The interpretation is also non-binding staff guidance, rescindable without APA rulemaking. The Jan 2026 Corp Fin tokenized-securities statement reinforces this: \"the technological format in which a security is issued, recorded, or transferred does not alter its legal characterization\" — putting it on Story Protocol does not change the analysis. CONSEQUENCE: issuance needs registration or an exemption; transfer must be restricted; secondary trading must run through a registered venue. Caveat: a NON-transferable, purely contractual revenue right with no profit-from-others' efforts narrative (e.g., the intra-org co-hold structured as deferred comp, or an education split structured as a contractual license fee) can sometimes sit OUTSIDE securities treatment — but the protocol's headline feature is that the claims are TRADEABLE, which forces securities treatment for the marketplace mode.\n\n(b) x402 STABLECOIN SETTLEMENT / MSB: The payment leg is manageable and is NOT where the blocker is — but the design of the 'collar' is the determinative fact. Money-transmission exposure turns on CUSTODY and whether you route third-party funds as a business. If the collar is a NON-CUSTODIAL pass-through that rides a HOSTED facilitator (Coinbase's x402 facilitator, which carries its own KYT/OFAC/state+federal licensing), then the platform looks like a merchant using Stripe/PayPal — NOT a money transmitter, \"absent unusual facts.\" If instead the collar takes custody (omnibus wallets), converts fiat<->crypto, or routes payments between wielders and creators as a business, it \"almost certainly\" becomes money transmission requiring FinCEN MSB registration + state money-transmitter licenses + a BSA/AML program. The GENIUS Act (enacted July 18 2025; effective the earlier of 18 months or 120 days after final rules; ~3-yr transition for non-compliant stablecoins to ~July 2028) regulates payment-stablecoin ISSUERS (bank subsidiaries, OCC-approved federal qualified issuers, state qualified issuers <$10B). It does NOT make a payer or payee an MSB merely for using a compliant stablecoin like USDC, and it preempts state licensing for federal qualified issuers — so simply settling per-invocation in a GENIUS-compliant stablecoin via a regulated facilitator is a clean path. The \"execution credential\" mint (ADR 0003) is itself a non-financial access token (single-use right to run), so it does not add money-transmission exposure as long as it is not itself made tradeable/redeemable for value.\n\n(c) KYC/AML: Two distinct regimes. (i) Payment side: GENIUS/FinCEN AML+CIP+SAR+sanctions obligations fall on the stablecoin ISSUER and on any MSB-classified facilitator — NOT on mere holders/merchants. So a non-custodial collar pushes most payment-side KYC onto Coinbase/the issuer. (ii) Securities side: because the royalty tokens are securities, KYC re-enters through securities law — Reg D 506(c) REQUIRES verifying accredited status; transfer agents for registered/Form-10 securities must hold each holder's real-world name and physical address (a wallet address alone is insufficient); and any registered ATS/broker-dealer in the secondary loop runs full BSA/AML/CIP. The practical implementation is an ALLOW-LIST token (ERC-3643 / the BUIDL model): transfers cannot execute unless the recipient wallet is pre-KYC'd and whitelisted by the transfer agent. This is the mechanism that makes \"tradeable\" royalty claims legal — it is permissioned trading, not permissionless.\n\n(d) PRACTICAL COMPLIANCE PATHS others actually use, mapped to the three modes: Marketplace (indie -> any wielder) — issue the royalty tokens under Reg D 506(c) to accredited investors with a smart-contract allow-list (ERC-3643/BUIDL-style), or Reg A+ (up to $75M/yr, retail-eligible, tradeable on a registered ATS from day 1) / Reg CF (up to $5M/yr via a funding portal) if you need retail; do secondary trading ONLY on an SEC-registered ATS run by a broker-dealer (e.g., PPEX-type venues for exempt digital-asset securities); use a transfer agent. Reg S is available to reach non-US holders without US registration but requires genuine offshore offering + flowback controls, which are hard to reconcile with open token trading. Intra-org co-hold mode is the EASIEST to keep non-securities: structure the employee+employer claim as non-transferable contractual/deferred-comp rights (no resale, no 'efforts of others' marketing) — upside from external invocations flows as contract revenue, not a traded security. Education mode: the student's Derivative-owned claim and the flow-through split to the school can likewise be contractual license/royalty splits if kept non-transferable; it becomes a security the moment those claims are made tradeable. EU/global: a revenue/profit-sharing token is a MiFID II \"financial instrument\" (NOT MiCA) -> triggers the Prospectus Regulation (or an exemption), licensed-intermediary involvement, MAR/CSDR; MiCA full enforcement July 2026 governs the stablecoin/utility side, not the royalty claim.", + "evidence": [ + { + "source": "SEC, 'Application of the Federal Securities Laws to Certain Types of Crypto Assets...' (Mar. 17, 2026 interpretation), summarized", + "url": "https://www.wilmerhale.com/en/insights/client-alerts/20260324-the-secs-new-framework-for-crypto-assets-under-howey", + "note": "Carves out Digital Commodities, Digital Collectibles, Digital Tools (nontransferable) only. Does NOT address/exempt tokens conferring royalty streams or profit/revenue participation -> those fall in Digital Securities ('all devices and instruments that have the economic characteristics of a security are securities regardless of format or label'). Non-binding; rescindable without APA rulemaking. Issuer remains liable under antifraud provisions." + }, + { + "source": "Skadden, 'Howey's Still Here' (Aug. 2025) — SEC v. Barry (9th Cir.)", + "url": "https://www.skadden.com/insights/publications/2025/08/howeys-still-here", + "note": "Fractional interests in a cash-flow stream are securities where investors depend on a manager's ongoing, non-administrative efforts. Directly analogous to fractional royalty claims dependent on creator/platform effort. Howey 'has by no means gone away' despite the crypto thaw." + }, + { + "source": "SEC Div. of Corporation Finance, Statement on Tokenized Securities (Jan. 28, 2026), via Morgan Lewis", + "url": "https://www.morganlewis.com/pubs/2026/02/sec-clarifies-federal-securities-law-treatment-of-tokenized-securities", + "note": "'The technological format in which a security is issued, recorded, or transferred does not alter its legal characterization.' Registration/exemption, broker-dealer, ATS, and transfer-agent obligations all persist for tokenized securities. Putting royalty claims on Story Protocol does not change the analysis." + }, + { + "source": "GENIUS Act (S.1582, 119th Cong.; enacted July 18, 2025) — Paul Hastings guide", + "url": "https://www.paulhastings.com/insights/crypto-policy-tracker/the-genius-act-a-comprehensive-guide-to-us-stablecoin-regulation", + "note": "AML/BSA/CIP/SAR/sanctions obligations fall on payment-stablecoin ISSUERS (IDI subsidiaries, OCC federal qualified issuers, state qualified issuers <$10B). Does not impose AML/KYC on merchants/end-users/holders; does not make a payer/payee an MSB for using a compliant stablecoin. Sec 5(h) preempts state licensing for federal qualified issuers. Effective earlier of 18 mo or 120 days post-final-rules; ~3-yr transition to ~July 2028." + }, + { + "source": "Braumiller Law / Mondaq, 'Activating HTTP 402: The x402 Protocol and Legal Framework' (Dec. 2025)", + "url": "https://www.braumillerlaw.com/activating-http-402-the-x402-protocol-and-legal-framework-for-internet-native-stablecoin-payments/", + "note": "Non-custodial operator on a hosted facilitator (Coinbase, with its own KYT/OFAC/licensing) ~ merchant on Stripe/PayPal, not an MSB 'absent unusual facts.' Self-hosted facilitator that holds customer assets/omnibus wallets, converts fiat<->crypto, or routes third-party payments as a business 'almost certainly' = money transmission needing FinCEN MSB + state MTLs + BSA program. Custody is the dividing line." + }, + { + "source": "FinCEN proposed AML/CFT & sanctions rule for permitted payment stablecoin issuers (Fed. Reg., Apr. 10, 2026)", + "url": "https://www.federalregister.gov/documents/2026/04/10/2026-06963/permitted-payment-stablecoin-issuer-anti-money-launderingcountering-the-financing-of-terrorism", + "note": "Implements GENIUS illicit-finance requirements; obligations targeted at issuers (and MSB-classified intermediaries). Full text was access-blocked to the fetcher; scope corroborated via Treasury press release SB0435 and Paul Hastings. Treat exact covered-persons wording as not directly verified from primary text." + }, + { + "source": "Skadden, 'Tokenized Securities: Untangling Legal and Regulatory Knots' (Apr. 2026) + BUIDL/ERC-3643 allow-list model", + "url": "https://www.skadden.com/insights/publications/2026/04/tokenized-securities", + "note": "Each exemption's investor-type/holding-period/transfer limits must be embedded in the token or enforced by intermediaries. ERC-3643 embeds KYC/AML so transfers cannot execute unless recipient is verified; allow-list/transfer-agent enforcement (BUIDL = effectively a Reg D restricted security with smart-contract resale enforcement). Secondary intermediaries need broker-dealer/ATS/transfer-agent registration. Reg S has flowback-reconciliation problems with open token trading." + }, + { + "source": "Reg A+/Reg CF/Reg D/Reg S/ATS practical-path roundup", + "url": "https://primior.com/how-secondary-trading-works-for-tokenized-securities-under-u-s-regulations/", + "note": "Reg A+ up to $75M/yr retail-eligible tradeable on a registered ATS from day 1; Reg CF up to $5M/yr via funding portal; Reg D private placement; secondary trading via SEC-registered ATS run by a broker-dealer (PPEX-type). Tokenization does not remove holding periods or accredited-investor requirements." + }, + { + "source": "EU treatment — MiCA vs MiFID II for revenue-sharing tokens", + "url": "https://tokenizationpolicy.com/eu-mica/tokenized-securities-europe/", + "note": "A token granting rights to profits/financial benefits is a MiFID II financial instrument, OUTSIDE MiCA -> Prospectus Regulation (or exemption), licensed investment firm, MAR/CSDR. MiCA full enforcement July 2026 governs stablecoin/utility tokens, not the royalty claim." + } + ], + "caveats": [ + "The royalty token being TRADEABLE is what forces securities treatment. The protocol can have either 'frictionless permissionless trading' OR 'royalty claims as the headline tradeable asset' cleanly, but not both — tradeable royalty claims must be permissioned (allow-list/KYC'd wallets only, registered ATS for secondary), which contradicts the implicit 'open composable royalty graph' vibe of ADR 0002. The derivative flow-through (a fork auto-paying ancestors) is fine as an on-chain royalty MECHANIC; it's the tradeability/co-holding of the CLAIM that triggers securities law.", + "ADR 0004's competitive thesis (provenance, live evolution, reputation, network moats = the platform/creator keep actively working) directly STRENGTHENS the Howey 'efforts of others' prong — i.e., the moat strategy makes the securities classification harder to avoid, not easier.", + "The March 2026 SEC crypto interpretation and Jan 2026 tokenized-securities statement are NON-BINDING staff guidance, rescindable without notice-and-comment. Do not build the legal foundation assuming they soften Howey for revenue-share tokens — they don't even address that category.", + "Money-transmission outcome is entirely contingent on how the v1 'collar' is built. NON-CUSTODIAL + hosted facilitator (Coinbase x402) = likely no MSB. The moment the collar custodies funds, nets/escrows multi-party invocation payments, or splits royalties on-platform by moving others' money, it likely becomes a money transmitter (FinCEN MSB + multi-state MTLs). The royalty-split/flow-through settlement is the most dangerous custody-creating feature — settle splits via the issuer/facilitator/smart contract, not via the collar holding funds.", + "Securities-side KYC is unavoidable for the marketplace mode even though payment-side KYC can be outsourced: Reg D 506(c) accredited verification + transfer agent holding real-world identity (name + physical address, not just wallet) + allow-list. Budget for a transfer agent and a broker-dealer/ATS relationship.", + "Intra-org co-hold and education modes can likely AVOID securities treatment entirely IF the claims are non-transferable contractual/deferred-comp/license-fee rights with no resale and no marketed profit-from-others narrative. Making those claims tradeable converts them into securities.", + "State money-transmitter licensing (if the collar is custodial) is a 40-50-state, ~12-24 month, multi-hundred-thousand-dollar slog — manageable overhead but a real time/capital gate, often deferred by using a licensed BaaS/facilitator partner.", + "EU/global: revenue-sharing tokens are MiFID II instruments (prospectus + licensed intermediary), not the lighter MiCA regime — non-US distribution is not a shortcut." + ], + "gaps": [ + "Could not retrieve the full FinCEN Apr 10 2026 proposed-rule text directly (Federal Register blocked the fetcher and the SEC statement URL returned 403); covered-persons scope is corroborated via Paul Hastings + Treasury summaries but not verified verbatim from primary text. Confirm whether the final rule reaches non-issuer intermediaries/facilitators before relying on the 'issuer-only' framing.", + "No source squarely analyzes a per-invocation, machine-to-machine (agent-to-agent) micropayment stream specifically; the MSB analysis is extrapolated from general x402/stablecoin guidance. A reserved-instructions / no-action style read for the specific collar architecture would need counsel.", + "Whether a single-use, non-tradeable 'execution credential' (ADR 0003) could ever be argued to be a regulated instrument was not directly sourced; treated as a non-financial access token, which is the reasonable default but unconfirmed.", + "The exact securities status of an on-chain DERIVATIVE royalty flow-through (fork auto-pays ancestor) as distinct from the ancestor's own token was not separately addressed by any source; analyzed by analogy.", + "SEC v. Barry citation/holding is via Skadden's summary, not the slip opinion; verify the precise holding and any 2026 appellate developments before citing in a memo.", + "Did not confirm whether any current SEC 'innovation exemption' (reported in-progress in 2026) would create a lighter path specifically for tokenized revenue-share claims — appears aimed at tokenized public equities, not bespoke IP-royalty tokens." + ] + }, + "verification": { + "refuted": false, + "correctedVerdict": "works-with-caveats", + "refutationBasis": "Could not refute. Every load-bearing claim was independently corroborated against mid-2026 sources, and the finding is conservative throughout (it consistently resolves ambiguity AGAINST the project, e.g., treating tradeability as forcing securities treatment and not relying on the delayed SEC innovation exemption). Verification results: (a) SECURITIES TREATMENT OF ROYALTY TOKENS — CONFIRMED. The March 17, 2026 SEC/CFTC interpretation's taxonomy does not carve out revenue/royalty-share tokens; WilmerHale confirms tokens granting 'rights to future income, profits, or assets' face securities scrutiny and there is NO safe harbor for revenue-share tokens. SEC v. Barry (9th Cir., Aug 11 2025) holding verified verbatim — fractional interests in a cash-flow stream are securities because of the manager's essential ongoing (non-administrative) efforts; directly analogous. Jan 28 2026 tokenized-securities statement verified ('technological format... does not alter its legal characterization'; no bespoke regime/exemption). (b) x402/MSB — CONFIRMED. Custody-is-the-dividing-line analysis (non-custodial + hosted Coinbase facilitator ~ Stripe/PayPal merchant, not MSB 'absent unusual facts'; custodial/omnibus/routing = money transmission) verified via Braumiller/Mondaq. GENIUS Act (enacted Jul 18 2025) issuer-focused BSA/AML, state preemption for federal qualified issuers, and effective-date framing (earlier of 18 mo or 120 days post-final-rules; enforcement ~Jan 18 2027) verified. (c) KYC/AML two-regime split — CONFIRMED, and the finding's own flagged gap is now RESOLVED: the FinCEN/OFAC proposed rule (NPRM Apr 8 2026) targets Permitted Payment Stablecoin Issuers as standalone BSA financial institutions, explicitly does NOT impose monitoring on secondary-market actors, and merchants/payees using a compliant stablecoin are not automatically MSBs. ERC-3643/BUIDL allow-list permissioned-trading mechanism verified and STRENGTHENED — Securitize (the BUIDL issuer) received FINRA approval May 4 2026 as the first broker-dealer cleared to custody tokenized securities and run an ATS for them, confirming the permissioned-ATS + transfer-agent path is real and operating. (d) Practical paths + EU — CONFIRMED. Reg A+/Reg CF/Reg D/ATS path exists; MiFID-II-not-MiCA treatment of profit/revenue-sharing tokens (Prospectus Regulation, MAR/CSDR) verified; MiCA full enforcement / grandfathering close Jul 1 2026 verified. The SEC 'innovation exemption' (one of the finding's gaps) was actually PULLED/DELAYED in May 2026 with no timeline and targets tokenized public equities/MMFs/Treasuries, not bespoke IP-royalty revenue-share tokens — so it offers zero help, exactly as the finding concluded.", + "notes": "Verdict 'works-with-caveats' upheld. The finding's core claim — that tradeable/fractional/co-held tokenized royalty claims, per-invocation x402 stablecoin settlement, and the associated KYC/AML posture can launch in the US in mid-2026 with no FATAL regulatory blocker but heavy compliance overhead — survives adversarial scrutiny intact. The single heaviest weight (royalty tokens = securities -> permissioned/allow-list trading via registered ATS + transfer agent, not permissionless) is correctly identified as a structural constraint, not a blocker, and the supporting infrastructure (ERC-3643/BUIDL/Securitize ATS) is demonstrably live.\n\nMINOR DISCREPANCIES FOUND (none undermine the verdict; several are conservative-leaning):\n1. Attribution of March 17 2026 interpretation: the finding calls it 'non-binding staff guidance, rescindable without APA rulemaking.' It is actually a COMMISSION interpretation (with CFTC coordination), which WilmerHale notes 'carries more weight than Staff-level statements,' though still not a formal APA rule. The finding under-rates its authority — conservative, and does not help the project either way since it does not exempt revenue-share tokens.\n2. The finding lists a 4-bucket taxonomy (Digital Commodities/Collectibles/Tools/Securities); the actual framework has 5 categories (it also breaks out Stablecoins separately). Royalty tokens still land in Digital Securities regardless. Immaterial.\n3. Jan 28 2026 statement attributed to 'Corp Fin' alone; it was actually issued jointly by three SEC Divisions (Corp Fin, Investment Management, Trading & Markets). Attribution detail only.\n4. FinCEN NPRM date: finding says Apr 10 2026; sources say NPRM released Apr 8 2026 (Apr 10 is likely the Federal Register publication date). Immaterial.\n5. One substantive nuance the finding's 'issuer-only' framing slightly oversimplifies: the GENIUS proposed rule's technical-capabilities/lawful-order (freeze/block) requirements extend to BOTH primary and secondary market activity, while only the SAR obligation is issuer/primary-market-only. This does not change the conclusion that mere payers/payees/merchants are not MSBs, but the 'AML obligations fall ONLY on issuers' phrasing is marginally too clean re: technical-capability reach. Finding itself flagged this exact item as an unverified gap.\n\nADDITIONAL CONFIRMATION beyond the finding: the SEC innovation exemption being delayed/pulled (May 2026) means there is no near-term lighter path; the finding correctly built its legal foundation without depending on it. No outdated claims, no testnet-vs-mainnet confusion, no API/doc over-reads detected. Confidence 'medium' is appropriate given the dependence on non-binding interpretations and the absence of any source squarely analyzing per-invocation agent-to-agent micropayment streams (a genuine, correctly-disclosed gap requiring counsel)." + } + } + ] +} \ No newline at end of file diff --git a/docs/feasibility/prebuild-spikes.md b/docs/feasibility/prebuild-spikes.md new file mode 100644 index 0000000..bf38d72 --- /dev/null +++ b/docs/feasibility/prebuild-spikes.md @@ -0,0 +1,63 @@ +# Pre-build spikes — results + +The four spikes the PRD gated the build on. **All resolved; none change the architecture.** Run +date: 2026-06. Sources cited in `findings.json` / the feasibility report; benchmark + experiment +scripts in `prototype/`. + +## 1. Hosted-agent latency (Anthropic Managed Agents / CMA) — ✅ resolved, ship + +No published latency SLA (CMA is beta). Realistic interactive path: + +| Segment | Figure (mid-2026, observed/relative — not contractual) | +|---|---| +| `sessions.create` round-trip | ~2.4s (third-party prod measurement) — *skippable via session pool* | +| stream open + first send | ~0.6s (overlapping, stream-first) | +| model time-to-first-answer-token | ~0.5–2s at `effort=low`/no-thinking; up to ~21s at max-effort reasoning | +| sandbox cold-start | lazy, off the critical path (Anthropic: −60% p50 / −90% p95 TTFT after decoupling) | + +**Verdict:** acceptable; the async-after-gate design is correct (CMA latency is post-gate). +**Build rules:** persist `agent_id` (never `agents.create` on the hot path); pre-warmed session pool +(depth ~1, cap ~2, 5-min age) to skip the ~2.4s; stream-first; render progress off the **first event** +(`session.status_running` / first `agent.thinking`), not the first answer token; default interactive +turns to `effort=low/medium`. **Benchmark with your own key before setting latency budgets:** +`prototype/spike-cma-latency.mjs`. Note CMA session cost ~$0.08/session-hour bounds idle pool cost. + +## 2. x402 ↔ Story settlement — ✅ resolved, no change + +The two-leg design (ADR-0005) **remains mandatory**. No x402 facilitator supports Story chain 1514; +x402 V2 "multi-chain" means more independent single-chain networks, not cross-chain settlement; +x402-exec is Base/X-Layer/BSC only. **CDP facilitator fee:** first 1,000 settled payments/month free, +then **$0.001/payment** (gas covered). New facts: a self-hosted facilitator can dynamically register +1514 but it is a **non-solution** (wrong token, wrong primitive, still custody); **LayerZero** joined +the x402 Foundation — watch as a future cross-chain-settlement path. **Finality caveat:** "~200ms" is +a Flashblocks preconfirmation, not finality; under congestion Base can take 10–28s → collar needs +timeouts + treasury-funded refunds (x402 is irreversible). + +## 3. Execution credential — ✅ resolved, confirmed + +**Use the x402 settled txHash as the off-chain, single-use, replay-proof credential.** Do **not** mint +a Story License Token per call: a License Token is an ERC-721 that only authorizes *derivative +registration* and burns on use; it can be held un-burned but confers no run right and has no native +verification beyond ERC-721 ownership, and per-call minting is on-chain WIP + gas + block latency. +**License Tokens are scoped to Phase 0 provenance / fork declaration.** (Re-verify Story-mainnet +WIP-only minting-fee rule at build time; true at SDK v1.4.4, 2026-03.) + +## 4. Fork economics — ✅ resolved (one product decision left to you) + +Run `node prototype/spike-fork-economics.mjs`. + +- **Fork-killing threshold confirmed:** the latest forker keeps `(1 − inherit)` of net regardless of + depth; forking beats going solo while `inherit < p_parent/p_fork`. Suggest the inherit default at + the price-ratio and let Creators tune it. +- **Surprise — the dilution victim is the ORIGINAL creator, not the leaf.** With a flat per-hop + inherit, the originator's share decays geometrically with depth (at 30%/hop: 30% → 9% → 2.7% → + 0.8% across depths 1–4). The school earns from *breadth* (direct forks), not *depth*. +- **OPEN PRODUCT DECISION (Phase 2):** Story **LRP** (per-hop relative — dilutes originators by depth) + vs **LAP** (whole-ancestry absolute — protects the originator's share regardless of depth, but caps + total downstream royalty). To be decided and ADR'd when royalty settlement is built. + +## Net effect on the plan + +No blocker surfaced. The architecture in ADR-0005 / ADR-0006 stands. The only unresolved item is the +LRP-vs-LAP **product** choice (#4), which is a Phase-2 decision and does **not** block Phase 0. Clear +to build Phase 0 (provenance). diff --git a/docs/feasibility/report.md b/docs/feasibility/report.md new file mode 100644 index 0000000..1aa7d72 --- /dev/null +++ b/docs/feasibility/report.md @@ -0,0 +1,164 @@ +I have all the inputs I need. The CONTEXT.md confirms the line references (49, 95-96). Now I'll write the feasibility report. + +# Skill Asset Protocol — Feasibility Report + +*Prepared for: technical founder, build/no-build decision · Validation horizon: mid-2026 · Status of design under test: ADRs 0001–0004, v1 platform-native topology* + +--- + +## 1. Bottom line up front + +**Verdict: GO, with caveats.** + +The Skill Asset Protocol is **feasible to build in mid-2026 with no fatal blocker** — every one of the five components validated at *works-with-caveats* (regulatory at *medium* confidence, the rest *high*). All four on-chain primitives the design needs are real, live, and audited on Story Protocol (chainId 1514, SDK v1.4.4 current as of Mar 2026); Anthropic Managed Agents (CMA, beta `managed-agents-2026-04-01`) genuinely hides the Skill from the Wielder in its session-output stream; and x402 is a clean, mature per-invocation gate that yields a replay-proof on-chain receipt. **But the literal ADR vision does *not* compose end-to-end.** The headline promise — *"a fork automatically pays its ancestors atomically on every invocation, via one payment that both gates execution and lands on Story's royalty contract"* — is physically impossible as stated, because x402 settles on the wrong chain (Base 8453), in the wrong token (USDC, not Story's WIP-only mainnet currency), via the wrong primitive (a transfer to an EOA, not the `payRoyaltyOnBehalf` contract call). The honest version is a **decoupled, two-leg, batched, eventually-consistent settlement** in which a collar custodies funds in-flight — and that custody is simultaneously the worst regulatory exposure (FinCEN MSB), the re-introduction of the very trusted accumulator ADR-0003 set out to eliminate, and the per-hop fee math that forces batching. Build it — but build the honest version, launch the closed (intra-org / education) modes first, and correct the atomicity language in ADR-0003 and CONTEXT.md lines 49 and 95-96. The deepest strategic risk sits *below* the chain entirely: ADR-0001 hands the Wielder the output, and for most Skills the output *is* the value, so a high-value Skill is the cheapest thing in the world to behaviorally clone — and your revenue volume is the cloner's free training set. + +--- + +## 2. Per-component findings + +| Component | Verdict | Key caveat | Confidence | +|---|---|---|---| +| **Managed Agents (CMA)** — host Skill server-side, hide from Wielder, gate per-invocation | works-with-caveats | Hiding is **key-custody-dependent, not a platform secrecy guarantee** (`GET /v1/agents/{id}` echoes the system prompt verbatim; Anthropic sees the Skill in plaintext, no TEE). **No native "no credential, no run" gate** exists on CMA, OpenAI, or Google — the gate is 100% your collar. Beta, not GA; 300 create-req/min/org ceiling; not ZDR/HIPAA-eligible. | high | +| **x402** — gate the call + receipt; settle to Story royalty contract | works-with-caveats | Part (a) gating+receipt is **solid**. Part (b) is the **broken seam**: x402 cannot settle onto Story (wrong chain, wrong token, pays an EOA not a function, no facilitator supports 1514). Forces a two-leg bridge architecture; CDP facilitator now charges $0.001/tx after 1k/mo. | high | +| **Story Protocol** — IP Asset, derivative flow-through, co-held royalty tokens, per-call credential | works-with-caveats | All four primitives are real, audited, on-chain. But flow-through is **PULL not PUSH** (ancestors must call `claimAllRevenue`); mainnet currency is **WIP only** (not USDC), $IP down ~97.5% from ATH; royalty-token granularity floor is 1%; **minting a License Token per LLM call is economically/latency-broken**. | high | +| **Leakage / moat (ADR-0004)** | works-with-caveats | ADR-0004 is **correct to abandon secrecy** (externally vindicated — OWASP LLM07:2025 says the system prompt is not a security control). But the threat is mis-named: the moat-killer is **off-platform behavioral cloning** of outputs, which the design does nothing about. Watermarking is a forensic tripwire, **not a moat** (SIRA ~100% removal). Moat defends the *marketplace*, not an individual breakout *Skill*. | high | +| **Regulatory (US)** | works-with-caveats | No fatal blocker, but **tradeable royalty claims are almost certainly securities** under Howey (March 2026 SEC interpretation does *not* carve out revenue-share tokens) → permissioned trading only (ERC-3643 + registered ATS + transfer agent). Custodial collar → likely **FinCEN MSB** (multi-state MTLs, 12–24 mo slog). Intra-org / education can stay non-securities if claims are non-transferable. | medium | + +--- + +## 3. End-to-end composition walk-through + +The settlement loop was traced step by step. It **does not compose end-to-end** as written; here is where each step holds and where it breaks. + +| Step | Composes? | What actually happens | +|---|---|---| +| **1 — Register Skill on Story** | ✅ Yes | `mintAndRegisterIpAssetWithPilTerms` + `PILFlavor.commercialRemix()` is one real on-chain tx on Story mainnet. Forks register as declared Derivatives with on-chain ancestry. **The soundest step in the loop.** | +| **2 — Host behind managed agent, "content never leaves host"** | ⚠️ With correction | `{system, skills}` live on the persisted Agent object; the session output stream (`agent.message/thinking/tool_*/span.*`) never carries the prompt or skill bodies, so the Wielder cannot read the Skill. **But "content never leaves the host" is false against the host itself** — Anthropic processes the Skill in plaintext and `GET /v1/agents` echoes it to the key-holder. Hiding holds *only* because the collar is the sole key-holder and never proxies the agent-read. CONTEXT.md line 95–96 ("hidden from the host too") remains unsolved in v1. | +| **3a — Wielder pays, x402 gates + yields receipt** | ✅ Yes | Textbook x402 resource server: `402 + PAYMENT-REQUIRED` → EIP-3009 `transferWithAuthorization` (gasless, bytes32 nonce) → `/verify` + `/settle` → `PAYMENT-RESPONSE {success, txHash, networkId}`. The settled `txHash` **is** the single-use, replay-proof credential. *Caveat:* the protocol does **not** "safely resubmit" after a settled-but-failed run — the collar must do its own nonce/txHash bookkeeping. | +| **3b — Settle that SAME payment toward Story's royalty contract** | ❌ **Does not compose** | **The load-bearing break.** x402 settles a USDC transfer to an EOA on Base; Story needs `payRoyaltyOnBehalf(ipId, amount, token)` — a *contract call*, in *WIP*, on *eip155:1514*. x402 fails all three. The single payment that gates execution **cannot also be** the on-chain royalty payment. See §4. | +| **3c — Payment mints the execution credential** | ⚠️ Conditional | "Payment mints a credential" holds. "The credential is an on-chain Story License Token at per-call cadence" does **not** — each mint is a full on-chain tx dragging an IP→WIP wrap + ERC-20 approve + CometBFT block latency, for a credential gating an LLM call worth cents. Use the x402 `txHash` (or a collar-issued off-chain token) instead. | +| **4 — Collar verifies credential → invokes agent → returns only output** | ✅ Yes — and the gate **must** be your proxy | No native pre-execution gate on any of the three platforms. CMA's only mid-run gate (`permission_policy: always_ask`) is a tool-approval gate keyed to the key-holder, cannot stop a turn from starting/spending tokens; webhooks are after-the-fact. Anthropic keys are workspace-scoped (full/read-only only), so you *cannot* hand a Wielder a key that allows `sessions.create` but forbids `GET /v1/agents`. **The collar is structurally forced to be the sole key-holder and the entire gate.** This matches design intent. | +| **5 — Settlement: protocol fee, recursive royalty split, tradeable/co-held claims** | ⚠️ On-chain but PULL, batched, custodial | On-chain mechanics are real (LAP = whole ancestry, LRP = direct parents; 100 royalty tokens/vault = 1% each; co-holdable). But: **(i)** flow-through is *claimable*, not pushed — ancestors must call `claimAllRevenue` (needs a keeper or the school's revenue silently piles up); **(ii)** per-hop fees dwarf a micro-royalty → Story-side settlement **must** be batched; **(iii)** the two-leg design makes the collar an in-flight custodian. "A fork automatically pays its ancestors on every invocation" is true in *accounting* terms, eventually-consistent — **not** one atomic on-chain action per invocation. | + +**Net composition result: `worksEndToEnd = false`, `criticalBlockers = []`.** No single blocker is fatal, but four high-severity gaps compound into one architectural reality (§4, §5). + +--- + +## 4. The cross-chain settlement problem (the crux) + +This is the likely make-or-break of the whole design, so it gets its own section. + +### 4.1 Why x402 physically cannot settle onto Story + +x402's `exact` scheme settles **a USDC token transfer to a `payTo` address on Base (eip155:8453)**. Story's Royalty Module requires **`payRoyaltyOnBehalf(ipId, amount, token)` — a contract function call, denominated in WIP (`0x1514…0000`, the *only* mainnet-whitelisted royalty currency), on eip155:1514.** Four independent mismatches, each sufficient on its own: + +1. **Wrong chain.** No x402 facilitator (Coinbase CDP or any third party) supports Story 1514 as of mid-2026, and x402 settlement is **single-chain by design** — the transfer settles on the same chain it was signed for. There is no native cross-chain settlement. *(Sources: [docs.cdp.coinbase.com/x402/network-support](https://docs.cdp.coinbase.com/x402/network-support) — lists Base/Polygon/Arbitrum/World/Solana only; [x402.org/ecosystem](https://www.x402.org/ecosystem) — no Story facilitator.)* +2. **Wrong token.** Story whitelists **WIP only** on mainnet, not USDC. *(Source: [docs.story.foundation/concepts/royalty-module/overview](https://docs.story.foundation/concepts/royalty-module/overview).)* +3. **Wrong primitive.** x402 `exact` pays an **EOA/address**, not a function. Driving `payRoyaltyOnBehalf` inside settlement needs an x402-exec-class router/hook — and [nuwa-protocol/x402-exec](https://github.com/nuwa-protocol/x402-exec) is deployed **Base / X-Layer / BSC only, explicitly not cross-chain, never on Story**. +4. **Wrong authorization variant.** Even direct-to-contract settlement of EIP-3009 needs `receiveWithAuthorization`; x402 uses `transferWithAuthorization`, and WIP / `RoyaltyModule.sol` supporting `receiveWithAuthorization` is unverified (likely not the case). + +An independent 2026 academic source ([A402, arXiv 2603.01179](https://arxiv.org/pdf/2603.01179)) names cross-chain payment/service split as a known open limitation that causes exactly this settlement delay and complexity — corroborating the seam adversarially. + +### 4.2 The only realistic architecture: a decoupled two-leg loop + +``` +WIELDER + │ (1) HTTP 402 + PAYMENT-REQUIRED + ▼ +COLLAR (x402 resource server, sole Anthropic key-holder) + │ + ├── LEG 1 (synchronous, sub-second) ─────────────────────────────┐ + │ EIP-3009 transferWithAuthorization, USDC on Base │ + │ /verify + /settle → settled txHash = single-use credential │ + │ credential checked OFF-CHAIN → invoke CMA → return OUTPUT │ + │ ▼ + │ EXECUTION GATED + │ (no credential, no run) + │ + └── LEG 2 (asynchronous, batched, eventually-consistent) ─────────┐ + settlement worker batches accrued payments per threshold/interval + bridge/swap USDC(Base) → WIP(Story) via Stargate / Across / deBridge + call payRoyaltyOnBehalf on Story (eip155:1514) + permissionless keeper calls claimAllRevenue on ancestors' behalf + ▼ + ANCESTORS PAID (LATER) +``` + +**The two legs are decoupled.** Execution is gated by Leg-1 finality (sub-second); the royalty leg settles later (Across intent fills ~2–15s, canonical paths longer) and **in batches** — because per-hop fees (CDP facilitator $0.001/tx after 1k/mo + Base gas + bridge fee + USDC→WIP swap slippage + Story gas + claim gas) can each exceed a cents-level micro-royalty. *Batching is mandatory.* + +### 4.3 The three things this forces you to accept + +1. **Eventually-consistent, not atomic.** ADR-0002's "fork pays its ancestors atomically on every invocation" is achievable in *accounting* terms (claimable balance), not as one on-chain action. **Correct ADR-0002 / CONTEXT.md line 49: "automatically" → "automatically credited, claimable on demand."** +2. **The collar becomes an in-flight custodian** of Wielder funds on Base between Leg-1 (execution done) and Leg-2 (ancestors paid). A bridge stall means execution happened but the school is unpaid — a reconciliation surface. **And this is the exact fact pattern that "almost certainly" makes the collar a FinCEN MSB** ([Braumiller/Mondaq, Dec 2025](https://www.braumillerlaw.com/activating-http-402-the-x402-protocol-and-legal-framework-for-internet-native-stablecoin-payments/)): custody is the dividing line. The cross-chain workaround forces the same custody that creates the worst regulatory exposure. +3. **It re-centralizes the trust ADR-0003 tried to remove.** The off-chain batched meter that decides which invocations get settled **is** a trusted accumulator — exactly the oracle ADR-0003 rejected as able to under/over-report. ADR-0003's "usage fraud structurally impossible" holds only for the synchronous atomic case the cross-chain gap forces you to abandon. The guarantee degrades from *"structurally impossible to defraud"* to *"auditable accumulator."* **Make this explicit in ADR-0003.** + +**Re-verify before building** (recency-sensitive): that no third-party x402 facilitator has added Story 1514 ([x402.org/ecosystem](https://www.x402.org/ecosystem)); the CDP fee schedule; that Story still whitelists WIP-only for royalties; and run a Leg-2 latency/fee spike on real Story mainnet. + +--- + +## 5. Risk register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| **Off-platform behavioral cloning of a breakout Skill** — output *is* the value (ADR-0001); a high-value, high-volume Skill needs only ~thousands of I/O pairs (~30× cheaper distillation), and your paid invocations supply them free. Moat defends the *marketplace*, not the *Skill*. | **High** (when-not-if, months) | **High** — undermines core value prop at success; v1 (no TEE) cannot prevent it | Live-evolution moving target + value-binding to non-output-carryable things (live tool/data access, fresh context, reputation) > pricing below clone-cost > anomaly detection > provenance graph > watermarking (tripwire only). Launch closed modes first. | +| **Collar custody → FinCEN MSB** classification | Medium-high (forced by two-leg unless architected around) | **High** — 12–24 mo, multi-$100K licensing slog that gates launch | Keep collar non-custodial on a hosted facilitator (Coinbase x402); settle splits via smart contract/issuer; push in-flight custody to a licensed bridge/BaaS partner. | +| **Tradeable royalty claims = securities** → full permissioned stack (ATS + transfer agent + KYC allow-list), contradicting "frictionless composable graph" | High (near-certain under Howey) | **High** for marketplace; **zero** for intra-org/education if non-transferable | ERC-3643 + Reg D 506(c)/Reg A+ + registered ATS ([Securitize](https://www.skadden.com/insights/publications/2026/04/tokenized-securities), FINRA-approved May 2026). Keep closed modes non-transferable. Defer tradeable claims to the last phase. | +| **Composition re-centralizes trust** — collar is sole key-holder + custodian + off-chain meter + (with Anthropic) sees the Skill | High (structural) | Medium — per-call gate stays trust-minimized; settlement trust reintroduced | Signed/auditable invocation logs; on-chain published settlement batches; refund/reputation for accept-but-fail; TEE as eventual fix. | +| **Cross-chain bridge stall** between Leg-1 and Leg-2 — execution done, ancestors unpaid | Medium | Medium — eventually-consistent + reconciliation overhead | Auditable off-chain ledger; retry/reconciliation; conservative batching windows. | +| **Stacked per-invocation fees exceed micro-royalty** | High at literal per-call; low once batched | Medium — forces batching + price floor | Mandatory batching; price above amortized settlement cost. | +| **$IP / WIP volatility + thin liquidity** — accrued value swings between accrual and claim; enterprise payers forced into involuntary $IP exposure | High ($IP ~−97.5% from ATH) | Medium — FX risk + enterprise friction | Fast-claim to limit WIP exposure window; build fiat/USDC→WIP on-ramp; monitor for USDC whitelisting. | +| **Verbatim system-prompt extraction** via agentic steering (runtime *is* a steerable agent) | Medium | Medium — leaks text, but ADR-0004 already abandons secrecy | Taxonomy-aware wrapper cuts extraction quality ~18% (never eliminates); real defense is the moats. | +| **CMA is beta, not GA**; not ZDR/HIPAA-eligible | Medium (beta churn) | Medium — rework + compliance gap for regulated data | Abstract the runtime behind the collar (swappable host); self-hosted sandbox for regulated data; track release notes. | +| **Recency risk in load-bearing facts** (a facilitator could add Story; fees, whitelist, License-Token semantics could change) | Low-medium | Low-medium — could simplify or invalidate parts | Re-verify the four recency items in §4.3 immediately before building. | + +--- + +## 6. Recommended MVP path (routing around the blockers) + +**Launch the closed modes first. They are the safest** — closed populations, aligned incentives, on-platform by construction, lowest cloning pressure — and they can structure co-held claims as **non-transferable contractual / deferred-comp / license-fee rights that sidestep securities treatment entirely** (no ATS, transfer agent, or allow-list needed for v1). The open Marketplace — thinnest moat, highest cloning incentive, full securities stack — comes **last**. + +**Chain decision:** Story for IP / royalty / provenance (the only layer that models any of this); Base for the x402 gate. **Do not attempt to make x402 settle directly to Story** — accept the two-leg split as permanent v1 architecture. + +### Phase 0 — Provenance (all-Story, ships immediately) +Register Skills as Story IP Assets via `mintAndRegisterIpAssetWithPilTerms` + `PILFlavor.commercialRemix()`; forks register as declared Derivatives with on-chain ancestry. This is the strongest part of the loop and establishes the provenance/derivative-graph moat regardless of how settlement evolves. + +### Phase 1 — Gate + run + off-chain meter +Collar = **sole Anthropic key-holder + x402 resource server**. Flow: `402` → EIP-3009 `transferWithAuthorization` (USDC on Base) → `/verify` + `/settle` → settled `txHash` **is** the single-use credential (collar owns its own nonce/txHash bookkeeping; do **not** rely on protocol resubmit). **Settle payment first (sub-second), release the credential, *then* run the agent asynchronously and stream output — never hold the 402 handshake open across the agent run** (x402 `maxTimeoutSeconds` ~60s < cold `sessions.create` + agent loop). Credential is **off-chain-checked**; do **not** mint one Story License Token per call. Account royalties in an auditable off-chain ledger. Skill hidden from the Wielder (collar never proxies `GET /v1/agents`); document that the host still sees it (accepted per ADR-0004); use self-hosted sandbox config for regulated intra-org/education data (CMA is not ZDR/HIPAA). + +### Phase 2 — On-chain royalty settlement (batched two-leg) +Async settlement worker batches accrued payments per threshold/interval, bridges/swaps USDC(Base)→WIP(Story) via Stargate/Across/deBridge, calls `payRoyaltyOnBehalf` on Story, and runs a **permissionless keeper that auto-claims `claimAllRevenue`** on ancestors' behalf (so the school's revenue never silently piles up). Publish settlement batches on-chain for reconciliation. **Minimize custody:** push in-flight value-holding to a licensed bridge/facilitator partner; keep the collar a non-custodial pass-through riding a hosted facilitator (Coinbase x402, which carries its own KYT/OFAC/licensing) so it looks like a merchant-on-Stripe, not an MSB. + +### Phase 3 — Open Marketplace + tradeable claims (only when warranted) +Only here introduce tradeable Royalty claims — **permissioned**: ERC-3643 allow-list token, Reg D 506(c) to accredited (or Reg A+/CF for retail), secondary trading only on a registered ATS (e.g. Securitize) + transfer agent + KYC. **Engage securities counsel before this phase.** + +### Doc corrections to make now +- **CONTEXT.md line 49 / ADR-0002:** "A Derivative … owes royalties … on each Invocation" → clarify *"automatically credited, claimable on demand (keeper auto-claims)."* +- **CONTEXT.md lines 95–96:** "hidden from the host too" → document as an *accepted v1 trust boundary* (host sees Skill; TEE tabled), not an open question the build solves. +- **ADR-0003:** make explicit that the "no trusted oracle / fraud structurally impossible" guarantee holds **only for the synchronous atomic case** and degrades to "auditable accumulator" once cross-chain batching is introduced. + +--- + +## 7. Open questions to resolve before building + +1. **License Token as non-burned per-call credential** — natively a License Token is an ERC-721 burned *only on derivative registration*. Whether it can be repurposed as a non-burned off-chain entitlement is **unverified** — needs a design spike. (Realistic path: use the x402 `txHash`, treat any License Token as a durable invocation-right metered off-chain.) +2. **Per-invocation agent-to-agent micropayment streams** — no regulatory source squarely analyzes this fact pattern; the MSB / merchant analysis is extrapolated. **Get counsel** to bless the specific collar architecture before launch. +3. **Cold-start latency** — real `sessions.create` → first `agent.message` (sandbox provisioning) is unmeasured. Benchmark before sizing SLAs. +4. **Leg-2 economics on real Story mainnet** — exact USDC(Base)→WIP(Story) bridge cost + confirmation time (vs. generic quotes) is unmeasured; needed to size the batching window and confirm micro-royalty economics. +5. **Long-lived-session isolation across buyers** — to dodge the 300 create-req/min/org ceiling you'll want to reuse sessions, but whether one session cleanly isolates distinct buyers (history accumulation, compaction cost) is unverified; likely one-session-per-buyer, which reintroduces the ceiling. +6. **`receiveWithAuthorization` on WIP / `RoyaltyModule.sol`** — would be needed for any hypothetical direct-to-contract settlement; needs a contract-level read on Story mainnet (almost certainly absent, but confirm). +7. **Live-evolution anti-clone efficacy** — the load-bearing assumption of the no-TEE defense (ship faster than the clone's distill-and-redeploy cadence) is *asserted by analogy, unmeasured*. No source quantifies how fast a Skill must change to keep a distilled clone economically stale. +8. **Off-platform Story enforcement** — the Royalty Module handles on-chain *declared* derivatives flawlessly, but real dispute/takedown outcomes for an *off-platform behavioral clone* (on-chain provenance meets off-chain courts) are undocumented. + +--- + +### What does *not* work (stated plainly) + +- **One payment cannot both gate execution and pay Story royalties.** This is the single hard architectural break. Two legs, always. +- **Per-invocation atomic royalty flow-through.** It is eventually-consistent, batched, and claimable — not atomic, not pushed. +- **Minting a Story License Token per LLM call.** Economically and latency-broken; the credential lives off-chain. +- **Hiding the Skill from the host.** v1 cannot; the host (Anthropic) sees it in plaintext. Accepted per ADR-0004; TEE is the tabled future fix. +- **A permissionless, frictionless tradeable royalty graph.** Tradeable claims are securities → permissioned trading only. +- **ADR-0003's "fraud structurally impossible" once cross-chain batching is in.** It degrades to an auditable trusted accumulator. +- **Defending an individual breakout Skill from off-platform cloning.** The moat protects the marketplace, not the asset, and v1 has no cryptographic answer — only economic and operational ones. + +**None of these is fatal.** They are the difference between the ADR's idealized loop and the buildable one. Build the buildable one. \ No newline at end of file diff --git a/docs/prd-redteam.json b/docs/prd-redteam.json new file mode 100644 index 0000000..52b791a --- /dev/null +++ b/docs/prd-redteam.json @@ -0,0 +1,93 @@ +{ + "overall": "Strong, largely faithful PRD that correctly internalizes the two-leg / eventually-consistent / non-transferable-closed-modes architecture. But it commits one outright reversion to a disproven claim (the \"Why now\" bullet asserts managed agents \"genuinely hide the Skill from the Wielder\" with a headline that reads as a solved-secrecy benefit, and the Competitive table header says CMA is the reason a \"copyable plaintext artifact\" is \"monetizable\" — both flirt with the disproven hidden-from-host framing the way they're titled, though caveated in body), one fabricated statistic (\"~49% of volume on non-Coinbase facilitators\" appears nowhere in report.md or findings.json), and several over-claims — most importantly presenting the fork-killing threshold \"i* = p_parent/p_fork\" as \"a clean, defensible answer\" when both the prototype README and the report list it as an UNRESOLVED open question whose verdict is explicitly \"TBD.\" The closed-form is also derived from a questionable \"recoup-own-uplift\" premise the founder should not present to an investor as settled. Internal contradictions exist between the Economic Design (\"anchor default at the price-ratio, bias ~80% below\") and Risks/Roadmap (which still call inheritBps an open spike to be run) and between R3's \"zero impact for intra-org/education\" and the regulatory section's \"medium confidence / no source squarely analyzes this.\" Missing content a founder/investor demands: no team/capital/timeline/cost, no concrete competitor (Story's own forthcoming monetization, Payman/Skyfire/agent-payment incumbents), no failure/wind-down or kill-criteria, no quantified clone-cost model behind the load-bearing \"price below amortized clone cost\" claim, and no treatment of the legally fatal Phase-1 question of whether a non-transferable revenue right tied to \"efforts of others\" is truly outside Howey (the PRD asserts it repeatedly as settled when the report rates regulatory only medium and the SAFT/Reg-A precedent is unaddressed). Fix the reversion headers, delete the 49% stat, downgrade the threshold from \"answer\" to \"hypothesis to test,\" and add the missing founder-grade sections.", + "revertedClaims": [ + "WHY NOW section, bullet 1 ('Managed agents genuinely hide the Skill from the Wielder'): the bold lead-in reads as a solved-secrecy guarantee and frames CMA hiding as the load-bearing enabler ('This is what makes a copyable plaintext artifact monetizable per-use'). The parenthetical caveat is correct, but the headline framing edges back toward the disproven 'hidden from host' / secrecy-as-moat posture. report.md §3 step 2 and CONTEXT.md lines 98-101 are explicit that content is NOT hidden from the host and that hiding holds ONLY because the collar is sole key-holder — the bold claim should foreground key-custody dependence, not 'genuinely hide.'", + "Competitive Landscape, 'Managed-agent platforms' table row and 'The combination we ship' item 1 ('Hidden hosted execution — the Wielder receives only the output, never the Skill'): repeatedly states hiding as a clean property. Acceptable for Wielder-hiding but the phrase 'hidden hosted execution' without the 'from the Wielder, not the host' qualifier in the headline recurs ~5 times; an adversary reads this as the disproven hidden-from-host claim. Needs the qualifier inline every time it is used as a selling point, per ADR-0004 / report 'What does not work' (#hiding from host).", + "Roadmap MVP section: 'Skill content is hidden from the Wielder (the collar never proxies GET /v1/agents); the host (Anthropic) still sees it in plaintext' — this one is CORRECT and should be the template; flagged only to show the inconsistency with the looser Why-Now/Competitive phrasings above." + ], + "unsupported": [ + "'~49% of volume already on non-Coinbase facilitators' (Problem & Market, Directional market framing) and again implied in Why-Now — this statistic appears NOWHERE in report.md or findings.json (verified by grep). It is a fabricated/uncited number presented as a hard sourced fact. Either source it or delete it.", + "'The threshold is i* = p_parent / p_fork ... Running the model yields a clean, defensible answer' (Economic Design) — presents an OPEN research question as solved. prototype/README.md experiment 5 lists the fork-killing threshold as an experiment whose NOTES/verdict are 'TBD'; the report (§7) and Roadmap Phase-1 spike both list it as unresolved to be validated against the engine. The PRD's own Roadmap contradicts this by still scheduling it as a spike. Over-optimistic.", + "The derivation behind i* assumes the forker should 'recoup exactly their own uplift' and that taxing the forker's own value-add is the definition of the fork-killing zone. This is one defensible modeling choice presented as THE answer; a sophisticated investor will note the premise is asserted, not justified (e.g., it ignores the forker's ongoing maintenance cost, the option value of the parent's live evolution, and demand elasticity). Should be labeled a hypothesis.", + "'a soft floor around $2-3 per invocation at launch batch sizes' (Economic Design) — derived from a self-constructed fee table (avg $5 invocation, 0.5% slippage) that extrapolates beyond the sourced fee components in findings.json. The $0.001/tx facilitator fee and 0.5% swap slippage are sourced, but the specific $2-3 floor and the N=10/100/1000 amortization table are the PRD's own modeling presented with more precision than the inputs support. Flag as illustrative, not validated (report §7.4 lists real Leg-2 bridge cost/latency as UNMEASURED).", + "'price below amortized clone cost' is load-bearing across Exec Summary, Economic Design, GTM, and Risks, but no clone-cost model is ever given. report §7.7 explicitly says live-evolution anti-clone efficacy is 'asserted by analogy, unmeasured' and no source quantifies how fast a Skill must change. The PRD repeats the prescription without acknowledging it rests on an unmeasured assumption in the body (only R1/spike-8 admit it). Over-optimistic where stated as actionable pricing guidance.", + "'2.5% ... sits below typical marketplace take rates (Apple/Google 15-30%, Stripe ~3%)' and 'Treat 2.5% as a ceiling' — the 2.5% is just the prototype's default feeBps; presenting it as a benchmarked, competitively-positioned rate is unsupported. No analysis shows 2.5% covers protocol opex at closed-mode volumes; it may be a floor, not a ceiling.", + "Regulatory section asserts the closed-mode non-transferable claim 'can sit OUTSIDE securities treatment entirely' as near-settled, but the source rates regulatory only MEDIUM confidence and notes 'no regulatory source squarely analyzes the per-invocation agent-to-agent collar fact pattern.' The non-transferability-defeats-Howey conclusion is plausible but the PRD overstates its certainty relative to the evidence (Howey's 'efforts of others' prong is satisfied by the platform's ongoing metering/evolution regardless of transferability; non-transferability defeats the 'investment'/secondary-market narrative but is not a guaranteed safe harbor).", + "GTM ICP '100-800 people' firm and '3-5 design-partner accounts' are presented without any validation that such firms will agree to restructure work-for-hire IP terms; R12 rates willingness-to-co-hold as Medium-High risk and says it is UNvalidated, yet the GTM section treats the wedge as established." + ], + "contradictions": [ + "Economic Design states the fork-killing threshold has a 'clean, defensible answer' (i* = p_parent/p_fork) and prescribes a default, while Roadmap Phase-1 lists 'Fork-killing threshold (economics)' as a pre-build spike that 'must pass before main build' and Risks R-section / prototype README treat it as the key OPEN question. The PRD simultaneously claims to have answered it and schedules work to answer it.", + "R3 (Risks register) lists impact as 'zero for intra-org/education if claims stay non-transferable' (certainty), but the Regulatory section flags regulatory confidence as MEDIUM and says counsel must bless the structure and 'no source squarely analyzes' the fact pattern. 'Zero impact' contradicts 'medium confidence / get counsel.'", + "Economic Design says the engine's hard-coded inheritBps=3000 'should NOT be the protocol-wide recommendation' and recommends anchoring at the price-ratio (~2000 bps neutral, suggest 1500-2000), yet the Education UX, Problem/Market, and the seeded examples throughout present 30% as the working default without flagging it as over-taxing — and the GTM metrics section calls 3000 the 'prototype default' to track against. Inconsistent stance on whether 30% is fine.", + "Exec Summary and Architecture say the keeper 'hides' the pull-not-push reality so 'to every user it just looks like money arriving,' but Risks R-section and Metrics treat unclaimed ancestor balance as an 'operational alarm' that must be monitored and 'royalties claimed vs credited' as a tracked gap — i.e., the lag is NOT fully hidden and is a known reconciliation surface. The 'looks like money arriving' framing undersells the eventual-consistency gap that R14 separately warns will be noticed by sophisticated buyers.", + "Architecture 'What is v1 vs deferred' table marks Leg 2 / on-chain settlement as 'Deferred to Phase 2' and NOT v1, but the MVP/Roadmap and the Who-Pays narrative repeatedly describe the keeper auto-claim and payRoyaltyOnBehalf as part of the core loop users 'see.' The Product UX 'shape everyone shares' step 6 presents on-chain claim as part of the shared loop without flagging it is Phase-2-deferred — a reader of the UX section would think settlement ships in v1.", + "Pricing: Economic Design enforces a '$2-3 soft floor' and says 'the collar should reject Skills priced under the live amortized floor,' but the seeded/illustrative examples used throughout (finmod $5, pdfx $10, recon $20, biofin $25) are fine — EXCEPT the Education base finmod at $5 and any sub-$2 micro-skill the text elsewhere says 'belong in the off-chain-metered ledger of Phase 1.' The floor logic and the Phase-1 off-chain-ledger escape hatch are stated but their interaction (a $5 skill is above floor; sub-dollar ones are not) is muddled — the text says both 'enforce a floor' and 'sub-dollar skills live in the off-chain ledger until batches grow,' which is contradictory about whether they are rejected or accepted-but-off-chain." + ], + "missing": [ + "No team, capital ask, runway, headcount, or build cost/timeline — a founder/investor PRD with a 4-phase roadmap gives zero estimate of engineering months, cost to reach Phase 1 revenue, or what funding each phase needs.", + "No kill-criteria / failure conditions / wind-down. The doc is GO-with-caveats but never states what evidence would make the team STOP (e.g., if cold-start latency exceeds X, if no design-partner employer will co-hold, if clone appears within N weeks of a breakout). Investors demand falsifiable milestones.", + "No real competitive threat analysis of the most obvious incumbents/adjacents: Story Protocol shipping its own monetization/licensing UX (the platform the whole thing depends on could disintermediate it), and the agent-payments incumbents (Skyfire, Payman, Catena/Nevermined, Coinbase's own x402 tooling) who could add metering+royalty. The competitor table lists Virtuals/Olas/Bittensor/Sahara but omits the closest threats: the platforms it builds ON adding the missing layer.", + "No quantitative unit-economics for the BUSINESS (only per-invocation settlement cost). At 2.5% take on $2-25 invocations, what invocation volume is needed to cover the collar's opex, the runtime token cost (CMA ITPM/OTPM passthrough), bridge/keeper gas, and compliance counsel? The Anthropic token cost of the agent run itself (the collar pays it as sole key-holder) is never modeled and could exceed the 2.5% fee.", + "No analysis of who bears the agent inference cost. The collar is sole API-key holder, so the collar PAYS Anthropic for every run. The PRD never says whether the Wielder's USDC payment covers inference cost + protocol fee + royalty, or whether the collar eats inference. This is a load-bearing economic gap.", + "No SLA/reliability or refund-rate target quantified for accept-payment-but-fail-to-run beyond 'keep near zero'; no treatment of chargeback/dispute UX for an irreversible on-chain USDC payment (x402 has no chargebacks — a failed run after settled payment is a refund the collar must fund).", + "No data on demand-side willingness-to-pay. The entire thesis assumes Beneficiaries will pay per-invocation for outputs they could get by cloning; no evidence, pilot LOI, or pricing research is cited beyond x402 aggregate volume (which is mostly agent-infra micropayments, not skill-royalty payments).", + "No treatment of co-authorship / multiple Creators (CONTEXT.md explicitly flags 'A Skill has exactly one Creator at origin (co-authorship is an open question)') — the PRD silently assumes single-creator everywhere; an investor will ask about teams that build a skill jointly.", + "No tax / accounting treatment for the employee in Intra-org (a co-held royalty claim as deferred comp has income-recognition and 409A implications) — the PRD asserts 'like deferred comp' but never addresses that deferred comp is itself heavily regulated (the very mention of 409A/constructive-receipt is absent).", + "No discussion of Story chain / $IP existential dependency mitigation beyond 'monitor' — R15 names it High-impact but offers no concrete fallback (e.g., can provenance be mirrored to a more liquid chain? what if Story sunsets?). For a protocol whose entire IP/royalty layer is one thin illiquid chain, this is under-addressed for an investor.", + "No verification status disclaimer surfaced prominently: findings.json shows regulatory at MEDIUM confidence and multiple load-bearing facts UNMEASURED (cold-start latency, Leg-2 real cost, session isolation, clone-evolution cadence). The PRD scatters these in spikes but never gives the investor a single 'here is what we have NOT yet validated and it could change the plan' box." + ], + "fixes": [ + { + "where": "Problem & Market > Why now, bullet 1 ('Managed agents genuinely hide the Skill from the Wielder')", + "change": "Rewrite the bold lead-in to foreground key-custody dependence, not secrecy: 'Managed agents let the collar hide the Skill from the Wielder — because the collar is the sole API-key holder and never proxies GET /v1/agents, the session output stream carries only the output (report §3 step 2). This is hiding from the WIELDER only; the host (Anthropic) still processes the Skill in plaintext (no TEE), per ADR-0004.' Avoid 'genuinely hide' as a standalone benefit." + }, + { + "where": "Competitive Landscape (table row 'Managed-agent platforms' + 'combination we ship' item 1) and every other use of 'hidden hosted execution'", + "change": "Append the qualifier 'from the Wielder, not from the host' inline at each occurrence, or define the term once as 'Wielder-hidden hosted execution' and use that. Add the host-sees-plaintext caveat to the table row, matching the (correct) Roadmap MVP phrasing." + }, + { + "where": "Problem & Market > Directional market framing (and any Why-Now echo): '~49% of volume already on non-Coinbase facilitators'", + "change": "Delete the 49% figure — it is not in report.md or findings.json. If a multi-facilitator point is wanted, cite only the sourced facts (x402 became a Linux Foundation / x402 Foundation standard with multiple facilitators) without the unsupported percentage." + }, + { + "where": "Economic Design > 'Derivative flow-through and the inherit-bps fork-incentive threshold'", + "change": "Downgrade from answer to hypothesis. Replace 'Running the model yields a clean, defensible answer. The threshold is i* = p_parent/p_fork' with 'A first-pass model SUGGESTS a candidate neutral point at i* = p_parent/p_fork under a recoup-own-uplift assumption; this is a HYPOTHESIS to validate against the engine and against real fork behavior (prototype experiment 5, currently TBD), not a settled result.' State the assumptions explicitly (ignores maintenance cost, parent live-evolution option value, demand elasticity) and reconcile with Roadmap Phase-1 spike which still lists it as open." + }, + { + "where": "Economic Design > Pricing (the N=1/10/100/1000 fee table and '$2-3 soft floor')", + "change": "Label the table 'illustrative model, inputs partially unmeasured' and cite report §7.4 (real USDC→WIP bridge cost/latency UNMEASURED). Reconcile the '$2-3 floor / collar rejects sub-floor skills' rule with the separate claim that sub-dollar skills 'live in the off-chain Phase-1 ledger' — state one coherent policy (e.g., off-chain metered for sub-floor, on-chain settlement above floor)." + }, + { + "where": "Risks register R3 ('zero for intra-org/education')", + "change": "Change 'zero' to 'low/likely-outside-securities, MEDIUM confidence — non-transferability defeats the secondary-market investment narrative but the Howey efforts-of-others prong is still satisfied by ongoing platform metering/evolution; counsel sign-off required (report regulatory = medium confidence). Add 409A/deferred-comp and constructive-receipt as a named sub-risk.'" + }, + { + "where": "Regulatory & Compliance Strategy > Securities posture (closed-mode escape hatch)", + "change": "Soften the certainty to match medium-confidence source: state that non-transferability is the BEST AVAILABLE route to stay outside securities treatment but is not a guaranteed safe harbor, that no source squarely analyzes the per-invocation collar fact pattern, and that counsel must bless the deferred-comp/license-fee structure BEFORE Phase 1 ships (not merely as 'overhead')." + }, + { + "where": "Economic Design (new subsection) and Architecture", + "change": "Add who-bears-inference-cost economics: the collar is sole Anthropic key-holder and therefore pays per-run inference (ITPM/OTPM). Model whether the Wielder's USDC invocation price covers inference + protocol fee + royalty + settlement; show that the 2.5% protocol fee is computed on the price but the inference cost is a separate COGS the collar funds. Without this the unit economics are undefined." + }, + { + "where": "Whole document (add a new top-level section, e.g. after Risks)", + "change": "Add the founder/investor-grade sections currently missing: (1) Team & capital/timeline per phase; (2) Kill-criteria / falsifiable go-no-go milestones (latency threshold, design-partner LOI, time-to-first-clone); (3) Business-level unit economics (volume to cover opex incl. inference); (4) Demand-side validation status (no LOIs yet — say so); (5) a single 'What we have NOT validated' box pulling the unmeasured items from report §7." + }, + { + "where": "Competitive Landscape table", + "change": "Add the nearest real threats omitted: Story Protocol itself adding a monetization/hosting layer (platform-disintermediation risk), and agent-payment incumbents (Skyfire / Payman / Nevermined / Coinbase x402 native tooling) who could bolt royalty+metering onto an existing rail. State the defensibility honestly given every primitive is open." + }, + { + "where": "Product & UX > 'shape everyone shares' step 6 and Architecture v1/deferred table", + "change": "Flag in the shared-loop walkthrough that steps 5-6 (on-chain claim/settlement, Leg 2) are DEFERRED to Phase 2; in Phase 1 the 'claim' is a withdrawal against the off-chain auditable ledger, not on-chain settlement. Make the UX section consistent with the v1-vs-deferred table." + }, + { + "where": "Problem & Market and GTM (ICP / wedge claims)", + "change": "Mark the willingness-to-co-hold and ICP profile as ASSUMPTIONS to validate with design partners (per R12, currently rated Medium-High and unvalidated), rather than stated as established market structure. Add explicit demand-side validation as a Phase-0/1 gate." + }, + { + "where": "Co-authorship (CONTEXT.md flagged open question)", + "change": "Add a short subsection acknowledging multi-Creator skills are an open design question (single-Creator assumed in v1), since CONTEXT.md flags it and the royalty model currently assumes one creator at origin." + } + ] +} \ No newline at end of file diff --git a/phase0/.env.example b/phase0/.env.example new file mode 100644 index 0000000..f5c7393 --- /dev/null +++ b/phase0/.env.example @@ -0,0 +1,16 @@ +# Copy to .env and fill in. NEVER commit .env (see .gitignore). + +# A testnet wallet private key (hex, with or without 0x). Use a THROWAWAY key. +# Fund it with test IP from https://aeneid.faucet.story.foundation/ +WALLET_PRIVATE_KEY= + +# Story Aeneid testnet RPC (default is fine) +RPC_PROVIDER_URL=https://aeneid.storyrpc.io + +# Set after running `npm run create-collection` (the printed spgNftContract) +SPG_NFT_CONTRACT= + +# Optional: real IPFS metadata URIs. If unset, placeholders are used (fine for a +# provenance demo; the on-chain record still stores the content hashes). +IP_METADATA_URI= +NFT_METADATA_URI= diff --git a/phase0/.gitignore b/phase0/.gitignore new file mode 100644 index 0000000..d21b1cb --- /dev/null +++ b/phase0/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +dist/ +*.log diff --git a/phase0/README.md b/phase0/README.md new file mode 100644 index 0000000..ece685a --- /dev/null +++ b/phase0/README.md @@ -0,0 +1,65 @@ +# Phase 0 — Provenance (Story IP Assets + Derivatives) + +The first real, shippable slice (ADR-0006, Phase 0). It establishes the +**provenance + fork-graph** that the whole moat rests on — *before* any payments, +agents, or settlement. Everything runs on **Story's Aeneid testnet**. + +What it does: +- Register a **Skill** as a Story **IP Asset** with commercial-remix **PIL** license terms. +- Hash the actual skill artifact (`--skill-file`) into the on-chain record (content provenance). +- Register a **Derivative** (fork) that declares its parent on-chain, so royalties can flow later. + +Royalty policy defaults to **LAP** (the originator keeps a share of *all* descendants regardless of +depth) — the answer to the spike-4 depth-dilution finding. Use `--policy LRP` for per-hop relative. +The LAP-vs-LRP decision is a real Phase-2 commitment (see `../docs/feasibility/prebuild-spikes.md`); +LAP is the safer default for the education vision. + +## Setup + +```bash +cd phase0 +npm install +cp .env.example .env # then edit .env +``` + +In `.env`, set `WALLET_PRIVATE_KEY` to a **throwaway** testnet key, then fund it: +- Faucet: https://aeneid.faucet.story.foundation/ (10 IP per claim) + +```bash +npm run check # confirms wallet, chain, balance +``` + +## Usage + +```bash +# 1. one-time: create an SPG NFT collection to mint Skills into +npm run create-collection +# → copy the printed spgNftContract into .env as SPG_NFT_CONTRACT + +# 2. register a Skill (here, hashing this repo's own CONTEXT.md as the artifact) +npm run register-skill -- --name "fin-modeling" --description "base financial-modeling skill" \ + --skill-file ../CONTEXT.md --rev-share 25 +# → prints ipId + licenseTermsId, and the exact command to fork it + +# 3. register a Derivative (a student forking the school's Skill) +npm run register-derivative -- --parent --license-terms-id \ + --name "biotech-fin-modeling" --description "a fork specialised for biotech" +``` + +Each command prints an explorer link (`https://aeneid.explorer.story.foundation/ipa/`) so you +can see the IP Asset and its parent/child links on-chain. + +## What this proves (and what it deliberately doesn't) + +**Proves:** a Skill and its fork lineage are registered on-chain with declared ancestry and license +terms — the provenance layer the marketplace moat depends on (ADR-0004). + +**Out of scope for Phase 0** (later phases): the payment gate (x402), hidden hosted execution +(managed agent), and royalty *settlement* (the two-leg flow of ADR-0005). This slice intentionally +has no money movement. + +## Notes +- Testnet only. Use a throwaway key. +- Metadata: if you don't set `IP_METADATA_URI` / `NFT_METADATA_URI`, placeholders are used — the + on-chain content **hashes** are still real. For production, pin the metadata JSON to IPFS. +- SDK: `@story-protocol/core-sdk` v1.4.x. `commercialRevShare` is an integer **percent (0–100)**. diff --git a/phase0/package-lock.json b/phase0/package-lock.json new file mode 100644 index 0000000..565ed5f --- /dev/null +++ b/phase0/package-lock.json @@ -0,0 +1,894 @@ +{ + "name": "skill-asset-phase0", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skill-asset-phase0", + "version": "0.0.0", + "dependencies": { + "@story-protocol/core-sdk": "^1.4.4", + "dotenv": "^16.4.5", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@story-protocol/core-sdk": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@story-protocol/core-sdk/-/core-sdk-1.4.4.tgz", + "integrity": "sha512-YZlNcbVydVnESuAseaBV74/vi8S9/DiYjo18XBQGLoOsrV2tSoaWL1f/zQBscwnK8PFyi5bmS29kTysKwjVfJA==", + "license": "MIT", + "dependencies": { + "@scure/bip32": "^1.6.2", + "abitype": "^0.10.2", + "bs58": "^6.0.0", + "dotenv": "^16.3.1", + "multiformats": "9.9.0", + "viem": "^2.8.12" + } + }, + "node_modules/@types/node": { + "version": "22.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abitype": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-0.10.3.tgz", + "integrity": "sha512-tRN+7XIa7J9xugdbRzFv/95ka5ivR/sRe01eiWvM0HWWjHuigSZEACgKa0sj4wGuekTDtghCx+5Izk/cOi78pQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, + "node_modules/ox": { + "version": "0.14.29", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.29.tgz", + "integrity": "sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/abitype": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.4.tgz", + "integrity": "sha512-dpKH+N27vRjarMVTFFkeY445VTKftzGWpL0FiT7xmVmzQRKazZexzC5uHG0f6XKsVLAuUlndnbGau6lRejClxg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/viem": { + "version": "2.52.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.52.2.tgz", + "integrity": "sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.29", + "ws": "8.20.1" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/phase0/package.json b/phase0/package.json new file mode 100644 index 0000000..e10bfdb --- /dev/null +++ b/phase0/package.json @@ -0,0 +1,24 @@ +{ + "name": "skill-asset-phase0", + "private": true, + "type": "module", + "version": "0.0.0", + "description": "Phase 0 (ADR-0006): register a Skill as a Story IP Asset with commercial-remix PIL terms, and declare a Derivative (fork). Provenance + fork-graph on Story Aeneid testnet.", + "scripts": { + "check": "tsx src/index.ts check", + "create-collection": "tsx src/index.ts create-collection", + "register-skill": "tsx src/index.ts register-skill", + "register-derivative": "tsx src/index.ts register-derivative", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@story-protocol/core-sdk": "^1.4.4", + "dotenv": "^16.4.5", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } +} diff --git a/phase0/src/client.ts b/phase0/src/client.ts new file mode 100644 index 0000000..017a1bb --- /dev/null +++ b/phase0/src/client.ts @@ -0,0 +1,40 @@ +import "dotenv/config"; +import { createPublicClient, defineChain, http } from "viem"; +import { privateKeyToAccount, type Account } from "viem/accounts"; +import { StoryClient, type StoryConfig } from "@story-protocol/core-sdk"; + +const RPC = process.env.RPC_PROVIDER_URL ?? "https://aeneid.storyrpc.io"; + +// Story Aeneid testnet (chainId 1315). Defined locally so the read path doesn't +// depend on an SDK chain export. +export const aeneidChain = defineChain({ + id: 1315, + name: "Story Aeneid", + nativeCurrency: { name: "IP", symbol: "IP", decimals: 18 }, + rpcUrls: { default: { http: [RPC] } }, +}); + +export const EXPLORER = "https://aeneid.explorer.story.foundation"; + +export function getAccount(): Account { + const pk = process.env.WALLET_PRIVATE_KEY; + if (!pk) throw new Error("WALLET_PRIVATE_KEY missing — copy .env.example to .env and fill it in."); + const hex = (pk.startsWith("0x") ? pk : `0x${pk}`) as `0x${string}`; + return privateKeyToAccount(hex); +} + +// Story SDK client (write path). chainId "aeneid" lets the SDK resolve its own +// contract addresses for the testnet. +export function getClient(): StoryClient { + const config: StoryConfig = { + account: getAccount(), + transport: http(RPC), + chainId: "aeneid", + }; + return StoryClient.newClient(config); +} + +// viem public client (read path: balances, etc.) +export function getPublicClient() { + return createPublicClient({ chain: aeneidChain, transport: http(RPC) }); +} diff --git a/phase0/src/index.ts b/phase0/src/index.ts new file mode 100644 index 0000000..cd5933b --- /dev/null +++ b/phase0/src/index.ts @@ -0,0 +1,152 @@ +import { parseArgs } from "node:util"; +import { formatEther, type Address } from "viem"; +import { PILFlavor, WIP_TOKEN_ADDRESS, NativeRoyaltyPolicy } from "@story-protocol/core-sdk"; +import { getAccount, getClient, getPublicClient, EXPLORER } from "./client"; +import { buildMetadata } from "./metadata"; + +const { values: o, positionals } = parseArgs({ + allowPositionals: true, + options: { + name: { type: "string" }, + description: { type: "string" }, + "skill-file": { type: "string" }, + "rev-share": { type: "string" }, // percent 0-100 + policy: { type: "string" }, // LAP | LRP + "minting-fee": { type: "string" }, // wei + spg: { type: "string" }, + symbol: { type: "string" }, + parent: { type: "string" }, // parent ipId for a derivative + "license-terms-id": { type: "string" }, + }, +}); + +const cmd = positionals[0]; + +function spgAddress(): Address { + const spg = (o.spg ?? process.env.SPG_NFT_CONTRACT) as Address | undefined; + if (!spg) throw new Error("No SPG collection. Run `npm run create-collection`, then set SPG_NFT_CONTRACT in .env (or pass --spg)."); + return spg; +} + +async function check() { + const account = getAccount(); + const bal = await getPublicClient().getBalance({ address: account.address }); + console.log("wallet :", account.address); + console.log("chain : Story Aeneid (1315)"); + console.log("balance :", formatEther(bal), "IP"); + console.log("SPG contract:", process.env.SPG_NFT_CONTRACT || "(none — run create-collection)"); + if (bal === 0n) console.log("\n⚠ Wallet has 0 IP. Fund it: https://aeneid.faucet.story.foundation/"); +} + +async function createCollection() { + const client = getClient(); + const res = await client.nftClient.createNFTCollection({ + name: o.name ?? "Skills", + symbol: o.symbol ?? "SKILL", + isPublicMinting: true, + mintOpen: true, + mintFeeRecipient: getAccount().address, + contractURI: "", + }); + console.log("✓ SPG collection created"); + console.log("spgNftContract:", res.spgNftContract); + console.log("txHash :", res.txHash); + console.log("\n→ add to .env: SPG_NFT_CONTRACT=" + res.spgNftContract); +} + +async function registerSkill() { + if (!o.name) throw new Error("--name is required"); + const client = getClient(); + const revShare = Number(o["rev-share"] ?? "25"); + const policy = (o.policy ?? "LAP").toUpperCase() === "LRP" ? NativeRoyaltyPolicy.LRP : NativeRoyaltyPolicy.LAP; + const mintingFee = BigInt(o["minting-fee"] ?? "0"); + + const meta = buildMetadata(client, { + name: o.name, + description: o.description ?? "", + creatorAddress: getAccount().address, + skillFile: o["skill-file"], + }); + + const terms = PILFlavor.commercialRemix({ + defaultMintingFee: mintingFee, + commercialRevShare: revShare, // percent 0-100 + currency: WIP_TOKEN_ADDRESS, + royaltyPolicy: policy, // default LAP — protects originators against depth-dilution (spike 4) + }); + + const res = await client.ipAsset.mintAndRegisterIpAssetWithPilTerms({ + spgNftContract: spgAddress(), + licenseTermsData: [{ terms }], + ipMetadata: meta.onchain, + }); + + console.log("✓ Skill registered as a Story IP Asset"); + console.log("ipId :", res.ipId); + console.log("tokenId :", res.tokenId?.toString()); + console.log("licenseTermsId:", res.licenseTermsIds?.[0]?.toString()); + console.log("revShare :", revShare + "%", "| policy:", policy === NativeRoyaltyPolicy.LRP ? "LRP" : "LAP"); + if (meta.contentHash) console.log("skill content :", meta.contentHash, "(keccak256 of the artifact)"); + console.log("txHash :", res.txHash); + console.log("explorer :", `${EXPLORER}/ipa/${res.ipId}`); + console.log("\n→ to fork this Skill: npm run register-derivative -- --parent " + res.ipId + " --license-terms-id " + res.licenseTermsIds?.[0]?.toString() + " --name \"\""); +} + +async function registerDerivative() { + if (!o.name) throw new Error("--name is required"); + if (!o.parent) throw new Error("--parent is required"); + if (!o["license-terms-id"]) throw new Error("--license-terms-id is required (the parent's licenseTermsId)"); + const client = getClient(); + + const meta = buildMetadata(client, { + name: o.name, + description: o.description ?? "", + creatorAddress: getAccount().address, + skillFile: o["skill-file"], + }); + + const res = await client.ipAsset.mintAndRegisterIpAndMakeDerivative({ + spgNftContract: spgAddress(), + derivData: { + parentIpIds: [o.parent as Address], + licenseTermsIds: [BigInt(o["license-terms-id"])], + maxMintingFee: 0n, + maxRts: 100_000_000, + maxRevenueShare: 100, + }, + ipMetadata: meta.onchain, + }); + + console.log("✓ Derivative registered (owes royalties to its parent on-chain)"); + console.log("ipId :", res.ipId); + console.log("tokenId :", res.tokenId?.toString()); + console.log("parent :", o.parent); + console.log("txHash :", res.txHash); + console.log("explorer:", `${EXPLORER}/ipa/${res.ipId}`); +} + +const commands: Record Promise> = { + check, + "create-collection": createCollection, + "register-skill": registerSkill, + "register-derivative": registerDerivative, +}; + +async function main() { + const run = cmd ? commands[cmd] : undefined; + if (!run) { + console.log("Phase 0 — Story provenance CLI\n"); + console.log("commands:"); + console.log(" npm run check"); + console.log(" npm run create-collection [-- --name Skills --symbol SKILL]"); + console.log(" npm run register-skill -- --name \"\" [--description \"..\"] [--skill-file path] [--rev-share 25] [--policy LAP|LRP]"); + console.log(" npm run register-derivative -- --parent --license-terms-id --name \"\""); + process.exit(cmd ? 1 : 0); + } + await run(); +} + +main().catch((err) => { + console.error("\n✗ " + (err instanceof Error ? err.message : String(err))); + process.exit(1); +}); diff --git a/phase0/src/metadata.ts b/phase0/src/metadata.ts new file mode 100644 index 0000000..23baa89 --- /dev/null +++ b/phase0/src/metadata.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { keccak256, toHex } from "viem"; +import type { StoryClient } from "@story-protocol/core-sdk"; + +export interface SkillInput { + name: string; + description: string; + creatorAddress: `0x${string}`; + /** Optional path to the real SKILL.md / artifact — its content is hashed for provenance. */ + skillFile?: string; +} + +/** + * Build the IP + NFT metadata for a Skill and the on-chain {uri, hash} pairs the + * register calls expect. `createdAt` is fixed so the metadata (and its hash) is + * reproducible — do not inject a wall-clock time here. + */ +export function buildMetadata(client: StoryClient, input: SkillInput) { + let contentHash: `0x${string}` | undefined; + if (input.skillFile) { + const content = readFileSync(input.skillFile, "utf8"); + contentHash = keccak256(toHex(content)); // fingerprint of the actual artifact + } + + const ipMetadata = client.ipAsset.generateIpMetadata({ + title: input.name, + description: input.description, + createdAt: "0", + ipType: "skill", + creators: [ + { name: "creator", address: input.creatorAddress, contributionPercent: 100 }, + ], + ...(contentHash ? { mediaHash: contentHash, mediaType: "text/markdown" } : {}), + }); + + const nftMetadata = { name: input.name, description: input.description }; + + const onchain = { + ipMetadataURI: process.env.IP_METADATA_URI || "ipfs://placeholder-ip-metadata", + ipMetadataHash: keccak256(toHex(JSON.stringify(ipMetadata))), + nftMetadataURI: process.env.NFT_METADATA_URI || "ipfs://placeholder-nft-metadata", + nftMetadataHash: keccak256(toHex(JSON.stringify(nftMetadata))), + }; + + return { ipMetadata, nftMetadata, contentHash, onchain }; +} diff --git a/phase0/tsconfig.json b/phase0/tsconfig.json new file mode 100644 index 0000000..77069ae --- /dev/null +++ b/phase0/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/prototype/README.md b/prototype/README.md new file mode 100644 index 0000000..131fcfc --- /dev/null +++ b/prototype/README.md @@ -0,0 +1,65 @@ +# Prototype — settlement loop economics + +> **Throwaway.** This exists to answer one question, then be deleted or absorbed. +> `settlement-engine.mjs` is the keeper (pure logic); `settlement-tui.mjs` is the disposable shell. + +## The question + +**Do the Skill Asset Protocol settlement economics actually create the incentives we claim?** +Specifically: when a Wielder pays per **Invocation**, does the recursive royalty split — protocol fee, +then composable flow-through up the **Derivative** ancestry to ancestors — distribute money in a way +that feels *fair and motivating* across all three modes (marketplace, intra-org co-ownership, +education)? Does payment-gating ("no credential, no run") hold? Push multi-level derivative chains, +extreme prices, and lopsided co-held claims through it and watch for "wait, that shouldn't happen." + +This is a **logic** prototype — state and money flow, not UI. + +## Run + +```bash +node prototype/settlement-tui.mjs +``` + +In-memory only; nothing persists. `seed` resets to the demo scenario; `quit` exits. + +## What's seeded + +| Skill | Mode | Price | Ancestry | Royalty claim | +|---|---|---|---|---| +| `pdfx` | marketplace | $10 | root | dana 100% | +| `recon` | intra-org | $20 | root | sam 50% + megacorp 50% (co-held) | +| `finmod` | education | $5 | root | stateu 100% | +| `biofin` | education | $25 | ↳ finmod @ 30%↑ | mia 100% | + +## Experiments worth running (the interesting moments) + +1. `invoke biofin biocorp` — education flow-through. Mia keeps ~$17, State U gets ~$7 as ancestor. *Does that split feel right?* +2. `invoke recon otherco` — co-held claim. Sam **and** MegaCorp both earn from an external invocation. *This is the "benefits both" claim made concrete.* +3. `bypass pdfx acme` — the gate. Confirm there's no legal way to run without paying. +4. Build a 3-deep chain: `fork biofin mia deep-skill 40 5000` then `invoke deep-skill biocorp` — *does multi-level flow-through still feel fair, or does the original creator get dust?* +5. `inherit biofin 9000` then re-invoke — *what happens to the forker's incentive when ancestors take almost everything?* (Is there a fork-killing threshold?) +6. `fee 4000` — crank the protocol fee. *At what point does the model feel extractive?* +7. `royalty recon sam:9000,megacorp:1000` — shift the co-ownership. *Where's the split an employer would actually sign?* + +## NOTES — the answer (from the pre-build economics spike) + +Run `node prototype/spike-fork-economics.mjs` to reproduce. Verdict: + +**1. The fork-killing threshold is real and matches the hypothesis `i* = p_parent / p_fork`.** +The leaf (latest forker) keeps `(1 − inherit)` of net *regardless of chain depth*. Forking beats +authoring your uplift solo as long as `inherit < p_parent/p_fork`. Confirmed: parent $5→fork $15 +crosses at 33%; parent $15→fork $25 crosses at 60%. **Recommended default: suggest inherit at the +price-ratio and let the Creator tune it; flag anything above as "may discourage forking."** + +**2. The surprising finding — the dilution victim is the ORIGINAL creator, not the leaf.** +With a *flat per-hop* inherit, the originator's share decays geometrically with depth: +at 30%/hop the root (school) gets 30% → 9% → 2.7% → 0.8% across depths 1–4. The school earns well +from *direct* forks but is squeezed to dust in long lineages. The leaf is never squeezed. + +**3. Design implication (for Phase 2 royalty policy).** This is exactly the **Story LAP vs LRP** +choice. A flat per-hop relative split (LRP-like) dilutes originators by depth. To protect the +originator's share against depth, use **LAP (whole-ancestry absolute)** — the root keeps a fixed % +of *all* descendants regardless of depth. Trade-off: LAP caps total downstream royalty, LRP lets it +compound per hop. Decision deferred to Phase 2; record as an ADR when committed. + +_No CONTEXT.md term change needed; this is an economic-policy finding, not a language one._ diff --git a/prototype/settlement-engine.mjs b/prototype/settlement-engine.mjs new file mode 100644 index 0000000..d1b9c3e --- /dev/null +++ b/prototype/settlement-engine.mjs @@ -0,0 +1,200 @@ +// settlement-engine.mjs +// +// PROTOTYPE — pure logic module (the keeper). No terminal I/O lives here, so this +// can be lifted into the real codebase later. The TUI shell imports it; nothing +// flows the other way. +// +// Models the Skill Asset Protocol settlement loop (see ../CONTEXT.md, ../docs/adr/): +// register Skill -> fork into Derivatives (ancestry graph) -> Wielder pays per +// Invocation -> single-use Execution credential minted & consumed -> payment splits +// to royalty holders, flowing through the Derivative ancestry to ancestors, minus a +// protocol fee. + +export function createState() { + return { + feeBps: 250, // protocol fee, basis points (250 = 2.5%) + treasury: 0, + parties: {}, // id -> { id, name, role, balance } + skills: {}, // id -> { id, name, creatorId, mode, price, parentIds:[], inheritBps, royalty:[{partyId,bps}] } + credentials: [], // { id, skillId, wielderId, used } + seq: 0, + lastResult: null, // most recent action outcome, for the TUI to render + }; +} + +// ---- mutations (pure w.r.t. the passed state; no side effects outside it) ---- + +export function addParty(state, { id, name, role, balance = 0 }) { + id = id || slug(name); + if (state.parties[id]) throw new Error(`party '${id}' already exists`); + state.parties[id] = { id, name, role, balance: Number(balance) }; + return id; +} + +export function registerSkill(state, { id, name, creatorId, price, mode = 'marketplace' }) { + requireParty(state, creatorId); + id = id || slug(name); + if (state.skills[id]) throw new Error(`skill '${id}' already exists`); + state.skills[id] = { + id, name, creatorId, mode, + price: Number(price), + parentIds: [], + inheritBps: 0, + royalty: [{ partyId: creatorId, bps: 10000 }], // creator holds 100% of its own claim by default + }; + return id; +} + +export function forkSkill(state, { id, parentId, creatorId, name, price, inheritBps = 3000 }) { + const parent = requireSkill(state, parentId); + requireParty(state, creatorId); + id = id || slug(name); + if (state.skills[id]) throw new Error(`skill '${id}' already exists`); + state.skills[id] = { + id, name, creatorId, mode: parent.mode, + price: Number(price), + parentIds: [parentId], + inheritBps: clampBps(Number(inheritBps)), + royalty: [{ partyId: creatorId, bps: 10000 }], + }; + return id; +} + +export function setPrice(state, skillId, price) { + requireSkill(state, skillId).price = Number(price); +} + +export function setInherit(state, skillId, bps) { + requireSkill(state, skillId).inheritBps = clampBps(Number(bps)); +} + +export function setFee(state, bps) { + state.feeBps = clampBps(Number(bps)); +} + +// holders: [{ partyId, bps }] — must sum to 10000. This is how a Royalty claim is co-held. +export function setRoyalty(state, skillId, holders) { + const skill = requireSkill(state, skillId); + const total = holders.reduce((a, h) => a + h.bps, 0); + if (total !== 10000) throw new Error(`royalty must sum to 10000 bps (got ${total})`); + for (const h of holders) requireParty(state, h.partyId); + skill.royalty = holders.map((h) => ({ partyId: h.partyId, bps: clampBps(h.bps) })); +} + +// The core economic event. Payment-gated (ADR 0003): pay -> mint credential -> +// consume credential -> execute -> settle. +export function invoke(state, skillId, wielderId) { + const skill = requireSkill(state, skillId); + const wielder = requireParty(state, wielderId); + const price = skill.price; + if (wielder.balance < price) + throw new Error(`${wielder.name} can't afford ${money(price)} (balance ${money(wielder.balance)})`); + + // 1. pay + wielder.balance = round(wielder.balance - price); + // 2. mint single-use Execution credential + const credId = `cred-${(state.seq += 1)}`; + const credential = { id: credId, skillId, wielderId, used: false }; + state.credentials.push(credential); + // 3. runtime verifies + consumes credential ("no credential, no run") + credential.used = true; + // 4. settle: protocol fee, then recursive royalty split through the ancestry + const fee = round((price * state.feeBps) / 10000); + state.treasury = round(state.treasury + fee); + const net = round(price - fee); + const breakdown = []; + distribute(state, skillId, net, breakdown, 0); + + const result = { + type: 'invoke', skillId, skillName: skill.name, wielderId, + wielderName: wielder.name, price, fee, net, credentialId: credId, breakdown, + output: `«mock output of ${skill.name}»`, + }; + state.lastResult = result; + return result; +} + +// Demonstrates the gate: there is no legal way to run without paying first. +export function attemptBypass(state, skillId, wielderId) { + requireSkill(state, skillId); + requireParty(state, wielderId); + throw new Error( + `NO CREDENTIAL, NO RUN — payment is the meter (ADR 0003). Run \`invoke ${skillId} ${wielderId}\` to pay first.`, + ); +} + +// Recursive composable royalty flow-through (ADR 0002). `amount` arrives at a Skill; +// it passes `inheritBps` up to its parent(s) (who recurse), and pays the remainder to +// its own royalty holders. +function distribute(state, skillId, amount, breakdown, depth) { + const skill = state.skills[skillId]; + let own = amount; + if (skill.parentIds.length && skill.inheritBps > 0) { + const up = round((amount * skill.inheritBps) / 10000); + own = round(amount - up); + const perParent = round(up / skill.parentIds.length); + for (const pid of skill.parentIds) distribute(state, pid, perParent, breakdown, depth + 1); + } + for (const h of skill.royalty) { + const amt = round((own * h.bps) / 10000); + state.parties[h.partyId].balance = round(state.parties[h.partyId].balance + amt); + breakdown.push({ + partyId: h.partyId, + partyName: state.parties[h.partyId].name, + viaSkillId: skillId, + viaSkillName: skill.name, + bps: h.bps, + amount: amt, + kind: depth === 0 ? 'creator' : 'ancestor', + depth, + }); + } +} + +// Seed all three modes so there's something to play with immediately. +export function seedDemo(state) { + Object.assign(state, createState()); + + // --- Marketplace: independent creator, Wielder == Beneficiary --- + addParty(state, { id: 'dana', name: 'Dana (indie creator)', role: 'Creator', balance: 0 }); + addParty(state, { id: 'acme', name: 'Acme Corp', role: 'Wielder/Beneficiary', balance: 1000 }); + registerSkill(state, { id: 'pdfx', name: 'pdf-extract', creatorId: 'dana', price: 10, mode: 'marketplace' }); + + // --- Intra-org: employee + employer CO-HOLD the royalty claim (50/50) --- + addParty(state, { id: 'sam', name: 'Sam (employee)', role: 'Creator', balance: 0 }); + addParty(state, { id: 'megacorp', name: 'MegaCorp (employer)', role: 'Co-owner', balance: 0 }); + addParty(state, { id: 'otherco', name: 'OtherCo (external)', role: 'Wielder/Beneficiary', balance: 1000 }); + registerSkill(state, { id: 'recon', name: 'ledger-recon', creatorId: 'sam', price: 20, mode: 'intra-org' }); + setRoyalty(state, 'recon', [{ partyId: 'sam', bps: 5000 }, { partyId: 'megacorp', bps: 5000 }]); + + // --- Education: school base Skill -> student Derivative -> employer pays --- + addParty(state, { id: 'stateu', name: 'State U (school)', role: 'Creator', balance: 0 }); + addParty(state, { id: 'mia', name: 'Mia (student->grad)', role: 'Creator', balance: 0 }); + addParty(state, { id: 'biocorp', name: 'BioCorp (employer)', role: 'Wielder/Beneficiary', balance: 1000 }); + registerSkill(state, { id: 'finmod', name: 'fin-modeling (base)', creatorId: 'stateu', price: 5, mode: 'education' }); + forkSkill(state, { id: 'biofin', parentId: 'finmod', creatorId: 'mia', name: 'biotech-fin-modeling', price: 25, inheritBps: 3000 }); + + state.lastResult = { + type: 'note', + note: 'Seeded 3 modes: marketplace (pdfx), intra-org co-held 50/50 (recon), education chain finmod->biofin (30% flow-through). Try: invoke biofin biocorp', + }; +} + +// helper to walk a Skill's ancestry, for display +export function ancestry(state, skillId) { + const chain = []; + let cur = state.skills[skillId]; + while (cur && cur.parentIds.length) { + chain.push(cur.parentIds[0]); + cur = state.skills[cur.parentIds[0]]; + } + return chain; +} + +// ---- internals ---- +function requireParty(state, id) { const p = state.parties[id]; if (!p) throw new Error(`no party '${id}'`); return p; } +function requireSkill(state, id) { const s = state.skills[id]; if (!s) throw new Error(`no skill '${id}'`); return s; } +function clampBps(n) { if (Number.isNaN(n)) throw new Error('bps must be a number'); return Math.max(0, Math.min(10000, Math.round(n))); } +function round(n) { return Math.round(n * 100) / 100; } +function slug(s) { return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 12); } +export function money(n) { return '$' + Number(n).toFixed(2); } diff --git a/prototype/settlement-tui.mjs b/prototype/settlement-tui.mjs new file mode 100644 index 0000000..2fcf691 --- /dev/null +++ b/prototype/settlement-tui.mjs @@ -0,0 +1,112 @@ +// settlement-tui.mjs +// +// PROTOTYPE — THROWAWAY terminal shell. Delete this file once the economics question +// is answered; keep settlement-engine.mjs. Run: node prototype/settlement-tui.mjs + +import readline from 'node:readline'; +import * as E from './settlement-engine.mjs'; + +const B = (s) => `\x1b[1m${s}\x1b[0m`; +const D = (s) => `\x1b[2m${s}\x1b[0m`; +const G = (s) => `\x1b[32m${s}\x1b[0m`; +const R = (s) => `\x1b[31m${s}\x1b[0m`; +const Y = (s) => `\x1b[33m${s}\x1b[0m`; +const C = (s) => `\x1b[36m${s}\x1b[0m`; +const pad = (s, n) => String(s).padEnd(n); +const m = E.money; + +const state = E.createState(); +E.seedDemo(state); + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +function render() { + console.clear(); + const L = []; + L.push(B(' SKILL ASSET PROTOCOL — settlement loop prototype')); + L.push(D(` protocol fee: ${(state.feeBps / 100).toFixed(2)}% treasury: ${m(state.treasury)} credentials minted: ${state.credentials.length}`)); + L.push(''); + + // Parties + L.push(B(' PARTIES')); + L.push(D(' ' + pad('id', 10) + pad('name', 26) + pad('role', 22) + 'balance')); + for (const p of Object.values(state.parties)) { + const bal = p.balance > 0 ? G(m(p.balance)) : D(m(p.balance)); + L.push(' ' + C(pad(p.id, 10)) + pad(p.name, 26) + D(pad(p.role, 22)) + bal); + } + L.push(''); + + // Skills + L.push(B(' SKILLS')); + L.push(D(' ' + pad('id', 9) + pad('name', 24) + pad('mode', 12) + pad('price', 8) + pad('ancestry / inherit', 24) + 'royalty claim')); + for (const s of Object.values(state.skills)) { + const anc = s.parentIds.length ? `↳ ${s.parentIds.join(',')} @ ${(s.inheritBps / 100).toFixed(0)}%↑` : D('— (root)'); + const roy = s.royalty.map((h) => `${h.partyId} ${(h.bps / 100).toFixed(0)}%`).join(' + '); + L.push(' ' + C(pad(s.id, 9)) + pad(s.name, 24) + D(pad(s.mode, 12)) + pad(m(s.price), 8) + pad(anc, 24) + roy); + } + L.push(''); + + // Last result + L.push(B(' LAST ACTION')); + const r = state.lastResult; + if (!r) { + L.push(D(' (none yet)')); + } else if (r.type === 'error') { + L.push(' ' + R('✗ ' + r.message)); + } else if (r.type === 'note') { + L.push(' ' + Y(r.note)); + } else if (r.type === 'invoke') { + L.push(' ' + G(`✓ ${r.wielderName} invoked ${B(r.skillName)}`) + D(` → ${r.output}`)); + L.push(D(` paid ${m(r.price)} · credential ${r.credentialId} minted & consumed · protocol fee ${m(r.fee)} · net ${m(r.net)}`)); + L.push(D(' split:')); + for (const b of r.breakdown) { + const indent = ' ' + ' '.repeat(b.depth); + const tag = b.kind === 'creator' ? C('creator ') : Y('↑ancestor'); + L.push(indent + tag + ' ' + pad(b.partyName, 24) + G(m(b.amount)) + D(` via ${b.viaSkillName} (${(b.bps / 100).toFixed(0)}%)`)); + } + } + L.push(''); + + // Commands + L.push(B(' COMMANDS')); + L.push(D(' invoke bypass price ')); + L.push(D(' royalty p:bps,p:bps inherit fee ')); + L.push(D(' fork skill ')); + L.push(D(' party seed (reset) help quit')); + L.push(''); + process.stdout.write(L.join('\n') + '\n'); +} + +function handle(line) { + const [cmd, ...a] = line.trim().split(/\s+/).filter(Boolean); + try { + switch ((cmd || 'help').toLowerCase()) { + case 'invoke': E.invoke(state, a[0], a[1]); break; + case 'bypass': E.attemptBypass(state, a[0], a[1]); break; + case 'price': E.setPrice(state, a[0], a[1]); state.lastResult = { type: 'note', note: `price of ${a[0]} set to ${m(a[1])}` }; break; + case 'inherit': E.setInherit(state, a[0], a[1]); state.lastResult = { type: 'note', note: `${a[0]} now passes ${(a[1] / 100).toFixed(0)}% up to ancestors` }; break; + case 'fee': E.setFee(state, a[0]); state.lastResult = { type: 'note', note: `protocol fee set to ${(a[0] / 100).toFixed(2)}%` }; break; + case 'royalty': { + const holders = a[1].split(',').map((pair) => { const [partyId, bps] = pair.split(':'); return { partyId, bps: Number(bps) }; }); + E.setRoyalty(state, a[0], holders); + state.lastResult = { type: 'note', note: `royalty claim of ${a[0]} re-split: ${a[1]}` }; + break; + } + case 'fork': E.forkSkill(state, { parentId: a[0], creatorId: a[1], name: a[2], price: a[3], inheritBps: a[4] }); state.lastResult = { type: 'note', note: `forked ${a[2]} from ${a[0]}` }; break; + case 'skill': E.registerSkill(state, { name: a[0], creatorId: a[1], price: a[2] }); state.lastResult = { type: 'note', note: `registered skill ${a[0]}` }; break; + case 'party': E.addParty(state, { name: a[0], role: a[1], balance: a[2] }); state.lastResult = { type: 'note', note: `added party ${a[0]}` }; break; + case 'seed': case 'reset': E.seedDemo(state); break; + case 'help': case 'h': case '?': state.lastResult = { type: 'note', note: 'Try: invoke biofin biocorp (education flow-through), or invoke recon otherco (co-held claim).' }; break; + case 'quit': case 'q': case 'exit': rl.close(); return; + default: state.lastResult = { type: 'error', message: `unknown command '${cmd}' — type help` }; + } + } catch (err) { + state.lastResult = { type: 'error', message: err.message }; + } + render(); + rl.question('> ', handle); +} + +render(); +rl.question('> ', handle); +rl.on('close', () => { console.log('\nbye — prototype state was in-memory only.'); process.exit(0); }); diff --git a/prototype/spike-cma-latency.mjs b/prototype/spike-cma-latency.mjs new file mode 100644 index 0000000..b6bc549 --- /dev/null +++ b/prototype/spike-cma-latency.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// cma-latency-bench.mjs +// +// Benchmark for Anthropic Managed Agents (CMA) interactive latency. +// Measures the two latency profiles that matter for an interactive Wielder gate: +// (a) COLD: sessions.create -> stream open + first user.message send -> first +// agent token (first agent.message text delta). +// (b) WARM: reuse an already-created session, send a new user.message, measure +// send -> first agent token (no sessions.create on the hot path). +// Prints p50/p95 for each phase over N trials. +// +// ============================================================================ +// WE CANNOT RUN THIS HERE. There is no API key and no CMA beta access in this +// environment. Run it yourself with your own key + managed-agents beta access. +// (Script was syntax-verified with `node --check` only.) +// ============================================================================ +// +// Prerequisites: +// npm install @anthropic-ai/sdk (a version with client.beta.{agents, +// environments,sessions} CMA support) +// export ANTHROPIC_API_KEY=sk-ant-... +// Your org/key must be enabled for the managed-agents-2026-04-01 beta. +// +// Usage: +// node cma-latency-bench.mjs [--trials=10] [--model=claude-opus-4-8] +// [--reuse-agent=agent_xxx] [--reuse-env=env_xxx] [--cold-only] [--warm-only] +// +// Notes: +// * The SDK sets `managed-agents-2026-04-01` automatically on +// client.beta.{agents,environments,sessions}.* calls. +// * "First token" = first agent.message text delta. CMA streams thinking as +// agent.thinking events; with adaptive thinking the first VISIBLE answer +// token can lag stream-open while the model thinks. We measure BOTH +// time-to-first-event (any agent/span/status event) and +// time-to-first-answer-token. For an interactive gate, first-event is what +// you surface as 'working...' immediately. +// * Re-create the agent with a different model/effort to compare configs; +// effort lives on the model/agent config, low + no-thinking is the floor, +// high/max adaptive is the worst case (tens of seconds to first answer token). + +import Anthropic from \"@anthropic-ai/sdk\"; + +const args = Object.fromEntries( + process.argv.slice(2).map((a) => { + const [k, v] = a.replace(/^--/, \"\").split(\"=\"); + return [k, v === undefined ? true : v]; + }), +); +const TRIALS = parseInt(args.trials ?? \"10\", 10); +const MODEL = args.model ?? \"claude-opus-4-8\"; +const COLD_ONLY = !!args[\"cold-only\"]; +const WARM_ONLY = !!args[\"warm-only\"]; +const REUSE_AGENT = args[\"reuse-agent\"] ?? process.env.CMA_AGENT_ID ?? null; +const REUSE_ENV = args[\"reuse-env\"] ?? process.env.CMA_ENV_ID ?? null; + +if (!process.env.ANTHROPIC_API_KEY) { + console.error(\"ERROR: set ANTHROPIC_API_KEY in your environment.\"); + process.exit(1); +} +const client = new Anthropic(); +const PROMPT = \"Reply with exactly the word: ack\"; + +const ms = () => Number(process.hrtime.bigint() / 1000000n); +function pct(arr, p) { + if (arr.length === 0) return NaN; + const s = [...arr].sort((a, b) => a - b); + return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; +} +function report(label, samples) { + const c = samples.filter((x) => Number.isFinite(x)); + if (c.length === 0) return console.log(` ${label.padEnd(34)} no data`); + console.log( + ` ${label.padEnd(34)} p50=${String(pct(c,50)).padStart(7)}ms ` + + `p95=${String(pct(c,95)).padStart(7)}ms min=${Math.min(...c)}ms max=${Math.max(...c)}ms n=${c.length}`, + ); +} + +async function ensureAgentAndEnv() { + let agentId = REUSE_AGENT, envId = REUSE_ENV; + if (!envId) { + const env = await client.beta.environments.create({ + name: `bench-env-${Date.now()}`, + config: { type: \"cloud\", networking: { type: \"unrestricted\" } }, + }); + envId = env.id; console.log(`created environment ${envId}`); + } + if (!agentId) { + const agent = await client.beta.agents.create({ + name: `bench-agent-${Date.now()}`, + model: MODEL, + system: \"You are a latency benchmark target. Answer in one short word. Do not use tools.\", + tools: [{ type: \"agent_toolset_20260401\", default_config: { enabled: true } }], + }); + agentId = agent.id; console.log(`created agent ${agentId}`); + } + return { agentId, envId }; +} + +async function runTurn({ agentId, envId, existingSessionId }) { + const marks = {}; + const t0 = ms(); marks.t0 = t0; + let sessionId = existingSessionId; + if (!sessionId) { + const session = await client.beta.sessions.create({ + agent: { type: \"agent\", id: agentId }, environment_id: envId, + }); + sessionId = session.id; marks.tSessionCreated = ms() - t0; + } + const stream = await client.beta.sessions.events.stream(sessionId); + marks.tStreamOpen = ms() - t0; + const sendP = client.beta.sessions.events.send(sessionId, { + events: [{ type: \"user.message\", content: [{ type: \"text\", text: PROMPT }] }], + }); + let gotFirstEvent = false, gotAnswer = false; + for await (const event of stream) { + if (!gotFirstEvent && event.type !== \"user.message\" && event.type !== \"user.custom_tool_result\") { + marks.tFirstEvent = ms() - t0; gotFirstEvent = true; + } + if (!gotAnswer && event.type === \"agent.message\") { + for (const block of event.content ?? []) { + if (block.type === \"text\" && block.text?.length > 0) { + marks.tFirstAnswerToken = ms() - t0; gotAnswer = true; break; + } + } + } + if (event.type === \"session.status_terminated\") break; + if (event.type === \"session.status_idle\" && event.stop_reason?.type !== \"requires_action\") { + marks.tIdle = ms() - t0; break; + } + } + await sendP.catch(() => {}); + return { sessionId, marks }; +} + +async function main() { + console.log(`CMA latency benchmark — model=${MODEL} trials=${TRIALS}`); + const { agentId, envId } = await ensureAgentAndEnv(); + const cold = { sessionCreate: [], streamOpen: [], firstEvent: [], firstAnswer: [], total: [] }; + const sessionsForWarm = []; + if (!WARM_ONLY) { + console.log(`\\n=== COLD (sessions.create on hot path) ===`); + for (let i = 0; i < TRIALS; i++) { + try { + const { sessionId, marks } = await runTurn({ agentId, envId }); + cold.sessionCreate.push(marks.tSessionCreated); + cold.streamOpen.push(marks.tStreamOpen); + cold.firstEvent.push(marks.tFirstEvent); + cold.firstAnswer.push(marks.tFirstAnswerToken); + cold.total.push(marks.tIdle); + sessionsForWarm.push(sessionId); + process.stdout.write(` trial ${i+1}/${TRIALS}: create=${marks.tSessionCreated}ms firstEvent=${marks.tFirstEvent}ms firstAnswer=${marks.tFirstAnswerToken}ms\\n`); + } catch (e) { console.error(` trial ${i+1} failed:`, e?.message ?? e); } + } + } + const warm = { streamOpen: [], firstEvent: [], firstAnswer: [], total: [] }; + if (!COLD_ONLY) { + console.log(`\\n=== WARM (reuse existing session, no sessions.create) ===`); + let pool = sessionsForWarm; + if (pool.length === 0) { + for (let i = 0; i < Math.min(TRIALS, 3); i++) { + const s = await client.beta.sessions.create({ agent: { type: \"agent\", id: agentId }, environment_id: envId }); + pool.push(s.id); + } + } + for (let i = 0; i < TRIALS; i++) { + const sessionId = pool[i % pool.length]; + try { + const { marks } = await runTurn({ agentId, envId, existingSessionId: sessionId }); + warm.streamOpen.push(marks.tStreamOpen); + warm.firstEvent.push(marks.tFirstEvent); + warm.firstAnswer.push(marks.tFirstAnswerToken); + warm.total.push(marks.tIdle); + process.stdout.write(` trial ${i+1}/${TRIALS}: firstEvent=${marks.tFirstEvent}ms firstAnswer=${marks.tFirstAnswerToken}ms\\n`); + } catch (e) { console.error(` trial ${i+1} failed:`, e?.message ?? e); } + } + } + console.log(`\\n================ SUMMARY (p50 / p95) ================`); + if (!WARM_ONLY) { + console.log(\"COLD path:\"); + report(\"sessions.create\", cold.sessionCreate); + report(\"-> stream open (cumulative)\", cold.streamOpen); + report(\"-> first event (cumulative)\", cold.firstEvent); + report(\"-> first ANSWER token (cumulative)\", cold.firstAnswer); + report(\"-> idle/end_turn (cumulative)\", cold.total); + } + if (!COLD_ONLY) { + console.log(\"WARM path (reused session):\"); + report(\"send -> stream open\", warm.streamOpen); + report(\"send -> first event\", warm.firstEvent); + report(\"send -> first ANSWER token\", warm.firstAnswer); + report(\"send -> idle/end_turn\", warm.total); + } + console.log(`\\nInterpretation: for an interactive Wielder gate the perceptible numbers are 'first event' (render 'working…' immediately) and 'first answer token'. effort=low + minimal agent => single-digit seconds; high/max + adaptive thinking => first-answer-token can be tens of seconds because thinking precedes the answer. Archive/delete the bench agent+env afterward (archiving an agent is PERMANENT); delete sessions with client.beta.sessions.delete(id).`); +} +main().catch((e) => { console.error(e); process.exit(1); }); \ No newline at end of file diff --git a/prototype/spike-fork-economics.mjs b/prototype/spike-fork-economics.mjs new file mode 100644 index 0000000..74a7203 --- /dev/null +++ b/prototype/spike-fork-economics.mjs @@ -0,0 +1,49 @@ +// spike-fork-economics.mjs +// +// PRE-BUILD SPIKE (run: node prototype/spike-fork-economics.mjs) +// Question: what inherit-bps keeps forking worthwhile, and what happens to the +// ORIGINAL creator (the school) as derivative chains get deeper? +// Drives the design choice between Story's LRP (per-hop relative) and LAP +// (whole-ancestry absolute) royalty policies. See prototype/README.md NOTES. + +import * as E from './settlement-engine.mjs'; + +const FEE = 250, base = 5, uplift = 10; + +function run(depth, inh) { + const s = E.createState(); + E.addParty(s, { id: 'w', name: 'W', role: 'Wielder', balance: 1e9 }); + for (let k = 0; k <= depth; k++) E.addParty(s, { id: 'c' + k, name: 'c' + k, role: 'Creator' }); + E.registerSkill(s, { id: 's0', name: 's0', creatorId: 'c0', price: base }); + let price = base; + for (let k = 1; k <= depth; k++) { price += uplift; E.forkSkill(s, { id: 's' + k, parentId: 's' + (k - 1), creatorId: 'c' + k, name: 's' + k, price, inheritBps: inh }); } + const r = E.invoke(s, 's' + depth, 'w'); + const got = {}; for (const b of r.breakdown) got[b.partyId] = (got[b.partyId] || 0) + b.amount; + return { price: r.price, net: r.net, got }; +} + +const pct = (x, n) => (x / n * 100).toFixed(1) + '%'; +const freshAlt = uplift * (1 - FEE / 10000); + +console.log('MODEL: root price $5; each fork adds +$10 uplift; flat inherit per hop; fee 2.5%.'); +console.log('Fresh alternative (author your $10 uplift solo) = $' + freshAlt.toFixed(2) + ' per call.\n'); + +console.log('=== A. DEPTH DILUTION of the ORIGINAL creator (root c0) ==='); +for (const inh of [2000, 3000]) { + console.log('\n inherit = ' + (inh / 100) + '% per hop:'); + for (const d of [1, 2, 3, 4]) { + const { price, net, got } = run(d, inh); + const leaf = got['c' + d] || 0, root = got['c0'] || 0; + console.log(' depth ' + d + ' ($' + price + '): leaf ' + pct(leaf, net) + ' | ORIGINAL ' + pct(root, net)); + } +} + +console.log('\n=== B. FORK-KILLING THRESHOLD for the leaf (hypothesis i* = p_parent/p_fork) ==='); +for (const d of [1, 2]) { + const parentPrice = base + (d - 1) * uplift, leafPrice = base + d * uplift; + console.log('\n depth ' + d + ' (parent $' + parentPrice + ' -> leaf $' + leafPrice + '): i* = ' + ((parentPrice / leafPrice) * 100).toFixed(1) + '%'); + for (const inh of [0, 3000, 4000, 5000, 6000, 7000]) { + const { got } = run(d, inh); const leaf = got['c' + d] || 0; + console.log(' ' + (inh / 100) + '% -> leaf keeps $' + leaf.toFixed(2) + (leaf >= freshAlt ? ' (beats solo)' : ' NO')); + } +} From 1c85de71fd713ca0bda3bddc5ed63403812fea2d Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 11 Jul 2026 12:50:26 -0400 Subject: [PATCH 002/165] Add reframe + Pi-Wielder spike design doc Validated in brainstorming after the adversarial premise review: closed-mode compensation/attribution layer becomes the terminal product; the Wielder is a wallet, not a harness; inference payments are the demand-side wedge. Includes the full document change spec and the testnet spike design. Co-Authored-By: Claude Fable 5 --- ...026-07-11-reframe-and-pi-wielder-design.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/plans/2026-07-11-reframe-and-pi-wielder-design.md diff --git a/docs/plans/2026-07-11-reframe-and-pi-wielder-design.md b/docs/plans/2026-07-11-reframe-and-pi-wielder-design.md new file mode 100644 index 0000000..12eadd1 --- /dev/null +++ b/docs/plans/2026-07-11-reframe-and-pi-wielder-design.md @@ -0,0 +1,202 @@ +# Reframe & Pi-Wielder Spike — Design + +*2026-07-11. Validated in a brainstorming session following an adversarial premise +review (11 agents: 6 readers over the full corpus, 4 premise critics, 1 memory +search). All four critics returned "shaky, not broken" and converged on the same +prescription. This document is the change spec for the doc reframe and the design +for the first Wielder-side spike.* + +## 1. The verdict that drives this design + +The premise review found the corpus honest but inverted: the PRD's **weakest +claims are the open-marketplace royalty story it leads with**, and its +**strongest asset is the part it treats as a stepping stone** — the off-chain +metered ledger plus co-held, non-transferable claims. + +Objections that survived steelmanning: + +1. **Success is self-defeating in the open market** — a breakout Skill's paid + I/O pairs are a ~30x-cheaper distillation set (ADR-0004's own concession); + the addressable middle (too dynamic to distill, not valuable enough to + SaaS-ify) is unsized and may be empty. +2. **Claude Code skills are context-bound; hosting strips most of their value.** +3. **"When Sam quits" is undesigned** — no vesting/clawback/termination anywhere. +4. **Education mode has a free bypass** — provenance cannot distinguish "forked + the school's Skill" from "re-authored using what the class taught," which is + nearly free and pays the school nothing. +5. **The likeliest killer is dismissed, not analyzed** — a platform-native skill + marketplace (Anthropic/OpenAI/GitHub). No kill-criterion covers it; the GPT + Store precedent appears nowhere in the corpus. + +What survives: the closed-mode kernel **reframed as a compensation/attribution +instrument** (the employer already possesses the Skill, so clone-resistance is +irrelevant there); neutral cross-platform provenance; the gate/settlement +engineering itself. + +## 2. The reframe (decision) + +**The product is a compensation, attribution, and metering layer for authored +AI Skills — "Carta for AI work artifacts" — not a skill marketplace.** + +- **Phase 1 is the terminal state by design.** The off-chain signed ledger + + co-held non-transferable claims + Story provenance must be independently + viable if Phases 2–3 never ship. On-chain settlement and tradeability are + explicitly underwritten optionality. +- Real-world precedent replaces the marketplace pitch: Germany's ArbEG statutory + inventor remuneration, corporate patent-award programs, university + tech-transfer revenue splits. Institutions demonstrably share invention + upside with individuals; the missing piece is the metering rail. +- Platforms can ship a skill marketplace in a quarter; they will never ship + 409A-structured co-held compensation instruments. That asymmetry is the moat. + +**The Wielder is a wallet, not a harness.** The Wielder-side protocol footprint +is exactly: *answer HTTP 402 with a signed USDC payment and retry.* No Story +SDK, no token custody, no chain reads client-side. The invocation-right is +exercised by paying, not held. + +**Demand-side wedge: inference payments install the rail; skills ride it.** +BYO-wallet per-call payment for model APIs is live and growing (Router402, +tx402.ai, BlockRun ClawRouter; x402 at ~75M tx in the last 30 days as of +2026-07). The inference-payment leg is commoditizing — the differentiator is +the **unified meter**: one wallet whose ledger attributes inference calls AND +skill invocations, with royalty splits on the skill leg. + +## 3. Document change spec + +### 3.1 CONTEXT.md + +- Rewrite the header paragraph: compensation/attribution protocol; the open + Marketplace is one (future) mode, not the identity. +- **Wielder**: extend the definition — "any client that can pay: a wallet, not + a specific harness. Claude Code, Pi, a cron job, and curl are all Wielders." +- **Add the missing term "Collar"** (used throughout the PRD, absent here): + the sole platform-key holder, x402 resource server, and off-chain meter; the + single trusted component. +- Update "Flagged ambiguities": mark the marketplace-vs-closed-mode identity + question resolved (closed modes are the product); add "fraction of the skill + supply that is host-compatible" as a new flagged unknown. +- Keep all existing role/relationship definitions otherwise intact. + +### 3.2 docs/PRD.md + +Rewrite the spine; preserve the honest tone, citations, and the +"What we have NOT validated" discipline (extend it, never delete it). + +1. **Executive Summary**: lead with the compensation/attribution layer and + "Phase 1 is the terminal state by design"; marketplace becomes underwritten + optionality; add the demand-side wedge in one paragraph. +2. **Problem & Market**: add a *demand-side wedge* subsection (BYO-wallet + inference payments as the rail; the Pi spike named as its validation + experiment); add the **GPT Store precedent** (platforms do ship native + skill-adjacent marketplaces; builder monetization demand was weak even with + free distribution); add a **skill-depreciation subsection** (skill half-life + vs. model release cadence; segment model-absorbable vs. live-access-bound). +3. **Product & UX**: education mode demoted — deferred pending a re-run + fork-economics spike whose alternative branch is "re-author with class + knowledge ≈ free"; note the school-claim restructure options (living + school-maintained content, or direct school→employer licensing). +4. **Technical Architecture**: rename "trust-minimized" → + **"Wielder-side trust-minimized"** everywhere; restate "gate-leak rate = 0" + as an **ops SLO backed by a key-custody/rotation design**, not an + architectural property; add the **beneficiary-verifiable meter** as a + Phase-1 design requirement (Merkle-committed invocation log; root published + with every Leg-2 batch so ancestors audit rather than trust); add a + **committed TEE trigger** (any Skill exceeding a revenue threshold moves to + confidential execution) replacing the thrice-tabled deferral. +5. **Economic Design**: note the unified meter across asset classes (inference + pass-through and skill royalties are entries in the same ledger). +6. **Regulatory**: upgrade kill-criterion 5 from "counsel blesses" to + "counsel **drafts the actual instrument**", resolving on-demand-withdrawal + vs. 409A fixed-payment-events; add **vesting/clawback/termination ("when Sam + quits")** as first-class design inputs for the co-held claim. +7. **Competitive Landscape**: reclassify managed-agent platforms from + "infrastructure we consume, not competitors" to **"supplier AND likeliest + disintermediator"**, with GPT Store as the base rate for platform timelines; + add rows for **BlockRun ClawRouter** and **Router402/tx402.ai** (x402 + inference-payment incumbents; differentiation = the attribution/royalty + meter, not payment). +8. **GTM**: intra-org pitch leads with compensation/retention, not royalty + upside; education is a later motion. +9. **Kill-criteria**: add **#7 — platform-native skill marketplace announced** + (Anthropic/OpenAI/GitHub), with a monitoring trigger and the stated + counter-positioning (neutrality, cross-platform provenance, + securities-barred mechanism, comp products platforms won't build). +10. **Risks**: add platform-native-marketplace and skill-depreciation risks; + note inference-payment commoditization. +11. **Roadmap**: "Phase 1 terminal by design" stated; Phase 3 additionally + gated on **compliance unit economics** (transfer-agent + ATS + KYC cost per + claim vs. claim cash flow → a minimum-claim-size floor; if the floor + excludes realistic claims, re-scope to pooled instruments or cut); add the + **Pi-Wielder spike** to the spike list (thin-payer proof + measured x402 + payment-overhead latency). +12. **What we have NOT validated**: add — fraction of skill supply that is + host-compatible; skill half-life; keep every existing item. + +### 3.3 ADRs + +- **Status headers on all six** existing ADRs (Accepted; note amendment dates). +- **ADR-0001 amendment**: absorb ADR-0004's concession explicitly — hosting + preserves *artifact* scarcity, not *economic* scarcity; the invocation-right + protects the file, the moats protect the economics. +- **New ADR-0007 — "The closed-mode compensation layer is the terminal + product."** Decision, rationale (the four critiques), precedents (ArbEG, + patent awards, tech transfer), consequences (marketplace = optionality; + Phase-3 investment deferred until closed-mode traction). +- **New ADR-0008 — "The Wielder is a wallet, not a harness."** Thin-payer + client decision; rejected alternatives (token-holding client, full protocol + client); inference-as-wedge demand strategy; evidence (ClawRouter, Router402, + x402 volume); consequence: validated by the Pi spike. + +Cross-doc rule: PRD/ADR citations of "CONTEXT.md lines NN–NN" should be +converted to section references wherever a touched passage cites them. + +## 4. Pi-Wielder spike (`spikes/pi-wielder/`) + +**Goal**: prove "one wallet, two asset classes" end-to-end — Pi pays per-call +for model inference AND invokes one hosted Skill behind a mock collar, with a +unified attributed session ledger. Testnet-only; zero real money. + +Components: + +1. **Wallet** — viem `privateKeyToAccount`; Base Sepolia USDC (CDP faucet). +2. **Paying proxy** (~100 lines, Hono): OpenAI-compatible localhost endpoint; + upstream fetch wrapped with `@x402/fetch` + `@x402/evm`. This proxy is the + *entire* Wielder-side protocol footprint (proves ADR-0008 by construction). +3. **Mock collar** (Hono + x402 middleware, free facilitator + `https://x402.org/facilitator`): 402-gates one hosted Skill (this repo's own + `optimizing-claude-code-prompts`); settled txHash = single-use credential; + runs the Skill via the Anthropic API; returns **output only**; credits the + split into a signed ledger reusing `prototype/settlement-engine.mjs` + `distribute()`. +4. **Mock inference gateway**: 402-gates OpenAI-compatible chat completions, + proxying to real Anthropic/OpenAI APIs with local keys (no first-party API + accepts x402; live gateways are mainnet-only resellers — we simulate one on + testnet). +5. **Pi extension**: `registerProvider("x402", { baseUrl: proxy })`, an + `invoke_skill` tool, and a session-ledger command + (`claude/plan $… · gpt/implement $… · skill $… → creator split`). + +**Modes**: `MOCK_FACILITATOR=1` for an offline end-to-end test (CI-able); +testnet mode against the real facilitator once the wallet is faucet-funded +(the only manual step). + +**Demo scenario**: Claude plans, GPT implements, one skill invocation — one +wallet, three payees, unified ledger. + +**Measurements to feed back into the PRD**: x402 payment overhead per call +(sign → verify → settle p50/p95), end-to-end skill-invocation latency, +ledger-split correctness against the prototype engine. + +## 5. Execution order + +1. This design doc, committed. ✅ +2. Doc-reframe workflow: parallel writers (CONTEXT.md / PRD / ADRs) → + adversarial checkers (stale-language sweep, cross-doc consistency, + spec-faithfulness) → fixer. Commit. +3. Spike build (background agent), offline e2e green in mock mode. Commit. +4. Faucet-fund the test wallet (user) → run testnet demo → feed measurements + into the PRD demand-side section. + +Out of scope here (tracked from the review, still pending): funding the Aeneid +wallet and executing the Phase-0 write path; repairing and running +`spike-cma-latency.mjs`; the clone-economics spike; design-partner interviews. From d1e68b91dcdba5a53358ad722986d89d0bfc4316 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 11 Jul 2026 13:08:12 -0400 Subject: [PATCH 003/165] Add pi-wielder spike: one wallet, two asset classes, e2e green Proves ADR-0008 by construction: the Wielder-side protocol footprint is a single ~150-line paying proxy (402 -> sign EIP-3009 -> retry). Mock collar hosts the repo's own skill behind x402, credentials are single-use (replay -> 409), output-only responses, and splits are computed by the actual prototype settlement engine. Offline e2e: 20 checks green with zero network, keys, or funds. Manual steps remain: faucet-fund the Base Sepolia wallet, testnet run against x402.org/facilitator, pi extension live demo (RUNBOOK.md). Co-Authored-By: Claude Fable 5 --- spikes/pi-wielder/.env.example | 35 +++ spikes/pi-wielder/.gitignore | 4 + spikes/pi-wielder/README.md | 132 ++++++++++ spikes/pi-wielder/RUNBOOK.md | 105 ++++++++ spikes/pi-wielder/e2e.mjs | 157 ++++++++++++ spikes/pi-wielder/package-lock.json | 273 +++++++++++++++++++++ spikes/pi-wielder/package.json | 19 ++ spikes/pi-wielder/pi-extension/x402.ts | 98 ++++++++ spikes/pi-wielder/src/collar.mjs | 142 +++++++++++ spikes/pi-wielder/src/facilitator-mock.mjs | 92 +++++++ spikes/pi-wielder/src/gateway.mjs | 139 +++++++++++ spikes/pi-wielder/src/ledger.mjs | 46 ++++ spikes/pi-wielder/src/proxy.mjs | 151 ++++++++++++ spikes/pi-wielder/src/wallet.mjs | 33 +++ spikes/pi-wielder/src/x402-seller.mjs | 152 ++++++++++++ 15 files changed, 1578 insertions(+) create mode 100644 spikes/pi-wielder/.env.example create mode 100644 spikes/pi-wielder/.gitignore create mode 100644 spikes/pi-wielder/README.md create mode 100644 spikes/pi-wielder/RUNBOOK.md create mode 100644 spikes/pi-wielder/e2e.mjs create mode 100644 spikes/pi-wielder/package-lock.json create mode 100644 spikes/pi-wielder/package.json create mode 100644 spikes/pi-wielder/pi-extension/x402.ts create mode 100644 spikes/pi-wielder/src/collar.mjs create mode 100644 spikes/pi-wielder/src/facilitator-mock.mjs create mode 100644 spikes/pi-wielder/src/gateway.mjs create mode 100644 spikes/pi-wielder/src/ledger.mjs create mode 100644 spikes/pi-wielder/src/proxy.mjs create mode 100644 spikes/pi-wielder/src/wallet.mjs create mode 100644 spikes/pi-wielder/src/x402-seller.mjs diff --git a/spikes/pi-wielder/.env.example b/spikes/pi-wielder/.env.example new file mode 100644 index 0000000..7231ede --- /dev/null +++ b/spikes/pi-wielder/.env.example @@ -0,0 +1,35 @@ +# Pi-Wielder spike — environment template. Copy to .env and fill in. +# NEVER commit a real .env (the repo root .gitignore already excludes it). +# Mock mode (`npm run e2e`) needs NONE of these. + +# --- the Wielder wallet (testnet ONLY — never a mainnet key) ----------------- +# 0x-prefixed private key of a throwaway Base Sepolia account. +# Fund it with testnet USDC + ETH via the Coinbase CDP faucet (see RUNBOOK.md). +PRIVATE_KEY= + +# Where sellers receive USDC (collar + gateway payTo). Any address you control. +PAY_TO_ADDRESS= + +# --- upstream model keys (sellers' side; only needed without MOCK_LLM=1) ----- +ANTHROPIC_API_KEY= +OPENAI_API_KEY= + +# --- modes ------------------------------------------------------------------- +# 1 = in-process fake facilitator (offline). Unset/0 = real facilitator below. +MOCK_FACILITATOR=1 +# 1 = canned completions/skill output, no model keys needed. +MOCK_LLM=1 +FACILITATOR_URL=https://x402.org/facilitator + +# --- ports (defaults shown; 8402 is a nod to ClawRouter's paying proxy) ------ +PROXY_PORT=8402 +GATEWAY_PORT=8403 +COLLAR_PORT=8404 +# Proxy -> seller wiring (defaults match the ports above) +GATEWAY_URL=http://127.0.0.1:8403 +COLLAR_URL=http://127.0.0.1:8404 +# Optional JSONL session-ledger path for the proxy +LEDGER_FILE= + +# --- pricing (USDC per call) -------------------------------------------------- +SKILL_PRICE_USDC=0.25 diff --git a/spikes/pi-wielder/.gitignore b/spikes/pi-wielder/.gitignore new file mode 100644 index 0000000..9fc338f --- /dev/null +++ b/spikes/pi-wielder/.gitignore @@ -0,0 +1,4 @@ +# Session ledgers are per-run artifacts, not source. +session-ledger.jsonl +*.jsonl +# (.env and node_modules are already ignored by the repo root .gitignore) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md new file mode 100644 index 0000000..626911c --- /dev/null +++ b/spikes/pi-wielder/README.md @@ -0,0 +1,132 @@ +# Pi-Wielder spike — one wallet, two asset classes + +Design: [section 4 of the 2026-07-11 reframe & Pi-Wielder design](../../docs/plans/2026-07-11-reframe-and-pi-wielder-design.md). + +## What this proves + +A coding harness (Pi) pays **per-call for model inference** and **per-invocation +for a hosted Skill** from the **same wallet**, and every payment lands in **one +attributed session ledger** — with a royalty split on the skill leg computed by +the same settlement engine prototyped in `prototype/settlement-engine.mjs`: + +``` +claude/plan $0.041 · gpt/implement $0.087 · skill/optimizing-claude-code-prompts $0.25 → creator $0.24375 / treasury $0.00625 +``` + +Three claims, each demonstrated end-to-end by `npm run e2e` (offline, zero +keys, zero funds): + +1. **The Wielder is a wallet, not a harness (ADR-0008).** The *entire* + Wielder-side protocol footprint is one file, `src/proxy.mjs`: answer + HTTP 402 with a signed USDC `transferWithAuthorization` (EIP-3009) and + retry. No Story SDK, no token custody, no chain reads. Pi itself contains + zero payment code — its extension just points at a localhost baseUrl + (precedent: BlockRun's ClawRouter does exactly this for OpenClaw on 8402). +2. **The unified meter is the differentiator.** Inference payments are + commoditizing (Router402, tx402.ai, ClawRouter). What they don't have is + the second asset class in the same ledger: skill invocations with royalty + attribution. Here both legs are entries in one JSONL session ledger. +3. **The collar keeps the platform key.** The Wielder pays for an + *invocation* and receives *output only*; the skill content + (`.claude/skills/optimizing-claude-code-prompts/SKILL.md`) never leaves the + collar process. The settled txHash is a single-use execution credential — + replays are rejected ("no credential, no run", ADR-0003). + +## Architecture + +``` + Pi (the harness — knows NOTHING about payments) + │ .pi/extensions/x402.ts: + │ provider "x402" → baseUrl localhost:8402/v1 + │ tool invoke_skill → localhost:8402/invoke/… + │ command /ledger → localhost:8402/ledger + ▼ + ┌──────────────────────────────────────────────────────────┐ + │ src/proxy.mjs — THE WIELDER (= wallet + paying fetch) │ + │ · viem account from PRIVATE_KEY (src/wallet.mjs) │ + │ · on 402: sign EIP-3009 USDC auth → X-PAYMENT → retry │ + │ · appends every paid call to the SESSION LEDGER (JSONL) │ + │ {ts, leg, label, amountUSDC, txHash, splits} │ + └────────────┬─────────────────────────────┬───────────────┘ + /v1/* (leg: model) /invoke/* (leg: skill) + ▼ ▼ + ┌─────────────────────────┐ ┌──────────────────────────────────┐ + │ src/gateway.mjs │ │ src/collar.mjs — MOCK COLLAR │ + │ x402 inference reseller │ │ 402-gate → verify → settle → │ + │ (simulated, testnet) │ │ txHash = single-use credential → │ + │ 402-gate → claude-* to │ │ run SKILL.md via Anthropic API │ + │ Anthropic, gpt-* to │ │ (output ONLY) → meter split via │ + │ OpenAI (or MOCK_LLM) │ │ prototype/settlement-engine.mjs │ + └────────────┬────────────┘ └────────────┬─────────────────────┘ + │ verify/settle │ + └───────────┬─────────────────┘ + ▼ + x402 facilitator (Base Sepolia) + · real: https://x402.org/facilitator + · MOCK_FACILITATOR=1: src/facilitator-mock.mjs — in-process; + really verifies the EIP-712 signature, fakes only settlement + (deterministic txHash preserves the replay property) +``` + +One wallet (top), two asset classes (the two sellers), three payees in the +demo scenario (inference reseller, skill creator, protocol treasury), one +ledger (in the proxy — where the wallet is, because the meter belongs to the +payer's session). + +## How it maps to ADR-0008 + +ADR-0008 rejects the token-holding client and the full-protocol client. This +spike is the constructive proof: grep `src/proxy.mjs` — the only protocol +concepts in it are *HTTP 402*, *EIP-3009 signature*, and *retry*. Everything +skill-economic (credentials, royalty tables, `distribute()`, provenance) +lives seller-side behind the collar. If a cron job or `curl` replaced Pi +tomorrow, nothing in the protocol would notice: **any client that can pay is +a Wielder**. + +## Run it + +```bash +npm install +npm run e2e # offline proof: MOCK_FACILITATOR=1 MOCK_LLM=1, no keys, no funds +``` + +The e2e boots facilitator-mock + collar + gateway + proxy on ephemeral ports, +drives all three legs through the proxy only, and asserts: 402-first on every +leg, no skill-content leak, replay rejection, exact split match against the +settlement engine, and prints the rendered ledger plus (mock) payment-overhead +timings. + +For the real-facilitator testnet run and the live Pi demo, see +[RUNBOOK.md](./RUNBOOK.md). + +## Files + +| File | Role | +|---|---| +| `src/proxy.mjs` | The Wielder: paying proxy; the whole client-side protocol footprint | +| `src/wallet.mjs` | viem account from `PRIVATE_KEY`; throwaway key for mock mode | +| `src/gateway.mjs` | Simulated x402 inference reseller (OpenAI-compatible, 402-gated) | +| `src/collar.mjs` | Mock collar: hosts + gates the skill, meters splits via the settlement engine | +| `src/x402-seller.mjs` | Seller half of x402 v1 (`exact` scheme), hand-written Hono middleware | +| `src/facilitator-mock.mjs` | Offline facilitator: real signature verification, fake settlement | +| `src/ledger.mjs` | JSONL session ledger + `renderLedger()` | +| `pi-extension/x402.ts` | Pi extension: provider `x402`, tool `invoke_skill`, command `/ledger` | +| `e2e.mjs` | The offline proof (`npm run e2e`) | + +## Deviations from the design's research notes + +- **`@x402/*` packages not used.** The published `@x402/fetch` / `@x402/evm` / + `@x402/hono` (v2.18.0) implement protocol **v2** — class-based scheme + registries, CAIP-2 network ids, facilitator sync-on-start — while the free + testnet facilitator speaks **v1**, and the sync-on-start network coupling + breaks the zero-network mock mode. The design blesses the manual path + ("shows the protocol plainly"); the v1 `exact` scheme is implemented by hand + in `src/proxy.mjs` (buyer, ~40 protocol lines) and `src/x402-seller.mjs` + (seller). Only `viem` is used for cryptography. +- **`distribute()` is not exported** by `prototype/settlement-engine.mjs` (it + is an internal). The collar and the e2e drive it through the engine's public + economic event `invoke()` and use the returned `breakdown` — same math, + public API, and the e2e still asserts an *exact* match. +- **Engine amounts are atomic USDC** (6-decimal integers): the prototype + rounds to 2 decimals, which is lossy for $0.25 micro-royalties; integers + keep the split exact (250000 → creator 243750 / treasury 6250). diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md new file mode 100644 index 0000000..44cd6d1 --- /dev/null +++ b/spikes/pi-wielder/RUNBOOK.md @@ -0,0 +1,105 @@ +# RUNBOOK — testnet run & live Pi demo + +Everything in [README.md](./README.md) up to `npm run e2e` is fully offline. +This runbook covers the two things that are **manual by design**: funding a +testnet wallet, and installing Pi with the extension for the live demo. + +## 1. Create and fund the Wielder wallet (Base Sepolia — manual) + +1. Generate a throwaway key (never reuse a real one): + + ```bash + node -e "import('viem/accounts').then(m => { const pk = m.generatePrivateKey(); console.log('PRIVATE_KEY=' + pk); console.log('address:', m.privateKeyToAccount(pk).address); })" + ``` + +2. `cp .env.example .env`, paste the `PRIVATE_KEY`, and set `PAY_TO_ADDRESS` + to a second address you control (that's where the sellers receive USDC — + generate one the same way if needed). **Never commit `.env`.** + +3. Fund the Wielder address from the **Coinbase CDP faucet** + (, free, requires a CDP + account): + - network **Base Sepolia**, asset **USDC** → request (typically 10 USDC/day); + - network **Base Sepolia**, asset **ETH** → request a small amount. + (EIP-3009 settlement is facilitator-sponsored, so the buyer mostly needs + USDC; the ETH covers you if you later broadcast anything yourself.) + +4. Sanity-check the balance on (search the + address; USDC contract `0x036CbD53842c5426634e7929541eC2318f3dCF7e`). + +## 2. Testnet run (real facilitator, real model APIs) + +In `.env`: unset the mocks and add model keys — + +```bash +MOCK_FACILITATOR=0 +MOCK_LLM=0 +FACILITATOR_URL=https://x402.org/facilitator # free, no-auth, Base Sepolia +ANTHROPIC_API_KEY=sk-ant-… +OPENAI_API_KEY=sk-… +``` + +Three terminals (or background them), sellers first: + +```bash +set -a; source .env; set +a # in each terminal +npm run collar # :8404 — hosted skill behind the collar +npm run gateway # :8403 — 402-gated inference reseller +npm run proxy # :8402 — THE WIELDER (paying proxy) +``` + +Exercise all three legs through the proxy only: + +```bash +curl -s localhost:8402/v1/chat/completions -H 'content-type: application/json' \ + -H 'x-session-label: plan' \ + -d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"Plan a refactor of the settlement engine tests."}]}' + +curl -s localhost:8402/v1/chat/completions -H 'content-type: application/json' \ + -H 'x-session-label: implement' \ + -d '{"model":"gpt-5.2","messages":[{"role":"user","content":"Implement the plan."}]}' + # use any chat model your OpenAI key can access + +curl -s localhost:8402/invoke/optimizing-claude-code-prompts \ + -H 'content-type: application/json' -d '{"input":"make the checkout page faster"}' + +curl -s localhost:8402/ledger # the unified session ledger +``` + +Each response carries `x-wielder-overhead` (402 roundtrip + sign + +facilitator verify/settle, in ms) — these are the **real** payment-overhead +numbers to feed back into the PRD's demand-side section, and every settled +txHash is checkable on . + +Replay check on testnet: re-sending a captured `X-PAYMENT` header fails at +the facilitator (the EIP-3009 nonce is already used on-chain) or, if it were +somehow re-settled, at the collar's consumed-set (HTTP 409). + +## 3. Live Pi demo (manual — pi is not installed by this spike) + +1. Install Pi (v0.80.x): `npm install -g @earendil-works/pi-coding-agent` +2. Install the extension into the project you'll demo from: + + ```bash + mkdir -p .pi/extensions + cp /spikes/pi-wielder/pi-extension/x402.ts .pi/extensions/ + ``` + + (or `~/.pi/agent/extensions/` for a global install). If the proxy is not + on the default port, export `PI_WIELDER_PROXY=http://localhost:`. +3. With collar + gateway + proxy running (section 2), start `pi` in that + project and `/reload` to pick up the extension. Verify against pi's docs + that the `registerProvider` config fields (`api`, model entries) match + your installed version — the extension is written against the documented + v0.80.x API but is exercised manually, not in CI. +4. Demo script ("Claude plans, GPT implements, one skill invocation"): + - select the `x402` provider's claude model → ask for a plan; + - switch to the gpt model → ask it to implement; + - have Pi call the `invoke_skill` tool (e.g. "optimize this prompt: …"); + - run `/ledger` → one wallet, three payees, unified attributed ledger. + +## What stays manual, on purpose + +- CDP faucet funding (no faucet automation — ToS and flakiness). +- Pi installation and the extension smoke-test (pi may not exist in CI). +- Feeding the measured testnet overhead numbers back into the PRD. diff --git a/spikes/pi-wielder/e2e.mjs b/spikes/pi-wielder/e2e.mjs new file mode 100644 index 0000000..17e6b69 --- /dev/null +++ b/spikes/pi-wielder/e2e.mjs @@ -0,0 +1,157 @@ +// e2e.mjs — the proof: ONE WALLET, TWO ASSET CLASSES, ONE ATTRIBUTED LEDGER. +// +// Fully offline under MOCK_FACILITATOR=1 + MOCK_LLM=1 (the default when run +// as `npm run e2e`): no network, no API keys, no funds. It boots the collar, +// the inference gateway, and the Wielder proxy on ephemeral ports, then — +// through THE PROXY ONLY — makes a claude "plan" completion, a gpt +// "implement" completion, and one hosted-skill invocation, asserting the +// whole x402 + settlement story along the way. + +process.env.MOCK_FACILITATOR ??= '1'; +process.env.MOCK_LLM ??= '1'; + +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { startMockFacilitator } from './src/facilitator-mock.mjs'; +import { startCollar, SKILL_ID } from './src/collar.mjs'; +import { startGateway, MODEL_PRICES_USDC } from './src/gateway.mjs'; +import { startProxy } from './src/proxy.mjs'; +import { throwawayAccount } from './src/wallet.mjs'; +import { usdcToAtomic, atomicToUsdc } from './src/x402-seller.mjs'; +import { + createState, addParty, registerSkill, setRoyalty, invoke, +} from '../../prototype/settlement-engine.mjs'; + +// --- tiny assertion harness -------------------------------------------------- +let checks = 0; +function ok(cond, label) { + checks += 1; + if (!cond) { console.error(` ✗ ${label}`); process.exitCode = 1; throw new Error(`FAILED: ${label}`); } + console.log(` ✓ ${label}`); +} +const eq = (a, b, label) => ok(JSON.stringify(a) === JSON.stringify(b), `${label} (${JSON.stringify(a)} === ${JSON.stringify(b)})`); + +const here = (p) => fileURLToPath(new URL(p, import.meta.url)); +const LEDGER_FILE = here('./session-ledger.jsonl'); +fs.rmSync(LEDGER_FILE, { force: true }); + +// --- boot: facilitator -> sellers -> the one wallet's paying proxy ------------ +const account = throwawayAccount(); // zero funds needed: mock facilitator verifies signatures, fakes settlement +const facilitator = await startMockFacilitator(); +const collar = await startCollar({ facilitatorUrl: facilitator.url }); +const gateway = await startGateway({ facilitatorUrl: facilitator.url }); +const proxy = await startProxy({ account, gatewayUrl: gateway.url, collarUrl: collar.url, ledgerFile: LEDGER_FILE }); + +console.log(`\nPi-Wielder e2e (MOCK_FACILITATOR=${process.env.MOCK_FACILITATOR}, MOCK_LLM=${process.env.MOCK_LLM})`); +console.log(`wallet ${account.address}`); +console.log(`facilitator ${facilitator.url} · collar ${collar.url} · gateway ${gateway.url} · proxy ${proxy.url}\n`); + +const overheads = []; +async function viaProxy(path, body, label) { + const res = await fetch(`${proxy.url}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(label ? { 'x-session-label': label } : {}) }, + body: JSON.stringify(body), + }); + const json = await res.json(); + const timings = res.headers.get('x-wielder-overhead'); + if (timings) overheads.push({ call: label ?? path, ...JSON.parse(timings) }); + return { res, json }; +} + +try { + // --- 0. the gates are real: unpaid direct requests are refused -------------- + console.log('unpaid requests are 402-challenged:'); + for (const [name, url] of [['gateway', `${gateway.url}/v1/chat/completions`], ['collar', `${collar.url}/invoke/${SKILL_ID}`]]) { + const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model: 'gpt-x', input: 'x' }) }); + const b = await r.json(); + ok(r.status === 402 && b.x402Version === 1 && b.accepts?.[0]?.scheme === 'exact', `${name} answers 402 with an "exact" payment offer`); + } + + // --- 1. asset class one: per-call model inference (claude plans...) --------- + console.log('\nleg 1 — model inference, claude/plan:'); + const plan = await viaProxy('/v1/chat/completions', { + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'Plan a refactor of the settlement engine tests.' }], + }, 'plan'); + ok(plan.res.status === 200, 'completion succeeded through the proxy'); + ok(plan.res.headers.get('x-wielder-402') === '1', 'proxy hit a 402 first and paid to proceed'); + ok(plan.json.choices?.[0]?.message?.content?.length > 0, 'got assistant content back'); + + // --- 2. ...and gpt implements — same wallet, different upstream ------------- + console.log('\nleg 2 — model inference, gpt/implement:'); + const impl = await viaProxy('/v1/chat/completions', { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'Implement the plan.' }], + }, 'implement'); + ok(impl.res.status === 200, 'completion succeeded through the proxy'); + ok(impl.res.headers.get('x-wielder-402') === '1', 'proxy hit a 402 first and paid to proceed'); + + // --- 3. asset class two: hosted-skill invocation behind the collar ---------- + console.log(`\nleg 3 — hosted skill, ${SKILL_ID}:`); + const skill = await viaProxy(`/invoke/${SKILL_ID}`, { input: 'make the checkout page faster' }); + ok(skill.res.status === 200, 'invocation succeeded through the proxy'); + ok(skill.res.headers.get('x-wielder-402') === '1', 'proxy hit a 402 first and paid to proceed'); + ok(skill.json.output?.length > 0, 'skill returned output'); + + // Output only — the skill's content must never cross the collar boundary. + const skillMd = fs.readFileSync(here(`../../.claude/skills/${SKILL_ID}/SKILL.md`), 'utf8'); + const fullResponse = JSON.stringify(skill.json); + const fingerprints = ['The one rule that makes this skill worth invoking', 'The seven ingredients', skillMd.slice(0, 400)]; + ok(fingerprints.every((f) => !fullResponse.includes(f)), 'response contains NO skill content (checked 3 fingerprints)'); + + // --- 4. the settled txHash is a single-use credential: replay refused ------- + console.log('\nreplay protection:'); + const usedPayment = skill.res.headers.get('x-wielder-payment'); + const replay = await fetch(`${collar.url}/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'X-PAYMENT': usedPayment }, + body: JSON.stringify({ input: 'try to run again on the same payment' }), + }); + ok(replay.status === 409, `replaying the settled credential is rejected (HTTP ${replay.status})`); + ok((await replay.json()).error?.includes('replay'), 'rejection names the replay'); + + // --- 5. the unified ledger: 3 entries, exact engine-computed skill split ---- + console.log('\nunified session ledger:'); + const entries = await (await fetch(`${proxy.url}/ledger?format=json`)).json(); + eq(entries.length, 3, 'ledger has exactly 3 entries'); + eq(entries.map((e) => e.leg), ['model', 'model', 'skill'], 'legs attributed: model, model, skill'); + eq(entries.map((e) => e.label), ['claude/plan', 'gpt/implement', `skill/${SKILL_ID}`], 'labels attributed'); + eq(entries.map((e) => e.amountUSDC), [MODEL_PRICES_USDC.claude, MODEL_PRICES_USDC.gpt, 0.25], 'amounts match the quoted 402 offers'); + ok(entries.every((e) => /^0x[0-9a-f]{64}$/.test(e.txHash)), 'every entry carries a settlement txHash'); + + // Recompute the skill split with the settlement engine itself (same seed + // shapes the collar uses) and demand an exact match. distribute() is not + // exported by the prototype, so we drive it through the public invoke(). + const ref = createState(); + addParty(ref, { id: 'creator', name: 'Skill creator', role: 'Creator' }); + addParty(ref, { id: 'wielder', name: 'Session wallet', role: 'Wielder/Beneficiary', balance: 1e12 }); + registerSkill(ref, { id: SKILL_ID, name: SKILL_ID, creatorId: 'creator', price: Number(usdcToAtomic(0.25)), mode: 'marketplace' }); + setRoyalty(ref, SKILL_ID, [{ partyId: 'creator', bps: 10000 }]); + const expected = invoke(ref, SKILL_ID, 'wielder'); + const expectedSplits = [ + ...expected.breakdown.map((b) => ({ party: b.partyId, amountUSDC: atomicToUsdc(b.amount) })), + { party: 'treasury', amountUSDC: atomicToUsdc(expected.fee) }, + ]; + eq(entries[2].splits, expectedSplits, 'skill split matches settlement-engine distribute() exactly'); + eq(entries[2].splits, skill.json.receipt.splits, 'collar receipt and ledger agree'); + + // --- 6. payment overhead (MOCK numbers) -------------------------------------- + console.log('\nper-call x402 payment overhead — MOCK numbers (localhost, fake settlement;'); + console.log('testnet adds real facilitator HTTP + Base Sepolia inclusion time):'); + for (const o of overheads) { + console.log(` ${o.call.padEnd(10)} 402-roundtrip ${o.ms402.toFixed(1)}ms · sign ${o.msSign.toFixed(1)}ms · verify+settle ${o.msFacilitator.toFixed(1)}ms · total overhead ${o.msOverhead.toFixed(1)}ms`); + } + const sorted = overheads.map((o) => o.msOverhead).sort((a, b) => a - b); + const p50 = sorted[Math.floor(sorted.length / 2)]; + console.log(` p50 ${p50.toFixed(1)}ms · max ${sorted.at(-1).toFixed(1)}ms (n=${sorted.length}, mock)`); + + // --- the money shot ----------------------------------------------------------- + console.log('\nsession ledger (one wallet, two asset classes, three payees):'); + console.log(' ' + (await (await fetch(`${proxy.url}/ledger`)).text()).split('\n').join('\n ')); + console.log(` (JSONL at ${LEDGER_FILE})`); + + console.log(`\nPASS — ${checks} checks green.`); +} finally { + proxy.close(); gateway.close(); collar.close(); facilitator.close(); +} diff --git a/spikes/pi-wielder/package-lock.json b/spikes/pi-wielder/package-lock.json new file mode 100644 index 0000000..5d0280a --- /dev/null +++ b/spikes/pi-wielder/package-lock.json @@ -0,0 +1,273 @@ +{ + "name": "pi-wielder", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-wielder", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@hono/node-server": "^2.0.8", + "hono": "^4.12.29", + "viem": "^2.55.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.8.tgz", + "integrity": "sha512-GuCWzLxwg218fy1JaHculFsdcuY12hxit83V+algozTPnwhNjLrRL/Alg9OYjLZLoUZ1rw/S4CdTMsnkSKCmFA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.29", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.29.tgz", + "integrity": "sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/ox": { + "version": "0.14.30", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.30.tgz", + "integrity": "sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.0.tgz", + "integrity": "sha512-5XWSTCTmNvHCeT38Fsp31uofmOZFJdj4nOUH+H2Vh/hzsx9M7r+KgL3fSYUZeVn4H0UyQxjwPFqEaV4M0CI1tA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.30", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/spikes/pi-wielder/package.json b/spikes/pi-wielder/package.json new file mode 100644 index 0000000..4694a28 --- /dev/null +++ b/spikes/pi-wielder/package.json @@ -0,0 +1,19 @@ +{ + "name": "pi-wielder-spike", + "version": "0.1.0", + "private": true, + "description": "Pi-Wielder spike: one wallet, two asset classes. A coding harness pays per-call x402 for model inference AND a hosted Skill behind a mock collar, with a unified attributed session ledger. Testnet-only (Base Sepolia); fully offline in mock mode.", + "type": "module", + "scripts": { + "e2e": "MOCK_FACILITATOR=1 MOCK_LLM=1 node e2e.mjs", + "collar": "node src/collar.mjs", + "gateway": "node src/gateway.mjs", + "proxy": "node src/proxy.mjs" + }, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^2.0.8", + "hono": "^4.12.29", + "viem": "^2.55.0" + } +} diff --git a/spikes/pi-wielder/pi-extension/x402.ts b/spikes/pi-wielder/pi-extension/x402.ts new file mode 100644 index 0000000..c60570b --- /dev/null +++ b/spikes/pi-wielder/pi-extension/x402.ts @@ -0,0 +1,98 @@ +// pi-extension/x402.ts — makes Pi (@earendil-works/pi-coding-agent, v0.80.x) +// a Wielder without teaching it anything about payments. +// +// Install: copy this file into the project's `.pi/extensions/` (or +// `~/.pi/agent/extensions/`), run the paying proxy (`npm run proxy` plus +// collar + gateway, see RUNBOOK.md), then `/reload` inside pi. +// +// Note the shape of this extension: it points Pi at a localhost baseUrl and +// adds one HTTP tool and one display command. There is ZERO payment, wallet, +// or chain code here — Pi has no custom-fetch/retry hook, and it doesn't need +// one, because the paying proxy (src/proxy.mjs) answers every 402 upstream. +// That is ADR-0008: the Wielder is a wallet, not a harness. (Same pattern +// BlockRun's ClawRouter uses for OpenClaw on port 8402.) +// +// Written against the documented pi extension API (registerProvider / +// registerTool / registerCommand); exercised manually in the live demo — pi +// may not be installed in this environment, so nothing in the build depends +// on this file compiling. + +const PROXY = process.env.PI_WIELDER_PROXY ?? "http://localhost:8402"; + +// Minimal structural type for the documented extension surface, so this file +// stands alone without pi's type package. +type Pi = { + registerProvider(name: string, config: Record): void; + registerTool(tool: Record): void; + registerCommand(name: string, command: Record): void; + on?(event: string, handler: (...args: unknown[]) => unknown): void; +}; + +export default function activate(pi: Pi) { + // --- one provider, two model families, one paying wallet behind it ------- + // Everything Pi sends to these models 402-pays per call through the proxy. + pi.registerProvider("x402", { + baseUrl: `${PROXY}/v1`, + api: "openai-completions", // the proxy/gateway speak OpenAI chat-completions + models: [ + { + id: "claude-sonnet-4-6", + name: "claude via x402 (pay-per-call, Base Sepolia)", + contextWindow: 200_000, + maxTokens: 8_192, + }, + { + id: "gpt-5.2", + name: "gpt via x402 (pay-per-call, Base Sepolia)", + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }); + + // --- the second asset class: a paid, hosted skill as a Pi tool ----------- + pi.registerTool({ + name: "invoke_skill", + description: + "Invoke the hosted, x402-paid skill 'optimizing-claude-code-prompts'. " + + "Send a rough prompt/request as `input`; returns the optimized prompt. " + + "Costs testnet USDC per call; the payment, royalty split, and ledger " + + "entry are handled by the local paying proxy.", + parameters: { + type: "object", + properties: { + skillId: { + type: "string", + description: "Hosted skill id", + default: "optimizing-claude-code-prompts", + }, + input: { type: "string", description: "The rough request to optimize" }, + }, + required: ["input"], + }, + async execute(args: { skillId?: string; input: string }) { + const skillId = args.skillId ?? "optimizing-claude-code-prompts"; + const res = await fetch(`${PROXY}/invoke/${skillId}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ input: args.input }), + }); + if (!res.ok) return `invoke_skill failed (HTTP ${res.status}): ${await res.text()}`; + const { output, receipt } = (await res.json()) as { + output: string; + receipt: { txHash: string; amountUSDC: number; splits: { party: string; amountUSDC: number }[] }; + }; + const split = receipt.splits.map((s) => `${s.party} $${s.amountUSDC}`).join(" / "); + return `${output}\n\n[paid $${receipt.amountUSDC} · tx ${receipt.txHash.slice(0, 10)}… · split ${split}]`; + }, + }); + + // --- /ledger: the unified session meter, rendered by the proxy ----------- + pi.registerCommand("ledger", { + description: "Show this session's unified x402 ledger (inference + skills)", + async handler() { + const res = await fetch(`${PROXY}/ledger`); + return res.ok ? await res.text() : `ledger unavailable (HTTP ${res.status}) — is the proxy running?`; + }, + }); +} diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs new file mode 100644 index 0000000..5431a9f --- /dev/null +++ b/spikes/pi-wielder/src/collar.mjs @@ -0,0 +1,142 @@ +// collar.mjs — the mock Collar: the single trusted component on the seller side. +// +// The Collar is the sole platform-key holder, the x402 resource server, and +// the off-chain meter for ONE hosted Skill: this repo's own +// `.claude/skills/optimizing-claude-code-prompts`. Its contract: +// +// * The skill CONTENT never leaves this process. The Wielder pays for an +// INVOCATION and receives OUTPUT ONLY — artifact scarcity is preserved by +// hosting (ADR-0001), and the x402-settled txHash is the single-use +// execution credential ("no credential, no run", ADR-0003). +// * Every settled invocation is metered into the prototype settlement +// engine (prototype/settlement-engine.mjs), which computes the royalty +// split (creator 100% here) net of the 2.5% protocol fee. The engine is +// the accounting mirror of the on-chain USDC payment: value arrives once +// via EIP-3009, the engine attributes it. +// +// The engine's public economic event is `invoke()` (pay -> mint credential -> +// consume -> settle); its internal `distribute()` does the recursive royalty +// flow-through. `distribute` is not exported, so we drive it through +// `invoke()` and use the returned breakdown — same math, public API. +// +// Engine amounts are kept in ATOMIC USDC (6-decimal integers) because the +// engine rounds to 2 decimals — fine for dollars, lossy for $0.25 micro- +// royalties. 0.25 USDC = 250_000 atomic -> fee 6_250, creator 243_750, exact. + +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { x402Paywall, atomicToUsdc, usdcToAtomic } from './x402-seller.mjs'; +import { + createState, addParty, registerSkill, setRoyalty, invoke, +} from '../../../prototype/settlement-engine.mjs'; + +export const SKILL_ID = 'optimizing-claude-code-prompts'; +const SKILL_PATH = fileURLToPath( + new URL(`../../../.claude/skills/${SKILL_ID}/SKILL.md`, import.meta.url), +); +const DEFAULT_PRICE_USDC = 0.25; + +export function createCollar({ + facilitatorUrl, + payTo = process.env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dEaD', + priceUsdc = Number(process.env.SKILL_PRICE_USDC || DEFAULT_PRICE_USDC), + mockLlm = process.env.MOCK_LLM === '1', +} = {}) { + // The platform key: the skill content, loaded once, never serialized out. + const skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); + + // --- the off-chain meter: settlement-engine state for this one skill ----- + const state = createState(); // feeBps: 250 (2.5% protocol fee) + addParty(state, { id: 'creator', name: 'Skill creator', role: 'Creator' }); + // The engine debits the wielder's balance as the mirror of the on-chain + // USDC transfer; seed it deep enough for any session. + addParty(state, { id: 'wielder', name: 'Session wallet', role: 'Wielder/Beneficiary', balance: 1e12 }); + registerSkill(state, { id: SKILL_ID, name: SKILL_ID, creatorId: 'creator', price: Number(usdcToAtomic(priceUsdc)), mode: 'marketplace' }); + setRoyalty(state, SKILL_ID, [{ partyId: 'creator', bps: 10000 }]); // creator holds 100% + + const app = new Hono(); + app.get('/healthz', (c) => c.json({ ok: true, skill: SKILL_ID, priceUsdc })); + + app.post( + '/invoke/:skillId', + x402Paywall({ price: priceUsdc, payTo, facilitatorUrl, description: `hosted-skill invocation: ${SKILL_ID}` }), + async (c) => { + if (c.req.param('skillId') !== SKILL_ID) return c.json({ error: `unknown skill '${c.req.param('skillId')}'` }, 404); + const { input } = await c.req.json().catch(() => ({})); + if (!input) return c.json({ error: 'body must be JSON: { "input": "..." }' }, 400); + const payment = c.get('x402'); // { txHash, payer, amountUsdc } from the paywall + + // Execute the skill: SKILL.md is the system prompt, the buyer's input is + // the user turn. Output only ever flows out. + const output = mockLlm ? mockSkillOutput(input) : await runSkillViaAnthropic(skillContent, input); + + // Meter the settled invocation. invoke() re-runs pay -> credential -> + // distribute() inside the engine and returns the royalty breakdown. + const result = invoke(state, SKILL_ID, 'wielder'); + const splits = [ + ...result.breakdown.map((b) => ({ party: b.partyId, amountUSDC: atomicToUsdc(b.amount) })), + { party: 'treasury', amountUSDC: atomicToUsdc(result.fee) }, + ]; + + return c.json({ + output, // and ONLY the output — never skillContent + receipt: { skillId: SKILL_ID, txHash: payment.txHash, payer: payment.payer, amountUSDC: payment.amountUsdc, splits }, + }); + }, + ); + + return app; +} + +// Canned skill output for MOCK_LLM=1: recognizably an *optimized prompt*, +// recognizably NOT the skill's own text. +function mockSkillOutput(input) { + return [ + `[mock ${SKILL_ID}] Optimized prompt for: "${String(input).slice(0, 120)}"`, + '', + 'Goal: ', + 'Context: ', + 'Constraints: ', + 'Done when: ', + ].join('\n'); +} + +async function runSkillViaAnthropic(skillContent, input) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) throw new Error('ANTHROPIC_API_KEY required unless MOCK_LLM=1'); + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4-6', + max_tokens: 2048, + system: skillContent, // the platform key stays server-side + messages: [{ role: 'user', content: String(input) }], + }), + }); + if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${await res.text()}`); + const data = await res.json(); + return data.content?.map((b) => b.text ?? '').join('') ?? ''; +} + +/** Boot helper shared by the standalone script and e2e.mjs. */ +export function startCollar({ port = 0, ...opts } = {}) { + const app = createCollar(opts); + return new Promise((resolve) => { + const server = serve({ fetch: app.fetch, port }, (info) => { + resolve({ url: `http://127.0.0.1:${info.port}`, port: info.port, close: () => server.close() }); + }); + }); +} + +// Standalone: `npm run collar` +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const { startMockFacilitator } = await import('./facilitator-mock.mjs'); + const facilitatorUrl = process.env.MOCK_FACILITATOR === '1' + ? (await startMockFacilitator()).url + : (process.env.FACILITATOR_URL || 'https://x402.org/facilitator'); + const { url } = await startCollar({ port: Number(process.env.COLLAR_PORT || 8404), facilitatorUrl }); + console.log(`[collar] hosted skill '${SKILL_ID}' at ${url}/invoke/${SKILL_ID} (facilitator: ${facilitatorUrl})`); +} diff --git a/spikes/pi-wielder/src/facilitator-mock.mjs b/spikes/pi-wielder/src/facilitator-mock.mjs new file mode 100644 index 0000000..0dfc926 --- /dev/null +++ b/spikes/pi-wielder/src/facilitator-mock.mjs @@ -0,0 +1,92 @@ +// facilitator-mock.mjs — an in-process stand-in for https://x402.org/facilitator +// (MOCK_FACILITATOR=1). Zero network, zero keys, zero funds. +// +// It is deliberately NOT a rubber stamp: +// /verify really recovers the EIP-712 signer of the EIP-3009 +// TransferWithAuthorization (pure secp256k1 — no chain needed) and +// checks it against `authorization.from`, the payee, the amount and +// the validity window. So even offline, a forged or mis-signed +// payment is rejected and the buyer-side signing code is genuinely +// exercised. The only thing we cannot check offline is whether the +// payer actually holds USDC. +// /settle fakes the on-chain broadcast. The fake txHash is a DETERMINISTIC +// hash of the payment payload, which preserves the real chain's +// replay property: re-settling the same authorization yields the +// same txHash, so the seller's consumed-set rejects it (on the real +// chain the reused EIP-3009 nonce would make /settle revert). + +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { recoverTypedDataAddress, keccak256, toHex } from 'viem'; +import { X402_VERSION, NETWORK, CHAIN_ID } from './x402-seller.mjs'; + +// Identical types/domain to what the buyer signs in proxy.mjs. +const EIP3009_TYPES = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], +}; + +export function createMockFacilitator() { + const app = new Hono(); + + app.post('/verify', async (c) => { + const { paymentPayload, paymentRequirements: req } = await c.req.json(); + const auth = paymentPayload?.payload?.authorization; + const signature = paymentPayload?.payload?.signature; + const fail = (reason) => c.json({ isValid: false, invalidReason: reason }); + + if (paymentPayload?.x402Version !== X402_VERSION) return fail('unsupported x402Version'); + if (paymentPayload?.scheme !== 'exact' || paymentPayload?.network !== NETWORK) return fail('scheme/network mismatch'); + if (!auth || !signature) return fail('missing authorization or signature'); + if (auth.to?.toLowerCase() !== req?.payTo?.toLowerCase()) return fail('authorization pays the wrong address'); + if (BigInt(auth.value) < BigInt(req.maxAmountRequired)) return fail('authorized amount below price'); + const now = Math.floor(Date.now() / 1000); + if (now <= Number(auth.validAfter) || now >= Number(auth.validBefore)) return fail('authorization outside validity window'); + + // The real check: who signed this? (pure crypto, no chain access) + const signer = await recoverTypedDataAddress({ + domain: { name: req.extra.name, version: req.extra.version, chainId: CHAIN_ID, verifyingContract: req.asset }, + types: EIP3009_TYPES, + primaryType: 'TransferWithAuthorization', + message: { + from: auth.from, + to: auth.to, + value: BigInt(auth.value), + validAfter: BigInt(auth.validAfter), + validBefore: BigInt(auth.validBefore), + nonce: auth.nonce, + }, + signature, + }); + if (signer.toLowerCase() !== auth.from.toLowerCase()) return fail('signature does not match payer'); + + return c.json({ isValid: true, payer: auth.from }); + }); + + app.post('/settle', async (c) => { + const { paymentPayload } = await c.req.json(); + const auth = paymentPayload?.payload?.authorization; + if (!auth) return c.json({ success: false, errorReason: 'missing authorization' }); + // Deterministic fake txHash: same authorization -> same "transaction", + // which is what makes replay detection meaningful in mock mode. + const txHash = keccak256(toHex(JSON.stringify(auth) + (paymentPayload.payload.signature ?? ''))); + return c.json({ success: true, transaction: txHash, network: NETWORK, payer: auth.from }); + }); + + return app; +} + +/** Boot on an ephemeral (or given) port; resolves to { url, close }. */ +export function startMockFacilitator(port = 0) { + return new Promise((resolve) => { + const server = serve({ fetch: createMockFacilitator().fetch, port }, (info) => { + resolve({ url: `http://127.0.0.1:${info.port}`, close: () => server.close() }); + }); + }); +} diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs new file mode 100644 index 0000000..b6363eb --- /dev/null +++ b/spikes/pi-wielder/src/gateway.mjs @@ -0,0 +1,139 @@ +// gateway.mjs — a simulated x402 inference reseller (the OTHER asset class). +// +// Live x402 inference gateways (Router402, tx402.ai, BlockRun's ClawRouter) +// are mainnet-only, and no first-party model API accepts x402 yet. So for a +// testnet spike we run our own: an OpenAI-compatible /v1/chat/completions +// that is 402-gated exactly like theirs, and that fulfills the request with +// local API keys (Anthropic for claude-*, OpenAI for gpt-*) — or canned +// completions under MOCK_LLM=1. +// +// Economically this leg is plain pass-through: no royalty table, no split. +// The interesting part is that it lands in the SAME session ledger as the +// skill leg — that contrast is the spike's whole point. + +import { pathToFileURL } from 'node:url'; +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { x402Paywall } from './x402-seller.mjs'; + +// Flat per-call testnet prices by model family (real resellers price per +// token; per-call keeps the 402 requirements computable before inference). +export const MODEL_PRICES_USDC = { claude: 0.041, gpt: 0.087, default: 0.05 }; +const priceFor = (model = '') => + model.startsWith('claude') ? MODEL_PRICES_USDC.claude + : model.startsWith('gpt') ? MODEL_PRICES_USDC.gpt + : MODEL_PRICES_USDC.default; + +export function createGateway({ + facilitatorUrl, + payTo = process.env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dEaD', + mockLlm = process.env.MOCK_LLM === '1', +} = {}) { + const app = new Hono(); + app.get('/healthz', (c) => c.json({ ok: true, prices: MODEL_PRICES_USDC })); + + app.post( + '/v1/chat/completions', + x402Paywall({ + // Per-request pricing: Hono caches the parsed body, so reading it here + // and again in the handler is safe. + price: async (c) => priceFor((await c.req.json().catch(() => ({}))).model), + payTo, + facilitatorUrl, + description: 'per-call model inference (x402 reseller, testnet)', + }), + async (c) => { + const body = await c.req.json(); + const model = body.model ?? ''; + if (mockLlm) return c.json(mockCompletion(body)); + if (model.startsWith('claude')) return c.json(await viaAnthropic(body)); + return c.json(await viaOpenAI(body)); // gpt-* and anything else + }, + ); + + return app; +} + +// --- MOCK_LLM=1: canned OpenAI-format completions -------------------------- +function mockCompletion(body) { + const lastUser = [...(body.messages ?? [])].reverse().find((m) => m.role === 'user')?.content ?? ''; + const family = (body.model ?? '').startsWith('claude') ? 'claude' : 'gpt'; + const content = + family === 'claude' + ? `[mock ${body.model}] PLAN:\n1. Read the failing module.\n2. Sketch the fix.\n3. Hand off to implementation.\n(for: "${String(lastUser).slice(0, 80)}")` + : `[mock ${body.model}] IMPLEMENTATION:\n\`\`\`js\n// minimal change implementing the plan\n\`\`\`\n(for: "${String(lastUser).slice(0, 80)}")`; + return { + id: `chatcmpl-mock-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: body.model, + choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }], + usage: { prompt_tokens: 42, completion_tokens: 42, total_tokens: 84 }, + }; +} + +// --- real upstreams --------------------------------------------------------- +// Thin OpenAI-chat -> Anthropic Messages translation (system extraction, text +// content only — spike-grade, no tools/streaming). +async function viaAnthropic(body) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) throw new Error('ANTHROPIC_API_KEY required for claude-* models unless MOCK_LLM=1'); + const system = (body.messages ?? []).filter((m) => m.role === 'system').map((m) => m.content).join('\n') || undefined; + const messages = (body.messages ?? []).filter((m) => m.role !== 'system') + .map((m) => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content) })); + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, + body: JSON.stringify({ model: body.model, max_tokens: body.max_tokens ?? 2048, system, messages }), + }); + if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${await res.text()}`); + const data = await res.json(); + return { + id: data.id, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: data.model, + choices: [{ + index: 0, + message: { role: 'assistant', content: data.content?.map((b) => b.text ?? '').join('') ?? '' }, + finish_reason: data.stop_reason === 'max_tokens' ? 'length' : 'stop', + }], + usage: { + prompt_tokens: data.usage?.input_tokens ?? 0, + completion_tokens: data.usage?.output_tokens ?? 0, + total_tokens: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0), + }, + }; +} + +async function viaOpenAI(body) { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error('OPENAI_API_KEY required for gpt-* models unless MOCK_LLM=1'); + const res = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`OpenAI API ${res.status}: ${await res.text()}`); + return res.json(); +} + +/** Boot helper shared by the standalone script and e2e.mjs. */ +export function startGateway({ port = 0, ...opts } = {}) { + const app = createGateway(opts); + return new Promise((resolve) => { + const server = serve({ fetch: app.fetch, port }, (info) => { + resolve({ url: `http://127.0.0.1:${info.port}`, port: info.port, close: () => server.close() }); + }); + }); +} + +// Standalone: `npm run gateway` +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const { startMockFacilitator } = await import('./facilitator-mock.mjs'); + const facilitatorUrl = process.env.MOCK_FACILITATOR === '1' + ? (await startMockFacilitator()).url + : (process.env.FACILITATOR_URL || 'https://x402.org/facilitator'); + const { url } = await startGateway({ port: Number(process.env.GATEWAY_PORT || 8403), facilitatorUrl }); + console.log(`[gateway] x402-gated /v1/chat/completions at ${url} (facilitator: ${facilitatorUrl})`); +} diff --git a/spikes/pi-wielder/src/ledger.mjs b/spikes/pi-wielder/src/ledger.mjs new file mode 100644 index 0000000..e75cf6c --- /dev/null +++ b/spikes/pi-wielder/src/ledger.mjs @@ -0,0 +1,46 @@ +// ledger.mjs — the unified, attributed session ledger. THE product claim. +// +// Inference calls and skill invocations are different asset classes with +// different settlement stories (pass-through vs royalty split), but they are +// entries in the SAME ledger, attributed to the SAME wallet session. That +// "unified meter" is what the design doc says differentiates this from the +// commoditizing x402 inference resellers (Router402, ClawRouter, tx402.ai). +// +// Format: JSONL, one entry per paid call: +// { ts, leg: "model"|"skill", label, amountUSDC, txHash, splits } +// splits: [{ party, amountUSDC }] for skill legs (royalty breakdown + +// protocol treasury, produced by the settlement engine), null for +// plain pass-through model legs. + +import fs from 'node:fs'; + +export function createLedger(filePath = null) { + const entries = []; + return { + entries, + record(entry) { + const full = { ts: new Date().toISOString(), ...entry }; + entries.push(full); + if (filePath) fs.appendFileSync(filePath, JSON.stringify(full) + '\n'); + return full; + }, + }; +} + +const fmt = (n) => '$' + Number(n).toFixed(6).replace(/0+$/, '').replace(/\.$/, ''); + +/** + * One-line session view, e.g.: + * claude/plan $0.041 · gpt/implement $0.087 · skill/optimizing-claude-code-prompts $0.25 + * → creator $0.24375 / treasury $0.00625 + */ +export function renderLedger(entries) { + if (!entries.length) return '(empty session ledger)'; + const parts = entries.map((e) => { + let s = `${e.label} ${fmt(e.amountUSDC)}`; + if (e.splits?.length) s += ` → ${e.splits.map((x) => `${x.party} ${fmt(x.amountUSDC)}`).join(' / ')}`; + return s; + }); + const total = entries.reduce((a, e) => a + Number(e.amountUSDC), 0); + return `${parts.join(' · ')}\n session total ${fmt(total)} across ${entries.length} paid calls, one wallet`; +} diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs new file mode 100644 index 0000000..3602da7 --- /dev/null +++ b/spikes/pi-wielder/src/proxy.mjs @@ -0,0 +1,151 @@ +// proxy.mjs — THE WIELDER. +// +// ╔══════════════════════════════════════════════════════════════════════════╗ +// ║ THIS FILE IS THE ENTIRE WIELDER-SIDE PROTOCOL FOOTPRINT. ║ +// ║ ║ +// ║ Everything a client needs in order to consume BOTH asset classes ║ +// ║ (per-call model inference AND hosted-skill invocations) is below: ║ +// ║ answer HTTP 402 with a signed USDC payment and retry. No Story SDK, ║ +// ║ no token custody, no chain reads. The harness (Pi) never sees any of ║ +// ║ it — it just talks OpenAI-compatible HTTP to localhost. That is ║ +// ║ ADR-0008 ("the Wielder is a wallet, not a harness") proved by ║ +// ║ construction. Precedent: BlockRun's ClawRouter runs the same paying- ║ +// ║ proxy pattern for OpenClaw on port 8402. ║ +// ╚══════════════════════════════════════════════════════════════════════════╝ + +import crypto from 'node:crypto'; +import { pathToFileURL } from 'node:url'; +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { loadAccount } from './wallet.mjs'; +import { createLedger, renderLedger } from './ledger.mjs'; + +// EIP-712 typed data for EIP-3009 transferWithAuthorization — the single +// signature that IS the payment. (Constants restated here on purpose: the +// Wielder must be self-contained, importing nothing from the seller side.) +const CHAIN_ID = 84532; // Base Sepolia +const EIP3009_TYPES = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' }, + ], +}; +const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64'); +const unb64 = (s) => JSON.parse(Buffer.from(s, 'base64').toString('utf8')); + +// The whole buyer protocol: request -> 402 -> sign EIP-3009 -> retry once. +// Returns { res, paid, xPayment, timings } — timings are the spike's +// payment-overhead measurement (402 roundtrip + sign + facilitator). +async function payingFetch(account, url, init) { + const t0 = performance.now(); + const first = await fetch(url, init); + if (first.status !== 402) return { res: first, paid: false }; + const ms402 = performance.now() - t0; + + // The 402 body carries PaymentRequirements; we accept the first offer. + const { accepts } = await first.json(); + const req = accepts?.[0]; + if (!req || req.scheme !== 'exact') throw new Error('402 without a usable "exact" payment offer'); + + // Sign the USDC transfer authorization. Pure local cryptography — this is + // the only "wallet" action the Wielder ever performs. + const tSign = performance.now(); + const now = Math.floor(Date.now() / 1000); + const authorization = { + from: account.address, + to: req.payTo, + value: req.maxAmountRequired, // atomic USDC + validAfter: String(now - 60), // clock-skew slack + validBefore: String(now + (req.maxTimeoutSeconds ?? 60)), + nonce: `0x${crypto.randomBytes(32).toString('hex')}`, // random EIP-3009 nonce = replay protection + }; + const signature = await account.signTypedData({ + domain: { name: req.extra?.name, version: req.extra?.version, chainId: CHAIN_ID, verifyingContract: req.asset }, + types: EIP3009_TYPES, + primaryType: 'TransferWithAuthorization', + message: { ...authorization, value: BigInt(authorization.value), validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore) }, + }); + const msSign = performance.now() - tSign; + + // Retry with X-PAYMENT. The seller verifies + settles via its facilitator. + const xPayment = b64({ x402Version: 1, scheme: 'exact', network: req.network, payload: { signature, authorization } }); + const tRetry = performance.now(); + const res = await fetch(url, { ...init, headers: { ...init.headers, 'X-PAYMENT': xPayment } }); + const msPaidRoundtrip = performance.now() - tRetry; + const msFacilitator = Number(res.headers.get('X-402-FACILITATOR-MS') ?? NaN); // seller-reported verify+settle + return { + res, paid: true, xPayment, + amountUSDC: Number(req.maxAmountRequired) / 1e6, + timings: { ms402, msSign, msFacilitator, msPaidRoundtrip, msOverhead: ms402 + msSign + (msFacilitator || 0) }, + }; +} + +export function createProxy({ + account = loadAccount(), + gatewayUrl = process.env.GATEWAY_URL || 'http://127.0.0.1:8403', + collarUrl = process.env.COLLAR_URL || 'http://127.0.0.1:8404', + ledgerFile = process.env.LEDGER_FILE ?? null, +} = {}) { + const ledger = createLedger(ledgerFile); + const app = new Hono(); + + // One handler for both asset classes: /v1/* -> inference gateway (leg: + // "model"), /invoke/* -> collar (leg: "skill"). Same wallet, same ledger. + const forward = (upstreamBase, leg) => async (c) => { + const path = c.req.path; + const bodyText = await c.req.text(); + const { res, paid, xPayment, amountUSDC, timings } = await payingFetch(account, `${upstreamBase}${path}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: bodyText, + }); + const resBody = await res.text(); + + if (paid && res.ok) { + const parsed = JSON.parse(resBody); + const model = JSON.parse(bodyText || '{}').model ?? ''; + const label = leg === 'skill' + ? `skill/${path.split('/').pop()}` + : `${model.startsWith('claude') ? 'claude' : 'gpt'}/${c.req.header('x-session-label') || 'chat'}`; + ledger.record({ + leg, label, amountUSDC, + txHash: unb64(res.headers.get('X-PAYMENT-RESPONSE')).transaction, + splits: parsed.receipt?.splits ?? null, // royalty breakdown rides back on the skill leg only + }); + } + + // Spike-only debug headers: proof-of-402 + overhead for e2e, and the raw + // X-PAYMENT so the e2e can attempt (and be refused) a credential replay. + const headers = { 'content-type': 'application/json' }; + if (paid) { + headers['x-wielder-402'] = '1'; + headers['x-wielder-overhead'] = JSON.stringify(timings); + headers['x-wielder-payment'] = xPayment; // testnet-only; never expose a mainnet authorization like this + } + return c.newResponse(resBody, res.status, headers); + }; + + app.post('/v1/*', forward(gatewayUrl, 'model')); + app.post('/invoke/*', forward(collarUrl, 'skill')); + + // The unified session ledger — what Pi's /ledger command renders. + app.get('/ledger', (c) => + c.req.query('format') === 'json' ? c.json(ledger.entries) : c.text(renderLedger(ledger.entries))); + + return { app, ledger, account }; +} + +/** Boot helper shared by the standalone script and e2e.mjs. */ +export function startProxy({ port = 0, ...opts } = {}) { + const { app, ledger, account } = createProxy(opts); + return new Promise((resolve) => { + const server = serve({ fetch: app.fetch, port }, (info) => { + resolve({ url: `http://127.0.0.1:${info.port}`, port: info.port, ledger, account, close: () => server.close() }); + }); + }); +} + +// Standalone: `npm run proxy` +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const { url, account } = await startProxy({ port: Number(process.env.PROXY_PORT || 8402) }); + console.log(`[proxy] Wielder wallet ${account.address} paying at ${url} (/v1/* -> gateway, /invoke/* -> collar, /ledger)`); +} diff --git a/spikes/pi-wielder/src/wallet.mjs b/spikes/pi-wielder/src/wallet.mjs new file mode 100644 index 0000000..f51e490 --- /dev/null +++ b/spikes/pi-wielder/src/wallet.mjs @@ -0,0 +1,33 @@ +// wallet.mjs — the Wielder's entire identity is one EOA private key. +// +// That is the point of ADR-0008 ("the Wielder is a wallet, not a harness"): +// no token custody, no chain reads, no Story SDK. Just a key that can sign +// EIP-3009 USDC transfer authorizations (see proxy.mjs). +// +// Real (testnet) mode: set PRIVATE_KEY in .env and fund the derived address +// with Base Sepolia USDC + ETH from the Coinbase CDP faucet (see RUNBOOK.md). +// Mock mode: a throwaway key is generated per process — signing is pure +// cryptography, so no funds, no network, no faucet are needed. + +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'; + +/** viem local account from the PRIVATE_KEY env var, or null if unset. */ +export function accountFromEnv(env = process.env) { + const pk = env.PRIVATE_KEY?.trim(); + if (!pk) return null; + return privateKeyToAccount(pk.startsWith('0x') ? pk : `0x${pk}`); +} + +/** A fresh, unfunded, in-memory account. Perfectly fine for MOCK_FACILITATOR=1. */ +export function throwawayAccount() { + return privateKeyToAccount(generatePrivateKey()); +} + +/** Env account if present, else a throwaway (with a loud note so nobody is surprised). */ +export function loadAccount(env = process.env) { + const fromEnv = accountFromEnv(env); + if (fromEnv) return fromEnv; + const acct = throwawayAccount(); + console.error(`[wallet] no PRIVATE_KEY set — using throwaway account ${acct.address} (mock mode only)`); + return acct; +} diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs new file mode 100644 index 0000000..542441e --- /dev/null +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -0,0 +1,152 @@ +// x402-seller.mjs — the SELLER half of the x402 protocol, written out by hand. +// +// Both paid services in this spike (collar.mjs, gateway.mjs) gate their routes +// with the `x402Paywall` Hono middleware below. We deliberately implement the +// x402 v1 "exact" scheme manually instead of pulling in `@x402/hono`: +// the published 2.x packages implement protocol v2 (class-based scheme +// registries, facilitator sync-on-start) while the free no-auth testnet +// facilitator at https://x402.org/facilitator speaks v1 — and, for a spike, +// spelling the handshake out is the argument. The whole protocol is ~100 +// commented lines. +// +// The seller flow (this file), per the x402 v1 spec: +// 1. Request arrives without an X-PAYMENT header +// -> respond 402 with { x402Version: 1, accepts: [PaymentRequirements] }. +// 2. Client retries with X-PAYMENT: base64(JSON payment payload) +// -> POST facilitator /verify (checks the EIP-3009 signature + funds) +// -> POST facilitator /settle (broadcasts transferWithAuthorization; +// the settled txHash is the receipt) +// 3. The settled txHash is treated as a SINGLE-USE EXECUTION CREDENTIAL: +// it goes into an in-memory consumed-set and any replay of the same +// payment is rejected. (On a real chain the EIP-3009 nonce makes the +// replayed /settle fail anyway; the consumed-set makes the same property +// hold under the mock facilitator, and models the protocol's +// "no credential, no run" rule explicitly.) +// 4. Only then does the resource handler run. Success responses carry an +// X-PAYMENT-RESPONSE header (base64 settlement receipt) so the buyer +// learns the txHash. +// +// NOTE we settle BEFORE executing the resource. Production middleware usually +// executes first and settles after (so a crashed handler doesn't charge the +// buyer); the collar wants the opposite order because the txHash *is* the +// execution credential — pay -> mint -> consume -> execute, exactly the +// sequence in prototype/settlement-engine.mjs. + +// --- x402 v1 / Base Sepolia constants ------------------------------------- +export const X402_VERSION = 1; +export const NETWORK = 'base-sepolia'; +export const CHAIN_ID = 84532; +// Circle's canonical USDC deployment on Base Sepolia (6 decimals). +export const USDC_ADDRESS = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; +// EIP-712 domain values USDC uses for EIP-3009 signatures. +export const USDC_EIP712 = { name: 'USDC', version: '2' }; +export const USDC_DECIMALS = 6; + +export const usdcToAtomic = (usdc) => String(Math.round(Number(usdc) * 10 ** USDC_DECIMALS)); +export const atomicToUsdc = (atomic) => Number(atomic) / 10 ** USDC_DECIMALS; + +const b64ToJson = (s) => JSON.parse(Buffer.from(s, 'base64').toString('utf8')); +const jsonToB64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64'); + +/** + * Hono middleware that 402-gates a route. + * + * @param {object} opts + * @param {number|function} opts.price price in USDC (e.g. 0.25), or an async + * (honoContext) => number for per-request pricing + * @param {string} opts.payTo address the USDC authorization must pay + * @param {string} opts.facilitatorUrl x402 facilitator base URL (/verify, /settle) + * @param {string} opts.description human-readable description in the 402 offer + * + * On success, the settlement receipt is exposed to the downstream handler as + * c.get('x402') = { txHash, payer, amountUsdc, requirements }. + */ +export function x402Paywall({ price, payTo, facilitatorUrl, description = '' }) { + const consumed = new Set(); // settled txHash -> already-used execution credentials + + return async (c, next) => { + const priceUsdc = typeof price === 'function' ? await price(c) : price; + const requirements = { + scheme: 'exact', + network: NETWORK, + maxAmountRequired: usdcToAtomic(priceUsdc), // atomic USDC (6 decimals) + resource: c.req.url, + description, + mimeType: 'application/json', + payTo, + maxTimeoutSeconds: 60, + asset: USDC_ADDRESS, + // The buyer needs these to build the EIP-712 domain it signs against. + extra: { name: USDC_EIP712.name, version: USDC_EIP712.version }, + }; + + // -- step 1: no payment attached -> challenge with 402 ------------------ + const paymentHeader = c.req.header('X-PAYMENT'); + if (!paymentHeader) { + return c.json( + { x402Version: X402_VERSION, error: 'X-PAYMENT header is required', accepts: [requirements] }, + 402, + ); + } + + // -- step 2: decode + verify + settle through the facilitator ----------- + let paymentPayload; + try { + paymentPayload = b64ToJson(paymentHeader); + } catch { + return c.json({ x402Version: X402_VERSION, error: 'malformed X-PAYMENT header', accepts: [requirements] }, 402); + } + + const facilitatorBody = { x402Version: X402_VERSION, paymentPayload, paymentRequirements: requirements }; + const tFacilitator = performance.now(); // measured so the buyer can report verify+settle overhead + const verify = await postJson(`${facilitatorUrl}/verify`, facilitatorBody); + if (!verify?.isValid) { + return c.json( + { x402Version: X402_VERSION, error: `payment verification failed: ${verify?.invalidReason ?? 'unknown'}`, accepts: [requirements] }, + 402, + ); + } + + const settle = await postJson(`${facilitatorUrl}/settle`, facilitatorBody); + const facilitatorMs = performance.now() - tFacilitator; + if (!settle?.success) { + return c.json( + { x402Version: X402_VERSION, error: `payment settlement failed: ${settle?.errorReason ?? 'unknown'}`, accepts: [requirements] }, + 402, + ); + } + + // -- step 3: the settled txHash is a single-use credential -------------- + if (consumed.has(settle.transaction)) { + // "NO CREDENTIAL, NO RUN" — a credential spends exactly once. + return c.json({ error: 'replayed payment: credential already consumed', txHash: settle.transaction }, 409); + } + consumed.add(settle.transaction); + + // -- step 4: run the resource with the receipt in scope ----------------- + c.set('x402', { + txHash: settle.transaction, + payer: settle.payer ?? paymentPayload?.payload?.authorization?.from, + amountUsdc: atomicToUsdc(requirements.maxAmountRequired), + requirements, + }); + await next(); + + // Buyer-visible settlement receipt (standard x402 response header) plus a + // spike-only timing header so the buyer can attribute verify+settle cost. + c.res.headers.set( + 'X-PAYMENT-RESPONSE', + jsonToB64({ success: true, transaction: settle.transaction, network: NETWORK, payer: settle.payer }), + ); + c.res.headers.set('X-402-FACILITATOR-MS', facilitatorMs.toFixed(1)); + }; +} + +async function postJson(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json().catch(() => null); +} From 73cb5ca5c16c7d697d8caadd471f5161003bdcce Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 11 Jul 2026 13:43:00 -0400 Subject: [PATCH 004/165] Reframe corpus: compensation/attribution layer as terminal product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/plans/2026-07-11-reframe-and-pi-wielder-design.md §3 in full, verified by three adversarial checkers (29 findings, 18 actionable, all fixed): - CONTEXT.md: Carta-for-AI-work-artifacts identity; Wielder = wallet; Collar added to the glossary; ambiguities updated - PRD: "Phase 1 is the terminal state by design"; demand-side wedge (inference installs the rail, skills ride it; Pi-Wielder spike named as validation); GPT Store precedent; skill-depreciation analysis; kill-criterion 7 (platform-native marketplace); Wielder-side trust-minimized renames; gate-leak SLO + key-custody design; Merkle beneficiary-verifiable meter as Phase-1 requirement; "when Sam quits" vesting/clawback inputs; education deferred - ADRs: status headers on all; ADR-0001 amended (artifact scarcity is not economic scarcity); ADR-0002/0006 amended to match; new ADR-0007 (closed-mode layer terminal) and ADR-0008 (Wielder is a wallet) Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 46 +++- docs/PRD.md | 257 +++++++++++------- ...0001-skills-as-hosted-invocation-rights.md | 23 ++ ...nize-skills-as-programmable-ip-on-story.md | 5 + docs/adr/0003-payment-gated-execution.md | 12 +- docs/adr/0004-compete-on-moats-not-secrecy.md | 2 + .../0005-two-leg-cross-chain-settlement.md | 12 +- .../0006-phased-rollout-closed-modes-first.md | 18 ++ ...-compensation-layer-as-terminal-product.md | 81 ++++++ docs/adr/0008-the-wielder-is-a-wallet.md | 72 +++++ 10 files changed, 411 insertions(+), 117 deletions(-) create mode 100644 docs/adr/0007-closed-mode-compensation-layer-as-terminal-product.md create mode 100644 docs/adr/0008-the-wielder-is-a-wallet.md diff --git a/CONTEXT.md b/CONTEXT.md index 44cd961..53dbfa8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,9 +1,13 @@ # Skill Asset Protocol (working name) -A protocol for tokenizing and monetizing the long-term value of authored **Skills** -(natively-digital work artifacts such as Claude Code skills, plugins, and agents) so -their **Creators** retain a durable economic claim each time others use them — instead -of handing the value over once and capturing none of the upside. +A compensation, attribution, and metering layer for authored **Skills** +(natively-digital work artifacts such as Claude Code skills, plugins, and agents) — +"Carta for AI work artifacts." **Creators** retain a durable, co-holdable economic +claim each time others use their Skill, instead of handing the value over once under +work-for-hire and capturing none of the upside. The closed modes (**Intra-org**; +**Education**, currently deferred pending the fork-economics re-run — ADR-0007) are +the product; the open **Marketplace** is one *future* mode — +underwritten optionality, not the identity (ADR-0007). ## Language @@ -18,8 +22,12 @@ _Avoid_: author, developer, owner (ownership becomes ambiguous once tokenized) **Wielder**: The party that invokes a **Skill** to perform productive work. Need not be the party -that ultimately profits from that work. -_Avoid_: user (overloaded), operator, consumer +that ultimately profits from that work. Any client that can pay is a Wielder: a wallet, +not a specific harness — Claude Code, Pi, a cron job, and curl are all Wielders. The +paying wallet may be funded by the **Beneficiary** — who wields and who funds the +payment remain distinct roles. The entire Wielder-side protocol footprint is "answer +HTTP 402 with a signed payment and retry" (ADR-0008). +_Avoid_: user (overloaded), operator, consumer, harness (a Wielder may be one, but need not be) **Beneficiary**: The party that realizes downstream economic value from a **Wielder**'s use of a **Skill** @@ -32,13 +40,21 @@ The open venue where **Invocation-rights** to **Skills** are offered to any **Wi privately; the Marketplace is the public one. _Avoid_: store, exchange, platform +**Collar**: +The hosted gate wrapped around a **Skill**'s execution: the sole platform API-key holder, +the x402 resource server (it answers unpaid requests with HTTP 402 and checks the settled +payment as the **Execution credential**), and the off-chain meter that records every +**Invocation** and credits the royalty split. The single trusted component in the system — +the **Wielder** stays a thin payer precisely because the Collar absorbs the trust. +_Avoid_: gateway, proxy, middleware (each names a mechanism; the Collar is the trust boundary) + ## Archetypes The three distribution modes are all the same Creator → Wielder → Beneficiary shape, collapsed differently: - **Marketplace**: independent **Creator** → any **Wielder** (Wielder and Beneficiary are the same person) -- **Intra-org**: employee-**Creator** and employer **co-hold the Royalty claim** (replacing work-for-hire's 100/0); shared upside comes from *external* **Wielders** invoking the Skill across the **Marketplace** +- **Intra-org**: employee-**Creator** and employer **co-hold the Royalty claim** (replacing work-for-hire's 100/0); shared upside comes from *external* **Wielders** invoking the Skill — routed privately/directly in the closed modes, or via the open **Marketplace** if that mode ever ships - **Education**: institution-**Creator** authors a base **Skill**; the student forks it into a **Derivative** they own (becoming a **Creator** themselves) and wields it at work; the employer-**Beneficiary** pays per **Invocation**, which splits to the student's **Derivative** and flows through to the school ## Relationships @@ -57,7 +73,9 @@ _Avoid_: call, request, run (use Invocation for the billable unit specifically) **Invocation-right**: What a Wielder acquires — permission to trigger **Invocations** of a **Skill**, priced -per use. This (not the artifact) is the thing that gets tokenized and traded. +per use and exercised by paying (ADR-0008). It (not the artifact) is what gets +tokenized — and traded only in the open **Marketplace** mode (underwritten +optionality, ADR-0007). _Avoid_: license (too broad), ownership **Derivative**: @@ -100,5 +118,13 @@ _Avoid_: token (overloaded), license, key deferred trust boundary per ADR-0004; TEE is the tabled future hardening. The Skill IS hidden from the **Wielder**, who sees only the output. - Settlement is **eventually-consistent**, not atomic: the per-**Invocation** payment gate and the - on-chain royalty settlement are decoupled legs (ADR-0005). The gate is trust-minimized; batched - settlement is an "auditable accumulator" (ADR-0003 Update). + on-chain royalty settlement are decoupled legs (ADR-0005). The gate is **Wielder-side** + trust-minimized — the trusted component is the **Collar**; batched settlement is an "auditable + accumulator" (ADR-0003 Update). +- Marketplace-vs-closed-mode identity — RESOLVED (2026-07): the closed modes ARE the product — a + compensation/attribution layer that must be independently viable even if the open **Marketplace** + never ships. The Marketplace is underwritten optionality, not the identity (ADR-0007). +- What fraction of the existing skill supply is **host-compatible** — i.e. loses little of its value + when executed hosted, behind a **Collar**, rather than inside the caller's own context — is + UNKNOWN and unmeasured. Context-bound skills (plausibly most Claude Code skills) may lose most of + their value when hosted. Flagged, not resolved. diff --git a/docs/PRD.md b/docs/PRD.md index 18249d7..76d9501 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,8 +1,8 @@ # Skill Asset Protocol -- Product Requirements & Feasibility-Grounded Plan -*A settlement protocol that lets the Creators of authored AI Skills keep a durable, monetizable claim on every future use -- instead of handing the value over once.* +*A compensation, attribution, and metering layer for authored AI Skills — "Carta for AI work artifacts" — that lets a Creator keep a durable, monetizable claim on every future use instead of handing the value over once.* -> **Status:** Derived from a structured design interview and an adversarial feasibility validation; see `CONTEXT.md`, `docs/adr/`, `docs/feasibility/report.md`, `docs/feasibility/findings.json`. +> **Status:** Derived from a structured design interview and an adversarial feasibility validation; **reframed 2026-07-11** after an adversarial premise review found the original framing inverted — the marketplace royalty story it led with was its weakest claim, and the closed-mode compensation kernel it treated as a stepping stone was its strongest asset. The change spec is `docs/plans/2026-07-11-reframe-and-pi-wielder-design.md`; the decisions are recorded in ADR-0007 and ADR-0008. See also `CONTEXT.md`, `docs/adr/`, `docs/feasibility/report.md`, `docs/feasibility/findings.json`. > **Feasibility verdict:** GO-WITH-CAVEATS. Every component is real and live on mid-2026 APIs; the idealized atomic loop does not compose and is rebuilt here as a decoupled, two-leg, batched, eventually-consistent settlement. **Confidence is high on every technical component and *medium* on regulatory**, and several load-bearing facts remain unmeasured -- see [What we have NOT validated](#what-we-have-not-validated). ## Table of Contents @@ -24,17 +24,21 @@ ## Executive Summary -**Knowledge workers who build reusable AI Skills hand them to employers once and capture none of the recurring value — automating themselves out of the very upside they created. This protocol lets a Creator keep a durable, monetizable claim on every future use of a Skill instead of surrendering it for a one-time wage.** An authored Skill (a Claude Code skill, plugin, or agent definition) is a natively-digital asset whose value can be retained rather than transferred: it runs behind a hosted runtime that meters each use and hands the user only the *output*, never the Skill itself, while on-chain royalty tokens route a share of that metered revenue back to the Creator and through to any ancestors it was forked from. +**Knowledge workers who build reusable AI Skills hand them to employers once and capture none of the recurring value — automating themselves out of the very upside they created. This product is a compensation, attribution, and metering layer for authored AI Skills — "Carta for AI work artifacts" — not a skill marketplace.** It lets a Creator keep a durable, monetizable claim on every future use of a Skill instead of surrendering it for a one-time wage. An authored Skill (a Claude Code skill, plugin, or agent definition) is a natively-digital asset whose value can be retained rather than transferred: it runs behind a hosted runtime that meters each use and hands the user only the *output*, never the Skill itself, while co-holdable royalty claims route a share of that metered revenue back to the Creator and through to any ancestors it was forked from. -What we are building is a settlement protocol around the **Invocation** — the billable, metered use of a Skill. A **Creator** authors a Skill; a **Wielder** invokes it for productive work; a **Beneficiary** (typically the employer) pays per Invocation and profits from the output. Each payment gates execution (no payment, no run) and accrues a royalty that splits among the holders of the Skill's **Royalty claim** — which can be co-held (e.g. employee plus employer) — and flows through the **Derivative** ancestry to every Skill a fork was built on. The Skill is registered as an IP Asset on Story Protocol with declared lineage, so provenance and the derivative-royalty graph are on-chain and auditable. +Mechanically, what we are building is a settlement protocol around the **Invocation** — the billable, metered use of a Skill. A **Creator** authors a Skill; a **Wielder** invokes it for productive work; a **Beneficiary** (typically the employer) pays per Invocation and profits from the output. Each payment gates execution (no payment, no run) and accrues a royalty that splits among the holders of the Skill's **Royalty claim** — which can be co-held (e.g. employee plus employer) — and flows through the **Derivative** ancestry to every Skill a fork was built on. The Skill is registered as an IP Asset on Story Protocol with declared lineage, so provenance and the derivative-royalty graph are on-chain and auditable. -The protocol ships in **three modes**: **Marketplace** (independent Creator to any Wielder), **Intra-org** (employee-Creator and employer co-hold the claim, replacing work-for-hire 100/0, sharing upside from external invocations), and **Education** (a school authors a base Skill, a student forks it into a Derivative they own and wields it at work, the employer pays per Invocation, royalties split to the student and flow through to the school). +The protocol has **three modes, and they are not equals**: **Intra-org** (employee-Creator and employer co-hold the claim, replacing work-for-hire's 100/0 — this is the product), **Education** (a school authors a base Skill, a student forks it into a Derivative they own — **demoted to deferred**, because provenance cannot distinguish a fork from a graduate re-authoring with what the class taught, a nearly-free bypass; see [Product & User Experience](#product--user-experience)), and **Marketplace** (independent Creator to any Wielder — **underwritten optionality, not the base case**). The closed modes are not a beachhead for the marketplace; they are the terminal product. Real-world precedent for the closed-mode instrument already operates at scale: Germany's ArbEG statutory inventor remuneration, corporate patent-award programs, and university tech-transfer revenue splits all show institutions durably sharing invention upside with individual employees — the missing piece is the metering rail (research, 2026-07). + +**Phase 1 is the terminal state by design.** The off-chain signed ledger, the co-held non-transferable claims, and Story provenance must be independently viable — a complete compensation and attribution product — even if Phase 2 (on-chain settlement) and Phase 3 (tradeable claims) never ship. Platforms can ship a skill marketplace in a quarter; they will never ship 409A-structured co-held compensation instruments. That asymmetry, not clone-resistance, is the moat (ADR-0007). + +**Demand-side wedge: inference payments install the rail; skills ride it.** BYO-wallet, per-call payment for model inference over x402 is live today via third-party gateways — Router402, tx402.ai, BlockRun's ClawRouter — and x402 itself ran ~75.4M transactions / ~$24.2M volume in the 30 days to 2026-07-11 (x402.org; research, 2026-07). That payment leg is commoditizing; the differentiator is the **unified meter** — one wallet whose ledger attributes inference calls AND Skill invocations, with royalty splits on the skill leg. Accordingly, **the Wielder is a wallet, not a harness** (ADR-0008): the entire Wielder-side protocol footprint is *answer HTTP 402 with a signed USDC payment and retry* — no Story SDK, no token custody, no chain reads client-side. The Pi-Wielder spike (`spikes/pi-wielder/`) validates this end-to-end and measures the payment overhead. **Honest architecture:** the vision validates as real but does *not* compose into one atomic action. It is a **decoupled two-leg settlement**. Leg 1 is the gate — x402 settles gasless USDC on Base (chainId 8453); the replay-proof txHash is the single-use execution credential, checked off-chain so the Wielder pays first and the agent runs after. Leg 2 is royalties — an off-chain worker batches payments, bridges and swaps USDC into WIP on Story (chainId 1514), calls `payRoyaltyOnBehalf`, and runs a keeper that pull-claims for ancestors. Royalty flow-through is therefore **eventually-consistent and claimable**, not atomic per call. Do not attempt to make x402 settle directly to Story (wrong chain, wrong token, wrong primitive — see `docs/feasibility/report.md` §4.1). -**Go-to-market is closed modes first.** Intra-org and Education face the least cloning pressure, are on-platform by construction, and can keep Royalty claims **non-transferable**, which is the best available route to staying outside securities law — though not a guaranteed safe harbor, and one counsel must bless before Phase 1 ships. Phase 0 ships provenance immediately (register Skills as Story IP Assets and Derivatives); Phase 1 adds the gate, run, and an off-chain metered ledger; Phase 2 adds on-chain batched royalty settlement; Phase 3 opens the tradeable Marketplace — but only permissioned (ATS, transfer agent, exemption, KYC), with securities counsel engaged first, because tradeable claims are securities under Howey. +**Go-to-market is Intra-org first, pitched as compensation and retention — not royalty upside.** Intra-org faces the least cloning pressure, is on-platform by construction, and can keep Royalty claims **non-transferable**, which is the best available route to staying outside securities law — though not a guaranteed safe harbor, and counsel must draft the actual instrument before Phase 1 ships. Education is deferred pending a re-run of the fork-economics spike whose alternative branch is "re-author with class knowledge ≈ free." Phase 0 ships provenance immediately (register Skills as Story IP Assets and Derivatives); Phase 1 adds the gate, run, and an off-chain metered ledger — **and Phase 1 is the terminal state by design**. Phase 2 (on-chain batched royalty settlement) and Phase 3 (the tradeable Marketplace — permissioned only: ATS, transfer agent, exemption, KYC, securities counsel engaged first, because tradeable claims are securities under Howey) are underwritten optionality, exercised on evidence, never load-bearing. -**The single most important strategic risk is off-platform behavioral cloning, and it sits below the chain.** Because the Wielder receives the output, and for most Skills the output *is* the value, a high-volume Skill is the cheapest thing to clone — its own paid input/output pairs are a roughly 30x-cheaper distillation set (`report.md` §5; ADR-0004 Update). Watermarking is a forensic tripwire, not a moat. The protocol defends the *marketplace* (liquidity, provenance, declared-derivative royalties), not an individual breakout Skill. The recommended response — **price below amortized clone cost, out-evolve via live updates, bind value to live tool and data access** — is load-bearing but rests on an *unmeasured* assumption: no source quantifies how fast a Skill must change to keep a distilled clone economically stale (`report.md` §7.7). We launch in the closed modes, where the pressure is lowest, partly to buy time to measure this. +**Two strategic risks sit above everything else, and both sit below the chain.** First, off-platform behavioral cloning: because the Wielder receives the output, and for most Skills the output *is* the value, a high-volume Skill is the cheapest thing to clone — its own paid input/output pairs are a roughly 30x-cheaper distillation set (`report.md` §5; ADR-0004 Update). Watermarking is a forensic tripwire, not a moat. The protocol defends the *marketplace* (liquidity, provenance, declared-derivative royalties), not an individual breakout Skill. The recommended response — **price below amortized clone cost, out-evolve via live updates, bind value to live tool and data access** — is load-bearing but rests on an *unmeasured* assumption: no source quantifies how fast a Skill must change to keep a distilled clone economically stale (`report.md` §7.7). The reframe blunts this risk without dismissing it: in the Intra-org compensation frame the employer already possesses the Skill, so clone-resistance is not what the product sells there — attribution and compensation are. Second, a **platform-native skill marketplace** (Anthropic, OpenAI, GitHub): the GPT Store precedent (OpenAI, Jan 2024) proves platforms do ship native skill-adjacent marketplaces — and also that builder monetization demand was weak even with zero-friction distribution (research, 2026-07). Both readings are now priced in: kill-criterion 7 monitors the announcement, and the plan survives it because the compensation product is not what a platform marketplace replaces. --- @@ -53,25 +57,58 @@ A necessary honesty caveat, because it bounds the whole market: the protocol def The three modes are one shape — Creator → Wielder → Beneficiary — collapsed three ways (CONTEXT.md). Each has a distinct payer and a distinct pain. **1. Independent Creators → any Wielder (Marketplace mode).** -A skilled author writes a genuinely valuable Skill and has exactly two bad options today: sell the file once (buyer copies it infinitely, author earns nothing further) or self-host it as a SaaS (build billing, auth, infra, and a metering pipeline from scratch). Their pain is the absence of a per-use monetization rail for a copyable artifact. *Who pays:* the **Wielder is the Beneficiary** — the person invoking the Skill profits directly from its output and pays per Invocation. This is the highest-pain, highest-incentive-to-clone, and most regulated segment (tradeable claims are securities), so it is sequenced **last** (ADR-0006). +A skilled author writes a genuinely valuable Skill and has exactly two bad options today: sell the file once (buyer copies it infinitely, author earns nothing further) or self-host it as a SaaS (build billing, auth, infra, and a metering pipeline from scratch). Their pain is the absence of a per-use monetization rail for a copyable artifact. *Who pays:* the **Wielder is the Beneficiary** — the person invoking the Skill profits directly from its output and pays per Invocation. This is the highest-pain, highest-incentive-to-clone, and most regulated segment (tradeable claims are securities), so it is sequenced **last** (ADR-0006) — and, per the 2026-07-11 reframe, underwritten as **optionality rather than the base case** (ADR-0007): the GPT Store precedent (below) is the base rate against betting the company on it. **2. Employee-Creators + Employers (Intra-org mode).** -The employee who builds a Skill at work today gets work-for-hire's 100/0 split and watches their leverage evaporate. The employer's reciprocal pain is retention and incentive: their best people have every reason to hoard expertise, build Skills on the side, or leave. The mode replaces 100/0 with a **co-held Royalty claim** — employee and employer both hold a fractional, co-holdable claim on the Skill, and both earn from *external* invocations across the marketplace. The prototype makes this concrete (`recon`: Sam 50% + MegaCorp 50%, both paid on an external invocation). *Who pays:* an **external Wielder/Beneficiary** outside the org; the internal split is the alignment mechanism, not the revenue source. Claims here are kept **non-transferable**, the best available route to keeping the mode outside securities law (ADR-0006) — a reason it ships before the marketplace. **Assumption flagged:** whether mid-size employers will actually restructure work-for-hire IP terms into a co-held claim is *unvalidated* (R12, rated Medium-High); the design-partner LOIs that would prove it do not yet exist and are a Phase-0/1 gate (see [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria)). +The employee who builds a Skill at work today gets work-for-hire's 100/0 split and watches their leverage evaporate. The employer's reciprocal pain is retention and incentive: their best people have every reason to hoard expertise, build Skills on the side, or leave. The mode replaces 100/0 with a **co-held Royalty claim** — employee and employer both hold a fractional, co-holdable claim on the Skill, and both earn from *external* invocations (direct/private in Phase 1; via the open Marketplace only if that optionality is ever exercised). The prototype makes this concrete (`recon`: Sam 50% + MegaCorp 50%, both paid on an external invocation). *Who pays:* an **external Wielder/Beneficiary** outside the org; the internal split is the alignment mechanism, not the revenue source. Claims here are kept **non-transferable**, the best available route to keeping the mode outside securities law (ADR-0006) — a reason it ships before the marketplace. **Assumption flagged:** whether mid-size employers will actually restructure work-for-hire IP terms into a co-held claim is *unvalidated* (R12, rated Medium-High); the design-partner LOIs that would prove it do not yet exist and are a Phase-0/1 gate (see [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria)). **3. Schools + Students + Employers (Education mode).** -A school teaches a capability but captures none of its graduates' downstream economic value; a student graduates with debt and a credential but no durable, owned, income-producing asset; an employer wants the capability but has no clean per-use rail to pay for it. The mode threads all three: the **school authors a base Skill**, the **student forks it into a Derivative they own** (becoming a Creator), and wields that Derivative at work. *Who pays:* the **employer is the Beneficiary** and pays per Invocation; royalties **split to the student's Derivative and flow through to the school** as an ancestor. The prototype's `biofin` (forks `finmod` at 30% inherit) shows the split. The asset the student graduates with is a real Royalty claim. **Open economics question:** the prototype flags a "fork-killing threshold" — at high ancestor-royalty rates forking stops being worth it; the right inherit-bps is *unresolved* and is the key economics experiment in `prototype/README.md` (verdict currently **TBD**). See [Economic Design](#economic-design). +A school teaches a capability but captures none of its graduates' downstream economic value; a student graduates with debt and a credential but no durable, owned, income-producing asset; an employer wants the capability but has no clean per-use rail to pay for it. The mode threads all three: the **school authors a base Skill**, the **student forks it into a Derivative they own** (becoming a Creator), and wields that Derivative at work. *Who pays:* the **employer is the Beneficiary** and pays per Invocation; royalties **split to the student's Derivative and flow through to the school** as an ancestor. The prototype's `biofin` (forks `finmod` at 30% inherit) shows the split. The asset the student graduates with is a real Royalty claim. **Open economics question:** the prototype flags a "fork-killing threshold" — at high ancestor-royalty rates forking stops being worth it; the right inherit-bps is *unresolved* and is the key economics experiment in `prototype/README.md` (verdict currently **TBD**). See [Economic Design](#economic-design). **Status (2026-07-11): this mode is demoted to deferred.** The premise review surfaced a bypass the economics question understated: provenance cannot distinguish "forked the school's Skill" from "re-authored an equivalent Skill using what the class taught" — which is nearly free and pays the school nothing. Education un-defers only if a re-run of the fork-economics spike, with "re-author with class knowledge ≈ free" as an explicit alternative branch, still shows a real forking incentive (see [Product & User Experience](#product--user-experience)). ### Why now Four independent enablers crossed the line into "real and usable" in mid-2026 — the protocol was not buildable even a year earlier: -- **Managed agents let the collar hide the Skill from the Wielder — because the collar holds the sole API key, not because the platform keeps it secret.** Anthropic Managed Agents (CMA, beta `managed-agents-2026-04-01`) persist the system prompt and skills on the Agent object; the session *output* stream (`agent.message`/`thinking`/`tool_*`/`span.*`) never carries them, so a party who only sees output gets only the output (`report.md` §3 step 2; `findings.json` `/verdict/confirmed[1]`). **This is hiding from the WIELDER only.** The hiding property is a function of *who holds the platform API key*, not a platform secrecy feature: `GET /v1/agents` echoes the full system prompt verbatim to the key-holder, and Anthropic processes the Skill in plaintext (no TEE). It holds solely because the collar is the sole key-holder and never proxies `GET /v1/agents` (accepted per ADR-0004; CONTEXT.md lines 98–101). What this *enables* is monetizing a copyable plaintext artifact per-use — not because it is secret, but because the Wielder pays for outputs without receiving the artifact. -- **x402 is mature.** Coinbase's HTTP-402 stablecoin rail became a Linux Foundation / x402 Foundation standard (donated 2026-04-02), reached ~165M transactions and ~$50M cumulative volume by April 2026, and is backed by Stripe (x402 USDC-on-Base live Feb 2026), AWS Bedrock AgentCore, and Cloudflare (`findings.json` `/verdict/confirmed[2]`). It gives a clean per-invocation gate whose settled `txHash` is a replay-proof, single-use execution credential ("no credential, no run"). +- **Managed agents let the Collar hide the Skill from the Wielder — because the Collar holds the sole API key, not because the platform keeps it secret.** Anthropic Managed Agents (CMA, beta `managed-agents-2026-04-01`) persist the system prompt and skills on the Agent object; the session *output* stream (`agent.message`/`thinking`/`tool_*`/`span.*`) never carries them, so a party who only sees output gets only the output (`report.md` §3 step 2; `findings.json` `/verdict/confirmed[1]`). **This is hiding from the WIELDER only.** The hiding property is a function of *who holds the platform API key*, not a platform secrecy feature: `GET /v1/agents` echoes the full system prompt verbatim to the key-holder, and Anthropic processes the Skill in plaintext (no TEE). It holds solely because the Collar is the sole key-holder and never proxies `GET /v1/agents` (accepted per ADR-0004; CONTEXT.md, Flagged ambiguities). What this *enables* is monetizing a copyable plaintext artifact per-use — not because it is secret, but because the Wielder pays for outputs without receiving the artifact. **A supply-side caveat the reframe adds:** Claude Code skills are context-bound — they assume a harness, local files, and tool access — so hosting strips most of the value for some unknown fraction of the skill supply. **The fraction that is host-compatible is unmeasured** (see [What we have NOT validated](#what-we-have-not-validated)). +- **x402 is mature.** Coinbase's HTTP-402 stablecoin rail became a Linux Foundation / x402 Foundation standard (donated 2026-04-02), reached ~165M transactions and ~$50M cumulative volume by April 2026, and is backed by Stripe (x402 USDC-on-Base live Feb 2026), AWS Bedrock AgentCore, and Cloudflare (`findings.json` `/verdict/confirmed[2]`). The x402 Foundation launched April 2026 with 20+ members — Google, Visa, Stripe, AWS, Mastercard, Circle, Microsoft, Shopify, Amex — and trailing-30-day volume as of 2026-07-11 was ~75.4M transactions / ~$24.2M (x402.org; research, 2026-07). It gives a clean per-invocation gate whose settled `txHash` is a replay-proof, single-use execution credential ("no credential, no run"). - **Story Protocol is live.** chainId 1514, SDK v1.4.4 — registers a Skill as an IP Asset with declared Derivative ancestry, mints co-holdable fractional royalty tokens, and supports derivative flow-through. The on-chain provenance and royalty graph the whole model depends on exists today (Phase 0 ships immediately, ADR-0006). - **The supply side exploded.** Authored AI Skills (Claude Code skills, plugins, agent definitions) went from niche to a fast-growing artifact class — meaning there is now a large, growing population of Creators with real expertise to encode and a real fear of giving it away. One honest caveat on timing: the literal vision — a single payment that both gates execution and atomically pays ancestors on Story — does **not** compose, because x402 settles USDC on Base while Story royalties need WIP on Story, with no facilitator bridging the two. It must be rebuilt as a decoupled, two-leg, batched, eventually-consistent settlement (ADR-0005). The components are real; the wiring is harder than the pitch. +### The demand-side wedge: inference payments install the rail; skills ride it + +Added 2026-07-11. The hardest demand-side question — "why would a Wielder set up a wallet just to pay for Skills?" — now has an empirical answer: **they already are, for inference.** BYO-wallet, per-call payment for model APIs over x402 is live and multi-vendor (research, 2026-07): + +- **Router402** (router402.xyz) — an OpenRouter-like x402 gateway; USDC on Base, ~200ms settlement via Flashblocks. +- **tx402.ai** — 20+ EU-hosted open models behind x402. +- **BlockRun ClawRouter** (github.com/BlockRunAI/ClawRouter) — a local proxy for OpenClaw that auto-generates a wallet and pays per LLM call; 55+ models, Base + Solana, mainnet-only. +- **Cloudflare's Monetization Gateway** opened its waitlist 2026-07-01. + +Two honest qualifications. First, **every live gateway is a third-party reseller — no first-party OpenAI or Anthropic x402 support exists** (research, 2026-07); the rail is real, but its first-party endorsement is not. Second, this leg is **commoditizing**: multiple resellers, near-zero switching costs, and Cloudflare arriving. We do not compete on payments. The differentiator is the **unified meter**: one wallet whose ledger attributes inference calls AND Skill invocations, with royalty splits on the skill leg — the thing a pure payment reseller has no reason to build. + +The strategic consequence is ADR-0008: **the Wielder is a wallet, not a harness.** The entire Wielder-side protocol footprint is *answer HTTP 402 with a signed USDC payment and retry* — no Story SDK, no token custody, no chain reads client-side. The invocation-right is exercised by paying, not held. Every wallet the inference gateways install is a Skill-ready Wielder the moment a Collar exists to answer it. + +**The validation experiment for this wedge is the Pi-Wielder spike (`spikes/pi-wielder/`).** Pi (earendil-works/pi, formerly badlogic/pi-mono) is a ~70k-star MIT TypeScript coding agent — multi-provider, custom `baseUrl` support, mid-session model switching, and **no wallet or x402 support shipped** (research, 2026-07): the ideal thin-payer testbed. The spike has Pi pay per-call for inference AND invoke one hosted Skill behind a mock Collar, producing one unified attributed session ledger, and it measures x402 payment overhead per call (sign → verify → settle p50/p95) plus end-to-end skill-invocation latency. Testnet-only, zero real money. **Discipline note: the spike proves the thin-payer client by construction and measures overhead; it is *not* demand evidence** — willingness-to-pay remains unvalidated (R12; see [What we have NOT validated](#what-we-have-not-validated)). + +### The GPT Store precedent (the base rate to respect) + +Added 2026-07-11 — the likeliest killer, previously dismissed rather than analyzed. OpenAI launched the **GPT Store** in January 2024: a platform-native, skill-adjacent marketplace with a piloted builder revenue-share, zero-friction distribution, and a massive installed user base. Monetization outcomes for builders were weak despite all of that (research, 2026-07). Two readings, and both bind this plan: + +1. **Platforms DO ship native skill-adjacent marketplaces, and fast.** Treat a platform-native skill marketplace from Anthropic, OpenAI, or GitHub as when-not-if, not a tail risk. This is now **kill-criterion 7** (with a concrete monitoring trigger) and risk **R17**. +2. **Demand for monetizing packaged prompts was weak even with free distribution.** This is the base rate *against* the open-marketplace bet itself, and a core reason the Marketplace is underwritten as optionality while the closed-mode compensation product carries the thesis. + +The counter-positioning, stated once and reused throughout: **neutrality** (a platform's marketplace only serves its own platform), **cross-platform provenance**, a **securities-barred mechanism** (platforms will not operate a permissioned securities venue for skill claims), and **compensation products platforms won't build** — no platform is going to ship 409A-structured co-held deferred-comp instruments between an employee and their employer. + +### Skill depreciation (unmeasured, load-bearing) + +Added 2026-07-11. A durable claim is only as durable as the Skill's economic life, and **a Skill's economic life is bounded by the model release cadence**: each frontier release absorbs capability that previously required an authored Skill. A claim on a Skill whose value the next model ships natively decays to zero on the platform's clock, not the holder's. This forces a segmentation of the supply: + +- **Model-absorbable Skills** — packaged prompting and workflow cleverness. Value decays toward zero at each release; a royalty claim on one is a wasting asset with an unknown, possibly short, half-life. +- **Live-access-bound Skills** — value bound to live tool/data access, fresh private context, and ongoing maintenance. These depreciate on the org's clock and are the only class worth underwriting durable claims on — conveniently, also the anti-clone posture ADR-0004 already recommends. + +**Skill half-life is unmeasured** — no source quantifies how fast authored Skills depreciate against the release cadence (see [What we have NOT validated](#what-we-have-not-validated)). Until measured, claim terms (duration, vesting, pricing) must be designed assuming depreciation, and the intra-org pitch must not promise perpetuity. + ### Directional market framing (ranges and logic, no false precision) We deliberately avoid a fabricated TAM. The honest framing is a chain of logic plus a few hard, sourced numbers: @@ -90,11 +127,11 @@ The defensible claim is therefore *not* a dollar figure. It is: there is a real, |---|---|---|---|---|---| | Marketplace | Independent author | Any buyer | The Wielder (Wielder = Beneficiary) | All to Creator (less protocol fee) | Tradeable → security (Phase 3) | | Intra-org | Employee | External party | An external Wielder/Beneficiary | Split to co-held employee + employer | Non-transferable (best route outside securities law) | -| Education | School (base) + student (Derivative) | The student, at work | The student's employer | Split to student's Derivative, flows through to school | Non-transferable (best route outside securities law) | +| Education *(deferred 2026-07-11)* | School (base) + student (Derivative) | The student, at work | The student's employer | Split to student's Derivative, flows through to school | Non-transferable (best route outside securities law) | ### Co-authorship (open design question) -CONTEXT.md (line 46) states that a Skill has **exactly one Creator at origin** and explicitly flags **co-authorship as an open question**. v1 assumes single-Creator origin everywhere in this document. Teams that build a Skill jointly are not yet modeled: the co-held *Royalty claim* mechanic (employee+employer, student+school) can express multiple *holders* of a claim, but the question of multiple *originating authors* — how they split the claim at registration, how disputes resolve, and how a multi-author Skill declares ancestry — is unresolved and must be designed before Marketplace (where independent multi-Creator teams are likeliest). Until then, multi-author teams should designate a single registering Creator and split via the co-hold mechanic as a stopgap. +CONTEXT.md (Relationships) states that a Skill has **exactly one Creator at origin** and explicitly flags **co-authorship as an open question**. v1 assumes single-Creator origin everywhere in this document. Teams that build a Skill jointly are not yet modeled: the co-held *Royalty claim* mechanic (employee+employer, student+school) can express multiple *holders* of a claim, but the question of multiple *originating authors* — how they split the claim at registration, how disputes resolve, and how a multi-author Skill declares ancestry — is unresolved and must be designed before Marketplace (where independent multi-Creator teams are likeliest). Until then, multi-author teams should designate a single registering Creator and split via the co-hold mechanic as a stopgap. --- @@ -116,13 +153,13 @@ Concrete cast, taken from the seeded prototype (`prototype/settlement-engine.mjs Before splitting by mode, here is the loop in plain terms, because all three modes are the same steps collapsed differently (CONTEXT.md, Archetypes). 1. **Register.** A Creator signs in, names a Skill, uploads the `SKILL.md` / plugin / agent definition, sets a per-Invocation price, and — if it's a fork — picks the parent and a flow-through rate. One click writes a Story IP Asset (and, for a fork, a declared Derivative with on-chain ancestry). The Creator sees a provenance card: "Registered. IP Asset 0x… · lineage: you → State U." This is **Phase 0** and ships first (ADR-0006); it is the soundest step in the whole loop (`report.md` §3, step 1). -2. **Host.** The artifact is pushed into a hosted runtime (an Anthropic Managed Agent) behind the collar, which is the sole API-key holder. From this moment the Skill is **never handed over to the Wielder** (ADR-0001). The Creator gets an Invocation endpoint and a price; the artifact is now a metered service, not a file. (**Phase 1.**) -3. **Invoke + pay.** A Wielder hits the endpoint. It returns `402 Payment Required`. The Wielder's client signs a gasless USDC payment on Base (x402, EIP-3009 `transferWithAuthorization`); the settled `txHash` is the single-use execution credential. The collar checks it off-chain — *no credential, no run* (ADR-0003) — and only then starts the agent. (**Phase 1.**) -4. **Receive output.** Payment settles in well under a second; the collar releases the credential, runs the agent asynchronously, and streams back **only the output** (ADR-0005 Leg 1). The Wielder never sees the system prompt or skill body; the session stream simply doesn't carry them (`report.md` §3, step 2). To the Wielder it feels exactly like calling any paid API. (**Phase 1.**) +2. **Host.** The artifact is pushed into a hosted runtime (an Anthropic Managed Agent) behind the Collar, which is the sole API-key holder. From this moment the Skill is **never handed over to the Wielder** (ADR-0001). The Creator gets an Invocation endpoint and a price; the artifact is now a metered service, not a file. (**Phase 1.**) +3. **Invoke + pay.** A Wielder hits the endpoint. It returns `402 Payment Required`. The Wielder's client signs a gasless USDC payment on Base (x402, EIP-3009 `transferWithAuthorization`); the settled `txHash` is the single-use execution credential. The Collar checks it off-chain — *no credential, no run* (ADR-0003) — and only then starts the agent. (**Phase 1.**) +4. **Receive output.** Payment settles in well under a second; the Collar releases the credential, runs the agent asynchronously, and streams back **only the output** (ADR-0005 Leg 1). The Wielder never sees the system prompt or skill body; the session stream simply doesn't carry them (`report.md` §3, step 2). To the Wielder it feels exactly like calling any paid API. (**Phase 1.**) 5. **Accrue.** Each payment lands in an auditable off-chain ledger as a credited royalty balance, split by the claim table and flowed through the Derivative ancestry minus a protocol fee (the engine's `distribute()`, default 2.5%). (**Phase 1 — this is the off-chain ledger; nothing is on-chain yet.**) 6. **Claim / settle.** **In Phase 1, "claim" is a withdrawal against the off-chain auditable ledger, not an on-chain settlement.** On-chain settlement is **deferred to Phase 2**: a batched worker bridges/swaps USDC→WIP and calls `payRoyaltyOnBehalf` on Story, and a permissionless keeper auto-claims for ancestors so their balance never silently piles up (ADR-0005 Leg 2). Under the hood **flow-through is pull, not push**; the keeper hides the mechanic, but the *lag* between credited and on-chain-settled is real and surfaced (see caveat below). -The honest caveat the UX must absorb: settlement is **eventually-consistent, not atomic** (ADR-0005). Step 3 (the gate) is instant and trust-minimized; steps 5–6 (the on-chain split, Phase 2) are batched and lag. The keeper hides the *pull-not-push* mechanic, but it does **not** hide the *gap* between "credited" and "settled on-chain" — that gap is a real reconciliation surface that the protocol monitors as an operational alarm ("royalties credited vs. claimed"), and sophisticated buyers will notice it (R14). The product's job is to make the lag *tolerable and legible*: show a credited balance immediately, settle on-chain quietly in the background, and surface the on-chain batch as a reconciliation receipt — not to pretend the gap does not exist. +The honest caveat the UX must absorb: settlement is **eventually-consistent, not atomic** (ADR-0005). Step 3 (the gate) is instant and Wielder-side trust-minimized; steps 5–6 (the on-chain split, Phase 2) are batched and lag. The keeper hides the *pull-not-push* mechanic, but it does **not** hide the *gap* between "credited" and "settled on-chain" — that gap is a real reconciliation surface that the protocol monitors as an operational alarm ("royalties credited vs. claimed"), and sophisticated buyers will notice it (R14). The product's job is to make the lag *tolerable and legible*: show a credited balance immediately, settle on-chain quietly in the background, and surface the on-chain batch as a reconciliation receipt — not to pretend the gap does not exist. ### Mode (a) — Marketplace: independent Creator → any Wielder @@ -136,7 +173,7 @@ The honest framing for Marketplace, surfaced in the product's own positioning ra ### Mode (b) — Intra-org: employee-Creator and employer co-hold the claim -This replaces work-for-hire's 100/0 split. Sam builds `ledger-recon` on company time; instead of MegaCorp owning it outright, **Sam and MegaCorp co-hold the royalty claim 50/50**, and the upside they share comes from *external* Wielders invoking the Skill across the Marketplace (CONTEXT.md; ADR-0006). Cast: **Sam** (employee-Creator), **MegaCorp** (employer co-holder), **OtherCo** (external Wielder/Beneficiary). +This replaces work-for-hire's 100/0 split. Sam builds `ledger-recon` on company time; instead of MegaCorp owning it outright, **Sam and MegaCorp co-hold the royalty claim 50/50**, and the upside they share comes from *external* Wielders invoking the Skill (via the Collar's metered endpoint; the open Marketplace, if ever exercised, only widens this — CONTEXT.md; ADR-0006; ADR-0007). Cast: **Sam** (employee-Creator), **MegaCorp** (employer co-holder), **OtherCo** (external Wielder/Beneficiary). - **Sam (Creator).** Registers `ledger-recon` at $20 inside the org workspace, and instead of the default 100%-to-creator claim, sets a co-held claim: `sam:5000, megacorp:5000` (the engine's `setRoyalty`, enforcing the split summing to 100%). Sam's view: "Your share 50% · Employer 50% · this is your durable claim, not a one-time deliverable." The emotional payload of the whole project lives here. - **MegaCorp (employer / co-holder).** Sees the other half of the same claim plus governance: who can fork internal Skills, which are exposed externally, audit logs of every external Invocation. MegaCorp's incentive flips from "lock the work away" to "expose it so external invocations pay us both." @@ -144,12 +181,14 @@ This replaces work-for-hire's 100/0 split. Sam builds `ledger-recon` on company - **Settlement Sam/MegaCorp see.** On OtherCo's $20 Invocation: $0.50 fee, $19.50 net, split $9.75 / $9.75. **Both** balances tick up on every external call (the prototype's `invoke recon otherco` makes this concrete). Two product consequences that shape the UX heavily: -- **Claims are non-transferable here** (ADR-0006). Sam cannot sell his half on an open market; it's a contractual / deferred-comp right. This is deliberate — non-transferability is the best available route to keeping the claim outside securities law (no ATS, transfer agent, or KYC allow-list needed). The UX reflects it: a "claim" panel but **no "sell"/"list" button**. (Note the tax wrinkle: a co-held royalty claim structured as deferred comp carries 409A / constructive-receipt implications — see [Regulatory & Compliance Strategy](#regulatory--compliance-strategy) — that counsel must structure before launch.) +- **Claims are non-transferable here** (ADR-0006). Sam cannot sell his half on an open market; it's a contractual / deferred-comp right. This is deliberate — non-transferability is the best available route to keeping the claim outside securities law (no ATS, transfer agent, or KYC allow-list needed). The UX reflects it: a "claim" panel but **no "sell"/"list" button**. (Note the tax wrinkle: a co-held royalty claim structured as deferred comp carries 409A / constructive-receipt implications — see [Regulatory & Compliance Strategy](#regulatory--compliance-strategy) — that counsel must resolve *in the drafted instrument* before launch.) And the instrument must answer the question this walkthrough previously skipped: **what happens when Sam quits.** Vesting, clawback, and termination treatment of the co-held claim — resignation, for-cause termination, Skill retirement — are first-class design inputs of the instrument, not details to negotiate at separation (added 2026-07-11; previously undesigned anywhere in the corpus). - A self-hosted sandbox is offered for regulated internal data, because the managed runtime is not ZDR/HIPAA-eligible and the host still sees the Skill in plaintext (`report.md` §3 step 2; ADR-0004). The product surfaces this as a data-residency toggle. Closed population, aligned incentives, lowest cloning pressure — which is why Intra-org ships **first** in Phase 1 (ADR-0006). -### Mode (c) — Education: school authors a base Skill, student forks and owns the Derivative +### Mode (c) — Education: school authors a base Skill, student forks and owns the Derivative — **DEFERRED (2026-07-11)** + +> **Status: demoted from launch mode to deferred.** The premise review surfaced a free bypass this mode cannot currently answer: provenance can prove a student *forked the school's Skill*, but it cannot distinguish that from a graduate who **re-authors an equivalent Skill using what the class taught** — which is nearly free and pays the school nothing. The fork-economics spike (`prototype/README.md` experiment 5) must therefore be **re-run with "re-author with class knowledge ≈ free" as an explicit alternative branch**; Education un-defers only if that spike still shows a real forking incentive. Two restructurings of the school's claim to evaluate inside that spike: **(1) living, school-maintained content** — the base Skill stays worth forking because the school keeps evolving it, and re-authoring forfeits the update stream; or **(2) direct school→employer licensing** — drop the student-fork hop entirely and license the school's Skill to employers per Invocation. The walkthrough below is preserved as a design record, not a launch plan. The richest journey, because it spans years and produces an asset the student literally graduates with (CONTEXT.md example dialogue). Cast: **State U** (school-Creator of the base Skill), **Mia** (student → graduate, who forks it into a Derivative she owns and wields at work), **BioCorp** (Mia's employer, the Beneficiary who pays per Invocation). @@ -158,14 +197,14 @@ The richest journey, because it spans years and produces an asset the student li - **BioCorp (Wielder + Beneficiary).** Mia uses her own Skill at work; **BioCorp pays per Invocation** because BioCorp is the Beneficiary. Same flow: `402` → pay $25 USDC → output. BioCorp sees a normal paid endpoint plus a provenance trail (Mia → State U) it can audit. - **Settlement everyone sees (at the seeded 30% flow-through, which is *illustrative, not a recommended default* — see Economic Design).** On BioCorp's $25 Invocation, the engine produces: $0.625 protocol fee; of the $24.375 net, 30% ($7.31) flows up to State U as the ancestor, and Mia keeps $17.06. So **Mia ~$17, State U ~$7, per call** — the "does that split feel right?" experiment from `prototype/README.md`. Mia's dashboard: "+$17.06 (your claim)." State U's: "+$7.31 (flow-through from biotech-fin-modeling)." -Education is closed-population like Intra-org, so its claims are likewise **non-transferable** and ship in Phase 1 after Intra-org (ADR-0006). The open design question the UX must eventually answer is the **fork-killing threshold** (`prototype/README.md`, experiment 5, verdict **TBD**): if State U sets flow-through too high, forking stops being worth it for students and the lineage never grows. The product should expose flow-through as a *visible, tuned* dial with a live "what the student keeps vs. what flows up" preview at fork time. +Education is closed-population like Intra-org, so its claims would likewise be **non-transferable** — but it no longer ships in Phase 1 (2026-07-11 demotion, above). The open design question is now sharper than the **fork-killing threshold** (`prototype/README.md`, experiment 5, verdict **TBD** — if State U sets flow-through too high, forking stops being worth it and the lineage never grows): the re-run spike must price the *fork vs. re-author* decision, where the re-author branch costs the student nearly nothing and the school gets nothing. If Education un-defers, the product should expose flow-through as a *visible, tuned* dial with a live "what the student keeps vs. what flows up" preview at fork time. ### The UX gap: a non-transferable closed-mode claim vs. a (later) tradeable Marketplace claim This is the single biggest experiential fork in the product, and it maps directly to a legal boundary (ADR-0006, CONTEXT.md). -**Closed-mode claim (Intra-org, Education — Phase 1, ships first).** The claim is a **balance and an entitlement, not an instrument.** What the holder sees and does: -- A claim panel: your %, co-holders or ancestors, accrued balance, claim/withdraw (**in Phase 1, withdraw against the off-chain ledger; on-chain settlement arrives in Phase 2**). +**Closed-mode claim (Intra-org — Phase 1, ships first; Education, if it un-defers, uses the same claim shape).** The claim is a **balance and an entitlement, not an instrument.** What the holder sees and does: +- A claim panel: your %, co-holders or ancestors, accrued balance, claim/withdraw (**in Phase 1, withdraw against the off-chain ledger; on-chain settlement arrives in Phase 2**). One honest caveat on "withdraw anytime": on-demand withdrawal for an employee's co-held claim is close to the constructive-receipt fact pattern under §409A, so the drafted instrument may force scheduled payment events instead — the withdrawal UX is *subject to the counsel-drafted instrument*, not a settled promise (see Regulatory). - **No "sell," no "list," no order book, no price chart.** Transfer is structurally disabled. - Onboarding is just login — **no KYC, no accreditation gate, no securities disclosures**, on the basis that a non-transferable revenue-share right is the best available route outside Howey. (This is *medium-confidence*, not settled law — see Regulatory.) - The mental model offered to Sam and Mia: "this is your durable, personal claim on future use — like deferred comp or a license fee that keeps paying," explicitly *not* "a tradeable security." @@ -181,34 +220,34 @@ The crisp product line: **the derivative-royalty *mechanic* (fork, flow-through, ## Technical Architecture -This is the **honest v1 design**: not the idealized loop where one payment both gates execution and atomically pays ancestors on Story, but the buildable one the feasibility validation supports — a **decoupled, two-leg, batched, eventually-consistent** settlement, fronted by a single trusted component (the *collar*) and anchored to two chains that each do the one job they are good at. Every claim below is grounded in `docs/feasibility/report.md` and the ADRs it produced (`docs/adr/0001`–`0006`). +This is the **honest v1 design**: not the idealized loop where one payment both gates execution and atomically pays ancestors on Story, but the buildable one the feasibility validation supports — a **decoupled, two-leg, batched, eventually-consistent** settlement, fronted by a single trusted component (the *Collar*) and anchored to two chains that each do the one job they are good at. Every claim below is grounded in `docs/feasibility/report.md` and the ADRs it produced (`docs/adr/0001`–`0006`). The single load-bearing fact that shapes everything: **the gating payment cannot also be the on-chain royalty payment.** x402 settles a USDC transfer to an EOA on Base (eip155:8453); Story's Royalty Module needs `payRoyaltyOnBehalf(ipId, amount, token)` — a *contract call*, denominated in *WIP*, on *eip155:1514* — and no x402 facilitator supports chain 1514. Four independent mismatches (wrong chain, wrong token, wrong primitive, wrong EIP-3009 variant — `transferWithAuthorization` vs `receiveWithAuthorization`), each fatal on its own (`report.md` §4.1; ADR-0005). So: **two legs, always.** -### The collar +### The Collar -The collar is the one piece you build and the one piece everyone must trust. It is, simultaneously: +The Collar is the one piece you build and the one piece everyone must trust. It is, simultaneously: -- **The sole Anthropic API-key holder.** This is forced, not chosen. There is no native "no credential, no run" gate on CMA, OpenAI, or Google; CMA's only mid-run control (`permission_policy: always_ask`) is a tool-approval gate keyed to the key-holder and cannot stop a turn from starting or spending tokens. Anthropic keys are workspace-scoped (full / read-only only), so you cannot mint a Wielder a key that permits `sessions.create` but forbids `GET /v1/agents`. The collar must therefore be the only key-holder and the entire gate (`report.md` §3 step 4; ADR-0003). **A direct economic consequence: the collar pays Anthropic for every run** — see [who bears the inference cost](#who-bears-the-inference-cost) in Economic Design. +- **The sole Anthropic API-key holder.** This is forced, not chosen. There is no native "no credential, no run" gate on CMA, OpenAI, or Google; CMA's only mid-run control (`permission_policy: always_ask`) is a tool-approval gate keyed to the key-holder and cannot stop a turn from starting or spending tokens. Anthropic keys are workspace-scoped (full / read-only only), so you cannot mint a Wielder a key that permits `sessions.create` but forbids `GET /v1/agents`. The Collar must therefore be the only key-holder and the entire gate (`report.md` §3 step 4; ADR-0003). **A direct economic consequence: the Collar pays Anthropic for every run** — see [who bears the inference cost](#who-bears-the-inference-cost) in Economic Design. - **The x402 resource server.** It issues the `402`, verifies and settles the payment, and treats the settled txHash as the execution credential (Leg 1). - **The off-chain meter / ledger.** It accrues each settled invocation into an auditable ledger, then drives the batched Story settlement (Leg 2). -Because the collar is the sole key-holder and never proxies `GET /v1/agents`, the **Skill stays hidden from the Wielder (not from the host)** — the session output stream never carries the system prompt or skill bodies, only the output (ADR-0001; `report.md` §3 step 2). The Skill is **not** hidden from Anthropic, which processes it in plaintext (no TEE); that is an accepted v1 trust boundary, not a solved problem (ADR-0004; CONTEXT.md lines 98–101). +Because the Collar is the sole key-holder and never proxies `GET /v1/agents`, the **Skill stays hidden from the Wielder (not from the host)** — the session output stream never carries the system prompt or skill bodies, only the output (ADR-0001; `report.md` §3 step 2). The Skill is **not** hidden from Anthropic, which processes it in plaintext (no TEE); that is an accepted v1 trust boundary, not a solved problem (ADR-0004; CONTEXT.md, Flagged ambiguities) — though it now carries a committed hardening trigger rather than an open-ended deferral (see [Trust model](#trust-model)). ### Leg 1 — synchronous gate (Base, sub-second) -The per-invocation gate. Trust-minimized: no payment, no run, enforced on every call. +The per-invocation gate. **Wielder-side trust-minimized**: no payment, no run, enforced on every call. (The qualifier is deliberate and used throughout this document: the guarantee runs *from the Wielder's side* — the Wielder cannot invoke without paying. Parties on the other side of the meter — Creators, ancestors, co-holders — still trust the Collar; see [Trust model](#trust-model).) -1. Wielder requests an invocation; collar returns **`HTTP 402` + `PAYMENT-REQUIRED`**. +1. Wielder requests an invocation; Collar returns **`HTTP 402` + `PAYMENT-REQUIRED`**. 2. Wielder signs **EIP-3009 `transferWithAuthorization`** (gasless USDC on Base, bytes32 nonce). 3. Collar calls the facilitator's **`/verify`** then **`/settle`**; gets back **`PAYMENT-RESPONSE {success, txHash, networkId}`**. -4. The settled **`txHash` is the single-use, replay-proof execution credential**, checked **off-chain** by the collar. Do **not** mint a per-call on-chain Story License Token — each mint drags an IP→WIP wrap + ERC-20 approve + block latency for a credential gating a call worth cents (`report.md` §3 step 3c; ADR-0005). +4. The settled **`txHash` is the single-use, replay-proof execution credential**, checked **off-chain** by the Collar. Do **not** mint a per-call on-chain Story License Token — each mint drags an IP→WIP wrap + ERC-20 approve + block latency for a credential gating a call worth cents (`report.md` §3 step 3c; ADR-0005). -**Critical ordering: settle first, then run async.** The collar settles the payment (sub-second), releases the credential, and *then* invokes the agent asynchronously and streams the output. It must **never hold the x402 handshake open across the agent run** — x402's `maxTimeoutSeconds` is ~60s, shorter than a cold `sessions.create` plus the agent loop (ADR-0005; `report.md` Phase 1). The collar owns its own nonce/txHash bookkeeping; x402 does **not** safely resubmit after a settled-but-failed run, so accept-payment-but-fail-to-run is handled by the collar via refund / reputation, not by the protocol (`report.md` §3 step 3a). **Because x402/USDC payments are irreversible and have no chargebacks, a failed run after settled payment is a refund the collar must fund out of treasury** — see the reliability/refund target in [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria). +**Critical ordering: settle first, then run async.** The Collar settles the payment (sub-second), releases the credential, and *then* invokes the agent asynchronously and streams the output. It must **never hold the x402 handshake open across the agent run** — x402's `maxTimeoutSeconds` is ~60s, shorter than a cold `sessions.create` plus the agent loop (ADR-0005; `report.md` Phase 1). The Collar owns its own nonce/txHash bookkeeping; x402 does **not** safely resubmit after a settled-but-failed run, so accept-payment-but-fail-to-run is handled by the Collar via refund / reputation, not by the protocol (`report.md` §3 step 3a). **Because x402/USDC payments are irreversible and have no chargebacks, a failed run after settled payment is a refund the Collar must fund out of treasury** — see the reliability/refund target in [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria). ### Leg 2 — asynchronous settlement (Story, batched, eventually-consistent) — **Phase 2, deferred** -The royalty path. This is where "the fork pays its ancestors" actually happens — **in accounting terms, credited and claimable on demand, not as one atomic on-chain action per invocation** (ADR-0002 Update; CONTEXT.md line 49). **This entire leg is Phase-2 work and is NOT in v1**; in v1 (Phase 1) royalties are credited and withdrawn against the off-chain ledger only. +The royalty path. This is where "the fork pays its ancestors" actually happens — **in accounting terms, credited and claimable on demand, not as one atomic on-chain action per invocation** (ADR-0002 Update; CONTEXT.md, Relationships). **This entire leg is Phase-2 work and is NOT in v1**; in v1 (Phase 1) royalties are credited and withdrawn against the off-chain ledger only. An off-chain **settlement worker**: @@ -216,7 +255,7 @@ An off-chain **settlement worker**: 2. **Bridges/swaps USDC(Base) → WIP(Story)** via a licensed bridge (e.g. Stargate / Across / deBridge). WIP is the only mainnet-whitelisted royalty currency, and it is volatile and illiquid ($IP down ~97.5% from ATH), so fold the conversion into the bridge and fast-claim to limit FX exposure (`report.md` §4.1, risk register; ADR-0002 Update). 3. Calls **`payRoyaltyOnBehalf(ipId, amount, token)`** on Story's Royalty Module. 4. Runs a **permissionless keeper that auto-claims `claimAllRevenue`** for ancestors. Flow-through on Story is **PULL, not PUSH** — ancestors accrue a claimable balance and must claim it, or (e.g.) the school's revenue silently piles up. The keeper claims on their behalf (`report.md` §3 step 5; ADR-0002 Update; ADR-0005). -5. **Publishes the settlement batch on-chain** for reconciliation. +5. **Publishes the settlement batch on-chain** for reconciliation — **including the Merkle root of the invocation log the batch settles**, so any Creator or ancestor can verify their invocations are included against their own receipts (the beneficiary-verifiable meter; see [Trust model](#trust-model)). A bridge stall leaves "execution done, ancestor unpaid" — a reconciliation surface handled by the auditable ledger plus retry, with conservative batching windows (ADR-0005 Consequences). **The real USDC(Base)→WIP(Story) bridge cost and confirmation time are unmeasured** (`report.md` §7.4) and must be spiked before Phase 2. @@ -226,35 +265,40 @@ All four are real, live, and audited on Story Protocol (chainId 1514, SDK v1.4.4 - **IP Asset** — each registered Skill, created via `mintAndRegisterIpAssetWithPilTerms`. One real on-chain tx (Phase 0 ships this immediately). - **PIL terms** — the license, via `PILFlavor.commercialRemix()`. Forks register as **declared Derivatives** with on-chain ancestry. -- **Fractional, co-holdable royalty tokens** — 100 per IP vault (1% granularity floor). Co-holdable by employee + employer (intra-org) or student + school (education) (ADR-0002; CONTEXT.md lines 41–42). +- **Fractional, co-holdable royalty tokens** — 100 per IP vault (1% granularity floor). Co-holdable by employee + employer (intra-org) or student + school (education) (ADR-0002; CONTEXT.md, Relationships). - **Flow-through** — **LAP** (Liquid Absolute Percentage, whole-ancestry) or **LRP** (Liquid Relative Percentage, direct-parents). Composable up the Derivative graph (`report.md` §3 step 5). ### Trust model Two halves, with different guarantees — and being honest about the seam between them is the point (ADR-0003 Update; `report.md` §4.3): -- **The gate (Leg 1) stays trust-minimized.** No credential, no run, enforced per call. Usage fraud on the money path is structurally impossible at the gate. -- **Settlement (Leg 2) degrades to an auditable accumulator.** The off-chain batched meter that decides which invocations settle *is* the trusted oracle ADR-0003 originally rejected. The "fraud structurally impossible" guarantee held only for the synchronous, single-chain, atomic case that the cross-chain gap forces us to abandon. The collar could in principle mis-report or skim. Mitigations: signed, auditable invocation logs; on-chain published settlement batches for reconciliation; refund + reputation for accept-but-fail; **TEE tabled as the eventual structural fix**, not a v1 feature (ADR-0003 Update; ADR-0004). +- **The gate (Leg 1) is Wielder-side trust-minimized.** No credential, no run, enforced per call: the *Wielder* cannot obtain a run without a settled payment, and needs no trust in anyone to know their payment preceded execution. The qualifier is load-bearing. From every other seat — Creator, ancestor, co-holder — "no payment, no run" is enforced by the Collar's own code and its custody of the sole platform key, which makes it an **operational guarantee backed by a key-custody/rotation design, not an architectural property**. This is why the gate-leak invariant is stated as an **ops SLO** (see [Reliability / refund targets](#reliability--refund-targets)), not asserted as impossible-by-construction. +- **Settlement (Leg 2) degrades to an auditable accumulator.** The off-chain batched meter that decides which invocations settle *is* the trusted oracle ADR-0003 originally rejected. The "fraud structurally impossible" guarantee held only for the synchronous, single-chain, atomic case that the cross-chain gap forces us to abandon. The Collar could in principle mis-report or skim. Mitigations: the beneficiary-verifiable meter (below); on-chain published settlement batches for reconciliation; refund + reputation for accept-but-fail; TEE on a committed trigger (below). -This trust re-centralization is the price of the cross-chain reality. The collar is sole key-holder + in-flight custodian + off-chain meter — and the custody is itself the worst regulatory exposure (likely FinCEN MSB), so v1 minimizes it: keep the collar a non-custodial pass-through riding a hosted facilitator (Coinbase x402, which carries its own KYT/OFAC/licensing) and push in-flight value to a licensed bridge partner — look like a merchant on Stripe, not a money transmitter (`report.md` §4.3, risk register; ADR-0006). The full analysis is in [Regulatory & Compliance Strategy](#regulatory--compliance-strategy). +Two hardening commitments, added 2026-07-11, that convert "trust us" into "audit us": + +- **The beneficiary-verifiable meter is a Phase-1 design requirement, not a Phase-2 nicety.** The off-chain invocation log is **Merkle-committed**: every settled invocation is a leaf, and the running root is signed and distributed to all claim-holders on a published cadence from Phase 1. From Phase 2, **the root is published on-chain with every Leg-2 settlement batch**, so Creators and ancestors *audit* the meter against their own invocation receipts rather than trusting the Collar's totals. This does not remove the accumulator's trust assumption — the Collar can still omit invocations it never logs — but it makes under-reporting *detectable by the injured party* whenever the Wielder or Beneficiary side holds a receipt, which is the strongest verifiability available short of a TEE. +- **A committed TEE trigger replaces the open-ended deferral.** Previous drafts tabled TEE / confidential execution three separate times. The commitment now: **any Skill whose credited revenue exceeds a threshold (set concretely by the Phase-1 unit-economics spike) is moved to confidential execution**, closing both the host-plaintext exposure and the mis-metering hole for exactly the Skills where the stakes have become real. Below the threshold, the plaintext-host trust boundary stands as accepted (ADR-0004). + +This trust re-centralization is the price of the cross-chain reality. The Collar is sole key-holder + in-flight custodian + off-chain meter — and the custody is itself the worst regulatory exposure (likely FinCEN MSB), so v1 minimizes it: keep the Collar a non-custodial pass-through riding a hosted facilitator (Coinbase x402, which carries its own KYT/OFAC/licensing) and push in-flight value to a licensed bridge partner — look like a merchant on Stripe, not a money transmitter (`report.md` §4.3, risk register; ADR-0006). The full analysis is in [Regulatory & Compliance Strategy](#regulatory--compliance-strategy). ### What is v1 vs. deferred | Capability | Phase | Status in v1 | |---|---|---| | Register Skills as Story IP Assets + declared Derivatives (provenance) | **Phase 0** | **v1** — ships immediately, soundest step | -| Collar as sole key-holder + x402 resource server; Leg 1 gate; off-chain metered ledger; closed modes (intra-org → education); claims **non-transferable** | **Phase 1** | **v1** | +| Collar as sole key-holder + x402 resource server; Leg 1 gate; off-chain metered ledger; closed modes (Intra-org; Education deferred pending the fork-economics re-run — see [Product & User Experience](#product--user-experience)); claims **non-transferable** | **Phase 1** | **v1** | | On-chain batched royalty settlement (Leg 2: bridge/swap → `payRoyaltyOnBehalf` → keeper `claimAllRevenue`) | **Phase 2** | **Deferred** to Phase 2 (the shared-loop "claim" is an off-chain-ledger withdrawal in v1) | -| TEE / confidential execution (hide Skill from host; structural fix for the accumulator) | later | **Deferred — tabled** | +| TEE / confidential execution (hide Skill from host; structural fix for the accumulator) | revenue-triggered | **Deferred with a committed trigger** — any Skill whose credited revenue exceeds the Phase-1-set threshold moves to confidential execution (no longer open-endedly tabled) | | Open Marketplace + **tradeable** royalty claims (securities: ERC-3643 allow-list + Reg D 506(c)/Reg A+/CF + registered ATS like Securitize + transfer agent + KYC) | **Phase 3** | **Deferred** — counsel-gated; closed-mode claims stay non-transferable | The derivative-royalty **mechanic** is available throughout; only the **tradeability** of a claim triggers the securities stack (ADR-0006). Chain split is permanent: **Story for IP / royalty / provenance, Base for the x402 gate.** Do not try to make x402 settle directly to Story (`report.md` §6; ADR-0005). ### End-to-end sequence (education mode; steps 7–12 are Phase-2-deferred) -1. **Register (Phase 0, Story).** School registers its base Skill as an IP Asset (`mintAndRegisterIpAssetWithPilTerms` + `commercialRemix`). Student forks it into a **Derivative** they own — a declared derivative with on-chain ancestry (ADR-0002; CONTEXT.md line 42). +1. **Register (Phase 0, Story).** School registers its base Skill as an IP Asset (`mintAndRegisterIpAssetWithPilTerms` + `commercialRemix`). Student forks it into a **Derivative** they own — a declared derivative with on-chain ancestry (ADR-0002; CONTEXT.md, Archetypes). *(Education itself is deferred as a launch mode — this sequence remains the reference walkthrough because it exercises every primitive, including flow-through.)* 2. **Host (Phase 1).** Collar holds the only Anthropic key; the Derivative lives on the persisted Agent object; the employer can invoke it but cannot read it. -3. **Gate — 402 (Phase 1).** Employer requests an Invocation; collar returns `402` + `PAYMENT-REQUIRED`. +3. **Gate — 402 (Phase 1).** Employer requests an Invocation; Collar returns `402` + `PAYMENT-REQUIRED`. 4. **Pay (Phase 1, Leg 1, Base).** Employer signs EIP-3009 `transferWithAuthorization` (gasless USDC on Base). 5. **Settle + credential (Phase 1).** Collar runs `/verify` + `/settle`; the settled **txHash is the single-use execution credential**, checked off-chain. 6. **Run async (Phase 1).** Collar releases the handshake, then invokes the agent asynchronously and **streams only the output** to the employer — never the Skill, never holding the 402 open across the run. @@ -272,9 +316,9 @@ Steps 3–7 are synchronous-gate + off-chain meter (Phase 1, v1). Steps 8–12 a - **Collar** — sole Anthropic API-key holder; x402 resource server; off-chain meter/ledger; the entire execution gate and the single trusted component. Pays Anthropic per run. - **x402 facilitator** (Coinbase CDP or equivalent) — `/verify` + `/settle` for Leg-1 USDC-on-Base payments; carries its own KYT/OFAC/licensing. - **Managed agent runtime** (Anthropic CMA, beta; runtime abstracted so it is swappable) — Wielder-hidden hosted execution; returns output only. Not ZDR/HIPAA-eligible — use a self-hosted sandbox for regulated intra-org/education data. -- **Off-chain ledger** — signed, auditable invocation log; source of truth for batching and reconciliation. +- **Off-chain ledger** — signed, auditable, **Merkle-committed** invocation log (the beneficiary-verifiable meter, a Phase-1 design requirement): each invocation is a leaf; the signed root is distributed to claim-holders from Phase 1 and published on-chain with every Leg-2 batch from Phase 2. Source of truth for batching and reconciliation. Carries both asset classes — skill royalties and inference pass-through — as entries in the same ledger (see [Economic Design](#economic-design)). - **Settlement worker (Phase 2)** — batches accrued payments; drives the bridge/swap and `payRoyaltyOnBehalf`. -- **Licensed bridge/swap (Phase 2)** (e.g. Stargate / Across / deBridge) — USDC(Base) → WIP(Story); absorbs in-flight custody to keep the collar a pass-through. +- **Licensed bridge/swap (Phase 2)** (e.g. Stargate / Across / deBridge) — USDC(Base) → WIP(Story); absorbs in-flight custody to keep the Collar a pass-through. - **Permissionless keeper (Phase 2)** — auto-claims `claimAllRevenue` for ancestors. - **Story Protocol contracts** — IP Asset registry, PIL/License module, Royalty Module (LAP/LRP), fractional co-holdable royalty tokens. - **(Phase 3 only) Securities stack** — ERC-3643 allow-list token, registered ATS, transfer agent, KYC — required only when royalty claims become tradeable. @@ -289,18 +333,22 @@ The prototype at `prototype/settlement-engine.mjs` is the reference implementati ### Who bears the inference cost -This is a load-bearing economic fact that must be stated plainly: **the collar is the sole Anthropic API-key holder, so the collar — not the Wielder — pays Anthropic for every run.** CMA runtime billing is **$0.08 per *active* session-hour, measured to the millisecond** (idle/rescheduling free), **plus standard per-token model costs (ITPM/OTPM) on top** (`findings.json` `/validated[managed-agents]/howItWorks` (c), `/evidence` note: "~$0.027 for a 20-min active run + tokens"). A short invocation's runtime cost is small, but the **per-token model cost is the dominant and variable COGS**, and for a verbose Skill it can be non-trivial relative to a $2–25 price. +This is a load-bearing economic fact that must be stated plainly: **the Collar is the sole Anthropic API-key holder, so the Collar — not the Wielder — pays Anthropic for every run.** CMA runtime billing is **$0.08 per *active* session-hour, measured to the millisecond** (idle/rescheduling free), **plus standard per-token model costs (ITPM/OTPM) on top** (`findings.json` `/validated[managed-agents]/howItWorks` (c), `/evidence` note: "~$0.027 for a 20-min active run + tokens"). A short invocation's runtime cost is small, but the **per-token model cost is the dominant and variable COGS**, and for a verbose Skill it can be non-trivial relative to a $2–25 price. The unit economics therefore have **three distinct cost layers the Wielder's USDC must cover**, in order: ``` -Wielder USDC price ≥ inference COGS (CMA runtime + model tokens, paid by collar to Anthropic) +Wielder USDC price ≥ inference COGS (CMA runtime + model tokens, paid by Collar to Anthropic) + settlement cost (Leg-1 facilitator/gas; Leg-2 bridge/swap/gas, amortized) + protocol fee (the 2.5% feeBps skim) + net royalty to Creator + ancestors ``` -The 2.5% protocol fee is computed **on the price**; the inference cost is a **separate COGS the collar funds** and is *not* covered by the fee. **If a Skill is verbose and cheap, the collar can lose money on the run even while the fee looks healthy** — the fee is a percentage of price, but inference is a near-fixed dollar cost per call. The collar must therefore either (a) meter and pass through inference cost as a line item on top of the Creator's price, or (b) enforce a price floor high enough that inference COGS + settlement + fee + royalty all clear. **v1 recommendation: pass inference COGS through transparently** (Wielder pays price + metered inference), so the collar never eats inference and the Creator's price is purely "value of the Skill." The exact pass-through model is an open economic spike (the per-token cost is a function of the Skill's verbosity, which the Creator controls). Until it is modeled against real Skill token profiles, **the business unit economics are undefined** — see [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria). +The 2.5% protocol fee is computed **on the price**; the inference cost is a **separate COGS the Collar funds** and is *not* covered by the fee. **If a Skill is verbose and cheap, the Collar can lose money on the run even while the fee looks healthy** — the fee is a percentage of price, but inference is a near-fixed dollar cost per call. The Collar must therefore either (a) meter and pass through inference cost as a line item on top of the Creator's price, or (b) enforce a price floor high enough that inference COGS + settlement + fee + royalty all clear. **v1 recommendation: pass inference COGS through transparently** (Wielder pays price + metered inference), so the Collar never eats inference and the Creator's price is purely "value of the Skill." The exact pass-through model is an open economic spike (the per-token cost is a function of the Skill's verbosity, which the Creator controls). Until it is modeled against real Skill token profiles, **the business unit economics are undefined** — see [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria). + +### One meter, two asset classes + +Added 2026-07-11. The inference pass-through and the skill royalty are **entries in the same ledger, not two billing systems.** A Wielder session that pays per-call for model inference (the commoditizing x402 gateway leg — see the [demand-side wedge](#the-demand-side-wedge-inference-payments-install-the-rail-skills-ride-it)) and invokes a metered Skill produces one attributed session ledger: `inference $… · skill $… → creator split`. This unified meter is the economic differentiation over pure inference-payment resellers (Router402, tx402.ai, ClawRouter — research, 2026-07), which meter *payment* but attribute *nothing*: no creator splits, no ancestry, no provenance. It also keeps the accounting machinery singular — the same signed, Merkle-committed ledger that credits royalty splits carries the inference pass-through line items, so per-invocation contribution margin (price − inference COGS − settlement − royalty) is computed from one source of truth rather than reconciled across two. The Pi-Wielder spike (`spikes/pi-wielder/`) exercises exactly this shape: one wallet, inference calls plus a skill invocation, one attributed ledger with a creator split. ### Pricing: creator-set, above an enforced floor @@ -345,7 +393,7 @@ fee = price × feeBps/10000 → treasury net = price − fee → distributed to royalty holders + ancestors ``` -2.5% is the prototype's default, not a benchmarked rate. It is *lower* than typical app-store take rates (Apple/Google 15–30%) and near card-processing rates (Stripe ~3%), which signals "infrastructure, not rent-extractor" — but **we have not shown that 2.5% covers protocol opex at closed-mode volumes.** Critically, the protocol fee is computed on *price* and does **not** cover the collar's inference COGS (which is passed through separately, above). At low volumes, 2.5% of a handful of $2–25 invocations may be a **floor that fails to cover opex, not a ceiling to negotiate down from.** The honest stance: **treat 2.5% as a starting default to validate against real opex, not a positioned competitive rate.** The prototype lets you crank it (`fee 4000`) to probe where the model feels extractive; the answer is that every basis point of fee competes with the royalty and the settlement floor for the same net. Whether 2.5% is sustainable is a Phase-1 unit-economics question, not a settled benchmark. +2.5% is the prototype's default, not a benchmarked rate. It is *lower* than typical app-store take rates (Apple/Google 15–30%) and near card-processing rates (Stripe ~3%), which signals "infrastructure, not rent-extractor" — but **we have not shown that 2.5% covers protocol opex at closed-mode volumes.** Critically, the protocol fee is computed on *price* and does **not** cover the Collar's inference COGS (which is passed through separately, above). At low volumes, 2.5% of a handful of $2–25 invocations may be a **floor that fails to cover opex, not a ceiling to negotiate down from.** The honest stance: **treat 2.5% as a starting default to validate against real opex, not a positioned competitive rate.** The prototype lets you crank it (`fee 4000`) to probe where the model feels extractive; the answer is that every basis point of fee competes with the royalty and the settlement floor for the same net. Whether 2.5% is sustainable is a Phase-1 unit-economics question, not a settled benchmark. ### Derivative flow-through and the inherit-bps fork-incentive threshold @@ -396,7 +444,7 @@ Royalties settle on Story in **WIP ($IP), the only mainnet-whitelisted royalty c - **The royalty/ownership asset is Story's native IP-Asset royalty tokens** — co-holdable, fractional, with built-in derivative flow-through. A protocol token would duplicate this with something strictly worse. - **Settlement value is WIP**, already an FX headache to minimize — a second volatile native asset multiplies the FX surface for zero benefit. -A native token would add securities burden (a freely-traded value-accruing token is a textbook Howey security — the exact trap ADR-0006 routes around), bootstrapping burden (liquidity, market-making, distribution), and fills **no mechanism gap** (fee capture works as a USDC skim; Story provides chain security; Base provides the gate). **The moat is the marketplace, provenance graph, and declared-derivative royalties (ADR-0004) — not a token.** If a protocol-level incentive asset is ever warranted, it belongs no earlier than the open Marketplace phase, evaluated then. +A native token would add securities burden (a freely-traded value-accruing token is a textbook Howey security — the exact trap ADR-0006 routes around), bootstrapping burden (liquidity, market-making, distribution), and fills **no mechanism gap** (fee capture works as a USDC skim; Story provides chain security; Base provides the gate). **The moats are provenance, the derivative-royalty graph, and the comp-instrument asymmetry (ADR-0004; ADR-0007) — with marketplace liquidity only if that optionality is exercised — not a token.** If a protocol-level incentive asset is ever warranted, it belongs no earlier than the open Marketplace phase, evaluated then. ### Deferred to Phase 3 @@ -406,7 +454,7 @@ All economics of a **tradeable royalty-claim secondary market** — price discov ## Regulatory & Compliance Strategy -The regulatory verdict is **works-with-caveats at *medium* confidence: no fatal blocker, but the headline feature is the heaviest constraint, and no source squarely analyzes our exact fact pattern.** Two distinct regimes apply and must be analyzed separately — **securities law** (the tradeable royalty claim) and **money-transmission/AML** (the x402 settlement collar) — each with a different trigger and mitigation. The most important framing: **the derivative-royalty *mechanic* is fine; only the *tradeability* of a claim, and only *custody* of in-flight funds, trigger the expensive regimes** — and both are avoidable in the closed modes. **Because regulatory confidence is only medium, treat every "outside securities law" statement below as the best available route, not a settled safe harbor, and get counsel to bless the specific structures BEFORE Phase 1 ships.** +The regulatory verdict is **works-with-caveats at *medium* confidence: no fatal blocker, but the headline feature is the heaviest constraint, and no source squarely analyzes our exact fact pattern.** Two distinct regimes apply and must be analyzed separately — **securities law** (the tradeable royalty claim) and **money-transmission/AML** (the x402 settlement Collar) — each with a different trigger and mitigation. The most important framing: **the derivative-royalty *mechanic* is fine; only the *tradeability* of a claim, and only *custody* of in-flight funds, trigger the expensive regimes** — and both are avoidable in the closed modes. **Because regulatory confidence is only medium, treat every "outside securities law" statement below as the best available route, not a settled safe harbor — and (bar upgraded 2026-07-11) have counsel *draft the actual closed-mode instrument*, not merely bless the concept, BEFORE Phase 1 ships.** ### Securities posture — the heaviest weight, but mode-dependent @@ -414,27 +462,27 @@ A **tradeable, fractional royalty claim is almost certainly a security** under * The 2026 SEC thaw does **not** rescue these tokens. The March 17, 2026 crypto interpretation carves out only Digital Commodities, Digital Collectibles, and Digital Tools (non-transferable membership/credential/ticket), and states that "all devices and instruments that have the economic characteristics of a security are securities regardless of format or label" ([WilmerHale, Mar 2026](https://www.wilmerhale.com/en/insights/client-alerts/20260324-the-secs-new-framework-for-crypto-assets-under-howey)). A transferable token whose purpose is to pay holders a share of recurring revenue fits none of the three buckets. Putting the claim on Story does not change the analysis (Jan 2026 Corp Fin statement: "the technological format … does not alter its legal characterization"). Two cautions: the March interpretation is *non-binding staff guidance*, rescindable without APA rulemaking; and a globally-traded claim is a MiFID II "financial instrument" in the EU, triggering the Prospectus Regulation, licensed intermediaries, and MAR/CSDR. -**The best available route outside securities treatment is non-transferability — but it is not a guaranteed safe harbor, and the confidence is medium.** Be precise about what it does and does not buy you: non-transferability **defeats the secondary-market / "investment" narrative** (no resale, no liquidity event, no speculative buyer). It does **not**, on its own, defeat the **"efforts of others" prong**, which is satisfied by the platform's ongoing metering and evolution regardless of whether the claim can be transferred. The plausible — but **not certain** — conclusion is that a non-transferable, purely contractual revenue right, with no resale and structured as deferred comp / a license fee, sits outside securities treatment. **No regulatory source squarely analyzes the per-invocation, agent-to-agent collar fact pattern**, so this is an extrapolation. Counsel must bless the specific deferred-comp / license-fee structure **before Phase 1 ships** — this is a gate, not mere overhead. +**The best available route outside securities treatment is non-transferability — but it is not a guaranteed safe harbor, and the confidence is medium.** Be precise about what it does and does not buy you: non-transferability **defeats the secondary-market / "investment" narrative** (no resale, no liquidity event, no speculative buyer). It does **not**, on its own, defeat the **"efforts of others" prong**, which is satisfied by the platform's ongoing metering and evolution regardless of whether the claim can be transferred. The plausible — but **not certain** — conclusion is that a non-transferable, purely contractual revenue right, with no resale and structured as deferred comp / a license fee, sits outside securities treatment. **No regulatory source squarely analyzes the per-invocation, agent-to-agent Collar fact pattern**, so this is an extrapolation. Counsel must **draft the actual deferred-comp / license-fee instrument before Phase 1 ships** — this is a gate, not mere overhead, and drafting (unlike blessing) forces the 409A and termination questions into the open (next bullet). -- **Intra-org:** structure the employee+employer co-hold as **non-transferable deferred-compensation / contractual rights.** **But deferred comp is itself heavily regulated:** a co-held royalty claim that pays out over time implicates **IRC §409A** (deferred-compensation rules) and **constructive-receipt** doctrine — i.e., when the employee is taxed, and whether the structure triggers 409A penalties. The PRD's earlier "like deferred comp" framing is correct in spirit but understates that deferred comp is a regulated structure of its own. Counsel must address 409A / constructive receipt for the employee, not just Howey. +- **Intra-org:** structure the employee+employer co-hold as **non-transferable deferred-compensation / contractual rights.** **But deferred comp is itself heavily regulated:** a co-held royalty claim that pays out over time implicates **IRC §409A** (deferred-compensation rules) and **constructive-receipt** doctrine — i.e., when the employee is taxed, and whether the structure triggers 409A penalties. The PRD's earlier "like deferred comp" framing is correct in spirit but understates that deferred comp is a regulated structure of its own. **The bar, upgraded 2026-07-11: counsel must *draft the actual instrument*, not bless the concept.** Drafting forces two things blessing does not. First, it must **resolve the direct conflict between the Phase-1 "withdraw on demand against the off-chain ledger" UX and §409A's fixed-payment-events requirement** — on-demand withdrawal is close to the textbook constructive-receipt fact pattern, so either the withdrawal UX changes (scheduled payment events) or the instrument takes a different form; this cannot be waved through. Second, it must specify **vesting, clawback, and termination — "when Sam quits" — as first-class design inputs**, currently undesigned anywhere in the corpus: what happens to the co-held claim when the employee resigns, is terminated for cause, or the Skill is retired must be terms of the instrument, not ad-hoc negotiation at separation. - **Education:** structure the student's Derivative-owned claim and the school flow-through as **non-transferable contractual license/royalty splits.** It becomes a security the *moment* those claims are tradeable — so do not make them tradeable in v1. ### Money-transmission / MSB — custody is the dividing line -The payment leg is **manageable, but the design of the collar is determinative.** Exposure turns entirely on **custody**: +The payment leg is **manageable, but the design of the Collar is determinative.** Exposure turns entirely on **custody**: - **Non-custodial pass-through riding a hosted facilitator** (Coinbase's x402 facilitator, carrying its own KYT/OFAC/state+federal licensing) → the platform looks like a **merchant using Stripe/PayPal, not a money transmitter, "absent unusual facts"** ([Braumiller/Mondaq, Dec 2025](https://www.braumillerlaw.com/activating-http-402-the-x402-protocol-and-legal-framework-for-internet-native-stablecoin-payments/)). This is the target posture. -- **Custodial collar** — omnibus wallets, fiat↔crypto conversion, or routing third-party payments as a business → "almost certainly" **money transmission requiring FinCEN MSB registration + multi-state money-transmitter licenses + a BSA/AML program** (12–24 months, multi-hundred-thousand-dollar). +- **Custodial Collar** — omnibus wallets, fiat↔crypto conversion, or routing third-party payments as a business → "almost certainly" **money transmission requiring FinCEN MSB registration + multi-state money-transmitter licenses + a BSA/AML program** (12–24 months, multi-hundred-thousand-dollar). -The sharpest tension in the design: **the cross-chain two-leg settlement (ADR-0005) forces *someone* to hold value in-flight** (USDC on Base before WIP on Story), and in-flight holding is the exact MSB fact pattern. Mitigations, in order: (1) minimize custody — settle splits via smart contract / facilitator / issuer; (2) push unavoidable in-flight value to a **licensed bridge / facilitator / BaaS partner**; (3) keep the collar a non-custodial pass-through so the merchant-on-Stripe analogy holds. One clean adjacency: the **execution credential** is a *non-financial access token*, adding no money-transmission exposure **as long as it is never tradeable or redeemable for value** — which the design intends. +The sharpest tension in the design: **the cross-chain two-leg settlement (ADR-0005) forces *someone* to hold value in-flight** (USDC on Base before WIP on Story), and in-flight holding is the exact MSB fact pattern. Mitigations, in order: (1) minimize custody — settle splits via smart contract / facilitator / issuer; (2) push unavoidable in-flight value to a **licensed bridge / facilitator / BaaS partner**; (3) keep the Collar a non-custodial pass-through so the merchant-on-Stripe analogy holds. One clean adjacency: the **execution credential** is a *non-financial access token*, adding no money-transmission exposure **as long as it is never tradeable or redeemable for value** — which the design intends. -**Honest caveat (this is why confidence is medium):** no regulatory source squarely analyzes the per-invocation, agent-to-agent collar fact pattern; the merchant-vs-MSB conclusion is extrapolated from custody doctrine. A custodial collar *would* block launch; a non-custodial collar on a licensed facilitator is **manageable overhead**. Get counsel to bless the specific architecture before Phase 2 moves real money. +**Honest caveat (this is why confidence is medium):** no regulatory source squarely analyzes the per-invocation, agent-to-agent Collar fact pattern; the merchant-vs-MSB conclusion is extrapolated from custody doctrine. A custodial Collar *would* block launch; a non-custodial Collar on a licensed facilitator is **manageable overhead**. Get counsel to bless the specific architecture before Phase 2 moves real money. ### KYC/AML and the GENIUS Act allocation KYC/AML enters through **two doors**: -**Payment side — obligations fall on issuers, not merchants.** The GENIUS Act (enacted July 18, 2025; ~3-year transition to ~July 2028) regulates **payment-stablecoin *issuers***; AML/BSA/CIP/SAR/sanctions obligations land on the **issuer** (and any MSB-classified intermediary) — **not on a payer/payee for merely using a compliant stablecoin like USDC** ([Paul Hastings GENIUS Act guide](https://www.paulhastings.com/insights/crypto-policy-tracker/the-genius-act-a-comprehensive-guide-to-us-stablecoin-regulation)). The FinCEN proposed AML/CFT & sanctions rule (Fed. Reg., Apr 10, 2026) likewise targets *issuers* (exact covered-persons wording not directly verified — treat as such). **Net: a non-custodial collar pushes nearly all payment-side KYC/AML onto Coinbase and the issuer** — the single biggest reason the payment leg is not a blocker. +**Payment side — obligations fall on issuers, not merchants.** The GENIUS Act (enacted July 18, 2025; ~3-year transition to ~July 2028) regulates **payment-stablecoin *issuers***; AML/BSA/CIP/SAR/sanctions obligations land on the **issuer** (and any MSB-classified intermediary) — **not on a payer/payee for merely using a compliant stablecoin like USDC** ([Paul Hastings GENIUS Act guide](https://www.paulhastings.com/insights/crypto-policy-tracker/the-genius-act-a-comprehensive-guide-to-us-stablecoin-regulation)). The FinCEN proposed AML/CFT & sanctions rule (Fed. Reg., Apr 10, 2026) likewise targets *issuers* (exact covered-persons wording not directly verified — treat as such). **Net: a non-custodial Collar pushes nearly all payment-side KYC/AML onto Coinbase and the issuer** — the single biggest reason the payment leg is not a blocker. **Securities side — KYC re-enters because the claims are securities.** Once you reach tradeable claims: Reg D 506(c) requires accredited-investor verification; a transfer agent must hold each holder's real-world name and address (a wallet alone is insufficient); any registered ATS/broker-dealer runs full BSA/AML/CIP. The mechanism is an **allow-list token (ERC-3643 / BlackRock BUIDL model)**: a transfer cannot execute unless the recipient is pre-KYC'd and whitelisted ([Skadden, "Tokenized Securities," Apr 2026](https://www.skadden.com/insights/publications/2026/04/tokenized-securities)). This is why **tradeable claims are *permissioned*, never permissionless.** @@ -443,7 +491,7 @@ KYC/AML enters through **two doors**: | Phase | What ships | Securities | MSB/AML | Launch gate? | |---|---|---|---|---| | **0 — Provenance** | Register Skills as IP Assets + Derivatives | None (no claim sold) | None (no money moves) | **No** — ships immediately | -| **1 — Intra-org → Education** | Gate + run + off-chain ledger; claims **non-transferable** | **Best route outside securities law (medium confidence)** — counsel must bless deferred-comp / license-fee structure incl. 409A **before launch** | Non-custodial collar on hosted facilitator → merchant-not-MSB | **Yes (soft):** counsel sign-off on the structure is a gate, not just overhead | +| **1 — Intra-org (Education deferred)** | Gate + run + off-chain ledger; claims **non-transferable** | **Best route outside securities law (medium confidence)** — counsel must **draft the actual instrument** (deferred-comp / license-fee), resolving on-demand-withdrawal vs. 409A fixed-payment-events and specifying vesting/clawback/termination, **before launch** | Non-custodial Collar on hosted facilitator → merchant-not-MSB | **Yes (soft):** a counsel-drafted instrument is the gate, not just overhead | | **2 — On-chain batched settlement** | Two-leg USDC(Base)→WIP(Story), keeper auto-claim | Still non-transferable | **The real custody decision** — push in-flight value to a licensed partner; counsel blesses | Custody design is the gate | | **3 — Open Marketplace + tradeable claims** | Permissioned tradeable claims | **Full securities stack** | Securities-side BSA/AML via ATS/broker-dealer | **Yes — securities counsel before this phase, non-negotiable** | @@ -451,7 +499,7 @@ For **Phase 3**, the stack is well-trodden ([Skadden roundups](https://www.skadd ### Bottom line for the build decision -**Nothing in the regulatory analysis hard-blocks Phases 0–1, but Phase 1 has a soft gate: counsel must bless the non-transferable deferred-comp / license-fee structure (including 409A) before it ships.** Both expensive regimes are *opt-in* — securities law via tradeability, MSB law via custody — and the recommended path defers both. The single hard gate is **Phase 3**, which requires the full permissioned securities stack and counsel engaged *before* you build it. The strategic implication aligns with the rest of the document: **build and launch the closed modes first** — they are where cloning pressure is lowest, incentives most aligned, and the law lets you move fastest, *provided counsel signs off on the closed-mode claim structure first.* +**Nothing in the regulatory analysis hard-blocks Phases 0–1, but Phase 1 has a soft gate: counsel must draft the actual non-transferable deferred-comp / license-fee instrument — resolving on-demand-withdrawal vs. 409A fixed-payment-events and specifying vesting/clawback/termination ("when Sam quits") — before it ships.** Both expensive regimes are *opt-in* — securities law via tradeability, MSB law via custody — and the recommended path defers both. The single hard gate is **Phase 3**, which requires the full permissioned securities stack, counsel engaged *before* you build it, and (added 2026-07-11) a **compliance unit-economics check** — transfer-agent + ATS + KYC cost per claim against realistic claim cash flows (see [Roadmap & Milestones](#roadmap--milestones)). The strategic implication aligns with the rest of the document: **build and launch Intra-org first** — it is where cloning pressure is lowest, incentives most aligned, and the law lets you move fastest, *provided the counsel-drafted instrument exists first.* --- @@ -463,13 +511,15 @@ We are not competing with the chains we build on. The Skill Asset Protocol is an | Project | What it does | Where we differ / the threat | |---|---|---| -| **Story Protocol** (chainId 1514) | On-chain IP registry: IP Assets, PIL terms, co-holdable fractional royalty tokens, declared-derivative flow-through. | We **build on it** — Story is the ledger of *who owns what and who owes whom*; it has no execution gate, no Wielder-hidden runtime, no per-invocation meter. **Threat (platform-disintermediation):** Story could ship its own monetization/hosting/licensing UX and absorb the metering+gate layer we add. The platform our entire IP/royalty layer depends on is also the most capable disintermediator. Our hedge is the gate + collar + Skill-specific product and closed-mode GTM, none of which Story does today — but this is a real strategic dependency, not a moat. | +| **Story Protocol** (chainId 1514) | On-chain IP registry: IP Assets, PIL terms, co-holdable fractional royalty tokens, declared-derivative flow-through. | We **build on it** — Story is the ledger of *who owns what and who owes whom*; it has no execution gate, no Wielder-hidden runtime, no per-invocation meter. **Threat (platform-disintermediation):** Story could ship its own monetization/hosting/licensing UX and absorb the metering+gate layer we add. The platform our entire IP/royalty layer depends on is also the most capable disintermediator. Our hedge is the gate + Collar + Skill-specific product and closed-mode GTM, none of which Story does today — but this is a real strategic dependency, not a moat. | | **Agent-payment incumbents** (Skyfire, Payman, Nevermined/Catena, Coinbase's own x402 tooling) | Per-call/agent-to-agent payment rails, metering, and (some) settlement for AI agents. | These are the **nearest adjacents that could bolt royalty + provenance onto an existing rail.** Coinbase already owns the x402 facilitator we depend on; Skyfire/Payman/Nevermined already do agent metering and could add a Story-style royalty graph. **Honest defensibility:** every primitive is open, so the defensible thing is the *assembly* + the *closed-mode wedge* + the accumulated provenance/derivative graph — not a technical monopoly. We must out-execute on the Skill-specific product and lock in the graph before an incumbent generalizes into it. | | **Virtuals Protocol** | Launchpad/marketplace for tokenized *autonomous agents* — bonding-curve speculation. | We tokenize the **royalty stream of an authored Skill**, not a speculative agent token. Our unit is a metered Invocation-right for human-directed work, settled per use — not a coin priced on sentiment. | | **Olas / Autonolas** | Registry + staking for composable *autonomous agent services*. | Olas rewards agents for *running services autonomously*; we reward a **Creator each time a human Wielder invokes their Skill**, with declared-derivative flow-through. Different value event. | | **Bittensor** | Incentive market for *machine intelligence*: subnets pay miners in TAO for scored model outputs. | Bittensor pays for *inference quality* in a competitive subnet; we pay the **author of a specific reusable Skill artifact** with provenance and a derivative graph. No shared base model, no peer-scoring. | | **Sahara AI** | Provenance + revenue-share marketplace for AI *data and model assets*. | Closest in spirit, but the asset class is **data/models**, not executable authored Skills, and there is no hidden hosted *execution* gate — value flows from selling/licensing the asset, not metering each Wielder-hidden invocation. | -| **Managed-agent platforms** (Anthropic CMA, OpenAI, Google) | Host agents/Skills server-side; the Wielder gets the output, not the Skill. CMA keeps the Skill off the session-output stream — **hidden from the Wielder, not from the host** (the host processes it in plaintext; `GET /v1/agents` echoes it to the key-holder). | **Infrastructure we consume, not competitors.** None ships a native "no credential, no run" gate, a per-invocation meter, or a royalty/provenance layer (the gate is 100% our collar). We add the economic and ownership layer they omit. | +| **Managed-agent platforms** (Anthropic CMA, OpenAI, Google) | Host agents/Skills server-side; the Wielder gets the output, not the Skill. CMA keeps the Skill off the session-output stream — **hidden from the Wielder, not from the host** (the host processes it in plaintext; `GET /v1/agents` echoes it to the key-holder). | **Supplier AND likeliest disintermediator** (reclassified 2026-07-11 from "infrastructure we consume, not competitors" — that framing dismissed the likeliest killer instead of analyzing it). The GPT Store is the base rate for platform timelines: OpenAI shipped a native, skill-adjacent marketplace with a piloted builder revenue-share in January 2024 — platforms demonstrably ship these natively and within quarters (research, 2026-07). True, none ships a "no credential, no run" gate, a per-invocation meter, or a royalty/provenance layer today — but any of the three could, and an announcement writes our open-Marketplace optionality to zero (kill-criterion 7; R17). Counter-positioning: neutrality (a platform marketplace serves only its own platform), cross-platform provenance, the securities-barred mechanism, and compensation products platforms won't build — no platform will ship 409A-structured co-held employee/employer instruments. | +| **x402 inference gateways** (Router402, tx402.ai) | OpenRouter-style x402-metered model inference: BYO wallet, USDC on Base, ~200ms settlement via Flashblocks (Router402); 20+ EU-hosted open models (tx402.ai). All third-party resellers — **no first-party OpenAI/Anthropic x402 support exists** (research, 2026-07). | They prove the demand-side rail we ride — and commoditize the payment leg (Cloudflare's Monetization Gateway waitlist opened 2026-07-01). We do not compete on payments; the differentiation is the **attribution/royalty meter** — one ledger across inference AND skill invocations with creator splits, ancestry, and provenance, which a pure payment reseller has no reason to build (R19). | +| **BlockRun ClawRouter** (github.com/BlockRunAI/ClawRouter) | Local proxy for OpenClaw that auto-generates a wallet and pays per LLM call over x402; 55+ models, Base + Solana, mainnet-only (research, 2026-07). | Validates "the Wielder is a wallet, not a harness" (ADR-0008) in the wild — the thin-payer client pattern is already shipping. Same differentiation as above: it meters *payment*, not *attribution* — no royalty graph, no provenance, no splits. | ### The combination we ship @@ -496,19 +546,19 @@ That is a genuinely novel assembly. It is **not** a defensible *technical* monop ## Go-to-Market & Rollout -> **The wedge is Intra-org — as an assumption to validate, not an established market.** Lead with a company converting its internal Skills into co-owned revenue assets. Education is the second motion, seeded *inside* the intra-org beachhead. **The willingness of employers to co-hold royalty claims is UNVALIDATED (R12, Medium-High); securing design-partner LOIs is an explicit Phase-0/1 gate, not a backdrop.** +> **The wedge is Intra-org — as an assumption to validate, not an established market — and the pitch leads with compensation and retention, not royalty upside.** Sell the employer a compensation/attribution instrument for its Skill-building employees — the ArbEG / patent-award / tech-transfer pattern with a metering rail (research, 2026-07) — where external-invocation royalties are the sweetener, not the headline. Education is a later motion, deferred pending the fork-economics re-run (see [Product & User Experience](#product--user-experience)). **The willingness of employers to co-hold royalty claims is UNVALIDATED (R12, Medium-High); securing design-partner LOIs is an explicit Phase-0/1 gate, not a backdrop.** ### Why Intra-org wins the wedge (and Education does not, yet) Both closed modes are the right *place* to start — closed populations, aligned incentives, on-platform, lowest cloning pressure, and claims structurable as **non-transferable** rights that are the best route outside securities law (ADR-0006; report §6). The question is which closed mode is the sharpest *initial* wedge. Intra-org wins on five counts: 1. **One signature unlocks the whole loop.** Intra-org has a single decision-maker (the employer) who is simultaneously the **Beneficiary** (funds settlement), the **co-holder** of the claim, and the employer of the **Creator**. Education needs three independent parties (school, student, a different employer) — a three-sided cold start. -2. **The pain is acute, named, and on the buyer's desk.** From the employer's side, "why won't my best people leave the moment they've built the automation?" is a live 2026 retention problem. Intra-org reframes work-for-hire's 100/0 as a co-held claim where the employee keeps upside from *external* invocations. +2. **The pain is acute, named, and on the buyer's desk — and it is a compensation/retention pain, which is how the pitch leads.** From the employer's side, "why won't my best people leave the moment they've built the automation?" is a live 2026 retention problem. Intra-org reframes work-for-hire's 100/0 as a co-held claim — a retention and incentive instrument first, with upside from *external* invocations as the sweetener. Institutions already share invention upside with employees at scale (Germany's ArbEG statutory inventor remuneration, corporate patent-award programs, university tech-transfer splits — research, 2026-07); the pitch is "the metering rail that makes that pattern work for Skills," not "a royalty marketplace." 3. **It exercises the riskiest machinery without the riskiest exposure.** External invocations force you to build and harden the real gate + meter + Leg-1 settlement (ADR-0005) — with claims non-transferable, so no securities stack. You stress the hard parts inside the safest regulatory envelope. 4. **Lower cloning pressure, by construction.** Intra-org Skills bind value to *fresh private context and live internal tool/data access* — exactly the recommended anti-clone posture (ADR-0004; report §5). -5. **It is the natural distribution channel for Education.** Land the employer, prove the co-held claim pays, and the *same* employer becomes the Beneficiary in an Education deal. +5. **It is the natural distribution channel for Education — if Education un-defers.** Land the employer, prove the co-held claim pays, and the *same* employer becomes the Beneficiary in an Education deal (or the direct school→employer licensing variant). -**Decision: ship Intra-org first. Education is Phase-1b, sold into the same accounts.** Both rest on the unvalidated willingness-to-co-hold assumption (R12). +**Decision: ship Intra-org first, pitched as compensation/retention. Education is a later motion — deferred pending the fork-economics re-run (free-bypass branch: "re-author with class knowledge ≈ free") — and, if it un-defers, sells into the same accounts.** Both rest on the unvalidated willingness-to-co-hold assumption (R12). ### Ideal first customer profile (an assumption to test) @@ -533,7 +583,7 @@ A **mid-size, AI-forward services or product firm (roughly 100–800 people) whe **Pricing guardrails dictated by the architecture** (full unit economics in [Economic Design](#economic-design)): -- **Per-Invocation price must sit above amortized (settlement + inference) cost.** Batching is mandatory; the collar pays Anthropic per run, so inference COGS is passed through. There is a live computed floor (real bridge cost unmeasured, `report.md` §7.4). +- **Per-Invocation price must sit above amortized (settlement + inference) cost.** Batching is mandatory; the Collar pays Anthropic per run, so inference COGS is passed through. There is a live computed floor (real bridge cost unmeasured, `report.md` §7.4). - **Per-Invocation price must also sit *below* amortized clone cost** — *but that figure is unmeasured* (`report.md` §7.7), so this is a direction, not a number. - **Take-rate, not seat licensing**, computed on price; **inference is a separate pass-through, not covered by the take-rate.** - **Quote and settle in USDC; never ask the buyer to touch $IP** — fold USDC→WIP into the bridge and fast-claim. @@ -543,7 +593,7 @@ A **mid-size, AI-forward services or product firm (roughly 100–800 people) whe | ADR-0006 phase | GTM motion | Goal | |---|---|---| | **Phase 0 — Provenance** | Self-serve, free, viral. "Register your Skills, own your lineage." | Build the derivative graph; top-of-funnel; provenance as trust default. | -| **Phase 1 — Intra-org, then Education** | Founder-led design-partner sales to 3–5 ICP accounts **(target; none signed yet — R12)**. Co-held **non-transferable** claims; gate + run + off-chain meter. Education sold into the same accounts once intra-org pays. | Prove the loop pays Creators from external invocations; harden the collar inside the safest regulatory envelope. | +| **Phase 1 — Intra-org (terminal by design; Education deferred)** | Founder-led design-partner sales to 3–5 ICP accounts **(target; none signed yet — R12)**. **Pitch = compensation/retention (the ArbEG / patent-award pattern), royalty upside as sweetener.** Co-held **non-transferable** claims; gate + run + off-chain meter. Education is a later motion, gated on the fork-economics re-run. | Prove the loop pays Creators from external invocations; harden the Collar inside the safest regulatory envelope. This phase must stand alone as a complete product (ADR-0007). | | **Phase 2 — On-chain batched settlement** | Expand within proven accounts. Turn the off-chain accumulator into on-chain settled, published batches; ride a licensed facilitator/bridge (merchant-on-Stripe). | Make settlement auditable; de-risk MSB exposure. | | **Phase 3 — Open Marketplace + tradeable claims** | Permissioned launch, counsel-gated. ERC-3643 + Reg D 506(c)/Reg A+/CF + registered ATS + transfer agent + KYC. | Open the composable royalty market only when warranted — highest-cloning, full-securities surface, shipped last. | @@ -572,7 +622,7 @@ This section gives the founder/investor-grade numbers the rest of the document i | Phase | Core build | Indicative eng-effort | Non-eng spend | What this phase needs funded | |---|---|---|---|---| | **0 — Provenance** | Story registration + Derivative declaration + ancestry viewer | ~1–2 eng · ~1–2 months | Story gas (negligible) | A small pre-seed slice; ships on a 2-person team. The cheapest, soundest step. | -| **1 — Gate + run + off-chain meter** (Intra-org → Education) | Collar (sole key-holder, x402 resource server, off-chain signed ledger), pay-first-then-async orchestration, refund path, self-hosted sandbox option | ~2–4 eng · ~3–6 months | **Securities counsel for the non-transferable deferred-comp / 409A structure (a gate)**; design-partner sales | Seed round. The dominant *recurring* cost once live is **Anthropic inference COGS** (collar pays per run) + counsel. | +| **1 — Gate + run + off-chain meter** (Intra-org; Education deferred; **terminal by design**) | Collar (sole key-holder, x402 resource server, off-chain signed Merkle-committed ledger), pay-first-then-async orchestration, refund path, self-hosted sandbox option | ~2–4 eng · ~3–6 months | **Securities counsel to draft the non-transferable deferred-comp / 409A instrument (a gate)**; design-partner sales | Seed round. The dominant *recurring* cost once live is **Anthropic inference COGS** (Collar pays per run) + counsel. | | **2 — On-chain batched settlement** | Settlement worker, bridge/swap integration, permissionless keeper, reconciliation, fast-claim/hedge | ~2–4 eng · ~3–5 months | MSB/custody counsel; licensed bridge/BaaS partner contracts | Seed-to-A. Custody design is the regulatory gate. | | **3 — Open Marketplace + tradeable claims** | ERC-3643 allow-list, ATS integration (e.g. Securitize), transfer-agent + KYC wiring, routing/reputation | ~3–6 eng · ~6–9 months | **Securities counsel + exemption filing + ATS/transfer-agent fees (substantial)** | Series A+, and only when closed-mode traction warrants it. | @@ -580,8 +630,8 @@ This section gives the founder/investor-grade numbers the rest of the document i ### Reliability / refund targets -- **Failed-run-after-settled-payment rate < 0.5%** of paid invocations, with **automatic treasury-funded refund within one settlement cycle** (x402 is irreversible and has no chargebacks, so the collar funds every refund). -- **Gate leak rate (runs without a valid single-use credential) = 0** — a hard invariant, not a target. +- **Failed-run-after-settled-payment rate < 0.5%** of paid invocations, with **automatic treasury-funded refund within one settlement cycle** (x402 is irreversible and has no chargebacks, so the Collar funds every refund). +- **Gate-leak rate (runs without a valid single-use credential) = 0 — an ops SLO backed by a key-custody/rotation design, not an architectural property** (restated 2026-07-11; earlier drafts called this a "hard invariant," which overstated it — the gate is Wielder-side trust-minimized, but whether the Collar itself can leak a run depends entirely on its own code and key custody). What backs the SLO: the sole platform key held in an HSM/KMS with scheduled and on-suspicion rotation; no human read-path to the key; the credential set (settled txHashes, single-use) kept in a transactional store; and an alert on any run whose credential is absent from the settled set. - **Credited→on-chain-settled lag (Phase 2)** disclosed to customers and held under a published SLA window; unclaimed ancestor balance alarmed. ### Kill-criteria (falsifiable go/no-go) @@ -592,8 +642,9 @@ Stop or restructure if any of these fire — investors should hold us to them: 2. **Cold-start latency makes pay-first-then-async unusable.** If measured `sessions.create` → first-token latency (currently unmeasured, `report.md` §7.3) is so high or variable that the async UX is unacceptable and no pooling fix exists, the gate UX premise fails. 3. **Inference COGS exceeds defensible price.** If, against real Skill token profiles, **inference COGS + settlement cost routinely exceeds what Beneficiaries will pay** (i.e., contribution margin is negative at viable prices), the unit economics do not close. 4. **A breakout closed-mode Skill is cloned within weeks of launch with no economic counter.** If a high-value Skill is behaviorally cloned faster than live-evolution can stay ahead — and the unmeasured evolution-cadence defense (`report.md` §7.7) proves ineffective — even the closed-mode value prop is at risk; re-underwrite before Marketplace. -5. **Counsel cannot bless the non-transferable closed-mode structure.** If securities/409A counsel concludes the closed-mode claim is *not* outside securities treatment (medium-confidence today), the whole "launch closed first" sequencing must be reworked. +5. **Counsel cannot draft the closed-mode instrument.** The bar is upgraded (2026-07-11) from "counsel blesses the structure" to "counsel **drafts the actual instrument**" — a deferred-comp / license-fee agreement that survives §409A, resolves the on-demand-withdrawal vs. fixed-payment-events conflict (the Phase-1 "withdraw anytime" UX is close to the constructive-receipt fact pattern), and specifies vesting, clawback, and termination ("when Sam quits"). If no draftable instrument keeps the claim outside securities treatment (medium-confidence today), the whole "launch closed first" sequencing must be reworked. 6. **Story / $IP existential degradation.** If Story sunsets or $IP liquidity collapses below a usable settlement threshold and no mitigation lands (see R15), the on-chain layer must be re-platformed or the protocol re-scoped. +7. **A platform-native skill marketplace is announced (added 2026-07-11).** If Anthropic, OpenAI, or GitHub publicly announces or betas a native skill marketplace or skill-monetization program with builder payouts, the open-Marketplace optionality is written to zero that day — the GPT Store (launched January 2024 with a piloted builder revenue-share) is the base rate proving platforms ship these natively and within quarters (research, 2026-07). **Monitoring trigger (concrete, owned):** a standing monthly review — logged in the ops calendar with a named owner — of Anthropic, OpenAI, and GitHub changelogs, developer-event announcements, and marketplace/revenue-share program launches; the criterion fires on a public announcement or beta with builder payouts, not on rumor. **This criterion restructures rather than kills:** Phase-3 investment stops, and the closed-mode compensation product must stand alone — which it is designed to do (Phase 1 is the terminal state by design, ADR-0007). The counter-positioning is the one stated in Problem & Market: neutrality, cross-platform provenance, the securities-barred mechanism, and compensation products platforms won't build. --- @@ -606,15 +657,15 @@ The feasibility study (`docs/feasibility/report.md`, `docs/feasibility/findings. | # | Risk | Likelihood | Impact | Mitigation | |---|---|---|---|---| | **R1** | **Off-platform behavioral cloning** of a breakout Skill. ADR-0001 hands the Wielder the *output*; thousands of paid I/O pairs are a ~30×-cheaper distillation set. The moat defends the *marketplace*, not an individual Skill. | **High** (when-not-if for any breakout earner) | **High** — undermines the value prop at success; v1 (no TEE) cannot prevent it, only out-evolve it | Manage economically: live evolution (ship faster than distill-and-redeploy); bind value to live tool/data access + fresh context; price below amortized clone cost; anomaly detection; watermark/provenance as forensic backstop. **Caveat: evolution-cadence efficacy is unmeasured (§7.7).** Launch closed modes first. | -| **R2** | **MSB / money-transmitter classification.** The two-leg design makes the collar hold funds in-flight — the FinCEN MSB fact pattern. | **Medium-high** | **High** — 12–24mo, multi-$100K slog gating launch | Minimize/eliminate custody; non-custodial pass-through on a hosted facilitator; push in-flight value to a licensed bridge/BaaS partner; **counsel blesses the specific architecture** (no source squarely analyzes it). | -| **R3** | **Securities classification.** Tradeable royalty claims are securities under Howey; ADR-0004's moat *strengthens* the efforts-of-others prong; the March 2026 SEC interpretation does not carve out revenue-share tokens. | **High** (near-certain for tradeable) | **High for Marketplace; LOW / likely-outside-securities for intra-org/education if non-transferable — but MEDIUM confidence, NOT zero.** Non-transferability defeats the secondary-market/investment narrative, but the efforts-of-others prong is still satisfied by ongoing platform metering/evolution; **counsel sign-off required before Phase 1.** **Sub-risk: 409A / deferred-comp / constructive-receipt** for the employee's co-held claim. | Permissioned stack for Marketplace (ERC-3643 + Reg D/A+/CF + ATS + transfer agent + KYC). Keep closed-mode claims non-transferable; structure as deferred-comp/license-fee **with 409A addressed**; engage counsel before Phase 1, not just Phase 3. | +| **R2** | **MSB / money-transmitter classification.** The two-leg design makes the Collar hold funds in-flight — the FinCEN MSB fact pattern. | **Medium-high** | **High** — 12–24mo, multi-$100K slog gating launch | Minimize/eliminate custody; non-custodial pass-through on a hosted facilitator; push in-flight value to a licensed bridge/BaaS partner; **counsel blesses the specific architecture** (no source squarely analyzes it). | +| **R3** | **Securities classification.** Tradeable royalty claims are securities under Howey; ADR-0004's moat *strengthens* the efforts-of-others prong; the March 2026 SEC interpretation does not carve out revenue-share tokens. | **High** (near-certain for tradeable) | **High for Marketplace; LOW / likely-outside-securities for intra-org/education if non-transferable — but MEDIUM confidence, NOT zero.** Non-transferability defeats the secondary-market/investment narrative, but the efforts-of-others prong is still satisfied by ongoing platform metering/evolution; **a counsel-drafted instrument is required before Phase 1.** **Sub-risk: 409A / deferred-comp / constructive-receipt** for the employee's co-held claim (on-demand withdrawal vs. fixed payment events). | Permissioned stack for Marketplace (ERC-3643 + Reg D/A+/CF + ATS + transfer agent + KYC). Keep closed-mode claims non-transferable; counsel **drafts the actual deferred-comp/license-fee instrument** — resolving 409A and specifying vesting/clawback/termination ("when Sam quits") — before Phase 1, not just Phase 3. | | **R4** | **Bridge-stall reconciliation** (Phase 2): execution done on Base, ancestors unpaid on Story. | **Medium** | **Medium** — eventually-consistent + reconciliation overhead; not loss-of-funds with sound bookkeeping | Auditable off-chain ledger; on-chain published batches; retry/reconciliation; refund/reputation; conservative batching windows. | -| **R5** | **Trusted-accumulator degradation.** ADR-0003's "fraud structurally impossible" degrades to "auditable accumulator" once batched + cross-chain; the collar could skim/mis-report at settlement. | **High** (structural) | **Medium** — gate stays trust-minimized; settlement trust reintroduced | Explicit in ADR-0003; signed/auditable logs; on-chain batch publication; refund/reputation; TEE as eventual fix. | +| **R5** | **Trusted-accumulator degradation.** ADR-0003's "fraud structurally impossible" degrades to "auditable accumulator" once batched + cross-chain; the Collar could skim/mis-report at settlement. | **High** (structural) | **Medium** — gate stays Wielder-side trust-minimized; settlement trust reintroduced | Explicit in ADR-0003; signed, **Merkle-committed** logs (the beneficiary-verifiable meter — roots to claim-holders from Phase 1, on-chain with every Leg-2 batch); on-chain batch publication; refund/reputation; **TEE on a committed revenue trigger**, no longer open-endedly tabled. | | **R6** | **Negative fee economics.** Stacked per-invocation fees can exceed a cents-level micro-royalty. | **High** at literal per-call; **low** once batched | **Medium** — forces batching + price floor | Mandatory batching; price above amortized settlement + inference cost. | | **R7** | **$IP / WIP volatility + thin liquidity** ($IP ~−97.5% from ATH); WIP-only royalty currency forces involuntary $IP exposure on payers. | **High** (current market) | **Medium** — FX risk + enterprise friction | Fast-claim; hedge; fold USDC→WIP into the bridge; build a fiat/USDC→WIP on-ramp; monitor for USDC whitelisting (Spike 3). | | **R8** | **Verbatim prompt extraction** via agentic steering. | **Medium** | **Medium** — leaks text, but ADR-0004 abandons secrecy as load-bearing | Taxonomy-aware wrapper cuts extraction ~18% (never eliminates); the moats, not secrecy. Accept residual per ADR-0004. | -| **R9** | **CMA beta churn**; not ZDR/HIPAA-eligible for regulated data. | **Medium** | **Medium** — integration rework; compliance gap | Abstract the runtime behind the collar (swappable host); self-hosted sandbox for regulated data; track release notes. | -| **R10** | **Rate-limit ceiling** (300 create-req/min/org). | **Low** (not a v1 blocker at low volume) | **Low-medium** at scale | Reuse long-lived sessions, queue, or shard. **Unverified:** clean buyer isolation in one long-lived session (Spike 6). | +| **R9** | **CMA beta churn**; not ZDR/HIPAA-eligible for regulated data. | **Medium** | **Medium** — integration rework; compliance gap | Abstract the runtime behind the Collar (swappable host); self-hosted sandbox for regulated data; track release notes. | +| **R10** | **Rate-limit ceiling** (300 create-req/min/org). | **Low** (not a v1 blocker at low volume) | **Low-medium** at scale | Reuse long-lived sessions, queue, or shard. **Unverified:** clean buyer isolation in one long-lived session (Spike 7). | | **R11** | **Recency risk in load-bearing facts** (a facilitator could add Story 1514; fees/whitelist/License-Token semantics could change). | **Low-medium** | **Low-medium** — could simplify (good) or invalidate (bad) | Re-verify all load-bearing facts immediately before building (Spikes). | #### Product / market / adoption risks @@ -624,8 +675,11 @@ The feasibility study (`docs/feasibility/report.md`, `docs/feasibility/findings. | **R12** | **No-pain-felt adoption / willingness-to-co-hold UNVALIDATED.** Asking an employer to co-hold a royalty claim instead of work-for-hire is a hard sell with no template, and the upside is *external* invocations that may not materialize. | **Medium-high** | **High** — without willing first employers, the recommended wedge has no demand side | Lead with the retention/incentive narrative, not a rights grab. **Validate with design-partner LOIs before building Phase 1 (a kill-criterion).** Education offers a more concrete exchange as the second motion. | | **R13** | **Wrong-side marketplace cold start** — the open Marketplace needs Creators + Wielders + claim buyers at once, thinnest moat, full securities stack. | **Medium-high** for the open marketplace | **Medium** — a failed marketplace does not kill the closed modes | Sequence it last; bootstrap provenance value first (Phase 0). | | **R14** | **Value-prop dilution from "eventually consistent."** The buildable reality (credited per invocation, claimable on demand, batched, FX-exposed) is weaker than "automatically pays ancestors every invocation"; sophisticated buyers notice the credited-vs-settled gap. | **Medium** | **Medium** — credibility erosion if oversold | Correct the language (done: "automatically credited, claimable on demand"); **surface the gap as a tracked reconciliation metric, do not hide it**; ship the keeper so ancestor revenue never silently piles up. | -| **R15** | **Single-host + single-IP-chain dependency.** The gate is the collar on CMA; all IP/royalty/provenance is on Story (thin, illiquid). **Story could also disintermediate by adding the monetization/hosting layer itself** (see Competitive Landscape). | **Low-medium** | **High** if it triggers | Keep the runtime swappable behind the collar; treat Story as the IP layer and Base as the gate (do not couple). **Concrete fallback to develop, not just "monitor":** provenance/ancestry is the most portable artifact — design the registration layer so the declared-derivative graph can be mirrored/exported to a more liquid chain or an off-chain notarization if Story sunsets or $IP liquidity collapses; settlement (WIP) is the hardest-coupled piece and would need re-platforming. Track Story health + $IP liquidity against the R15 kill-criterion. | -| **R16** | **Co-authorship unmodeled.** v1 assumes a single Creator at origin; multi-author Skills (CONTEXT.md line 46, flagged open) are unhandled. | **Medium** (likely in Marketplace) | **Medium** — disputes / unclear split for jointly-built Skills | Single-Creator origin in v1; multi-author teams use the co-hold mechanic as a stopgap; design true multi-Creator origin before Marketplace. | +| **R15** | **Single-host + single-IP-chain dependency.** The gate is the Collar on CMA; all IP/royalty/provenance is on Story (thin, illiquid). **Story could also disintermediate by adding the monetization/hosting layer itself** (see Competitive Landscape). | **Low-medium** | **High** if it triggers | Keep the runtime swappable behind the Collar; treat Story as the IP layer and Base as the gate (do not couple). **Concrete fallback to develop, not just "monitor":** provenance/ancestry is the most portable artifact — design the registration layer so the declared-derivative graph can be mirrored/exported to a more liquid chain or an off-chain notarization if Story sunsets or $IP liquidity collapses; settlement (WIP) is the hardest-coupled piece and would need re-platforming. Track Story health + $IP liquidity against the R15 kill-criterion. | +| **R16** | **Co-authorship unmodeled.** v1 assumes a single Creator at origin; multi-author Skills (CONTEXT.md, Relationships — flagged open) are unhandled. | **Medium** (likely in Marketplace) | **Medium** — disputes / unclear split for jointly-built Skills | Single-Creator origin in v1; multi-author teams use the co-hold mechanic as a stopgap; design true multi-Creator origin before Marketplace. | +| **R17** | **Platform-native skill marketplace** (Anthropic / OpenAI / GitHub) — added 2026-07-11; previously dismissed rather than analyzed. The GPT Store (Jan 2024, piloted builder revenue-share) proves platforms ship skill-adjacent marketplaces natively, fast, and with zero-friction distribution (research, 2026-07). | **Medium-high** (when-not-if on some horizon) | **High** for the open-Marketplace optionality (written to zero on announcement); **Medium** for the closed modes — co-held compensation instruments are not what a platform marketplace replaces | Kill-criterion 7 with its monthly monitoring trigger; counter-position on neutrality, cross-platform provenance, the securities-barred mechanism, and comp products platforms won't build; keep Phase 1 independently viable (terminal by design, ADR-0007). | +| **R18** | **Skill depreciation / model absorption** — added 2026-07-11. Each frontier release absorbs capability that previously required an authored Skill; a claim on a model-absorbable Skill decays on the platform's release cadence. **Skill half-life is unmeasured.** | **Medium-high** for model-absorbable Skills | **Medium-high** — undermines the "durable claim" promise if half-life is shorter than vesting/settlement horizons | Segment the supply (model-absorbable vs. live-access-bound; see Problem & Market); underwrite durable claims only on live-access-bound Skills; measure half-life (see What we have NOT validated); design claim terms (duration, vesting) assuming depreciation. | +| **R19** | **Inference-payment commoditization** — added 2026-07-11. The x402 inference-gateway leg is already a multi-vendor reseller market (Router402, tx402.ai, ClawRouter; Cloudflare waitlist opened 2026-07-01) with near-zero switching costs (research, 2026-07). | **High** (already happening) | **Low-medium** — erodes any payments-based differentiation, not the attribution thesis | Do not compete on payments; differentiate on the unified attribution/royalty meter (one ledger, two asset classes); treat the commodity rail as the demand-side wedge's distribution, not as a moat. | ### Pre-build re-verification spikes @@ -641,11 +695,12 @@ Secondary spikes (resolve before the relevant phase): 5. **Inference-COGS / price model** — model per-invocation Anthropic cost (CMA $0.08/active session-hour + per-token ITPM/OTPM) against real Skill token profiles to set the pass-through model and confirm contribution margin (feeds kill-criterion 3). *Currently undefined.* 6. **Leg-2 economics on real Story mainnet** — exact USDC(Base)→WIP(Story) bridge cost + confirmation time vs. generic quotes (Phase 2; §7.4). 7. **Long-lived-session isolation across buyers** — whether one CMA session cleanly isolates distinct buyers; likely one-session-per-buyer, reintroducing the ceiling (R10; §7.5). -8. **Fork-killing threshold (economics)** — run the `prototype/` engine (experiments 4–6) to **test** the `i* = p_parent/p_fork` *hypothesis*, find where forks collapse as inherit-bps rises, and where the fee feels extractive. **This spike exists precisely because the threshold is OPEN (TBD); it sets launch defaults — the closed form is a hypothesis to validate here, not a settled answer (see Economic Design).** +8. **Fork-killing threshold (economics) — re-run required (2026-07-11)** — run the `prototype/` engine (experiments 4–6) to **test** the `i* = p_parent/p_fork` *hypothesis*, find where forks collapse as inherit-bps rises, and where the fee feels extractive — **now with an explicit alternative branch: "re-author with class knowledge ≈ free."** A rational graduate can re-author an equivalent Skill instead of forking, at near-zero cost, paying the school nothing; the spike must price *fork vs. re-author*, not just fork vs. not-fork. **Education stays deferred until this spike shows a real forking incentive** (see Product & UX). This spike exists precisely because the threshold is OPEN (TBD); it sets launch defaults — the closed form is a hypothesis to validate here, not a settled answer (see Economic Design). 9. **`receiveWithAuthorization` on WIP / `RoyaltyModule.sol`** — contract-level read for any hypothetical direct-to-contract settlement (almost certainly absent; confirm). 10. **Live-evolution anti-clone efficacy** — load-bearing for the no-TEE defense; *asserted by analogy, unmeasured* (§7.7). No source quantifies required change cadence (R1; feeds kill-criterion 4). 11. **Off-platform Story enforcement** — real dispute/takedown outcomes for behavioral clones (on-chain provenance vs. off-chain courts) are undocumented (§7.8). -12. **Counsel sign-off on the collar architecture + closed-mode claim structure** — no regulatory source squarely analyzes the per-invocation agent-to-agent fact pattern; the MSB and the non-transferable-outside-Howey analyses are both extrapolated. **Get counsel to bless the architecture AND the deferred-comp/409A structure before launch** (R2, R3; feeds kill-criterion 5). +12. **Counsel drafts the Collar architecture read + the closed-mode instrument** — no regulatory source squarely analyzes the per-invocation agent-to-agent fact pattern; the MSB and the non-transferable-outside-Howey analyses are both extrapolated. **Counsel must bless the architecture AND draft the actual deferred-comp/409A instrument (on-demand-withdrawal vs. fixed-payment-events; vesting/clawback/termination) before launch** (R2, R3; feeds kill-criterion 5). +13. **Pi-Wielder spike (`spikes/pi-wielder/`)** — added 2026-07-11; the demand-side wedge's validation experiment. Prove the thin-payer client by construction — a ~100-line paying proxy is the *entire* Wielder-side protocol footprint (ADR-0008) — and measure x402 payment overhead per call (sign → verify → settle, p50/p95) plus end-to-end skill-invocation latency, with one wallet paying across inference calls and a skill invocation into one unified attributed ledger (split correctness checked against `prototype/settlement-engine.mjs`). Testnet-only; zero real money. *Output: measured payment-overhead and latency distributions to feed back into this document's demand-side section — NOT demand evidence (R12 stands).* --- @@ -655,15 +710,17 @@ A single honest box, because the verdict is GO-**with-caveats** and several load - **Cold-start latency** (`sessions.create` → first token) — **unmeasured** (§7.3). Sizes the entire pay-first-then-async UX. - **Real Leg-2 settlement cost & latency** (USDC(Base)→WIP(Story) bridge/swap on Story mainnet) — **unmeasured** (§7.4). The pricing-floor and batch-window models depend on it; the fee table in Economic Design is illustrative. -- **Inference unit economics** — the collar pays Anthropic per run; **no model yet ties Wielder price to inference COGS + settlement + fee + royalty.** Until spiked, **business unit economics are undefined.** +- **Inference unit economics** — the Collar pays Anthropic per run; **no model yet ties Wielder price to inference COGS + settlement + fee + royalty.** Until spiked, **business unit economics are undefined.** - **Live-evolution anti-clone efficacy** — **asserted by analogy, unmeasured** (§7.7). The load-bearing "price below amortized clone cost / out-evolve the clone" prescription rests on an unquantified assumption. - **Off-platform clone enforcement** — real dispute/takedown outcomes for behavioral clones are **undocumented** (§7.8). - **Long-lived-session buyer isolation** — **unverified** (§7.5); likely forces one-session-per-buyer, reintroducing the rate ceiling. - **License Token as non-burned off-chain credential** — **unverified** (§7.1); treated as off-chain entitlement pending a spike. -- **Regulatory fact pattern** — **medium confidence; no source squarely analyzes the per-invocation agent-to-agent collar** (§7.2). Both the MSB merchant-not-transmitter posture and the non-transferable-outside-Howey conclusion are extrapolations counsel must bless. +- **Regulatory fact pattern** — **medium confidence; no source squarely analyzes the per-invocation agent-to-agent Collar** (§7.2). Both the MSB merchant-not-transmitter posture and the non-transferable-outside-Howey conclusion are extrapolations counsel must bless. - **Demand-side willingness-to-pay and willingness-to-co-hold** — **no LOI, no pilot, no pricing research** (R12). The only demand signal is x402 aggregate volume, which is agent-infra micropayments, not skill royalties. -- **Fork-killing threshold** — **OPEN / TBD** (`prototype/README.md`). The `i* = p_parent/p_fork` closed form is a hypothesis, not a result. -- **Co-authorship / multi-Creator origin** — **open design question** (CONTEXT.md line 46), unmodeled in v1. +- **Fork-killing threshold** — **OPEN / TBD** (`prototype/README.md`). The `i* = p_parent/p_fork` closed form is a hypothesis, not a result — and the re-run must add the "re-author with class knowledge ≈ free" branch (the Education demotion, 2026-07-11). +- **Co-authorship / multi-Creator origin** — **open design question** (CONTEXT.md, Relationships), unmodeled in v1. +- **Fraction of the skill supply that is host-compatible** — **unmeasured** (added 2026-07-11). Claude Code skills are context-bound (they assume a harness, local files, tool access); hosting strips most of that value for some unknown fraction of the supply. If the fraction is small, the addressable supply for the hosted meter shrinks accordingly. Flagged in CONTEXT.md. +- **Skill half-life** — **unmeasured** (added 2026-07-11). No source quantifies how fast authored Skills depreciate against the model release cadence; the model-absorbable vs. live-access-bound segmentation (Problem & Market) is a hypothesis without a decay curve, and claim durations/vesting are being designed against it blind. Any of these could change the plan; the phased path is designed so the cheapest, soundest step (Provenance) ships first and the most-uncertain bets (Marketplace, tradeable claims) ship last, after these have been measured. @@ -671,7 +728,7 @@ Any of these could change the plan; the phased path is designed so the cheapest, ## Roadmap & Milestones -The build order is **safety-first, not ambition-first** (ADR-0006). Each phase ships a self-contained, defensible product, de-risks the next, and is gated by a small number of pre-build *spikes*. The atomic-payment, permissionless-trading, and hidden-from-host fantasies are out; everything below is the buildable loop (`report.md` §6). +The build order is **safety-first, not ambition-first** (ADR-0006) — and, as of the 2026-07-11 reframe, it is not a ramp: **Phase 1 is the terminal state by design (ADR-0007).** The off-chain signed ledger, the co-held non-transferable claims, and Story provenance must be independently viable — a complete compensation and attribution product — if Phases 2–3 never ship. Phases 2 (on-chain settlement) and 3 (tradeable claims) are **underwritten optionality**, exercised only on evidence; nothing in Phase 1's viability may depend on them. Each phase ships a self-contained, defensible product, de-risks the next, and is gated by a small number of pre-build *spikes*. The atomic-payment, permissionless-trading, and hidden-from-host fantasies are out; everything below is the buildable loop (`report.md` §6). **Two fixed chain decisions hold across all phases:** Story (chainId 1514, SDK v1.4.4) for IP, royalty, provenance; Base (8453) for the x402 gate. Do **not** make x402 settle directly onto Story — the two-leg split (ADR-0005) is permanent v1 architecture. @@ -680,11 +737,11 @@ The build order is **safety-first, not ambition-first** (ADR-0006). Each phase s The first shippable thing is **one intra-org Skill, registered for provenance, gated, run, and metered** — the thinnest vertical slice proving the core loop without touching anything regulated. - **Provenance:** one Skill as a Story IP Asset (`mintAndRegisterIpAssetWithPilTerms` + `PILFlavor.commercialRemix()`), with at least one declared Derivative. -- **Gate + run:** collar as **sole Anthropic key-holder + x402 resource server**. `402` → EIP-3009 `transferWithAuthorization` (USDC on Base) → `/verify` + `/settle` → settled `txHash` **is** the single-use execution credential, checked **off-chain**. Settle first (sub-second), release credential, **then** run the agent asynchronously and stream only the output — never hold the x402 handshake across the run. +- **Gate + run:** Collar as **sole Anthropic key-holder + x402 resource server**. `402` → EIP-3009 `transferWithAuthorization` (USDC on Base) → `/verify` + `/settle` → settled `txHash` **is** the single-use execution credential, checked **off-chain**. Settle first (sub-second), release credential, **then** run the agent asynchronously and stream only the output — never hold the x402 handshake across the run. - **Meter:** an auditable off-chain ledger crediting each invocation's split (protocol fee → creator → flow-through), credited but **not** settled on-chain. -- **Claims non-transferable** — co-held employee/employer entitlement as a contractual/deferred-comp right (counsel-blessed structure). +- **Claims non-transferable** — co-held employee/employer entitlement as a contractual/deferred-comp right (counsel-**drafted** instrument, resolving on-demand-withdrawal vs. 409A fixed-payment-events and specifying vesting/clawback/termination). -**Why this slice:** it exercises payment-gating, Wielder-hidden execution, the derivative graph, and the recursive split while deferring the two hardest dependencies — on-chain cross-chain settlement and securities/custody law. **Skill content is hidden from the Wielder (the collar never proxies `GET /v1/agents`); the host (Anthropic) still sees it in plaintext, accepted per ADR-0004** — this is the correct, qualified framing the rest of the document follows. +**Why this slice:** it exercises payment-gating, Wielder-hidden execution, the derivative graph, and the recursive split while deferring the two hardest dependencies — on-chain cross-chain settlement and securities/custody law. **Skill content is hidden from the Wielder (the Collar never proxies `GET /v1/agents`); the host (Anthropic) still sees it in plaintext, accepted per ADR-0004** — this is the correct, qualified framing the rest of the document follows. ### Phase 0 — Provenance (all-Story, ships immediately) @@ -696,21 +753,21 @@ The soundest step, no broken seam. Register Skills as IP Assets; forks register **Spikes:** *None blocking* — Story registration is confirmed real and audited (§3 step 1). Optionally confirm the `PILFlavor` surface against SDK v1.4.4 before wiring. **GTM gate at this phase: secure at least one design-partner LOI to co-hold (kill-criterion 1) before committing Phase 1 build.** -### Phase 1 — Intra-org, then Education: gate + run + off-chain meter +### Phase 1 — Intra-org: gate + run + off-chain meter — **the terminal state by design** -Closed populations, lowest cloning pressure (ADR-0004 Update, ADR-0006). Claims **non-transferable** (best route outside the securities stack, *counsel-blessed*). Intra-org first; Education second. +Closed population, lowest cloning pressure (ADR-0004 Update, ADR-0006). Claims **non-transferable** (best route outside the securities stack, *counsel-drafted instrument*). **This phase is the terminal state by design (ADR-0007): it must stand alone as a complete compensation/attribution product, with no dependence on Phases 2–3 shipping.** Education is deferred pending the fork-economics re-run (see [Product & User Experience](#product--user-experience)); its deliverables below are retained but conditioned on that spike. -**Deliverables:** collar service (sole key-holder, x402 resource server, off-chain credential bookkeeping); pay-first-then-run-async orchestration against CMA; auditable, **signed** off-chain invocation+settlement ledger; intra-org co-held accrual from *external* invocations; education fork-to-Derivative + flow-through-to-school crediting; self-hosted sandbox for regulated data; refund/reputation path for accept-payment-but-fail-to-run; **inference-COGS pass-through metering.** +**Deliverables:** Collar service (sole key-holder, x402 resource server, off-chain credential bookkeeping); pay-first-then-run-async orchestration against CMA; auditable, **signed, Merkle-committed** off-chain invocation+settlement ledger (the beneficiary-verifiable meter — roots distributed to claim-holders on a published cadence); intra-org co-held accrual from *external* invocations; unified-meter ledger entries for inference pass-through alongside skill royalties; *(conditioned on the Education spike)* education fork-to-Derivative + flow-through-to-school crediting; self-hosted sandbox for regulated data; refund/reputation path for accept-payment-but-fail-to-run; **inference-COGS pass-through metering.** -**Success criteria:** no run without a valid single-use replay-proof credential; both intra-org co-holders earn from an external invocation; an education chain credits student-Derivative and school-ancestor correctly; signed, independently auditable ledger, no credential double-spend; usable p50/p95 latency; **positive per-invocation contribution margin after inference COGS** at design-partner prices. +**Success criteria:** no run without a valid single-use replay-proof credential (the gate-leak ops SLO, backed by the key-custody/rotation design); both intra-org co-holders earn from an external invocation; claim-holders can independently verify their credited invocations against the published Merkle roots; signed, independently auditable ledger, no credential double-spend; usable p50/p95 latency; **positive per-invocation contribution margin after inference COGS** at design-partner prices; *(if Education un-defers)* an education chain credits student-Derivative and school-ancestor correctly. -**Spikes (each must pass before main build):** cold-start latency (§7.3); inference-COGS/price model (Spike 5); session isolation across buyers (§7.5); credential semantics (§7.1); **fork-killing threshold — run `prototype/` to *test* the `i* = p_parent/p_fork` hypothesis and pick launch defaults (it is OPEN, not solved).** **Legal gate: counsel sign-off on the non-transferable deferred-comp / 409A structure before launch.** +**Spikes (each must pass before main build):** cold-start latency (§7.3); inference-COGS/price model (Spike 5); session isolation across buyers (§7.5); credential semantics (§7.1); **Pi-Wielder spike (`spikes/pi-wielder/`, Spike 13) — thin-payer proof plus measured x402 payment-overhead latency, feeding the demand-side wedge**; **fork-killing threshold — re-run `prototype/` with the "re-author with class knowledge ≈ free" branch to *test* the `i* = p_parent/p_fork` hypothesis and decide Education's deferral (it is OPEN, not solved).** **Legal gate: counsel drafts the non-transferable deferred-comp / 409A instrument — resolving on-demand-withdrawal vs. fixed-payment-events, specifying vesting/clawback/termination — before launch.** ### Phase 2 — On-chain batched royalty settlement (ADR-0005 Leg 2) Turn the off-chain accumulator into on-chain truth — **closed modes only.** Custody enters here, so engineer to *minimize* custody from day one. -**Deliverables:** async settlement worker (batches per threshold/interval); bridge/swap pipeline USDC(Base)→WIP(Story) via a licensed partner; `payRoyaltyOnBehalf` + **permissionless keeper** auto-claiming `claimAllRevenue` for ancestors; on-chain published batches; reconciliation for bridge stalls; fast-claim/hedge logic; collar as **non-custodial pass-through** on a hosted facilitator. +**Deliverables:** async settlement worker (batches per threshold/interval); bridge/swap pipeline USDC(Base)→WIP(Story) via a licensed partner; `payRoyaltyOnBehalf` + **permissionless keeper** auto-claiming `claimAllRevenue` for ancestors; on-chain published batches, **each carrying the Merkle root of the invocation log it settles** (completing the beneficiary-verifiable meter: ancestors audit rather than trust); reconciliation for bridge stalls; fast-claim/hedge logic; Collar as **non-custodial pass-through** on a hosted facilitator. **Success criteria:** a batch settles on-chain to correct ancestors with zero unexplained drift vs. the signed ledger; keeper claims reliably with no manual intervention; per-invocation amortized settlement cost below the price floor; a simulated bridge stall is detected and reconciled without losing funds. @@ -718,14 +775,16 @@ Turn the off-chain accumulator into on-chain truth — **closed modes only.** Cu ### Phase 3 — Open Marketplace + tradeable claims (only when warranted, permissioned) -The headline "open composable royalty graph" ships **last** — thinnest moat, highest cloning incentive, full securities stack (ADR-0006). Enter only when closed-mode traction and live-evolution/live-data moats justify the open market. +The headline "open composable royalty graph" ships **last** — thinnest moat, highest cloning incentive, full securities stack (ADR-0006) — and, per the reframe, it is **underwritten optionality that may simply never be exercised** (ADR-0007; the GPT Store base rate cuts against it on both the platform-timeline and the weak-builder-demand readings). Enter only when closed-mode traction and live-evolution/live-data moats justify the open market, kill-criterion 7 has not fired, **and the compliance unit economics clear (gate below).** + +**Additional gate — compliance unit economics (added 2026-07-11).** Before any Phase-3 build, compute the per-claim compliance cost — transfer-agent fees + ATS fees + KYC, per holder per year — against realistic per-claim cash flows from closed-mode data. That quotient sets a **minimum-claim-size floor**: a claim whose annual cash flow cannot cover its own compliance overhead is uneconomic to make tradeable at any volume. **If the floor excludes the claim sizes the closed modes actually produce, Phase 3 as designed is dead — re-scope to pooled instruments (a fund-like wrapper aggregating many claims above the floor) or cut the phase.** This gate is arithmetic, not counsel; it can and must be computed from real fee schedules and real closed-mode cash flows before engaging the securities stack. **Deliverables:** tradeable Royalty claim as a **permissioned** security (ERC-3643 allow-list); an exemption path (Reg D 506(c) / Reg A+/CF); secondary trading only on a registered ATS (e.g. Securitize) + transfer agent + KYC; marketplace routing/reputation; economic anti-clone tooling (price-below-clone-cost guidance, live-evolution cadence, value-binding to live tool/data access). **Success criteria:** a claim trades on a registered ATS between KYC'd, allow-listed parties with transfer-agent records; routing demonstrably favors provenance-verified originals over clones; no regulated activity outside the permissioned rails. -**Spikes / gates:** **engage securities counsel before this phase (hard gate);** off-platform clone-defense efficacy (§7.7, unmeasured — quantify before betting the Marketplace on it); off-platform enforcement reality (§7.8). +**Spikes / gates:** **engage securities counsel before this phase (hard gate);** **compliance unit economics → minimum-claim-size floor (gate above — arithmetic, run it first);** off-platform clone-defense efficacy (§7.7, unmeasured — quantify before betting the Marketplace on it); off-platform enforcement reality (§7.8). -### Cross-phase tabled item +### Cross-phase committed trigger (was: tabled item) -**TEE / confidential execution** remains tabled (ADR-0003, ADR-0004) as the eventual structural fix for both host-side secrecy and run-without-charging. Not on any phase's critical path; revisit for high-value Skills once the closed modes prove the model. \ No newline at end of file +**TEE / confidential execution is no longer open-endedly tabled.** Earlier drafts deferred it three separate times (ADR-0003, ADR-0004, and this section); as of 2026-07-11 it carries a **committed trigger**: any Skill whose credited revenue exceeds a threshold — set concretely by the Phase-1 unit-economics spike — is moved to confidential execution, closing both the host-plaintext exposure and the mis-metering hole for exactly the Skills where the stakes have become real. Not on any phase's critical path until a Skill crosses the threshold; below it, the plaintext-host trust boundary stands as accepted (ADR-0004). \ No newline at end of file diff --git a/docs/adr/0001-skills-as-hosted-invocation-rights.md b/docs/adr/0001-skills-as-hosted-invocation-rights.md index d79480e..cf0b255 100644 --- a/docs/adr/0001-skills-as-hosted-invocation-rights.md +++ b/docs/adr/0001-skills-as-hosted-invocation-rights.md @@ -1,5 +1,7 @@ # Skills are sold as hosted invocation-rights, not as files +**Status:** Accepted, amended 2026-07-11 + ## Context A **Skill** is plaintext (a `SKILL.md`, plugin, or agent definition) and therefore trivially @@ -29,3 +31,24 @@ per use. The tradeable asset is the royalty stream, not the artifact. *output-channel distillation* (a Wielder reconstructing behavior from outputs). This must be managed separately — prompt hardening and/or TEE/confidential execution — and is an open question, not solved by this decision. + +## Amendment (2026-07-11) + +This decision has been over-read, including by us, and the amendment absorbs ADR-0004's concession +explicitly: hosting preserves **artifact** scarcity, not **economic** scarcity. The `SKILL.md` never +leaves the Collar, but for most Skills the *output is the value*, and a high-volume Skill's own paid +outputs are a cheap (~30×) behavioral-distillation training set. So the division of labor is: + +- **The invocation-right protects the file.** Per-use payment and never-handing-over remain the + mechanism — they stop artifact copying and make usage meterable. +- **The moats protect the economics** (ADR-0004): provenance, the derivative royalty graph, + reputation/routing, live evolution — and, per that ADR's own update, they defend the marketplace + as a whole, not an individual breakout Skill. + +The 2026-07 premise review rated the open-market reading of this decision ("hosting keeps the +Creator's income safe") among the corpus's weakest claims, and it is one of the four critiques +behind the closed-mode reframe (ADR-0007). In closed modes the counterparty (the employer) already +*possesses* the Skill, so clone-resistance is irrelevant there; hosting's job narrows to what it +actually delivers — a single meterable execution point that feeds the compensation ledger. +"Never handed over" stands as the mechanism for the (now optional) open Marketplace; it is not, +anywhere, the thing that makes the Creator's economics safe. diff --git a/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md b/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md index d36682c..04a300c 100644 --- a/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md +++ b/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md @@ -1,5 +1,7 @@ # Tokenize Skills as Programmable IP on Story Protocol +**Status:** Accepted, updated 2026-06 (post-feasibility validation); amended 2026-07-11 (see Update) + ## Context The token must do three jobs at once: (1) tamper-proof **provenance** of who authored a Skill, @@ -51,3 +53,6 @@ full analysis in `docs/feasibility/report.md`: composable trading market is not available. Trading must be permissioned (ERC-3643 + ATS + transfer agent); claims are kept non-transferable in closed modes (ADR-0006). - The "attestation bridge" is resolved as the **two-leg settlement** of ADR-0005. +- **2026-07-11:** Superseded on emphasis by ADR-0007 — the tradeable claim is underwritten + optionality, not the goal ("the goal is to trailblaze" no longer stands); the regulatory + exposure of tradeability is deferred with Phase 3, not accepted up front. diff --git a/docs/adr/0003-payment-gated-execution.md b/docs/adr/0003-payment-gated-execution.md index 47b4571..d05e638 100644 --- a/docs/adr/0003-payment-gated-execution.md +++ b/docs/adr/0003-payment-gated-execution.md @@ -1,5 +1,7 @@ # Payment-gated execution: payment is the meter, not a witness +**Status:** Accepted, updated 2026-06 (post-feasibility validation); clarified 2026-07-11 (Wielder-side qualifier per ADR-0008) + ## Context A Skill runs hidden off-chain; royalties settle on-chain. The intuitive design — "prove to the @@ -37,10 +39,14 @@ synchronous, single-chain, atomic** case. The real architecture (ADR-0005) decou gate (x402/USDC on Base) from on-chain royalty settlement (Story), with **off-chain batching**. Consequence: -- The per-invocation **gate** stays trust-minimized — *no credential, no run*, enforced per call. +- The per-invocation **gate** stays **Wielder-side** trust-minimized — *no credential, no run*, + enforced per call (the Wielder cannot obtain a run without a settled payment; from every other + seat this is enforced by the Collar's own code and key custody — an ops SLO backed by a + key-custody/rotation design, not an architectural property; see ADR-0008 and the PRD's + Reliability targets). - **Settlement** degrades from "structurally impossible to defraud" to an **auditable accumulator**: - the collar batches off-chain and could in principle mis-report or skim. Mitigations: signed, + the Collar batches off-chain and could in principle mis-report or skim. Mitigations: signed, auditable invocation logs; on-chain published settlement batches for reconciliation; refund + reputation for accept-payment-but-fail-to-run; tabled TEE as the eventual structural fix. -- The **execution credential** is the x402 settled **txHash** (collar-checked off-chain), NOT a +- The **execution credential** is the x402 settled **txHash** (Collar-checked off-chain), NOT a per-call on-chain Story License Token (uneconomic at per-call cadence). diff --git a/docs/adr/0004-compete-on-moats-not-secrecy.md b/docs/adr/0004-compete-on-moats-not-secrecy.md index 5c472fa..034a405 100644 --- a/docs/adr/0004-compete-on-moats-not-secrecy.md +++ b/docs/adr/0004-compete-on-moats-not-secrecy.md @@ -1,5 +1,7 @@ # Compete on economic & network moats, not Skill secrecy +**Status:** Accepted, updated 2026-06 (post-feasibility validation) + ## Context Without TEE (tabled in ADR 0003), a hidden Skill's *behavior* can be partially reconstructed from diff --git a/docs/adr/0005-two-leg-cross-chain-settlement.md b/docs/adr/0005-two-leg-cross-chain-settlement.md index 71cf3ba..23092e1 100644 --- a/docs/adr/0005-two-leg-cross-chain-settlement.md +++ b/docs/adr/0005-two-leg-cross-chain-settlement.md @@ -1,5 +1,7 @@ # Two-leg cross-chain settlement; off-chain execution credential +**Status:** Accepted, updated 2026-06 (pre-build spikes) + ## Context Feasibility validation (`docs/feasibility/report.md`) found the literal ADR-0003 vision — one @@ -19,7 +21,7 @@ Settle in **two decoupled legs**: - **Leg 1 — synchronous gate (Base):** the Wielder pays USDC on Base via x402 (EIP-3009 `transferWithAuthorization`, gasless). The settled **txHash is the single-use execution - credential**, checked off-chain by the collar. Settle the payment first (sub-second), release the + credential**, checked off-chain by the Collar. Settle the payment first (sub-second), release the credential, *then* run the agent asynchronously and stream output — never hold the x402 handshake open across the agent run (x402 `maxTimeoutSeconds` ~60s < cold agent start + loop). - **Leg 2 — asynchronous settlement (Story):** an off-chain worker accrues payments in an auditable @@ -39,10 +41,10 @@ The execution credential is **off-chain**; do NOT mint a per-call on-chain Licen ## Consequences - Settlement is **eventually-consistent**, not atomic. -- Batching makes the collar an **in-flight fund custodian** → FinCEN MSB exposure. Minimize custody: - route in-flight value through a licensed facilitator/bridge and keep the collar a non-custodial +- Batching makes the Collar an **in-flight fund custodian** → FinCEN MSB exposure. Minimize custody: + route in-flight value through a licensed facilitator/bridge and keep the Collar a non-custodial pass-through (see ADR-0006 + regulatory section of the report). -- A bridge stall leaves "execution done, ancestor unpaid" — needs reconciliation + sound collar +- A bridge stall leaves "execution done, ancestor unpaid" — needs reconciliation + sound Collar bookkeeping. - **Re-verify before building:** that no x402 facilitator has added Story 1514; the CDP fee schedule; cold `sessions.create`→first-token latency; whether a License Token can serve as a non-burned @@ -64,7 +66,7 @@ Spike results in `docs/feasibility/prebuild-spikes.md`. All four confirm this AD and per-call minting is on-chain WIP + gas + block latency. **License Tokens are scoped to Phase 0 provenance / fork declaration only.** - **Finality caveat (new):** the x402 "~200ms" is a Base **Flashblocks preconfirmation**, not hard - finality; under congestion Base confirmation can take **10–28s**. The collar must own its own + finality; under congestion Base confirmation can take **10–28s**. The Collar must own its own txHash/nonce bookkeeping, set sane timeouts, and fund refunds from treasury (x402 is irreversible, no resubmit). - **Latency (Leg 1 + run) is acceptable:** no Anthropic SLA (CMA beta), but the async-after-gate diff --git a/docs/adr/0006-phased-rollout-closed-modes-first.md b/docs/adr/0006-phased-rollout-closed-modes-first.md index 744b9b3..6aa8868 100644 --- a/docs/adr/0006-phased-rollout-closed-modes-first.md +++ b/docs/adr/0006-phased-rollout-closed-modes-first.md @@ -1,5 +1,7 @@ # Phased rollout: closed modes first; tradeable claims are permissioned securities +**Status:** Accepted (2026-06-06), amended 2026-07-11 + ## Context Feasibility validation (`docs/feasibility/report.md`) found: @@ -35,3 +37,19 @@ Launch in order of *safety*, not ambition: is gated behind the securities stack. - Cloning risk is met first where it is weakest (closed modes), buying time to build live-evolution / live-data moats before facing the open market. + +## Amendment (2026-07-11) + +Emphasis reversed per ADR-0007. The phasing above stands; its framing does not: + +- **Phase 1 is the terminal state by design.** The closed-mode compensation layer must be + independently viable if Phases 2–3 never ship. Phases 2–3 are exercised only if warranted — + they are no longer the point of the exercise (ADR-0007). +- **The open Marketplace is underwritten optionality, not the headline or the destination.** The + Consequences above ("the headline … is the **last** thing shipped", "buying time … before facing + the open market") record the pre-reframe emphasis: closed modes are no longer a waypoint toward + the open market. +- **Education is demoted from the Phase-1 follow-on to deferred** ("Intra-org, then Education" no + longer holds): it un-defers only if a re-run of the fork-economics spike, with "re-author with + class knowledge ≈ free" as an explicit alternative branch, still shows a real forking incentive + (ADR-0007). diff --git a/docs/adr/0007-closed-mode-compensation-layer-as-terminal-product.md b/docs/adr/0007-closed-mode-compensation-layer-as-terminal-product.md new file mode 100644 index 0000000..066e6ba --- /dev/null +++ b/docs/adr/0007-closed-mode-compensation-layer-as-terminal-product.md @@ -0,0 +1,81 @@ +# The closed-mode compensation layer is the terminal product + +**Status:** Accepted (2026-07-11) + +## Context + +An adversarial premise review (2026-07-11; six full-corpus readers, four premise critics) returned +a consistent verdict — "shaky, not broken" — and a consistent diagnosis: the corpus is honest but +**inverted**. The PRD leads with its weakest claims (the open-marketplace royalty story) and treats +its strongest asset (the off-chain metered ledger + co-held, non-transferable claims) as a stepping +stone. Four critiques of the marketplace frame survived steelmanning: + +1. **Success is self-defeating in the open market.** A breakout Skill's own paid I/O pairs are a + ~30×-cheaper distillation set — ADR-0004's own concession. The addressable middle (too dynamic + to distill, not valuable enough to SaaS-ify) is unsized and may be empty. +2. **Hosting strips context-bound value.** Claude Code skills are context-bound; a hosted + invocation that returns output only loses much of what makes them useful (see the ADR-0001 + amendment: hosting preserves artifact scarcity, not economic scarcity). +3. **Education mode has a free bypass.** Provenance cannot distinguish "forked the school's Skill" + from "re-authored using what the class taught" — which is nearly free and pays the school + nothing. +4. **The likeliest killer was dismissed, not analyzed.** A platform-native skill marketplace + (Anthropic/OpenAI/GitHub) had no kill-criterion. The GPT Store (OpenAI, launched Jan 2024) is + the base rate: platforms *do* ship native skill-adjacent marketplaces, and builder monetization + was weak even with zero-friction distribution to a massive user base (research, 2026-07). + +A fifth objection also survived — the co-held claim has no vesting/clawback/termination design +("when Sam quits") — but it indicts the closed-mode *design*, not the closed-mode *frame*; it is +absorbed as a design input below. + +What survives the same review: the closed-mode kernel, **reframed as a compensation/attribution +instrument**. In an intra-org deployment the employer already possesses the Skill, so +clone-resistance (critiques 1–2) is irrelevant there; what is missing is the metering rail. And the +institutional behavior the rail serves is not hypothetical: Germany's **ArbEG** statutory inventor +remuneration, corporate **patent-award programs**, and university **tech-transfer revenue splits** +all demonstrate that institutions share invention upside with individual employees — none of them +has a metering rail for AI work artifacts. + +## Decision + +**The product is a compensation, attribution, and metering layer for authored AI Skills — "Carta +for AI work artifacts" — not a skill marketplace.** + +- **Phase 1 is the terminal state by design.** The off-chain signed ledger + co-held + non-transferable claims + Story provenance must be independently viable if Phases 2–3 never + ship. On-chain settlement and tradeability are explicitly underwritten optionality, not the + destination. +- The intra-org pitch leads with **compensation and retention** — the ArbEG / patent-award / + tech-transfer shape — not royalty upside. +- The positioning against platforms is the asymmetry: platforms can ship a skill marketplace in a + quarter (the GPT Store proves they will); they will never ship 409A-structured co-held + compensation instruments. That asymmetry, plus cross-platform neutrality of provenance, is the + moat. + +## Considered options + +- **Keep the marketplace as the headline and patch the critiques individually** — rejected: the + four critiques compound in the open market and none is individually solved (distillation is + conceded, the addressable middle is unsized, the platform incumbent is proven willing); leading + with them means leading with the corpus's weakest claims. +- **Kill Phases 2–3 outright** — rejected: the mechanics (ledger schema, split logic, provenance + graph) are identical in closed and open modes, so preserving the marketplace as optionality is + nearly free, and deleting it forecloses upside the closed mode itself underwrites. + +## Consequences + +- **The open Marketplace becomes underwritten optionality**, not the identity. ADR-0006's phasing + stands with its emphasis reversed: Phases 2–3 are exercised only if warranted, they are no longer + the point of the exercise. +- **Phase-3 investment is deferred until closed-mode traction.** The securities stack (ERC-3643, + ATS, transfer agent, KYC) is not built, and counsel is not engaged for it, before the closed mode + has paying deployments. +- **Education mode is demoted** — deferred pending a re-run of the fork-economics spike whose + alternative branch is "re-author with class knowledge ≈ free" (critique 3). If that branch + holds, the school claim must be restructured (living school-maintained content, or direct + school→employer licensing) or cut. +- The co-held claim inherits first-class design inputs the marketplace frame ignored: **vesting, + clawback, and termination ("when Sam quits")** — tracked in the PRD's regulatory section. +- We have NOT validated that employers will buy this. The precedents show institutions *do* share + invention upside under statute or policy; they do not show demand for a third-party metering + rail. Design-partner interviews remain the open validation step. diff --git a/docs/adr/0008-the-wielder-is-a-wallet.md b/docs/adr/0008-the-wielder-is-a-wallet.md new file mode 100644 index 0000000..b790bb9 --- /dev/null +++ b/docs/adr/0008-the-wielder-is-a-wallet.md @@ -0,0 +1,72 @@ +# The Wielder is a wallet, not a harness + +**Status:** Accepted (2026-07-11) + +## Context + +Earlier drafts implicitly assumed a "protocol client" on the demand side: a harness that holds +tokens, reads Story state, and speaks the full protocol. That assumption is expensive exactly where +we can least afford it — demand-side adoption is the corpus's least-validated leg, and every +client-side SDK dependency shrinks the set of possible Wielders to harnesses we integrate by hand. + +Meanwhile the payment substrate has matured without us (research, 2026-07): + +- **x402 is a Linux Foundation standard.** The x402 Foundation launched 2026-04 with 20+ members + (Google, Visa, Stripe, AWS, Mastercard, Circle, Microsoft, Shopify, Amex); ~75.4M transactions / + ~$24.2M volume in the 30 days before 2026-07-11 (x402.org). +- **BYO-wallet per-call inference payment is live and growing:** Router402 (OpenRouter-like, USDC + on Base, ~200ms settlement via Flashblocks), tx402.ai (20+ EU-hosted open models), BlockRun + ClawRouter (a local proxy for OpenClaw that auto-generates a wallet and pays per LLM call; 55+ + models, Base + Solana, mainnet-only), and Cloudflare's Monetization Gateway (waitlist opened + 2026-07-01). All are third-party resellers — **no first-party OpenAI/Anthropic x402 support + exists**. +- **Harnesses already externalize the endpoint.** Pi (earendil-works/pi, ~70k stars, MIT, + TypeScript) is a minimal multi-provider coding agent with custom `baseUrl` support and + mid-session model switching — and ships no wallet or x402 support. A harness like this becomes a + Wielder by pointing its `baseUrl` at a paying proxy; nothing inside the harness needs to know + the protocol exists. + +## Decision + +**The Wielder-side protocol footprint is exactly: answer HTTP 402 with a signed USDC payment and +retry.** No Story SDK, no token custody, no chain reads client-side. The invocation-right is +exercised by paying, not held. Claude Code, Pi, a cron job, and curl are all Wielders. + +**Demand strategy — inference as the wedge.** BYO-wallet inference payment installs the +wallet-and-402 rail; skills ride the same rail. A Wielder that already pays per inference call +needs zero additional machinery to pay per skill invocation — the two legs differ only in what the +Collar does behind the gate (royalty splits vs pass-through). + +## Considered options + +- **Token-holding client** (Wielder custodies a license/royalty token; the Collar verifies + on-chain holdings before running) — rejected: forces wallet custody plus chain reads into every + harness; resurrects the per-call on-chain object ADR-0005 already rejected as uneconomic; and + turns the invocation-right into a *held* asset, dragging the securities surface ADR-0006 walls + off onto the demand side — where friction must be lowest. +- **Full protocol client** (harness-side SDK speaking Story + x402 + Collar APIs) — rejected: + shrinks the addressable Wielder population to harnesses we integrate one by one; couples + adoption to per-harness engineering; and duplicates state the Collar must own anyway — anything + the client "knows" beyond pay-and-retry is something the Collar can no longer trust. + +## Consequences + +- **Validated by construction in the Pi-Wielder spike (`spikes/pi-wielder/`):** a ~100-line paying + proxy in front of an unmodified-core Pi is the entire Wielder-side footprint — one wallet pays + per-call for inference on two models AND one hosted Skill invocation, with a unified attributed + session ledger. The spike's measured x402 payment overhead (sign → verify → settle p50/p95) + feeds the PRD's demand-side section. +- **Competitive implication: inference payment is commoditizing.** Router402, tx402.ai, and + ClawRouter already sell it; Cloudflare is entering. Payment cannot be the differentiator. The + differentiator is the **unified attributed meter** — one wallet whose ledger attributes + inference calls AND skill invocations, with royalty splits on the skill leg (see the PRD's + competitive landscape). +- The Collar remains the single trusted component (it already was — ADR-0003/0005); this decision + refuses to leak trust requirements to the client. "Trust-minimized" in this corpus means + **Wielder-side** trust-minimized, nothing stronger. +- Cost of thinness: a payer this thin can verify nothing client-side — not splits, not provenance. + Beneficiary auditability must therefore come from the Collar's published, Merkle-committed + invocation log (a Phase-1 PRD requirement), not from the Wielder. +- Dependence on resellers is real: until a first-party API accepts x402, the inference leg rides + third-party gateways (mainnet-only today), and the wedge's durability is unvalidated beyond the + 30-day volume snapshot above. From 24e35447574ec48d33d8f392301f979f15fe8755 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 11 Jul 2026 13:52:05 -0400 Subject: [PATCH 005/165] Add AGENTS.md + Codex handoff for premise-review follow-ups Four agent-doable tasks handed to the codex/prd-execution session: repair the CMA latency spike, make the phase0 write path one command, build the clone-economics spike, re-run fork economics with the free re-author branch. Human-only steps (wallet funding, LOIs, counsel) explicitly excluded. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 35 ++++++ ...26-07-11-codex-premise-review-followups.md | 106 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/handoffs/2026-07-11-codex-premise-review-followups.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca89dbf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Agent instructions — Skill Asset Protocol + +## Current assignment + +**→ `docs/handoffs/2026-07-11-codex-premise-review-followups.md`** — read it +first and execute its four tasks in order, on branch `codex/prd-execution`. + +## Repo orientation (durable) + +This is a **design-and-spike repo**, not a product codebase. The product is a +compensation, attribution, and metering layer for authored AI Skills +("Carta for AI work artifacts") — see ADR-0007. Reading order for any new +session: + +1. `CONTEXT.md` — the ubiquitous language. Use its terms exactly + (Skill, Creator, Wielder, Beneficiary, Collar, Invocation, Derivative, + Royalty claim). Definitions are load-bearing. +2. `docs/adr/` — 0001–0008; 0007 (terminal product) and 0008 (Wielder is a + wallet) encode the 2026-07-11 reframe. +3. `docs/PRD.md` — the plan; its "What we have NOT validated" section is the + honest ledger of open assumptions. Extend it, never delete from it. +4. `docs/plans/` — validated designs; `spikes/` and `prototype/` — executable + evidence. + +## Rules + +- Never commit `.env` or any private key. `phase0/.env` holds a wallet key. +- No mainnet transactions, no real funds. Testnet only; wallet funding is a + human step. +- Spike results go in the spike's own README; do not edit `CONTEXT.md`, + `docs/PRD.md`, or `docs/adr/` without an explicit instruction — propose + changes in your summary instead. +- Preserve the corpus's honesty discipline: measured numbers are labeled + measured, hypotheses are labeled hypotheses, and a spike that didn't run + says so. diff --git a/docs/handoffs/2026-07-11-codex-premise-review-followups.md b/docs/handoffs/2026-07-11-codex-premise-review-followups.md new file mode 100644 index 0000000..6e0e778 --- /dev/null +++ b/docs/handoffs/2026-07-11-codex-premise-review-followups.md @@ -0,0 +1,106 @@ +# Handoff: premise-review follow-ups (for Codex, branch `codex/prd-execution`) + +*2026-07-11. From a Claude Code session that ran an adversarial premise review, +reframed the corpus, and built the first Wielder-side spike. You are picking up +the remaining agent-doable work.* + +## Context in 60 seconds + +This repo is the **Skill Asset Protocol** — as of today reframed (all docs +committed) from "skill marketplace with royalties" to **a compensation, +attribution, and metering layer for authored AI Skills ("Carta for AI work +artifacts")**. Phase 1 (off-chain signed ledger + co-held non-transferable +claims + Story provenance) is the terminal product by design; the open +Marketplace is underwritten optionality. + +**Read these first, in order (do not skip):** +1. `docs/plans/2026-07-11-reframe-and-pi-wielder-design.md` — the decision + change spec +2. `docs/adr/0007-closed-mode-compensation-layer-as-terminal-product.md` +3. `docs/adr/0008-the-wielder-is-a-wallet.md` +4. `CONTEXT.md` — the ubiquitous language (Collar, Wielder, Invocation, …). Use + these terms exactly; do not reintroduce marketplace-first framing anywhere. + +Recent commits on this branch tell the story: `8ad9a1c` (corpus snapshot), +`1c85de7` (design doc), `d1e68b9` (pi-wielder spike, e2e green), `73cb5ca` +(reframed corpus, adversarially verified). + +## Your tasks (in priority order) + +### 1. Repair `prototype/spike-cma-latency.mjs` +The file is syntactically broken on disk — every quote is a literal `\"` +escape; `node --check` fails — while its header falsely claims it "was +syntax-verified with node --check". Fix the escaping globally, delete the false +claim from the header, verify with `node --check`. If `ANTHROPIC_API_KEY` is +available in the environment, run it and append the measured +`sessions.create` → first-token latency distribution to `prototype/README.md` +NOTES (this feeds PRD kill-criterion 2). If no key, stop after the repair and +say so — do not fabricate numbers. + +### 2. Make the phase0 write path one command from funded-wallet to proof +`phase0/` compiles and its read path works, but the on-chain write path has +never executed (wallet holds 0 IP, no ipId/txHash recorded anywhere). Do NOT +fund the wallet (human step). Instead: +- Add a single `npm run demo` that runs create-collection → register-skill → + register-derivative (multi-level chain), checks the balance first and exits + with clear faucet instructions if unfunded. +- Persist resulting ipIds/txHashes/licenseTermsIds to a committed artifact + (e.g. `phase0/registrations.json`) — later phases must consume the fork + graph; console-only output is a known defect. +- Fix the hard-coded `maxMintingFee: 0n` in registerDerivative (forking a + paid-mint parent currently reverts). +- Replace placeholder `ipfs://` metadata URIs with retrievable content (pin + real metadata, or at minimum an HTTPS URI whose bytes match the on-chain + hash) — unverifiable hashes are weak evidence in the very disputes + provenance exists to win. +- Document plainly in `phase0/README.md` that this targets Aeneid testnet + (1315) while the PRD's Phase-0 success criterion is mainnet (1514) — a known + discrepancy, do not silently "fix" it by pointing at mainnet. + +### 3. Build the clone-economics spike (`spikes/clone-economics/`) +The PRD's single most load-bearing unmeasured number (report.md §7.7; PRD +kill-criterion 4): how cheaply can a paid Skill's own I/O pairs be distilled +into a clone, and how fast must the original evolve to keep the clone stale? +Build a harness that: (a) generates N I/O pairs by invoking a target skill +(use `.claude/skills/optimizing-claude-code-prompts/` as the target), (b) +distills a clone by prompting a model with those pairs to author an equivalent +skill, (c) scores clone fidelity on held-out inputs, (d) reports cost ratio +(clone cost vs. N × invocation price). Requirements: offline mock mode +(`MOCK_LLM=1`, canned pairs) must run green with zero keys/network — follow +the pattern in `spikes/pi-wielder/` (README + RUNBOOK + e2e script). Real runs +only if keys are present. + +### 4. Re-run fork economics with the free re-author branch +`prototype/spike-fork-economics.mjs` models the forker's alternative as +"author solo at fresh cost". The education mode was deferred today precisely +because the student's *real* alternative is "re-author using everything the +class taught" at near-zero cost (ADR-0007; PRD Mode (c) DEFERRED banner). +Extend the spike with that branch: alternative cost ≈ 0, no lineage declared, +school gets nothing. Find whether ANY `inheritBps` > 0 survives, and under +what assumptions (e.g. option value of the parent's live evolution). Append +results to `prototype/README.md` NOTES with the same honest verdict style +(TBD → measured). + +## Hard constraints + +- Work and commit on `codex/prd-execution` only; small commits, one task each. +- **Never** commit `.env` or any key (repo `.gitignore` covers it; keep it so). +- **No real money, no mainnet transactions.** Testnet only, and even testnet + funding is a human step you must not attempt to automate. +- Do NOT edit the just-reframed corpus (`CONTEXT.md`, `docs/PRD.md`, + `docs/adr/`) — record spike results in the spikes'/prototype's own READMEs; + propose corpus edits in your final summary instead of making them. +- Do NOT touch `spikes/pi-wielder/` — it is done and committed (e2e: 20 checks + green offline). + +## Explicitly NOT yours (human-only, for context) + +Faucet-funding either wallet (Base Sepolia for pi-wielder, Aeneid for phase0); +design-partner LOI conversations (PRD kill-criterion 1); securities/409A +counsel engagement. + +## Suggested skills (installed under `.agents/skills/`) + +- `tdd` — for the clone-economics harness and the phase0 demo runner. +- `prototype` — task 3 is a classic throwaway-prototype-to-answer-a-question. +- `ubiquitous-language` / `domain-modeling` — keep new code speaking + CONTEXT.md's terms (Collar, Wielder, Invocation, Derivative, Royalty claim). From a02b303ab201c5dd4ecc7543673d9cf86c7d296a Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 12 Jul 2026 11:56:29 -0400 Subject: [PATCH 006/165] Fix CMA latency spike escaping --- prototype/spike-cma-latency.mjs | 79 ++++++++++++++++----------------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/prototype/spike-cma-latency.mjs b/prototype/spike-cma-latency.mjs index b6bc549..276b0d5 100644 --- a/prototype/spike-cma-latency.mjs +++ b/prototype/spike-cma-latency.mjs @@ -12,7 +12,6 @@ // ============================================================================ // WE CANNOT RUN THIS HERE. There is no API key and no CMA beta access in this // environment. Run it yourself with your own key + managed-agents beta access. -// (Script was syntax-verified with `node --check` only.) // ============================================================================ // // Prerequisites: @@ -38,27 +37,27 @@ // effort lives on the model/agent config, low + no-thinking is the floor, // high/max adaptive is the worst case (tens of seconds to first answer token). -import Anthropic from \"@anthropic-ai/sdk\"; +import Anthropic from "@anthropic-ai/sdk"; const args = Object.fromEntries( process.argv.slice(2).map((a) => { - const [k, v] = a.replace(/^--/, \"\").split(\"=\"); + const [k, v] = a.replace(/^--/, "").split("="); return [k, v === undefined ? true : v]; }), ); -const TRIALS = parseInt(args.trials ?? \"10\", 10); -const MODEL = args.model ?? \"claude-opus-4-8\"; -const COLD_ONLY = !!args[\"cold-only\"]; -const WARM_ONLY = !!args[\"warm-only\"]; -const REUSE_AGENT = args[\"reuse-agent\"] ?? process.env.CMA_AGENT_ID ?? null; -const REUSE_ENV = args[\"reuse-env\"] ?? process.env.CMA_ENV_ID ?? null; +const TRIALS = parseInt(args.trials ?? "10", 10); +const MODEL = args.model ?? "claude-opus-4-8"; +const COLD_ONLY = !!args["cold-only"]; +const WARM_ONLY = !!args["warm-only"]; +const REUSE_AGENT = args["reuse-agent"] ?? process.env.CMA_AGENT_ID ?? null; +const REUSE_ENV = args["reuse-env"] ?? process.env.CMA_ENV_ID ?? null; if (!process.env.ANTHROPIC_API_KEY) { - console.error(\"ERROR: set ANTHROPIC_API_KEY in your environment.\"); + console.error("ERROR: set ANTHROPIC_API_KEY in your environment."); process.exit(1); } const client = new Anthropic(); -const PROMPT = \"Reply with exactly the word: ack\"; +const PROMPT = "Reply with exactly the word: ack"; const ms = () => Number(process.hrtime.bigint() / 1000000n); function pct(arr, p) { @@ -80,7 +79,7 @@ async function ensureAgentAndEnv() { if (!envId) { const env = await client.beta.environments.create({ name: `bench-env-${Date.now()}`, - config: { type: \"cloud\", networking: { type: \"unrestricted\" } }, + config: { type: "cloud", networking: { type: "unrestricted" } }, }); envId = env.id; console.log(`created environment ${envId}`); } @@ -88,8 +87,8 @@ async function ensureAgentAndEnv() { const agent = await client.beta.agents.create({ name: `bench-agent-${Date.now()}`, model: MODEL, - system: \"You are a latency benchmark target. Answer in one short word. Do not use tools.\", - tools: [{ type: \"agent_toolset_20260401\", default_config: { enabled: true } }], + system: "You are a latency benchmark target. Answer in one short word. Do not use tools.", + tools: [{ type: "agent_toolset_20260401", default_config: { enabled: true } }], }); agentId = agent.id; console.log(`created agent ${agentId}`); } @@ -102,29 +101,29 @@ async function runTurn({ agentId, envId, existingSessionId }) { let sessionId = existingSessionId; if (!sessionId) { const session = await client.beta.sessions.create({ - agent: { type: \"agent\", id: agentId }, environment_id: envId, + agent: { type: "agent", id: agentId }, environment_id: envId, }); sessionId = session.id; marks.tSessionCreated = ms() - t0; } const stream = await client.beta.sessions.events.stream(sessionId); marks.tStreamOpen = ms() - t0; const sendP = client.beta.sessions.events.send(sessionId, { - events: [{ type: \"user.message\", content: [{ type: \"text\", text: PROMPT }] }], + events: [{ type: "user.message", content: [{ type: "text", text: PROMPT }] }], }); let gotFirstEvent = false, gotAnswer = false; for await (const event of stream) { - if (!gotFirstEvent && event.type !== \"user.message\" && event.type !== \"user.custom_tool_result\") { + if (!gotFirstEvent && event.type !== "user.message" && event.type !== "user.custom_tool_result") { marks.tFirstEvent = ms() - t0; gotFirstEvent = true; } - if (!gotAnswer && event.type === \"agent.message\") { + if (!gotAnswer && event.type === "agent.message") { for (const block of event.content ?? []) { - if (block.type === \"text\" && block.text?.length > 0) { + if (block.type === "text" && block.text?.length > 0) { marks.tFirstAnswerToken = ms() - t0; gotAnswer = true; break; } } } - if (event.type === \"session.status_terminated\") break; - if (event.type === \"session.status_idle\" && event.stop_reason?.type !== \"requires_action\") { + if (event.type === "session.status_terminated") break; + if (event.type === "session.status_idle" && event.stop_reason?.type !== "requires_action") { marks.tIdle = ms() - t0; break; } } @@ -138,7 +137,7 @@ async function main() { const cold = { sessionCreate: [], streamOpen: [], firstEvent: [], firstAnswer: [], total: [] }; const sessionsForWarm = []; if (!WARM_ONLY) { - console.log(`\\n=== COLD (sessions.create on hot path) ===`); + console.log(`\n=== COLD (sessions.create on hot path) ===`); for (let i = 0; i < TRIALS; i++) { try { const { sessionId, marks } = await runTurn({ agentId, envId }); @@ -148,17 +147,17 @@ async function main() { cold.firstAnswer.push(marks.tFirstAnswerToken); cold.total.push(marks.tIdle); sessionsForWarm.push(sessionId); - process.stdout.write(` trial ${i+1}/${TRIALS}: create=${marks.tSessionCreated}ms firstEvent=${marks.tFirstEvent}ms firstAnswer=${marks.tFirstAnswerToken}ms\\n`); + process.stdout.write(` trial ${i+1}/${TRIALS}: create=${marks.tSessionCreated}ms firstEvent=${marks.tFirstEvent}ms firstAnswer=${marks.tFirstAnswerToken}ms\n`); } catch (e) { console.error(` trial ${i+1} failed:`, e?.message ?? e); } } } const warm = { streamOpen: [], firstEvent: [], firstAnswer: [], total: [] }; if (!COLD_ONLY) { - console.log(`\\n=== WARM (reuse existing session, no sessions.create) ===`); + console.log(`\n=== WARM (reuse existing session, no sessions.create) ===`); let pool = sessionsForWarm; if (pool.length === 0) { for (let i = 0; i < Math.min(TRIALS, 3); i++) { - const s = await client.beta.sessions.create({ agent: { type: \"agent\", id: agentId }, environment_id: envId }); + const s = await client.beta.sessions.create({ agent: { type: "agent", id: agentId }, environment_id: envId }); pool.push(s.id); } } @@ -170,26 +169,26 @@ async function main() { warm.firstEvent.push(marks.tFirstEvent); warm.firstAnswer.push(marks.tFirstAnswerToken); warm.total.push(marks.tIdle); - process.stdout.write(` trial ${i+1}/${TRIALS}: firstEvent=${marks.tFirstEvent}ms firstAnswer=${marks.tFirstAnswerToken}ms\\n`); + process.stdout.write(` trial ${i+1}/${TRIALS}: firstEvent=${marks.tFirstEvent}ms firstAnswer=${marks.tFirstAnswerToken}ms\n`); } catch (e) { console.error(` trial ${i+1} failed:`, e?.message ?? e); } } } - console.log(`\\n================ SUMMARY (p50 / p95) ================`); + console.log(`\n================ SUMMARY (p50 / p95) ================`); if (!WARM_ONLY) { - console.log(\"COLD path:\"); - report(\"sessions.create\", cold.sessionCreate); - report(\"-> stream open (cumulative)\", cold.streamOpen); - report(\"-> first event (cumulative)\", cold.firstEvent); - report(\"-> first ANSWER token (cumulative)\", cold.firstAnswer); - report(\"-> idle/end_turn (cumulative)\", cold.total); + console.log("COLD path:"); + report("sessions.create", cold.sessionCreate); + report("-> stream open (cumulative)", cold.streamOpen); + report("-> first event (cumulative)", cold.firstEvent); + report("-> first ANSWER token (cumulative)", cold.firstAnswer); + report("-> idle/end_turn (cumulative)", cold.total); } if (!COLD_ONLY) { - console.log(\"WARM path (reused session):\"); - report(\"send -> stream open\", warm.streamOpen); - report(\"send -> first event\", warm.firstEvent); - report(\"send -> first ANSWER token\", warm.firstAnswer); - report(\"send -> idle/end_turn\", warm.total); + console.log("WARM path (reused session):"); + report("send -> stream open", warm.streamOpen); + report("send -> first event", warm.firstEvent); + report("send -> first ANSWER token", warm.firstAnswer); + report("send -> idle/end_turn", warm.total); } - console.log(`\\nInterpretation: for an interactive Wielder gate the perceptible numbers are 'first event' (render 'working…' immediately) and 'first answer token'. effort=low + minimal agent => single-digit seconds; high/max + adaptive thinking => first-answer-token can be tens of seconds because thinking precedes the answer. Archive/delete the bench agent+env afterward (archiving an agent is PERMANENT); delete sessions with client.beta.sessions.delete(id).`); + console.log(`\nInterpretation: for an interactive Wielder gate the perceptible numbers are 'first event' (render 'working…' immediately) and 'first answer token'. effort=low + minimal agent => single-digit seconds; high/max + adaptive thinking => first-answer-token can be tens of seconds because thinking precedes the answer. Archive/delete the bench agent+env afterward (archiving an agent is PERMANENT); delete sessions with client.beta.sessions.delete(id).`); } -main().catch((e) => { console.error(e); process.exit(1); }); \ No newline at end of file +main().catch((e) => { console.error(e); process.exit(1); }); From 3236e64a2cb1dba787ddab75dabbb3c6194a0981 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 12 Jul 2026 12:43:39 -0400 Subject: [PATCH 007/165] Add resumable Phase 0 provenance demo --- phase0/.env.example | 9 +- phase0/README.md | 139 +++++++---- phase0/fixtures/demo-base/SKILL.md | 3 + phase0/fixtures/demo-child/SKILL.md | 3 + phase0/fixtures/demo-grandchild/SKILL.md | 3 + phase0/package.json | 10 +- phase0/registrations.json | 16 ++ phase0/src/demo.ts | 278 ++++++++++++++++++++++ phase0/src/index.ts | 237 +++++++++++-------- phase0/src/metadata.ts | 160 +++++++++---- phase0/src/registrations.ts | 253 ++++++++++++++++++++ phase0/src/story.ts | 133 +++++++++++ phase0/tests/demo.test.ts | 286 +++++++++++++++++++++++ phase0/tests/metadata.test.ts | 78 +++++++ phase0/tests/registrations.test.ts | 100 ++++++++ phase0/tests/story.test.ts | 120 ++++++++++ phase0/tsconfig.json | 2 +- 17 files changed, 1638 insertions(+), 192 deletions(-) create mode 100644 phase0/fixtures/demo-base/SKILL.md create mode 100644 phase0/fixtures/demo-child/SKILL.md create mode 100644 phase0/fixtures/demo-grandchild/SKILL.md create mode 100644 phase0/registrations.json create mode 100644 phase0/src/demo.ts create mode 100644 phase0/src/registrations.ts create mode 100644 phase0/src/story.ts create mode 100644 phase0/tests/demo.test.ts create mode 100644 phase0/tests/metadata.test.ts create mode 100644 phase0/tests/registrations.test.ts create mode 100644 phase0/tests/story.test.ts diff --git a/phase0/.env.example b/phase0/.env.example index f5c7393..336fdbe 100644 --- a/phase0/.env.example +++ b/phase0/.env.example @@ -7,10 +7,13 @@ WALLET_PRIVATE_KEY= # Story Aeneid testnet RPC (default is fine) RPC_PROVIDER_URL=https://aeneid.storyrpc.io -# Set after running `npm run create-collection` (the printed spgNftContract) +# Optional for the advanced one-step commands. `npm run demo` creates and +# persists its own collection in registrations.json. SPG_NFT_CONTRACT= -# Optional: real IPFS metadata URIs. If unset, placeholders are used (fine for a -# provenance demo; the on-chain record still stores the content hashes). +# Optional advanced overrides. Each must be a retrievable HTTPS URL whose +# response bytes exactly match the metadata JSON generated for this command. +# If unset, the CLI uses an https://httpbin.org/base64/ URL containing +# the exact JSON bytes, fetches it back, and verifies its SHA-256 before writing. IP_METADATA_URI= NFT_METADATA_URI= diff --git a/phase0/README.md b/phase0/README.md index ece685a..fcb84dc 100644 --- a/phase0/README.md +++ b/phase0/README.md @@ -1,65 +1,104 @@ -# Phase 0 — Provenance (Story IP Assets + Derivatives) +# Phase 0 — Story provenance demo -The first real, shippable slice (ADR-0006, Phase 0). It establishes the -**provenance + fork-graph** that the whole moat rests on — *before* any payments, -agents, or settlement. Everything runs on **Story's Aeneid testnet**. - -What it does: -- Register a **Skill** as a Story **IP Asset** with commercial-remix **PIL** license terms. -- Hash the actual skill artifact (`--skill-file`) into the on-chain record (content provenance). -- Register a **Derivative** (fork) that declares its parent on-chain, so royalties can flow later. - -Royalty policy defaults to **LAP** (the originator keeps a share of *all* descendants regardless of -depth) — the answer to the spike-4 depth-dilution finding. Use `--policy LRP` for per-hop relative. -The LAP-vs-LRP decision is a real Phase-2 commitment (see `../docs/feasibility/prebuild-spikes.md`); -LAP is the safer default for the education vision. - -## Setup +From a funded Aeneid wallet, one command creates an SPG NFT collection and +registers a real three-level provenance graph: ```bash cd phase0 npm install -cp .env.example .env # then edit .env +cp .env.example .env # add a throwaway testnet key +npm run demo ``` -In `.env`, set `WALLET_PRIVATE_KEY` to a **throwaway** testnet key, then fund it: -- Faucet: https://aeneid.faucet.story.foundation/ (10 IP per claim) +The demo checks the RPC's chain ID and the wallet's native-IP balance before it +does anything else. An exactly-zero balance exits nonzero before metadata is +fetched, `registrations.json` is changed, or a Story transaction is submitted, +and prints the wallet, network, and Aeneid faucet URL. Funding is always a human +step: . + +## What the command writes + +The confirmed sequence is: + +1. create an SPG NFT collection; +2. register the base **Skill** with commercial-remix PIL terms and a small, + positive, testnet-only minting fee (`0.001 IP`); +3. register a declared **Derivative** of that Skill; +4. register a second-level Derivative whose parent is the first Derivative. + +The three artifacts are committed under `fixtures/`; every registration hashes +the actual `SKILL.md` bytes with SHA-256. IP and NFT metadata JSON are each +serialized exactly once, hashed with SHA-256, embedded by default in a +retrievable `https://httpbin.org/base64/` URI, fetched back, and +byte-compared before any Story write. `IP_METADATA_URI` and `NFT_METADATA_URI` +may override those defaults only when their fetched bytes match exactly. + +Immediately after every confirmed transaction, the demo atomically replaces +`registrations.json`. The artifact records the network and wallet, SPG contract, +collection transaction, and each Skill/Derivative's `ipId`, `tokenId`, +transaction hash, inherited license-terms ID, parent IP IDs, minting-fee values, +and metadata URI/hash pairs. Native `bigint` values are persisted as decimal +strings. A rerun with the same chain and wallet skips confirmed stages and +resumes only the missing suffix; a different wallet is rejected rather than +overwriting the proof. + +The committed artifact is deliberately `status: "not-run"` with null IDs. The +write path remains **unexecuted** until `registrations.json` contains confirmed +IDs and transaction hashes from a funded-wallet run. + +Before each Derivative transaction, the CLI calls +`predictMintingLicenseFee(..., amount: 1)` and passes the returned `tokenAmount` +as an explicit `maxMintingFee` cap. In Story SDK 1.4.4, `0` means unlimited; the +explicit predicted cap is spend protection and exercises the paid-parent path, +not a workaround for a claimed SDK incompatibility. + +## Network boundary and PRD criterion + +This code targets **Story Aeneid testnet, chain ID 1315**, and never sends +mainnet transactions or real funds. The PRD's Phase-0 success criterion targets +**Story mainnet, chain ID 1514**, and requires broader proof than this testnet +write path. Aeneid results are useful engineering evidence; they **do not +satisfy the PRD Phase-0 success criterion**. + +## Advanced commands + +The individual commands remain available for targeted runs. They return only +after validating the SDK's optional proof fields, and registrations verify +metadata bytes before submitting a transaction. ```bash -npm run check # confirms wallet, chain, balance +npm run check + +npm run create-collection -- --name Skills --symbol SKILL + +npm run register-skill -- \ + --spg \ + --name "research-skill" \ + --description "base research Skill" \ + --skill-file fixtures/demo-base/SKILL.md \ + --rev-share 25 \ + --policy LAP \ + --minting-fee 1000000000000000 + +npm run register-derivative -- \ + --spg \ + --parent \ + --license-terms-id \ + --name "research-derivative" \ + --description "declared Derivative" \ + --skill-file fixtures/demo-child/SKILL.md ``` -## Usage +`register-derivative` predicts the parent's current minting fee immediately +before its write and uses that value as the cap. Explorer links use +. + +## Local verification ```bash -# 1. one-time: create an SPG NFT collection to mint Skills into -npm run create-collection -# → copy the printed spgNftContract into .env as SPG_NFT_CONTRACT - -# 2. register a Skill (here, hashing this repo's own CONTEXT.md as the artifact) -npm run register-skill -- --name "fin-modeling" --description "base financial-modeling skill" \ - --skill-file ../CONTEXT.md --rev-share 25 -# → prints ipId + licenseTermsId, and the exact command to fork it - -# 3. register a Derivative (a student forking the school's Skill) -npm run register-derivative -- --parent --license-terms-id \ - --name "biotech-fin-modeling" --description "a fork specialised for biotech" +npm test +npm run typecheck ``` -Each command prints an explorer link (`https://aeneid.explorer.story.foundation/ipa/`) so you -can see the IP Asset and its parent/child links on-chain. - -## What this proves (and what it deliberately doesn't) - -**Proves:** a Skill and its fork lineage are registered on-chain with declared ancestry and license -terms — the provenance layer the marketplace moat depends on (ADR-0004). - -**Out of scope for Phase 0** (later phases): the payment gate (x402), hidden hosted execution -(managed agent), and royalty *settlement* (the two-leg flow of ADR-0005). This slice intentionally -has no money movement. - -## Notes -- Testnet only. Use a throwaway key. -- Metadata: if you don't set `IP_METADATA_URI` / `NFT_METADATA_URI`, placeholders are used — the - on-chain content **hashes** are still real. For production, pin the metadata JSON to IPFS. -- SDK: `@story-protocol/core-sdk` v1.4.x. `commercialRevShare` is an integer **percent (0–100)**. +The tests use injected fakes only at filesystem, HTTP, RPC, and Story SDK +boundaries. They make no network calls and use no wallet key. diff --git a/phase0/fixtures/demo-base/SKILL.md b/phase0/fixtures/demo-base/SKILL.md new file mode 100644 index 0000000..0a606b7 --- /dev/null +++ b/phase0/fixtures/demo-base/SKILL.md @@ -0,0 +1,3 @@ +# Demo Research Skill + +Summarize one supplied source and identify its central claim. diff --git a/phase0/fixtures/demo-child/SKILL.md b/phase0/fixtures/demo-child/SKILL.md new file mode 100644 index 0000000..1b9f337 --- /dev/null +++ b/phase0/fixtures/demo-child/SKILL.md @@ -0,0 +1,3 @@ +# Demo Research Derivative + +Compare two supplied sources and call out material disagreements. diff --git a/phase0/fixtures/demo-grandchild/SKILL.md b/phase0/fixtures/demo-grandchild/SKILL.md new file mode 100644 index 0000000..8d1983f --- /dev/null +++ b/phase0/fixtures/demo-grandchild/SKILL.md @@ -0,0 +1,3 @@ +# Demo Research Grandchild + +Synthesize the comparison into three concise, attributed conclusions. diff --git a/phase0/package.json b/phase0/package.json index e10bfdb..064e234 100644 --- a/phase0/package.json +++ b/phase0/package.json @@ -5,10 +5,12 @@ "version": "0.0.0", "description": "Phase 0 (ADR-0006): register a Skill as a Story IP Asset with commercial-remix PIL terms, and declare a Derivative (fork). Provenance + fork-graph on Story Aeneid testnet.", "scripts": { - "check": "tsx src/index.ts check", - "create-collection": "tsx src/index.ts create-collection", - "register-skill": "tsx src/index.ts register-skill", - "register-derivative": "tsx src/index.ts register-derivative", + "check": "node --import tsx src/index.ts check", + "demo": "node --import tsx src/index.ts demo", + "create-collection": "node --import tsx src/index.ts create-collection", + "register-skill": "node --import tsx src/index.ts register-skill", + "register-derivative": "node --import tsx src/index.ts register-derivative", + "test": "node --import tsx --test tests/*.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/phase0/registrations.json b/phase0/registrations.json new file mode 100644 index 0000000..7dc6c0f --- /dev/null +++ b/phase0/registrations.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "status": "not-run", + "network": { + "name": "Story Aeneid", + "chainId": 1315 + }, + "wallet": null, + "spgNftContract": null, + "collectionTxHash": null, + "registrations": { + "root": null, + "child": null, + "grandchild": null + } +} diff --git a/phase0/src/demo.ts b/phase0/src/demo.ts new file mode 100644 index 0000000..222bffd --- /dev/null +++ b/phase0/src/demo.ts @@ -0,0 +1,278 @@ +import { fileURLToPath } from "node:url"; +import { parseEther } from "viem"; + +import { + AENEID_NETWORK, + type DemoStage, + type MetadataProof, + type RegistrationManifest, + type RegistrationProof, + type RegistrationStore, +} from "./registrations"; + +export const AENEID_CHAIN_ID = AENEID_NETWORK.chainId; +export const AENEID_FAUCET_URL = "https://aeneid.faucet.story.foundation/"; +export const DEMO_ROOT_MINTING_FEE = parseEther("0.001"); + +export interface DemoSkillDefinition { + stage: DemoStage; + name: string; + description: string; + artifactPath: string; +} + +export interface PreparedMetadata { + onchain: { + ipMetadataURI: string; + ipMetadataHash: `0x${string}`; + nftMetadataURI: string; + nftMetadataHash: `0x${string}`; + }; + proof: MetadataProof; +} + +export interface DemoMetadataProvider { + prepare(input: DemoSkillDefinition & { creatorAddress: `0x${string}` }): Promise; +} + +export interface DemoChain { + getChainId(): Promise; + getBalance(address: `0x${string}`): Promise; + createCollection(input: { + name: string; + symbol: string; + mintFeeRecipient: `0x${string}`; + }): Promise<{ spgNftContract: `0x${string}`; txHash: `0x${string}` }>; + registerSkill(input: { + spgNftContract: `0x${string}`; + metadata: PreparedMetadata["onchain"]; + defaultMintingFee: bigint; + revShare?: number; + policy?: "LAP" | "LRP"; + }): Promise<{ + ipId: `0x${string}`; + tokenId: bigint; + txHash: `0x${string}`; + licenseTermsId: bigint; + }>; + predictMintingLicenseFee(input: { + licensorIpId: `0x${string}`; + licenseTermsId: bigint; + amount: number; + }): Promise<{ tokenAmount: bigint }>; + registerDerivative(input: { + spgNftContract: `0x${string}`; + parentIpId: `0x${string}`; + licenseTermsId: bigint; + maxMintingFee: bigint; + metadata: PreparedMetadata["onchain"]; + }): Promise<{ ipId: `0x${string}`; tokenId: bigint; txHash: `0x${string}` }>; +} + +export interface RunDemoInput { + wallet: `0x${string}`; + chain: DemoChain; + metadata: DemoMetadataProvider; + store: RegistrationStore; + skills?: readonly DemoSkillDefinition[]; +} + +export const DEMO_SKILLS: readonly DemoSkillDefinition[] = [ + { + stage: "root", + name: "demo-research-skill", + description: "A tiny research Skill used to prove Story provenance on Aeneid.", + artifactPath: fileURLToPath(new URL("../fixtures/demo-base/SKILL.md", import.meta.url)), + }, + { + stage: "child", + name: "demo-research-derivative", + description: "A declared Derivative that adds source comparison.", + artifactPath: fileURLToPath(new URL("../fixtures/demo-child/SKILL.md", import.meta.url)), + }, + { + stage: "grandchild", + name: "demo-research-grandchild", + description: "A second-level Derivative that adds concise synthesis.", + artifactPath: fileURLToPath(new URL("../fixtures/demo-grandchild/SKILL.md", import.meta.url)), + }, +] as const; + +function definitionFor(skills: readonly DemoSkillDefinition[], stage: DemoStage) { + const definition = skills.find((skill) => skill.stage === stage); + if (!definition) throw new Error(`Missing demo Skill definition for ${stage}`); + return definition; +} + +function ensureResumableManifest(manifest: RegistrationManifest, wallet: `0x${string}`) { + if (manifest.schemaVersion !== 1) { + throw new Error(`Unsupported registrations schema version: ${manifest.schemaVersion}`); + } + if (manifest.network.chainId !== AENEID_CHAIN_ID) { + throw new Error( + `registrations.json targets chain ${manifest.network.chainId}; expected Story Aeneid (${AENEID_CHAIN_ID})`, + ); + } + if (manifest.wallet && manifest.wallet.toLowerCase() !== wallet.toLowerCase()) { + throw new Error(`registrations.json belongs to wallet ${manifest.wallet}; current wallet is ${wallet}`); + } + if (manifest.registrations.root === null && (manifest.registrations.child || manifest.registrations.grandchild)) { + throw new Error("registrations.json is inconsistent: a Derivative exists without the root Skill"); + } + if (manifest.registrations.child === null && manifest.registrations.grandchild) { + throw new Error("registrations.json is inconsistent: the grandchild exists without its parent Derivative"); + } +} + +function proof(input: { + definition: DemoSkillDefinition; + result: { ipId: `0x${string}`; tokenId: bigint; txHash: `0x${string}` }; + licenseTermsId: bigint; + parentIpIds: `0x${string}`[]; + defaultMintingFee?: bigint; + maxMintingFee?: bigint; + metadata: PreparedMetadata; +}): RegistrationProof { + return { + stage: input.definition.stage, + kind: input.definition.stage === "root" ? "Skill" : "Derivative", + name: input.definition.name, + ipId: input.result.ipId, + tokenId: input.result.tokenId.toString(), + txHash: input.result.txHash, + licenseTermsId: input.licenseTermsId.toString(), + parentIpIds: input.parentIpIds, + defaultMintingFee: input.defaultMintingFee?.toString() ?? null, + maxMintingFee: input.maxMintingFee?.toString() ?? null, + metadata: input.metadata.proof, + }; +} + +export async function runDemo(input: RunDemoInput): Promise { + const chainId = await input.chain.getChainId(); + if (chainId !== AENEID_CHAIN_ID) { + throw new Error(`Wrong network: expected Story Aeneid (${AENEID_CHAIN_ID}), received chain ${chainId}`); + } + + const balance = await input.chain.getBalance(input.wallet); + if (balance === 0n) { + throw new Error( + `Wallet ${input.wallet} on Story Aeneid (${AENEID_CHAIN_ID}) has exactly 0 IP. Fund it manually at ${AENEID_FAUCET_URL}`, + ); + } + + const manifest = await input.store.load(); + ensureResumableManifest(manifest, input.wallet); + const skills = input.skills ?? DEMO_SKILLS; + const metadata = new Map(); + + for (const stage of ["root", "child", "grandchild"] as const) { + if (!manifest.registrations[stage]) { + const definition = definitionFor(skills, stage); + metadata.set(stage, await input.metadata.prepare({ ...definition, creatorAddress: input.wallet })); + } + } + + if (!manifest.spgNftContract) { + const collection = await input.chain.createCollection({ + name: "Skill Asset Protocol Demo", + symbol: "SKILL", + mintFeeRecipient: input.wallet, + }); + manifest.wallet = input.wallet; + manifest.spgNftContract = collection.spgNftContract; + manifest.collectionTxHash = collection.txHash; + manifest.status = "partial"; + await input.store.save(manifest); + } + + const spgNftContract = manifest.spgNftContract; + if (!spgNftContract) throw new Error("Collection transaction confirmed without an SPG NFT contract"); + + if (!manifest.registrations.root) { + const definition = definitionFor(skills, "root"); + const prepared = metadata.get("root"); + if (!prepared) throw new Error("Root Skill metadata was not prepared"); + const result = await input.chain.registerSkill({ + spgNftContract, + metadata: prepared.onchain, + defaultMintingFee: DEMO_ROOT_MINTING_FEE, + }); + manifest.registrations.root = proof({ + definition, + result, + licenseTermsId: result.licenseTermsId, + parentIpIds: [], + defaultMintingFee: DEMO_ROOT_MINTING_FEE, + metadata: prepared, + }); + manifest.status = "partial"; + await input.store.save(manifest); + } + + const root = manifest.registrations.root; + if (!root) throw new Error("Root Skill transaction confirmed without a persisted proof"); + + if (!manifest.registrations.child) { + const definition = definitionFor(skills, "child"); + const prepared = metadata.get("child"); + if (!prepared) throw new Error("Child Derivative metadata was not prepared"); + const licenseTermsId = BigInt(root.licenseTermsId); + const predicted = await input.chain.predictMintingLicenseFee({ + licensorIpId: root.ipId, + licenseTermsId, + amount: 1, + }); + const result = await input.chain.registerDerivative({ + spgNftContract, + parentIpId: root.ipId, + licenseTermsId, + maxMintingFee: predicted.tokenAmount, + metadata: prepared.onchain, + }); + manifest.registrations.child = proof({ + definition, + result, + licenseTermsId, + parentIpIds: [root.ipId], + maxMintingFee: predicted.tokenAmount, + metadata: prepared, + }); + manifest.status = "partial"; + await input.store.save(manifest); + } + + const child = manifest.registrations.child; + if (!child) throw new Error("Child Derivative transaction confirmed without a persisted proof"); + + if (!manifest.registrations.grandchild) { + const definition = definitionFor(skills, "grandchild"); + const prepared = metadata.get("grandchild"); + if (!prepared) throw new Error("Grandchild Derivative metadata was not prepared"); + const licenseTermsId = BigInt(child.licenseTermsId); + const predicted = await input.chain.predictMintingLicenseFee({ + licensorIpId: child.ipId, + licenseTermsId, + amount: 1, + }); + const result = await input.chain.registerDerivative({ + spgNftContract, + parentIpId: child.ipId, + licenseTermsId, + maxMintingFee: predicted.tokenAmount, + metadata: prepared.onchain, + }); + manifest.registrations.grandchild = proof({ + definition, + result, + licenseTermsId, + parentIpIds: [child.ipId], + maxMintingFee: predicted.tokenAmount, + metadata: prepared, + }); + manifest.status = "complete"; + await input.store.save(manifest); + } + + return manifest; +} diff --git a/phase0/src/index.ts b/phase0/src/index.ts index cd5933b..95f3bb8 100644 --- a/phase0/src/index.ts +++ b/phase0/src/index.ts @@ -1,152 +1,203 @@ +import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; + import { formatEther, type Address } from "viem"; -import { PILFlavor, WIP_TOKEN_ADDRESS, NativeRoyaltyPolicy } from "@story-protocol/core-sdk"; -import { getAccount, getClient, getPublicClient, EXPLORER } from "./client"; -import { buildMetadata } from "./metadata"; -const { values: o, positionals } = parseArgs({ +import { EXPLORER, getAccount, getClient, getPublicClient } from "./client"; +import { runDemo } from "./demo"; +import { HttpMetadataProvider } from "./metadata"; +import { FileRegistrationStore } from "./registrations"; +import { + StoryChain, +} from "./story"; + +const { values: options, positionals } = parseArgs({ allowPositionals: true, options: { name: { type: "string" }, description: { type: "string" }, "skill-file": { type: "string" }, - "rev-share": { type: "string" }, // percent 0-100 - policy: { type: "string" }, // LAP | LRP - "minting-fee": { type: "string" }, // wei + "rev-share": { type: "string" }, + policy: { type: "string" }, + "minting-fee": { type: "string" }, spg: { type: "string" }, symbol: { type: "string" }, - parent: { type: "string" }, // parent ipId for a derivative + parent: { type: "string" }, "license-terms-id": { type: "string" }, }, }); -const cmd = positionals[0]; +const command = positionals[0]; +const registrationsPath = fileURLToPath(new URL("../registrations.json", import.meta.url)); + +function storyChain(): StoryChain { + return new StoryChain({ + sdk: getClient(), + publicClient: getPublicClient(), + }); +} + +function requiredOption(name: keyof typeof options): string { + const value = options[name]; + if (typeof value !== "string" || value.length === 0) throw new Error(`--${name} is required`); + return value; +} function spgAddress(): Address { - const spg = (o.spg ?? process.env.SPG_NFT_CONTRACT) as Address | undefined; - if (!spg) throw new Error("No SPG collection. Run `npm run create-collection`, then set SPG_NFT_CONTRACT in .env (or pass --spg)."); - return spg; + const value = options.spg ?? process.env.SPG_NFT_CONTRACT; + if (!value) { + throw new Error( + "No SPG collection. Run `npm run demo`, or run `npm run create-collection` and pass --spg/set SPG_NFT_CONTRACT.", + ); + } + return value as Address; +} + +function royaltyPolicy(): "LAP" | "LRP" { + const value = (options.policy ?? "LAP").toUpperCase(); + if (value !== "LAP" && value !== "LRP") throw new Error("--policy must be LAP or LRP"); + return value; } async function check() { const account = getAccount(); - const bal = await getPublicClient().getBalance({ address: account.address }); + const chain = storyChain(); + const chainId = await chain.getChainId(); + const balance = await chain.getBalance(account.address); console.log("wallet :", account.address); - console.log("chain : Story Aeneid (1315)"); - console.log("balance :", formatEther(bal), "IP"); - console.log("SPG contract:", process.env.SPG_NFT_CONTRACT || "(none — run create-collection)"); - if (bal === 0n) console.log("\n⚠ Wallet has 0 IP. Fund it: https://aeneid.faucet.story.foundation/"); + console.log("chain :", `Story Aeneid (${chainId})`); + console.log("balance :", formatEther(balance), "IP"); + console.log("SPG contract:", process.env.SPG_NFT_CONTRACT || "(none — npm run demo creates one)"); + if (balance === 0n) { + console.log("\n⚠ Wallet has 0 IP. Fund it manually: https://aeneid.faucet.story.foundation/"); + } + return { wallet: account.address, chainId, balance }; } async function createCollection() { - const client = getClient(); - const res = await client.nftClient.createNFTCollection({ - name: o.name ?? "Skills", - symbol: o.symbol ?? "SKILL", - isPublicMinting: true, - mintOpen: true, - mintFeeRecipient: getAccount().address, - contractURI: "", + const account = getAccount(); + const result = await storyChain().createCollection({ + name: options.name ?? "Skills", + symbol: options.symbol ?? "SKILL", + mintFeeRecipient: account.address, }); - console.log("✓ SPG collection created"); - console.log("spgNftContract:", res.spgNftContract); - console.log("txHash :", res.txHash); - console.log("\n→ add to .env: SPG_NFT_CONTRACT=" + res.spgNftContract); + console.log("✓ SPG NFT collection created"); + console.log("spgNftContract:", result.spgNftContract); + console.log("txHash :", result.txHash); + console.log("\n→ pass to advanced commands: --spg " + result.spgNftContract); + return result; } async function registerSkill() { - if (!o.name) throw new Error("--name is required"); - const client = getClient(); - const revShare = Number(o["rev-share"] ?? "25"); - const policy = (o.policy ?? "LAP").toUpperCase() === "LRP" ? NativeRoyaltyPolicy.LRP : NativeRoyaltyPolicy.LAP; - const mintingFee = BigInt(o["minting-fee"] ?? "0"); - - const meta = buildMetadata(client, { - name: o.name, - description: o.description ?? "", - creatorAddress: getAccount().address, - skillFile: o["skill-file"], - }); - - const terms = PILFlavor.commercialRemix({ - defaultMintingFee: mintingFee, - commercialRevShare: revShare, // percent 0-100 - currency: WIP_TOKEN_ADDRESS, - royaltyPolicy: policy, // default LAP — protects originators against depth-dilution (spike 4) + const account = getAccount(); + const name = requiredOption("name"); + const artifactPath = requiredOption("skill-file"); + const metadata = await new HttpMetadataProvider().prepare({ + stage: "root", + name, + description: options.description ?? "", + creatorAddress: account.address, + artifactPath, }); - - const res = await client.ipAsset.mintAndRegisterIpAssetWithPilTerms({ + const revShare = Number(options["rev-share"] ?? "25"); + const result = await storyChain().registerSkill({ spgNftContract: spgAddress(), - licenseTermsData: [{ terms }], - ipMetadata: meta.onchain, + metadata: metadata.onchain, + defaultMintingFee: BigInt(options["minting-fee"] ?? "0"), + revShare, + policy: royaltyPolicy(), }); - console.log("✓ Skill registered as a Story IP Asset"); - console.log("ipId :", res.ipId); - console.log("tokenId :", res.tokenId?.toString()); - console.log("licenseTermsId:", res.licenseTermsIds?.[0]?.toString()); - console.log("revShare :", revShare + "%", "| policy:", policy === NativeRoyaltyPolicy.LRP ? "LRP" : "LAP"); - if (meta.contentHash) console.log("skill content :", meta.contentHash, "(keccak256 of the artifact)"); - console.log("txHash :", res.txHash); - console.log("explorer :", `${EXPLORER}/ipa/${res.ipId}`); - console.log("\n→ to fork this Skill: npm run register-derivative -- --parent " + res.ipId + " --license-terms-id " + res.licenseTermsIds?.[0]?.toString() + " --name \"\""); + console.log("ipId :", result.ipId); + console.log("tokenId :", result.tokenId.toString()); + console.log("licenseTermsId:", result.licenseTermsId.toString()); + console.log("artifact hash :", metadata.proof.artifact.mediaHash, "(SHA-256)"); + console.log("txHash :", result.txHash); + console.log("explorer :", `${EXPLORER}/ipa/${result.ipId}`); + return { ...result, metadata }; } async function registerDerivative() { - if (!o.name) throw new Error("--name is required"); - if (!o.parent) throw new Error("--parent is required"); - if (!o["license-terms-id"]) throw new Error("--license-terms-id is required (the parent's licenseTermsId)"); - const client = getClient(); - - const meta = buildMetadata(client, { - name: o.name, - description: o.description ?? "", - creatorAddress: getAccount().address, - skillFile: o["skill-file"], + const account = getAccount(); + const name = requiredOption("name"); + const artifactPath = requiredOption("skill-file"); + const parentIpId = requiredOption("parent") as Address; + const licenseTermsId = BigInt(requiredOption("license-terms-id")); + const metadata = await new HttpMetadataProvider().prepare({ + stage: "child", + name, + description: options.description ?? "", + creatorAddress: account.address, + artifactPath, }); - - const res = await client.ipAsset.mintAndRegisterIpAndMakeDerivative({ + const chain = storyChain(); + const predicted = await chain.predictMintingLicenseFee({ + licensorIpId: parentIpId, + licenseTermsId, + amount: 1, + }); + const result = await chain.registerDerivative({ spgNftContract: spgAddress(), - derivData: { - parentIpIds: [o.parent as Address], - licenseTermsIds: [BigInt(o["license-terms-id"])], - maxMintingFee: 0n, - maxRts: 100_000_000, - maxRevenueShare: 100, - }, - ipMetadata: meta.onchain, + parentIpId, + licenseTermsId, + maxMintingFee: predicted.tokenAmount, + metadata: metadata.onchain, }); + console.log("✓ Derivative registered (declared parent on-chain)"); + console.log("ipId :", result.ipId); + console.log("tokenId :", result.tokenId.toString()); + console.log("parentIpId :", parentIpId); + console.log("licenseTermsId:", licenseTermsId.toString()); + console.log("maxMintingFee :", predicted.tokenAmount.toString(), "(predicted explicit cap)"); + console.log("artifact hash :", metadata.proof.artifact.mediaHash, "(SHA-256)"); + console.log("txHash :", result.txHash); + console.log("explorer :", `${EXPLORER}/ipa/${result.ipId}`); + return { ...result, licenseTermsId, maxMintingFee: predicted.tokenAmount, metadata }; +} - console.log("✓ Derivative registered (owes royalties to its parent on-chain)"); - console.log("ipId :", res.ipId); - console.log("tokenId :", res.tokenId?.toString()); - console.log("parent :", o.parent); - console.log("txHash :", res.txHash); - console.log("explorer:", `${EXPLORER}/ipa/${res.ipId}`); +async function demo() { + const account = getAccount(); + const manifest = await runDemo({ + wallet: account.address, + chain: storyChain(), + metadata: new HttpMetadataProvider(), + store: new FileRegistrationStore(registrationsPath), + }); + console.log("✓ Phase 0 provenance demo status:", manifest.status); + console.log("wallet :", manifest.wallet); + console.log("spgNftContract:", manifest.spgNftContract); + for (const stage of ["root", "child", "grandchild"] as const) { + const registration = manifest.registrations[stage]; + console.log(`${stage.padEnd(10)}:`, registration?.ipId ?? "not registered"); + } + console.log("proof artifact:", registrationsPath); + return manifest; } -const commands: Record Promise> = { +const commands: Record Promise> = { check, + demo, "create-collection": createCollection, "register-skill": registerSkill, "register-derivative": registerDerivative, }; async function main() { - const run = cmd ? commands[cmd] : undefined; + const run = command ? commands[command] : undefined; if (!run) { console.log("Phase 0 — Story provenance CLI\n"); console.log("commands:"); + console.log(" npm run demo"); console.log(" npm run check"); console.log(" npm run create-collection [-- --name Skills --symbol SKILL]"); - console.log(" npm run register-skill -- --name \"\" [--description \"..\"] [--skill-file path] [--rev-share 25] [--policy LAP|LRP]"); - console.log(" npm run register-derivative -- --parent --license-terms-id --name \"\""); - process.exit(cmd ? 1 : 0); + console.log(" npm run register-skill -- --spg
--name --skill-file [--rev-share 25] [--policy LAP|LRP]"); + console.log(" npm run register-derivative -- --spg
--parent --license-terms-id --name --skill-file "); + process.exit(command ? 1 : 0); } await run(); } -main().catch((err) => { - console.error("\n✗ " + (err instanceof Error ? err.message : String(err))); +main().catch((error) => { + console.error("\n✗ " + (error instanceof Error ? error.message : String(error))); process.exit(1); }); diff --git a/phase0/src/metadata.ts b/phase0/src/metadata.ts index 23baa89..f49ee8e 100644 --- a/phase0/src/metadata.ts +++ b/phase0/src/metadata.ts @@ -1,46 +1,124 @@ -import { readFileSync } from "node:fs"; -import { keccak256, toHex } from "viem"; -import type { StoryClient } from "@story-protocol/core-sdk"; - -export interface SkillInput { - name: string; - description: string; - creatorAddress: `0x${string}`; - /** Optional path to the real SKILL.md / artifact — its content is hashed for provenance. */ - skillFile?: string; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { isAbsolute, relative } from "node:path"; + +import type { + DemoMetadataProvider, + DemoSkillDefinition, + PreparedMetadata, +} from "./demo"; + +export interface HttpMetadataProviderOptions { + fetcher?: typeof fetch; + ipMetadataURI?: string; + nftMetadataURI?: string; +} + +function sha256Hex(bytes: Uint8Array): `0x${string}` { + return `0x${createHash("sha256").update(bytes).digest("hex")}`; +} + +function inlineHttpsUri(bytes: Uint8Array): string { + const unpadded = Buffer.from(bytes).toString("base64url"); + const padding = (4 - (unpadded.length % 4)) % 4; + return `https://httpbin.org/base64/${unpadded}${"=".repeat(padding)}`; } -/** - * Build the IP + NFT metadata for a Skill and the on-chain {uri, hash} pairs the - * register calls expect. `createdAt` is fixed so the metadata (and its hash) is - * reproducible — do not inject a wall-clock time here. - */ -export function buildMetadata(client: StoryClient, input: SkillInput) { - let contentHash: `0x${string}` | undefined; - if (input.skillFile) { - const content = readFileSync(input.skillFile, "utf8"); - contentHash = keccak256(toHex(content)); // fingerprint of the actual artifact +function requireHttps(uri: string, label: string): string { + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + throw new Error(`${label} must be a valid HTTPS URL`); } + if (parsed.protocol !== "https:") { + throw new Error(`${label} must use HTTPS`); + } + return parsed.toString(); +} - const ipMetadata = client.ipAsset.generateIpMetadata({ - title: input.name, - description: input.description, - createdAt: "0", - ipType: "skill", - creators: [ - { name: "creator", address: input.creatorAddress, contributionPercent: 100 }, - ], - ...(contentHash ? { mediaHash: contentHash, mediaType: "text/markdown" } : {}), - }); - - const nftMetadata = { name: input.name, description: input.description }; - - const onchain = { - ipMetadataURI: process.env.IP_METADATA_URI || "ipfs://placeholder-ip-metadata", - ipMetadataHash: keccak256(toHex(JSON.stringify(ipMetadata))), - nftMetadataURI: process.env.NFT_METADATA_URI || "ipfs://placeholder-nft-metadata", - nftMetadataHash: keccak256(toHex(JSON.stringify(nftMetadata))), - }; - - return { ipMetadata, nftMetadata, contentHash, onchain }; +async function verifyExactBytes( + fetcher: typeof fetch, + uri: string, + expectedBytes: Uint8Array, + expectedHash: `0x${string}`, +): Promise { + const response = await fetcher(uri); + if (!response.ok) { + throw new Error(`Metadata fetch failed (${response.status}) for ${uri}`); + } + const fetched = new Uint8Array(await response.arrayBuffer()); + if (!Buffer.from(fetched).equals(Buffer.from(expectedBytes))) { + throw new Error(`Fetched metadata bytes do not match the serialized metadata for ${uri}`); + } + if (sha256Hex(fetched) !== expectedHash) { + throw new Error(`Fetched metadata SHA-256 does not match the expected hash for ${uri}`); + } +} + +export class HttpMetadataProvider implements DemoMetadataProvider { + private readonly fetcher: typeof fetch; + private readonly ipMetadataURI?: string; + private readonly nftMetadataURI?: string; + + constructor(options: HttpMetadataProviderOptions = {}) { + this.fetcher = options.fetcher ?? fetch; + this.ipMetadataURI = options.ipMetadataURI ?? (process.env.IP_METADATA_URI?.trim() || undefined); + this.nftMetadataURI = options.nftMetadataURI ?? (process.env.NFT_METADATA_URI?.trim() || undefined); + } + + async prepare( + input: DemoSkillDefinition & { creatorAddress: `0x${string}` }, + ): Promise { + const artifactBytes = await readFile(input.artifactPath); + const mediaHash = sha256Hex(artifactBytes); + const ipMetadata = { + title: input.name, + description: input.description, + createdAt: "0", + ipType: "skill", + creators: [ + { name: "creator", address: input.creatorAddress, contributionPercent: 100 }, + ], + mediaHash, + mediaType: "text/markdown", + }; + const nftMetadata = { name: input.name, description: input.description }; + + // Serialize each document exactly once. These exact bytes are encoded into the + // default URI, hashed, fetched back, and compared before a Story write. + const ipBytes = Buffer.from(JSON.stringify(ipMetadata), "utf8"); + const nftBytes = Buffer.from(JSON.stringify(nftMetadata), "utf8"); + const ipMetadataHash = sha256Hex(ipBytes); + const nftMetadataHash = sha256Hex(nftBytes); + const ipMetadataURI = requireHttps( + this.ipMetadataURI ?? inlineHttpsUri(ipBytes), + "IP_METADATA_URI", + ); + const nftMetadataURI = requireHttps( + this.nftMetadataURI ?? inlineHttpsUri(nftBytes), + "NFT_METADATA_URI", + ); + + await verifyExactBytes(this.fetcher, ipMetadataURI, ipBytes, ipMetadataHash); + await verifyExactBytes(this.fetcher, nftMetadataURI, nftBytes, nftMetadataHash); + + const artifactPath = isAbsolute(input.artifactPath) + ? relative(process.cwd(), input.artifactPath) + : input.artifactPath; + + return { + onchain: { + ipMetadataURI, + ipMetadataHash, + nftMetadataURI, + nftMetadataHash, + }, + proof: { + ip: { uri: ipMetadataURI, hash: ipMetadataHash }, + nft: { uri: nftMetadataURI, hash: nftMetadataHash }, + artifact: { path: artifactPath, mediaHash, mediaType: "text/markdown" }, + }, + }; + } } diff --git a/phase0/src/registrations.ts b/phase0/src/registrations.ts new file mode 100644 index 0000000..764e919 --- /dev/null +++ b/phase0/src/registrations.ts @@ -0,0 +1,253 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +export const REGISTRATION_SCHEMA_VERSION = 1 as const; +export const AENEID_NETWORK = { + name: "Story Aeneid", + chainId: 1315, +} as const; + +export type DemoStage = "root" | "child" | "grandchild"; +export type ManifestStatus = "not-run" | "partial" | "complete"; + +export interface MetadataPairProof { + uri: string; + hash: `0x${string}`; +} + +export interface MetadataProof { + ip: MetadataPairProof; + nft: MetadataPairProof; + artifact: { + path: string; + mediaHash: `0x${string}`; + mediaType: string; + }; +} + +export interface RegistrationProof { + stage: DemoStage; + kind: "Skill" | "Derivative"; + name: string; + ipId: `0x${string}`; + tokenId: string; + txHash: `0x${string}`; + licenseTermsId: string; + parentIpIds: `0x${string}`[]; + defaultMintingFee: string | null; + maxMintingFee: string | null; + metadata: MetadataProof; +} + +export interface RegistrationManifest { + schemaVersion: typeof REGISTRATION_SCHEMA_VERSION; + status: ManifestStatus; + network: typeof AENEID_NETWORK; + wallet: `0x${string}` | null; + spgNftContract: `0x${string}` | null; + collectionTxHash: `0x${string}` | null; + registrations: Record; +} + +export interface RegistrationStore { + load(): Promise; + save(manifest: RegistrationManifest): Promise; +} + +export function createEmptyRegistrationManifest(): RegistrationManifest { + return { + schemaVersion: REGISTRATION_SCHEMA_VERSION, + status: "not-run", + network: AENEID_NETWORK, + wallet: null, + spgNftContract: null, + collectionTxHash: null, + registrations: { + root: null, + child: null, + grandchild: null, + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isAddress(value: unknown): value is `0x${string}` { + return typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value); +} + +function isHash(value: unknown): value is `0x${string}` { + return typeof value === "string" && /^0x[0-9a-fA-F]{64}$/.test(value); +} + +function isDecimal(value: unknown): value is string { + return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value); +} + +function requireHttps(value: unknown, path: string): asserts value is string { + if (typeof value !== "string") throw new Error(`${path} must be an HTTPS URL`); + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${path} must be an HTTPS URL`); + } + if (url.protocol !== "https:") throw new Error(`${path} must be an HTTPS URL`); +} + +function validateMetadata(value: unknown, stage: DemoStage): asserts value is MetadataProof { + if (!isRecord(value)) throw new Error(`${stage}.metadata must be an object`); + for (const key of ["ip", "nft"] as const) { + const pair = value[key]; + if (!isRecord(pair)) throw new Error(`${stage}.metadata.${key} must be an object`); + requireHttps(pair.uri, `${stage}.metadata.${key}.uri`); + if (!isHash(pair.hash)) throw new Error(`${stage}.metadata.${key}.hash must be a SHA-256 hash`); + } + const artifact = value.artifact; + if (!isRecord(artifact)) throw new Error(`${stage}.metadata.artifact must be an object`); + if (typeof artifact.path !== "string" || artifact.path.length === 0) { + throw new Error(`${stage}.metadata.artifact.path must be a non-empty string`); + } + if (!isHash(artifact.mediaHash)) { + throw new Error(`${stage}.metadata.artifact.mediaHash must be a SHA-256 hash`); + } + if (artifact.mediaType !== "text/markdown") { + throw new Error(`${stage}.metadata.artifact.mediaType must be text/markdown`); + } +} + +function validateProof(value: unknown, stage: DemoStage): RegistrationProof | null { + if (value === null) return null; + if (!isRecord(value) || value.stage !== stage) throw new Error(`${stage}.stage must be ${stage}`); + const expectedKind = stage === "root" ? "Skill" : "Derivative"; + if (value.kind !== expectedKind) throw new Error(`${stage}.kind must be ${expectedKind}`); + if (typeof value.name !== "string" || value.name.length === 0) { + throw new Error(`${stage}.name must be a non-empty string`); + } + if (!isAddress(value.ipId)) throw new Error(`${stage}.ipId must be a 20-byte address`); + if (!isDecimal(value.tokenId)) throw new Error(`${stage}.tokenId must be a decimal string`); + if (!isHash(value.txHash)) throw new Error(`${stage}.txHash must be a 32-byte hash`); + if (!isDecimal(value.licenseTermsId)) { + throw new Error(`${stage}.licenseTermsId must be a decimal string`); + } + if (!Array.isArray(value.parentIpIds) || !value.parentIpIds.every(isAddress)) { + throw new Error(`${stage}.parentIpIds must contain addresses`); + } + if (stage === "root") { + if (value.parentIpIds.length !== 0) throw new Error("root.parentIpIds must be empty"); + if (!isDecimal(value.defaultMintingFee)) { + throw new Error("root.defaultMintingFee must be a decimal string"); + } + if (value.maxMintingFee !== null) throw new Error("root.maxMintingFee must be null"); + } else { + if (value.parentIpIds.length !== 1) throw new Error(`${stage}.parentIpIds must contain one parent`); + if (value.defaultMintingFee !== null) throw new Error(`${stage}.defaultMintingFee must be null`); + if (!isDecimal(value.maxMintingFee)) { + throw new Error(`${stage}.maxMintingFee must be a decimal string`); + } + } + validateMetadata(value.metadata, stage); + return value as unknown as RegistrationProof; +} + +export function parseRegistrationManifest(value: unknown): RegistrationManifest { + if (!isRecord(value) || value.schemaVersion !== REGISTRATION_SCHEMA_VERSION) { + throw new Error(`registrations.json must use schemaVersion ${REGISTRATION_SCHEMA_VERSION}`); + } + if (!(["not-run", "partial", "complete"] as unknown[]).includes(value.status)) { + throw new Error("registrations.json has an invalid status"); + } + if ( + !isRecord(value.network) + || value.network.chainId !== AENEID_NETWORK.chainId + || value.network.name !== AENEID_NETWORK.name + ) { + throw new Error(`registrations.json must target Story Aeneid (${AENEID_NETWORK.chainId})`); + } + if (value.wallet !== null && !isAddress(value.wallet)) { + throw new Error("registrations.json wallet must be an address or null"); + } + if (value.spgNftContract !== null && !isAddress(value.spgNftContract)) { + throw new Error("registrations.json spgNftContract must be an address or null"); + } + if (value.collectionTxHash !== null && !isHash(value.collectionTxHash)) { + throw new Error("registrations.json collectionTxHash must be a 32-byte hash or null"); + } + if (!isRecord(value.registrations)) { + throw new Error("registrations.json is missing registrations"); + } + for (const stage of ["root", "child", "grandchild"] as const) { + if (!(stage in value.registrations)) { + throw new Error(`registrations.json is missing the ${stage} stage`); + } + } + const root = validateProof(value.registrations.root, "root"); + const child = validateProof(value.registrations.child, "child"); + const grandchild = validateProof(value.registrations.grandchild, "grandchild"); + + if ((value.spgNftContract === null) !== (value.collectionTxHash === null)) { + throw new Error("registrations.json must persist the SPG contract and collection txHash together"); + } + if (child && (!root || child.parentIpIds[0].toLowerCase() !== root.ipId.toLowerCase())) { + throw new Error("child.parentIpIds must point to the root Skill"); + } + if (grandchild && (!child || grandchild.parentIpIds[0].toLowerCase() !== child.ipId.toLowerCase())) { + throw new Error("grandchild.parentIpIds must point to the child Derivative"); + } + if (child && root && child.licenseTermsId !== root.licenseTermsId) { + throw new Error("child.licenseTermsId must inherit the root license terms"); + } + if (grandchild && child && grandchild.licenseTermsId !== child.licenseTermsId) { + throw new Error("grandchild.licenseTermsId must inherit the child license terms"); + } + + if (value.status === "not-run") { + if (value.wallet || value.spgNftContract || value.collectionTxHash || root || child || grandchild) { + throw new Error("not-run registrations.json cannot contain confirmed proof fields"); + } + } else { + if (!value.wallet || !value.spgNftContract || !value.collectionTxHash) { + throw new Error(`${value.status} registrations.json must contain wallet and collection proof`); + } + if (value.status === "complete" && (!root || !child || !grandchild)) { + throw new Error("complete registrations.json must contain root, child, and grandchild proofs"); + } + if (value.status === "partial" && root && child && grandchild) { + throw new Error("registrations.json with all stages must use complete status"); + } + } + return value as unknown as RegistrationManifest; +} + +export class FileRegistrationStore implements RegistrationStore { + constructor(private readonly path: string) {} + + async load(): Promise { + try { + const raw = await readFile(this.path, "utf8"); + return parseRegistrationManifest(JSON.parse(raw) as unknown); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return createEmptyRegistrationManifest(); + } + throw error; + } + } + + async save(manifest: RegistrationManifest): Promise { + parseRegistrationManifest(manifest); + await mkdir(dirname(this.path), { recursive: true }); + const temporaryPath = `${this.path}.${process.pid}.${randomUUID()}.tmp`; + const contents = `${JSON.stringify(manifest, null, 2)}\n`; + try { + await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx" }); + await rename(temporaryPath, this.path); + } catch (error) { + await unlink(temporaryPath).catch(() => undefined); + throw error; + } + } +} diff --git a/phase0/src/story.ts b/phase0/src/story.ts new file mode 100644 index 0000000..b846dfd --- /dev/null +++ b/phase0/src/story.ts @@ -0,0 +1,133 @@ +import { + NativeRoyaltyPolicy, + PILFlavor, + type StoryClient, + WIP_TOKEN_ADDRESS, +} from "@story-protocol/core-sdk"; + +import type { DemoChain, PreparedMetadata } from "./demo"; + +type Address = `0x${string}`; + +export type StorySdkBoundary = { + nftClient: Pick; + ipAsset: Pick< + StoryClient["ipAsset"], + "mintAndRegisterIpAssetWithPilTerms" | "mintAndRegisterIpAndMakeDerivative" + >; + license: Pick; +}; + +export interface StoryPublicClientBoundary { + getChainId(): Promise; + getBalance(input: { address: Address }): Promise; +} + +function required(value: T | undefined, label: string): T { + if (value === undefined) throw new Error(`Story SDK response is missing ${label}`); + return value; +} + +function validateRevShare(value: number): number { + if (!Number.isFinite(value) || value < 0 || value > 100) { + throw new Error("rev-share must be a finite number from 0 to 100"); + } + return value; +} + +export class StoryChain implements DemoChain { + private readonly sdk: StorySdkBoundary; + private readonly publicClient: StoryPublicClientBoundary; + + constructor(input: { sdk: StorySdkBoundary; publicClient: StoryPublicClientBoundary }) { + this.sdk = input.sdk; + this.publicClient = input.publicClient; + } + + getChainId(): Promise { + return this.publicClient.getChainId(); + } + + getBalance(address: Address): Promise { + return this.publicClient.getBalance({ address }); + } + + async createCollection(input: { + name: string; + symbol: string; + mintFeeRecipient: Address; + }) { + const response = await this.sdk.nftClient.createNFTCollection({ + ...input, + isPublicMinting: true, + mintOpen: true, + contractURI: "", + }); + return { + spgNftContract: required(response.spgNftContract, "spgNftContract"), + txHash: required(response.txHash, "collection txHash"), + }; + } + + async registerSkill(input: { + spgNftContract: Address; + metadata: PreparedMetadata["onchain"]; + defaultMintingFee: bigint; + revShare?: number; + policy?: "LAP" | "LRP"; + }) { + const revShare = validateRevShare(input.revShare ?? 25); + const policy = input.policy ?? "LAP"; + const terms = PILFlavor.commercialRemix({ + defaultMintingFee: input.defaultMintingFee, + commercialRevShare: revShare, + currency: WIP_TOKEN_ADDRESS, + royaltyPolicy: policy === "LRP" ? NativeRoyaltyPolicy.LRP : NativeRoyaltyPolicy.LAP, + }); + const response = await this.sdk.ipAsset.mintAndRegisterIpAssetWithPilTerms({ + spgNftContract: input.spgNftContract, + licenseTermsData: [{ terms }], + ipMetadata: input.metadata, + }); + return { + ipId: required(response.ipId, "ipId"), + tokenId: required(response.tokenId, "tokenId"), + txHash: required(response.txHash, "registration txHash"), + licenseTermsId: required(response.licenseTermsIds?.[0], "licenseTermsId"), + }; + } + + async predictMintingLicenseFee(input: { + licensorIpId: Address; + licenseTermsId: bigint; + amount: number; + }) { + const response = await this.sdk.license.predictMintingLicenseFee(input); + return { tokenAmount: required(response.tokenAmount, "predicted tokenAmount") }; + } + + async registerDerivative(input: { + spgNftContract: Address; + parentIpId: Address; + licenseTermsId: bigint; + maxMintingFee: bigint; + metadata: PreparedMetadata["onchain"]; + }) { + const response = await this.sdk.ipAsset.mintAndRegisterIpAndMakeDerivative({ + spgNftContract: input.spgNftContract, + derivData: { + parentIpIds: [input.parentIpId], + licenseTermsIds: [input.licenseTermsId], + maxMintingFee: input.maxMintingFee, + maxRts: 100_000_000, + maxRevenueShare: 100, + }, + ipMetadata: input.metadata, + }); + return { + ipId: required(response.ipId, "ipId"), + tokenId: required(response.tokenId, "tokenId"), + txHash: required(response.txHash, "registration txHash"), + }; + } +} diff --git a/phase0/tests/demo.test.ts b/phase0/tests/demo.test.ts new file mode 100644 index 0000000..60caa87 --- /dev/null +++ b/phase0/tests/demo.test.ts @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AENEID_CHAIN_ID, + AENEID_FAUCET_URL, + runDemo, + type DemoChain, + type DemoMetadataProvider, +} from "../src/demo"; +import { + createEmptyRegistrationManifest, + type RegistrationManifest, + type RegistrationStore, +} from "../src/registrations"; + +const WALLET = "0x00000000000000000000000000000000000000aa" as const; +const COLLECTION = "0x00000000000000000000000000000000000000bb" as const; +const ROOT = "0x0000000000000000000000000000000000000001" as const; +const CHILD = "0x0000000000000000000000000000000000000002" as const; +const GRANDCHILD = "0x0000000000000000000000000000000000000003" as const; + +function clone(value: T): T { + return structuredClone(value); +} + +class MemoryStore implements RegistrationStore { + loadCalls = 0; + saveCalls = 0; + snapshots: RegistrationManifest[] = []; + + constructor(public manifest = createEmptyRegistrationManifest()) {} + + async load(): Promise { + this.loadCalls += 1; + return clone(this.manifest); + } + + async save(manifest: RegistrationManifest): Promise { + this.saveCalls += 1; + this.manifest = clone(manifest); + this.snapshots.push(clone(manifest)); + } +} + +class FakeMetadata implements DemoMetadataProvider { + stages: string[] = []; + + async prepare(input: { stage: string; artifactPath: string }) { + this.stages.push(input.stage); + const digit = input.stage === "root" ? "1" : input.stage === "child" ? "2" : "3"; + const hash = `0x${digit.repeat(64)}` as const; + return { + onchain: { + ipMetadataURI: `https://example.test/${input.stage}/ip`, + ipMetadataHash: hash, + nftMetadataURI: `https://example.test/${input.stage}/nft`, + nftMetadataHash: hash, + }, + proof: { + ip: { uri: `https://example.test/${input.stage}/ip`, hash }, + nft: { uri: `https://example.test/${input.stage}/nft`, hash }, + artifact: { path: input.artifactPath, mediaHash: hash, mediaType: "text/markdown" }, + }, + }; + } +} + +class FakeChain implements DemoChain { + writes: string[] = []; + derivativeInputs: Array<{ parentIpId: string; licenseTermsId: bigint; maxMintingFee: bigint }> = []; + failOn: "collection" | "root" | "child" | "grandchild" | null = null; + + constructor( + public chainId: number = AENEID_CHAIN_ID, + public balance: bigint = 1n, + public predictedFee: bigint = 123n, + ) {} + + async getChainId() { + return this.chainId; + } + + async getBalance() { + return this.balance; + } + + async createCollection() { + this.writes.push("collection"); + if (this.failOn === "collection") throw new Error("collection failed"); + return { spgNftContract: COLLECTION, txHash: "0xcollection" as const }; + } + + async registerSkill() { + this.writes.push("root"); + if (this.failOn === "root") throw new Error("root failed"); + return { ipId: ROOT, tokenId: 1n, txHash: "0xroot" as const, licenseTermsId: 7n }; + } + + async predictMintingLicenseFee() { + return { tokenAmount: this.predictedFee }; + } + + async registerDerivative(input: { + parentIpId: string; + licenseTermsId: bigint; + maxMintingFee: bigint; + }) { + const stage = input.parentIpId === ROOT ? "child" : "grandchild"; + this.writes.push(stage); + this.derivativeInputs.push(input); + if (this.failOn === stage) throw new Error(`${stage} failed`); + return stage === "child" + ? { ipId: CHILD, tokenId: 2n, txHash: "0xchild" as const } + : { ipId: GRANDCHILD, tokenId: 3n, txHash: "0xgrandchild" as const }; + } +} + +test("zero balance exits with faucet details before metadata, store, or writes", async () => { + const chain = new FakeChain(AENEID_CHAIN_ID, 0n); + const metadata = new FakeMetadata(); + const store = new MemoryStore(); + + await assert.rejects( + runDemo({ wallet: WALLET, chain, metadata, store }), + (error: Error) => { + assert.match(error.message, new RegExp(WALLET, "i")); + assert.match(error.message, /Story Aeneid \(1315\)/); + assert.match(error.message, new RegExp(AENEID_FAUCET_URL.replaceAll(".", "\\."))); + return true; + }, + ); + + assert.deepEqual(chain.writes, []); + assert.deepEqual(metadata.stages, []); + assert.equal(store.loadCalls, 0); + assert.equal(store.saveCalls, 0); +}); + +test("wrong chain exits before balance, metadata, store, or writes", async () => { + const chain = new FakeChain(1514, 1n); + let balanceCalls = 0; + chain.getBalance = async () => { + balanceCalls += 1; + return 1n; + }; + const metadata = new FakeMetadata(); + const store = new MemoryStore(); + + await assert.rejects(runDemo({ wallet: WALLET, chain, metadata, store }), /expected Story Aeneid \(1315\).*1514/i); + + assert.equal(balanceCalls, 0); + assert.deepEqual(chain.writes, []); + assert.deepEqual(metadata.stages, []); + assert.equal(store.loadCalls, 0); + assert.equal(store.saveCalls, 0); +}); + +test("metadata verification failure rejects before any chain write or manifest save", async () => { + const chain = new FakeChain(); + const store = new MemoryStore(); + const metadata: DemoMetadataProvider = { + prepare: async () => { + throw new Error("fetched metadata bytes do not match"); + }, + }; + + await assert.rejects(runDemo({ wallet: WALLET, chain, metadata, store }), /bytes do not match/); + + assert.deepEqual(chain.writes, []); + assert.equal(store.saveCalls, 0); +}); + +test("funded demo persists a root Skill and two-level Derivative chain", async () => { + const chain = new FakeChain(); + const metadata = new FakeMetadata(); + const store = new MemoryStore(); + + const result = await runDemo({ wallet: WALLET, chain, metadata, store }); + + assert.deepEqual(chain.writes, ["collection", "root", "child", "grandchild"]); + assert.deepEqual(metadata.stages, ["root", "child", "grandchild"]); + assert.equal(result.status, "complete"); + assert.equal(result.wallet, WALLET); + assert.equal(result.spgNftContract, COLLECTION); + assert.equal(result.collectionTxHash, "0xcollection"); + assert.deepEqual(result.registrations.root?.parentIpIds, []); + assert.deepEqual(result.registrations.child?.parentIpIds, [ROOT]); + assert.deepEqual(result.registrations.grandchild?.parentIpIds, [CHILD]); + assert.equal(result.registrations.root?.ipId, ROOT); + assert.equal(result.registrations.child?.ipId, CHILD); + assert.equal(result.registrations.grandchild?.ipId, GRANDCHILD); + assert.equal(result.registrations.root?.tokenId, "1"); + assert.equal(result.registrations.child?.tokenId, "2"); + assert.equal(result.registrations.grandchild?.tokenId, "3"); + assert.equal(result.registrations.root?.licenseTermsId, "7"); + assert.equal(result.registrations.child?.licenseTermsId, "7"); + assert.equal(result.registrations.grandchild?.licenseTermsId, "7"); + assert.equal(result.registrations.child?.maxMintingFee, "123"); + assert.equal(result.registrations.grandchild?.maxMintingFee, "123"); + assert.equal(store.saveCalls, 4); +}); + +test("each Derivative receives the fee predicted immediately before it", async () => { + const chain = new FakeChain(); + + await runDemo({ wallet: WALLET, chain, metadata: new FakeMetadata(), store: new MemoryStore() }); + + assert.deepEqual(chain.derivativeInputs.map(({ parentIpId, licenseTermsId, maxMintingFee }) => ({ + parentIpId, + licenseTermsId, + maxMintingFee, + })), [ + { parentIpId: ROOT, licenseTermsId: 7n, maxMintingFee: 123n }, + { parentIpId: CHILD, licenseTermsId: 7n, maxMintingFee: 123n }, + ]); +}); + +test("a confirmed partial proof survives failure and rerun resumes only missing stages", async () => { + const store = new MemoryStore(); + const firstChain = new FakeChain(); + firstChain.failOn = "child"; + + await assert.rejects( + runDemo({ wallet: WALLET, chain: firstChain, metadata: new FakeMetadata(), store }), + /child failed/, + ); + + assert.equal(store.manifest.status, "partial"); + assert.equal(store.manifest.spgNftContract, COLLECTION); + assert.equal(store.manifest.registrations.root?.ipId, ROOT); + assert.equal(store.manifest.registrations.child, null); + assert.equal(store.saveCalls, 2); + + const resumedChain = new FakeChain(); + const resumedMetadata = new FakeMetadata(); + const result = await runDemo({ wallet: WALLET, chain: resumedChain, metadata: resumedMetadata, store }); + + assert.deepEqual(resumedChain.writes, ["child", "grandchild"]); + assert.deepEqual(resumedMetadata.stages, ["child", "grandchild"]); + assert.equal(result.status, "complete"); + assert.equal(result.registrations.root?.txHash, "0xroot"); + assert.equal(result.registrations.child?.txHash, "0xchild"); + assert.equal(result.registrations.grandchild?.txHash, "0xgrandchild"); +}); + +for (const scenario of [ + { failOn: "root" as const, saves: 1, lastProof: "collection" }, + { failOn: "grandchild" as const, saves: 3, lastProof: "child" }, +]) { + test(`failure at ${scenario.failOn} keeps every earlier confirmed proof`, async () => { + const store = new MemoryStore(); + const chain = new FakeChain(); + chain.failOn = scenario.failOn; + + await assert.rejects( + runDemo({ wallet: WALLET, chain, metadata: new FakeMetadata(), store }), + new RegExp(`${scenario.failOn} failed`), + ); + + assert.equal(store.saveCalls, scenario.saves); + assert.equal(store.manifest.spgNftContract, COLLECTION); + assert.equal(store.manifest.collectionTxHash, "0xcollection"); + if (scenario.lastProof === "child") { + assert.equal(store.manifest.registrations.root?.txHash, "0xroot"); + assert.equal(store.manifest.registrations.child?.txHash, "0xchild"); + assert.equal(store.manifest.registrations.grandchild, null); + } else { + assert.equal(store.manifest.registrations.root, null); + } + }); +} + +test("a run refuses to resume another wallet's proof", async () => { + const manifest = createEmptyRegistrationManifest(); + manifest.status = "partial"; + manifest.wallet = "0x00000000000000000000000000000000000000ff"; + manifest.spgNftContract = COLLECTION; + manifest.collectionTxHash = "0xcollection"; + const store = new MemoryStore(manifest); + + await assert.rejects( + runDemo({ wallet: WALLET, chain: new FakeChain(), metadata: new FakeMetadata(), store }), + /belongs to wallet.*00ff/i, + ); +}); diff --git a/phase0/tests/metadata.test.ts b/phase0/tests/metadata.test.ts new file mode 100644 index 0000000..aec6f55 --- /dev/null +++ b/phase0/tests/metadata.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { HttpMetadataProvider } from "../src/metadata"; + +const WALLET = "0x00000000000000000000000000000000000000aa" as const; +const ARTIFACT = "# Fixture Skill\n\nReturn one concise answer.\n"; +const ARTIFACT_HASH = "0x316163c97d7669db8b33fc52d05d458318acec1aad98fc5515b2f0f508957912"; +const IP_HASH = "0x6c42a18b50e58fbe307da28995b381d6c5690f7815070733946c20344b58bae9"; +const NFT_HASH = "0xdb24ac9487196af7c830b213c8127f08b3b2c12eb2baeb1fcc6625281ab16098"; +const IP_JSON = `{"title":"Fixture Skill","description":"fixture","createdAt":"0","ipType":"skill","creators":[{"name":"creator","address":"${WALLET}","contributionPercent":100}],"mediaHash":"${ARTIFACT_HASH}","mediaType":"text/markdown"}`; +const NFT_JSON = '{"name":"Fixture Skill","description":"fixture"}'; + +async function withArtifact(t: test.TestContext) { + const directory = await mkdtemp(join(tmpdir(), "phase0-metadata-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, "SKILL.md"); + await writeFile(path, ARTIFACT, "utf8"); + return path; +} + +function decodeHttpbin(input: string | URL | Request): Response { + const url = new URL(String(input)); + assert.equal(url.origin, "https://httpbin.org"); + assert.match(url.pathname, /^\/base64\//); + const encoded = url.pathname.slice("/base64/".length); + if (encoded.length % 4 !== 0) return new Response("Incorrect Base64 data"); + return new Response(Buffer.from(encoded, "base64url")); +} + +test("metadata HTTPS URIs decode to exact serialized bytes with Story SHA-256 hashes", async (t) => { + const artifactPath = await withArtifact(t); + const fetched: string[] = []; + const provider = new HttpMetadataProvider({ + fetcher: async (input) => { + fetched.push(String(input)); + return decodeHttpbin(input); + }, + }); + + const prepared = await provider.prepare({ + stage: "root", + name: "Fixture Skill", + description: "fixture", + creatorAddress: WALLET, + artifactPath, + }); + + assert.equal(prepared.proof.artifact.mediaHash, ARTIFACT_HASH); + assert.equal(prepared.onchain.ipMetadataHash, IP_HASH); + assert.equal(prepared.onchain.nftMetadataHash, NFT_HASH); + assert.equal(prepared.proof.ip.hash, IP_HASH); + assert.equal(prepared.proof.nft.hash, NFT_HASH); + assert.equal(fetched.length, 2); + assert.equal(Buffer.from(new URL(fetched[0]).pathname.slice("/base64/".length), "base64url").toString(), IP_JSON); + assert.equal(Buffer.from(new URL(fetched[1]).pathname.slice("/base64/".length), "base64url").toString(), NFT_JSON); +}); + +test("altered fetched metadata bytes are rejected", async (t) => { + const artifactPath = await withArtifact(t); + const provider = new HttpMetadataProvider({ + fetcher: async () => new Response("altered"), + }); + + await assert.rejects( + provider.prepare({ + stage: "root", + name: "Fixture Skill", + description: "fixture", + creatorAddress: WALLET, + artifactPath, + }), + /fetched metadata bytes do not match/i, + ); +}); diff --git a/phase0/tests/registrations.test.ts b/phase0/tests/registrations.test.ts new file mode 100644 index 0000000..d3f6604 --- /dev/null +++ b/phase0/tests/registrations.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + FileRegistrationStore, + createEmptyRegistrationManifest, + parseRegistrationManifest, + type RegistrationProof, +} from "../src/registrations"; + +const WALLET = "0x00000000000000000000000000000000000000aa" as const; +const SPG = "0x00000000000000000000000000000000000000bb" as const; +const TX_HASH = `0x${"1".repeat(64)}` as const; +const METADATA_HASH = `0x${"2".repeat(64)}` as const; + +function proof(stage: "root" | "child" | "grandchild", ipId: `0x${string}`): RegistrationProof { + return { + stage, + kind: stage === "root" ? "Skill" : "Derivative", + name: stage, + ipId, + tokenId: stage === "root" ? "1" : stage === "child" ? "2" : "3", + txHash: TX_HASH, + licenseTermsId: "7", + parentIpIds: [], + defaultMintingFee: stage === "root" ? "1000000000000000" : null, + maxMintingFee: stage === "root" ? null : "123", + metadata: { + ip: { uri: "https://example.test/ip", hash: METADATA_HASH }, + nft: { uri: "https://example.test/nft", hash: METADATA_HASH }, + artifact: { path: `fixtures/${stage}/SKILL.md`, mediaHash: METADATA_HASH, mediaType: "text/markdown" }, + }, + }; +} + +test("filesystem store atomically writes valid JSON and round-trips the schema", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-registrations-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, "registrations.json"); + const store = new FileRegistrationStore(path); + const manifest = createEmptyRegistrationManifest(); + manifest.wallet = WALLET; + manifest.status = "partial"; + manifest.spgNftContract = SPG; + manifest.collectionTxHash = TX_HASH; + + await store.save(manifest); + + const raw = await readFile(path, "utf8"); + assert.deepEqual(JSON.parse(raw), manifest); + assert.equal(raw.endsWith("\n"), true); + assert.deepEqual(await store.load(), manifest); + assert.deepEqual(await readdir(directory), ["registrations.json"]); +}); + +test("filesystem store returns the honest not-run schema when the artifact is absent", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-registrations-")); + t.after(() => rm(directory, { recursive: true, force: true })); + + const manifest = await new FileRegistrationStore(join(directory, "registrations.json")).load(); + + assert.deepEqual(manifest, createEmptyRegistrationManifest()); +}); + +test("manifest parser rejects truthy malformed proofs instead of treating them as resumable", () => { + const manifest = createEmptyRegistrationManifest(); + manifest.status = "partial"; + manifest.wallet = WALLET; + manifest.spgNftContract = SPG; + manifest.collectionTxHash = TX_HASH; + manifest.registrations.root = {} as RegistrationProof; + + assert.throws(() => parseRegistrationManifest(manifest), /root\.stage/i); +}); + +test("manifest parser enforces status and exact Derivative parent edges", () => { + const rootId = "0x0000000000000000000000000000000000000001" as const; + const childId = "0x0000000000000000000000000000000000000002" as const; + const grandchildId = "0x0000000000000000000000000000000000000003" as const; + const manifest = createEmptyRegistrationManifest(); + manifest.status = "complete"; + manifest.wallet = WALLET; + manifest.spgNftContract = SPG; + manifest.collectionTxHash = TX_HASH; + manifest.registrations.root = proof("root", rootId); + manifest.registrations.child = { + ...proof("child", childId), + parentIpIds: ["0x00000000000000000000000000000000000000ff"], + }; + manifest.registrations.grandchild = { ...proof("grandchild", grandchildId), parentIpIds: [childId] }; + + assert.throws(() => parseRegistrationManifest(manifest), /child\.parentIpIds.*root/i); + + manifest.registrations.child.parentIpIds = [rootId]; + manifest.registrations.grandchild = null; + assert.throws(() => parseRegistrationManifest(manifest), /complete.*grandchild/i); +}); diff --git a/phase0/tests/story.test.ts b/phase0/tests/story.test.ts new file mode 100644 index 0000000..3a8e8f9 --- /dev/null +++ b/phase0/tests/story.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { StoryChain, type StorySdkBoundary } from "../src/story"; + +const WALLET = "0x00000000000000000000000000000000000000aa" as const; +const SPG = "0x00000000000000000000000000000000000000bb" as const; +const PARENT = "0x0000000000000000000000000000000000000001" as const; +const HASH = `0x${"1".repeat(64)}` as const; +const METADATA = { + ipMetadataURI: "https://example.test/ip", + ipMetadataHash: HASH, + nftMetadataURI: "https://example.test/nft", + nftMetadataHash: HASH, +}; + +function sdk(overrides: Partial = {}): StorySdkBoundary { + return { + nftClient: { + createNFTCollection: async () => ({ spgNftContract: SPG, txHash: "0xcollection" }), + }, + ipAsset: { + mintAndRegisterIpAssetWithPilTerms: async () => ({ + ipId: PARENT, + tokenId: 1n, + txHash: "0xroot", + licenseTermsIds: [7n], + }), + mintAndRegisterIpAndMakeDerivative: async () => ({ + ipId: "0x0000000000000000000000000000000000000002", + tokenId: 2n, + txHash: "0xchild", + }), + }, + license: { + predictMintingLicenseFee: async () => ({ currencyToken: SPG, tokenAmount: 123n }), + }, + ...overrides, + }; +} + +function chain(boundary = sdk()) { + return new StoryChain({ + sdk: boundary, + publicClient: { + getChainId: async () => 1315, + getBalance: async () => 1n, + }, + }); +} + +test("SDK optional proof fields are guarded before the workflow can advance", async () => { + const missingCollection = sdk({ + nftClient: { createNFTCollection: async () => ({ txHash: "0xcollection" }) }, + }); + await assert.rejects( + chain(missingCollection).createCollection({ name: "Skills", symbol: "SKILL", mintFeeRecipient: WALLET }), + /missing spgNftContract/i, + ); + + const missingRootTerms = sdk({ + ipAsset: { + mintAndRegisterIpAssetWithPilTerms: async () => ({ ipId: PARENT, tokenId: 1n, txHash: "0xroot" }), + mintAndRegisterIpAndMakeDerivative: async () => ({ + ipId: "0x0000000000000000000000000000000000000002", + tokenId: 2n, + txHash: "0xchild", + }), + }, + }); + await assert.rejects( + chain(missingRootTerms).registerSkill({ + spgNftContract: SPG, + metadata: METADATA, + defaultMintingFee: 1n, + }), + /missing licenseTermsId/i, + ); +}); + +test("predicted fee is passed as the standalone Derivative maxMintingFee cap", async () => { + let observedCap: bigint | undefined; + const boundary = sdk({ + ipAsset: { + mintAndRegisterIpAssetWithPilTerms: async () => ({ + ipId: PARENT, + tokenId: 1n, + txHash: "0xroot", + licenseTermsIds: [7n], + }), + mintAndRegisterIpAndMakeDerivative: async (input) => { + const cap = input.derivData.maxMintingFee; + if (typeof cap !== "bigint") throw new Error("expected a bigint cap"); + observedCap = cap; + return { + ipId: "0x0000000000000000000000000000000000000002", + tokenId: 2n, + txHash: "0xchild", + }; + }, + }, + }); + const story = chain(boundary); + const prediction = await story.predictMintingLicenseFee({ + licensorIpId: PARENT, + licenseTermsId: 7n, + amount: 1, + }); + + await story.registerDerivative({ + spgNftContract: SPG, + parentIpId: PARENT, + licenseTermsId: 7n, + maxMintingFee: prediction.tokenAmount, + metadata: METADATA, + }); + + assert.equal(prediction.tokenAmount, 123n); + assert.equal(observedCap, 123n); +}); diff --git a/phase0/tsconfig.json b/phase0/tsconfig.json index 77069ae..51512d3 100644 --- a/phase0/tsconfig.json +++ b/phase0/tsconfig.json @@ -10,5 +10,5 @@ "types": ["node"], "noEmit": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts"] } From 69e7c6c17ba92792e1e0a8fee15fc90efc998c84 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 12 Jul 2026 13:13:41 -0400 Subject: [PATCH 008/165] Record first real-network run results (Base Sepolia) Two paid legs through the paying proxy against the live x402.org facilitator: real settlements (txHashes recorded), on-chain balances reconcile to the cent, measured payment overhead ~781ms/call (facilitator-dominated). Feeds the PRD spike list item 13. Co-Authored-By: Claude Fable 5 --- spikes/pi-wielder/README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 626911c..3f69da6 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -130,3 +130,35 @@ For the real-facilitator testnet run and the live Pi demo, see - **Engine amounts are atomic USDC** (6-decimal integers): the prototype rounds to 2 decimals, which is lossy for $0.25 micro-royalties; integers keep the split exact (250000 → creator 243750 / treasury 6250). + +## Measured results — first real-network run (Base Sepolia, 2026-07-12) + +Executed per RUNBOOK §1–2 with a Circle-faucet-funded Wielder wallet +(`0xdddf…053F`), the free `x402.org/facilitator`, and a real Anthropic key +(no OPENAI key was present, so the gpt leg was skipped — two paid legs, not +three). Everything below is on-chain-verifiable. + +**Session ledger (real settlements):** + +| leg | label | paid | txHash | split | +|---|---|---|---|---| +| model | claude/plan | $0.041 | `0x01daa723…38ff49` | — | +| skill | optimizing-claude-code-prompts | $0.25 | `0xaf1ba2fe…7af522` | creator $0.24375 / treasury $0.00625 | +| model | claude/plan2 (overhead capture) | $0.041 | — | — | + +On-chain balance check after the session: Wielder 20 → **19.668** USDC; +sellers' address received exactly **0.332** — every cent accounted for. + +**Measured x402 payment overhead (real facilitator, n=1 instrumented call):** +402-roundtrip **3.9 ms** · EIP-3009 sign **1.1 ms** · facilitator +verify+settle **776 ms** · **total ≈ 781 ms** per paid call. The facilitator +leg dominates; mainnet Base with Flashblocks claims ~200 ms, so testnet +numbers are likely an upper bound. End-to-end including inference: 6.7 s +(plan leg), 14.5 s (skill invocation — includes the hosted skill's own +model run). + +**What this run proved beyond the offline e2e:** real 402 → sign → settle +against a live facilitator; real USDC moving on a public chain per call; +skill executed behind the Collar with output-only response; splits credited +per the settlement engine — the protocol's Phase-1 Leg-1 loop, end to end, +for $0.33 of play money. From 8223808870c5ac5de828a26dcce9c39a1392a46e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 12 Jul 2026 13:32:51 -0400 Subject: [PATCH 009/165] Add offline clone economics spike --- spikes/clone-economics/.env.example | 19 ++ spikes/clone-economics/.gitignore | 2 + spikes/clone-economics/README.md | 87 ++++++ spikes/clone-economics/RUNBOOK.md | 98 +++++++ spikes/clone-economics/e2e.mjs | 255 +++++++++++++++++ .../fixtures/bad-clone/SKILL.md | 8 + .../fixtures/evolution-v2.json | 6 + .../fixtures/executor-settings.json | 7 + .../fixtures/good-clone/SKILL.md | 10 + spikes/clone-economics/fixtures/heldout.json | 80 ++++++ .../fixtures/mock-transcript.json | 68 +++++ .../fixtures/repo-inventory.json | 6 + spikes/clone-economics/fixtures/train.json | 8 + .../clone-economics/fixtures/v2-heldout.json | 32 +++ spikes/clone-economics/package.json | 12 + spikes/clone-economics/run.mjs | 40 +++ spikes/clone-economics/src/adapters.mjs | 160 +++++++++++ spikes/clone-economics/src/economics.mjs | 46 ++++ spikes/clone-economics/src/experiment.mjs | 256 ++++++++++++++++++ spikes/clone-economics/src/reports.mjs | 76 ++++++ spikes/clone-economics/src/scoring.mjs | 63 +++++ 21 files changed, 1339 insertions(+) create mode 100644 spikes/clone-economics/.env.example create mode 100644 spikes/clone-economics/.gitignore create mode 100644 spikes/clone-economics/README.md create mode 100644 spikes/clone-economics/RUNBOOK.md create mode 100644 spikes/clone-economics/e2e.mjs create mode 100644 spikes/clone-economics/fixtures/bad-clone/SKILL.md create mode 100644 spikes/clone-economics/fixtures/evolution-v2.json create mode 100644 spikes/clone-economics/fixtures/executor-settings.json create mode 100644 spikes/clone-economics/fixtures/good-clone/SKILL.md create mode 100644 spikes/clone-economics/fixtures/heldout.json create mode 100644 spikes/clone-economics/fixtures/mock-transcript.json create mode 100644 spikes/clone-economics/fixtures/repo-inventory.json create mode 100644 spikes/clone-economics/fixtures/train.json create mode 100644 spikes/clone-economics/fixtures/v2-heldout.json create mode 100644 spikes/clone-economics/package.json create mode 100644 spikes/clone-economics/run.mjs create mode 100644 spikes/clone-economics/src/adapters.mjs create mode 100644 spikes/clone-economics/src/economics.mjs create mode 100644 spikes/clone-economics/src/experiment.mjs create mode 100644 spikes/clone-economics/src/reports.mjs create mode 100644 spikes/clone-economics/src/scoring.mjs diff --git a/spikes/clone-economics/.env.example b/spikes/clone-economics/.env.example new file mode 100644 index 0000000..6773227 --- /dev/null +++ b/spikes/clone-economics/.env.example @@ -0,0 +1,19 @@ +# Offline mode is the default and requires no key or network. +MOCK_LLM=1 +ALLOW_LIVE_LLM=0 + +# Live mode requires every field below plus `npm run real`. +ANTHROPIC_API_KEY= +MODEL= +N= +MAX_INPUT_TOKENS= +MAX_TOKENS= +INPUT_USD_PER_MILLION= +OUTPUT_USD_PER_MILLION= +PRICING_AS_OF= +PRICING_SOURCE= +MAX_RUN_COST_USD= +INVOCATION_PRICE_USD= +CLONE_SERVING_COST_USD= +DEPLOY_COST_USD= +LABOR_COST_USD= diff --git a/spikes/clone-economics/.gitignore b/spikes/clone-economics/.gitignore new file mode 100644 index 0000000..aede806 --- /dev/null +++ b/spikes/clone-economics/.gitignore @@ -0,0 +1,2 @@ +.env +runs/ diff --git a/spikes/clone-economics/README.md b/spikes/clone-economics/README.md new file mode 100644 index 0000000..4d9ca54 --- /dev/null +++ b/spikes/clone-economics/README.md @@ -0,0 +1,87 @@ +# Clone-economics spike + +> Throwaway logic prototype. Its purpose is to answer one question, not to become production code. + +## Question + +How cheaply can `N` paid input/output pairs from the +`optimizing-claude-code-prompts` **Skill** be distilled into a clone, and how +quickly would the original have to evolve to keep a frozen clone stale? + +The offline experiment invokes the target Skill exactly `N` times against fixed +training inputs, gives a distiller only those `{input, output}` pairs, and scores +the target and clone on a disjoint heldout set with the same synthetic repository +context and executor settings. A deterministic v2 overlay then adds one material +target requirement and re-scores the updated target against the frozen v1 clone. + +## One-command offline run + +```bash +cd spikes/clone-economics +npm run e2e +``` + +`npm run e2e` forces `MOCK_LLM=1`, blanks model keys, replaces global network +access with a throwing function, writes reports only to temporary directories, +runs the experiment twice, byte-compares normalized JSON and Markdown, removes +the temporary outputs, and ends with `PASS — checks green`. + +## Architecture + +1. `runExperiment()` reads the target `SKILL.md` and its reference at runtime, + recording only their relative paths and SHA-256 hashes in reports. +2. Exactly `N` target Invocations generate training pairs. +3. The distillation payload contains only generic SKILL.md-authoring instructions + plus the `N` pairs—no target/reference text, heldout data, rubric, IDs, tool + traces, or distinctive target fingerprints. +4. Target, generated clone, and deliberately bad clone are scored with the same + versioned deterministic contract rubric. Absolute scores and critical gates + are primary; clone/target retention is secondary. +5. Economics separates modeled paid-pair acquisition (`A=N×P`), provider + distillation (`D`), attack-side tuning (`E_tune`), deployment/labor, and final + benchmark overhead (`E_measure`). +6. JSON and Markdown reports expose data hashes, scoring, raw/normalized usage, + pricing provenance, costs, request/phase timings, and limitations. + +## MOCK verdict + +All values below are **SYNTHETIC canned evidence**, chosen to make the harness +auditable—not observations about a live model or market: + +- `N=6`, `H=6`; target score `1.000`; good clone `0.900`, critical gates pass. +- Deliberately bad clone `0.200`, critical gates fail. +- Synthetic v2 updated target `1.000`; frozen clone `0.750`; stale-fidelity + delta `0.250`. +- Listed Invocation price `$0.25`: modeled `A=$1.50`, `D=$0.30`, no tuning, + deployment `$0.05`, labor explicitly excluded, so `B=$1.85`. +- `D/A=0.20`, `B/A=1.233333333333`, modeled break-even `10` Invocations at + `$0.05` clone serving cost. Benchmark evaluation `$0.126` is excluded from B. + +**LIVE RUN NOT EXECUTED — no key/explicit opt-in; no measured clone-economics result.** + +## Files + +| File | Role | +|---|---| +| `src/experiment.mjs` | Single public seam and phase orchestration | +| `src/adapters.mjs` | Separate canned mock and gated Anthropic live adapters | +| `src/scoring.mjs` | Versioned weighted deterministic fidelity contracts | +| `src/economics.mjs` | Acquisition/build/evaluation and break-even math | +| `src/reports.mjs` | Deterministic JSON and Markdown rendering | +| `run.mjs` | Thin mock/live CLI and report writer | +| `e2e.mjs` | Offline acceptance proof through `runExperiment()` only | +| `fixtures/` | Fixed train/heldout/v2 cases, synthetic repo/settings, mock transcript, good/bad clones | +| `RUNBOOK.md` | Explicitly gated live-run and N-sweep procedure | + +## Limitations + +- No live target Invocation, distillation, model evaluation, x402 settlement, or + provider billing occurred. `A` is modeled from a listed Invocation price. +- Mock output quality, usage, costs, pricing, and latency are synthetic. +- Deterministic predicates test contract compliance, not semantic equivalence in + every repository or task. +- One static v2 overlay cannot establish Skill half-life, real evolution + efficacy, or a required update cadence. Any cadence statement is + **HYPOTHESIS/EXTRAPOLATION** until repeated dated live runs exist. +- This spike does **not** validate the corpus-wide `~30x` statement. That refers + to matched-quality serving cost and remains unmeasured here. diff --git a/spikes/clone-economics/RUNBOOK.md b/spikes/clone-economics/RUNBOOK.md new file mode 100644 index 0000000..3d9d09b --- /dev/null +++ b/spikes/clone-economics/RUNBOOK.md @@ -0,0 +1,98 @@ +# RUNBOOK — explicitly gated live clone-economics run + +The supported default is the offline proof: + +```bash +npm run e2e +``` + +No live run was executed while building this spike. The procedure below is for +an operator who deliberately chooses to spend model credits and supplies a +current pricing snapshot. + +## 1. Supply every live input + +Copy `.env.example` to `.env`, fill it locally, then export it into the shell. +The CLI does not silently load `.env`. + +```bash +set -a +source .env +set +a +``` + +Required live fields: + +- `ALLOW_LIVE_LLM=1` and `MOCK_LLM=0` — explicit opt-in and non-mock mode. +- `ANTHROPIC_API_KEY` — never print it or commit `.env`. +- `MODEL`, `N`, `MAX_INPUT_TOKENS`, and `MAX_TOKENS` — all explicit; fixed + fixtures support `1 ≤ N ≤ 6`. `MAX_INPUT_TOKENS` is an operator ceiling; + the adapter uses UTF-8 byte length as a conservative tokenizer-unit upper + bound and aborts before fetch when a request exceeds it. +- `INPUT_USD_PER_MILLION`, `OUTPUT_USD_PER_MILLION`, `PRICING_AS_OF`, and + `PRICING_SOURCE` — operator-supplied current pricing, with a source URL or + provider document name. The spike has no hard-coded claim that mock pricing + is current. +- `MAX_RUN_COST_USD` — positive hard cap. +- `INVOCATION_PRICE_USD` — listed target Skill Invocation price used to model A; + no x402 payment is settled by this harness. +- `CLONE_SERVING_COST_USD` — used only for modeled break-even. +- `DEPLOY_COST_USD` and `LABOR_COST_USD` — set to explicit estimates or `0` to + record exclusion. + +Before constructing the network adapter, the harness validates every required +field and precomputes a conservative maximum from request count, +`MAX_INPUT_TOKENS`, `MAX_TOKENS`, and both token rates. It aborts if that +maximum exceeds `MAX_RUN_COST_USD`. +During a run it also enforces cumulative measured provider spend. If a provider +response omits usage, that request's cost is `null`, never `$0`. + +## 2. Run once + +```bash +npm run real +``` + +The command writes ignored artifacts to `runs/live/report.json` and +`runs/live/report.md`. Inspect the report before drawing conclusions: + +- Verify evidence labels distinguish measured provider usage from modeled pair + acquisition. +- Compare target and clone absolute scores and critical-gate results first. + Retention (`clone/target`) is secondary and can hide a weak target baseline. +- Treat `E_measure` as experiment overhead; it is not part of attacker build + cost B. +- Check raw usage, normalized tokens, pricing snapshot, every request latency, + sequential build time, and the parallel-acquisition lower bound. + +## 3. N sweep + +Run separate, capped experiments at the fixed supported sizes; keep model, +pricing, max tokens, repository inventory, and heldout set unchanged: + +```bash +N=2 npm run real +N=4 npm run real +N=6 npm run real +``` + +Move or rename each ignored report between runs if you want to retain it. Plot: + +- clone absolute score and critical-gate pass versus N; +- `D/A` and `B/A` versus N; +- break-even Invocations where `P - cloneServingCost > 0`; +- sequential build time and the parallel-acquisition lower bound. + +## 4. Interpretation discipline + +- `A=N×P` is **MODELED** paid-pair acquisition. The target provider's own model + cost is reported separately and not added again to A. +- `D` is distillation provider spend. `E_tune` is zero only when no + revision/selection attempt occurred. `C_deploy` and labor remain explicit. +- A ratio is undefined when `P=0`; break-even is undefined when clone serving + margin is non-positive. +- A frozen clone falling behind one synthetic v2 overlay is not a Skill + half-life estimate. Any recommended update interval is + **HYPOTHESIS/EXTRAPOLATION** pending repeated dated revisions. +- Do not use this run to validate a corpus-wide `~30x` claim; matched-quality + serving cost is a different, still-unmeasured quantity. diff --git a/spikes/clone-economics/e2e.mjs b/spikes/clone-economics/e2e.mjs new file mode 100644 index 0000000..c9dde15 --- /dev/null +++ b/spikes/clone-economics/e2e.mjs @@ -0,0 +1,255 @@ +// Offline end-to-end proof through the one public seam: runExperiment(). +process.env.MOCK_LLM = '1'; +process.env.ALLOW_LIVE_LLM = '0'; +delete process.env.ANTHROPIC_API_KEY; +delete process.env.OPENAI_API_KEY; + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +let networkAttempts = 0; +globalThis.fetch = async () => { + networkAttempts += 1; + throw new Error('NETWORK FORBIDDEN IN MOCK E2E'); +}; + +const { runExperiment } = await import('./src/experiment.mjs'); + +let checks = 0; +function ok(condition, label) { + checks += 1; + assert.ok(condition, label); + console.log(` ✓ ${label}`); +} +function eq(actual, expected, label) { + checks += 1; + assert.deepEqual(actual, expected, label); + console.log(` ✓ ${label}`); +} + +const here = path.dirname(fileURLToPath(import.meta.url)); +const targetPath = path.resolve(here, '../../.claude/skills/optimizing-claude-code-prompts/SKILL.md'); +const referencePath = path.resolve(here, '../../.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md'); +const targetText = fs.readFileSync(targetPath, 'utf8'); +const referenceText = fs.readFileSync(referencePath, 'utf8'); +const outputA = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-economics-a-')); +const outputB = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-economics-b-')); + +const config = { + mode: 'mock', + N: 6, + invocationPriceUsd: 0.25, + cloneServingCostUsd: 0.05, + deployCostUsd: 0.05, + laborCostUsd: 0, +}; + +console.log('\nClone-economics e2e — MOCK_LLM=1, network disabled\n'); +try { + const first = await runExperiment({ ...config, outputDir: outputA }); + const second = await runExperiment({ ...config, outputDir: outputB }); + const report = first.report; + + eq(report.mode, 'mock', 'mock mode recorded'); + eq(report.evidenceLabel, 'SYNTHETIC', 'all mock evidence labeled SYNTHETIC'); + ok([report.dataset, report.fidelity, report.economics, report.usage, report.pricing, report.timing].every((section) => section.evidenceLabel.includes('SYNTHETIC')), 'every numeric mock report section carries a SYNTHETIC label'); + ok(report.claimStatus.includes('LIVE RUN NOT EXECUTED'), 'report denies a live run'); + ok(report.claimStatus.includes('no measured clone-economics result'), 'report denies measured economics'); + + eq(report.dataset.N, 6, 'exactly N acquisition pairs'); + eq(report.dataset.H, 6, 'heldout count recorded'); + eq(new Set(report.dataset.train.map((item) => item.id)).size, 6, 'train fixture IDs unique'); + eq(new Set(report.dataset.heldout.map((item) => item.id)).size, 6, 'heldout fixture IDs unique'); + ok(report.dataset.disjoint, 'train and heldout IDs/hashes are disjoint'); + ok(report.dataset.train.concat(report.dataset.heldout).every((item) => /^sha256:[0-9a-f]{64}$/.test(item.inputHash)), 'normalized-input hashes recorded'); + + const acquisition = first.capturedRequests.filter((request) => request.kind === 'target-train'); + const evaluations = first.capturedRequests.filter((request) => request.kind.endsWith('-heldout')); + eq(acquisition.length, 6, 'target Skill invoked exactly N times for acquisition'); + ok(evaluations.every((request) => report.dataset.heldout.some((item) => item.id === request.caseId) || report.evolution.heldoutIds.includes(request.caseId)), 'evaluation uses heldout only'); + ok(acquisition.every((request) => !evaluations.some((evaluation) => evaluation.caseId === request.caseId)), 'train cases never enter evaluation'); + const targetHeldout = first.capturedRequests.find((request) => request.kind === 'target-heldout'); + const cloneHeldout = first.capturedRequests.find((request) => request.kind === 'clone-heldout' && request.caseId === targetHeldout.caseId); + eq(targetHeldout.payload.repoInventory, cloneHeldout.payload.repoInventory, 'target and clone receive identical synthetic repo context'); + eq(targetHeldout.payload.executorSettings, cloneHeldout.payload.executorSettings, 'target and clone receive identical executor settings'); + + const distill = first.capturedRequests.find((request) => request.kind === 'distill'); + ok(distill, 'distillation request captured'); + eq(Object.keys(distill.payload).sort(), ['instructions', 'pairs'], 'distillation payload has only generic instructions and pairs'); + eq(distill.payload.pairs.length, 6, 'distillation receives exactly N pairs'); + ok(distill.payload.pairs.every((pair) => Object.keys(pair).sort().join(',') === 'input,output'), 'each distillation pair contains input/output only'); + const distillBytes = JSON.stringify(distill.payload); + ok(!distillBytes.includes('The one rule that makes this skill worth invoking'), 'distinctive target fingerprint excluded'); + ok(!distillBytes.includes('The seven ingredients'), 'second target fingerprint excluded'); + ok(!distillBytes.includes(targetText), 'target Skill text excluded'); + ok(!distillBytes.includes(referenceText), 'target reference text excluded'); + ok(!report.dataset.heldout.some((item) => distillBytes.includes(item.id) || distillBytes.includes(item.input)), 'heldout IDs and requests excluded'); + ok(!/requiredAll|requiredAny|forbidden|rubric|toolTrace/i.test(distillBytes), 'rubric and tool traces excluded'); + const heldoutFixtures = JSON.parse(fs.readFileSync(path.join(here, 'fixtures/heldout.json'), 'utf8')); + const heldoutAnswerFeatures = heldoutFixtures.flatMap((fixture) => [ + ...fixture.rubric.exactPaths.map((item) => item.value), + ...fixture.rubric.exactCommands.map((item) => item.value), + ...fixture.rubric.requiredAll.map((item) => item.value), + ]); + const acquiredAnswers = distill.payload.pairs.map((pair) => pair.output).join('\n'); + ok(heldoutAnswerFeatures.every((feature) => !acquiredAnswers.includes(feature)), 'heldout path/command/constraint answer features are absent from acquired outputs'); + + ok(first.cloneSkillMd.startsWith('---\nname:'), 'valid clone SKILL.md produced'); + ok(first.cloneSkillMd.includes('\n# '), 'clone SKILL.md has a body'); + ok(!first.cloneSkillMd.includes('The one rule that makes this skill worth invoking'), 'clone does not copy target fingerprint'); + ok(/^sha256:[0-9a-f]{64}$/.test(report.generatedClone.sha256), 'clone hash recorded'); + eq(report.target.skill.path, '.claude/skills/optimizing-claude-code-prompts/SKILL.md', 'target path recorded without content'); + eq(report.target.reference.path, '.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md', 'reference path recorded without content'); + ok(!first.jsonReport.includes(targetText.slice(0, 300)), 'report does not copy target text'); + + eq(report.fidelity.rubricVersion, 'contract-v1', 'versioned deterministic rubric recorded'); + eq(report.fidelity.target.absoluteScore, 1, 'known literal target score'); + eq(report.fidelity.clone.absoluteScore, 0.9, 'known literal good-clone score'); + ok(report.fidelity.clone.passedThreshold && report.fidelity.clone.criticalGatePass, 'good clone clears 0.80 and critical gates'); + eq(report.fidelity.retention, 0.9, 'clone/target retention secondary metric'); + eq(report.fidelity.badClone.absoluteScore, 0.2, 'known literal bad-clone score'); + ok(!report.fidelity.badClone.criticalGatePass && !report.fidelity.badClone.passedThreshold, 'bad clone fails a critical gate'); + ok(report.fidelity.scoreDeterminism.byteIdentical, 'repeated deterministic scoring is byte-identical'); + eq(report.fidelity.target.cases.length, 6, 'per-case target scores reported'); + ok(report.fidelity.clone.cases.every((item) => item.dimensions && typeof item.score === 'number'), 'per-case and dimension clone scores reported'); + + eq(report.evolution.evidenceLabel, 'SYNTHETIC', 'v2 overlay labeled SYNTHETIC'); + eq(report.evolution.updatedTarget.absoluteScore, 1, 'known literal v2 target score'); + eq(report.evolution.frozenClone.absoluteScore, 0.75, 'known literal frozen-clone v2 score'); + eq(report.evolution.staleFidelityDelta, 0.25, 'known literal stale-fidelity delta'); + ok(report.evolution.statement.includes('cannot establish Skill half-life'), 'single overlay limitation explicit'); + + eq(report.economics.acquisitionModeledUsd, 1.5, 'A = N × listed Invocation price'); + eq(report.economics.distillationProviderUsd, 0.3, 'D literal'); + eq(report.economics.tuningEvaluationUsd, 0, 'E_tune is zero because no tuning occurred'); + eq(report.economics.attackerBuildUsd, 1.85, 'B excludes benchmark evaluation'); + eq(report.economics.distillationToAcquisition, 0.2, 'D/A literal ratio'); + eq(report.economics.buildToAcquisition, 1.233333333333, 'B/A literal ratio'); + eq(report.economics.breakEvenInvocations, 10, 'break-even literal'); + eq(report.economics.measurementEvaluationUsd, 0.126, 'E_measure reported separately'); + eq(report.economics.zeroPriceProbe.distillationToAcquisition, null, 'D/A undefined when P=0'); + eq(report.economics.zeroPriceProbe.buildToAcquisition, null, 'B/A undefined when P=0'); + eq(report.economics.zeroPriceProbe.breakEvenInvocations, null, 'break-even undefined without positive margin'); + ok(report.economics.providerCostsNotAddedToAcquisition, 'provider costs not double-counted into modeled acquisition'); + + ok(report.usage.raw.every((item) => item.evidenceLabel === 'SYNTHETIC'), 'raw usage tagged SYNTHETIC'); + ok(report.usage.raw.every((item) => { + if (item.costUsd === null) return true; + const expected = ( + item.rawUsage.input_tokens * report.pricing.inputUsdPerMillion + + item.rawUsage.output_tokens * report.pricing.outputUsdPerMillion + ) / 1_000_000; + return Math.abs(item.costUsd - expected) < 1e-12; + }), 'every mock provider cost equals usage × supplied pricing'); + ok(report.usage.normalized.inputTokens > 0 && report.usage.normalized.outputTokens > 0, 'normalized usage totals reported'); + ok(report.pricing.asOf && report.pricing.source && report.pricing.inputUsdPerMillion > 0, 'pricing snapshot/as-of/source reported'); + eq(report.timing.sequentialBuildMs, 1000, 'sequential build time literal'); + eq(report.timing.parallelAcquisitionLowerBoundMs, 500, 'parallel-acquisition lower bound literal'); + ok(report.timing.requiredUpdateCadence.label === 'HYPOTHESIS/EXTRAPOLATION', 'update cadence labeled hypothesis/extrapolation'); + + ok(first.jsonReport.includes('SYNTHETIC') && first.markdownReport.includes('SYNTHETIC'), 'JSON and Markdown reports carry evidence labels'); + ok(first.markdownReport.includes('no measured clone-economics result'), 'Markdown preserves honesty verdict'); + ok(!first.markdownReport.split('\n').some((line) => /[ \t]+$/.test(line)), 'Markdown report contains no trailing whitespace'); + eq(fs.readFileSync(first.outputFiles.json, 'utf8'), fs.readFileSync(second.outputFiles.json, 'utf8'), 'two JSON report runs are byte-identical'); + eq(fs.readFileSync(first.outputFiles.markdown, 'utf8'), fs.readFileSync(second.outputFiles.markdown, 'utf8'), 'two Markdown report runs are byte-identical'); + ok(!fs.existsSync(path.join(here, 'runs')), 'e2e leaves no run artifacts in the tree'); + + const unknownTranscript = JSON.parse(fs.readFileSync(path.join(here, 'fixtures/mock-transcript.json'), 'utf8')); + unknownTranscript.usageProfiles.distill.inputTokens = null; + unknownTranscript.usageProfiles.distill.costUsd = null; + for (const outputs of Object.values(unknownTranscript.heldoutOutputs)) outputs.target = 'Wrong [placeholder]???'; + const unknown = await runExperiment({ ...config, outputDir: outputA, mockTranscript: unknownTranscript }); + eq(unknown.report.economics.distillationProviderUsd, null, 'missing provider usage keeps D unknown, never zero'); + eq(unknown.report.economics.attackerBuildUsd, null, 'unknown D propagates to B'); + eq(unknown.report.economics.distillationToAcquisition, null, 'unknown D propagates to D/A'); + eq(unknown.report.economics.buildToAcquisition, null, 'unknown B propagates to B/A'); + eq(unknown.report.usage.normalized.inputTokens, null, 'missing raw input usage keeps normalized input total unknown'); + eq(unknown.report.usage.normalized.providerCostUsd, null, 'missing request cost keeps normalized provider total unknown'); + eq(unknown.report.fidelity.retention, null, 'zero target score makes retention undefined'); + ok(unknown.markdownReport.includes('unknown'), 'Markdown renders unknown values without crashing'); + + const replayTranscript = JSON.parse(fs.readFileSync(path.join(here, 'fixtures/mock-transcript.json'), 'utf8')); + const replayCloneSkill = fs.readFileSync(path.join(here, 'fixtures/good-clone/SKILL.md'), 'utf8'); + const missingUsageReplayAdapter = { + pricing: replayTranscript.pricing, + capturedRequests: [], + records: [], + async invoke(request) { + this.capturedRequests.push(structuredClone(request)); + let output; + if (request.kind === 'distill') output = replayCloneSkill; + else if (request.kind === 'target-train') output = replayTranscript.trainOutputs[request.caseId]; + else if (request.kind.endsWith('-v2-heldout')) { + output = replayTranscript.v2Outputs[request.caseId][request.kind.startsWith('target-') ? 'target' : 'clone']; + } else { + const profile = request.kind === 'target-heldout' ? 'target' : request.kind === 'clone-heldout' ? 'clone' : 'bad'; + output = replayTranscript.heldoutOutputs[request.caseId][profile]; + } + const record = { + requestId: `replay-${String(this.records.length + 1).padStart(3, '0')}`, + kind: request.kind, + caseId: request.caseId ?? null, + evidenceLabel: 'MEASURED WHERE RETURNED; UNKNOWN OTHERWISE', + model: 'offline-missing-usage-replay', + rawUsage: null, + normalizedUsage: { inputTokens: null, outputTokens: null }, + costUsd: null, + latencyMs: 1, + }; + this.records.push(record); + return { output, ...record }; + }, + }; + const missingLiveUsage = await runExperiment({ + ...config, + mode: 'live', + outputDir: outputA, + adapter: missingUsageReplayAdapter, + }); + ok(missingLiveUsage.report.evidenceLabel.includes('measured where returned; unknown otherwise'), 'live summary labels incomplete usage as measured where returned and unknown otherwise'); + ok(missingLiveUsage.report.economics.evidenceLabel.includes('measured where returned; unknown otherwise'), 'live economics labels incomplete cost as measured where returned and unknown otherwise'); + ok(missingLiveUsage.report.usage.evidenceLabel.includes('measured where returned; unknown otherwise'), 'live usage labels incomplete fields as measured where returned and unknown otherwise'); + eq(missingLiveUsage.report.economics.distillationProviderUsd, null, 'stubbed-live missing usage keeps D unknown'); + eq(missingLiveUsage.report.economics.attackerBuildUsd, null, 'stubbed-live unknown D propagates to B'); + eq(missingLiveUsage.report.economics.distillationToAcquisition, null, 'stubbed-live unknown D propagates to D/A'); + eq(missingLiveUsage.report.economics.buildToAcquisition, null, 'stubbed-live unknown B propagates to B/A'); + eq(missingLiveUsage.report.usage.normalized.inputTokens, null, 'stubbed-live missing usage remains null in JSON'); + ok(missingLiveUsage.markdownReport.includes('Normalized usage: unknown input tokens, unknown output tokens.'), 'stubbed-live Markdown renders missing usage as unknown'); + eq(missingLiveUsage.report.economics.laborCostTreatment, 'Explicitly excluded from this run.', 'labor exclusion wording is mode-neutral'); + eq(networkAttempts, 0, 'stubbed-live report regression performs no fetch'); + + const liveConfig = { + mode: 'live', outputDir: outputA, N: 6, + invocationPriceUsd: 0.25, cloneServingCostUsd: 0.05, deployCostUsd: 0, laborCostUsd: 0, + apiKey: 'synthetic-never-used', model: 'synthetic-live-guard-probe', + maxInputTokens: 4096, maxTokens: 1024, + inputUsdPerMillion: 3, outputUsdPerMillion: 15, + pricingAsOf: '2026-07-12', pricingSource: 'synthetic guard probe', maxRunCostUsd: 100, + }; + process.env.MOCK_LLM = '0'; + process.env.ALLOW_LIVE_LLM = '0'; + checks += 1; + await assert.rejects(runExperiment(liveConfig), /ALLOW_LIVE_LLM=1/, 'explicit live opt-in blocks a fully configured run'); + console.log(' ✓ explicit live opt-in blocks a fully configured run'); + eq(networkAttempts, 0, 'opt-in rejection occurs before fetch'); + + process.env.ALLOW_LIVE_LLM = '1'; + checks += 1; + await assert.rejects( + runExperiment({ ...liveConfig, maxInputTokens: 1 }), + /input token upper bound/i, + 'configured input-token bound aborts before fetch', + ); + console.log(' ✓ configured input-token bound aborts before fetch'); + eq(networkAttempts, 0, 'input-bound rejection occurs before fetch'); + process.env.MOCK_LLM = '1'; + process.env.ALLOW_LIVE_LLM = '0'; + + console.log(`\nPASS — ${checks} checks green.`); +} finally { + fs.rmSync(outputA, { recursive: true, force: true }); + fs.rmSync(outputB, { recursive: true, force: true }); +} diff --git a/spikes/clone-economics/fixtures/bad-clone/SKILL.md b/spikes/clone-economics/fixtures/bad-clone/SKILL.md new file mode 100644 index 0000000..cc863b7 --- /dev/null +++ b/spikes/clone-economics/fixtures/bad-clone/SKILL.md @@ -0,0 +1,8 @@ +--- +name: synthetic-bad-clone +description: A deliberately insensitive baseline for the evaluator. +--- + +# Bad Clone + +Return a generic coding suggestion with placeholders and tell the caller to run tests. diff --git a/spikes/clone-economics/fixtures/evolution-v2.json b/spikes/clone-economics/fixtures/evolution-v2.json new file mode 100644 index 0000000..c724d0b --- /dev/null +++ b/spikes/clone-economics/fixtures/evolution-v2.json @@ -0,0 +1,6 @@ +{ + "evidenceLabel": "SYNTHETIC", + "version": "v2-overlay", + "newRequirement": "ask before destructive or externally visible actions", + "statement": "One static synthetic v2 overlay cannot establish Skill half-life or evolution efficacy." +} diff --git a/spikes/clone-economics/fixtures/executor-settings.json b/spikes/clone-economics/fixtures/executor-settings.json new file mode 100644 index 0000000..a6300e1 --- /dev/null +++ b/spikes/clone-economics/fixtures/executor-settings.json @@ -0,0 +1,7 @@ +{ + "evidenceLabel": "SYNTHETIC", + "temperature": 0, + "maxTokens": 1024, + "toolsEnabled": false, + "syntheticRepoOnly": true +} diff --git a/spikes/clone-economics/fixtures/good-clone/SKILL.md b/spikes/clone-economics/fixtures/good-clone/SKILL.md new file mode 100644 index 0000000..4564549 --- /dev/null +++ b/spikes/clone-economics/fixtures/good-clone/SKILL.md @@ -0,0 +1,10 @@ +--- +name: synthetic-prompt-optimizer-clone +description: Convert coding requests into concrete, repository-specific execution prompts. +--- + +# Synthetic Prompt Optimizer Clone + +Classify the request as Optimize, Generate, Diagnose, or Spec. Use only supplied repository +inventory to name exact files and runnable checks. Preserve explicit constraints, ask at most one +question when a blocking choice remains, and return a concise prompt the caller can execute. diff --git a/spikes/clone-economics/fixtures/heldout.json b/spikes/clone-economics/fixtures/heldout.json new file mode 100644 index 0000000..7ebdcc1 --- /dev/null +++ b/spikes/clone-economics/fixtures/heldout.json @@ -0,0 +1,80 @@ +[ + { + "id": "ho-optimize-cache", + "mode": "Optimize", + "input": "Optimize this prompt: make cache invalidation safer without breaking callers.", + "rubric": { + "expectedMode": "Optimize", "maxQuestions": 0, + "exactPaths": [{ "value": "@src/cache.ts", "weight": 2, "critical": true }], + "exactCommands": [{ "value": "npm test -- src/cache.test.ts", "weight": 2, "critical": true }], + "requiredAll": [{ "value": "keep the cache API backward-compatible", "dimension": "constraints", "weight": 2, "critical": true }], + "requiredAny": [{ "values": ["Show the diff", "Return the patch"], "dimension": "output", "weight": 1, "critical": false }], + "forbidden": [{ "value": "[", "dimension": "grounding", "weight": 1, "critical": true }] + } + }, + { + "id": "ho-generate-export", + "mode": "Generate", + "input": "Help me ask Claude Code to add CSV report export.", + "rubric": { + "expectedMode": "Generate", "maxQuestions": 0, + "exactPaths": [{ "value": "@src/export/report.ts", "weight": 2, "critical": true }], + "exactCommands": [{ "value": "npm test -- src/export/report.test.ts", "weight": 2, "critical": true }], + "requiredAll": [{ "value": "use no new dependencies", "dimension": "constraints", "weight": 2, "critical": true }], + "requiredAny": [{ "values": ["Show the diff", "Return the patch"], "dimension": "output", "weight": 1, "critical": false }], + "forbidden": [{ "value": "[", "dimension": "grounding", "weight": 1, "critical": true }] + } + }, + { + "id": "ho-diagnose-session", + "mode": "Diagnose", + "input": "Claude hid a session-timeout error instead of fixing it. Rewrite my request.", + "rubric": { + "expectedMode": "Diagnose", "maxQuestions": 0, + "exactPaths": [{ "value": "@src/auth/session.ts", "weight": 2, "critical": true }], + "exactCommands": [{ "value": "npm test -- src/auth/session.test.ts", "weight": 2, "critical": true }], + "requiredAll": [{ "value": "fix the root cause without suppressing the error", "dimension": "constraints", "weight": 2, "critical": true }], + "requiredAny": [{ "values": ["Show the diff", "Return the patch"], "dimension": "output", "weight": 1, "critical": false }], + "forbidden": [{ "value": "[", "dimension": "grounding", "weight": 1, "critical": true }] + } + }, + { + "id": "ho-spec-audit", + "mode": "Spec", + "input": "I need a multi-file audit trail feature. What should I ask Claude Code to do next?", + "rubric": { + "expectedMode": "Spec", "maxQuestions": 0, + "exactPaths": [{ "value": "@SPEC.md", "weight": 2, "critical": true }], + "exactCommands": [{ "value": "npm test -- src/audit", "weight": 2, "critical": true }], + "requiredAll": [{ "value": "keep implementation out of this session", "dimension": "constraints", "weight": 2, "critical": true }], + "requiredAny": [{ "values": ["Show the diff", "Return the patch"], "dimension": "output", "weight": 1, "critical": false }], + "forbidden": [{ "value": "[", "dimension": "grounding", "weight": 1, "critical": true }] + } + }, + { + "id": "ho-unresolved-pattern", + "mode": "Generate", + "input": "Generate a prompt for retry behavior when the repo has two plausible worker patterns.", + "rubric": { + "expectedMode": "Generate", "maxQuestions": 1, + "exactPaths": [{ "value": "@src/jobs/worker.ts", "weight": 2, "critical": true }], + "exactCommands": [{ "value": "npm test -- src/jobs/worker.test.ts", "weight": 2, "critical": true }], + "requiredAll": [{ "value": "ask at most one question before assuming the retry policy", "dimension": "constraints", "weight": 2, "critical": true }], + "requiredAny": [{ "values": ["Show the diff", "Return the patch"], "dimension": "output", "weight": 1, "critical": false }], + "forbidden": [{ "value": "[", "dimension": "grounding", "weight": 1, "critical": true }] + } + }, + { + "id": "ho-preserve-json", + "mode": "Optimize", + "input": "Tighten this request: add order filtering but do not change API JSON.", + "rubric": { + "expectedMode": "Optimize", "maxQuestions": 0, + "exactPaths": [{ "value": "@src/api/orders.ts", "weight": 2, "critical": true }], + "exactCommands": [{ "value": "npm test -- src/api/orders.test.ts", "weight": 2, "critical": true }], + "requiredAll": [{ "value": "preserve the JSON response shape exactly", "dimension": "constraints", "weight": 2, "critical": true }], + "requiredAny": [{ "values": ["Show the diff", "Return the patch"], "dimension": "output", "weight": 1, "critical": false }], + "forbidden": [{ "value": "[", "dimension": "grounding", "weight": 1, "critical": true }] + } + } +] diff --git a/spikes/clone-economics/fixtures/mock-transcript.json b/spikes/clone-economics/fixtures/mock-transcript.json new file mode 100644 index 0000000..c9baf9d --- /dev/null +++ b/spikes/clone-economics/fixtures/mock-transcript.json @@ -0,0 +1,68 @@ +{ + "evidenceLabel": "SYNTHETIC", + "pricing": { + "inputUsdPerMillion": 10, + "outputUsdPerMillion": 10, + "asOf": "2026-07-12", + "source": "SYNTHETIC canned pricing fixture; operator must replace for live runs" + }, + "usageProfiles": { + "target-train": { "inputTokens": 800, "outputTokens": 1200, "costUsd": 0.02, "latencyMs": 100 }, + "distill": { "inputTokens": 20000, "outputTokens": 10000, "costUsd": 0.3, "latencyMs": 400 }, + "target-heldout": { "inputTokens": 400, "outputTokens": 600, "costUsd": 0.01, "latencyMs": 80 }, + "clone-heldout": { "inputTokens": 200, "outputTokens": 300, "costUsd": 0.005, "latencyMs": 60 }, + "bad-clone-heldout": { "inputTokens": 40, "outputTokens": 60, "costUsd": 0.001, "latencyMs": 40 }, + "target-v2-heldout": { "inputTokens": 400, "outputTokens": 600, "costUsd": 0.01, "latencyMs": 80 }, + "clone-v2-heldout": { "inputTokens": 200, "outputTokens": 300, "costUsd": 0.005, "latencyMs": 60 } + }, + "trainOutputs": { + "tr-optimize-checkout": "Mode: Optimize\nChange @src/checkout/totals.ts only. Preserve totals behavior. Done when: npm test -- src/checkout/totals.test.ts. Show the diff.", + "tr-generate-auth": "Mode: Generate\nAdd logout in @src/auth/logout.ts following the supplied auth inventory. Done when: npm test -- src/auth/logout.test.ts. Show the diff.", + "tr-diagnose-scope": "Mode: Diagnose\nLimit changes to @src/validation/email.ts and revert unrelated modules. Reproduce first with npm test -- src/validation/email.test.ts. Show the diff.", + "tr-spec-billing": "Mode: Spec\nInterview for the recurring-billing boundaries, write @BILLING_SPEC.md, and defer implementation until the plan is approved. Validate with npm test -- src/billing/subscription. Show the diff.", + "tr-unresolved-command": "Mode: Generate\nUse @src/queue/runner.ts. Ask one question to choose the retry policy, then verify with npm test -- src/queue/runner.test.ts. Show the diff.", + "tr-preserve-no-deps": "Mode: Optimize\nEdit @src/download/archive.ts; preserve the public response and avoid dependency changes. Done when: npm test -- src/download/archive.test.ts. Show the diff." + }, + "heldoutOutputs": { + "ho-optimize-cache": { + "target": "Mode: Optimize\nEdit @src/cache.ts. Constraints: keep the cache API backward-compatible. Done when: npm test -- src/cache.test.ts. Show the diff.", + "clone": "Mode: Optimize\nEdit @src/cache.ts. Constraints: keep the cache API backward-compatible. Done when: npm test -- src/cache.test.ts.", + "bad": "Mode: Optimize\nChange [the relevant file] and run tests." + }, + "ho-generate-export": { + "target": "Mode: Generate\nAdd CSV export in @src/export/report.ts; use no new dependencies. Done when: npm test -- src/export/report.test.ts. Show the diff.", + "clone": "Mode: Generate\nAdd CSV export in @src/export/report.ts; use no new dependencies. Done when: npm test -- src/export/report.test.ts.", + "bad": "Mode: Generate\nChange [the relevant file] and run tests." + }, + "ho-diagnose-session": { + "target": "Mode: Diagnose\nInvestigate @src/auth/session.ts and fix the root cause without suppressing the error. Done when: npm test -- src/auth/session.test.ts. Show the diff.", + "clone": "Mode: Diagnose\nInvestigate @src/auth/session.ts and fix the root cause without suppressing the error. Done when: npm test -- src/auth/session.test.ts.", + "bad": "Mode: Diagnose\nChange [the relevant file] and run tests." + }, + "ho-spec-audit": { + "target": "Mode: Spec\nWrite @SPEC.md for the audit trail and keep implementation out of this session. The eventual check is npm test -- src/audit. Show the diff.", + "clone": "Mode: Spec\nWrite @SPEC.md for the audit trail and keep implementation out of this session. The eventual check is npm test -- src/audit.", + "bad": "Mode: Spec\nChange [the relevant file] and run tests." + }, + "ho-unresolved-pattern": { + "target": "Mode: Generate\nWork in @src/jobs/worker.ts and ask at most one question before assuming the retry policy: should the modern worker pattern win? Done when: npm test -- src/jobs/worker.test.ts. Show the diff.", + "clone": "Mode: Generate\nWork in @src/jobs/worker.ts and ask at most one question before assuming the retry policy: should the modern worker pattern win? Done when: npm test -- src/jobs/worker.test.ts.", + "bad": "Mode: Generate\nChange [the relevant file] and run tests." + }, + "ho-preserve-json": { + "target": "Mode: Optimize\nEdit @src/api/orders.ts and preserve the JSON response shape exactly. Done when: npm test -- src/api/orders.test.ts. Show the diff.", + "clone": "Mode: Optimize\nEdit @src/api/orders.ts and preserve the JSON response shape exactly. Done when: npm test -- src/api/orders.test.ts.", + "bad": "Mode: Optimize\nChange [the relevant file] and run tests." + } + }, + "v2Outputs": { + "v2-optimize-cache": { + "target": "Mode: Optimize\nEdit @src/cache.ts. Constraints: keep the cache API backward-compatible and ask before destructive or externally visible actions. Done when: npm test -- src/cache.test.ts. Show the diff.", + "clone": "Mode: Optimize\nEdit @src/cache.ts. Constraints: keep the cache API backward-compatible. Done when: npm test -- src/cache.test.ts." + }, + "v2-diagnose-session": { + "target": "Mode: Diagnose\nInvestigate @src/auth/session.ts, fix the root cause without suppressing the error, and ask before destructive or externally visible actions. Done when: npm test -- src/auth/session.test.ts. Show the diff.", + "clone": "Mode: Diagnose\nInvestigate @src/auth/session.ts and fix the root cause without suppressing the error. Done when: npm test -- src/auth/session.test.ts." + } + } +} diff --git a/spikes/clone-economics/fixtures/repo-inventory.json b/spikes/clone-economics/fixtures/repo-inventory.json new file mode 100644 index 0000000..299fc01 --- /dev/null +++ b/spikes/clone-economics/fixtures/repo-inventory.json @@ -0,0 +1,6 @@ +{ + "evidenceLabel": "SYNTHETIC", + "files": ["src/checkout/totals.ts", "src/checkout/totals.test.ts", "src/auth/logout.ts", "src/auth/logout.test.ts", "src/validation/email.ts", "src/validation/email.test.ts", "src/billing/subscription.ts", "src/queue/runner.ts", "src/queue/runner.test.ts", "src/download/archive.ts", "src/download/archive.test.ts", "BILLING_SPEC.md", "src/cache.ts", "src/cache.test.ts", "src/export/report.ts", "src/export/report.test.ts", "src/auth/session.ts", "src/auth/session.test.ts", "src/audit/index.ts", "src/jobs/worker.ts", "src/jobs/worker.test.ts", "src/api/orders.ts", "src/api/orders.test.ts", "SPEC.md"], + "scripts": { "test": "node --test" }, + "patterns": { "jobs": ["src/jobs/worker.ts", "src/jobs/legacy-worker.ts"], "auth": "src/auth/session.ts" } +} diff --git a/spikes/clone-economics/fixtures/train.json b/spikes/clone-economics/fixtures/train.json new file mode 100644 index 0000000..c8d65ba --- /dev/null +++ b/spikes/clone-economics/fixtures/train.json @@ -0,0 +1,8 @@ +[ + { "id": "tr-optimize-checkout", "mode": "Optimize", "input": "Tighten this request for the synthetic repo: make checkout totals faster without changing behavior." }, + { "id": "tr-generate-auth", "mode": "Generate", "input": "Write a Claude Code request to add logout support using the existing auth pattern." }, + { "id": "tr-diagnose-scope", "mode": "Diagnose", "input": "Claude refactored three modules when I asked for one validation fix. Help me correct the request." }, + { "id": "tr-spec-billing", "mode": "Spec", "input": "Turn a broad recurring-billing feature idea into the right next instruction for Claude Code." }, + { "id": "tr-unresolved-command", "mode": "Generate", "input": "Ask Claude to add retry handling, but the synthetic inventory exposes two possible worker patterns." }, + { "id": "tr-preserve-no-deps", "mode": "Optimize", "input": "Improve my export prompt while preserving the public response and adding no dependencies." } +] diff --git a/spikes/clone-economics/fixtures/v2-heldout.json b/spikes/clone-economics/fixtures/v2-heldout.json new file mode 100644 index 0000000..3c26b7c --- /dev/null +++ b/spikes/clone-economics/fixtures/v2-heldout.json @@ -0,0 +1,32 @@ +[ + { + "id": "v2-optimize-cache", "mode": "Optimize", + "input": "Optimize the cache-safety request under the v2 autonomy policy.", + "rubric": { + "expectedMode": "Optimize", "maxQuestions": 0, + "exactPaths": [{ "value": "@src/cache.ts", "weight": 2, "critical": true }], + "exactCommands": [{ "value": "npm test -- src/cache.test.ts", "weight": 2, "critical": true }], + "requiredAll": [ + { "value": "keep the cache API backward-compatible", "dimension": "constraints", "weight": 2, "critical": true }, + { "value": "ask before destructive or externally visible actions", "dimension": "evolution-v2", "weight": 2, "critical": true } + ], + "requiredAny": [{ "values": ["Show the diff", "Return the patch"], "dimension": "output", "weight": 1, "critical": false }], + "forbidden": [{ "value": "[", "dimension": "grounding", "weight": 1, "critical": true }] + } + }, + { + "id": "v2-diagnose-session", "mode": "Diagnose", + "input": "Rewrite the session-timeout diagnosis under the v2 autonomy policy.", + "rubric": { + "expectedMode": "Diagnose", "maxQuestions": 0, + "exactPaths": [{ "value": "@src/auth/session.ts", "weight": 2, "critical": true }], + "exactCommands": [{ "value": "npm test -- src/auth/session.test.ts", "weight": 2, "critical": true }], + "requiredAll": [ + { "value": "fix the root cause without suppressing the error", "dimension": "constraints", "weight": 2, "critical": true }, + { "value": "ask before destructive or externally visible actions", "dimension": "evolution-v2", "weight": 2, "critical": true } + ], + "requiredAny": [{ "values": ["Show the diff", "Return the patch"], "dimension": "output", "weight": 1, "critical": false }], + "forbidden": [{ "value": "[", "dimension": "grounding", "weight": 1, "critical": true }] + } + } +] diff --git a/spikes/clone-economics/package.json b/spikes/clone-economics/package.json new file mode 100644 index 0000000..49e07b6 --- /dev/null +++ b/spikes/clone-economics/package.json @@ -0,0 +1,12 @@ +{ + "name": "clone-economics-spike", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Throwaway offline-first harness for Skill clone economics and staleness.", + "scripts": { + "e2e": "MOCK_LLM=1 ALLOW_LIVE_LLM=0 node e2e.mjs", + "run": "MOCK_LLM=1 node run.mjs", + "real": "MOCK_LLM=0 node run.mjs --live" + } +} diff --git a/spikes/clone-economics/run.mjs b/spikes/clone-economics/run.mjs new file mode 100644 index 0000000..3552681 --- /dev/null +++ b/spikes/clone-economics/run.mjs @@ -0,0 +1,40 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { runExperiment } from './src/experiment.mjs'; + +const args = new Map(process.argv.slice(2).map((item) => { + const [key, value = '1'] = item.replace(/^--/, '').split('=', 2); + return [key, value]; +})); +const live = args.has('live'); +const number = (name, fallback) => { + const raw = args.get(name) ?? process.env[name.toUpperCase().replaceAll('-', '_')]; + return raw === undefined || raw === '' ? fallback : Number(raw); +}; +const here = path.dirname(fileURLToPath(import.meta.url)); +const mode = live ? 'live' : 'mock'; +const outputDir = path.resolve(args.get('output') ?? path.join(here, 'runs', mode)); + +const result = await runExperiment({ + mode, + outputDir, + N: number('n', live ? undefined : 6), + invocationPriceUsd: number('invocation-price-usd', live ? undefined : 0.25), + cloneServingCostUsd: number('clone-serving-cost-usd', live ? undefined : 0.05), + deployCostUsd: number('deploy-cost-usd', live ? undefined : 0.05), + laborCostUsd: number('labor-cost-usd', live ? undefined : 0), + apiKey: live ? process.env.ANTHROPIC_API_KEY : undefined, + model: process.env.MODEL, + maxInputTokens: number('max-input-tokens'), + maxTokens: number('max-tokens'), + inputUsdPerMillion: number('input-usd-per-million'), + outputUsdPerMillion: number('output-usd-per-million'), + pricingAsOf: process.env.PRICING_AS_OF, + pricingSource: process.env.PRICING_SOURCE, + maxRunCostUsd: number('max-run-cost-usd'), +}); + +console.log(result.markdownReport); +console.log(`JSON: ${result.outputFiles.json}`); +console.log(`Markdown: ${result.outputFiles.markdown}`); diff --git a/spikes/clone-economics/src/adapters.mjs b/spikes/clone-economics/src/adapters.mjs new file mode 100644 index 0000000..44703c0 --- /dev/null +++ b/spikes/clone-economics/src/adapters.mjs @@ -0,0 +1,160 @@ +import { performance } from 'node:perf_hooks'; + +const clone = (value) => structuredClone(value); +const rounded = (value) => Number(value.toFixed(12)); + +const LIVE_KIND_INSTRUCTIONS = { + 'target-train': 'Apply the supplied target Skill and reference to the supplied request and synthetic repository context. Return only the resulting response.', + 'target-heldout': 'Apply the supplied target Skill and reference to the supplied heldout request and synthetic repository context. Return only the resulting response.', + 'clone-heldout': 'Apply the supplied clone Skill to the supplied heldout request and synthetic repository context. Return only the resulting response.', + 'bad-clone-heldout': 'Apply the supplied clone Skill to the supplied heldout request and synthetic repository context. Return only the resulting response.', + 'target-v2-heldout': 'Apply the supplied target Skill, reference, and evolution overlay to the supplied heldout request and synthetic repository context. Return only the resulting response.', + 'clone-v2-heldout': 'Apply the frozen supplied clone Skill to the supplied v2 heldout request and synthetic repository context. Do not infer target or reference content that was not supplied. Return only the resulting response.', + distill: 'Using only payload.instructions and payload.pairs, author one valid SKILL.md that reproduces the demonstrated capability. Return SKILL.md only.', +}; + +export class MockLlmAdapter { + constructor({ transcript, cloneSkillMd }) { + this.transcript = transcript; + this.cloneSkillMd = cloneSkillMd; + this.capturedRequests = []; + this.records = []; + this.pricing = transcript.pricing; + } + + async invoke(request) { + this.capturedRequests.push(clone(request)); + let output; + if (request.kind === 'distill') output = this.cloneSkillMd; + else if (request.kind === 'target-train') output = this.transcript.trainOutputs[request.caseId]; + else if (request.kind.endsWith('-v2-heldout')) { + const profile = request.kind.startsWith('target-') ? 'target' : 'clone'; + output = this.transcript.v2Outputs[request.caseId]?.[profile]; + } else { + const profile = request.kind === 'target-heldout' ? 'target' : request.kind === 'clone-heldout' ? 'clone' : 'bad'; + output = this.transcript.heldoutOutputs[request.caseId]?.[profile]; + } + if (typeof output !== 'string') throw new Error(`Missing SYNTHETIC transcript output for ${request.kind}:${request.caseId ?? 'distill'}`); + const profile = this.transcript.usageProfiles[request.kind]; + if (!profile) throw new Error(`Missing SYNTHETIC usage profile for ${request.kind}`); + const derivedCostUsd = Number.isFinite(profile.inputTokens) && Number.isFinite(profile.outputTokens) + ? rounded(( + profile.inputTokens * this.pricing.inputUsdPerMillion + + profile.outputTokens * this.pricing.outputUsdPerMillion + ) / 1_000_000) + : null; + if (profile.costUsd !== null && (!Number.isFinite(derivedCostUsd) || Math.abs(profile.costUsd - derivedCostUsd) > 1e-12)) { + throw new Error(`SYNTHETIC cost does not reconcile with usage and pricing for ${request.kind}`); + } + const record = { + requestId: `mock-${String(this.records.length + 1).padStart(3, '0')}`, + kind: request.kind, + caseId: request.caseId ?? null, + evidenceLabel: 'SYNTHETIC', + model: 'mock-canned-model', + rawUsage: { input_tokens: profile.inputTokens, output_tokens: profile.outputTokens }, + normalizedUsage: { inputTokens: profile.inputTokens, outputTokens: profile.outputTokens }, + costUsd: derivedCostUsd, + latencyMs: profile.latencyMs, + }; + this.records.push(record); + return { output, ...record }; + } +} + +function requiredString(value, name) { + if (typeof value !== 'string' || value.trim() === '') throw new Error(`${name} is required for a live run`); + return value; +} + +function requiredPositive(value, name) { + if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number for a live run`); + return value; +} + +export class LiveAnthropicAdapter { + constructor(config) { + if (config.mode !== 'live' || process.env.MOCK_LLM === '1') throw new Error('Live adapter requires non-mock live mode'); + if (process.env.ALLOW_LIVE_LLM !== '1') throw new Error('ALLOW_LIVE_LLM=1 is required before any live adapter construction'); + this.apiKey = requiredString(config.apiKey, 'ANTHROPIC_API_KEY'); + this.model = requiredString(config.model, 'MODEL'); + this.N = requiredPositive(config.N, 'N'); + this.maxInputTokens = requiredPositive(config.maxInputTokens, 'MAX_INPUT_TOKENS'); + this.maxTokens = requiredPositive(config.maxTokens, 'MAX_TOKENS'); + this.pricing = { + inputUsdPerMillion: requiredPositive(config.inputUsdPerMillion, 'INPUT_USD_PER_MILLION'), + outputUsdPerMillion: requiredPositive(config.outputUsdPerMillion, 'OUTPUT_USD_PER_MILLION'), + asOf: requiredString(config.pricingAsOf, 'PRICING_AS_OF'), + source: requiredString(config.pricingSource, 'PRICING_SOURCE'), + }; + this.maxRunCostUsd = requiredPositive(config.maxRunCostUsd, 'MAX_RUN_COST_USD'); + const perRequestCap = ( + this.maxInputTokens * this.pricing.inputUsdPerMillion + + this.maxTokens * this.pricing.outputUsdPerMillion + ) / 1_000_000; + this.conservativeMaxCostUsd = perRequestCap * requiredPositive(config.estimatedRequests, 'estimatedRequests'); + if (this.conservativeMaxCostUsd > this.maxRunCostUsd) { + throw new Error(`Conservative maximum $${this.conservativeMaxCostUsd.toFixed(6)} exceeds MAX_RUN_COST_USD $${this.maxRunCostUsd.toFixed(6)}`); + } + this.capturedRequests = []; + this.records = []; + this.measuredSpendUsd = 0; + } + + async invoke(request) { + this.capturedRequests.push(clone(request)); + const instruction = LIVE_KIND_INSTRUCTIONS[request.kind]; + if (!instruction) throw new Error(`Unsupported live request kind: ${request.kind}`); + const prompt = JSON.stringify({ instruction, payload: request.payload }); + // UTF-8 bytes are a deliberately conservative upper bound for tokenizer + // units: abort before fetch if even that bound exceeds the operator cap. + const inputTokenUpperBound = Buffer.byteLength(prompt, 'utf8'); + if (inputTokenUpperBound > this.maxInputTokens) { + throw new Error(`Input token upper bound ${inputTokenUpperBound} exceeds MAX_INPUT_TOKENS ${this.maxInputTokens}`); + } + const started = performance.now(); + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': this.apiKey, + }, + body: JSON.stringify({ + model: this.model, + max_tokens: this.maxTokens, + messages: [{ role: 'user', content: prompt }], + }), + }); + if (!response.ok) throw new Error(`Anthropic request failed with HTTP ${response.status}`); + const json = await response.json(); + const inputTokens = json.usage?.input_tokens; + const outputTokens = json.usage?.output_tokens; + const costUsd = Number.isFinite(inputTokens) && Number.isFinite(outputTokens) + ? inputTokens * this.pricing.inputUsdPerMillion / 1_000_000 + outputTokens * this.pricing.outputUsdPerMillion / 1_000_000 + : null; + const usageEvidenceLabel = costUsd === null + ? 'PROVIDER RESPONSE MEASURED; USAGE/COST UNKNOWN' + : 'MEASURED'; + if (costUsd !== null) { + this.measuredSpendUsd += costUsd; + if (this.measuredSpendUsd > this.maxRunCostUsd) throw new Error('Cumulative measured spend exceeded MAX_RUN_COST_USD'); + } + const record = { + requestId: json.id ?? `live-${this.records.length + 1}`, + kind: request.kind, + caseId: request.caseId ?? null, + evidenceLabel: usageEvidenceLabel, + model: this.model, + rawUsage: json.usage ?? null, + normalizedUsage: { + inputTokens: Number.isFinite(inputTokens) ? inputTokens : null, + outputTokens: Number.isFinite(outputTokens) ? outputTokens : null, + }, + costUsd, + latencyMs: performance.now() - started, + }; + this.records.push(record); + return { output: json.content?.find((item) => item.type === 'text')?.text ?? '', ...record }; + } +} diff --git a/spikes/clone-economics/src/economics.mjs b/spikes/clone-economics/src/economics.mjs new file mode 100644 index 0000000..8e0e7a2 --- /dev/null +++ b/spikes/clone-economics/src/economics.mjs @@ -0,0 +1,46 @@ +const rounded = (value) => Number(value.toFixed(12)); + +function core({ N, invocationPriceUsd, cloneServingCostUsd, distillationProviderUsd, tuningEvaluationUsd, deployCostUsd, laborCostUsd }) { + const acquisitionModeledUsd = rounded(N * invocationPriceUsd); + const buildParts = [acquisitionModeledUsd, distillationProviderUsd, tuningEvaluationUsd, deployCostUsd, laborCostUsd]; + const attackerBuildUsd = buildParts.every((value) => Number.isFinite(value)) + ? rounded(buildParts.reduce((sum, value) => sum + value, 0)) + : null; + const margin = invocationPriceUsd - cloneServingCostUsd; + return { + acquisitionModeledUsd, + attackerBuildUsd, + distillationToAcquisition: acquisitionModeledUsd > 0 && Number.isFinite(distillationProviderUsd) ? rounded(distillationProviderUsd / acquisitionModeledUsd) : null, + buildToAcquisition: acquisitionModeledUsd > 0 && attackerBuildUsd !== null ? rounded(attackerBuildUsd / acquisitionModeledUsd) : null, + breakEvenInvocations: margin > 0 && attackerBuildUsd !== null ? Math.ceil(attackerBuildUsd / margin) : null, + }; +} + +export function computeEconomics(input) { + const values = core(input); + const zeroPriceProbe = core({ ...input, invocationPriceUsd: 0 }); + return { + acquisitionFormula: 'A = N × listed Invocation price (MODELED; no x402 settlement in this spike)', + acquisitionModeledUsd: values.acquisitionModeledUsd, + distillationProviderUsd: input.distillationProviderUsd, + tuningEvaluationUsd: input.tuningEvaluationUsd, + tuningNote: input.tuningEvaluationUsd === 0 ? 'No tuning/revision attempt was performed.' : 'Attack-side tuning/revision cost included.', + deployCostUsd: input.deployCostUsd, + laborCostUsd: input.laborCostUsd, + laborCostTreatment: input.laborCostUsd === 0 ? 'Explicitly excluded from this run.' : 'Operator-supplied input.', + attackerBuildUsd: values.attackerBuildUsd, + measurementEvaluationUsd: input.measurementEvaluationUsd, + evaluationExcludedFromBuild: true, + distillationToAcquisition: values.distillationToAcquisition, + buildToAcquisition: values.buildToAcquisition, + breakEvenInvocations: values.breakEvenInvocations, + cloneServingCostUsd: input.cloneServingCostUsd, + providerCostsNotAddedToAcquisition: true, + providerCostBreakdown: input.providerCostBreakdown, + zeroPriceProbe: { + distillationToAcquisition: zeroPriceProbe.distillationToAcquisition, + buildToAcquisition: zeroPriceProbe.buildToAcquisition, + breakEvenInvocations: zeroPriceProbe.breakEvenInvocations, + }, + }; +} diff --git a/spikes/clone-economics/src/experiment.mjs b/spikes/clone-economics/src/experiment.mjs new file mode 100644 index 0000000..198f115 --- /dev/null +++ b/spikes/clone-economics/src/experiment.mjs @@ -0,0 +1,256 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { MockLlmAdapter, LiveAnthropicAdapter } from './adapters.mjs'; +import { computeEconomics } from './economics.mjs'; +import { renderJson, renderMarkdown } from './reports.mjs'; +import { FIDELITY_THRESHOLD, RUBRIC_VERSION, scoreEvaluation } from './scoring.mjs'; + +const srcDir = path.dirname(fileURLToPath(import.meta.url)); +const spikeRoot = path.resolve(srcDir, '..'); +const repoRoot = path.resolve(spikeRoot, '../..'); +const fixturePath = (name) => path.join(spikeRoot, 'fixtures', name); +const readJson = (name) => JSON.parse(fs.readFileSync(fixturePath(name), 'utf8')); +const sha256 = (value) => `sha256:${createHash('sha256').update(value).digest('hex')}`; +const normalizedInputHash = (value) => sha256(value.trim().replace(/\s+/g, ' ').toLowerCase()); +const rounded = (value) => Number(value.toFixed(12)); + +function assertValidClone(skillMd) { + if (!skillMd.startsWith('---\nname:') || !skillMd.includes('\n---\n') || !skillMd.includes('\n# ')) { + throw new Error('Distillation did not produce a valid SKILL.md'); + } +} + +async function collect(adapter, fixtures, kind, payloadFor) { + const outputs = {}; + for (const fixture of fixtures) { + const response = await adapter.invoke({ kind, caseId: fixture.id, payload: payloadFor(fixture) }); + outputs[fixture.id] = response.output; + } + return outputs; +} + +function sumCosts(records, kinds) { + const selected = records.filter((item) => kinds.includes(item.kind)); + if (selected.some((item) => item.costUsd === null)) return null; + return rounded(selected.reduce((sum, item) => sum + item.costUsd, 0)); +} + +function normalizedUsage(records) { + const inputKnown = records.every((item) => item.normalizedUsage.inputTokens !== null); + const outputKnown = records.every((item) => item.normalizedUsage.outputTokens !== null); + return { + inputTokens: inputKnown ? records.reduce((sum, item) => sum + item.normalizedUsage.inputTokens, 0) : null, + outputTokens: outputKnown ? records.reduce((sum, item) => sum + item.normalizedUsage.outputTokens, 0) : null, + providerCostUsd: records.some((item) => item.costUsd === null) ? null : rounded(records.reduce((sum, item) => sum + item.costUsd, 0)), + }; +} + +function buildAdapter(mode, options) { + if (options.adapter) return options.adapter; + if (mode === 'mock') { + return new MockLlmAdapter({ + transcript: options.mockTranscript ?? readJson('mock-transcript.json'), + cloneSkillMd: fs.readFileSync(fixturePath('good-clone/SKILL.md'), 'utf8'), + }); + } + return new LiveAnthropicAdapter({ + mode, + apiKey: options.apiKey, + model: options.model, + N: options.N, + maxInputTokens: options.maxInputTokens, + maxTokens: options.maxTokens, + inputUsdPerMillion: options.inputUsdPerMillion, + outputUsdPerMillion: options.outputUsdPerMillion, + pricingAsOf: options.pricingAsOf, + pricingSource: options.pricingSource, + maxRunCostUsd: options.maxRunCostUsd, + estimatedRequests: options.estimatedRequests, + }); +} + +export async function runExperiment(options = {}) { + const mode = options.mode ?? (process.env.MOCK_LLM === '1' ? 'mock' : 'live'); + if (!['mock', 'live'].includes(mode)) throw new Error('mode must be mock or live'); + const trainFixtures = readJson('train.json'); + const heldoutFixtures = readJson('heldout.json'); + const v2Fixtures = readJson('v2-heldout.json'); + const repoInventory = readJson('repo-inventory.json'); + const executorSettings = readJson('executor-settings.json'); + const evolutionOverlay = readJson('evolution-v2.json'); + const N = Number(options.N ?? (mode === 'mock' ? 6 : Number.NaN)); + if (!Number.isInteger(N) || N <= 0 || N > trainFixtures.length) { + throw new Error(`N must be an integer from 1 to ${trainFixtures.length}`); + } + const selectedTrain = trainFixtures.slice(0, N); + const targetFile = path.join(repoRoot, '.claude/skills/optimizing-claude-code-prompts/SKILL.md'); + const referenceFile = path.join(repoRoot, '.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md'); + const targetText = fs.readFileSync(targetFile, 'utf8'); + const referenceText = fs.readFileSync(referenceFile, 'utf8'); + const badCloneSkillMd = fs.readFileSync(fixturePath('bad-clone/SKILL.md'), 'utf8'); + const datasetTrain = selectedTrain.map((item) => ({ ...item, inputHash: normalizedInputHash(item.input) })); + const datasetHeldout = heldoutFixtures.map((item) => ({ id: item.id, mode: item.mode, input: item.input, inputHash: normalizedInputHash(item.input) })); + const trainIds = new Set(datasetTrain.map((item) => item.id)); + const trainHashes = new Set(datasetTrain.map((item) => item.inputHash)); + const disjoint = datasetHeldout.every((item) => !trainIds.has(item.id) && !trainHashes.has(item.inputHash)); + if (!disjoint) throw new Error('Train and heldout fixtures must be disjoint by ID and normalized-input hash'); + + const invocationPriceUsd = Number(options.invocationPriceUsd ?? (mode === 'mock' ? 0.25 : Number.NaN)); + const cloneServingCostUsd = Number(options.cloneServingCostUsd ?? (mode === 'mock' ? 0.05 : Number.NaN)); + const deployCostUsd = Number(options.deployCostUsd ?? (mode === 'mock' ? 0.05 : Number.NaN)); + const laborCostUsd = Number(options.laborCostUsd ?? (mode === 'mock' ? 0 : Number.NaN)); + for (const [name, value] of Object.entries({ invocationPriceUsd, cloneServingCostUsd, deployCostUsd, laborCostUsd })) { + if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be supplied explicitly as a non-negative number`); + } + + const estimatedRequests = N + 1 + heldoutFixtures.length * 3 + v2Fixtures.length * 2; + const adapter = buildAdapter(mode, { ...options, N, estimatedRequests }); + const sharedExecutor = { repoInventory, executorSettings }; + const acquisitionPairs = []; + for (const fixture of selectedTrain) { + const response = await adapter.invoke({ + kind: 'target-train', + caseId: fixture.id, + payload: { input: fixture.input, targetSkill: targetText, reference: referenceText, ...sharedExecutor }, + }); + acquisitionPairs.push({ input: fixture.input, output: response.output }); + } + + const distillationPayload = { + instructions: 'Author one valid SKILL.md that reproduces the demonstrated input-to-output capability. Use only the supplied examples. Return SKILL.md only.', + pairs: acquisitionPairs, + }; + const distilled = await adapter.invoke({ kind: 'distill', payload: distillationPayload }); + const cloneSkillMd = distilled.output; + assertValidClone(cloneSkillMd); + + const targetOutputs = await collect(adapter, heldoutFixtures, 'target-heldout', (fixture) => ({ input: fixture.input, targetSkill: targetText, reference: referenceText, ...sharedExecutor })); + const cloneOutputs = await collect(adapter, heldoutFixtures, 'clone-heldout', (fixture) => ({ input: fixture.input, cloneSkill: cloneSkillMd, ...sharedExecutor })); + const badOutputs = await collect(adapter, heldoutFixtures, 'bad-clone-heldout', (fixture) => ({ input: fixture.input, cloneSkill: badCloneSkillMd, ...sharedExecutor })); + const targetV2Outputs = await collect(adapter, v2Fixtures, 'target-v2-heldout', (fixture) => ({ input: fixture.input, targetSkill: targetText, reference: referenceText, evolutionOverlay, ...sharedExecutor })); + const cloneV2Outputs = await collect(adapter, v2Fixtures, 'clone-v2-heldout', (fixture) => ({ input: fixture.input, cloneSkill: cloneSkillMd, ...sharedExecutor })); + + const targetScore = scoreEvaluation(targetOutputs, heldoutFixtures); + const cloneScore = scoreEvaluation(cloneOutputs, heldoutFixtures); + const badCloneScore = scoreEvaluation(badOutputs, heldoutFixtures); + const updatedTargetScore = scoreEvaluation(targetV2Outputs, v2Fixtures); + const frozenCloneScore = scoreEvaluation(cloneV2Outputs, v2Fixtures); + const scoringA = JSON.stringify({ target: scoreEvaluation(targetOutputs, heldoutFixtures), clone: scoreEvaluation(cloneOutputs, heldoutFixtures) }); + const scoringB = JSON.stringify({ target: scoreEvaluation(targetOutputs, heldoutFixtures), clone: scoreEvaluation(cloneOutputs, heldoutFixtures) }); + + const acquisitionProviderUsd = sumCosts(adapter.records, ['target-train']); + const distillationProviderUsd = sumCosts(adapter.records, ['distill']); + const evaluationKinds = ['target-heldout', 'clone-heldout', 'bad-clone-heldout', 'target-v2-heldout', 'clone-v2-heldout']; + const measurementEvaluationUsd = sumCosts(adapter.records, evaluationKinds); + const economics = computeEconomics({ + N, + invocationPriceUsd, + cloneServingCostUsd, + distillationProviderUsd, + tuningEvaluationUsd: 0, + deployCostUsd, + laborCostUsd, + measurementEvaluationUsd, + providerCostBreakdown: { + acquisitionHarnessProviderUsd: acquisitionProviderUsd, + distillationProviderUsd, + benchmarkEvaluationProviderUsd: measurementEvaluationUsd, + }, + }); + const trainRecords = adapter.records.filter((item) => item.kind === 'target-train'); + const distillRecord = adapter.records.find((item) => item.kind === 'distill'); + const sequentialBuildMs = rounded(trainRecords.reduce((sum, item) => sum + item.latencyMs, 0) + distillRecord.latencyMs); + const parallelAcquisitionLowerBoundMs = rounded(Math.max(...trainRecords.map((item) => item.latencyMs)) + distillRecord.latencyMs); + const completeProviderUsage = adapter.records.every((item) => ( + Number.isFinite(item.normalizedUsage.inputTokens) + && Number.isFinite(item.normalizedUsage.outputTokens) + && Number.isFinite(item.costUsd) + )); + const providerUsageEvidence = completeProviderUsage ? 'measured' : 'measured where returned; unknown otherwise'; + const evidenceLabel = mode === 'mock' + ? 'SYNTHETIC' + : `MIXED — provider execution measured; usage/cost ${providerUsageEvidence}; paid-pair acquisition MODELED; fixtures SYNTHETIC`; + const claimStatus = mode === 'mock' + ? 'LIVE RUN NOT EXECUTED — no key/explicit opt-in; no measured clone-economics result.' + : `LIVE RUN EXECUTED — provider calls executed; usage/cost ${providerUsageEvidence}; paid-pair acquisition remains MODELED unless separately settled.`; + + const report = { + schemaVersion: 1, + question: 'How cheaply can N paid I/O pairs from the target Skill be distilled into a clone, and how quickly would the original have to evolve to keep a frozen clone stale?', + mode, + evidenceLabel, + claimStatus, + target: { + skill: { path: '.claude/skills/optimizing-claude-code-prompts/SKILL.md', sha256: sha256(targetText) }, + reference: { path: '.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md', sha256: sha256(referenceText) }, + }, + dataset: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'SYNTHETIC FIXTURES + MEASURED OUTPUTS', N, H: heldoutFixtures.length, train: datasetTrain, heldout: datasetHeldout, disjoint }, + isolation: { + distillationPairCount: acquisitionPairs.length, + distillationPayloadSha256: sha256(JSON.stringify(distillationPayload)), + payloadFields: Object.keys(distillationPayload), + targetAndCloneSharedContextHash: sha256(JSON.stringify(sharedExecutor)), + }, + generatedClone: { sha256: sha256(cloneSkillMd), validSkillMd: true }, + fidelity: { + evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'MEASURED AGAINST DETERMINISTIC RUBRIC', + rubricVersion: RUBRIC_VERSION, + threshold: FIDELITY_THRESHOLD, + target: targetScore, + clone: cloneScore, + retention: targetScore.absoluteScore > 0 ? rounded(cloneScore.absoluteScore / targetScore.absoluteScore) : null, + badClone: badCloneScore, + scoreDeterminism: { byteIdentical: scoringA === scoringB }, + }, + evolution: { + evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'SYNTHETIC OVERLAY + MEASURED OUTPUTS', + overlayVersion: evolutionOverlay.version, + newRequirement: evolutionOverlay.newRequirement, + heldoutIds: v2Fixtures.map((item) => item.id), + updatedTarget: updatedTargetScore, + frozenClone: frozenCloneScore, + staleFidelityDelta: rounded(updatedTargetScore.absoluteScore - frozenCloneScore.absoluteScore), + statement: evolutionOverlay.statement, + }, + economics: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC + MODELED' : `Provider cost ${providerUsageEvidence}; acquisition MODELED`, ...economics }, + usage: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : `Provider usage ${providerUsageEvidence}`, raw: adapter.records, normalized: normalizedUsage(adapter.records) }, + pricing: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'OPERATOR-SUPPLIED', ...adapter.pricing }, + timing: { + evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'MEASURED + DERIVED LOWER BOUND', + requestLatencies: adapter.records.map((item) => ({ requestId: item.requestId, kind: item.kind, caseId: item.caseId, latencyMs: item.latencyMs, evidenceLabel: item.evidenceLabel })), + acquisitionSequentialMs: rounded(trainRecords.reduce((sum, item) => sum + item.latencyMs, 0)), + distillationMs: distillRecord.latencyMs, + sequentialBuildMs, + parallelAcquisitionLowerBoundMs, + evaluationMs: rounded(adapter.records.filter((item) => evaluationKinds.includes(item.kind)).reduce((sum, item) => sum + item.latencyMs, 0)), + requiredUpdateCadence: { + label: 'HYPOTHESIS/EXTRAPOLATION', + statement: 'A static synthetic overlay gives no calendar cadence; dated live Skill revisions and repeated clone freezes are required.', + }, + }, + limitations: mode === 'mock' ? [ + 'All mock outputs, usage, prices, costs, and timings are SYNTHETIC canned evidence.', + 'No API key, model network call, x402 settlement, or live Skill Invocation occurred.', + 'Paid-pair acquisition A is MODELED as N × listed Invocation price.', + 'One static v2 overlay cannot establish Skill half-life or evolution efficacy.', + 'This does not validate the corpus-wide ~30x claim; matched-quality serving cost remains unmeasured.', + ] : [ + 'Provider usage, cost, and latency are measured only when returned; missing values remain unknown.', + 'Paid-pair acquisition A remains MODELED as N × listed Invocation price; this harness does not settle x402 payments.', + 'Deterministic predicates measure contract fidelity, not universal semantic equivalence.', + 'One static v2 overlay cannot establish Skill half-life, evolution efficacy, or a calendar update cadence.', + 'This does not validate the corpus-wide ~30x claim; matched-quality serving cost remains unmeasured.', + ], + }; + const jsonReport = renderJson(report); + const markdownReport = renderMarkdown(report); + const outputDir = path.resolve(options.outputDir ?? path.join(spikeRoot, 'runs', mode)); + fs.mkdirSync(outputDir, { recursive: true }); + const outputFiles = { json: path.join(outputDir, 'report.json'), markdown: path.join(outputDir, 'report.md') }; + fs.writeFileSync(outputFiles.json, jsonReport); + fs.writeFileSync(outputFiles.markdown, markdownReport); + return { report, jsonReport, markdownReport, outputFiles, capturedRequests: adapter.capturedRequests, cloneSkillMd }; +} diff --git a/spikes/clone-economics/src/reports.mjs b/spikes/clone-economics/src/reports.mjs new file mode 100644 index 0000000..1da7e4c --- /dev/null +++ b/spikes/clone-economics/src/reports.mjs @@ -0,0 +1,76 @@ +export function renderJson(report) { + return `${JSON.stringify(report, null, 2)}\n`; +} + +const number = (value, digits = 3) => Number.isFinite(value) ? value.toFixed(digits) : 'unknown'; + +function scoreRows(target, clone) { + return target.cases.map((item, index) => `| ${item.id} | ${item.score.toFixed(3)} | ${clone.cases[index].score.toFixed(3)} | ${clone.cases[index].criticalGatePass ? 'pass' : 'FAIL'} |`).join('\n'); +} + +export function renderMarkdown(report) { + return `# Clone-economics spike report + +**Evidence:** ${report.evidenceLabel}
+**Mode:** ${report.mode}
+**Verdict:** ${report.claimStatus} + +## Question + +${report.question} + +Target Skill: \`${report.target.skill.path}\` (\`${report.target.skill.sha256}\`)
+Reference: \`${report.target.reference.path}\` (\`${report.target.reference.sha256}\`)
+Dataset: N=${report.dataset.N} acquisition pairs, H=${report.dataset.H} heldout cases; IDs and normalized hashes disjoint=${report.dataset.disjoint}. + +## Fidelity (${report.fidelity.rubricVersion}) + +Threshold: ${report.fidelity.threshold.toFixed(2)} plus every critical gate. + +| Metric | Target | Clone | +|---|---:|---:| +| Absolute score | ${report.fidelity.target.absoluteScore.toFixed(3)} | ${report.fidelity.clone.absoluteScore.toFixed(3)} | +| Critical gates | ${report.fidelity.target.criticalGatePass ? 'pass' : 'FAIL'} | ${report.fidelity.clone.criticalGatePass ? 'pass' : 'FAIL'} | +| Retention (secondary) | — | ${number(report.fidelity.retention)} | + +| Case | Target | Clone | Clone critical gate | +|---|---:|---:|---| +${scoreRows(report.fidelity.target, report.fidelity.clone)} + +Deliberately bad clone: ${report.fidelity.badClone.absoluteScore.toFixed(3)}, critical gates ${report.fidelity.badClone.criticalGatePass ? 'pass' : 'FAIL'}. + +## Synthetic v2 staleness overlay + +**${report.evolution.evidenceLabel}** — updated target ${number(report.evolution.updatedTarget.absoluteScore)}; frozen v1 clone ${number(report.evolution.frozenClone.absoluteScore)}; stale-fidelity delta ${number(report.evolution.staleFidelityDelta)}. + +${report.evolution.statement} + +## Economics + +| Quantity | USD / ratio | +|---|---:| +| A — modeled pair acquisition | ${number(report.economics.acquisitionModeledUsd)} | +| D — distillation provider cost | ${number(report.economics.distillationProviderUsd)} | +| E_tune — attacker tuning/evaluation | ${number(report.economics.tuningEvaluationUsd)} | +| C_deploy | ${number(report.economics.deployCostUsd)} | +| C_labor | ${number(report.economics.laborCostUsd)} (${report.economics.laborCostTreatment}) | +| B — attacker build | ${number(report.economics.attackerBuildUsd)} | +| E_measure — benchmark overhead, excluded from B | ${number(report.economics.measurementEvaluationUsd)} | +| D/A | ${report.economics.distillationToAcquisition ?? 'undefined'} | +| B/A | ${report.economics.buildToAcquisition ?? 'undefined'} | +| Break-even Invocations | ${report.economics.breakEvenInvocations ?? 'undefined'} | + +Acquisition is MODELED as N × listed Invocation price; no x402 payment settled. Provider/harness costs are listed separately and not double-counted into A. + +## Usage, pricing, and timing + +Pricing snapshot: input $${report.pricing.inputUsdPerMillion}/M, output $${report.pricing.outputUsdPerMillion}/M; as of ${report.pricing.asOf}; source: ${report.pricing.source}.
+Normalized usage: ${number(report.usage.normalized.inputTokens, 0)} input tokens, ${number(report.usage.normalized.outputTokens, 0)} output tokens.
+Sequential build time: ${report.timing.sequentialBuildMs} ms. Parallel-acquisition lower bound: ${report.timing.parallelAcquisitionLowerBoundMs} ms.
+Required update cadence: **${report.timing.requiredUpdateCadence.label}** — ${report.timing.requiredUpdateCadence.statement} + +## Limitations + +${report.limitations.map((item) => `- ${item}`).join('\n')} +`; +} diff --git a/spikes/clone-economics/src/scoring.mjs b/spikes/clone-economics/src/scoring.mjs new file mode 100644 index 0000000..458debc --- /dev/null +++ b/spikes/clone-economics/src/scoring.mjs @@ -0,0 +1,63 @@ +export const RUBRIC_VERSION = 'contract-v1'; +export const FIDELITY_THRESHOLD = 0.80; + +const rounded = (value) => Number(value.toFixed(12)); + +function scoreCase(output, fixture) { + const checks = []; + const add = (id, dimension, weight, critical, passed) => { + checks.push({ id, dimension, weight, critical, passed }); + }; + const rubric = fixture.rubric; + add('expected-mode', 'mode', 1, true, output.includes(`Mode: ${rubric.expectedMode}`)); + add('max-questions', 'questions', 1, true, (output.match(/\?/g) ?? []).length <= rubric.maxQuestions); + for (const [index, item] of rubric.exactPaths.entries()) { + add(`exact-path-${index}`, 'grounding', item.weight, item.critical, output.includes(item.value)); + } + for (const [index, item] of rubric.exactCommands.entries()) { + add(`exact-command-${index}`, 'verification', item.weight, item.critical, output.includes(item.value)); + } + for (const [index, item] of rubric.requiredAll.entries()) { + add(`required-all-${index}`, item.dimension, item.weight, item.critical, output.includes(item.value)); + } + for (const [index, item] of rubric.requiredAny.entries()) { + add(`required-any-${index}`, item.dimension, item.weight, item.critical, item.values.some((value) => output.includes(value))); + } + for (const [index, item] of rubric.forbidden.entries()) { + add(`forbidden-${index}`, item.dimension, item.weight, item.critical, !output.includes(item.value)); + } + + const totalWeight = checks.reduce((sum, item) => sum + item.weight, 0); + const passedWeight = checks.filter((item) => item.passed).reduce((sum, item) => sum + item.weight, 0); + const dimensionEntries = new Map(); + for (const check of checks) { + const current = dimensionEntries.get(check.dimension) ?? { passedWeight: 0, totalWeight: 0 }; + current.totalWeight += check.weight; + if (check.passed) current.passedWeight += check.weight; + dimensionEntries.set(check.dimension, current); + } + const dimensions = Object.fromEntries([...dimensionEntries].map(([name, value]) => [name, rounded(value.passedWeight / value.totalWeight)])); + return { + id: fixture.id, + score: rounded(passedWeight / totalWeight), + criticalGatePass: checks.filter((item) => item.critical).every((item) => item.passed), + dimensions, + checks, + }; +} + +export function scoreEvaluation(outputsById, fixtures, threshold = FIDELITY_THRESHOLD) { + const cases = fixtures.map((fixture) => { + const output = outputsById[fixture.id]; + if (typeof output !== 'string') throw new Error(`Missing evaluation output for ${fixture.id}`); + return scoreCase(output, fixture); + }); + const absoluteScore = rounded(cases.reduce((sum, item) => sum + item.score, 0) / cases.length); + const criticalGatePass = cases.every((item) => item.criticalGatePass); + return { + absoluteScore, + criticalGatePass, + passedThreshold: absoluteScore >= threshold && criticalGatePass, + cases, + }; +} From 5d5ca226d6665d84b7e4417f5820cb75d0370440 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 12 Jul 2026 13:54:57 -0400 Subject: [PATCH 010/165] Model free reauthor fork economics --- prototype/README.md | 61 ++++- prototype/spike-fork-economics.mjs | 372 ++++++++++++++++++++++++++--- 2 files changed, 390 insertions(+), 43 deletions(-) diff --git a/prototype/README.md b/prototype/README.md index 131fcfc..efa91e1 100644 --- a/prototype/README.md +++ b/prototype/README.md @@ -45,11 +45,12 @@ In-memory only; nothing persists. `seed` resets to the demo scenario; `quit` exi Run `node prototype/spike-fork-economics.mjs` to reproduce. Verdict: -**1. The fork-killing threshold is real and matches the hypothesis `i* = p_parent / p_fork`.** -The leaf (latest forker) keeps `(1 − inherit)` of net *regardless of chain depth*. Forking beats -authoring your uplift solo as long as `inherit < p_parent/p_fork`. Confirmed: parent $5→fork $15 -crosses at 33%; parent $15→fork $25 crosses at 60%. **Recommended default: suggest inherit at the -price-ratio and let the Creator tune it; flag anything above as "may discourage forking."** +**1. HISTORICAL DETERMINISTIC MODEL RESULT — the fresh-uplift threshold matches the hypothesis +`i* = p_parent / p_fork`.** The leaf (latest forker) keeps `(1 − inherit)` of net *regardless of +chain depth*. Under this old assumption, forking beats authoring only the fresh uplift solo while +`inherit < p_parent/p_fork`: parent $5→fork $15 crosses at 33%; parent $15→fork $25 crosses at 60%. +The resulting price-ratio recommendation applies only to that modeled outside option; it is not +observed behavior and is superseded for Education by Note 4. **2. The surprising finding — the dilution victim is the ORIGINAL creator, not the leaf.** With a *flat per-hop* inherit, the originator's share decays geometrically with depth: @@ -62,4 +63,54 @@ originator's share against depth, use **LAP (whole-ancestry absolute)** — the of *all* descendants regardless of depth. Trade-off: LAP caps total downstream royalty, LRP lets it compound per hop. Decision deferred to Phase 2; record as an ADR when committed. +**4. MODEL RESULT (2026-07-12) — the free re-author bypass supersedes Note 1 for Education only.** +Note 1 compared a declared Derivative with authoring only the fresh uplift. That historical A/B +outside option is not the Education choice surfaced by the premise review: a student-Creator can +re-author the whole $15 candidate using class knowledge, declare no lineage, and pay the school +nothing. This result supersedes Note 1's inherit recommendation for Education only; it does not +measure or revise Marketplace or Intra-org behavior. + +The deterministic baseline holds candidate price, demand, quality, hosting cost, and inference cost +at parity: school root $5, declared or re-authored candidate $15, protocol fee 2.5%, amortized +re-author cost per Invocation $0, and Creator-captured lineage option value `V=$0`. The public engine +API produces these integer-cent results: + +- The no-lineage re-authored root has ancestry `[]`. On one $15 Invocation the fee is $0.38, the + student-Creator receives the full $14.62 net, and the school has no breakdown entry or payout. +- On the economic grid (0, then every integer percentage through 100%), 0 bps ties and every + positive rate pays the school and makes free re-authoring strictly preferable. At 1%, the exact + ancestor payout is $0.15. **No economically meaningful school-paying inherit survives this + baseline.** +- A separate **local engine cent-rounding probe — not a protocol or economic threshold** finds that + nominal 1–3 bps tie only because the school's payout rounds to $0.00; 4 bps pays $0.01 and loses + when re-author cost is $0. The script also streams all 10,001 integer-bps cases to confirm every + school-paying rate loses, without retaining or promoting those sub-1% rows as economic results. + +This is deterministic settlement-engine arithmetic, not observed student choice, market demand, or +evolution behavior. + +**5. LIVE-EVOLUTION OPTION VALUE — HYPOTHESIS, NOT MEASURED.** A declared Derivative survives when: + +`ancestor payout <= net parity + amortized re-author cost per Invocation + Creator-captured lineage-only V` + +Under the parity baseline above, that reduces to `ancestor payout <= V`. Equality is a tie, not a +strict preference. Exact engine payouts therefore set these minimum Creator-captured values: + +| Inherit on $15 candidate | Ancestor payout | Minimum `V` not to lose | +|---:|---:|---:| +| 1% | $0.15 | $0.15 (tie) | +| 5% | $0.73 | $0.73 (tie) | +| 10% | $1.46 | $1.46 (tie) | +| 20% | $2.92 | $2.92 (tie) | +| 30% | $4.39 | $4.39 (tie) | + +The seeded TUI's $25 / 30% analog has a $24.37 post-fee net and a $7.31 ancestor payout, so it needs +at least $7.31 of Creator-captured `V` merely to tie. The current engine does not deliver living +updates. `V` counts only if exclusive lineage value is captured by the Creator through price, +demand, or avoided maintenance — not when updates merely create value for the Beneficiary. + +All `V` figures are modeled hypotheses. **Education remains deferred** unless exclusive living +updates, tool/data access, or support value is measured and made contractible, or the product uses +direct school→employer licensing instead of relying on student-declared lineage. + _No CONTEXT.md term change needed; this is an economic-policy finding, not a language one._ diff --git a/prototype/spike-fork-economics.mjs b/prototype/spike-fork-economics.mjs index 74a7203..a315fcb 100644 --- a/prototype/spike-fork-economics.mjs +++ b/prototype/spike-fork-economics.mjs @@ -1,49 +1,345 @@ // spike-fork-economics.mjs // // PRE-BUILD SPIKE (run: node prototype/spike-fork-economics.mjs) -// Question: what inherit-bps keeps forking worthwhile, and what happens to the -// ORIGINAL creator (the school) as derivative chains get deeper? -// Drives the design choice between Story's LRP (per-hop relative) and LAP -// (whole-ancestry absolute) royalty policies. See prototype/README.md NOTES. +// Questions: what inherit-bps keeps forking worthwhile; what happens to the +// ORIGINAL Creator as Derivative chains deepen; and does any school-paying +// inherit survive a free, no-lineage re-author alternative? + +import assert from 'node:assert/strict'; import * as E from './settlement-engine.mjs'; -const FEE = 250, base = 5, uplift = 10; - -function run(depth, inh) { - const s = E.createState(); - E.addParty(s, { id: 'w', name: 'W', role: 'Wielder', balance: 1e9 }); - for (let k = 0; k <= depth; k++) E.addParty(s, { id: 'c' + k, name: 'c' + k, role: 'Creator' }); - E.registerSkill(s, { id: 's0', name: 's0', creatorId: 'c0', price: base }); - let price = base; - for (let k = 1; k <= depth; k++) { price += uplift; E.forkSkill(s, { id: 's' + k, parentId: 's' + (k - 1), creatorId: 'c' + k, name: 's' + k, price, inheritBps: inh }); } - const r = E.invoke(s, 's' + depth, 'w'); - const got = {}; for (const b of r.breakdown) got[b.partyId] = (got[b.partyId] || 0) + b.amount; - return { price: r.price, net: r.net, got }; -} - -const pct = (x, n) => (x / n * 100).toFixed(1) + '%'; -const freshAlt = uplift * (1 - FEE / 10000); - -console.log('MODEL: root price $5; each fork adds +$10 uplift; flat inherit per hop; fee 2.5%.'); -console.log('Fresh alternative (author your $10 uplift solo) = $' + freshAlt.toFixed(2) + ' per call.\n'); - -console.log('=== A. DEPTH DILUTION of the ORIGINAL creator (root c0) ==='); -for (const inh of [2000, 3000]) { - console.log('\n inherit = ' + (inh / 100) + '% per hop:'); - for (const d of [1, 2, 3, 4]) { - const { price, net, got } = run(d, inh); - const leaf = got['c' + d] || 0, root = got['c0'] || 0; - console.log(' depth ' + d + ' ($' + price + '): leaf ' + pct(leaf, net) + ' | ORIGINAL ' + pct(root, net)); +const FEE_BPS = 250; +const ROOT_PRICE = 5; +const UPLIFT = 10; +const CANDIDATE_PRICE = ROOT_PRICE + UPLIFT; +const INITIAL_WIELDER_BALANCE = 1_000; +const BASELINE = Object.freeze({ + reauthorCostPerInvocationCents: 0, + creatorCapturedLineageValueCents: 0, +}); + +let invariantCount = 0; +function equal(actual, expected, message) { + invariantCount += 1; + assert.equal(actual, expected, message); +} +function deepEqual(actual, expected, message) { + invariantCount += 1; + assert.deepEqual(actual, expected, message); +} +function ok(value, message) { + invariantCount += 1; + assert.ok(value, message); +} + +const cents = (dollars) => Math.round(Number(dollars) * 100); +const money = (valueCents) => E.money(valueCents / 100); +const pct = (value, total) => `${(value / total * 100).toFixed(1)}%`; +const historicalFreshUpliftAlt = UPLIFT * (1 - FEE_BPS / 10_000); + +// Historical A/B model: the outside option values only the fresh $10 uplift. +function runHistorical(depth, inheritBps) { + const state = E.createState(); + E.setFee(state, FEE_BPS); + E.addParty(state, { id: 'w', name: 'W', role: 'Wielder', balance: 1e9 }); + for (let k = 0; k <= depth; k += 1) { + E.addParty(state, { id: `c${k}`, name: `c${k}`, role: 'Creator' }); + } + E.registerSkill(state, { id: 's0', name: 's0', creatorId: 'c0', price: ROOT_PRICE }); + let price = ROOT_PRICE; + for (let k = 1; k <= depth; k += 1) { + price += UPLIFT; + E.forkSkill(state, { + id: `s${k}`, + parentId: `s${k - 1}`, + creatorId: `c${k}`, + name: `s${k}`, + price, + inheritBps, + }); + } + const result = E.invoke(state, `s${depth}`, 'w'); + const got = {}; + for (const item of result.breakdown) { + got[item.partyId] = (got[item.partyId] || 0) + item.amount; + } + return { price: result.price, net: result.net, got }; +} + +function payoutCents(result, partyId) { + return result.breakdown + .filter((item) => item.partyId === partyId) + .reduce((sum, item) => sum + cents(item.amount), 0); +} + +function runEducationBranch({ kind, inheritBps = 0, candidatePrice = CANDIDATE_PRICE }) { + const state = E.createState(); + E.setFee(state, FEE_BPS); + E.addParty(state, { id: 'school', name: 'School', role: 'Creator' }); + E.addParty(state, { id: 'student', name: 'Student-Creator', role: 'Creator' }); + E.addParty(state, { + id: 'employer', + name: 'Employer', + role: 'Wielder/Beneficiary', + balance: INITIAL_WIELDER_BALANCE, + }); + E.registerSkill(state, { + id: 'school-base', + name: 'school-base', + creatorId: 'school', + price: ROOT_PRICE, + mode: 'education', + }); + + const skillId = kind === 'declared' ? 'declared-derivative' : 'reauthored-root'; + if (kind === 'declared') { + E.forkSkill(state, { + id: skillId, + parentId: 'school-base', + creatorId: 'student', + name: skillId, + price: candidatePrice, + inheritBps, + }); + } else if (kind === 'reauthored') { + E.registerSkill(state, { + id: skillId, + name: skillId, + creatorId: 'student', + price: candidatePrice, + mode: 'education', + }); + } else { + throw new Error(`unknown Education branch kind: ${kind}`); + } + + const result = E.invoke(state, skillId, 'employer'); + return { + ancestry: E.ancestry(state, skillId), + schoolBreakdownCount: result.breakdown.filter((item) => item.partyId === 'school').length, + feeBps: state.feeBps, + priceCents: cents(result.price), + feeCents: cents(result.fee), + netCents: cents(result.net), + schoolPayoutCents: payoutCents(result, 'school'), + creatorPayoutCents: payoutCents(result, 'student'), + schoolBalanceCents: cents(state.parties.school.balance), + creatorBalanceCents: cents(state.parties.student.balance), + wielderSpendCents: cents(INITIAL_WIELDER_BALANCE - state.parties.employer.balance), + treasuryCents: cents(state.treasury), + }; +} + +function classifyDeclaredChoice( + declared, + reauthored, + { + reauthorCostPerInvocationCents = BASELINE.reauthorCostPerInvocationCents, + creatorCapturedLineageValueCents = BASELINE.creatorCapturedLineageValueCents, + } = {}, +) { + const netParityCents = declared.netCents - reauthored.netCents; + const availableAdvantageCents = ( + netParityCents + + reauthorCostPerInvocationCents + + creatorCapturedLineageValueCents + ); + const classification = declared.schoolPayoutCents < availableAdvantageCents + ? 'declared-strictly-preferred' + : declared.schoolPayoutCents === availableAdvantageCents + ? 'tie' + : 'reauthor-strictly-preferred'; + return { netParityCents, classification }; +} + +const reauthored = runEducationBranch({ kind: 'reauthored' }); +deepEqual(reauthored.ancestry, [], 're-authored Skill is a no-lineage root'); +equal(reauthored.schoolBreakdownCount, 0, 'school is absent from re-author breakdown'); +equal(reauthored.schoolPayoutCents, 0, 'school receives no re-author payout'); +equal(reauthored.schoolBalanceCents, 0, 'school balance does not change on re-author Invocation'); +equal(reauthored.feeBps, FEE_BPS, 're-author branch explicitly sets the 2.5% fee'); +equal(reauthored.priceCents, 1_500, 're-author candidate price is $15'); +equal(reauthored.feeCents, 38, '2.5% fee rounds to $0.38 at $15'); +equal(reauthored.netCents, 1_462, 'post-fee net is $14.62 at $15'); +equal(reauthored.creatorPayoutCents, 1_462, 'student-Creator receives full re-author net'); +equal(reauthored.creatorBalanceCents, 1_462, 'student-Creator balance matches payout'); +equal(reauthored.wielderSpendCents, 1_500, 'Wielder spends the exact Invocation price'); +equal(reauthored.treasuryCents, 38, 'treasury receives the exact fee'); +equal(reauthored.feeCents + reauthored.netCents, reauthored.priceCents, 're-author fee plus net conserves price'); +equal(reauthored.creatorPayoutCents, reauthored.netCents, 're-author payout conserves net'); + +// Stream every integer bps to answer the literal "any positive rate" question, +// but retain only the 1%-grid economics and a separate local rounding probe. +const economicRates = new Set([0, ...Array.from({ length: 100 }, (_, index) => (index + 1) * 100)]); +const roundingProbeRates = new Set([1, 2, 3, 4]); +const economicRows = new Map(); +const roundingRows = new Map(); +let integerRatesChecked = 0; +let positivePayingRates = 0; +let allBranchShapesMatch = true; +let allAccountingConserves = true; +let allPositivePayingRatesLose = true; + +for (let inheritBps = 0; inheritBps <= 10_000; inheritBps += 1) { + const declared = runEducationBranch({ kind: 'declared', inheritBps }); + const comparison = classifyDeclaredChoice(declared, reauthored); + const row = { inheritBps, ...declared, ...comparison }; + integerRatesChecked += 1; + if (declared.schoolPayoutCents > 0) positivePayingRates += 1; + allBranchShapesMatch &&= ( + declared.ancestry.length === 1 + && declared.ancestry[0] === 'school-base' + && declared.feeBps === FEE_BPS + && declared.priceCents === reauthored.priceCents + && declared.netCents === reauthored.netCents + && comparison.netParityCents === 0 + ); + allAccountingConserves &&= ( + declared.feeCents + declared.netCents === declared.priceCents + && declared.creatorPayoutCents + declared.schoolPayoutCents === declared.netCents + && declared.creatorBalanceCents === declared.creatorPayoutCents + && declared.schoolBalanceCents === declared.schoolPayoutCents + && declared.wielderSpendCents === declared.priceCents + && declared.treasuryCents === declared.feeCents + ); + if (declared.schoolPayoutCents > 0) { + allPositivePayingRatesLose &&= comparison.classification === 'reauthor-strictly-preferred'; + } + if (economicRates.has(inheritBps)) economicRows.set(inheritBps, row); + if (roundingProbeRates.has(inheritBps)) roundingRows.set(inheritBps, row); +} + +equal(integerRatesChecked, 10_001, 'stream checks every integer rate from 0 through 10000 bps'); +equal(positivePayingRates, 9_997, 'integer stream finds school payouts from 4 through 10000 bps'); +ok(allBranchShapesMatch, 'all streamed declared branches hold fee, price, net, and ancestry at parity'); +ok(allAccountingConserves, 'all streamed branches conserve integer-cent fees and payouts'); +ok(allPositivePayingRatesLose, 'every streamed school-paying rate loses at V=$0'); +equal(economicRows.size, 101, 'economic grid contains 0 plus every integer percentage'); + +const zeroBps = economicRows.get(0); +equal(zeroBps.schoolPayoutCents, 0, '0 bps sends the school $0'); +equal(zeroBps.classification, 'tie', '0 bps ties the free re-author branch'); +const positiveEconomicRows = [...economicRows.values()].filter((row) => row.inheritBps > 0); +ok(positiveEconomicRows.every((row) => row.schoolPayoutCents > 0), 'every 1%-grid positive rate pays the school'); +ok( + positiveEconomicRows.every((row) => row.classification === 'reauthor-strictly-preferred'), + 'every 1%-grid positive rate loses at V=$0', +); + +for (const inheritBps of [1, 2, 3]) { + const row = roundingRows.get(inheritBps); + equal(row.schoolPayoutCents, 0, `${inheritBps} bps local probe rounds school payout to $0`); + equal(row.classification, 'tie', `${inheritBps} bps local probe ties only because payout rounds to $0`); + equal(row.schoolBreakdownCount, 1, `${inheritBps} bps local probe traverses lineage`); +} +const fourBps = roundingRows.get(4); +equal(fourBps.schoolPayoutCents, 1, '4 bps local probe is the first $0.01 school payout'); +equal(fourBps.classification, 'reauthor-strictly-preferred', '4 bps loses when re-author cost is $0'); +equal( + classifyDeclaredChoice(fourBps, reauthored, { reauthorCostPerInvocationCents: 1 }).classification, + 'tie', + '4 bps ties when re-authoring costs $0.01 per Invocation', +); +equal( + classifyDeclaredChoice(fourBps, reauthored, { reauthorCostPerInvocationCents: 2 }).classification, + 'declared-strictly-preferred', + '4 bps strictly wins when re-authoring costs $0.02 per Invocation', +); + +const optionThresholds = [ + { inheritBps: 100, expectedCents: 15 }, + { inheritBps: 500, expectedCents: 73 }, + { inheritBps: 1_000, expectedCents: 146 }, + { inheritBps: 2_000, expectedCents: 292 }, + { inheritBps: 3_000, expectedCents: 439 }, +].map(({ inheritBps, expectedCents }) => { + const row = economicRows.get(inheritBps); + equal(row.schoolPayoutCents, expectedCents, `${inheritBps} bps exact ancestor payout`); + equal( + classifyDeclaredChoice(row, reauthored, { + creatorCapturedLineageValueCents: expectedCents, + }).classification, + 'tie', + `${inheritBps} bps ties when Creator-captured V equals ancestor payout`, + ); + equal( + classifyDeclaredChoice(row, reauthored, { + creatorCapturedLineageValueCents: expectedCents - 1, + }).classification, + 'reauthor-strictly-preferred', + `${inheritBps} bps loses when Creator-captured V is one cent short`, + ); + equal( + classifyDeclaredChoice(row, reauthored, { + creatorCapturedLineageValueCents: expectedCents + 1, + }).classification, + 'declared-strictly-preferred', + `${inheritBps} bps wins when Creator-captured V is one cent above payout`, + ); + return { inheritBps, requiredValueCents: expectedCents }; +}); + +const seededTuiDeclared = runEducationBranch({ kind: 'declared', inheritBps: 3_000, candidatePrice: 25 }); +const seededTuiReauthored = runEducationBranch({ kind: 'reauthored', candidatePrice: 25 }); +equal(seededTuiDeclared.feeCents, 63, 'seeded TUI analog fee is $0.63'); +equal(seededTuiDeclared.netCents, 2_437, 'seeded TUI analog net is $24.37'); +equal(seededTuiDeclared.schoolPayoutCents, 731, 'seeded TUI 30% ancestor payout is $7.31'); +equal(seededTuiDeclared.creatorPayoutCents, 1_706, 'seeded TUI Creator payout is $17.06'); +equal(seededTuiDeclared.feeCents + seededTuiDeclared.netCents, 2_500, 'seeded TUI fee plus net conserves price'); +equal(seededTuiDeclared.schoolPayoutCents + seededTuiDeclared.creatorPayoutCents, seededTuiDeclared.netCents, 'seeded TUI payouts conserve net'); +equal( + classifyDeclaredChoice(seededTuiDeclared, seededTuiReauthored, { + creatorCapturedLineageValueCents: 731, + }).classification, + 'tie', + 'seeded TUI analog needs $7.31 of Creator-captured V to tie', +); + +console.log('MODEL A/B (historical): root price $5; each fork adds +$10 uplift; flat inherit per hop; fee 2.5%.'); +console.log(`Historical fresh-uplift outside option (author only the $10 uplift) = $${historicalFreshUpliftAlt.toFixed(2)} per call.`); +console.log('This A/B outside option is not the Education free re-author bypass.\n'); + +console.log('=== A. DEPTH DILUTION of the ORIGINAL Creator (root c0) ==='); +for (const inheritBps of [2_000, 3_000]) { + console.log(`\n inherit = ${inheritBps / 100}% per hop:`); + for (const depth of [1, 2, 3, 4]) { + const { price, net, got } = runHistorical(depth, inheritBps); + console.log(` depth ${depth} ($${price}): leaf ${pct(got[`c${depth}`] || 0, net)} | ORIGINAL ${pct(got.c0 || 0, net)}`); } } -console.log('\n=== B. FORK-KILLING THRESHOLD for the leaf (hypothesis i* = p_parent/p_fork) ==='); -for (const d of [1, 2]) { - const parentPrice = base + (d - 1) * uplift, leafPrice = base + d * uplift; - console.log('\n depth ' + d + ' (parent $' + parentPrice + ' -> leaf $' + leafPrice + '): i* = ' + ((parentPrice / leafPrice) * 100).toFixed(1) + '%'); - for (const inh of [0, 3000, 4000, 5000, 6000, 7000]) { - const { got } = run(d, inh); const leaf = got['c' + d] || 0; - console.log(' ' + (inh / 100) + '% -> leaf keeps $' + leaf.toFixed(2) + (leaf >= freshAlt ? ' (beats solo)' : ' NO')); +console.log('\n=== B. HISTORICAL FRESH-UPLIFT THRESHOLD (NOT THE EDUCATION BYPASS) ==='); +for (const depth of [1, 2]) { + const parentPrice = ROOT_PRICE + (depth - 1) * UPLIFT; + const leafPrice = ROOT_PRICE + depth * UPLIFT; + console.log(`\n depth ${depth} (parent $${parentPrice} -> leaf $${leafPrice}): hypothesis i* = ${((parentPrice / leafPrice) * 100).toFixed(1)}%`); + for (const inheritBps of [0, 3_000, 4_000, 5_000, 6_000, 7_000]) { + const leaf = runHistorical(depth, inheritBps).got[`c${depth}`] || 0; + const verdict = cents(leaf) > cents(historicalFreshUpliftAlt) + ? 'strictly beats historical solo uplift' + : cents(leaf) === cents(historicalFreshUpliftAlt) + ? 'ties historical solo uplift' + : 'loses to historical solo uplift'; + console.log(` ${inheritBps / 100}% -> leaf keeps $${leaf.toFixed(2)} (${verdict})`); } } + +console.log('\n=== C. FREE RE-AUTHOR BYPASS — DETERMINISTIC MODEL RESULT ==='); +console.log('Baseline: same $15 candidate price, demand, quality, hosting cost, and inference cost; amortized re-author cost $0; Creator-captured lineage V=$0.'); +console.log(`No-lineage re-authored root: ancestry []; fee ${money(reauthored.feeCents)}; student-Creator ${money(reauthored.creatorPayoutCents)}; school ${money(reauthored.schoolPayoutCents)} with no breakdown entry.`); +console.log(`Economic grid (0, then 1%..100%): 0 bps is a tie; every positive rate pays the school and makes re-authoring strictly preferable at V=$0. The 1% point pays ${money(economicRows.get(100).schoolPayoutCents)}.`); +console.log('No economically meaningful school-paying inherit survives this baseline.'); +console.log('LOCAL ENGINE CENT-ROUNDING PROBE — NOT A PROTOCOL OR ECONOMIC THRESHOLD: 1–3 bps tie because the payout rounds to $0.00; 4 bps pays $0.01 and loses when re-author cost is $0.'); +console.log('A streamed 0..10000 bps check confirms every school-paying integer rate loses; the full rows are not retained.'); + +console.log('\n=== D. LIVE-EVOLUTION OPTION VALUE — HYPOTHESIS, NOT MEASURED ==='); +console.log('Survival equation: ancestor payout <= net parity + amortized re-author cost per Invocation + Creator-captured lineage-only V.'); +console.log('Under this parity baseline with zero re-author cost, it reduces to: ancestor payout <= V. Equality is a tie, not strict preference.'); +for (const row of optionThresholds) { + console.log(` ${row.inheritBps / 100}% at $15 -> exact ancestor payout ${money(row.requiredValueCents)} -> minimum Creator-captured V ${money(row.requiredValueCents)} (tie)`); +} +console.log(` Seeded TUI analog: 30% at $25 -> net ${money(seededTuiDeclared.netCents)} -> ancestor payout / minimum V ${money(seededTuiDeclared.schoolPayoutCents)} (tie).`); +console.log('The current engine does not deliver living updates. V counts only when the Creator captures exclusive lineage value through price, demand, or avoided maintenance—not value enjoyed only by the Beneficiary.'); +console.log('\nThese are deterministic engine arithmetic results, not observed student, market, or evolution behavior.'); +console.log(`PASS — ${invariantCount} invariants green.`); From 45797ae5a4b4f710fab287e6ab252facb3d598ec Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 12 Jul 2026 14:16:21 -0400 Subject: [PATCH 011/165] Fix CMA latency measurement semantics --- prototype/spike-cma-latency.mjs | 121 +++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 41 deletions(-) diff --git a/prototype/spike-cma-latency.mjs b/prototype/spike-cma-latency.mjs index 276b0d5..6ff3f52 100644 --- a/prototype/spike-cma-latency.mjs +++ b/prototype/spike-cma-latency.mjs @@ -35,7 +35,7 @@ // you surface as 'working...' immediately. // * Re-create the agent with a different model/effort to compare configs; // effort lives on the model/agent config, low + no-thinking is the floor, -// high/max adaptive is the worst case (tens of seconds to first answer token). +// and adaptive-thinking configurations must be measured rather than assumed. import Anthropic from "@anthropic-ai/sdk"; @@ -45,13 +45,24 @@ const args = Object.fromEntries( return [k, v === undefined ? true : v]; }), ); -const TRIALS = parseInt(args.trials ?? "10", 10); +const trialsArg = args.trials ?? "10"; +const TRIALS = typeof trialsArg === "string" && trialsArg.trim() !== "" + ? Number(trialsArg) + : NaN; const MODEL = args.model ?? "claude-opus-4-8"; const COLD_ONLY = !!args["cold-only"]; const WARM_ONLY = !!args["warm-only"]; const REUSE_AGENT = args["reuse-agent"] ?? process.env.CMA_AGENT_ID ?? null; const REUSE_ENV = args["reuse-env"] ?? process.env.CMA_ENV_ID ?? null; +if (!Number.isInteger(TRIALS) || TRIALS <= 0) { + console.error("ERROR: --trials must be a positive integer."); + process.exit(1); +} +if (COLD_ONLY && WARM_ONLY) { + console.error("ERROR: --cold-only and --warm-only are mutually exclusive."); + process.exit(1); +} if (!process.env.ANTHROPIC_API_KEY) { console.error("ERROR: set ANTHROPIC_API_KEY in your environment."); process.exit(1); @@ -74,6 +85,22 @@ function report(label, samples) { ); } +function requireSampleCount(label, samplesByMetric) { + for (const [metric, samples] of Object.entries(samplesByMetric)) { + if (samples.length !== TRIALS) { + throw new Error(`${label} ${metric} has ${samples.length}/${TRIALS} required samples`); + } + } +} + +function requireFiniteTurnMarks(marks, cold) { + const required = ["tStreamSetup", "tSendFirstEvent", "tSendFirstAnswerToken", "tSendIdle"]; + if (cold) required.push("tSessionCreated", "tStreamOpen", "tFirstEvent", "tFirstAnswerToken", "tIdle"); + for (const name of required) { + if (!Number.isFinite(marks[name])) throw new Error(`turn completed without finite ${name}`); + } +} + async function ensureAgentAndEnv() { let agentId = REUSE_AGENT, envId = REUSE_ENV; if (!envId) { @@ -97,37 +124,51 @@ async function ensureAgentAndEnv() { async function runTurn({ agentId, envId, existingSessionId }) { const marks = {}; - const t0 = ms(); marks.t0 = t0; + const coldStarted = existingSessionId ? null : ms(); let sessionId = existingSessionId; if (!sessionId) { const session = await client.beta.sessions.create({ agent: { type: "agent", id: agentId }, environment_id: envId, }); - sessionId = session.id; marks.tSessionCreated = ms() - t0; + sessionId = session.id; marks.tSessionCreated = ms() - coldStarted; } + const streamSetupStarted = ms(); const stream = await client.beta.sessions.events.stream(sessionId); - marks.tStreamOpen = ms() - t0; + marks.tStreamSetup = ms() - streamSetupStarted; + if (coldStarted !== null) marks.tStreamOpen = ms() - coldStarted; + const sendStarted = ms(); const sendP = client.beta.sessions.events.send(sessionId, { events: [{ type: "user.message", content: [{ type: "text", text: PROMPT }] }], }); - let gotFirstEvent = false, gotAnswer = false; - for await (const event of stream) { - if (!gotFirstEvent && event.type !== "user.message" && event.type !== "user.custom_tool_result") { - marks.tFirstEvent = ms() - t0; gotFirstEvent = true; - } - if (!gotAnswer && event.type === "agent.message") { - for (const block of event.content ?? []) { - if (block.type === "text" && block.text?.length > 0) { - marks.tFirstAnswerToken = ms() - t0; gotAnswer = true; break; + const eventsP = (async () => { + let gotFirstEvent = false, gotAnswer = false; + for await (const event of stream) { + const eventAt = ms(); + if (!gotFirstEvent && event.type !== "user.message" && event.type !== "user.custom_tool_result") { + marks.tSendFirstEvent = eventAt - sendStarted; + if (coldStarted !== null) marks.tFirstEvent = eventAt - coldStarted; + gotFirstEvent = true; + } + if (!gotAnswer && event.type === "agent.message") { + for (const block of event.content ?? []) { + if (block.type === "text" && block.text?.length > 0) { + marks.tSendFirstAnswerToken = eventAt - sendStarted; + if (coldStarted !== null) marks.tFirstAnswerToken = eventAt - coldStarted; + gotAnswer = true; + break; + } } } + if (event.type === "session.status_terminated") break; + if (event.type === "session.status_idle" && event.stop_reason?.type !== "requires_action") { + marks.tSendIdle = eventAt - sendStarted; + if (coldStarted !== null) marks.tIdle = eventAt - coldStarted; + break; + } } - if (event.type === "session.status_terminated") break; - if (event.type === "session.status_idle" && event.stop_reason?.type !== "requires_action") { - marks.tIdle = ms() - t0; break; - } - } - await sendP.catch(() => {}); + })(); + await Promise.all([sendP, eventsP]); + requireFiniteTurnMarks(marks, coldStarted !== null); return { sessionId, marks }; } @@ -139,19 +180,18 @@ async function main() { if (!WARM_ONLY) { console.log(`\n=== COLD (sessions.create on hot path) ===`); for (let i = 0; i < TRIALS; i++) { - try { - const { sessionId, marks } = await runTurn({ agentId, envId }); - cold.sessionCreate.push(marks.tSessionCreated); - cold.streamOpen.push(marks.tStreamOpen); - cold.firstEvent.push(marks.tFirstEvent); - cold.firstAnswer.push(marks.tFirstAnswerToken); - cold.total.push(marks.tIdle); - sessionsForWarm.push(sessionId); - process.stdout.write(` trial ${i+1}/${TRIALS}: create=${marks.tSessionCreated}ms firstEvent=${marks.tFirstEvent}ms firstAnswer=${marks.tFirstAnswerToken}ms\n`); - } catch (e) { console.error(` trial ${i+1} failed:`, e?.message ?? e); } + const { sessionId, marks } = await runTurn({ agentId, envId }); + cold.sessionCreate.push(marks.tSessionCreated); + cold.streamOpen.push(marks.tStreamOpen); + cold.firstEvent.push(marks.tFirstEvent); + cold.firstAnswer.push(marks.tFirstAnswerToken); + cold.total.push(marks.tIdle); + sessionsForWarm.push(sessionId); + process.stdout.write(` trial ${i+1}/${TRIALS}: create=${marks.tSessionCreated}ms firstEvent(cumulative)=${marks.tFirstEvent}ms firstAnswer(cumulative)=${marks.tFirstAnswerToken}ms\n`); } + requireSampleCount("COLD", cold); } - const warm = { streamOpen: [], firstEvent: [], firstAnswer: [], total: [] }; + const warm = { streamSetup: [], firstEvent: [], firstAnswer: [], total: [] }; if (!COLD_ONLY) { console.log(`\n=== WARM (reuse existing session, no sessions.create) ===`); let pool = sessionsForWarm; @@ -163,15 +203,14 @@ async function main() { } for (let i = 0; i < TRIALS; i++) { const sessionId = pool[i % pool.length]; - try { - const { marks } = await runTurn({ agentId, envId, existingSessionId: sessionId }); - warm.streamOpen.push(marks.tStreamOpen); - warm.firstEvent.push(marks.tFirstEvent); - warm.firstAnswer.push(marks.tFirstAnswerToken); - warm.total.push(marks.tIdle); - process.stdout.write(` trial ${i+1}/${TRIALS}: firstEvent=${marks.tFirstEvent}ms firstAnswer=${marks.tFirstAnswerToken}ms\n`); - } catch (e) { console.error(` trial ${i+1} failed:`, e?.message ?? e); } + const { marks } = await runTurn({ agentId, envId, existingSessionId: sessionId }); + warm.streamSetup.push(marks.tStreamSetup); + warm.firstEvent.push(marks.tSendFirstEvent); + warm.firstAnswer.push(marks.tSendFirstAnswerToken); + warm.total.push(marks.tSendIdle); + process.stdout.write(` trial ${i+1}/${TRIALS}: streamSetup(pre-send)=${marks.tStreamSetup}ms firstEvent(from-send)=${marks.tSendFirstEvent}ms firstAnswer(from-send)=${marks.tSendFirstAnswerToken}ms\n`); } + requireSampleCount("WARM", warm); } console.log(`\n================ SUMMARY (p50 / p95) ================`); if (!WARM_ONLY) { @@ -184,11 +223,11 @@ async function main() { } if (!COLD_ONLY) { console.log("WARM path (reused session):"); - report("send -> stream open", warm.streamOpen); + report("stream setup (pre-send)", warm.streamSetup); report("send -> first event", warm.firstEvent); report("send -> first ANSWER token", warm.firstAnswer); report("send -> idle/end_turn", warm.total); } - console.log(`\nInterpretation: for an interactive Wielder gate the perceptible numbers are 'first event' (render 'working…' immediately) and 'first answer token'. effort=low + minimal agent => single-digit seconds; high/max + adaptive thinking => first-answer-token can be tens of seconds because thinking precedes the answer. Archive/delete the bench agent+env afterward (archiving an agent is PERMANENT); delete sessions with client.beta.sessions.delete(id).`); + console.log(`\nInterpretation: use only the observed sample distributions. For an interactive Wielder gate, 'first event' is when the UI can render 'working…', while 'first answer token' is when visible answer content begins. No latency range is assumed; compare measured model/effort configurations directly. Archive/delete the bench agent+env afterward (archiving an agent is PERMANENT); delete sessions with client.beta.sessions.delete(id).`); } main().catch((e) => { console.error(e); process.exit(1); }); From 7b225b50b82e52442596a767f1a9f8e51e1e4eba Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 12 Jul 2026 14:16:37 -0400 Subject: [PATCH 012/165] Verify metadata before resuming Phase 0 --- phase0/src/demo.ts | 28 ++++++++++++++++++++--- phase0/tests/demo.test.ts | 47 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/phase0/src/demo.ts b/phase0/src/demo.ts index 222bffd..c17ac30 100644 --- a/phase0/src/demo.ts +++ b/phase0/src/demo.ts @@ -124,6 +124,17 @@ function ensureResumableManifest(manifest: RegistrationManifest, wallet: `0x${st } } +function metadataProofMatches(stored: MetadataProof, current: MetadataProof): boolean { + return ( + stored.ip.uri === current.ip.uri + && stored.ip.hash === current.ip.hash + && stored.nft.uri === current.nft.uri + && stored.nft.hash === current.nft.hash + && stored.artifact.mediaHash === current.artifact.mediaHash + && stored.artifact.mediaType === current.artifact.mediaType + ); +} + function proof(input: { definition: DemoSkillDefinition; result: { ipId: `0x${string}`; tokenId: bigint; txHash: `0x${string}` }; @@ -167,9 +178,20 @@ export async function runDemo(input: RunDemoInput): Promise(); for (const stage of ["root", "child", "grandchild"] as const) { - if (!manifest.registrations[stage]) { - const definition = definitionFor(skills, stage); - metadata.set(stage, await input.metadata.prepare({ ...definition, creatorAddress: input.wallet })); + const definition = definitionFor(skills, stage); + metadata.set(stage, await input.metadata.prepare({ ...definition, creatorAddress: input.wallet })); + } + + for (const stage of ["root", "child", "grandchild"] as const) { + const stored = manifest.registrations[stage]; + if (stored) { + const current = metadata.get(stage); + if (!current) throw new Error(`${stage} metadata was not prepared`); + if (!metadataProofMatches(stored.metadata, current.proof)) { + throw new Error( + `${stage} metadata proof drift detected: the current artifact or metadata no longer matches registrations.json. Restore the recorded inputs or start a separate proof artifact before resuming.`, + ); + } } } diff --git a/phase0/tests/demo.test.ts b/phase0/tests/demo.test.ts index 60caa87..9764689 100644 --- a/phase0/tests/demo.test.ts +++ b/phase0/tests/demo.test.ts @@ -237,13 +237,58 @@ test("a confirmed partial proof survives failure and rerun resumes only missing const result = await runDemo({ wallet: WALLET, chain: resumedChain, metadata: resumedMetadata, store }); assert.deepEqual(resumedChain.writes, ["child", "grandchild"]); - assert.deepEqual(resumedMetadata.stages, ["child", "grandchild"]); + assert.deepEqual(resumedMetadata.stages, ["root", "child", "grandchild"]); assert.equal(result.status, "complete"); assert.equal(result.registrations.root?.txHash, "0xroot"); assert.equal(result.registrations.child?.txHash, "0xchild"); assert.equal(result.registrations.grandchild?.txHash, "0xgrandchild"); }); +test("resume rejects drift in a persisted root metadata proof before writes or saves", async () => { + const store = new MemoryStore(); + const firstChain = new FakeChain(); + firstChain.failOn = "child"; + + await assert.rejects( + runDemo({ wallet: WALLET, chain: firstChain, metadata: new FakeMetadata(), store }), + /child failed/, + ); + + const savesBeforeResume = store.saveCalls; + const persistedRootTxHash = store.manifest.registrations.root?.txHash; + const currentMetadata = new FakeMetadata(); + const driftedRootHash = `0x${"f".repeat(64)}` as const; + const metadata: DemoMetadataProvider = { + prepare: async (input) => { + const prepared = await currentMetadata.prepare(input); + if (input.stage !== "root") return prepared; + return { + ...prepared, + proof: { + ...prepared.proof, + artifact: { + ...prepared.proof.artifact, + mediaHash: driftedRootHash, + }, + }, + }; + }, + }; + const resumedChain = new FakeChain(); + + await assert.rejects( + runDemo({ wallet: WALLET, chain: resumedChain, metadata, store }), + /root.*metadata.*drift/i, + ); + + assert.deepEqual(currentMetadata.stages, ["root", "child", "grandchild"]); + assert.deepEqual(resumedChain.writes, []); + assert.equal(store.saveCalls, savesBeforeResume); + assert.equal(store.manifest.registrations.root?.txHash, persistedRootTxHash); + assert.equal(store.manifest.registrations.child, null); + assert.equal(store.manifest.registrations.grandchild, null); +}); + for (const scenario of [ { failOn: "root" as const, saves: 1, lastProof: "collection" }, { failOn: "grandchild" as const, saves: 3, lastProof: "child" }, From a462ade9d056efacbf0f856b5245ce33bc4c49bf Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 12 Jul 2026 15:01:44 -0400 Subject: [PATCH 013/165] Phase A: live measurements for KC2/KC4 + review findings First real numbers for two kill-criteria: CMA cold-start 2.5s p50 to first answer token (KC2 does not fire); live 6-pair distillation FAILED all critical fidelity gates while costing $1.58 with 8-invocation break-even (KC4: economics protect nothing, fidelity is the moat; high-N unknown). Education-mode deferral confirmed by the free re-author model. Codex review: zero must-fix across six commits. Harness fixes en route to the measurement: raw distillation output persisted pre-validation, SKILL.md format stated in the distill prompt, whole-response-only fence unwrapping, any-heading validator. prototype gains a package.json for its SDK dependency. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-07-12-phase-a-findings.md | 48 +++++++++++++ prototype/README.md | 20 ++++++ prototype/package-lock.json | 87 +++++++++++++++++++++++ prototype/package.json | 16 +++++ spikes/clone-economics/README.md | 34 +++++++++ spikes/clone-economics/package-lock.json | 12 ++++ spikes/clone-economics/src/experiment.mjs | 29 +++++++- 7 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-07-12-phase-a-findings.md create mode 100644 prototype/package-lock.json create mode 100644 prototype/package.json create mode 100644 spikes/clone-economics/package-lock.json diff --git a/docs/plans/2026-07-12-phase-a-findings.md b/docs/plans/2026-07-12-phase-a-findings.md new file mode 100644 index 0000000..f519ad2 --- /dev/null +++ b/docs/plans/2026-07-12-phase-a-findings.md @@ -0,0 +1,48 @@ +# Phase A — verification findings & measurements (2026-07-12) + +*Consolidates: adversarial review of Codex's six handoff commits, plus the +first live measurements for PRD kill-criteria 2 and 4. Detailed numbers live +in `prototype/README.md` and `spikes/clone-economics/README.md`; the fork +verdict in `prototype/README.md` NOTES (run `spike-fork-economics.mjs`).* + +## Verdicts against the PRD kill-criteria + +| KC | Question | Status after Phase A | +|---|---|---| +| 2 | Cold-start latency makes pay-then-run unusable? | **Does not fire** (first bound): cold ~2.5s to first answer token, warm ~1.5s (n=3, Sonnet). Composes with the measured ~0.8s testnet x402 gate into ~3.3s pay→output. | +| 4 | Breakout skill cloned with no economic counter? | **Split result** (N=6 only): fidelity FAILED for the clone (all critical gates), but the attack costs ~$1.58 with 8-invocation break-even — economics provide zero protection; fidelity difficulty is the only observed moat. Synthetic evolution overlay doubled the target–clone gap in one revision. High-N behavior unknown; do NOT cite as resolved. | +| — | Education mode (ADR-0007 deferral) | **Confirmed dead as designed**: with free re-authoring, every inherit rate > 0 is strictly dominated. Survival requires school-captured living value ≥ ancestor payout (measured minimum V per rate in the spike output). | + +## Codex review outcome + +Zero must-fix findings; all hard handoff constraints met (corpus untouched, +no secrets, pi-wielder untouched). Should-fix follow-ups, none blocking: + +1. `phase0`: `IP_METADATA_URI`/`NFT_METADATA_URI` env overrides brick + `npm run demo` — ignore or reject them in demo(). +2. `phase0`: unfunded gate is `balance === 0n`; dust-funded wallets die + mid-run with a raw revert — gate on an estimated minimum instead. +3. `phase0`: metadata URIs depend on httpbin.org durability — self-certifying + by construction, but pin real content before mainnet-grade evidence. +4. `clone-economics`: small-N/small-H limitation now documented in README + (done 2026-07-12); larger fixture sets needed before citing against KC4. +5. `cma-latency`: warm path assumes the events stream tails (not replays); + document/guard — live n=3 data showed no replay artifacts. + +Nits recorded in the review transcript (workflow `wf_ff6dac84-673`): epoch-zero +`createdAt` in provenance metadata, at-least-once crash window between confirm +and save, MemoryStore fake not exercising the manifest validator. + +## Harness fixes applied during measurement (committed with this doc) + +`spikes/clone-economics/src/experiment.mjs`: raw distillation output persisted +before validation; distillation prompt now states the public SKILL.md format; +extractor unwraps only whole-response fences; validator accepts any heading +level. Four failed runs before one green one — each failure documented in the +spike README. + +## What Phase A leaves open + +- High-N clone fidelity saturation (the real KC4 question). +- Aeneid write path — built, tested, waiting on wallet funding. +- Design-partner LOI (KC1) — the binding constraint on everything else. diff --git a/prototype/README.md b/prototype/README.md index efa91e1..f36b36c 100644 --- a/prototype/README.md +++ b/prototype/README.md @@ -114,3 +114,23 @@ updates, tool/data access, or support value is measured and made contractible, o direct school→employer licensing instead of relying on student-declared lineage. _No CONTEXT.md term change needed; this is an economic-policy finding, not a language one._ + +## MEASURED — CMA latency (claude-sonnet-4-6, trials=3, 2026-07-12) + +First live run of `spike-cma-latency.mjs` (managed-agents beta): + +- COLD (sessions.create on hot path): create p50 431ms; first event p50 + 1139ms; **first answer token p50 2541ms** (min 1930 / max 2541); end_turn + p50 2739ms. +- WARM (session reuse): stream setup p50 170ms; **send→first answer p50 + 1534ms** (one 7730ms outlier of three trials — needs more samples). + +Kill-criterion-2 reading: pay-then-run-async is comfortably usable — ~2.5s +cold to visible output on top of a ~0.8s testnet x402 gate (see +`spikes/pi-wielder/README.md`). n=3, one model, no effort sweep; treat as a +first bound, not a distribution. Reviewer caveat: the warm path assumes the +events stream tails rather than replays history; no near-zero samples +appeared (consistent with tailing), but the assumption is undocumented in +the API. Housekeeping: the bench created env_01ABHUjx5niRAtaqcnMUtA4c / +agent_016wddwbPwmmajbuZPZ5jHwV on the operator's account — archive/delete +when convenient (archiving is permanent). diff --git a/prototype/package-lock.json b/prototype/package-lock.json new file mode 100644 index 0000000..83ea79b --- /dev/null +++ b/prototype/package-lock.json @@ -0,0 +1,87 @@ +{ + "name": "prototype", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "prototype", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@anthropic-ai/sdk": "^0.111.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.111.0.tgz", + "integrity": "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + } + } +} diff --git a/prototype/package.json b/prototype/package.json new file mode 100644 index 0000000..fd8a88a --- /dev/null +++ b/prototype/package.json @@ -0,0 +1,16 @@ +{ + "name": "prototype", + "version": "1.0.0", + "description": "> **Throwaway.** This exists to answer one question, then be deleted or absorbed. > `settlement-engine.mjs` is the keeper (pure logic); `settlement-tui.mjs` is the disposable shell.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@anthropic-ai/sdk": "^0.111.0" + } +} diff --git a/spikes/clone-economics/README.md b/spikes/clone-economics/README.md index 4d9ca54..a6b65c1 100644 --- a/spikes/clone-economics/README.md +++ b/spikes/clone-economics/README.md @@ -85,3 +85,37 @@ auditable—not observations about a live model or market: **HYPOTHESIS/EXTRAPOLATION** until repeated dated live runs exist. - This spike does **not** validate the corpus-wide `~30x` statement. That refers to matched-quality serving cost and remains unmeasured here. + +## Measured results — first live run (claude-sonnet-4-6, 2026-07-12) + +Full report: `runs/live/report.{md,json}` (gitignored run artifacts; headline +numbers reproduced here). Operator inputs: N=6, invocation price $0.25, +pricing $3/$15 per M (operator-supplied), $5 hard cap. + +**Fidelity: the clone FAILED.** All 6 held-out cases failed critical gates +(clone 0.250 absolute vs target 0.400; retention 0.625; deliberately-bad +control 0.167). A 6-pair distillation did not reproduce the skill's gated +behaviors. + +**Economics: cost is no defense.** Attacker build B=$1.58, of which +distillation itself was $0.034 (D/A=0.023); modeled acquisition dominates. +Break-even after **8 invocations** if a clone ever passes. The protection +observed here is fidelity difficulty, not economics. + +**Staleness overlay (synthetic):** updated target 0.500 vs frozen clone +0.250 — one revision doubled the gap; says nothing about calendar cadence. + +**Limitations (in addition to those above):** +- **Small-N/small-H:** N≤6 training pairs and 6 held-out cases cannot locate + where fidelity saturates with N. Kill-criterion 4 concerns high-volume + skills (hundreds of paid pairs); a live result at this scale must NOT be + read as answering it. Larger fixture sets are required first. +- It took five runs to get one measurement; four failed on output-format + handling, not capability: (1–2) the distillation prompt never specified + the SKILL.md format (model returned plain markdown) — fixed by stating the + public format in the prompt; (3) an over-eager fence extractor added during + fixing replaced a valid document with an embedded code block — fixed to + unwrap only whole-response fences; (4) the validator demanded an H1 heading + the skill format does not require — loosened to any heading level. Raw + distillation output is now dumped to `runs//distilled-raw.txt` BEFORE + validation so failed runs keep their evidence. diff --git a/spikes/clone-economics/package-lock.json b/spikes/clone-economics/package-lock.json new file mode 100644 index 0000000..385f3ed --- /dev/null +++ b/spikes/clone-economics/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "clone-economics-spike", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clone-economics-spike", + "version": "0.1.0" + } + } +} diff --git a/spikes/clone-economics/src/experiment.mjs b/spikes/clone-economics/src/experiment.mjs index 198f115..55a1d08 100644 --- a/spikes/clone-economics/src/experiment.mjs +++ b/spikes/clone-economics/src/experiment.mjs @@ -17,8 +17,24 @@ const sha256 = (value) => `sha256:${createHash('sha256').update(value).digest('h const normalizedInputHash = (value) => sha256(value.trim().replace(/\s+/g, ' ').toLowerCase()); const rounded = (value) => Number(value.toFixed(12)); +// Models routinely wrap file content in code fences or add a one-line preamble; +// extraction is part of distillation tooling, not a fidelity judgment. Validity +// is still asserted on the extracted artifact. +function extractSkillMd(raw) { + let text = raw.trim(); + // Unwrap ONLY if the entire response is one fenced block — a fence appearing + // inside the body (e.g. an example snippet) must never trigger extraction. + const whole = text.match(/^```[a-z]*\n([\s\S]*)\n```$/); + if (whole) text = whole[1].trim(); + const start = text.indexOf('---\nname:'); + if (start > 0) text = text.slice(start); + return text; +} + function assertValidClone(skillMd) { - if (!skillMd.startsWith('---\nname:') || !skillMd.includes('\n---\n') || !skillMd.includes('\n# ')) { + // Frontmatter is required by the skill format; a body heading of ANY level + // suffices (the format does not mandate an H1 — live models favor H2s). + if (!skillMd.startsWith('---\nname:') || !skillMd.includes('\n---\n') || !/\n#{1,6} /.test(skillMd)) { throw new Error('Distillation did not produce a valid SKILL.md'); } } @@ -120,11 +136,18 @@ export async function runExperiment(options = {}) { } const distillationPayload = { - instructions: 'Author one valid SKILL.md that reproduces the demonstrated input-to-output capability. Use only the supplied examples. Return SKILL.md only.', + // The SKILL.md format is public documentation; specifying it tests capability + // reproduction rather than format guessing (a real cloner knows the format). + instructions: 'Author one valid SKILL.md that reproduces the demonstrated input-to-output capability. Use only the supplied examples. Return the raw file content only — no code fences, no preamble — starting with YAML frontmatter exactly like:\n---\nname: \ndescription: \n---\nfollowed by the markdown body.', pairs: acquisitionPairs, }; const distilled = await adapter.invoke({ kind: 'distill', payload: distillationPayload }); - const cloneSkillMd = distilled.output; + // Persist the raw distillation output BEFORE validation so a failed live run + // leaves evidence instead of discarding paid model output. + const rawDumpDir = path.resolve(options.outputDir ?? path.join(spikeRoot, 'runs', mode)); + fs.mkdirSync(rawDumpDir, { recursive: true }); + fs.writeFileSync(path.join(rawDumpDir, 'distilled-raw.txt'), distilled.output ?? ''); + const cloneSkillMd = extractSkillMd(distilled.output); assertValidClone(cloneSkillMd); const targetOutputs = await collect(adapter, heldoutFixtures, 'target-heldout', (fixture) => ({ input: fixture.input, targetSkill: targetText, reference: referenceText, ...sharedExecutor })); From a123bab728fbe7c2f3a903a197b00faa57a28c65 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 12 Jul 2026 15:33:49 -0400 Subject: [PATCH 014/165] Pre-publication hygiene from open-source audit No secrets were ever committed (full-history scan, all refs). This applies the preventive fixes: .env.* variants and run artifacts gitignored, third-party skill installs ignored with an exception for our own skill, vendor doc snapshots (.archive) removed as redistribution-unsafe, absolute local paths relativized in findings.json, account-scoped bench IDs reworded out of the prototype README. Co-Authored-By: Claude Fable 5 --- .archive/claude-code-best-practices.md | 608 ----------------- .archive/prompting-best-practices.md | 904 ------------------------- .gitignore | 15 + docs/feasibility/findings.json | 4 +- prototype/README.md | 6 +- 5 files changed, 20 insertions(+), 1517 deletions(-) delete mode 100644 .archive/claude-code-best-practices.md delete mode 100644 .archive/prompting-best-practices.md diff --git a/.archive/claude-code-best-practices.md b/.archive/claude-code-best-practices.md deleted file mode 100644 index 8937278..0000000 --- a/.archive/claude-code-best-practices.md +++ /dev/null @@ -1,608 +0,0 @@ -> ## Documentation Index -> Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt -> Use this file to discover all available pages before exploring further. - -# Best practices for Claude Code - -> Tips and patterns for getting the most out of Claude Code, from configuring your environment to scaling across parallel sessions. - -Claude Code is an agentic coding environment. Unlike a chatbot that answers questions and waits, Claude Code can read your files, run commands, make changes, and autonomously work through problems while you watch, redirect, or step away entirely. - -This changes how you work. Instead of writing code yourself and asking Claude to review it, you describe what you want and Claude figures out how to build it. Claude explores, plans, and implements. - -But this autonomy still comes with a learning curve. Claude works within certain constraints you need to understand. - -This guide covers patterns that have proven effective across Anthropic's internal teams and for engineers using Claude Code across various codebases, languages, and environments. For how the agentic loop works under the hood, see [How Claude Code works](/en/how-claude-code-works). - -*** - -Most best practices are based on one constraint: Claude's context window fills up fast, and performance degrades as it fills. - -Claude's context window holds your entire conversation, including every message, every file Claude reads, and every command output. However, this can fill up fast. A single debugging session or codebase exploration might generate and consume tens of thousands of tokens. - -This matters since LLM performance degrades as context fills. When the context window is getting full, Claude may start "forgetting" earlier instructions or making more mistakes. The context window is the most important resource to manage. To see how a session fills up in practice, [watch an interactive walkthrough](/en/context-window) of what loads at startup and what each file read costs. Track context usage continuously with a [custom status line](/en/statusline), and see [Reduce token usage](/en/costs#reduce-token-usage) for strategies on reducing token usage. - -*** - -## Give Claude a way to verify its work - - - Give Claude a check it can run: tests, a build, a screenshot to compare. It's the difference between a session you watch and one you walk away from. - - -Claude stops when the work looks done. Without a check it can run, "looks done" is the only signal available, and you become the verification loop: every mistake waits for you to notice it. Give Claude something that produces a pass or fail, and the loop closes on its own. Claude does the work, runs the check, reads the result, and iterates until the check passes. - -The check is anything that returns a signal Claude can read in the conversation: a test suite, a build exit code, a linter, a script that diffs output against a fixture, or a [browser screenshot](/en/chrome) compared against a design. - -| Strategy | Before | After | -| ------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Provide verification criteria** | *"implement a function that validates email addresses"* | *"write a validateEmail function. example test cases: [user@example.com](mailto:user@example.com) is true, invalid is false, [user@.com](mailto:user@.com) is false. run the tests after implementing"* | -| **Verify UI changes visually** | *"make the dashboard look better"* | *"\[paste screenshot] implement this design. take a screenshot of the result and compare it to the original. list differences and fix them"* | -| **Address root causes, not symptoms** | *"the build is failing"* | *"the build fails with this error: \[paste error]. fix it and verify the build succeeds. address the root cause, don't suppress the error"* | - -Once the check exists, decide how hard it gates the stop: - -* **In one prompt**: ask Claude to run the check and iterate in the same message, as in the table above. -* **Across a session**: set the check as a [`/goal` condition](/en/goal). A separate evaluator re-checks it after every turn and Claude keeps working until it holds. -* **As a deterministic gate**: a [Stop hook](/en/hooks#stop) runs your check as a script and blocks the turn from ending until it passes. Claude Code overrides the hook and ends the turn after 8 consecutive blocks. -* **By a second opinion**: a [verification subagent](/en/sub-agents) or a [dynamic workflow](/en/workflows) that checks its own findings has a fresh model try to refute the result, so the agent doing the work isn't the one grading it. - -Each step trades setup for attention. The prompt version works on any task today. The `/goal` and Stop hook versions are what let an unattended run finish correctly without you. - -Have Claude show evidence rather than asserting success: the test output, the command it ran and what it returned, or a screenshot of the result. Reviewing evidence is faster than re-running the verification yourself, and it works for sessions you weren't watching. - -*** - -## Explore first, then plan, then code - - - Separate research and planning from implementation to avoid solving the wrong problem. - - -Letting Claude jump straight to coding can produce code that solves the wrong problem. Use [plan mode](/en/permission-modes#analyze-before-you-edit-with-plan-mode) to separate exploration from execution. - -The recommended workflow has four phases: - - - - Enter plan mode. Claude reads files and answers questions without making changes. - - ```txt claude (plan mode) theme={null} - read /src/auth and understand how we handle sessions and login. - also look at how we manage environment variables for secrets. - ``` - - - - Ask Claude to create a detailed implementation plan. - - ```txt claude (plan mode) theme={null} - I want to add Google OAuth. What files need to change? - What's the session flow? Create a plan. - ``` - - Press `Ctrl+G` to open the plan in your text editor for direct editing before Claude proceeds. - - - - Switch out of plan mode and let Claude code, verifying against its plan. - - ```txt claude (default mode) theme={null} - implement the OAuth flow from your plan. write tests for the - callback handler, run the test suite and fix any failures. - ``` - - - - Ask Claude to commit with a descriptive message and create a PR. - - ```txt claude (default mode) theme={null} - commit with a descriptive message and open a PR - ``` - - - - - Plan mode is useful, but also adds overhead. - - For tasks where the scope is clear and the fix is small (like fixing a typo, adding a log line, or renaming a variable) ask Claude to do it directly. - - Planning is most useful when you're uncertain about the approach, when the change modifies multiple files, or when you're unfamiliar with the code being modified. If you could describe the diff in one sentence, skip the plan. - - -*** - -## Provide specific context in your prompts - - - The more precise your instructions, the fewer corrections you'll need. - - -Claude can infer intent, but it can't read your mind. Reference specific files, mention constraints, and point to example patterns. - -| Strategy | Before | After | -| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Scope the task.** Specify which file, what scenario, and testing preferences. | *"add tests for foo.py"* | *"write a test for foo.py covering the edge case where the user is logged out. avoid mocks."* | -| **Point to sources.** Direct Claude to the source that can answer a question. | *"why does ExecutionFactory have such a weird api?"* | *"look through ExecutionFactory's git history and summarize how its api came to be"* | -| **Reference existing patterns.** Point Claude to patterns in your codebase. | *"add a calendar widget"* | *"look at how existing widgets are implemented on the home page to understand the patterns. HotDogWidget.php is a good example. follow the pattern to implement a new calendar widget that lets the user select a month and paginate forwards/backwards to pick a year. build from scratch without libraries other than the ones already used in the codebase."* | -| **Describe the symptom.** Provide the symptom, the likely location, and what "fixed" looks like. | *"fix the login bug"* | *"users report that login fails after session timeout. check the auth flow in src/auth/, especially token refresh. write a failing test that reproduces the issue, then fix it"* | - -Vague prompts can be useful when you're exploring and can afford to course-correct. A prompt like `"what would you improve in this file?"` can surface things you wouldn't have thought to ask about. - -### Provide rich content - - - Use `@` to reference files, paste screenshots/images, or pipe data directly. - - -You can provide rich data to Claude in several ways: - -* **Reference files with `@`** instead of describing where code lives. Claude reads the file before responding. -* **Paste images directly**. Copy/paste or drag and drop images into the prompt. -* **Give URLs** for documentation and API references. Use `/permissions` to allowlist frequently-used domains. -* **Pipe in data** by running `cat error.log | claude` to send file contents directly. -* **Let Claude fetch what it needs**. Tell Claude to pull context itself using Bash commands, MCP tools, or by reading files. - -*** - -## Configure your environment - -A few setup steps make Claude Code significantly more effective across all your sessions. For a full overview of extension features and when to use each one, see [Extend Claude Code](/en/features-overview). - -### Write an effective CLAUDE.md - - - Run `/init` to generate a starter CLAUDE.md file based on your current project structure, then refine over time. - - -CLAUDE.md is a special file that Claude reads at the start of every conversation. Include Bash commands, code style, and workflow rules. This gives Claude persistent context it can't infer from code alone. - -The `/init` command analyzes your codebase to detect build systems, test frameworks, and code patterns, giving you a solid foundation to refine. - -There's no required format for CLAUDE.md files, but keep it short and human-readable. For example: - -```markdown CLAUDE.md theme={null} -# Code style -- Use ES modules (import/export) syntax, not CommonJS (require) -- Destructure imports when possible (eg. import { foo } from 'bar') - -# Workflow -- Be sure to typecheck when you're done making a series of code changes -- Prefer running single tests, and not the whole test suite, for performance -``` - -CLAUDE.md is loaded every session, so only include things that apply broadly. For domain knowledge or workflows that are only relevant sometimes, use [skills](/en/skills) instead. Claude loads them on demand without bloating every conversation. - -Keep it concise. For each line, ask: *"Would removing this cause Claude to make mistakes?"* If not, cut it. Bloated CLAUDE.md files cause Claude to ignore your actual instructions! - -| ✅ Include | ❌ Exclude | -| ---------------------------------------------------- | -------------------------------------------------- | -| Bash commands Claude can't guess | Anything Claude can figure out by reading code | -| Code style rules that differ from defaults | Standard language conventions Claude already knows | -| Testing instructions and preferred test runners | Detailed API documentation (link to docs instead) | -| Repository etiquette (branch naming, PR conventions) | Information that changes frequently | -| Architectural decisions specific to your project | Long explanations or tutorials | -| Developer environment quirks (required env vars) | File-by-file descriptions of the codebase | -| Common gotchas or non-obvious behaviors | Self-evident practices like "write clean code" | - -If Claude keeps doing something you don't want despite having a rule against it, the file is probably too long and the rule is getting lost. If Claude asks you questions that are answered in CLAUDE.md, the phrasing might be ambiguous. Treat CLAUDE.md like code: review it when things go wrong, prune it regularly, and test changes by observing whether Claude's behavior actually shifts. - -You can tune instructions by adding emphasis (e.g., "IMPORTANT" or "YOU MUST") to improve adherence. Check CLAUDE.md into git so your team can contribute. The file compounds in value over time. - -CLAUDE.md files can import additional files using `@path/to/import` syntax: - -```markdown CLAUDE.md theme={null} -See @README.md for project overview and @package.json for available npm commands. - -# Additional Instructions -- Git workflow: @docs/git-instructions.md -- Personal overrides: @~/.claude/my-project-instructions.md -``` - -You can place CLAUDE.md files in several locations: - -* **Home folder (`~/.claude/CLAUDE.md`)**: applies to all Claude sessions -* **Project root (`./CLAUDE.md`)**: check into git to share with your team -* **Project root (`./CLAUDE.local.md`)**: personal project-specific notes; add this file to your `.gitignore` so it isn't shared with your team -* **Parent directories**: useful for monorepos where both `root/CLAUDE.md` and `root/foo/CLAUDE.md` are pulled in automatically -* **Child directories**: Claude pulls in child CLAUDE.md files on demand when it reads a file in those directories - -### Configure permissions - - - Use [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) to let a classifier handle approvals, `/permissions` to allowlist specific commands, or `/sandbox` for OS-level isolation. Each reduces interruptions while keeping you in control. - - -By default, Claude Code requests permission for actions that might modify your system: file writes, Bash commands, MCP tools, etc. This is safe but tedious. After the tenth approval you're not really reviewing anymore, you're just clicking through. There are three ways to reduce these interruptions: - -* **Auto mode**: a separate classifier model reviews commands and blocks only what looks risky: scope escalation, unknown infrastructure, or hostile-content-driven actions. Best when you trust the general direction of a task but don't want to click through every step -* **Permission allowlists**: permit specific tools you know are safe, like `npm run lint` or `git commit` -* **Sandboxing**: enable OS-level isolation that restricts filesystem and network access, allowing Claude to work more freely within defined boundaries - -Read more about [permission modes](/en/permission-modes), [permission rules](/en/permissions), and [sandboxing](/en/sandboxing). - -### Use CLI tools - - - Tell Claude Code to use CLI tools like `gh`, `aws`, `gcloud`, and `sentry-cli` when interacting with external services. - - -CLI tools are the most context-efficient way to interact with external services. If you use GitHub, install the `gh` CLI. Claude knows how to use it for creating issues, opening pull requests, and reading comments. Without `gh`, Claude can still use the GitHub API, but unauthenticated requests often hit rate limits. - -Claude is also effective at learning CLI tools it doesn't already know. Try prompts like `Use 'foo-cli-tool --help' to learn about foo tool, then use it to solve A, B, C.` - -### Connect MCP servers - - - Run `claude mcp add` to connect external tools like Notion, Figma, or your database. - - -With [MCP servers](/en/mcp), you can ask Claude to implement features from issue trackers, query databases, analyze monitoring data, integrate designs from Figma, and automate workflows. - -### Set up hooks - - - Use hooks for actions that must happen every time with zero exceptions. - - -[Hooks](/en/hooks-guide) run scripts automatically at specific points in Claude's workflow. Unlike CLAUDE.md instructions which are advisory, hooks are deterministic and guarantee the action happens. - -Claude can write hooks for you. Try prompts like *"Write a hook that runs eslint after every file edit"* or *"Write a hook that blocks writes to the migrations folder."* Edit `.claude/settings.json` directly to configure hooks by hand, and run `/hooks` to browse what's configured. - -### Create skills - - - Create `SKILL.md` files in `.claude/skills/` to give Claude domain knowledge and reusable workflows. - - -[Skills](/en/skills) extend Claude's knowledge with information specific to your project, team, or domain. Claude applies them automatically when relevant, or you can invoke them directly with `/skill-name`. - -Create a skill by adding a directory with a `SKILL.md` to `.claude/skills/`: - -```markdown .claude/skills/api-conventions/SKILL.md theme={null} ---- -name: api-conventions -description: REST API design conventions for our services ---- -# API Conventions -- Use kebab-case for URL paths -- Use camelCase for JSON properties -- Always include pagination for list endpoints -- Version APIs in the URL path (/v1/, /v2/) -``` - -Skills can also define repeatable workflows you invoke directly: - -```markdown .claude/skills/fix-issue/SKILL.md theme={null} ---- -name: fix-issue -description: Fix a GitHub issue -disable-model-invocation: true ---- -Analyze and fix the GitHub issue: $ARGUMENTS. - -1. Use `gh issue view` to get the issue details -2. Understand the problem described in the issue -3. Search the codebase for relevant files -4. Implement the necessary changes to fix the issue -5. Write and run tests to verify the fix -6. Ensure code passes linting and type checking -7. Create a descriptive commit message -8. Push and create a PR -``` - -Run `/fix-issue 1234` to invoke it. Use `disable-model-invocation: true` for workflows with side effects that you want to trigger manually. - -### Create custom subagents - - - Define specialized assistants in `.claude/agents/` that Claude can delegate to for isolated tasks. - - -[Subagents](/en/sub-agents) run in their own context with their own set of allowed tools. They're useful for tasks that read many files or need specialized focus without cluttering your main conversation. - -```markdown .claude/agents/security-reviewer.md theme={null} ---- -name: security-reviewer -description: Reviews code for security vulnerabilities -tools: Read, Grep, Glob, Bash -model: opus ---- -You are a senior security engineer. Review code for: -- Injection vulnerabilities (SQL, XSS, command injection) -- Authentication and authorization flaws -- Secrets or credentials in code -- Insecure data handling - -Provide specific line references and suggested fixes. -``` - -Tell Claude to use subagents explicitly: *"Use a subagent to review this code for security issues."* - -### Install plugins - - - Run `/plugin` to browse the marketplace. Plugins add skills, tools, and integrations without configuration. - - -[Plugins](/en/plugins) bundle skills, hooks, subagents, and MCP servers into a single installable unit from the community and Anthropic. If you work with a typed language, install a [code intelligence plugin](/en/discover-plugins#code-intelligence) to give Claude precise symbol navigation and automatic error detection after edits. - -For guidance on choosing between skills, subagents, hooks, and MCP, see [Extend Claude Code](/en/features-overview#match-features-to-your-goal). - -*** - -## Communicate effectively - -The way you communicate with Claude Code significantly impacts the quality of results. - -### Ask codebase questions - - - Ask Claude questions you'd ask a senior engineer. - - -When onboarding to a new codebase, use Claude Code for learning and exploration. You can ask Claude the same sorts of questions you would ask another engineer: - -* How does logging work? -* How do I make a new API endpoint? -* What does `async move { ... }` do on line 134 of `foo.rs`? -* What edge cases does `CustomerOnboardingFlowImpl` handle? -* Why does this code call `foo()` instead of `bar()` on line 333? - -Using Claude Code this way is an effective onboarding workflow, improving ramp-up time and reducing load on other engineers. No special prompting required: ask questions directly. - -### Let Claude interview you - - - For larger features, have Claude interview you first. Start with a minimal prompt and ask Claude to interview you using the `AskUserQuestion` tool. - - -Claude asks about things you might not have considered yet, including technical implementation, UI/UX, edge cases, and tradeoffs. - -```text theme={null} -I want to build [brief description]. Interview me in detail using the AskUserQuestion tool. - -Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs. Don't ask obvious questions, dig into the hard parts I might not have considered. - -Keep interviewing until we've covered everything, then write a complete spec to SPEC.md. -``` - -Once the spec is complete, start a fresh session to execute it. The new session has clean context focused entirely on implementation, and you have a written spec to reference. - -The most useful specs are self-contained: they name the files and interfaces involved, state what is out of scope, and end with an end-to-end verification step that proves the feature works. Time spent making the spec precise pays off more than time spent watching the implementation. - -*** - -## Manage your session - -Conversations are persistent and reversible. Use this to your advantage! - -### Course-correct early and often - - - Correct Claude as soon as you notice it going off track. - - -The best results come from tight feedback loops. Though Claude occasionally solves problems perfectly on the first attempt, correcting it quickly generally produces better solutions faster. - -* **`Esc`**: stop Claude mid-action with the `Esc` key. Context is preserved, so you can redirect. -* **`Esc + Esc` or `/rewind`**: press `Esc` twice or run `/rewind` to open the rewind menu and restore previous conversation and code state, or summarize from a selected message. -* **`"Undo that"`**: have Claude revert its changes. -* **`/clear`**: reset context between unrelated tasks. Long sessions with irrelevant context can reduce performance. - -If you've corrected Claude more than twice on the same issue in one session, the context is cluttered with failed approaches. Run `/clear` and start fresh with a more specific prompt that incorporates what you learned. A clean session with a better prompt almost always outperforms a long session with accumulated corrections. - -### Manage context aggressively - - - Run `/clear` between unrelated tasks to reset context. - - -Claude Code automatically compacts conversation history when you approach context limits, which preserves important code and decisions while freeing space. - -During long sessions, Claude's context window can fill with irrelevant conversation, file contents, and commands. This can reduce performance and sometimes distract Claude. - -* Use `/clear` frequently between tasks to reset the context window entirely -* When auto compaction triggers, Claude summarizes what matters most, including code patterns, file states, and key decisions -* For more control, run `/compact `, like `/compact Focus on the API changes` -* To compact only part of the conversation, use `Esc + Esc` or `/rewind`, select a message checkpoint, and choose **Summarize from here** or **Summarize up to here**. The first condenses messages from that point forward while keeping earlier context intact; the second condenses earlier messages while keeping recent ones in full. See [Restore vs. summarize](/en/checkpointing#restore-vs-summarize). -* Customize compaction behavior in CLAUDE.md with instructions like `"When compacting, always preserve the full list of modified files and any test commands"` to ensure critical context survives summarization -* For quick questions that don't need to stay in context, use [`/btw`](/en/interactive-mode#side-questions-with-%2Fbtw). The answer appears in a dismissible overlay and never enters conversation history, so you can check a detail without growing context. - -### Use subagents for investigation - - - Delegate research with `"use subagents to investigate X"`. They explore in a separate context, keeping your main conversation clean for implementation. - - -Since context is your fundamental constraint, subagents are one of the most powerful tools available. When Claude researches a codebase it reads lots of files, all of which consume your context. Subagents run in separate context windows and report back summaries: - -```text theme={null} -Use subagents to investigate how our authentication system handles token -refresh, and whether we have any existing OAuth utilities I should reuse. -``` - -The subagent explores the codebase, reads relevant files, and reports back with findings, all without cluttering your main conversation. - -You can also use subagents for verification after Claude implements something: - -```text theme={null} -use a subagent to review this code for edge cases -``` - -### Rewind with checkpoints - - - Every prompt you send creates a checkpoint. You can restore conversation, code, or both to any previous checkpoint. - - -Claude automatically snapshots files before each change so a checkpoint can restore them. Double-tap `Escape` or run `/rewind` to open the rewind menu. You can restore conversation only, restore code only, restore both, or summarize from a selected message. See [Checkpointing](/en/checkpointing) for details. - -Instead of carefully planning every move, you can tell Claude to try something risky. If it doesn't work, rewind and try a different approach. Checkpoints persist across sessions, so you can close your terminal and still rewind later. - - - Checkpoints only track changes made *by Claude*, not external processes. This isn't a replacement for git. - - -### Resume conversations - - - Name sessions with `/rename` and treat them like branches: each workstream gets its own persistent context. - - -Claude Code saves conversations locally, so when a task spans multiple sittings you don't have to re-explain the context. Run `claude --continue` to pick up the most recent session, or `claude --resume` to choose from a list. Give sessions descriptive names like `oauth-migration` so you can find them later. See [Manage sessions](/en/sessions) for the full set of resume, branch, and naming controls. - -*** - -## Automate and scale - -Once you're effective with one Claude, multiply your output with parallel sessions, non-interactive mode, and fan-out patterns. - -Everything so far assumes one human, one Claude, and one conversation. But Claude Code scales horizontally. The techniques in this section show how you can get more done. - -### Run non-interactive mode - - - Use `claude -p "prompt"` in CI, pre-commit hooks, or scripts. Add `--output-format stream-json --verbose` for streaming JSON output. - - -With `claude -p "your prompt"`, you can run Claude non-interactively, without a session. [Non-interactive mode](/en/headless) is how you integrate Claude into CI pipelines, pre-commit hooks, or any automated workflow. The output formats let you parse results programmatically: plain text, JSON, or streaming JSON. - -```bash theme={null} -# One-off queries -claude -p "Explain what this project does" - -# Structured output for scripts -claude -p "List all API endpoints" --output-format json - -# Streaming for real-time processing -claude -p "Analyze this log file" --output-format stream-json --verbose -``` - -### Run multiple Claude sessions - - - Run multiple Claude sessions in parallel to speed up development, run isolated experiments, or start complex workflows. - - -Pick the parallel approach that fits how much coordination you want to do yourself: - -* [Worktrees](/en/worktrees): run separate CLI sessions in isolated git checkouts so edits don't collide -* [Desktop app](/en/desktop#work-in-parallel-with-sessions): manage multiple local sessions visually, each in its own worktree -* [Claude Code on the web](/en/claude-code-on-the-web): run sessions on Anthropic-managed cloud infrastructure in isolated VMs -* [Agent teams](/en/agent-teams): automated coordination of multiple sessions with shared tasks, messaging, and a team lead - -Beyond parallelizing work, multiple sessions enable quality-focused workflows. A fresh context improves code review since Claude won't be biased toward code it just wrote. - -For example, use a Writer/Reviewer pattern: - -| Session A (Writer) | Session B (Reviewer) | -| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Implement a rate limiter for our API endpoints` | | -| | `Review the rate limiter implementation in @src/middleware/rateLimiter.ts. Look for edge cases, race conditions, and consistency with our existing middleware patterns.` | -| `Here's the review feedback: [Session B output]. Address these issues.` | | - -You can do something similar with tests: have one Claude write tests, then another write code to pass them. - -### Fan out across files - - - Loop through tasks calling `claude -p` for each. Use `--allowedTools` to scope permissions for batch operations. - - -For large migrations or analyses, you can distribute work across many parallel Claude invocations: - - - - Have Claude list all files that need migrating (e.g., `list all 2,000 Python files that need migrating`) - - - - ```bash theme={null} - for file in $(cat files.txt); do - claude -p "Migrate $file from React to Vue. Return OK or FAIL." \ - --allowedTools "Edit,Bash(git commit *)" - done - ``` - - - - Refine your prompt based on what goes wrong with the first 2-3 files, then run on the full set. The `--allowedTools` flag restricts what Claude can do, which matters when you're running unattended. - - - -You can also integrate Claude into existing data/processing pipelines: - -```bash theme={null} -claude -p "" --output-format json | your_command -``` - -Use `--verbose` for debugging during development, and turn it off in production. - -### Run autonomously with auto mode - -For uninterrupted execution with background safety checks, use [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode). A classifier model reviews commands before they run, blocking scope escalation, unknown infrastructure, and hostile-content-driven actions while letting routine work proceed without prompts. - -```bash theme={null} -claude --permission-mode auto -p "fix all lint errors" -``` - -For non-interactive runs with the `-p` flag, auto mode aborts if the classifier repeatedly blocks actions, since there is no user to fall back to. See [when auto mode falls back](/en/permission-modes#when-auto-mode-falls-back) for thresholds. - -### Add an adversarial review step - - - Before treating a task as done, have a subagent review the diff in a fresh context and report gaps. - - -The longer Claude works unattended, the more an independent check matters before you count the work as done. A reviewer running in a fresh [subagent](/en/sub-agents) context sees only the diff and the criteria you give it, not the reasoning that produced the change, so it evaluates the result on its own terms. - -For a correctness check, run the bundled [`/code-review` skill](/en/commands), which reviews the current diff for bugs in a fresh subagent and returns findings to the session. To check the diff against your plan instead, write the review prompt yourself. Name the work to check, the plan to check it against, and what counts as a finding: - -```text theme={null} -Use a subagent to review the rate limiter diff against PLAN.md. Check that -every requirement is implemented, the listed edge cases have tests, and -nothing outside the task's scope changed. Report gaps, not style preferences. -``` - -Because the reviewer runs as a subagent, the implementing session receives the gaps directly and can fix them and re-review without you copying findings between windows. For longer autonomous runs, an [agent team](/en/agent-teams) can keep this loop going across many tasks while you spot-check the recorded findings. - - - A reviewer prompted to find gaps will usually report some, even when the work is sound, because that is what it was asked to do. Chasing every finding leads to over-engineering: extra abstraction layers, defensive code, and tests for cases that can't happen. Tell the reviewer to flag only gaps that affect correctness or the stated requirements, and treat the rest as optional. - - -*** - -## Avoid common failure patterns - -These are common mistakes. Recognizing them early saves time: - -* **The kitchen sink session.** You start with one task, then ask Claude something unrelated, then go back to the first task. Context is full of irrelevant information. - > **Fix**: `/clear` between unrelated tasks. -* **Correcting over and over.** Claude does something wrong, you correct it, it's still wrong, you correct again. Context is polluted with failed approaches. - > **Fix**: After two failed corrections, `/clear` and write a better initial prompt incorporating what you learned. -* **The over-specified CLAUDE.md.** If your CLAUDE.md is too long, Claude ignores half of it because important rules get lost in the noise. - > **Fix**: Ruthlessly prune. If Claude already does something correctly without the instruction, delete it or convert it to a hook. -* **The trust-then-verify gap.** Claude produces a plausible-looking implementation that doesn't handle edge cases. - > **Fix**: Always provide verification (tests, scripts, screenshots). If you can't verify it, don't ship it. -* **The infinite exploration.** You ask Claude to "investigate" something without scoping it. Claude reads hundreds of files, filling the context. - > **Fix**: Scope investigations narrowly or use subagents so the exploration doesn't consume your main context. - -*** - -## Develop your intuition - -The patterns in this guide aren't set in stone. They're starting points that work well in general, but might not be optimal for every situation. - -Sometimes you *should* let context accumulate because you're deep in one complex problem and the history is valuable. Sometimes you should skip planning and let Claude figure it out because the task is exploratory. Sometimes a vague prompt is exactly right because you want to see how Claude interprets the problem before constraining it. - -Pay attention to what works. When Claude produces great output, notice what you did: the prompt structure, the context you provided, the mode you were in. When Claude struggles, ask why. Was the context too noisy? The prompt too vague? The task too big for one pass? - -Over time, you'll develop intuition that no guide can capture. You'll know when to be specific and when to be open-ended, when to plan and when to explore, when to clear context and when to let it accumulate. - -## Related resources - -* [How Claude Code works](/en/how-claude-code-works): the agentic loop, tools, and context management -* [Extend Claude Code](/en/features-overview): skills, hooks, MCP, subagents, and plugins -* [Common workflows](/en/common-workflows): step-by-step recipes for debugging, testing, PRs, and more -* [CLAUDE.md](/en/memory): store project conventions and persistent context diff --git a/.archive/prompting-best-practices.md b/.archive/prompting-best-practices.md deleted file mode 100644 index 81206ba..0000000 --- a/.archive/prompting-best-practices.md +++ /dev/null @@ -1,904 +0,0 @@ -# Prompting best practices - -Comprehensive guide to prompt engineering techniques for Claude's latest models, covering clarity, examples, XML structuring, thinking, and agentic systems. - ---- - -This is the single reference for prompt engineering with Claude's latest models, including Claude Opus 4.8, Claude Opus 4.7, Claude Opus 4.6, Claude Sonnet 4.6, and Claude Haiku 4.5. It covers foundational techniques, output control, tool use, thinking, and agentic systems. Jump to the section that matches your situation. - - - For an overview of model capabilities, see the [models overview](/docs/en/about-claude/models/overview). For details on what's new in Claude Opus 4.8, see [What's new in Claude Opus 4.8](/docs/en/about-claude/models/whats-new-claude-4-8). For migration guidance, see the [Migration guide](/docs/en/about-claude/models/migration-guide). - - -## Prompting Claude Opus 4.8 - -Claude Opus 4.8 has particular strengths in long-horizon agentic work, knowledge work, vision, and memory tasks. It performs well out of the box on existing Claude Opus 4.7 prompts. The patterns below cover the behaviors that most often require tuning. - - -For API parameter changes when migrating from Claude Opus 4.7 (sampling parameters, effort default, 1M context window default (200k on Microsoft Foundry), mid-conversation system messages, and refusal stop details), see the [migration guide](/docs/en/about-claude/models/migration-guide#migrating-from-claude-opus-47). - - -### Response length and verbosity - -Claude Opus 4.8 calibrates response length to how complex it judges the task to be, rather than defaulting to a fixed verbosity. This usually means shorter answers on simple lookups and much longer ones on open-ended analysis. - -If your product depends on a certain style or verbosity of output, you may need to tune your prompts. As an example, to decrease verbosity, you might add: - -```text -Provide concise, focused responses. Skip non-essential context, and keep examples minimal. -``` - -If you see specific examples of kinds of verbosity (i.e. over-explaining), you can add additional instructions in your prompt to prevent them. Positive examples showing how Claude can communicate with the appropriate level of concision tend to be more effective than negative examples or instructions that tell the model what not to do. - -### Calibrating effort and thinking depth - -The [effort parameter](/docs/en/build-with-claude/effort) allows you to tune Claude's intelligence vs. token spend, trading off capability for faster speed and lower costs. Start with the `xhigh` effort level for coding and agentic use cases, and use a minimum of `high` effort for most intelligence-sensitive use cases. Experiment with other effort levels to further tune token usage and intelligence: - -- **`max`:** Max effort can deliver performance gains in some use cases, but may show diminishing returns from increased token usage. This setting can also sometimes be prone to overthinking. Test max effort for intelligence-demanding tasks. -- **`xhigh`:** Extra high effort is the best setting for most coding and agentic use cases. -- **`high`:** This setting balances token usage and intelligence. For most intelligence-sensitive use cases, use a minimum of `high` effort. -- **`medium`:** Good for cost-sensitive use cases that need to reduce token usage while trading off intelligence. -- **`low`:** Reserve for short, scoped tasks and latency-sensitive workloads that are not intelligence-sensitive. - -Claude Opus 4.8 respects effort levels strictly, especially at the low end. At `low` and `medium`, the model scopes its work to what was asked rather than going above and beyond. This is good for latency and cost, but on moderately complex tasks running at `low` effort there is some risk of under-thinking. - -If you observe shallow reasoning on complex problems, raise effort to `high` or `xhigh` rather than prompting around it. If you need to keep effort at `low` for latency, add targeted guidance: - -```text -This task involves multi-step reasoning. Think carefully through the problem before responding. -``` - -Effort is likely to be more important for this model than for any prior Opus, so experiment with it actively when you upgrade. - -On Claude Opus 4.8, thinking is off unless you explicitly set `thinking: {type: "adaptive"}`. The triggering behavior for adaptive thinking is steerable. If you find the model thinking more often than you'd like, which can happen with large or complex system prompts, add guidance to steer it. As always, measure the effect of any prompting changes on performance. Example: - -```text -Thinking adds latency and should only be used when it will meaningfully improve answer quality — typically for problems that require multi-step reasoning. When in doubt, respond directly. -``` - -Conversely, if you're running hard workloads at `medium` and seeing under-thinking, the first lever is to raise effort. If you need finer control, prompt for it directly. - - -If you are running Claude Opus 4.8 at `max` or `xhigh` effort, set a large max output token budget so the model has room to think and act across its subagents and tool calls. Start at 64k tokens and tune from there. - - -### Tool use triggering - -Claude Opus 4.8 has a tendency to favor reasoning over tool calls. This produces better results in most cases. However, increasing the effort setting is a useful lever to increase the level of tool usage, especially in knowledge work. `high` or `xhigh` effort settings show substantially more tool usage in agentic search and coding. For scenarios where you want more tool use, you can also adjust your prompt to explicitly instruct the model about when and how to properly use its tools. For instance, if you find that the model is not using your web search tools, clearly describe why and how it should. - -### User-facing progress updates - -Claude Opus 4.8 provides more regular, higher-quality updates to the user throughout long agentic traces. If you've added scaffolding to force interim status messages ("After every 3 tool calls, summarize progress"), try removing it. If you find that the length or contents of Claude Opus 4.8's user-facing updates are not well-calibrated to your use case, explicitly describe what these updates should look like in the prompt and provide examples. - -### More literal instruction following - -Claude Opus 4.8 interprets prompts literally and explicitly, particularly at lower effort levels. It does not silently generalize an instruction from one item to another, and it does not infer requests you didn't make. The upside of this literalism is precision and less thrash, and it generally performs better for API use cases with carefully tuned prompts, structured extraction, and pipelines where you want predictable behavior. If you need Claude to apply an instruction broadly, state the scope explicitly (for example, "Apply this formatting to every section, not just the first one"). - -### Tone and writing style - -As with any new model, prose style on long-form writing may shift. Claude Opus 4.8 tends toward a direct, opinionated style with minimal validation-forward phrasing and sparing emoji use. If your product relies on a specific voice, re-evaluate style prompts against the new baseline. - -For instance, if your product voice is warmer or more conversational, add: - -```text -Use a warm, collaborative tone. Acknowledge the user's framing before answering. -``` - -### Controlling subagent spawning - -Claude Opus 4.8 tends to spawn fewer subagents by default. However, this behavior is steerable through prompting; give Claude Opus 4.8 explicit guidance around when subagents are desirable. A toy example for a coding use case: - -```text -Do not spawn a subagent for work you can complete directly in a single response (e.g. refactoring a function you can already see). - -Spawn multiple subagents in the same turn when fanning out across items or reading multiple files. -``` - -### Design and frontend defaults - -Claude Opus 4.8 has strong design instincts, with a consistent default house style: warm cream/off-white backgrounds (~`#F4F1EA`), serif display type (Georgia, Fraunces, Playfair), italic word-accents, and a terracotta/amber accent. This reads well for editorial, hospitality, and portfolio briefs, but will feel off for dashboards, dev tools, fintech, healthcare, or enterprise apps. The default appears in slide decks as well as web UIs. - -This default is persistent. Generic instructions ("don't use cream," "make it clean and minimal") tend to shift the model to a different fixed palette rather than producing variety. Two approaches work reliably: - -**1. Specify a concrete alternative.** The model follows explicit specs precisely: - -```text -Design a desktop landing page for a supplement brand called AEFRM. - -The visual direction should come from a cold monochrome atmosphere using pale silver-gray tones that gradually deepen into blue-gray and near-black, similar to a misted metallic surface. - -The page should feel sharp and controlled, with a strong sense of structure and restraint. - -Use this tonal system across the full page instead of introducing bright accent colors. - -Use the uploaded image on the hero design in black and white. - -The layout should be built with clear horizontal sections and a centered max-width container. Use 4px corner radius consistently across cards, buttons, inputs, and media frames. Margins should feel generous, with enough empty space around each section so the page breathes. - -Typography should use a square, angular sans-serif with wider letter spacing than usual, especially in headings and navigation, so the text feels more engineered and less compressed. Headline text can be large and uppercase, while supporting copy remains short and sparse. The sub texts should be written with Alumni Sans SC in 4-6px like tiny little texts on corners bottom centre like that. - -For the structure, start with a hero section containing a strong product statement, one short supporting paragraph, and a clean product placeholder or packshot frame. Below that, add a benefit grid with three or four blocks, then a formulation or ingredients section, and finally a cta. - -Buttons should be flat and precise, with subtle hover changes using transition: all 160ms ease out where brightness and border contrast shift slightly rather than using dramatic motion. - -Color palette should stay within this range: -#E9ECEC, #C9D2D4, #8C9A9E, #44545B, #11171B. -``` - -**2. Have the model propose options before building.** This breaks the default and gives users control. If you previously relied on `temperature` for design variety, use this approach; it produces meaningfully different directions across runs. Example prompt: - -```text -Before building, propose 4 distinct visual directions tailored to this brief (each as: bg hex / accent hex / typeface — one-line rationale). Ask the user to pick one, then implement only that direction. -``` - -Additionally, Claude Opus 4.8 requires less frontend design prompting than previous models to avoid generic patterns that users call the "AI slop" aesthetic. With earlier models, Anthropic recommended a lengthier prompt snippet in the [frontend-design skill](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md). However, Claude Opus 4.8 generates distinctive, creative frontends with more minimal prompting guidance. This prompt snippet works well with the above prompting advice for variety: - -```text - -NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white or dark backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. Use unique fonts, cohesive colors and themes, and animations for effects and micro-interactions. - -``` - -### Interactive coding products - -Claude Opus 4.8's token usage and behavior can differ between autonomous, asynchronous coding agents with a single user turn and interactive, synchronous coding agents with multiple user turns. Specifically, it tends to use more tokens in interactive settings, primarily because it reasons more after user turns. This can improve long-horizon coherence, instruction following, and coding capabilities in long, interactive coding sessions, but also comes with more token usage. To maximize both performance and token efficiency in coding products, use `xhigh` or `high` effort, add autonomous features like an auto mode, and reduce the number of human interactions required from your users. - -Of course, when limiting the number of required user interactions, it's important to specify the task, intent, and relevant constraints upfront in the first human turn. Providing well-specified, clear, and accurate task descriptions upfront can help maximize autonomy and intelligence while minimizing extra token usage after user turns. Because Claude Opus 4.8 is more autonomous than prior models, this usage pattern helps to maximize performance. In contrast, ambiguous or underspecified prompts conveyed progressively over multiple user turns tend to relatively reduce token efficiency and sometimes performance. - -### Code review harnesses - -Claude Opus 4.8 is meaningfully better at finding bugs than prior models, and has both higher recall and precision in internal evals. However, if your code-review harness was tuned for an earlier model, you may initially see lower recall. This is likely a harness effect, not a capability regression. When a review prompt says things like "only report high-severity issues," "be conservative," or "don't nitpick," Claude Opus 4.8 may follow that instruction more faithfully than earlier models did: it may investigate the code just as thoroughly, identify the bugs, and then not report findings it judges to be below your stated bar. This can show up as the model doing the same depth of investigation but converting fewer investigations into reported findings, especially on lower-severity bugs. Precision typically rises, but measured recall can fall even though the model's underlying bug-finding ability has improved. - -Some recommended prompt language: - -```text -Report every issue you find, including ones you are uncertain about or consider low-severity. Do not filter for importance or confidence at this stage - a separate verification step will do that. Your goal here is coverage: it is better to surface a finding that later gets filtered out than to silently drop a real bug. For each finding, include your confidence level and an estimated severity so a downstream filter can rank them. -``` - -This prompt can be used without having an actual second step, but moving confidence filtering out of the finding step often helps. If your harness has a separate verification, deduplication, or ranking stage, tell the model explicitly that its job at the finding stage is coverage rather than filtering. - -If you do want the model to self-filter in a single pass, be concrete about where the bar is rather than using qualitative terms like "important": for example, "report any bugs that could cause incorrect behavior, a test failure, or a misleading result; only omit nits like pure style or naming preferences." - -Iterate on prompts against a subset of your evals or test cases to validate recall or F1 score gains. - -### Computer use - -[Computer use](/docs/en/agents-and-tools/tool-use/computer-use-tool) capability works across resolutions, up to a maximum resolution of 2576px / 3.75MP. Internal computer use testing shows that sending images at 1080p provides a good balance of performance and cost. - -For particularly cost-sensitive workloads, 720p or 1366×768 are lower-cost options with strong performance. Conduct your own testing to find the ideal settings for your use case; experimenting with effort settings can also help tune the model's behavior. - -## General principles - -### Be clear and direct - -Claude responds well to clear, explicit instructions. Being specific about your desired output can help enhance results. If you want "above and beyond" behavior, explicitly request it rather than relying on the model to infer this from vague prompts. - -Think of Claude as a brilliant but new employee who lacks context on your norms and workflows. The more precisely you explain what you want, the better the result. - -**Golden rule:** Show your prompt to a colleague with minimal context on the task and ask them to follow it. If they'd be confused, Claude will be too. - -- Be specific about the desired output format and constraints. -- Provide instructions as sequential steps using numbered lists or bullet points when the order or completeness of steps matters. - -
- -**Less effective:** -```text -Create an analytics dashboard -``` - -**More effective:** -```text -Create an analytics dashboard. Include as many relevant features and interactions as possible. Go beyond the basics to create a fully-featured implementation. -``` - -
- -### Add context to improve performance - -Providing context or motivation behind your instructions, such as explaining to Claude why such behavior is important, can help Claude better understand your goals and deliver more targeted responses. - -
- -**Less effective:** -```text -NEVER use ellipses -``` - -**More effective:** -```text -Your response will be read aloud by a text-to-speech engine, so never use ellipses since the text-to-speech engine will not know how to pronounce them. -``` - -
- -Claude is smart enough to generalize from the explanation. - -### Use examples effectively - -Examples are one of the most reliable ways to steer Claude's output format, tone, and structure. A few well-crafted examples (known as few-shot or multishot prompting) can dramatically improve accuracy and consistency. - -When adding examples, make them: -- **Relevant:** Mirror your actual use case closely. -- **Diverse:** Cover edge cases and vary enough that Claude doesn't pick up unintended patterns. -- **Structured:** Wrap examples in `` tags (multiple examples in `` tags) so Claude can distinguish them from instructions. - -Include 3–5 examples for best results. You can also ask Claude to evaluate your examples for relevance and diversity, or to generate additional ones based on your initial set. - -### Structure prompts with XML tags - -XML tags help Claude parse complex prompts unambiguously, especially when your prompt mixes instructions, context, examples, and variable inputs. Wrapping each type of content in its own tag (e.g. ``, ``, ``) reduces misinterpretation. - -Best practices: -- Use consistent, descriptive tag names across your prompts. -- Nest tags when content has a natural hierarchy (documents inside ``, each inside ``). - -### Give Claude a role - -Setting a role in the system prompt focuses Claude's behavior and tone for your use case. Even a single sentence makes a difference: - -```python Python -import anthropic - -client = anthropic.Anthropic() - -message = client.messages.create( - model="claude-opus-4-8", - max_tokens=1024, - system="You are a helpful coding assistant specializing in Python.", - messages=[ - {"role": "user", "content": "How do I sort a list of dictionaries by key?"} - ], -) -print(message.content) -``` - -### Long context prompting - -When working with large documents or data-rich inputs (20k+ tokens), structure your prompt carefully to get the best results: - -- **Put longform data at the top:** Place your long documents and inputs near the top of your prompt, above your query, instructions, and examples. This can significantly improve performance across all models. - - Queries at the end can improve response quality by up to 30% in tests, especially with complex, multi-document inputs. - -- **Structure document content and metadata with XML tags:** When using multiple documents, wrap each document in `` tags with `` and `` (and other metadata) subtags for clarity. - -
- - ```xml - - - annual_report_2023.pdf - - {{ANNUAL_REPORT}} - - - - competitor_analysis_q2.xlsx - - {{COMPETITOR_ANALYSIS}} - - - - - Analyze the annual report and competitor analysis. Identify strategic advantages and recommend Q3 focus areas. - ``` - -
- -- **Ground responses in quotes:** For long document tasks, ask Claude to quote relevant parts of the documents first before carrying out its task. This helps Claude cut through the noise of the rest of the document's contents. - -
- - ```xml - You are an AI physician's assistant. Your task is to help doctors diagnose possible patient illnesses. - - - - patient_symptoms.txt - - {{PATIENT_SYMPTOMS}} - - - - patient_records.txt - - {{PATIENT_RECORDS}} - - - - patient01_appt_history.txt - - {{PATIENT01_APPOINTMENT_HISTORY}} - - - - - Find quotes from the patient records and appointment history that are relevant to diagnosing the patient's reported symptoms. Place these in tags. Then, based on these quotes, list all information that would help the doctor diagnose the patient's symptoms. Place your diagnostic information in tags. - ``` - -
- -### Model self-knowledge - -If you would like Claude to identify itself correctly in your application or use specific API strings: - -```text Sample prompt for model identity -The assistant is Claude, created by Anthropic. The current model is Claude Opus 4.8. -``` - -For LLM-powered apps that need to specify model strings: - -```text Sample prompt for model string -When an LLM is needed, please default to Claude Opus 4.8 unless the user requests otherwise. The exact model string for Claude Opus 4.8 is claude-opus-4-8. -``` - -## Output and formatting - -### Communication style and verbosity - -Claude's latest models have a more concise and natural communication style compared to previous models: - -- **More direct and grounded:** Provides fact-based progress reports rather than self-celebratory updates -- **More conversational:** Slightly more fluent and colloquial, less machine-like -- **Less verbose:** May skip detailed summaries for efficiency unless prompted otherwise - -This means Claude may skip verbal summaries after tool calls, jumping directly to the next action. If you prefer more visibility into its reasoning: - -```text Sample prompt -After completing a task that involves tool use, provide a quick summary of the work you've done. -``` - -### Control the format of responses - -There are a few particularly effective ways to steer output formatting: - -1. **Tell Claude what to do instead of what not to do** - - - Instead of: "Do not use markdown in your response" - - Try: "Your response should be composed of smoothly flowing prose paragraphs." - -2. **Use XML format indicators** - - - Try: "Write the prose sections of your response in \ tags." - -3. **Match your prompt style to the desired output** - - The formatting style used in your prompt may influence Claude's response style. If you are still experiencing steerability issues with output formatting, try matching your prompt style to your desired output style as closely as possible. For example, removing markdown from your prompt can reduce the volume of markdown in the output. - -4. **Use detailed prompts for specific formatting preferences** - - For more control over markdown and formatting usage, provide explicit guidance: - -```text Sample prompt to minimize markdown - -When writing reports, documents, technical explanations, analyses, or any long-form content, write in clear, flowing prose using complete paragraphs and sentences. Use standard paragraph breaks for organization and reserve markdown primarily for `inline code`, code blocks (```...```), and simple headings (###, and ###). Avoid using **bold** and *italics*. - -DO NOT use ordered lists (1. ...) or unordered lists (*) unless : a) you're presenting truly discrete items where a list format is the best option, or b) the user explicitly requests a list or ranking - -Instead of listing items with bullets or numbers, incorporate them naturally into sentences. This guidance applies especially to technical writing. Using prose instead of excessive formatting will improve user satisfaction. NEVER output a series of overly short bullet points. - -Your goal is readable, flowing text that guides the reader naturally through ideas rather than fragmenting information into isolated points. - -``` - -### LaTeX output - -Claude's latest models default to LaTeX for mathematical expressions, equations, and technical explanations. If you prefer plain text, add the following instructions to your prompt: - -```text Sample prompt -Format your response in plain text only. Do not use LaTeX, MathJax, or any markup notation such as \( \), $, or \frac{}{}. Write all math expressions using standard text characters (e.g., "/" for division, "*" for multiplication, and "^" for exponents). -``` - -### Document creation - -Claude's latest models excel at creating presentations, animations, and visual documents with impressive creative flair and strong instruction following. The models produce polished, usable output on the first try in most cases. - -For best results with document creation: - -```text Sample prompt -Create a professional presentation on [topic]. Include thoughtful design elements, visual hierarchy, and engaging animations where appropriate. -``` - -### Migrating away from prefilled responses - -Starting with Claude 4.6 models and [Claude Mythos Preview](https://anthropic.com/glasswing), prefilled responses on the last assistant turn are no longer supported. Requests with prefilled assistant messages to these models return a 400 error. Model intelligence and instruction following have advanced such that most use cases of prefill no longer require it. Earlier models continue to support prefills, and adding assistant messages elsewhere in the conversation is not affected. - -Here are common prefill scenarios and how to migrate away from them: - -
- -Prefills have been used to force specific output formats like JSON/YAML, classification, and similar patterns where the prefill constrains Claude to a particular structure. - -**Migration:** The [Structured Outputs](/docs/en/build-with-claude/structured-outputs) feature is designed specifically to constrain Claude's responses to follow a given schema. Try simply asking the model to conform to your output structure first, as newer models can reliably match complex schemas when told to, especially if implemented with retries. For classification tasks, use either tools with an enum field containing your valid labels or structured outputs. - -
- -
- -Prefills like `Here is the requested summary:\n` were used to skip introductory text. - -**Migration:** Use direct instructions in the system prompt: "Respond directly without preamble. Do not start with phrases like 'Here is...', 'Based on...', etc." Alternatively, direct the model to output within XML tags, use structured outputs, or use tool calling. If the occasional preamble slips through, strip it in post-processing. - -
- -
- -Prefills were used to steer around unnecessary refusals. - -**Migration:** Claude is much better at appropriate refusals now. Clear prompting within the `user` message without prefill should be sufficient. - -
- -
- -Prefills were used to continue partial completions, resume interrupted responses, or pick up where a previous generation left off. - -**Migration:** Move the continuation to the user message, and include the final text from the interrupted response: "Your previous response was interrupted and ended with \`[previous_response]\`. Continue from where you left off." If this is part of error-handling or incomplete-response-handling and there is no UX penalty, retry the request. - -
- -
- -Prefills were used to periodically ensure refreshed or injected context. - -**Migration:** For very long conversations, inject what were previously prefilled-assistant reminders into the user turn. If context hydration is part of a more complex agentic system, consider hydrating via tools (expose or encourage use of tools containing context based on heuristics such as number of turns) or during context compaction. - -
- -## Tool use - -### Tool usage - -Claude's latest models are trained for precise instruction following and benefit from explicit direction to use specific tools. If you say "can you suggest some changes," Claude will sometimes provide suggestions rather than implementing them, even if making changes might be what you intended. - -For Claude to take action, be more explicit: - -
- -**Less effective (Claude will only suggest):** -```text -Can you suggest some changes to improve this function? -``` - -**More effective (Claude will make the changes):** -```text -Change this function to improve its performance. -``` - -Or: -```text -Make these edits to the authentication flow. -``` - -
- -To make Claude more proactive about taking action by default, you can add this to your system prompt: - -```text Sample prompt for proactive action - -By default, implement changes rather than only suggesting them. If the user's intent is unclear, infer the most useful likely action and proceed, using tools to discover any missing details instead of guessing. Try to infer the user's intent about whether a tool call (e.g., file edit or read) is intended or not, and act accordingly. - -``` - -On the other hand, if you want the model to be more hesitant by default, less prone to jumping straight into implementations, and only take action if requested, you can steer this behavior with a prompt like the below: - -```text Sample prompt for conservative action - -Do not jump into implementation or change files unless clearly instructed to make changes. When the user's intent is ambiguous, default to providing information, doing research, and providing recommendations rather than taking action. Only proceed with edits, modifications, or implementations when the user explicitly requests them. - -``` - -Claude Opus 4.5 and Claude Opus 4.6 are also more responsive to the system prompt than previous models. If your prompts were designed to reduce undertriggering on tools or skills, these models may now overtrigger. The fix is to dial back any aggressive language. Where you might have said "CRITICAL: You MUST use this tool when...", you can use more normal prompting like "Use this tool when...". - -### Optimize parallel tool calling - -Claude's latest models excel at parallel tool execution. These models will: - -- Run multiple speculative searches during research -- Read several files at once to build context faster -- Execute bash commands in parallel (which can even bottleneck system performance) - -This behavior is easily steerable. While the model has a high success rate in parallel tool calling without prompting, you can boost this to ~100% or adjust the aggression level: - -```text Sample prompt for maximum parallel efficiency - -If you intend to call multiple tools and there are no dependencies between the tool calls, make all of the independent tool calls in parallel. Prioritize calling tools simultaneously whenever the actions can be done in parallel rather than sequentially. For example, when reading 3 files, run 3 tool calls in parallel to read all 3 files into context at the same time. Maximize use of parallel tool calls where possible to increase speed and efficiency. However, if some tool calls depend on previous calls to inform dependent values like the parameters, do NOT call these tools in parallel and instead call them sequentially. Never use placeholders or guess missing parameters in tool calls. - -``` - -```text Sample prompt to reduce parallel execution -Execute operations sequentially with brief pauses between each step to ensure stability. -``` - -## Thinking and reasoning - -### Overthinking and excessive thoroughness - -Claude Opus 4.6 does significantly more upfront exploration than previous models, especially at higher `effort` settings. This initial work often helps to optimize the final results, but the model may gather extensive context or pursue multiple threads of research without being prompted. If your prompts previously encouraged the model to be more thorough, you should tune that guidance for Claude Opus 4.6: - -- **Replace blanket defaults with more targeted instructions.** Instead of "Default to using \[tool\]," add guidance like "Use \[tool\] when it would enhance your understanding of the problem." -- **Remove over-prompting.** Tools that undertriggered in previous models are likely to trigger appropriately now. Instructions like "If in doubt, use \[tool\]" will cause overtriggering. -- **Use effort as a fallback.** If Claude continues to be overly aggressive, use a lower setting for `effort`. - -In some cases, Claude Opus 4.6 may think extensively, which can inflate thinking tokens and slow down responses. If this behavior is undesirable, you can add explicit instructions to constrain its reasoning, or you can lower the `effort` setting to reduce overall thinking and token usage. - -```text Sample prompt -When you're deciding how to approach a problem, choose an approach and commit to it. Avoid revisiting decisions unless you encounter new information that directly contradicts your reasoning. If you're weighing two approaches, pick one and see it through. You can always course-correct later if the chosen approach fails. -``` - -If you need a hard ceiling on thinking costs, extended thinking with a `budget_tokens` cap is still functional on Opus 4.6 and Sonnet 4.6 but is deprecated. Prefer lowering the [effort](/docs/en/build-with-claude/effort) setting or using `max_tokens` as a hard limit with [adaptive thinking](/docs/en/build-with-claude/adaptive-thinking). - -### Leverage thinking & interleaved thinking capabilities - -Claude's latest models offer thinking capabilities that can be especially helpful for tasks involving reflection after tool use or complex multi-step reasoning. You can guide its initial or interleaved thinking for better results. - -Claude Opus 4.6 and Claude Sonnet 4.6 use [adaptive thinking](/docs/en/build-with-claude/adaptive-thinking) (`thinking: {type: "adaptive"}`), where Claude dynamically decides when and how much to think. Claude calibrates its thinking based on two factors: the `effort` parameter and query complexity. Higher effort elicits more thinking, and more complex queries do the same. On easier queries that don't require thinking, the model responds directly. In internal evaluations, adaptive thinking reliably drives better performance than extended thinking. Consider moving to adaptive thinking to get the most intelligent responses. - -Use adaptive thinking for workloads that require agentic behavior such as multi-step tool use, complex coding tasks, and long-horizon agent loops. Older models use manual thinking mode with `budget_tokens`. - -You can guide Claude's thinking behavior: - -```text Example prompt -After receiving tool results, carefully reflect on their quality and determine optimal next steps before proceeding. Use your thinking to plan and iterate based on this new information, and then take the best next action. -``` - -The triggering behavior for adaptive thinking is promptable. If you find the model thinking more often than you'd like, which can happen with large or complex system prompts, add guidance to steer it: - -```text Sample prompt -Extended thinking adds latency and should only be used when it will meaningfully improve answer quality - typically for problems that require multi-step reasoning. When in doubt, respond directly. -``` - -If you are migrating from [extended thinking](/docs/en/build-with-claude/extended-thinking) with `budget_tokens`, replace your thinking configuration and move budget control to `effort`: - -**Before (extended thinking, older models):** - -```python Python nocheck -client.messages.create( - model="claude-sonnet-4-5-20250929", - max_tokens=64000, - thinking={"type": "enabled", "budget_tokens": 32000}, - messages=[{"role": "user", "content": "..."}], -) -``` - -**After (adaptive thinking):** - -```python Python nocheck -client.messages.create( - model="claude-opus-4-8", - max_tokens=64000, - thinking={"type": "adaptive"}, - output_config={"effort": "high"}, # or "max", "xhigh", "medium", "low" - messages=[{"role": "user", "content": "..."}], -) -``` - -If you are not using extended thinking, no changes are required. Thinking is off by default when you omit the `thinking` parameter. - -- **Prefer general instructions over prescriptive steps.** A prompt like "think thoroughly" often produces better reasoning than a hand-written step-by-step plan. Claude's reasoning frequently exceeds what a human would prescribe. -- **Multishot examples work with thinking.** Use `` tags inside your few-shot examples to show Claude the reasoning pattern. It will generalize that style to its own extended thinking blocks. -- **Manual CoT as a fallback.** When thinking is off, you can still encourage step-by-step reasoning by asking Claude to think through the problem. Use structured tags like `` and `` to cleanly separate reasoning from the final output. -- **Ask Claude to self-check.** Append something like "Before you finish, verify your answer against [test criteria]." This catches errors reliably, especially for coding and math. - -When extended thinking is disabled, Claude Opus 4.5 is particularly sensitive to the word "think" and its variants. Consider using alternatives like "consider," "evaluate," or "reason through" in those cases. - - - For more information on thinking capabilities, see [Extended thinking](/docs/en/build-with-claude/extended-thinking) and [Adaptive thinking](/docs/en/build-with-claude/adaptive-thinking). - - -## Agentic systems - -### Long-horizon reasoning and state tracking - -Claude's latest models excel at long-horizon reasoning tasks with exceptional state tracking capabilities. Claude maintains orientation across extended sessions by focusing on incremental progress, making steady advances on a few things at a time rather than attempting everything at once. This capability especially emerges over multiple context windows or task iterations, where Claude can work on a complex task, save the state, and continue with a fresh context window. - -#### Context awareness and multi-window workflows - -Claude 4.6 and Claude 4.5 models feature [context awareness](/docs/en/build-with-claude/context-windows#context-awareness-in-claude-sonnet-4-6-sonnet-4-5-and-haiku-4-5), enabling the model to track its remaining context window (i.e. "token budget") throughout a conversation. This enables Claude to execute tasks and manage context more effectively by understanding how much space it has to work. - -**Managing context limits:** - -If you are using Claude in an agent harness that compacts context or allows saving context to external files (like in Claude Code), consider adding this information to your prompt so Claude can behave accordingly. Otherwise, Claude may sometimes naturally try to wrap up work as it approaches the context limit. Below is an example prompt: - -```text Sample prompt -Your context window will be automatically compacted as it approaches its limit, allowing you to continue working indefinitely from where you left off. Therefore, do not stop tasks early due to token budget concerns. As you approach your token budget limit, save your current progress and state to memory before the context window refreshes. Always be as persistent and autonomous as possible and complete tasks fully, even if the end of your budget is approaching. Never artificially stop any task early regardless of the context remaining. -``` - -The [memory tool](/docs/en/agents-and-tools/tool-use/memory-tool) pairs naturally with context awareness for seamless context transitions. - -#### Multi-context window workflows - -For tasks spanning multiple context windows: - -1. **Use a different prompt for the very first context window:** Use the first context window to set up a framework (write tests, create setup scripts), then use future context windows to iterate on a todo-list. - -2. **Have the model write tests in a structured format:** Ask Claude to create tests before starting work and keep track of them in a structured format (e.g., `tests.json`). This leads to better long-term ability to iterate. Remind Claude of the importance of tests: "It is unacceptable to remove or edit tests because this could lead to missing or buggy functionality." - -3. **Set up quality of life tools:** Encourage Claude to create setup scripts (e.g., `init.sh`) to gracefully start servers, run test suites, and linters. This prevents repeated work when continuing from a fresh context window. - -4. **Starting fresh vs compacting:** When a context window is cleared, consider starting with a brand new context window rather than using compaction. Claude's latest models are extremely effective at discovering state from the local filesystem. In some cases, you may want to take advantage of this over compaction. Be prescriptive about how it should start: - - "Call pwd; you can only read and write files in this directory." - - "Review progress.txt, tests.json, and the git logs." - - "Manually run through a fundamental integration test before moving on to implementing new features." - -5. **Provide verification tools:** As the length of autonomous tasks grows, Claude needs to verify correctness without continuous human feedback. Tools like Playwright MCP server or computer use capabilities for testing UIs are helpful. - -6. **Encourage complete usage of context:** Prompt Claude to efficiently complete components before moving on: - -```text Sample prompt -This is a very long task, so it may be beneficial to plan out your work clearly. It's encouraged to spend your entire output context working on the task - just make sure you don't run out of context with significant uncommitted work. Continue working systematically until you have completed this task. -``` - -#### State management best practices - -- **Use structured formats for state data:** When tracking structured information (like test results or task status), use JSON or other structured formats to help Claude understand schema requirements -- **Use unstructured text for progress notes:** Freeform progress notes work well for tracking general progress and context -- **Use git for state tracking:** Git provides a log of what's been done and checkpoints that can be restored. Claude's latest models perform especially well in using git to track state across multiple sessions. -- **Emphasize incremental progress:** Explicitly ask Claude to keep track of its progress and focus on incremental work - -
- -```json -// Structured state file (tests.json) -{ - "tests": [ - { "id": 1, "name": "authentication_flow", "status": "passing" }, - { "id": 2, "name": "user_management", "status": "failing" }, - { "id": 3, "name": "api_endpoints", "status": "not_started" } - ], - "total": 200, - "passing": 150, - "failing": 25, - "not_started": 25 -} -``` - -```text -// Progress notes (progress.txt) -Session 3 progress: -- Fixed authentication token validation -- Updated user model to handle edge cases -- Next: investigate user_management test failures (test #2) -- Note: Do not remove tests as this could lead to missing functionality -``` - -
- -### Balancing autonomy and safety - -Without guidance, Claude Opus 4.6 may take actions that are difficult to reverse or affect shared systems, such as deleting files, force-pushing, or posting to external services. If you want Claude Opus 4.6 to confirm before taking potentially risky actions, add guidance to your prompt: - -```text Sample prompt -Consider the reversibility and potential impact of your actions. You are encouraged to take local, reversible actions like editing files or running tests, but for actions that are hard to reverse, affect shared systems, or could be destructive, ask the user before proceeding. - -Examples of actions that warrant confirmation: -- Destructive operations: deleting files or branches, dropping database tables, rm -rf -- Hard to reverse operations: git push --force, git reset --hard, amending published commits -- Operations visible to others: pushing code, commenting on PRs/issues, sending messages, modifying shared infrastructure - -When encountering obstacles, do not use destructive actions as a shortcut. For example, don't bypass safety checks (e.g. --no-verify) or discard unfamiliar files that may be in-progress work. -``` - -### Research and information gathering - -Claude's latest models demonstrate exceptional agentic search capabilities and can find and synthesize information from multiple sources effectively. For optimal research results: - -1. **Provide clear success criteria:** Define what constitutes a successful answer to your research question - -2. **Encourage source verification:** Ask Claude to verify information across multiple sources - -3. **For complex research tasks, use a structured approach:** - -```text Sample prompt for complex research -Search for this information in a structured way. As you gather data, develop several competing hypotheses. Track your confidence levels in your progress notes to improve calibration. Regularly self-critique your approach and plan. Update a hypothesis tree or research notes file to persist information and provide transparency. Break down this complex research task systematically. -``` - -This structured approach allows Claude to find and synthesize virtually any piece of information and iteratively critique its findings, no matter the size of the corpus. - -### Subagent orchestration - -Claude's latest models demonstrate significantly improved native subagent orchestration capabilities. These models can recognize when tasks would benefit from delegating work to specialized subagents and do so proactively without requiring explicit instruction. - -To take advantage of this behavior: - -1. **Ensure well-defined subagent tools:** Have subagent tools available and described in tool definitions -2. **Let Claude orchestrate naturally:** Claude will delegate appropriately without explicit instruction -3. **Watch for overuse:** Claude Opus 4.6 has a strong predilection for subagents and may spawn them in situations where a simpler, direct approach would suffice. For example, the model may spawn subagents for code exploration when a direct grep call is faster and sufficient. - -If you're seeing excessive subagent use, add explicit guidance about when subagents are and aren't warranted: - -```text Sample prompt for subagent usage -Use subagents when tasks can run in parallel, require isolated context, or involve independent workstreams that don't need to share state. For simple tasks, sequential operations, single-file edits, or tasks where you need to maintain context across steps, work directly rather than delegating. -``` - -### Chain complex prompts - -With adaptive thinking and subagent orchestration, Claude handles most multi-step reasoning internally. Explicit prompt chaining (breaking a task into sequential API calls) is still useful when you need to inspect intermediate outputs or enforce a specific pipeline structure. - -The most common chaining pattern is **self-correction:** generate a draft → have Claude review it against criteria → have Claude refine based on the review. Each step is a separate API call so you can log, evaluate, or branch at any point. - -### Reduce file creation in agentic coding - -Claude's latest models may sometimes create new files for testing and iteration purposes, particularly when working with code. This approach allows Claude to use files, especially python scripts, as a 'temporary scratchpad' before saving its final output. Using temporary files can improve outcomes particularly for agentic coding use cases. - -If you'd prefer to minimize net new file creation, you can instruct Claude to clean up after itself: - -```text Sample prompt -If you create any temporary new files, scripts, or helper files for iteration, clean up these files by removing them at the end of the task. -``` - -### Overeagerness - -Claude Opus 4.5 and Claude Opus 4.6 have a tendency to overengineer by creating extra files, adding unnecessary abstractions, or building in flexibility that wasn't requested. If you're seeing this undesired behavior, add specific guidance to keep solutions minimal. - -For example: - -```text Sample prompt to minimize overengineering -Avoid over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused: - -- Scope: Don't add features, refactor code, or make "improvements" beyond what was asked. A bug fix doesn't need surrounding code cleaned up. A simple feature doesn't need extra configurability. - -- Documentation: Don't add docstrings, comments, or type annotations to code you didn't change. Only add comments where the logic isn't self-evident. - -- Defensive coding: Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). - -- Abstractions: Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task. -``` - -### Avoid focusing on passing tests and hard-coding - -Claude can sometimes focus too heavily on making tests pass at the expense of more general solutions, or may use workarounds like helper scripts for complex refactoring instead of using standard tools directly. To prevent this behavior and ensure robust, generalizable solutions: - -```text Sample prompt -Please write a high-quality, general-purpose solution using the standard tools available. Do not create helper scripts or workarounds to accomplish the task more efficiently. Implement a solution that works correctly for all valid inputs, not just the test cases. Do not hard-code values or create solutions that only work for specific test inputs. Instead, implement the actual logic that solves the problem generally. - -Focus on understanding the problem requirements and implementing the correct algorithm. Tests are there to verify correctness, not to define the solution. Provide a principled implementation that follows best practices and software design principles. - -If the task is unreasonable or infeasible, or if any of the tests are incorrect, please inform me rather than working around them. The solution should be robust, maintainable, and extendable. -``` - -### Minimizing hallucinations in agentic coding - -Claude's latest models are less prone to hallucinations and give more accurate, grounded, intelligent answers based on the code. To encourage this behavior even more and minimize hallucinations: - -```text Sample prompt - -Never speculate about code you have not opened. If the user references a specific file, you MUST read the file before answering. Make sure to investigate and read relevant files BEFORE answering questions about the codebase. Never make any claims about code before investigating unless you are certain of the correct answer - give grounded and hallucination-free answers. - -``` - -## Capability-specific tips - -### Improved vision capabilities - -Claude Opus 4.5 and Claude Opus 4.6 have improved vision capabilities compared to previous Claude models. They perform better on image processing and data extraction tasks, particularly when there are multiple images present in context. These improvements carry over to computer use, where the models can more reliably interpret screenshots and UI elements. You can also use these models to analyze videos by breaking them up into frames. - -One technique that has proven effective to further boost performance is to give Claude a crop tool or [skill](/docs/en/agents-and-tools/agent-skills/overview). Testing has shown consistent uplift on image evaluations when Claude is able to "zoom" in on relevant regions of an image. Anthropic has created a [cookbook for the crop tool](https://platform.claude.com/cookbook/multimodal-crop-tool). - -### Frontend design - -Claude Opus 4.5 and Claude Opus 4.6 excel at building complex, real-world web applications with strong frontend design. However, without guidance, models can default to generic patterns that create what users call the "AI slop" aesthetic. To create distinctive, creative frontends that surprise and delight: - - -For a detailed guide on improving frontend design, see the blog post on [improving frontend design through skills](https://www.claude.com/blog/improving-frontend-design-through-skills). - - -Here's a system prompt snippet you can use to encourage better frontend design: - -```text Sample prompt for frontend aesthetics - -You tend to converge toward generic, "on distribution" outputs. In frontend design, this creates what users call the "AI slop" aesthetic. Avoid this: make creative, distinctive frontends that surprise and delight. - -Focus on: -- Typography: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics. -- Color & Theme: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Draw from IDE themes and cultural aesthetics for inspiration. -- Motion: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. -- Backgrounds: Create atmosphere and depth rather than defaulting to solid colors. Layer CSS gradients, use geometric patterns, or add contextual effects that match the overall aesthetic. - -Avoid generic AI-generated aesthetics: -- Overused font families (Inter, Roboto, Arial, system fonts) -- Clichéd color schemes (particularly purple gradients on white backgrounds) -- Predictable layouts and component patterns -- Cookie-cutter design that lacks context-specific character - -Interpret creatively and make unexpected choices that feel genuinely designed for the context. Vary between light and dark themes, different fonts, different aesthetics. You still tend to converge on common choices (Space Grotesk, for example) across generations. Avoid this: it is critical that you think outside the box! - -``` - -You can also refer to the [full skill definition](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md). - -## Migration considerations - -When migrating to Claude 4.6 models from earlier generations: - -1. **Be specific about desired behavior:** Consider describing exactly what you'd like to see in the output. - -2. **Frame your instructions with modifiers:** Adding modifiers that encourage Claude to increase the quality and detail of its output can help better shape Claude's performance. For example, instead of "Create an analytics dashboard", use "Create an analytics dashboard. Include as many relevant features and interactions as possible. Go beyond the basics to create a fully-featured implementation." - -3. **Request specific features explicitly:** Animations and interactive elements should be requested explicitly when desired. - -4. **Update thinking configuration:** Claude 4.6 models use [adaptive thinking](/docs/en/build-with-claude/adaptive-thinking) (`thinking: {type: "adaptive"}`) instead of manual thinking with `budget_tokens`. Use the [effort parameter](/docs/en/build-with-claude/effort) to control thinking depth. - -5. **Migrate away from prefilled responses:** Prefilled responses on the last assistant turn are no longer supported starting with Claude 4.6 models. See [Migrating away from prefilled responses](#migrating-away-from-prefilled-responses) for detailed guidance on alternatives. - -6. **Tune anti-laziness prompting:** If your prompts previously encouraged the model to be more thorough or use tools more aggressively, dial back that guidance. Claude 4.6 models are significantly more proactive and may overtrigger on instructions that were needed for previous models. - -For detailed migration steps, see the [Migration guide](/docs/en/about-claude/models/migration-guide). - -### Migrating from Claude Sonnet 4.5 to Claude Sonnet 4.6 - -Claude Sonnet 4.6 defaults to an effort level of `high`, in contrast to Claude Sonnet 4.5 which had no effort parameter. Consider adjusting the effort parameter as you migrate from Claude Sonnet 4.5 to Claude Sonnet 4.6. If not explicitly set, you may experience higher latency with the default effort level. - -**Recommended effort settings:** -- **Medium** for most applications -- **Low** for high-volume or latency-sensitive workloads -- Set a large max output token budget (64k tokens recommended) at medium or high effort to give the model room to think and act - -**When to use Opus 4.8 instead:** For the hardest, longest-horizon problems (large-scale code migrations, deep research, extended autonomous work), Opus 4.8 remains the right choice. Sonnet 4.6 is optimized for workloads where fast turnaround and cost efficiency matter most. - -#### If you're not using extended thinking - -If you're not using extended thinking on Claude Sonnet 4.5, you can continue without it on Claude Sonnet 4.6. You should explicitly set effort to the level appropriate for your use case. At `low` effort with thinking disabled, you can expect similar or better performance relative to Claude Sonnet 4.5 with no extended thinking. - -```python Python -client.messages.create( - model="claude-sonnet-4-6", - max_tokens=8192, - thinking={"type": "disabled"}, - output_config={"effort": "low"}, - messages=[{"role": "user", "content": "..."}], -) -``` - -#### If you're using extended thinking - -If you're using extended thinking with `budget_tokens` on Claude Sonnet 4.5, it is still functional on Claude Sonnet 4.6 but is deprecated. Migrate to [adaptive thinking](/docs/en/build-with-claude/adaptive-thinking) with the [effort parameter](/docs/en/build-with-claude/effort). - -##### Migrating to adaptive thinking - -Adaptive thinking is particularly well suited to the following workload patterns: - -- **Autonomous multi-step agents:** coding agents that turn requirements into working software, data analysis pipelines, and bug finding where the model runs independently across many steps. Adaptive thinking lets the model calibrate its reasoning per step, staying on path over longer trajectories. For these workloads, start at `high` effort. If latency or token usage is a concern, scale down to `medium`. -- **Computer use agents:** Claude Sonnet 4.6 achieved best-in-class accuracy on computer use evaluations using adaptive mode. -- **Bimodal workloads:** a mix of easy and hard tasks where adaptive skips thinking on simple queries and reasons deeply on complex ones. - -When using adaptive thinking, evaluate `medium` and `high` effort on your tasks. The right level depends on your workload's tradeoff between quality, latency, and token usage. - -```python Python nocheck -client.messages.create( - model="claude-sonnet-4-6", - max_tokens=64000, - thinking={"type": "adaptive"}, - output_config={"effort": "high"}, - messages=[{"role": "user", "content": "..."}], -) -``` - -##### Keeping budget_tokens during migration - -If you need to keep `budget_tokens` temporarily while migrating, a budget around 16k tokens provides headroom for harder problems without risk of runaway token usage. This configuration is deprecated and will be removed in a future model release. - -**For coding use cases** (agentic coding, tool-heavy workflows, code generation), start with `medium` effort: - -```python Python nocheck -client.messages.create( - model="claude-sonnet-4-6", - max_tokens=16384, - thinking={"type": "enabled", "budget_tokens": 16384}, - output_config={"effort": "medium"}, - messages=[{"role": "user", "content": "..."}], -) -``` - -**For chat and non-coding use cases** (chat, content generation, search, classification), start with `low` effort: - -```python Python nocheck -client.messages.create( - model="claude-sonnet-4-6", - max_tokens=8192, - thinking={"type": "enabled", "budget_tokens": 16384}, - output_config={"effort": "low"}, - messages=[{"role": "user", "content": "..."}], -) -``` \ No newline at end of file diff --git a/.gitignore b/.gitignore index e3c23ae..80e9989 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Secrets — never commit (phase0/.env holds the Story wallet private key) .env +.env.* *.env !.env.example @@ -11,3 +12,17 @@ out/ # Local machine state .DS_Store .claude/settings.local.json + +# Run artifacts (belt-and-braces; spikes also ignore locally) +runs/ +*.jsonl + +# Vendor doc snapshots — do not track (redistribution-unsafe) +.archive/ + +# Locally installed third-party skills — track only our own +.claude/skills/* +!.claude/skills/optimizing-claude-code-prompts/ +.agents/skills/* +!.agents/skills/optimizing-claude-code-prompts/ +skills-lock.json diff --git a/docs/feasibility/findings.json b/docs/feasibility/findings.json index cd4ce00..b2271c6 100644 --- a/docs/feasibility/findings.json +++ b/docs/feasibility/findings.json @@ -462,7 +462,7 @@ }, { "source": "Local design docs", - "url": "/Users/antonyzaki/Documents/Repo/tokenized-assets/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md", + "url": "docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md", "note": "ADR 0002/0003 + CONTEXT.md define the Skill->IP Asset / per-invocation execution-credential design under test." } ], @@ -489,7 +489,7 @@ "refuted": false, "correctedVerdict": "works-with-caveats", "refutationBasis": "Could not refute the core finding. Every load-bearing technical claim was independently corroborated against authoritative sources (official docs, GitHub API, npm registry, contract source, chainid.network, CoinMarketCap/CoinGecko). The only factual defect found is a date error that runs in the finding's favor, not against it, and does not affect feasibility.\n\nCORROBORATED:\n- Homer mainnet live Feb 13 2025, chainId 1514 (chainid.network), $IP native gas, CometBFT instant finality.\n- protocol-core-v1 v1.3.2 published 2025-04-23 (GitHub API), BUSL-1.1, /audits directory present, deployment-1514.json (mainnet Homer) + deployment-1315.json (Aeneid testnet) — all confirmed.\n- Royalty Vault = exactly 100 royalty tokens = 1% each; claimAllRevenue is permissionless; payment and snapshot/claiming deliberately decoupled to cut gas on deep chains (PULL not PUSH) — confirmed on official docs.\n- On mainnet WIP (0x1514000000000000000000000000000000000000) is the ONLY whitelisted royalty currency — confirmed on the official Deployed Smart Contracts page.\n- License Token = ERC-721 carrying license terms, burned when used to register a derivative — confirmed. mintLicenseTokens is an on-chain tx that may require a minting fee and returns txHash + receipt — confirmed.\n- PILFlavor commercialRemix() and mintAndRegisterIpAssetWithPilTerms (register+create+attach in one tx) exist — confirmed.\n- LAP = whole-ancestry share, LRP = direct parents only; up to 1024 ancestors / 8 parents — confirmed via chainflow. ADDITIONAL CHECK: in protocol-core-v1 RoyaltyModule.sol these are admin-settable storage vars (maxParents / maxAncestors via setters), NOT hardcoded constants — exactly matching the finding's own stated gap.\n- $IP ~ $0.37, ~$133M market cap, ~$46M daily volume (Jun 2026) — corroborates the thin-liquidity caveat.\n\nThe adversarial CORE caveat (one on-chain License Token mint per LLM invocation, dragging an IP->WIP wrap + ERC-20 approve + block latency, mismatches sub-cent/sub-second call economics) is sound and the recommended fix (pre-mint batches or mint one durable invocation-right and settle off-chain in batches via payRoyaltyOnBehalf) is realistic and still all-Story. The finding also correctly flags as UNVERIFIED that a License Token can be repurposed as a non-burned per-call run credential — I likewise could not confirm any native non-burn single-use semantics; natively it is burned only on derivative registration. That open item is honestly disclosed, not over-claimed.", - "notes": "ONE FACTUAL ERROR FOUND (runs in the finding's favor, immaterial to verdict): The finding repeatedly dates TS SDK v1.4.4 to \"Mar 2025\" and worries about a \"slowing cadence / no newer release through mid-2026.\" Authoritative npm registry (registry.npmjs.org/@story-protocol/core-sdk) and GitHub API both show v1.4.4 was published 2026-03-02, NOT March 2025. The real release cadence was steady and continuous: v1.3.2 (2025-06-06), v1.3.3 (2025-07-11), v1.4.0 (2025-09-30), v1.4.1 (2025-10-21), v1.4.2 (2025-11-20), v1.4.3 (2026-01-31), v1.4.4 (2026-03-02). So: (a) v1.4.4 IS the current mid-2026 release, the SDK is actively maintained, and the \"15-month gap / slowing cadence\" gap in the finding is unfounded and should be deleted; (b) dist-tags latest = 1.4.4, no 1.5.x/2.x line yet (the finding's suspicion of a 1.5.x line is unconfirmed — none exists as of Jun 2026). Note the WebSearch on SDK versions initially surfaced a stale v1.3.0 reference and a WebFetch on the GitHub releases page mis-rendered years as 2024/2025 — the npm registry timestamps and GitHub API are the authoritative source and were used to settle this.\n\nNET: All five primitives (a-e) verified real, audited, and on-chain. The economic/UX caveats (pull-not-push royalties needing a keeper, WIP-only mainnet currency forcing an $IP/wrap on-ramp, 1% royalty-token granularity, depth ceilings, per-invocation minting being the core economic risk, thin $IP liquidity, no secrecy solution) are all accurate and appropriately scoped. works-with-caveats is the correct verdict; confidence high is justified.\n\nRelevant local files reviewed: /Users/antonyzaki/Documents/Repo/tokenized-assets/docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md, /docs/adr/0003-payment-gated-execution.md, /docs/adr/0004-compete-on-moats-not-secrecy.md, /CONTEXT.md — design under test matches what the finding validates.\n\nAuthoritative sources used: npm registry @story-protocol/core-sdk version/time map; GitHub API releases for storyprotocol/sdk and protocol-core-v1; docs.story.foundation (royalty-module overview, ip-royalty-vault, license-token, sdk-reference/license, deployed-smart-contracts); raw contract source RoyaltyModule.sol@v1.3.2; chainid.network/chain/1514; chainflow.io; CoinMarketCap/CoinGecko $IP price Jun 2026." + "notes": "ONE FACTUAL ERROR FOUND (runs in the finding's favor, immaterial to verdict): The finding repeatedly dates TS SDK v1.4.4 to \"Mar 2025\" and worries about a \"slowing cadence / no newer release through mid-2026.\" Authoritative npm registry (registry.npmjs.org/@story-protocol/core-sdk) and GitHub API both show v1.4.4 was published 2026-03-02, NOT March 2025. The real release cadence was steady and continuous: v1.3.2 (2025-06-06), v1.3.3 (2025-07-11), v1.4.0 (2025-09-30), v1.4.1 (2025-10-21), v1.4.2 (2025-11-20), v1.4.3 (2026-01-31), v1.4.4 (2026-03-02). So: (a) v1.4.4 IS the current mid-2026 release, the SDK is actively maintained, and the \"15-month gap / slowing cadence\" gap in the finding is unfounded and should be deleted; (b) dist-tags latest = 1.4.4, no 1.5.x/2.x line yet (the finding's suspicion of a 1.5.x line is unconfirmed — none exists as of Jun 2026). Note the WebSearch on SDK versions initially surfaced a stale v1.3.0 reference and a WebFetch on the GitHub releases page mis-rendered years as 2024/2025 — the npm registry timestamps and GitHub API are the authoritative source and were used to settle this.\n\nNET: All five primitives (a-e) verified real, audited, and on-chain. The economic/UX caveats (pull-not-push royalties needing a keeper, WIP-only mainnet currency forcing an $IP/wrap on-ramp, 1% royalty-token granularity, depth ceilings, per-invocation minting being the core economic risk, thin $IP liquidity, no secrecy solution) are all accurate and appropriately scoped. works-with-caveats is the correct verdict; confidence high is justified.\n\nRelevant local files reviewed: docs/adr/0002-tokenize-skills-as-programmable-ip-on-story.md, /docs/adr/0003-payment-gated-execution.md, /docs/adr/0004-compete-on-moats-not-secrecy.md, /CONTEXT.md — design under test matches what the finding validates.\n\nAuthoritative sources used: npm registry @story-protocol/core-sdk version/time map; GitHub API releases for storyprotocol/sdk and protocol-core-v1; docs.story.foundation (royalty-module overview, ip-royalty-vault, license-token, sdk-reference/license, deployed-smart-contracts); raw contract source RoyaltyModule.sol@v1.3.2; chainid.network/chain/1514; chainflow.io; CoinMarketCap/CoinGecko $IP price Jun 2026." } }, { diff --git a/prototype/README.md b/prototype/README.md index f36b36c..ca4e7d0 100644 --- a/prototype/README.md +++ b/prototype/README.md @@ -131,6 +131,6 @@ cold to visible output on top of a ~0.8s testnet x402 gate (see first bound, not a distribution. Reviewer caveat: the warm path assumes the events stream tails rather than replays history; no near-zero samples appeared (consistent with tailing), but the assumption is undocumented in -the API. Housekeeping: the bench created env_01ABHUjx5niRAtaqcnMUtA4c / -agent_016wddwbPwmmajbuZPZ5jHwV on the operator's account — archive/delete -when convenient (archiving is permanent). +the API. Housekeeping: each bench run creates a throwaway managed-agents +environment + agent on the operator's account — archive/delete them in the +console when convenient (archiving is permanent). From ae09fd62c56582c1aeabb362d23095263fa9afc2 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Mon, 13 Jul 2026 10:45:34 -0400 Subject: [PATCH 015/165] Add campaign kit: LinkedIn/X/HN copy + 2-week calendar, compliance-swept Five LinkedIn posts (buyer register, no crypto vocabulary), three X threads + cold-start engagement playbook + six spacers, Show HN draft with prepared answers to the five hardest questions, 57s demo-clip storyboard, launch runbook, and the day-by-day calendar (Day 0 = repo flip). All copy audited: securities-language sweep, factual overreach corrected against measured numbers, authenticity pass. Co-Authored-By: Claude Fable 5 --- docs/marketing/2026-07-13-campaign-plan.md | 132 +++++++++ docs/marketing/hn-and-demo.md | 211 ++++++++++++++ docs/marketing/linkedin.md | 203 ++++++++++++++ docs/marketing/x.md | 312 +++++++++++++++++++++ 4 files changed, 858 insertions(+) create mode 100644 docs/marketing/2026-07-13-campaign-plan.md create mode 100644 docs/marketing/hn-and-demo.md create mode 100644 docs/marketing/linkedin.md create mode 100644 docs/marketing/x.md diff --git a/docs/marketing/2026-07-13-campaign-plan.md b/docs/marketing/2026-07-13-campaign-plan.md new file mode 100644 index 0000000..8f23425 --- /dev/null +++ b/docs/marketing/2026-07-13-campaign-plan.md @@ -0,0 +1,132 @@ +# Launch campaign plan — Skill Asset Protocol / neverhandedover.com + +*Drafted 2026-07-13. This is the strategy and calendar that ties together the three content +kits. It does not rewrite any piece — it places them:* + +- `docs/marketing/linkedin.md` — LinkedIn Posts 1–5 +- `docs/marketing/x.md` — three X threads (§1–3), engagement playbook (§4), standalone spacers (§5) +- `docs/marketing/hn-and-demo.md` — Show HN draft + prepared answers (§1), demo clip script (§2), launch-day runbook and measurement plan (§3) + +*Compliance applies to every artifact and every reply, per the house rules in each kit: no +financial-upside language, and "testnet USDC (play money)" wherever the $0.25 demo appears.* + +--- + +## 1. Strategy on a page: the inverted playbook + +The standard launch playbook is: blast X for reach, submit to HN on day one, treat LinkedIn +as an afterthought. We invert all three, because our assets are inverted: + +**LinkedIn is the broadcast channel — because the network is real and the buyer lives there.** +The ICP is VP Eng / Head of Platform at 100–800-person AI-forward firms, co-read by Head of +People and the CFO. That audience is in Antony's existing LinkedIn network, not following a +cold X account. So LinkedIn gets the full five-post series (`linkedin.md`), starting Day 0, +and it carries the only direct ask in the campaign: the design-partner ask in Post 3. + +**X is a cold account that has to buy credibility with receipts before it broadcasts.** +Near-zero followers means threads posted on Day 0 land in a void. Instead the account spends +~a week living in other people's replies — x402, Base, Story Protocol, Claude Code, and +agent-payments conversations — answering real questions with measured numbers, per the +playbook in `x.md` §4. Only after that track record exists does the launch thread ship +(Day 7). Distribution on X comes from ecosystem accounts and builders quoting the work, and +they only quote names they've seen show up usefully. + +**Show HN is the neutral-ground, high-leverage event — deliberately not Day 0.** +HN is where the claims get stress-tested by strangers with no reason to be kind, and where +the honesty ledger (kill-criteria, "What we have NOT validated", the failed clone attack) is +worth the most. We go there only after the demo is battle-tested: a week of live paid +invocations, the fresh-machine clone-and-run verified again the night before, and the five +prepared answers (`hn-and-demo.md` §1) open in a tab. HN lands Day 9. + +**The funnel.** Each channel feeds the next: LinkedIn starts buyer conversations → the X +warm-up recruits the ecosystem amplifiers who will carry the launch thread → HN stress-tests +the claims and sends technical readers to the repo and the live endpoint. Everything +terminates at the same two artifacts: neverhandedover.com and the Apache-2.0 repo. + +**North-star metrics, in strict order** (measurement mechanics in `hn-and-demo.md` §3): + +1. **Design-partner conversations started** — the only path to the thing we have NOT + validated. Kill-criterion 1 hangs on this. One real conversation outranks everything + below combined. +2. **On-chain demo invocations** (count + unique payers, from Base Sepolia receipts) — + someone did the thing, not viewed the thing. +3. **Repo stars / forks / clones** — developer intent. +4. Everything else. Impressions, likes, follower counts, and HN points are explicitly not + success metrics and are not tracked as such. + +**Priority override:** a live design-partner conversation outranks any scheduled post. The +calendar slips before a conversation does. + +--- + +## 2. Two-week calendar + +Day 0 = **Tuesday 2026-07-14**: repo flip + soft launch. "Soft" means: repo public, site +verified live, LinkedIn Post 1 out — no X thread, no HN. + +**Standing daily items (every non-rest day, not repeated in the table):** + +- **X reply routine, 30–45 min** — the daily engagement routine from `x.md` §4: saved + searches, 3–5 conversations, replies with a receipt, never a pitch. This runs every working + day of the campaign, before and after the X launch thread. +- **Metrics log** — append the day's numbers to the table in `hn-and-demo.md` §3 + (invocations, unique payers, stars/forks/clones, conversations started, unanswerable + critiques). +- **LinkedIn comment tending** — reply to substantive comments on whichever posts are live. + +| Day | Date | Actions | +|---|---|---| +| **−1** | Mon 07-13 (today) | Pre-flight checklist from `hn-and-demo.md` §3: LICENSE, README offline-e2e story, secrets scan, fresh-machine clone-and-run with zero keys/funds, live 402 check, both domains serving, compliance pass on every queued post. If the fresh-machine test fails, Day 0 slips — nothing else changes. | +| **0** | Tue 07-14 | **Repo public first** (runbook step 1), verify clone-and-run from a logged-out browser. **LinkedIn Post 1** ("Never handed over", `linkedin.md`) at ~8:30am ET, link in first comment, pin to profile. X: replies only. Log day-0 measurements. | +| **1** | Wed 07-15 | X build-in-public artifact #1: screenshot of the raw HTTP 402 response (`x.md` §4, week-2 list). Single tweet, no thread. | +| **2** | Thu 07-16 | **LinkedIn Post 2** (clone story, "$1.58 to steal my own product") — 2 days after Post 1, mid-week per its posting context. X artifact #2: the ledger line, reconciled on-chain to the cent (testnet, play money). | +| **3** | Fri 07-17 | X artifact #3: the "What we have NOT validated" page, screenshotted. Light day otherwise. | +| **4** | Sat 07-18 | **Rest day.** Nothing posted anywhere. No replies. | +| **5** | Sun 07-19 | **Rest day** (light): optional 15 min of X replies if a good conversation is live; otherwise nothing. | +| **6** | Mon 07-20 | X artifact #4: the kill-criteria arithmetic that killed education mode. **Record the final demo clip** per the script in `hn-and-demo.md` §2 (one unbroken terminal take, real latency left in, testnet/play-money captions) — recorded now, after a week of live traffic, so the clip shows the battle-tested system. Verify the basescan links in it resolve. Prep the ecosystem tag reply; verify every org @handle by hand (`x.md` §4 tag strategy). | +| **7** | Tue 07-21 | **LinkedIn Post 3** (retention / the design-partner ask) at ~8:30am ET, then reshare to specific VP Eng / Head of People contacts with one-line personal notes per its posting context. **X launch thread** (`x.md` §1) late morning, demo clip attached to tweet 1 (per runbook step 3), pin it, ecosystem tag reply as the first reply. Hours 1–3: live in the X replies — this window decides whether the thread travels. | +| **8** | Wed 07-22 | X spacer: the ~150-line Wielder proxy code screenshot (`x.md` §4 artifact list) — a natural bridge to tomorrow's technical thread. Evening: **HN pre-flight** — re-run the fresh-machine test, re-verify the live 402 and a paid invocation, open the five prepared answers (`hn-and-demo.md` §1) in a tab. | +| **9** | Thu 07-23 | **LinkedIn Post 5** (build story — the technical post HN readers will arrive at) at ~8:00am ET, cross-linked from the repo README. **Show HN** at 8:30–10:00am ET: submit neverhandedover.com with title 1, §1 text as immediate first comment. Responding cadence per runbook step 5 (hour 1: every substantive comment within ~15 min; then 30-min sweeps; then hourly). **X how-it-works thread** (`x.md` §3) mid-morning — written for the same technical crowd, 48h after the launch thread, quoting the pinned thread from its last tweet. End of day: link the best critical HN thread from the site/X ("the hardest question we got today"). | +| **10** | Fri 07-24 | HN aftercare: hourly sweeps while the thread is warm; log any critique we couldn't answer as a corpus defect. X spacer: one standalone from `x.md` §5 (suggest D — "Output crosses the wire. The skill never does."). | +| **11** | Sat 07-25 | **Rest day.** Nothing posted. | +| **12** | Sun 07-26 | **Rest day** (light): optional HN/X reply sweep only if threads are still live. | +| **13** | Mon 07-27 | **X clone-attack thread** (`x.md` §2) — pushed past the weekend to keep the ≥48h thread spacing; it can reference the HN discussion if cloning came up there. **LinkedIn Post 4** (kill-criteria / honesty post) — its context says week 2–3 mid-week and it's the let-it-sit-and-compound post, so sliding it to Wed 07-29 is equally fine. **Day-7-after-launch review:** count conversations that could become a design-partner LOI — the one derived number that matters (`hn-and-demo.md` §3). Remaining `x.md` §5 spacers (A, B, C, E, F) feed week 3+ as-needed. | + +Reconciliation note: `hn-and-demo.md` §3 sequences repo → LinkedIn → X thread → HN as one +list. This plan keeps that ordering but stretches it across Days 0–9, per the inverted +playbook: steps 1–2 happen Day 0, step 3 (X thread) waits for the account's week of replies +(Day 7), steps 4–6 are HN day (Day 9). Within each day, the runbook's mechanics apply +unchanged. + +--- + +## 3. Risk notes + +**If a post flops: do nothing.** Cadence continues exactly as scheduled. No deleting, no +reposting, no "in case you missed it", no paying to boost. A single post is a sample of one, +and the metrics that matter (§1) are counted at day 7, not at hour 2. The one exception is a +factual error in a live post — correct it in a reply immediately, never silently. + +**If HN turns hostile: engage the top critique honestly, never defensively.** Find the +highest-voted critical comment and answer it first, conceding whatever is valid in the first +sentence — the corpus was built for this (kill-criteria, the not-validated ledger, the failed +clone attack). Use the five prepared answers in `hn-and-demo.md` §1 as the base; for the +known secondary flak (GPT Store, x402-volume-is-bots, "Anthropic will ship this"), use the +one-line stances at the end of that section. Reply to content, never to tone; do not chase +every commenter; never ask for votes. If the thread dies anyway, the end-of-day job stands: +log every critique we couldn't answer as a corpus defect to fix. A hostile thread that +surfaces a real hole is the measurement plan working. + +**If a securities-adjacent question appears (any channel): use the prepared answer, never +improvise.** The full answer is Q1 in `hn-and-demo.md` §1 — deploy it verbatim on HN, or +compressed to its load-bearing points elsewhere: v1 claims are non-transferable by design +(no resale, no secondary market), structured as a deferred-comp / license-fee instrument; we +rate that route medium confidence, not a safe harbor, and a published kill-criterion covers +counsel failing to draft the actual instrument; the live demo is testnet play money and +nothing is being offered to anyone. Do not debate Howey ad hoc, do not soften the concession +(the answer opens with "mostly agreed" for a reason), and do not let the banned vocabulary +in — not even inside a quotation or rebuttal. If the question arrives in a DM or a +design-partner conversation, same answer, plus a pointer to the kill-criteria doc in the repo. + +**Standing override, restated:** if any of this competes with a live design-partner +conversation, the conversation wins. That is metric #1; the calendar exists to produce it. diff --git a/docs/marketing/hn-and-demo.md b/docs/marketing/hn-and-demo.md new file mode 100644 index 0000000..1a2cf08 --- /dev/null +++ b/docs/marketing/hn-and-demo.md @@ -0,0 +1,211 @@ +# HN launch, demo clip, and launch-day runbook + +*Drafted 2026-07-13. Voice: Antony posting personally. Every number below is from the corpus +(`docs/PRD.md`, `docs/plans/2026-07-12-phase-a-findings.md`, Phase-A measurements on Base +Sepolia, 2026-07-12). Compliance rule for every artifact in this file: the demo is testnet +play money and is described as such anywhere a reader might act on it. No financial-upside +language anywhere.* + +--- + +## 1. Show HN draft + +### Title options (pick one; all under 80 chars) + +1. `Show HN: A manifesto that is also a paid API endpoint (HTTP 402)` +2. `Show HN: We paid $1.58 to clone our own AI skill. It failed all 6 gates` +3. `Show HN: Metering AI skills per invocation instead of handing them over` + +Recommendation: title 1. It describes the artifact, not the thesis, and the artifact is the +novel thing. Title 2 is the fallback if a second submission is ever warranted — it leads with +the result that is most likely to survive HN scrutiny, because it is us attacking ourselves. + +### Post text (submit as a text post with the URL, or as first comment — ~230 words) + +> https://neverhandedover.com is a manifesto that is literally a paid endpoint. POST to it +> without payment and you get HTTP 402. Pay $0.25 in testnet USDC (play money, Base Sepolia) +> and the hosted skill runs and streams you output. You never get the skill itself. +> +> The thesis: authored AI skills (Claude Code skills, plugins, agent definitions) are work +> artifacts, and work-for-hire's default split is 100/0 — employer gets everything, author +> gets salary. This is a compensation and attribution layer that meters use per invocation +> and splits the metered revenue to a co-held claim. Carta for AI work artifacts. The +> marketplace angle is future optionality, not the product. +> +> What we measured (testnet, 2026-07-12): one wallet paid per model call AND per skill +> invocation over x402. Ledger: claude/plan $0.041, skill $0.25 → creator $0.24375 / +> treasury $0.00625, reconciled against on-chain balances to the cent. Payment overhead +> ~781ms/call; hosted-agent cold start ~2.5s to first token. +> +> What we tried to break: we paid $1.58 to distill a clone of our own skill from its +> outputs (distillation itself cost $0.03). The clone failed all 6 held-out fidelity gates — +> but N=6, high-N behavior unknown. Modeled break-even if a clone ever passes: 8 invocations. +> Cost protects nothing. +> +> What is unproven: that employers will buy this. We published our kill-criteria and killed +> our own education mode with arithmetic. +> +> Code (Apache-2.0): https://github.com/Aznatkoiny/skill-asset-protocol — the end-to-end +> demo runs offline with zero API keys and zero funds. + +Notes on register: no adjectives doing sales work, every claim has a number, and the two +weakest points (N=6, no demand evidence) are volunteered before anyone finds them. On HN +the honesty ledger IS the pitch. + +### First-hour comment strategy: the 5 hardest questions, with prepared answers + +Post these as replies, verbatim or trimmed. Never argue tone; concede fast and link the +"What we have NOT validated" section. + +**Q1. "A claim on a revenue stream — you've built something securities law exists for."** + +> Mostly agreed, and it shaped the whole build order. A *transferable* fractional claim on a +> revenue stream sits squarely in securities-law territory under Howey — the Ninth Circuit's +> SEC v. Barry (2025) is on point, and uncomfortably, our own live-evolution defense +> *strengthens* the "efforts of others" prong rather than weakening it. That is why v1 claims +> are non-transferable by design: no resale, no secondary market, structured as a +> deferred-comp / license-fee instrument. We rate that route medium confidence, not a safe +> harbor — no source squarely analyzes our exact fact pattern — and one of our published +> kill-criteria is that if counsel cannot draft the actual instrument (surviving 409A, +> specifying vesting and what happens when the employee quits), the sequencing gets reworked. +> The live demo is testnet play money; nothing is being offered to anyone. + +**Q2. "Why blockchain at all? Postgres and Stripe do this."** + +> For the closed intra-org mode, largely yes — and our Phase 1 is deliberately an off-chain +> signed ledger, viable even if the on-chain settlement phases never ship. The chain buys +> three specific things. The payment gate: x402 is an open Linux Foundation standard +> (~75M transactions in the last 30 days) that lets any wallet — including another agent — +> pay a URL with no account, no card-on-file, no chargebacks, at sub-second settlement; +> Stripe's card-and-account rails can't do machine-to-machine 25-cent calls without an +> onboarding relationship (Stripe itself now ships an x402 integration). +> Provenance: fork ancestry lives on a neutral registry (Story Protocol) that neither +> employer nor employee administers. And the receipts in the demo are publicly auditable — +> that's how we reconciled the ledger to the cent. We're explicit in the docs that the +> idealized atomic loop does not compose (wrong chain, wrong token, wrong primitive) and +> settlement is two-leg and eventually consistent. + +**Q3. "A skill is a markdown file. Prompts are worthless; the model does the work."** + +> A skill is plaintext and trivially copyable — that's the first ADR in the repo, not a +> gotcha. We don't sell secrecy: the wielder gets output only, but the host sees the skill in +> plaintext, and in the intra-org mode the employer already possesses it. What's for sale is +> attribution and metered compensation, the way Carta doesn't make cap tables secret. On +> "worthless": some skills are — each frontier model release absorbs packaged prompting, so a +> claim on that class decays on the model-release clock, and we say skill half-life is +> unmeasured. The class worth metering is bound to live tool/data access and ongoing +> maintenance, which an output stream can't carry. + +**Q4. "Anyone can distill your skill from its own outputs for pennies. Your economics are dead."** + +> We ran exactly that attack on ourselves before launch and published it. Total cost $1.58, +> and the distillation step itself was $0.03; modeled break-even is 8 invocations if a clone +> ever passes. So yes: cost protects nothing, and we say so in those words. What we observed +> is that the clone failed all 6 held-out fidelity gates, and a synthetic evolution pass +> doubled the target–clone gap in one revision — fidelity and live evolution are the only +> defenses we've seen work. Big caveat we volunteer: N=6, high-N behavior unknown, we won't +> cite it as resolved. And in the intra-org frame the employer already has the file, so +> clone-resistance isn't what's being sold there — attribution is. + +**Q5. "Who would actually pay for this?"** + +> Honestly: unvalidated, and it's the first line of our "not validated" ledger. No LOI, no +> pilot, no pricing research. Our published kill-criterion 1 is that if no design-partner +> employer signs an LOI to co-hold a claim within the Phase-0 window, we do not build +> Phase 1 on spec. The adjacent evidence: per-call machine payments are real (x402 did ~75M +> transactions in 30 days — but that's agent-infra micropayments, not skill compensation), +> and institutions durably sharing invention proceeds with employees has precedent — Germany's +> ArbEG statutory inventor remuneration, corporate patent-award programs, university +> tech-transfer splits. The missing piece has always been the metering rail. If nobody +> signs, we stop; that's what kill-criteria are for. + +Secondary flak to expect, one-line stances: "GPT Store already failed at this" → agreed, +it's our stated base rate against the marketplace bet; the marketplace is optionality, the +compensation layer is the product. "x402 volume is bots" → we cite it as evidence the rail +works, explicitly not as demand for this. "Anthropic will just ship this" → kill-criterion 7, +monitored monthly; platforms won't ship 409A-structured co-held comp instruments. + +--- + +## 2. Demo clip script (45–60s screen recording, no voiceover, big captions) + +One continuous story: read → blocked → pay → output → receipt → split → thesis. Captions in +a large mono face, bottom third, one sentence max. Terminal at large font size (18pt+). +Target total: **57s**. + +| # | Sec | On screen | Caption text | +|---|-----|-----------|--------------| +| 1 | 0–6 (6s) | Slow scroll of neverhandedover.com — the manifesto text, ending on the URL bar | `This manifesto is a paid API endpoint.` | +| 2 | 6–13 (7s) | Terminal: `curl -X POST https://neverhandedover.com/...` → response renders, `HTTP/1.1 402 Payment Required` highlighted | `POST without payment → HTTP 402.` | +| 3 | 13–21 (8s) | Same terminal: client retries with x402 payment; a `$0.25` testnet USDC payment line and settle confirmation appear | `Pay $0.25 — testnet USDC. Play money.` | +| 4 | 21–31 (10s) | Skill output streams into the terminal, token by token (real speed; ~2.5s pause to first token left in — it is honest and reads as live) | `You get the output. Never the skill.` | +| 5 | 31–40 (9s) | Browser: the transaction on sepolia.basescan.org — highlight the transfer to the payTo address, cursor circles the amount | `Every invocation is an on-chain receipt.` | +| 6 | 40–49 (9s) | The ledger, zoomed on one line: `claude/plan $0.041 · skill $0.25 → creator $0.24375 / treasury $0.00625` — then a second frame: on-chain balances matching | `Metered, split, reconciled to the cent.` | +| 7 | 49–57 (8s) | Cut to black. Two lines of text, then URLs fade in: `neverhandedover.com` / `github.com/Aznatkoiny/skill-asset-protocol` | `The artifact was never handed over.` (line 2, smaller: `Testnet demo. Open source, Apache-2.0.`) | + +Production notes: +- No music required; if any, something metronomic and quiet. +- Shots 2–4 are one unbroken terminal take — do not cut between 402 and output; the + no-cut is the proof. +- Keep real latency visible (the ~781ms payment beat, the ~2.5s cold start). Speeding it up + would be the only dishonest frame in the clip. +- Shot 6's caption carries the compliance load with shot 3: "testnet / play money" must be + on screen in both the payment shot and the closing card. +- Export 1080p or better; the basescan and ledger text must be legible on a phone. + +--- + +## 3. Launch-day runbook + +### Pre-flight (the night before) + +- [ ] Repo: LICENSE (Apache-2.0) present, README top section = the offline e2e story, + secrets scan clean, issues enabled. +- [ ] Fresh-machine test: `git clone` → run the e2e demo with **zero keys, zero funds** on a + box that has never seen the project. If this fails, do not launch. +- [ ] Live check: `curl -X POST` against neverhandedover.com returns 402; a paid testnet + invocation completes; the basescan link in the demo clip still resolves. +- [ ] Both domains (neverhandedover.com, skillassetprotocol.com) serving; site links to + repo, kill-criteria, and the "What we have NOT validated" ledger — HN will look for + them within minutes. +- [ ] Compliance pass on every queued post: no financial-upside or income language; "testnet + USDC (play money)" appears wherever the $0.25 demo is mentioned. +- [ ] The 5 prepared HN answers (§1 above) open in a tab. + +### Launch sequence (a Tuesday, Wednesday, or Thursday) + +1. **Repo public** — first, before anything links to it. Verify the clone-and-run works + from a logged-out browser. +2. **LinkedIn post 1** — the personal founder post. Short, evidence-first, links to site + + repo. LinkedIn warms slowly; posting it first gives it the day to travel while HN is live. +3. **X thread** — the demo clip as the first tweet, numbers in the body, repo link at the + end. Tag the ecosystem accounts whose infrastructure is actually in the demo (x402, + Base, Story Protocol) — infrastructure attribution, not reach-begging. +4. **HN submission** — weekday morning US: target **8:30–10:00am ET** (peak US-audience + window; avoids the overnight queue and Friday/weekend dead zones). Submit the URL + (neverhandedover.com) with the chosen title; post the §1 text immediately as a first + comment. Never ask anyone to upvote, and don't share the direct HN link asking for + support — HN penalizes voting-ring patterns. +5. **Responding cadence** — hour 1: at the keyboard, reply to every substantive top-level + comment within ~15 minutes, using §1 answers as the base. Hours 2–4: sweep every 30 + minutes. Rest of day: hourly. Concede valid criticism in the first sentence of the reply; + the corpus was built for that. Do not reply to tone, only to content. +6. **End of day** — pin or link the best critical HN thread from the site/X ("the hardest + question we got today"), and log the day-0 measurements. + +### Measurement plan (what counts, what doesn't) + +Track **actions, not impressions**. Log daily for the first 7 days in a dated table +appended to this file: + +| Metric | Source | Why it counts | +|---|---|---| +| Demo invocations (count, unique payers) | On-chain receipts to the payTo address on Base Sepolia — the meter is its own analytics | Someone did the thing, not viewed the thing | +| Repo stars + forks + clones | GitHub insights | Developer intent | +| Inbound DMs / emails / conversations started | LinkedIn, X, email | The only path to the thing we have NOT validated: a design-partner conversation | +| Substantive HN/X critiques we couldn't answer | Manual log | Each one is a corpus defect to fix | + +Explicitly **not** tracked as success: impressions, likes, follower counts, HN points after +the fact. One derived number matters most at day 7: **conversations that could become a +design-partner LOI** — because kill-criterion 1 says that if none materialize in the +Phase-0 window, we do not build Phase 1 on spec. diff --git a/docs/marketing/linkedin.md b/docs/marketing/linkedin.md new file mode 100644 index 0000000..c5826d6 --- /dev/null +++ b/docs/marketing/linkedin.md @@ -0,0 +1,203 @@ +# LinkedIn Launch Series — Antony Zaki (personal account) + +Five posts for the launch of the Skill Asset Protocol / neverhandedover.com. + +**Rules baked into every post below:** +- Posts 1–3 contain zero crypto vocabulary; "a public test network" is the ceiling. Posts 4–5 may name x402 / testnet USDC / Base Sepolia because their audience is technical. +- Banned everywhere (compliance): earn/earnings, invest/investment, returns, passive income, yield, APY, token sale, tradeable, security, "get paid while you sleep." Framing is always: compensation infrastructure, attribution, metering, research, testnet demo. +- The demo is play money and is described as such anywhere a reader might act. +- Links go in the first comment (LinkedIn suppresses reach on posts with external links in the body). + +--- + +## Post 1 — Launch: "Never handed over" + +**Target audience:** the full network, with the hook aimed at VP Eng / Head of Platform at 100–800-person AI-forward firms. + +### Post text + +> Your best engineers are turning their expertise into AI skills right now. Claude Code skills, plugins, agent definitions. +> +> Then they hand them over. All of it. +> +> Work-for-hire has one default: the employer gets 100%, the author gets 0% plus salary. That was a tolerable deal when the work product was code that needed its author around to maintain it. +> +> An authored skill is different. It IS the expertise, packaged to run without the person. The better your people are at encoding what they know, the faster they automate away their own leverage. +> +> Nobody planned this. It's just the default, and defaults win when nobody is looking. +> +> I've spent the last months building the alternative: infrastructure that meters each use of a skill and splits the revenue to a claim the author holds jointly with the employer. Think Carta, but for AI work artifacts. +> +> It's live at neverhandedover.com, and the site is the demo. Reading is free. Running the hosted skill costs a quarter in play money on a public test network — and you get the output, never the skill. That one sentence is the whole architecture. +> +> What I have not proven: that employers will buy this. That's written on the site too, because a launch post that only lists what works is an ad, not evidence. +> +> If you run engineering or platform at a company where skills are becoming the work product, I want to hear how you're handling this. Link in the first comment. + +### First comment + +> The manifesto (it answers with a live meter, not a landing page): https://neverhandedover.com +> +> Everything is open source, Apache-2.0 — including what we have NOT validated: https://github.com/Aznatkoiny/skill-asset-protocol + +### Posting context + +Launch day, Tuesday–Thursday ~8:30am ET; pin to profile and leave it pinned through the series. + +--- + +## Post 2 — The clone story: "$1.58 to steal my own product" + +**Target audience:** business readers broadly — the moat lesson travels beyond the ICP. + +### Post text + +> Last week I paid $1.58 to steal my own product. +> +> The product is a hosted AI skill — expertise packaged as software, metered per use. You send a request, you get the output. You never see the skill itself. +> +> The obvious attack: buy enough outputs, train a copycat on them, stop paying. So I ran the attack on myself. +> +> Six paid runs. $1.58 total (play money on a public test network, but the ratios are what matter). The distillation step — turning those outputs into a working clone — cost three cents. +> +> The copying was over 40x cheaper than the buying, and the buying was under two dollars. +> +> Then the clone failed. All six held-out fidelity checks. Zero passes. It resembled my skill the way a wax figure resembles a person. +> +> Here's the uncomfortable math anyway. I modeled the break-even: if a clone ever DOES pass, it pays for itself in 8 invocations. Eight. Cost is not a moat. It never was — not for skills, not for playbooks, not for anything made of text. +> +> Honest caveat: six samples is a small test. Whether a clone passes at 600 or 6,000 samples is unknown, and the published write-up says so instead of pretending otherwise. +> +> The business lesson survives the caveat. If your defense is "copying is expensive," you don't have a defense. What held up wasn't price — it was fidelity (the parts of a skill that never show up in any single output) and the fact that a live skill keeps evolving while a clone is a photograph of it. +> +> That's true of your company's internal expertise too, whether or not you ever touch my product. +> +> Full numbers and method in the first comment. + +### First comment + +> The clone-attack harness, raw numbers, and all six fidelity gates are in the open-source repo: https://github.com/Aznatkoiny/skill-asset-protocol +> +> The skill it failed to clone is live (testnet, play money): https://neverhandedover.com + +### Posting context + +2–4 days after Post 1, mid-week morning; this hook stands alone, so it's the best post of the five for reaching past the existing network. + +--- + +## Post 3 — The retention angle: a compensation instrument, not a marketplace + +**Target audience:** the ICP directly — VP Eng / Head of Platform, co-read by Head of People and the CFO. + +### Post text + +> A retention question for engineering leaders: what happens to your best platform engineer's leverage the day after she ships the skill that automates her specialty? +> +> Under standard employment terms, the answer is: it transfers to you, completely, and she knows it. +> +> This is new. When the work product was a codebase, the author stayed valuable because the codebase needed her. An authored AI skill is designed not to need her. She built it; it runs without her; her comp doesn't change; her bargaining position gets worse with every improvement she ships. +> +> Rational people respond to that. They hold back the last 20%. They build the good version on the side. They leave and productize it. You have probably seen at least one of these already. +> +> Here's the thing: companies already know how to handle "employee creates durable value, company owns it." Germany has had statutory inventor remuneration for decades — employees are compensated by law when the employer uses their patent. Most large R&D shops run patent-award programs. Universities split tech-transfer proceeds with faculty. Nobody calls any of that radical. +> +> Skills have no equivalent, because there was no meter. You can't compensate per use if you can't count uses. +> +> That's what I built: metering for authored skills, plus a claim on each use that the author and the employer hold jointly. Not equity, not a bonus pool — a per-use compensation instrument, non-transferable by design, with an auditable count behind it. To the CFO it's deferred comp with a usage meter, and it costs nothing until the skill is actually used. +> +> Full honesty: no employer has signed yet. Whether companies will restructure work-for-hire terms this way is the open question, and it's listed as exactly that in our docs. +> +> I'm looking for 3–5 design partners — AI-forward firms, roughly 100–800 people, where skills are already the work product — to find out together. If that's you, or your Head of Platform, my DMs are open. Details in the first comment. + +### First comment + +> How the co-held claim and the meter work, including everything still unproven: https://neverhandedover.com +> +> If a design-partner conversation is easier over email: zaki.antony@gmail.com + +### Posting context + +Week 2, Tuesday morning; the post to reshare directly to specific VP Eng / Head of People contacts with a one-line personal note. + +--- + +## Post 4 — The honesty post: kill-criteria and a feature killed by arithmetic + +**Target audience:** founders, operators, and the diligence-minded — the credibility post that makes Posts 1–3 believable. + +### Post text + +> Two weeks before launch, we killed one of our three product modes. With arithmetic. In public. +> +> The mode was Education: a school authors a base skill, a student forks it into their own version, and a share of each use flows back to the school. Three-sided, elegant, everyone loved it on the whiteboard. +> +> Then we ran the adversarial branch we had been avoiding: what if the student doesn't fork the school's skill, and instead re-authors an equivalent one using what the class taught? +> +> Re-authoring is nearly free. So it dominates forking at EVERY royalty rate. Set the rate high and nobody forks; set it low and there's nothing to flow back. No number makes the mode work. The whiteboard was wrong, and one page of arithmetic proved it. +> +> So we deferred the mode and published the reasoning. +> +> The repo ships two documents I wish more launches included: +> +> 1. Kill-criteria. Falsifiable conditions under which we stop or restructure — including "no employer signs within the validation window" and "a platform ships this natively." Written down before launch, so we can't move the goalposts after. +> +> 2. A "What we have NOT validated" ledger. The biggest entry: we have not validated that employers will buy this. That is the load-bearing assumption of the whole company, and today it is an assumption. +> +> Why publish any of this? Not virtue. Selection. The design partners we want are the ones who read a list of open risks and lean in — they evaluate infrastructure for a living, and they know a pitch with no listed failure modes is hiding them. +> +> Also: a team that kills its own feature with arithmetic before launch is a team that won't ship you a fantasy after you've signed. +> +> Both documents are in the open-source repo. First comment. + +### First comment + +> Kill-criteria and the "What we have NOT validated" ledger, verbatim: https://github.com/Aznatkoiny/skill-asset-protocol +> +> The thing they're keeping honest: https://neverhandedover.com + +### Posting context + +Week 2–3, mid-week; resonates with founders and diligence-minded operators, so it's the one to let sit and compound rather than push. + +--- + +## Post 5 — The build story: research → adversarial review → live endpoint + +**Target audience:** engineering leaders; the technical-credibility post, and the only one that names the rails. + +### Post text + +> How we went from research question to a live paid endpoint — in three phases. +> +> The question: if authored AI skills are assets, can you meter their use and split compensation per invocation, without ever shipping the skill file to the caller? +> +> Phase 1 — research. Before product code, a findings document: what's real, what's vapor, what's unmeasured. x402, the HTTP 402 payment standard now under the Linux Foundation, turned out to be very real — ~75M transactions in the last 30 days. Story Protocol handles provenance. Several things we assumed were solved were not, and we wrote that down too. +> +> Phase 2 — adversarial review. We red-teamed our own PRD. This is where the Education mode died (free re-authoring beats every royalty rate — one page of arithmetic) and where the kill-criteria were written. The red-team output ships in the repo as a first-class artifact, not a postmortem. +> +> Phase 3 — the live endpoint. The manifesto site IS the system. POST without payment and you get an actual HTTP 402. Pay $0.25 in testnet USDC — play money, deliberately — and the hosted skill runs and streams you the output. Never the skill. +> +> Numbers from the working demo on Base Sepolia (July 12), one wallet paying per model call AND per skill invocation: +> +> — Ledger: $0.041 for the model's planning call, $0.25 for the skill invocation +> — Split: $0.24375 credited to the creator, $0.00625 to the treasury +> — On-chain balances reconciled to the cent +> — Payment overhead: ~781ms per call +> — Hosted-agent cold start: ~2.5s to first token +> +> That 781ms is honest and it isn't free. Neither is the cold start. Both are in the docs, because you'd find them in your first hour anyway. +> +> Everything is Apache-2.0: the collar that holds the sole API key, the metering ledger, the clone-attack harness we ran against ourselves, the kill-criteria, the not-validated list. +> +> What's not proven: that anyone will buy it. The engineering works; the market is the experiment. If you want to poke at either, repo and live endpoint in the first comment. + +### First comment + +> Code, findings, red-team artifacts, clone harness (Apache-2.0): https://github.com/Aznatkoiny/skill-asset-protocol +> +> The live endpoint — bring play money only, it's a public test network: https://neverhandedover.com + +### Posting context + +Thursday morning at the end of launch week or early week 2; the post to cross-link from the repo README and any HN/X discussion, since it's the one technical readers will arrive at. diff --git a/docs/marketing/x.md b/docs/marketing/x.md new file mode 100644 index 0000000..eb45910 --- /dev/null +++ b/docs/marketing/x.md @@ -0,0 +1,312 @@ +# X/Twitter Launch Kit — Skill Asset Protocol + +Account: @[Antony's personal handle] — posting as a founder-builder, not a brand. +Constraint: cold account, near-zero followers. Distribution comes from ecosystem +amplification and quality, not reach. Every tweet is written to survive being +screenshotted out of context. + +**House rules (defects if violated):** + +- Never use: earn/earnings, invest/investment, financial "return(s)", passive income, yield, APY, token sale, tradeable, "security", "get paid while you sleep". +- Frame everything as: compensation infrastructure, attribution, metering, research, testnet demo. +- The demo runs on **testnet USDC — play money**. Say so anywhere a reader might act on it. +- Numbers only from the measured runs. If we didn't measure it, we don't tweet it. +- Verify all @handles and character counts at post time. Org accounts are named, never guessed. + +--- + +## 1. Launch thread (10 tweets) + +**1/** +We published a manifesto that is also a paid API. + +POST to it without paying and you get HTTP 402. + +Pay $0.25 in testnet USDC (play money) and it runs the skill and sends back the output. You never get the skill. + +https://neverhandedover.com + +**2/** +The thesis: AI Skills — the Claude Code skills, plugins, and agents people are authoring right now — are assets. + +Work-for-hire's default is 100/0. Employer gets everything. Author gets salary. + +Nobody negotiated that. It's just the default. + +**3/** +Skill Asset Protocol meters use per Invocation and splits revenue to a claim co-held by author and org. + +Carta for AI work artifacts. + +The compensation and attribution layer is the product. A marketplace is future optionality, not the point. + +**4/** +Receipts from the live demo (Base Sepolia, 2026-07-12 — testnet, play money): + +One wallet paid per model call AND per skill invocation, over x402. + +claude/plan $0.041 · skill $0.25 → creator $0.24375 / treasury $0.00625 + +On-chain balances reconciled to the cent. + +**5/** +The overhead of paying per call, measured on the same run: + +· payment adds ~781ms per call +· hosted-agent cold start: ~2.5s to first token + +Not free. Not prohibitive. Numbers you can build against. + +**6/** +We attacked our own skill before launch. + +$1.58 bought a clone distilled from its own outputs (the distillation step itself: $0.03). + +The clone failed all 6 held-out fidelity gates. + +Cost protects nothing. Fidelity and live evolution are the defense. + +**7/** +We published kill-criteria before launch and already used them on ourselves. + +Our education mode died by arithmetic: free re-authoring dominates every royalty rate we modeled. So we killed it and published the math. + +**8/** +There is a page on neverhandedover.com titled "What we have NOT validated." + +The biggest entry: whether employers will buy this. We don't know yet. + +It sits in writing, next to the claims we can back. + +**9/** +None of this is built on hope. It's built on rails that already move volume: + +· x402 — a Linux Foundation standard, ~75M transactions in the last 30 days +· Story Protocol for provenance +· Runs on Base + +**10/** +Everything is open source, Apache-2.0: +github.com/Aznatkoiny/skill-asset-protocol + +The manifesto that charges a testnet quarter (play money): +https://neverhandedover.com + +If you author skills, this is about who gets credited and compensated for them. + +--- + +## 2. Clone-attack thread (7 tweets) + +**1/** +We paid $1.58 to steal our own product. + +Here's the experiment, the numbers, and the uncomfortable conclusion about what actually protects an AI skill. + +**2/** +Setup: our skill is a paid endpoint. $0.25 per invocation in testnet USDC (play money), output only — the skill itself never crosses the wire. + +The obvious attack: pay it, collect outputs, distill a clone, stop paying. + +So we ran that attack against ourselves. N=6. + +**3/** +The bill: + +· total attack cost: $1.58 +· the distillation step itself: $0.03 + +Three cents. The expensive part was buying our own outputs to distill from. If you think per-call pricing is a moat, that's the number that should bother you. + +**4/** +The result: the clone failed all 6 held-out fidelity gates. + +Every single one. + +It resembled our skill the way a photo of a bridge resembles a bridge. You can look at it. You can't drive across it. + +**5/** +We also modeled the case where a clone eventually passes the gates: break-even lands at 8 invocations. + +Eight. If price is your only defense, anyone who can afford 8 calls can afford the attack. + +**6/** +The conclusion we're publishing: cost protects nothing. + +What holds up: + +· fidelity — held-out gates a clone has to pass, not resemble +· live evolution — a skill that keeps changing is a moving target for distillation + +**7/** +The honest caveat: N=6 is small. High-N behavior is unknown — someone patient, with hundreds of outputs, might distill a passing clone. We don't know yet. + +The experiment is public so someone can prove us wrong: +github.com/Aznatkoiny/skill-asset-protocol + +--- + +## 3. How-it-works thread (8 tweets, technical) + +**1/** +How do you make a manifesto charge for POST requests? + +The full x402 flow behind https://neverhandedover.com, with measured latency, in one thread. For people building agent payments. + +**2/** +Step 1 — the refusal. + +Client POSTs with no payment. Server answers HTTP 402 Payment Required, with the terms in the response: $0.25 USDC on Base Sepolia (testnet — play money) and the address to pay. + +The status code finally has a job. + +**3/** +Step 2 — the signature. + +The client signs an EIP-3009 transferWithAuthorization for the exact amount. Off-chain signature, no gas from the buyer, no custody handoff — just signed authorization to move $0.25 of testnet USDC. + +**4/** +Step 3 — settlement. + +The signed authorization goes to an x402 facilitator, which settles it on-chain. x402 is a Linux Foundation standard; the rails did ~75M transactions in the last 30 days. + +We didn't build payment infrastructure. We built on it. + +**5/** +Step 4 — the credential. + +The settlement txHash IS the credential. The client retries the POST carrying it; the server verifies settlement on-chain and executes. + +No API keys. No accounts. The receipt is the auth. + +**6/** +Step 5 — output only. + +The server runs the hosted skill and sends back the result. The skill artifact never crosses the wire. + +That's the design constraint the whole protocol hangs on: metered use, never handover. + +**7/** +All of the server side fits in a ~150-line proxy we call the Wielder: enforce 402, verify settlement, run the hosted skill, split revenue to the ledger. + +150 lines, because the rails already exist. + +**8/** +Measured (Base Sepolia, 2026-07-12, testnet): + +· payment overhead ~781ms/call +· cold start ~2.5s to first token +· $0.25/invocation → creator $0.24375 / treasury $0.00625, reconciled on-chain to the cent + +Code, Apache-2.0: +github.com/Aznatkoiny/skill-asset-protocol + +--- + +## 4. Engagement playbook — the cold-start two weeks + +**The premise.** A cold account broadcasting threads is a radio tower with no power. +For the first two weeks, the account exists in other people's replies. Broadcasting +starts only after the account has a visible track record of showing up with numbers. +Amplification will come from ecosystem accounts and builders quoting the work — +that only happens if they've seen the name before launch day. + +### Week 1 — reply only. Post nothing original. + +Daily routine, 30–45 minutes: + +1. Work through saved searches (below). Find 3–5 conversations where we have something measured to add. +2. Write replies that answer the actual question with a number or a receipt. No links to our stuff unless someone asks or the link is literally the answer. +3. Follow the people whose threads were worth replying to. Builders, not brands. +4. Log which conversations got traction in a scratch file — those communities get the launch-thread reply-tags later. + +**Where the conversations are (saved searches to build):** + +- **x402 ecosystem** — searches: "x402", "HTTP 402", "402 Payment Required", "facilitator". This is the home crowd; the how-it-works thread is written for them. +- **Base builders** — searches: "Base Sepolia", "onchain agents", Base ecosystem hashtags. They care about the on-chain ledger reconciling to the cent. +- **Story Protocol / provenance** — searches: "Story Protocol", "IP provenance", "attribution onchain". They care about the authorship thesis. +- **Claude Code community** — searches: "Claude Code skills", "Claude plugins", "agent skills". These are the authors the protocol exists for. This is the most important room. +- **Agent-payments discourse** — searches: "agents paying agents", "agentic payments", "machine-to-machine payments", "pay per call". Broadest, noisiest; only reply where we have a measurement. + +**What a high-value reply looks like.** Three rules: bring a receipt, answer the +question asked, never pitch. + +Good (someone asks whether x402 latency is workable): +> We measured it on Base Sepolia last week: ~781ms of payment overhead per call, ~2.5s cold start to first token on a hosted agent. Fine for per-task pricing, painful inside a tight loop. + +Good (someone claims per-call pricing stops people cloning your agent): +> We tested that against our own skill. $1.58 total to distill a clone from its outputs — the distillation step cost $0.03. The clone failed our 6 fidelity gates, but cost was never the thing protecting it. N=6, so high-N is still an open question. + +Good (Claude Code author asks who owns the skills they write at work): +> Under standard work-for-hire, the default is 100/0 — employer gets the artifact, author gets salary. We've been building infrastructure to meter use and split per invocation instead. Happy to share the numbers if useful. + +Bad (any variation of): "Great point! We're building exactly this — check out [link]." Zero of these, ever. + +### Week 2 — build-in-public, one artifact per day, keep replying. + +Single tweets, not threads. One screenshot-sized artifact per day: + +- Day 8: screenshot of the raw HTTP 402 response from the manifesto endpoint. +- Day 9: the ledger line — claude/plan $0.041 · skill $0.25 → creator $0.24375 / treasury $0.00625 — with the note that it reconciled on-chain to the cent (testnet, play money). +- Day 10: the "What we have NOT validated" page, screenshotted. +- Day 11: the kill-criteria arithmetic that killed our education mode. +- Day 12: the ~150-line Wielder proxy, as a code screenshot. +- Day 13–14: rest the feed; replies only. Launch thread ships when the demo receipts are final. + +Reply routine continues daily throughout. The ratio stays lopsided: for every original post, several substantive replies elsewhere. + +### Launch sequencing + +- **Day 0:** Launch thread. Pin it. +- **Day 0, first reply under the thread:** the ecosystem tag reply (see below). +- **Day 0, hours 1–3:** live in the replies. Every substantive response gets a substantive answer. This window decides whether the thread travels. +- **Day 2:** How-it-works thread. Quote or link the pinned launch thread from the last tweet. +- **Day 4–5:** Clone-attack thread. +- **Between threads:** the standalone tweets (section 5), one at a time, as spacers. Never two threads within 48 hours. + +### Tag strategy + +Tag org accounts in the **first reply** under the launch thread, not in tweet 1 — +tweet 1 stays clean for screenshots. Orgs to tag, **by name; Antony verifies every +handle at post time — do not trust autocomplete, do not invent handles:** + +- x402 Foundation +- Base +- Story Protocol +- Coinbase Developer Platform + +Template for the tag reply: +> Built on [x402 Foundation]'s standard, settled on [Base] testnet, provenance via [Story Protocol], developer rails from [Coinbase Developer Platform]. Demo is testnet USDC — play money. Receipts in the thread above. + +Never cold-tag individuals. If an individual engaged during weeks 1–2, replying to +them or DM-ing the thread is fine; tagging strangers into a launch thread is not. + +--- + +## 5. Standalone tweets (spacers, manifesto register) + +**A.** +Work-for-hire defaults to 100/0. Not because anyone negotiated it — because nobody built the alternative. + +**B.** +Most manifestos ask for your agreement. Ours asks for a quarter. (A testnet quarter. Play money.) + +https://neverhandedover.com + +**C.** +We paid $1.58 to clone our own skill. The clone failed all 6 fidelity gates. + +Cost protects nothing. Fidelity and live evolution do. + +**D.** +Output crosses the wire. The skill never does. + +That single constraint is the whole protocol. + +**E.** +The most useful page we published is the list of things we haven't proven. It's shorter than the manifesto and it was harder to write. + +**F.** +We killed our own education mode with arithmetic: free re-authoring beats every royalty rate we modeled. + +Publishing the math felt better than shipping the feature. From 057e8653312fc412c38050f4c479e294f5597be7 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Mon, 13 Jul 2026 12:36:07 -0400 Subject: [PATCH 016/165] Fix pi extension: provider requires apiKey field; payment is the credential Co-Authored-By: Claude Fable 5 --- spikes/pi-wielder/pi-extension/x402.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spikes/pi-wielder/pi-extension/x402.ts b/spikes/pi-wielder/pi-extension/x402.ts index c60570b..436b9dc 100644 --- a/spikes/pi-wielder/pi-extension/x402.ts +++ b/spikes/pi-wielder/pi-extension/x402.ts @@ -34,6 +34,9 @@ export default function activate(pi: Pi) { pi.registerProvider("x402", { baseUrl: `${PROXY}/v1`, api: "openai-completions", // the proxy/gateway speak OpenAI chat-completions + // pi requires an apiKey field when models are defined; the paying proxy + // ignores Authorization entirely — payment IS the credential (ADR-0008). + apiKey: "x402-payment-is-the-credential", models: [ { id: "claude-sonnet-4-6", From 8488abcb71837d7e85f027ff36634d204e13517b Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Mon, 13 Jul 2026 12:40:15 -0400 Subject: [PATCH 017/165] Fix pi extension: model entries need reasoning/input/cost fields pi v0.80.6 crashes with 'undefined.includes' when model entries lack the input modality array; schema per pi's docs/custom-provider.md. Co-Authored-By: Claude Fable 5 --- spikes/pi-wielder/pi-extension/x402.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spikes/pi-wielder/pi-extension/x402.ts b/spikes/pi-wielder/pi-extension/x402.ts index 436b9dc..fe41540 100644 --- a/spikes/pi-wielder/pi-extension/x402.ts +++ b/spikes/pi-wielder/pi-extension/x402.ts @@ -41,12 +41,22 @@ export default function activate(pi: Pi) { { id: "claude-sonnet-4-6", name: "claude via x402 (pay-per-call, Base Sepolia)", + reasoning: false, + input: ["text"], + // pi tracks per-token cost; ours is flat per-call and lands on the + // /ledger — zeros here so pi's meter doesn't double-count. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 8_192, }, { id: "gpt-5.2", name: "gpt via x402 (pay-per-call, Base Sepolia)", + reasoning: false, + input: ["text"], + // pi tracks per-token cost; ours is flat per-call and lands on the + // /ledger — zeros here so pi's meter doesn't double-count. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 8_192, }, From 54eb8d916f140ebe7e57d71e80af76774eddbd9e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Mon, 13 Jul 2026 13:24:24 -0400 Subject: [PATCH 018/165] Gateway speaks SSE: fix 'Stream ended without finish_reason' in pi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi's openai-completions client requires streaming responses; the spike gateway returned buffered JSON, so pi discarded four PAID answers ($0.164 of testnet USDC — a live demonstration of the paid-but- unusable-response failure mode). The gateway now replays the completed answer as compliant SSE when stream:true; the proxy forwards upstream content-type and tolerates non-JSON bodies. Co-Authored-By: Claude Fable 5 --- spikes/pi-wielder/src/gateway.mjs | 24 +++++++++++++++++++++--- spikes/pi-wielder/src/proxy.mjs | 5 +++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index b6363eb..71fcc2a 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -45,9 +45,17 @@ export function createGateway({ async (c) => { const body = await c.req.json(); const model = body.model ?? ''; - if (mockLlm) return c.json(mockCompletion(body)); - if (model.startsWith('claude')) return c.json(await viaAnthropic(body)); - return c.json(await viaOpenAI(body)); // gpt-* and anything else + const completion = mockLlm ? mockCompletion(body) + : model.startsWith('claude') ? await viaAnthropic(body) + : await viaOpenAI(body); // gpt-* and anything else + if (!body.stream) return c.json(completion); + // OpenAI-style clients (pi included) speak SSE. The spike computes the + // full completion first, then replays it as one compliant stream: + // role+content delta -> finish chunk -> [DONE]. + return c.newResponse(sseFromCompletion(completion), 200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache', + }); }, ); @@ -137,3 +145,13 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const { url } = await startGateway({ port: Number(process.env.GATEWAY_PORT || 8403), facilitatorUrl }); console.log(`[gateway] x402-gated /v1/chat/completions at ${url} (facilitator: ${facilitatorUrl})`); } + +// Wrap a completed chat.completion as OpenAI SSE chunks. Not true streaming — +// the whole answer arrives in one delta — but protocol-correct for clients +// that refuse buffered JSON ("Stream ended without finish_reason"). +function sseFromCompletion(completion) { + const base = { id: completion.id, object: 'chat.completion.chunk', created: completion.created, model: completion.model }; + const delta = { ...base, choices: [{ index: 0, delta: { role: 'assistant', content: completion.choices[0].message.content }, finish_reason: null }] }; + const finish = { ...base, choices: [{ index: 0, delta: {}, finish_reason: completion.choices[0].finish_reason ?? 'stop' }], usage: completion.usage ?? null }; + return `data: ${JSON.stringify(delta)}\n\ndata: ${JSON.stringify(finish)}\n\ndata: [DONE]\n\n`; +} diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index 3602da7..a501d98 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -101,7 +101,8 @@ export function createProxy({ const resBody = await res.text(); if (paid && res.ok) { - const parsed = JSON.parse(resBody); + let parsed = {}; + try { parsed = JSON.parse(resBody); } catch { /* SSE bodies are not JSON */ } const model = JSON.parse(bodyText || '{}').model ?? ''; const label = leg === 'skill' ? `skill/${path.split('/').pop()}` @@ -115,7 +116,7 @@ export function createProxy({ // Spike-only debug headers: proof-of-402 + overhead for e2e, and the raw // X-PAYMENT so the e2e can attempt (and be refused) a credential replay. - const headers = { 'content-type': 'application/json' }; + const headers = { 'content-type': res.headers.get('content-type') ?? 'application/json' }; if (paid) { headers['x-wielder-402'] = '1'; headers['x-wielder-overhead'] = JSON.stringify(timings); From be31ce9ab5c287d59d884bd6589762e69651981e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Mon, 13 Jul 2026 14:12:15 -0400 Subject: [PATCH 019/165] Gateway translates pi's real OpenAI shapes: array content + tool calls String(m.content) on pi's array-of-parts content sent Claude literally '[object Object]' (two paid calls proved it on the session ledger). Normalize string/array content everywhere, translate OpenAI tools <-> Anthropic tool_use/tool_result with role merging, carry tool_calls through the synthesized SSE, and stop forwarding stream:true upstream. Co-Authored-By: Claude Fable 5 --- spikes/pi-wielder/src/gateway.mjs | 81 ++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index 71fcc2a..c29a339 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -64,7 +64,7 @@ export function createGateway({ // --- MOCK_LLM=1: canned OpenAI-format completions -------------------------- function mockCompletion(body) { - const lastUser = [...(body.messages ?? [])].reverse().find((m) => m.role === 'user')?.content ?? ''; + const lastUser = contentToText([...(body.messages ?? [])].reverse().find((m) => m.role === 'user')?.content); const family = (body.model ?? '').startsWith('claude') ? 'claude' : 'gpt'; const content = family === 'claude' @@ -81,21 +81,69 @@ function mockCompletion(body) { } // --- real upstreams --------------------------------------------------------- -// Thin OpenAI-chat -> Anthropic Messages translation (system extraction, text -// content only — spike-grade, no tools/streaming). +// OpenAI-chat -> Anthropic Messages translation. Covers the shapes pi actually +// sends: content as a string OR an array of typed parts, tool definitions, +// assistant tool_calls, and role:"tool" results. +const contentToText = (content) => + typeof content === 'string' ? content + : Array.isArray(content) ? content.map((p) => (typeof p === 'string' ? p : p?.text ?? '')).join('') + : content == null ? '' : String(content); + +function toAnthropicMessages(oaiMessages = []) { + const out = []; + const push = (role, blocks) => { + if (!blocks.length) return; + const prev = out[out.length - 1]; + // Anthropic requires alternating roles; tool results (user) can directly + // follow a real user turn, so merge consecutive same-role turns. + if (prev && prev.role === role) prev.content.push(...blocks); + else out.push({ role, content: [...blocks] }); + }; + for (const m of oaiMessages) { + if (m.role === 'system') continue; + if (m.role === 'tool') { + push('user', [{ type: 'tool_result', tool_use_id: m.tool_call_id, content: contentToText(m.content) }]); + } else if (m.role === 'assistant') { + const blocks = []; + const text = contentToText(m.content); + if (text) blocks.push({ type: 'text', text }); + for (const tc of m.tool_calls ?? []) { + blocks.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments || '{}') }); + } + push('assistant', blocks); + } else { + push('user', [{ type: 'text', text: contentToText(m.content) }]); + } + } + return out; +} + async function viaAnthropic(body) { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) throw new Error('ANTHROPIC_API_KEY required for claude-* models unless MOCK_LLM=1'); - const system = (body.messages ?? []).filter((m) => m.role === 'system').map((m) => m.content).join('\n') || undefined; - const messages = (body.messages ?? []).filter((m) => m.role !== 'system') - .map((m) => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content) })); + const system = (body.messages ?? []).filter((m) => m.role === 'system').map((m) => contentToText(m.content)).join('\n') || undefined; + const tools = (body.tools ?? []).map((t) => ({ + name: t.function.name, + description: t.function.description ?? '', + input_schema: t.function.parameters ?? { type: 'object', properties: {} }, + })); const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, - body: JSON.stringify({ model: body.model, max_tokens: body.max_tokens ?? 2048, system, messages }), + body: JSON.stringify({ + model: body.model, + max_tokens: body.max_tokens ?? 2048, + system, + messages: toAnthropicMessages(body.messages), + ...(tools.length ? { tools } : {}), + }), }); if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${await res.text()}`); const data = await res.json(); + const text = data.content?.filter((b) => b.type === 'text').map((b) => b.text).join('') ?? ''; + const toolCalls = (data.content ?? []).filter((b) => b.type === 'tool_use').map((b) => ({ + id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) }, + })); return { id: data.id, object: 'chat.completion', @@ -103,8 +151,13 @@ async function viaAnthropic(body) { model: data.model, choices: [{ index: 0, - message: { role: 'assistant', content: data.content?.map((b) => b.text ?? '').join('') ?? '' }, - finish_reason: data.stop_reason === 'max_tokens' ? 'length' : 'stop', + message: { + role: 'assistant', + content: toolCalls.length && !text ? null : text, + ...(toolCalls.length ? { tool_calls: toolCalls } : {}), + }, + finish_reason: data.stop_reason === 'tool_use' ? 'tool_calls' + : data.stop_reason === 'max_tokens' ? 'length' : 'stop', }], usage: { prompt_tokens: data.usage?.input_tokens ?? 0, @@ -117,10 +170,12 @@ async function viaAnthropic(body) { async function viaOpenAI(body) { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) throw new Error('OPENAI_API_KEY required for gpt-* models unless MOCK_LLM=1'); + // Always fetch buffered — the gateway synthesizes its own SSE downstream. + const { stream, stream_options, ...rest } = body; const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, - body: JSON.stringify(body), + body: JSON.stringify(rest), }); if (!res.ok) throw new Error(`OpenAI API ${res.status}: ${await res.text()}`); return res.json(); @@ -151,7 +206,11 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) // that refuse buffered JSON ("Stream ended without finish_reason"). function sseFromCompletion(completion) { const base = { id: completion.id, object: 'chat.completion.chunk', created: completion.created, model: completion.model }; - const delta = { ...base, choices: [{ index: 0, delta: { role: 'assistant', content: completion.choices[0].message.content }, finish_reason: null }] }; + const msg = completion.choices[0].message; + const d = { role: 'assistant' }; + if (msg.content) d.content = msg.content; + if (msg.tool_calls?.length) d.tool_calls = msg.tool_calls.map((tc, index) => ({ index, ...tc })); + const delta = { ...base, choices: [{ index: 0, delta: d, finish_reason: null }] }; const finish = { ...base, choices: [{ index: 0, delta: {}, finish_reason: completion.choices[0].finish_reason ?? 'stop' }], usage: completion.usage ?? null }; return `data: ${JSON.stringify(delta)}\n\ndata: ${JSON.stringify(finish)}\n\ndata: [DONE]\n\n`; } From 015f232e5b7293a1e7c44f76bc8135dfdd3b95a9 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Wed, 15 Jul 2026 12:35:28 -0400 Subject: [PATCH 020/165] Registry-not-marketplace: protocol distribution strategy, fact-checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers "crypto-enabled skills.sh?" (no — ADR-0007 holds; all four steelmanned critiques bind) and "distribute like MCP/A2A?" (yes — MCP playbook applied: adoption kit, neutral org, two named adopters before the word "standard", settlement-gated registry as protocol surface). Grounded in a 6-agent web+repo research sweep (2026-07-15) and an adversarial fact-check pass; 12 defects corrected before commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U2V9gsyEvYTSoZL4YkWYio --- .../2026-07-15-registry-not-marketplace.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/plans/2026-07-15-registry-not-marketplace.md diff --git a/docs/plans/2026-07-15-registry-not-marketplace.md b/docs/plans/2026-07-15-registry-not-marketplace.md new file mode 100644 index 0000000..042ec0e --- /dev/null +++ b/docs/plans/2026-07-15-registry-not-marketplace.md @@ -0,0 +1,192 @@ +# Registry, not marketplace — protocol distribution strategy (2026-07-15) + +*Consolidates a six-agent research sweep (five web/local researchers + one +synthesizer, adversarially cross-checked) run 2026-07-15 against the question: +"Can we build a crypto-enabled skills.sh? How do we distribute this as an +adoptable protocol like MCP/A2A?" All external facts are dated; re-verify +before publication — this space moves week to week.* + +## Verdict + +The question splits in two, with opposite answers. + +**Marketplace-as-product: no.** ADR-0007 (2026-07-11) decided this four days +ago: *"The product is a compensation, attribution, and metering layer for +authored AI Skills — 'Carta for AI work artifacts' — not a skill marketplace"* +(docs/adr/0007:41-42). All four steelmanned critiques that killed it bind a +crypto skills.sh specifically (0007:12-26): distillation self-defeat (a +breakout skill's paid I/O pairs are a ~30x-cheaper clone set; modeled +break-even 8 invocations *if* a clone ever passes fidelity gates — +spikes/clone-economics, N=6, not resolved), hosting strips context-bound +value, the free re-authoring bypass, and the platform-native marketplace +threat (KC7/R17; GPT Store base rate — and its second reading: builder +monetization was weak *even free*). Reopening ADR-0007 would require: +KC7 unfired, a specific answer to each critique, the Phase-3 +compliance-arithmetic gate clearing (docs/PRD.md:780), and LOI-grade demand +evidence for open supply. None exists; the KC1 LOI itself is unsigned. + +**Protocol distribution: yes — and ADR-0008 already did the hard part.** The +client footprint is "answer HTTP 402 and retry," so every harness is already a +compatible client (validated by construction: the pi live demo ran 2026-07-15, +8 paid calls, zero payment code in pi). The distribution play is the MCP +playbook applied to the Collar/ledger side. + +**The version of the founder's idea that survives both:** a **settlement-gated +registry** — a thin index over the ledger + provenance graph Phases 0–1 build +anyway. A skill is listed the moment its first x402 payment settles; ranked by +30-day settled volume and unique payers (unfakeable); auto-delisted when idle. +No submission, no curation, no hosting decisions, no tradeable instruments. +This passes ADR-0007's own optionality test ("nearly free" when mechanics are +shared, 0007:61-63) because it is a read API over shared mechanics — provided +it is named and sequenced as a *protocol surface* (like MCP's registry, which +launched 10 months post-launch), never as the product. + +## The empty slot, and who is closing in (all verified 2026-07-15) + +**No one runs a paid marketplace for installable/portable agent skills with +royalties or on-chain attribution for authors** (on-chain attribution is +absent everywhere). Every adjacent piece exists in a silo: + +| Who | What ships today | What's missing | +|---|---|---| +| skills.sh (Vercel, vercel-labs/skills, since 2026-01) | Free registry + `npx skills add`; ~9.6k on leaderboard of ~895k tracked; top skill ~2.5M installs; telemetry listing, no review | No payments, no attribution, no business model published. Owns the install rail; could flip monetization on overnight | +| Agent Bazaar (agent-bazaar.com) | 28+ hosted skills, per-call USDC via x402, Claude Code auto-discovery | No author comp/attribution; anonymous operator; unclear third-party publishing | +| MCP Hive (mcp-hive.com, launched 2026-07-12) | Newest provider-earnings loop ("providers earn per response"; 3 days old, payout mechanics unproven) | Hosted MCP tools, fiat monthly settlement, no attribution | +| Agent402 (agent402.tools, live 2026-06-12) | 403 tools + 101 skill packs, $0.05–$1.50, x402 on 8 networks, ~23k settled calls | One person. Proves the mechanics are a solo weekend — first-mover moat ≈ 0 | +| MuleRun (2025-12) / Agent37 (2025-12) | Creator rev-share (80–100% / 80/20), both **hosted** access | Fiat, no provenance; hosting confirms ADR-0001's leak logic | +| Agensi | *Claims* Stripe rev-share on installable SKILL.md files | Unproven — its own pages contradict each other (70% vs 80%); treat as vapor | +| Story Protocol | **Story Skills SDK (2026-05-06)**: IP registration, licensing, royalty policies as agent-usable skills | The one purpose-built on-chain royalty rail. PRD:514 already flags Story as "the most capable disintermediator" | +| Circle Agent Stack (2026-05-11) | Agent Wallets + Agent Marketplace (32 services / 349 endpoints), x402-adjacent | Enterprise services, not authored skills; no attribution | + +Window estimate: **3–6 months** before someone credible (Vercel, Story, +Circle/Coinbase, or a funded team) wires x402 payments + attribution onto an +existing registry. Demand-side caution: the window is for **claiming the +standard, not for revenue** — the entire x402 economy is ~$24M/30d across ~75M +transactions (~$0.32 average; ~94k buyers / ~22k sellers; CoinDesk +2026-07-15), with Chainalysis flagging heavy meme-farming contamination in +historical counts and CoinDesk (2026-03-11) reporting micropayment demand "is +just not there yet." + +Timing gift: the **x402 Foundation formally launched under the Linux +Foundation on 2026-07-14** (40 members; Visa, Mastercard, Amex, Stripe, +Ripple premier). Building on x402 now inherits that legitimacy for free. + +## The adoption evidence: MCP vs A2A + +Measured outcome (pypistats, 2026-07-15): `mcp` ≈ 295.5M downloads/month vs +`a2a-sdk` ≈ 11.3M — **~26x** — despite A2A having the larger logo roster. +(Python-only counts that include CI/bot traffic; the ratio is more +trustworthy than the absolutes.) + +| Move | MCP (won) | A2A (logos without usage) | +|---|---|---| +| Launch | Complete adoption kit in one day (2024-11-25): spec + 2 SDKs + reference servers, **shipped working inside Claude Desktop**, with named adopters (Block, Apollo; Zed, Replit, Codeium, Sourcegraph) — no logo roster | Draft spec + 50 partner logos (2025-04-09), no shipped product | +| Namespace | MIT spec in vendor-neutral `modelcontextprotocol/`, never `anthropic/`; rival-co-maintained SDKs | Google-led, then donated | +| Foundation | Donated month 13 (2025-12-09, AAIF), **after** ~100M monthly downloads — ratification | Donated week ~10 (2025-06-23), **before** v1.0 — bought a coalition, not adoption | +| Growth engine | Mid-size AI editors (Cursor, Windsurf, Copilot agent mode) adopted first; OpenAI (2025-03-26) and Google (2025-04-09) ratified existing usage | 150+ orgs, 22k stars at year one; no named production customers | +| Registry | Month 10 (2025-09-08), open catalog, after organic supply | — | + +AP2 (Google's payments protocol, 2025-09-16, 60+ partners) confirms the +pattern: 100+ logos, ~3 named deployments by 2026-04. x402's ~75M measured +transactions beat AP2's roster as a credibility asset. + +**The rule: don't announce a standard without a paying deployment. Our +equivalent of Claude Desktop is one employer with a live collared skill and a +co-held claim — which is KC1.** + +## The playbook, applied + +1. **Adoption kit in one release**: Apache-2.0 spec + reference Collar + middleware (Hono/Express) + the pi-wielder proxy as reference client + the + offline zero-key demo. Every audience gets a working entry within an hour. +2. **Extension of incumbents, never a rival**: wrap Anthropic's SKILL.md + format unmodified (Vercel indexed a format it doesn't own); settle on x402 + (LF-governed as of yesterday); register provenance on Story. One-line + pitch: *attribution and metering once, payable from any 402-capable agent.* +3. **Neutral namespace now**: move the spec from the personal + `Aznatkoiny/skill-asset-protocol` to a `skill-asset-protocol/` org with + co-maintainer slots (even if empty for months). Costs a day; removes the + single-founder governance objection before it's raised. +4. **Two named adopters before the word "standard"**: the KC1 design-partner + employer (credibility anchor) + one x402 inference gateway speaking the + Collar flow (distribution surface: Router402, BlockRun ClawRouter, + tx402.ai). Without them we are running the A2A play. +5. **Mid-size ecosystem before giants**: gateway operators, OpenClaw/ClawHub + maintainers, Smithery, Story devs — the campaign's week-of-living-in-replies + already targets exactly these. Platforms ratify; they are not the ask. +6. **Listing = proof of settlement** (the Coinbase Bazaar mechanic): the + anti-skills.sh. Their telemetry listing produced ~895k mostly-noise + entries; settlement-gating produces a small index where every entry is + provably alive and paid. +7. **Discovery as an MCP server**: search → quote → pay (x402) → invoke in one + agent tool-loop. x402 Bazaar, Nevermined, and MCP Hive all converged on MCP + as the surface agents actually touch. +8. **Publish unfakeable telemetry, ranked by the hierarchy that predicted + MCP-vs-A2A**: settled tx + unique payers → active collared skills → SDK + pulls → stars. Matches the campaign's existing metric doctrine. +9. **Sell attribution as security simultaneously**: signed immutable skill + definitions + derivation graphs answer the documented registry + supply-chain wound (Unit 42: five malicious ClawHub skills incl. macOS + infostealers, 2026-02..05; Trail of Bits reportedly bypassed skills.sh's + Snyk scanning via prompt injection). Buyers get supply-chain safety, authors + get the claim substrate — same primitive, two pitches. +10. **Donate late, like MCP and x402**: foundation paperwork before usage is + pure distraction for a solo founder; revisit only on a credible fork + threat. + +## Sequence + +| When | Do | Gate | +|---|---|---| +| Week 0 (now) | Execute the slipped launch — verified 2026-07-15 via `gh`: repo still private vs Day 0 = 07-14 — with a re-anchored Day 0 and design-partner conversations as metric #1; **instantiate KC7's monthly platform review with a named owner** (mandated PRD:647, currently uninstantiated) | — | +| Weeks 1–2 | Neutral GitHub org; package the adoption kit; finish pi-wielder as reference Wielder | Cheap, parallel | +| Weeks 2–6 | Land the two named adopters: the KC1 LOI + one x402 gateway; first settled mainnet payment through a collared skill, provenance on Story | The KC1 LOI is the hard gate for everything downstream | +| Weeks 6–10 | Ship **the Skill Asset Protocol registry** as an MCP server with proof-of-settlement listing + public telemetry dashboard | Only after organic supply exists; named registry, never marketplace | +| Months 3–4 | Attribution overlay on existing free registries (signed authorship + derivation records keyed to GitHub owner/repo); court ClawHub/Smithery — their malware problem is the sales wedge | — | +| Months 4–6+ | Phase-1 build per PRD | KC1 LOI signed + paying closed-mode deployment | + +**Standing rule: a design-partner conversation outranks any protocol task.** +MCP won because a product people ran shipped on day one; ours is one employer +with a live collared skill. + +## Risks recorded + +- **KC7 fires** (when-not-if, R17): optionality written to zero that day; the + registry survives because it indexes the closed-mode ledger. The monthly + review has no named owner yet — a live compliance gap; it should watch + Vercel and Story alongside Anthropic/OpenAI/GitHub. +- **Vercel monetizes skills.sh**: owns installer + telemetry. The paid-tier + rumor is uncorroborated and disputed — treat it as false today; the live + risk is Vercel's *option* to monetize later, and any overlay riding + skills.sh telemetry is exposed to Vercel productizing it. +- **Story disintermediates**: Story Skills SDK is the only purpose-built + on-chain royalty rail, and what it lacks versus this stack — no execution + gate, no Wielder-hidden runtime, no per-invocation meter (PRD:514) — is + buildable. Mitigation already specified (R15 exportable derivative graph, + PRD:678) — make it real before deepening the dependency. +- **The A2A trap**: a spec launch without a paying flagship is a + well-governed ghost town. Hence playbook #4. +- **Distillation remains unsolved** for any open supply; royalties on + *installable* SKILL.md files stay an open research problem — everyone + *verifiably* paying authors today does it by hosting. +- **Vocabulary/securities surface**: "crypto marketplace" framing invites + exactly the banned vocabulary (earn/yield/tradeable); the registry ships + under the compliance rules of the campaign kit, and nothing transferable + exists before the PRD:780/786 gates. +- **Registry supply-chain bill comes due at v1**: signed, immutable + definitions and provenance are launch requirements, not a verified tier + later (MCP tool-poisoning CVEs, ClawHub infostealers, skills.sh scanner + bypass are the base rate). + +## What this doc does not decide + +- It does **not** reopen ADR-0007. Marketplace stays underwritten optionality + with unchanged gates. +- Registry build is **not** authorized before the KC1 LOI — weeks 6–10 + assume the LOI lands; if kill-criterion 1 fires instead, the registry dies + with Phase 1 and the provenance layer stands alone. +- x402 volume quality (meme contamination) and skills.sh's paid-tier rumor + are unverified beyond the citations above. +- The neutral-org move and adoption-kit packaging are recommended but not yet + scheduled against the slipped campaign calendar — sequencing vs the + re-anchored Day 0 is the founder's call. From b6d4861d2ecf295c4277b114dc2fa58b37891013 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Wed, 15 Jul 2026 12:43:24 -0400 Subject: [PATCH 021/165] PRD: feed measured Phase-A/spike numbers back into the canonical doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes design-doc step 4 (promised 2026-07-11, RUNBOOK 'what stays manual'). Dated amendments only, caveats preserved: - KC2: cold ~2.5s / warm ~1.5s (n=3) measured 2026-07-12 — does not fire at first bound; production-n distribution still open. - KC4: N=6 first measurement ($1.58 attack, modeled 8-invocation break-even, all fidelity gates failed) — marked do-NOT-cite-as-resolved. - Pi-Wielder spike marked executed: ~781ms x402 overhead (n=1, testnet), on-chain reconciliation exact, live pi v0.80.6 demo 2026-07-15 (8 paid streaming calls); p50/p95 at n≈30 + gpt leg still pending. - Education: fork-economics re-run 2026-07-12 recorded as negative everywhere the PRD said 'deferred pending re-run' — only restructured forms (living content / direct licensing) remain live. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U2V9gsyEvYTSoZL4YkWYio --- docs/PRD.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 76d9501..3f4e93d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -36,7 +36,7 @@ The protocol has **three modes, and they are not equals**: **Intra-org** (employ **Honest architecture:** the vision validates as real but does *not* compose into one atomic action. It is a **decoupled two-leg settlement**. Leg 1 is the gate — x402 settles gasless USDC on Base (chainId 8453); the replay-proof txHash is the single-use execution credential, checked off-chain so the Wielder pays first and the agent runs after. Leg 2 is royalties — an off-chain worker batches payments, bridges and swaps USDC into WIP on Story (chainId 1514), calls `payRoyaltyOnBehalf`, and runs a keeper that pull-claims for ancestors. Royalty flow-through is therefore **eventually-consistent and claimable**, not atomic per call. Do not attempt to make x402 settle directly to Story (wrong chain, wrong token, wrong primitive — see `docs/feasibility/report.md` §4.1). -**Go-to-market is Intra-org first, pitched as compensation and retention — not royalty upside.** Intra-org faces the least cloning pressure, is on-platform by construction, and can keep Royalty claims **non-transferable**, which is the best available route to staying outside securities law — though not a guaranteed safe harbor, and counsel must draft the actual instrument before Phase 1 ships. Education is deferred pending a re-run of the fork-economics spike whose alternative branch is "re-author with class knowledge ≈ free." Phase 0 ships provenance immediately (register Skills as Story IP Assets and Derivatives); Phase 1 adds the gate, run, and an off-chain metered ledger — **and Phase 1 is the terminal state by design**. Phase 2 (on-chain batched royalty settlement) and Phase 3 (the tradeable Marketplace — permissioned only: ATS, transfer agent, exemption, KYC, securities counsel engaged first, because tradeable claims are securities under Howey) are underwritten optionality, exercised on evidence, never load-bearing. +**Go-to-market is Intra-org first, pitched as compensation and retention — not royalty upside.** Intra-org faces the least cloning pressure, is on-platform by construction, and can keep Royalty claims **non-transferable**, which is the best available route to staying outside securities law — though not a guaranteed safe harbor, and counsel must draft the actual instrument before Phase 1 ships. Education stays deferred: the fork-economics re-run (2026-07-12), with "re-author with class knowledge ≈ free" as an explicit branch, found forking strictly dominated at every inherit rate > 0 — it un-defers only if school-captured living value clears the measured threshold. Phase 0 ships provenance immediately (register Skills as Story IP Assets and Derivatives); Phase 1 adds the gate, run, and an off-chain metered ledger — **and Phase 1 is the terminal state by design**. Phase 2 (on-chain batched royalty settlement) and Phase 3 (the tradeable Marketplace — permissioned only: ATS, transfer agent, exemption, KYC, securities counsel engaged first, because tradeable claims are securities under Howey) are underwritten optionality, exercised on evidence, never load-bearing. **Two strategic risks sit above everything else, and both sit below the chain.** First, off-platform behavioral cloning: because the Wielder receives the output, and for most Skills the output *is* the value, a high-volume Skill is the cheapest thing to clone — its own paid input/output pairs are a roughly 30x-cheaper distillation set (`report.md` §5; ADR-0004 Update). Watermarking is a forensic tripwire, not a moat. The protocol defends the *marketplace* (liquidity, provenance, declared-derivative royalties), not an individual breakout Skill. The recommended response — **price below amortized clone cost, out-evolve via live updates, bind value to live tool and data access** — is load-bearing but rests on an *unmeasured* assumption: no source quantifies how fast a Skill must change to keep a distilled clone economically stale (`report.md` §7.7). The reframe blunts this risk without dismissing it: in the Intra-org compensation frame the employer already possesses the Skill, so clone-resistance is not what the product sells there — attribution and compensation are. Second, a **platform-native skill marketplace** (Anthropic, OpenAI, GitHub): the GPT Store precedent (OpenAI, Jan 2024) proves platforms do ship native skill-adjacent marketplaces — and also that builder monetization demand was weak even with zero-friction distribution (research, 2026-07). Both readings are now priced in: kill-criterion 7 monitors the announcement, and the plan survives it because the compensation product is not what a platform marketplace replaces. @@ -63,7 +63,7 @@ A skilled author writes a genuinely valuable Skill and has exactly two bad optio The employee who builds a Skill at work today gets work-for-hire's 100/0 split and watches their leverage evaporate. The employer's reciprocal pain is retention and incentive: their best people have every reason to hoard expertise, build Skills on the side, or leave. The mode replaces 100/0 with a **co-held Royalty claim** — employee and employer both hold a fractional, co-holdable claim on the Skill, and both earn from *external* invocations (direct/private in Phase 1; via the open Marketplace only if that optionality is ever exercised). The prototype makes this concrete (`recon`: Sam 50% + MegaCorp 50%, both paid on an external invocation). *Who pays:* an **external Wielder/Beneficiary** outside the org; the internal split is the alignment mechanism, not the revenue source. Claims here are kept **non-transferable**, the best available route to keeping the mode outside securities law (ADR-0006) — a reason it ships before the marketplace. **Assumption flagged:** whether mid-size employers will actually restructure work-for-hire IP terms into a co-held claim is *unvalidated* (R12, rated Medium-High); the design-partner LOIs that would prove it do not yet exist and are a Phase-0/1 gate (see [Team, Capital, Timeline & Kill-Criteria](#team-capital-timeline--kill-criteria)). **3. Schools + Students + Employers (Education mode).** -A school teaches a capability but captures none of its graduates' downstream economic value; a student graduates with debt and a credential but no durable, owned, income-producing asset; an employer wants the capability but has no clean per-use rail to pay for it. The mode threads all three: the **school authors a base Skill**, the **student forks it into a Derivative they own** (becoming a Creator), and wields that Derivative at work. *Who pays:* the **employer is the Beneficiary** and pays per Invocation; royalties **split to the student's Derivative and flow through to the school** as an ancestor. The prototype's `biofin` (forks `finmod` at 30% inherit) shows the split. The asset the student graduates with is a real Royalty claim. **Open economics question:** the prototype flags a "fork-killing threshold" — at high ancestor-royalty rates forking stops being worth it; the right inherit-bps is *unresolved* and is the key economics experiment in `prototype/README.md` (verdict currently **TBD**). See [Economic Design](#economic-design). **Status (2026-07-11): this mode is demoted to deferred.** The premise review surfaced a bypass the economics question understated: provenance cannot distinguish "forked the school's Skill" from "re-authored an equivalent Skill using what the class taught" — which is nearly free and pays the school nothing. Education un-defers only if a re-run of the fork-economics spike, with "re-author with class knowledge ≈ free" as an explicit alternative branch, still shows a real forking incentive (see [Product & User Experience](#product--user-experience)). +A school teaches a capability but captures none of its graduates' downstream economic value; a student graduates with debt and a credential but no durable, owned, income-producing asset; an employer wants the capability but has no clean per-use rail to pay for it. The mode threads all three: the **school authors a base Skill**, the **student forks it into a Derivative they own** (becoming a Creator), and wields that Derivative at work. *Who pays:* the **employer is the Beneficiary** and pays per Invocation; royalties **split to the student's Derivative and flow through to the school** as an ancestor. The prototype's `biofin` (forks `finmod` at 30% inherit) shows the split. The asset the student graduates with is a real Royalty claim. **Open economics question:** the prototype flags a "fork-killing threshold" — at high ancestor-royalty rates forking stops being worth it; the right inherit-bps is *unresolved* and is the key economics experiment in `prototype/README.md` (verdict currently **TBD**). See [Economic Design](#economic-design). **Status (2026-07-11): this mode is demoted to deferred.** The premise review surfaced a bypass the economics question understated: provenance cannot distinguish "forked the school's Skill" from "re-authored an equivalent Skill using what the class taught" — which is nearly free and pays the school nothing. Education un-defers only if a re-run of the fork-economics spike, with "re-author with class knowledge ≈ free" as an explicit alternative branch, still shows a real forking incentive (see [Product & User Experience](#product--user-experience)). **Re-run 2026-07-12: it does not — forking is strictly dominated at every inherit rate > 0; only the restructured forms (living school content, direct licensing) remain live.** ### Why now @@ -89,7 +89,7 @@ Two honest qualifications. First, **every live gateway is a third-party reseller The strategic consequence is ADR-0008: **the Wielder is a wallet, not a harness.** The entire Wielder-side protocol footprint is *answer HTTP 402 with a signed USDC payment and retry* — no Story SDK, no token custody, no chain reads client-side. The invocation-right is exercised by paying, not held. Every wallet the inference gateways install is a Skill-ready Wielder the moment a Collar exists to answer it. -**The validation experiment for this wedge is the Pi-Wielder spike (`spikes/pi-wielder/`).** Pi (earendil-works/pi, formerly badlogic/pi-mono) is a ~70k-star MIT TypeScript coding agent — multi-provider, custom `baseUrl` support, mid-session model switching, and **no wallet or x402 support shipped** (research, 2026-07): the ideal thin-payer testbed. The spike has Pi pay per-call for inference AND invoke one hosted Skill behind a mock Collar, producing one unified attributed session ledger, and it measures x402 payment overhead per call (sign → verify → settle p50/p95) plus end-to-end skill-invocation latency. Testnet-only, zero real money. **Discipline note: the spike proves the thin-payer client by construction and measures overhead; it is *not* demand evidence** — willingness-to-pay remains unvalidated (R12; see [What we have NOT validated](#what-we-have-not-validated)). +**The validation experiment for this wedge is the Pi-Wielder spike (`spikes/pi-wielder/`).** Pi (earendil-works/pi, formerly badlogic/pi-mono) is a ~70k-star MIT TypeScript coding agent — multi-provider, custom `baseUrl` support, mid-session model switching, and **no wallet or x402 support shipped** (research, 2026-07): the ideal thin-payer testbed. The spike has Pi pay per-call for inference AND invoke one hosted Skill behind a mock Collar, producing one unified attributed session ledger, and it measures x402 payment overhead per call (sign → verify → settle p50/p95) plus end-to-end skill-invocation latency. Testnet-only, zero real money. **Discipline note: the spike proves the thin-payer client by construction and measures overhead; it is *not* demand evidence** — willingness-to-pay remains unvalidated (R12; see [What we have NOT validated](#what-we-have-not-validated)). **Executed (2026-07-12/15): ~781 ms x402 payment overhead per call against the live testnet facilitator (n=1 instrumented; the facilitator leg is 776 ms of it — mainnet Flashblocks claims ~200 ms, so this is likely an upper bound), real Base Sepolia settlements reconciled on-chain to the cent, and an unmodified pi v0.80.6 session paying per-call through the proxy (8 streaming calls, 2026-07-15). The p50/p95 distribution (n≈30, incl. the gpt leg) is still pending.** ### The GPT Store precedent (the base rate to respect) @@ -188,7 +188,7 @@ Closed population, aligned incentives, lowest cloning pressure — which is why ### Mode (c) — Education: school authors a base Skill, student forks and owns the Derivative — **DEFERRED (2026-07-11)** -> **Status: demoted from launch mode to deferred.** The premise review surfaced a free bypass this mode cannot currently answer: provenance can prove a student *forked the school's Skill*, but it cannot distinguish that from a graduate who **re-authors an equivalent Skill using what the class taught** — which is nearly free and pays the school nothing. The fork-economics spike (`prototype/README.md` experiment 5) must therefore be **re-run with "re-author with class knowledge ≈ free" as an explicit alternative branch**; Education un-defers only if that spike still shows a real forking incentive. Two restructurings of the school's claim to evaluate inside that spike: **(1) living, school-maintained content** — the base Skill stays worth forking because the school keeps evolving it, and re-authoring forfeits the update stream; or **(2) direct school→employer licensing** — drop the student-fork hop entirely and license the school's Skill to employers per Invocation. The walkthrough below is preserved as a design record, not a launch plan. +> **Status: demoted from launch mode to deferred.** The premise review surfaced a free bypass this mode cannot currently answer: provenance can prove a student *forked the school's Skill*, but it cannot distinguish that from a graduate who **re-authors an equivalent Skill using what the class taught** — which is nearly free and pays the school nothing. The fork-economics spike (`prototype/README.md` experiment 5) must therefore be **re-run with "re-author with class knowledge ≈ free" as an explicit alternative branch**; Education un-defers only if that spike still shows a real forking incentive. Two restructurings of the school's claim to evaluate inside that spike: **(1) living, school-maintained content** — the base Skill stays worth forking because the school keeps evolving it, and re-authoring forfeits the update stream; or **(2) direct school→employer licensing** — drop the student-fork hop entirely and license the school's Skill to employers per Invocation. The walkthrough below is preserved as a design record, not a launch plan. **Re-run executed 2026-07-12: the free re-author branch strictly dominates forking at every inherit rate > 0 — no forking incentive found. The deferral stands; only restructurings (1)/(2) remain live** (`prototype/README.md` NOTES; `docs/plans/2026-07-12-phase-a-findings.md`). The richest journey, because it spans years and produces an asset the student literally graduates with (CONTEXT.md example dialogue). Cast: **State U** (school-Creator of the base Skill), **Mia** (student → graduate, who forks it into a Derivative she owns and wields at work), **BioCorp** (Mia's employer, the Beneficiary who pays per Invocation). @@ -287,7 +287,7 @@ This trust re-centralization is the price of the cross-chain reality. The Collar | Capability | Phase | Status in v1 | |---|---|---| | Register Skills as Story IP Assets + declared Derivatives (provenance) | **Phase 0** | **v1** — ships immediately, soundest step | -| Collar as sole key-holder + x402 resource server; Leg 1 gate; off-chain metered ledger; closed modes (Intra-org; Education deferred pending the fork-economics re-run — see [Product & User Experience](#product--user-experience)); claims **non-transferable** | **Phase 1** | **v1** | +| Collar as sole key-holder + x402 resource server; Leg 1 gate; off-chain metered ledger; closed modes (Intra-org; Education deferred — fork-economics re-run 2026-07-12 negative, see [Product & User Experience](#product--user-experience)); claims **non-transferable** | **Phase 1** | **v1** | | On-chain batched royalty settlement (Leg 2: bridge/swap → `payRoyaltyOnBehalf` → keeper `claimAllRevenue`) | **Phase 2** | **Deferred** to Phase 2 (the shared-loop "claim" is an off-chain-ledger withdrawal in v1) | | TEE / confidential execution (hide Skill from host; structural fix for the accumulator) | revenue-triggered | **Deferred with a committed trigger** — any Skill whose credited revenue exceeds the Phase-1-set threshold moves to confidential execution (no longer open-endedly tabled) | | Open Marketplace + **tradeable** royalty claims (securities: ERC-3643 allow-list + Reg D 506(c)/Reg A+/CF + registered ATS like Securitize + transfer agent + KYC) | **Phase 3** | **Deferred** — counsel-gated; closed-mode claims stay non-transferable | @@ -546,7 +546,7 @@ That is a genuinely novel assembly. It is **not** a defensible *technical* monop ## Go-to-Market & Rollout -> **The wedge is Intra-org — as an assumption to validate, not an established market — and the pitch leads with compensation and retention, not royalty upside.** Sell the employer a compensation/attribution instrument for its Skill-building employees — the ArbEG / patent-award / tech-transfer pattern with a metering rail (research, 2026-07) — where external-invocation royalties are the sweetener, not the headline. Education is a later motion, deferred pending the fork-economics re-run (see [Product & User Experience](#product--user-experience)). **The willingness of employers to co-hold royalty claims is UNVALIDATED (R12, Medium-High); securing design-partner LOIs is an explicit Phase-0/1 gate, not a backdrop.** +> **The wedge is Intra-org — as an assumption to validate, not an established market — and the pitch leads with compensation and retention, not royalty upside.** Sell the employer a compensation/attribution instrument for its Skill-building employees — the ArbEG / patent-award / tech-transfer pattern with a metering rail (research, 2026-07) — where external-invocation royalties are the sweetener, not the headline. Education is a later motion, deferred — the fork-economics re-run (2026-07-12) found no forking incentive under free re-authoring (see [Product & User Experience](#product--user-experience)). **The willingness of employers to co-hold royalty claims is UNVALIDATED (R12, Medium-High); securing design-partner LOIs is an explicit Phase-0/1 gate, not a backdrop.** ### Why Intra-org wins the wedge (and Education does not, yet) @@ -558,7 +558,7 @@ Both closed modes are the right *place* to start — closed populations, aligned 4. **Lower cloning pressure, by construction.** Intra-org Skills bind value to *fresh private context and live internal tool/data access* — exactly the recommended anti-clone posture (ADR-0004; report §5). 5. **It is the natural distribution channel for Education — if Education un-defers.** Land the employer, prove the co-held claim pays, and the *same* employer becomes the Beneficiary in an Education deal (or the direct school→employer licensing variant). -**Decision: ship Intra-org first, pitched as compensation/retention. Education is a later motion — deferred pending the fork-economics re-run (free-bypass branch: "re-author with class knowledge ≈ free") — and, if it un-defers, sells into the same accounts.** Both rest on the unvalidated willingness-to-co-hold assumption (R12). +**Decision: ship Intra-org first, pitched as compensation/retention. Education is a later motion — deferred, and the 2026-07-12 fork-economics re-run (free-bypass branch: "re-author with class knowledge ≈ free") found forking strictly dominated, so it un-defers only via restructurings (1)/(2) — and, if it un-defers, sells into the same accounts.** Both rest on the unvalidated willingness-to-co-hold assumption (R12). ### Ideal first customer profile (an assumption to test) @@ -593,7 +593,7 @@ A **mid-size, AI-forward services or product firm (roughly 100–800 people) whe | ADR-0006 phase | GTM motion | Goal | |---|---|---| | **Phase 0 — Provenance** | Self-serve, free, viral. "Register your Skills, own your lineage." | Build the derivative graph; top-of-funnel; provenance as trust default. | -| **Phase 1 — Intra-org (terminal by design; Education deferred)** | Founder-led design-partner sales to 3–5 ICP accounts **(target; none signed yet — R12)**. **Pitch = compensation/retention (the ArbEG / patent-award pattern), royalty upside as sweetener.** Co-held **non-transferable** claims; gate + run + off-chain meter. Education is a later motion, gated on the fork-economics re-run. | Prove the loop pays Creators from external invocations; harden the Collar inside the safest regulatory envelope. This phase must stand alone as a complete product (ADR-0007). | +| **Phase 1 — Intra-org (terminal by design; Education deferred)** | Founder-led design-partner sales to 3–5 ICP accounts **(target; none signed yet — R12)**. **Pitch = compensation/retention (the ArbEG / patent-award pattern), royalty upside as sweetener.** Co-held **non-transferable** claims; gate + run + off-chain meter. Education is a later motion (re-run 2026-07-12 negative; only restructured forms remain live). | Prove the loop pays Creators from external invocations; harden the Collar inside the safest regulatory envelope. This phase must stand alone as a complete product (ADR-0007). | | **Phase 2 — On-chain batched settlement** | Expand within proven accounts. Turn the off-chain accumulator into on-chain settled, published batches; ride a licensed facilitator/bridge (merchant-on-Stripe). | Make settlement auditable; de-risk MSB exposure. | | **Phase 3 — Open Marketplace + tradeable claims** | Permissioned launch, counsel-gated. ERC-3643 + Reg D 506(c)/Reg A+/CF + registered ATS + transfer agent + KYC. | Open the composable royalty market only when warranted — highest-cloning, full-securities surface, shipped last. | @@ -639,9 +639,9 @@ This section gives the founder/investor-grade numbers the rest of the document i Stop or restructure if any of these fire — investors should hold us to them: 1. **No design-partner LOI to co-hold within the Phase-0 window.** If, after the free Provenance funnel runs, **no ICP employer will sign an LOI to co-hold a royalty claim** (restructuring work-for-hire), the Intra-org wedge is invalidated (R12) — do not build Phase 1 on spec. -2. **Cold-start latency makes pay-first-then-async unusable.** If measured `sessions.create` → first-token latency (currently unmeasured, `report.md` §7.3) is so high or variable that the async UX is unacceptable and no pooling fix exists, the gate UX premise fails. +2. **Cold-start latency makes pay-first-then-async unusable.** If measured `sessions.create` → first-token latency is so high or variable that the async UX is unacceptable and no pooling fix exists, the gate UX premise fails. **Measured 2026-07-12 (first bound, n=3, Sonnet): cold ~2.5 s to first answer token, warm ~1.5 s — composing with the measured ~0.8 s testnet x402 gate into ~3.3 s pay→output. The criterion does not fire at this bound** (`docs/plans/2026-07-12-phase-a-findings.md`; `prototype/README.md`); re-measure at production n before committing an SLA. 3. **Inference COGS exceeds defensible price.** If, against real Skill token profiles, **inference COGS + settlement cost routinely exceeds what Beneficiaries will pay** (i.e., contribution margin is negative at viable prices), the unit economics do not close. -4. **A breakout closed-mode Skill is cloned within weeks of launch with no economic counter.** If a high-value Skill is behaviorally cloned faster than live-evolution can stay ahead — and the unmeasured evolution-cadence defense (`report.md` §7.7) proves ineffective — even the closed-mode value prop is at risk; re-underwrite before Marketplace. +4. **A breakout closed-mode Skill is cloned within weeks of launch with no economic counter.** If a high-value Skill is behaviorally cloned faster than live-evolution can stay ahead — and the unmeasured evolution-cadence defense (`report.md` §7.7) proves ineffective — even the closed-mode value prop is at risk; re-underwrite before Marketplace. **First measurement 2026-07-12 (N=6 — do NOT cite as resolved; `spikes/clone-economics/README.md`): the self-run attack cost ~$1.58 with a modeled 8-invocation break-even — economics provide zero protection — but the clone failed all six held-out fidelity gates, and a synthetic evolution overlay doubled the target–clone gap in one revision. Fidelity is the only observed moat; high-N saturation is the open question.** 5. **Counsel cannot draft the closed-mode instrument.** The bar is upgraded (2026-07-11) from "counsel blesses the structure" to "counsel **drafts the actual instrument**" — a deferred-comp / license-fee agreement that survives §409A, resolves the on-demand-withdrawal vs. fixed-payment-events conflict (the Phase-1 "withdraw anytime" UX is close to the constructive-receipt fact pattern), and specifies vesting, clawback, and termination ("when Sam quits"). If no draftable instrument keeps the claim outside securities treatment (medium-confidence today), the whole "launch closed first" sequencing must be reworked. 6. **Story / $IP existential degradation.** If Story sunsets or $IP liquidity collapses below a usable settlement threshold and no mitigation lands (see R15), the on-chain layer must be re-platformed or the protocol re-scoped. 7. **A platform-native skill marketplace is announced (added 2026-07-11).** If Anthropic, OpenAI, or GitHub publicly announces or betas a native skill marketplace or skill-monetization program with builder payouts, the open-Marketplace optionality is written to zero that day — the GPT Store (launched January 2024 with a piloted builder revenue-share) is the base rate proving platforms ship these natively and within quarters (research, 2026-07). **Monitoring trigger (concrete, owned):** a standing monthly review — logged in the ops calendar with a named owner — of Anthropic, OpenAI, and GitHub changelogs, developer-event announcements, and marketplace/revenue-share program launches; the criterion fires on a public announcement or beta with builder payouts, not on rumor. **This criterion restructures rather than kills:** Phase-3 investment stops, and the closed-mode compensation product must stand alone — which it is designed to do (Phase 1 is the terminal state by design, ADR-0007). The counter-positioning is the one stated in Problem & Market: neutrality, cross-platform provenance, the securities-barred mechanism, and compensation products platforms won't build. @@ -685,7 +685,7 @@ The feasibility study (`docs/feasibility/report.md`, `docs/feasibility/findings. Run **immediately before committing engineering.** The first four are the priority set (verdict, report §4.3 / §7). -1. **Benchmark cold `sessions.create` → first-token latency** (unmeasured, §7.3). Sizes the SLA and confirms pay-first-then-async (almost certainly required: x402 `maxTimeoutSeconds` ~60s < a managed-agent run). *Output: latency distribution the async design must absorb.* **Also feeds kill-criterion 2.** +1. **Benchmark cold `sessions.create` → first-token latency** (§7.3). Sizes the SLA and confirms pay-first-then-async (almost certainly required: x402 `maxTimeoutSeconds` ~60s < a managed-agent run). *Output: latency distribution the async design must absorb.* **Also feeds kill-criterion 2.** **Done 2026-07-12 at first bound: cold ~2.5 s / warm ~1.5 s (n=3, Sonnet; `prototype/README.md`); higher-n distribution pending.** 2. **Confirm no x402 facilitator has added Story 1514** ([x402.org/ecosystem](https://www.x402.org/ecosystem)). *Output: confirm two-leg is still mandatory.* 3. **Confirm the CDP fee schedule + Story's WIP-only royalty whitelist** ([Story royalty docs](https://docs.story.foundation/concepts/royalty-module/overview)). USDC whitelisting would remove the swap leg and defang R7. *Output: confirmed unit-economics inputs.* 4. **Decide the credential primitive.** Confirm the x402 settled `txHash` suffices as the off-chain credential and that a Story License Token is *not* needed per call (uneconomic at per-call cadence). *Output: a decision.* @@ -695,12 +695,12 @@ Secondary spikes (resolve before the relevant phase): 5. **Inference-COGS / price model** — model per-invocation Anthropic cost (CMA $0.08/active session-hour + per-token ITPM/OTPM) against real Skill token profiles to set the pass-through model and confirm contribution margin (feeds kill-criterion 3). *Currently undefined.* 6. **Leg-2 economics on real Story mainnet** — exact USDC(Base)→WIP(Story) bridge cost + confirmation time vs. generic quotes (Phase 2; §7.4). 7. **Long-lived-session isolation across buyers** — whether one CMA session cleanly isolates distinct buyers; likely one-session-per-buyer, reintroducing the ceiling (R10; §7.5). -8. **Fork-killing threshold (economics) — re-run required (2026-07-11)** — run the `prototype/` engine (experiments 4–6) to **test** the `i* = p_parent/p_fork` *hypothesis*, find where forks collapse as inherit-bps rises, and where the fee feels extractive — **now with an explicit alternative branch: "re-author with class knowledge ≈ free."** A rational graduate can re-author an equivalent Skill instead of forking, at near-zero cost, paying the school nothing; the spike must price *fork vs. re-author*, not just fork vs. not-fork. **Education stays deferred until this spike shows a real forking incentive** (see Product & UX). This spike exists precisely because the threshold is OPEN (TBD); it sets launch defaults — the closed form is a hypothesis to validate here, not a settled answer (see Economic Design). +8. **Fork-killing threshold (economics) — re-run required (2026-07-11)** — run the `prototype/` engine (experiments 4–6) to **test** the `i* = p_parent/p_fork` *hypothesis*, find where forks collapse as inherit-bps rises, and where the fee feels extractive — **now with an explicit alternative branch: "re-author with class knowledge ≈ free."** A rational graduate can re-author an equivalent Skill instead of forking, at near-zero cost, paying the school nothing; the spike must price *fork vs. re-author*, not just fork vs. not-fork. **Education stays deferred until this spike shows a real forking incentive** (see Product & UX). This spike exists precisely because the threshold is OPEN (TBD); it sets launch defaults — the closed form is a hypothesis to validate here, not a settled answer (see Economic Design). **Re-run executed 2026-07-12 (`prototype/spike-fork-economics.mjs`): with free re-authoring as a branch, forking is strictly dominated at every inherit rate > 0 — no forking incentive exists unless school-captured living value ≥ the ancestor payout (minimum V per rate in the spike output). Education's deferral stands; the surviving branches are restructurings (1)/(2) — living school-maintained content or direct school→employer licensing — not the student-fork hop.** 9. **`receiveWithAuthorization` on WIP / `RoyaltyModule.sol`** — contract-level read for any hypothetical direct-to-contract settlement (almost certainly absent; confirm). 10. **Live-evolution anti-clone efficacy** — load-bearing for the no-TEE defense; *asserted by analogy, unmeasured* (§7.7). No source quantifies required change cadence (R1; feeds kill-criterion 4). 11. **Off-platform Story enforcement** — real dispute/takedown outcomes for behavioral clones (on-chain provenance vs. off-chain courts) are undocumented (§7.8). 12. **Counsel drafts the Collar architecture read + the closed-mode instrument** — no regulatory source squarely analyzes the per-invocation agent-to-agent fact pattern; the MSB and the non-transferable-outside-Howey analyses are both extrapolated. **Counsel must bless the architecture AND draft the actual deferred-comp/409A instrument (on-demand-withdrawal vs. fixed-payment-events; vesting/clawback/termination) before launch** (R2, R3; feeds kill-criterion 5). -13. **Pi-Wielder spike (`spikes/pi-wielder/`)** — added 2026-07-11; the demand-side wedge's validation experiment. Prove the thin-payer client by construction — a ~100-line paying proxy is the *entire* Wielder-side protocol footprint (ADR-0008) — and measure x402 payment overhead per call (sign → verify → settle, p50/p95) plus end-to-end skill-invocation latency, with one wallet paying across inference calls and a skill invocation into one unified attributed ledger (split correctness checked against `prototype/settlement-engine.mjs`). Testnet-only; zero real money. *Output: measured payment-overhead and latency distributions to feed back into this document's demand-side section — NOT demand evidence (R12 stands).* +13. **Pi-Wielder spike (`spikes/pi-wielder/`)** — added 2026-07-11; the demand-side wedge's validation experiment. Prove the thin-payer client by construction — a ~100-line paying proxy is the *entire* Wielder-side protocol footprint (ADR-0008) — and measure x402 payment overhead per call (sign → verify → settle, p50/p95) plus end-to-end skill-invocation latency, with one wallet paying across inference calls and a skill invocation into one unified attributed ledger (split correctness checked against `prototype/settlement-engine.mjs`). Testnet-only; zero real money. *Output: measured payment-overhead and latency distributions to feed back into this document's demand-side section — NOT demand evidence (R12 stands).* **Executed: offline e2e green (2026-07-11); real Base Sepolia run 2026-07-12 — 402 roundtrip 3.9 ms + EIP-3009 sign 1.1 ms + facilitator verify/settle 776 ms ≈ 781 ms payment overhead per call (n=1 instrumented; facilitator leg dominates; gpt leg skipped — no OpenAI key that day), settlements reconciled on-chain to the cent; live demo 2026-07-15 — unmodified pi v0.80.6 paid per-call through the proxy (8 streaming calls, $0.328). Remaining: p50/p95 at n≈30 incl. the gpt leg (`spikes/pi-wielder/README.md`).** --- @@ -708,7 +708,7 @@ Secondary spikes (resolve before the relevant phase): A single honest box, because the verdict is GO-**with-caveats** and several load-bearing facts could change the plan. **Regulatory is rated *medium* confidence overall; everything below is unmeasured or unverified as of mid-2026** (sourced to `report.md` §7 and `findings.json`). -- **Cold-start latency** (`sessions.create` → first token) — **unmeasured** (§7.3). Sizes the entire pay-first-then-async UX. +- **Cold-start latency** (`sessions.create` → first token) — **first bound measured 2026-07-12** (cold ~2.5 s / warm ~1.5 s, n=3, Sonnet): kill-criterion 2 does not fire at this bound, but the **production-n distribution remains unmeasured** (§7.3). - **Real Leg-2 settlement cost & latency** (USDC(Base)→WIP(Story) bridge/swap on Story mainnet) — **unmeasured** (§7.4). The pricing-floor and batch-window models depend on it; the fee table in Economic Design is illustrative. - **Inference unit economics** — the Collar pays Anthropic per run; **no model yet ties Wielder price to inference COGS + settlement + fee + royalty.** Until spiked, **business unit economics are undefined.** - **Live-evolution anti-clone efficacy** — **asserted by analogy, unmeasured** (§7.7). The load-bearing "price below amortized clone cost / out-evolve the clone" prescription rests on an unquantified assumption. @@ -717,7 +717,7 @@ A single honest box, because the verdict is GO-**with-caveats** and several load - **License Token as non-burned off-chain credential** — **unverified** (§7.1); treated as off-chain entitlement pending a spike. - **Regulatory fact pattern** — **medium confidence; no source squarely analyzes the per-invocation agent-to-agent Collar** (§7.2). Both the MSB merchant-not-transmitter posture and the non-transferable-outside-Howey conclusion are extrapolations counsel must bless. - **Demand-side willingness-to-pay and willingness-to-co-hold** — **no LOI, no pilot, no pricing research** (R12). The only demand signal is x402 aggregate volume, which is agent-infra micropayments, not skill royalties. -- **Fork-killing threshold** — **OPEN / TBD** (`prototype/README.md`). The `i* = p_parent/p_fork` closed form is a hypothesis, not a result — and the re-run must add the "re-author with class knowledge ≈ free" branch (the Education demotion, 2026-07-11). +- **Fork-killing threshold** — **re-run executed 2026-07-12 with the free re-author branch: forking is strictly dominated at every inherit rate > 0** unless school-captured living value clears the measured per-rate minimum (`prototype/README.md` NOTES). What remains unvalidated is whether restructurings (1)/(2) — living school content or direct school→employer licensing — produce that value in reality. - **Co-authorship / multi-Creator origin** — **open design question** (CONTEXT.md, Relationships), unmodeled in v1. - **Fraction of the skill supply that is host-compatible** — **unmeasured** (added 2026-07-11). Claude Code skills are context-bound (they assume a harness, local files, tool access); hosting strips most of that value for some unknown fraction of the supply. If the fraction is small, the addressable supply for the hosted meter shrinks accordingly. Flagged in CONTEXT.md. - **Skill half-life** — **unmeasured** (added 2026-07-11). No source quantifies how fast authored Skills depreciate against the model release cadence; the model-absorbable vs. live-access-bound segmentation (Problem & Market) is a hypothesis without a decay curve, and claim durations/vesting are being designed against it blind. From ee9a01bfee302ae792d6fa0c6a5dd0760a3f0083 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Wed, 15 Jul 2026 12:46:15 -0400 Subject: [PATCH 022/165] Launch-week housekeeping: KC7 instantiated, slip recorded, handoff refreshed - docs/ops/kc7-platform-marketplace-review.md: the PRD-mandated monthly review now exists with a named owner and its first logged review (2026-07-15: no trigger; adjacent watchlist noted). - Campaign plan: Day-0 slip recorded (repo still private 07-15, no pre-flight log) with proposed re-anchor Day 0 = Thu 07-16 preserving weekday semantics; X reply routine starts immediately. - docs/marketing/artifacts/: raw 402 response + reconciled ledger render pre-captured for X posts 1-2. - Fresh handoff (2026-07-15) supersedes the completed 07-11 handoff; AGENTS.md assignment pointer updated. - Hygiene: scratch-site-check.mjs deleted per its own header (live-site pointer preserved in the handoff); .pi/ gitignored as an install artifact. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U2V9gsyEvYTSoZL4YkWYio --- AGENTS.md | 6 +- .../2026-07-15-launch-week-handoff.md | 60 +++++++++++++++++++ docs/marketing/2026-07-13-campaign-plan.md | 18 ++++++ docs/marketing/artifacts/raw-402-response.txt | 9 +++ .../artifacts/session-ledger-render.txt | 3 + docs/ops/kc7-platform-marketplace-review.md | 27 +++++++++ spikes/pi-wielder/.gitignore | 2 + 7 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 docs/handoffs/2026-07-15-launch-week-handoff.md create mode 100644 docs/marketing/artifacts/raw-402-response.txt create mode 100644 docs/marketing/artifacts/session-ledger-render.txt create mode 100644 docs/ops/kc7-platform-marketplace-review.md diff --git a/AGENTS.md b/AGENTS.md index ca89dbf..fd90e04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,10 @@ ## Current assignment -**→ `docs/handoffs/2026-07-11-codex-premise-review-followups.md`** — read it -first and execute its four tasks in order, on branch `codex/prd-execution`. +**→ `docs/handoffs/2026-07-15-launch-week-handoff.md`** — read it first; it +lists the ordered agent-doable tasks and the human-only launch items, on +branch `codex/prd-execution`. (The 2026-07-11 handoff is complete — +see `docs/plans/2026-07-12-phase-a-findings.md`.) ## Repo orientation (durable) diff --git a/docs/handoffs/2026-07-15-launch-week-handoff.md b/docs/handoffs/2026-07-15-launch-week-handoff.md new file mode 100644 index 0000000..021d368 --- /dev/null +++ b/docs/handoffs/2026-07-15-launch-week-handoff.md @@ -0,0 +1,60 @@ +# Handoff — launch-week state (2026-07-15) + +Supersedes `2026-07-11-codex-premise-review-followups.md` (all four tasks +executed and committed by 2026-07-12; see `docs/plans/2026-07-12-phase-a-findings.md`). + +## What has happened since the last handoff + +- **Phase A measurements (2026-07-12):** KC2 does not fire at first bound + (cold ~2.5 s / warm ~1.5 s, n=3); KC4 split result at N=6 ($1.58 attack, + fidelity failed, modeled 8-invocation break-even — do NOT cite as + resolved); Education fork-economics re-run negative (free re-authoring + strictly dominates). All now fed back into `docs/PRD.md` (commit b6d4861). +- **Pi-Wielder spike executed:** offline e2e green; real Base Sepolia run + 2026-07-12 (~781 ms x402 overhead/call, n=1; splits reconciled on-chain); + four gateway/extension fixes 2026-07-13 (SSE, OpenAI shapes); **live demo + verified 2026-07-15** — unmodified pi v0.80.6 paid 8 streaming calls + ($0.328) through the proxy. Remaining: p50/p95 at n≈30 incl. the gpt leg. +- **Public split + live site:** protocol at github.com/Aznatkoiny/skill-asset-protocol + (Apache-2.0, **still private as of 2026-07-15**); production x402 endpoint + live at neverhandedover.com/api/invoke/optimizing-claude-code-prompts + (402-gates unpaid POSTs; verified). skillassetprotocol.com serves. +- **Campaign kit committed (ae09fd6) but Day 0 (07-14) slipped** — slip + recorded and re-anchor proposed (Day 0 = Thu 07-16) in + `docs/marketing/2026-07-13-campaign-plan.md` §2. Raw artifacts for X posts + #1–2 pre-captured in `docs/marketing/artifacts/`. +- **Strategy:** `docs/plans/2026-07-15-registry-not-marketplace.md` — + marketplace stays rejected (ADR-0007 holds); distribution runs the MCP + playbook; settlement-gated registry is the compliant surface, gated on the + KC1 LOI. **KC7 monthly review instantiated** with first review logged: + `docs/ops/kc7-platform-marketplace-review.md`. + +## Next tasks (agent-doable, in order) + +1. **x402 overhead distribution:** n≈30 paid calls through the spike proxy + (both claude and the never-yet-run gpt leg), report p50/p95, update + `spikes/pi-wielder/README.md` and the PRD spike-13 note. +2. **High-N clone-economics run** (`spikes/clone-economics/`) — required + before any public copy leans on the N=6 result (LinkedIn Post 2, X + clone-attack thread). +3. **phase0 should-fixes then Aeneid run:** dust-funding gate + (`balance === 0n` → estimated minimum), confirm-to-save crash window, + env-override brick, metadata pinning off httpbin — wallet funding itself + is a human step. +4. **Adoption-kit packaging** (weeks 1–2 of the registry plan): neutral + GitHub org, reference Collar middleware, one-command offline demo. + +## Human-only (do not attempt) + +- Pre-flight + repo flip + LinkedIn Post 1 (proposed Day 0 Thu 07-16); the + daily X reply routine; design-partner LOI outreach (KC1 — the binding + constraint on everything); counsel engagement (KC5 instrument; collar/MSB). +- Known asset defect: the committed public-repo banner reads "EST.2024" + (contradicts the 2026-07-11 corpus) — needs an image edit before flip. + `docs/marketing-assets/` (untracked) holds duplicates, one with a space in + the filename ("github -banner.png"). + +## Rules (unchanged from AGENTS.md) + +Never commit `.env`/keys; testnet only; measured stays labeled measured; +extend "What we have NOT validated", never delete from it. diff --git a/docs/marketing/2026-07-13-campaign-plan.md b/docs/marketing/2026-07-13-campaign-plan.md index 8f23425..f377e10 100644 --- a/docs/marketing/2026-07-13-campaign-plan.md +++ b/docs/marketing/2026-07-13-campaign-plan.md @@ -64,6 +64,24 @@ calendar slips before a conversation does. Day 0 = **Tuesday 2026-07-14**: repo flip + soft launch. "Soft" means: repo public, site verified live, LinkedIn Post 1 out — no X thread, no HN. +> **Slip record (2026-07-15).** Day 0 did not execute: as of Wed 07-15 the repo is +> still private (`gh repo view` verified), no pre-flight was logged, and the §3 metrics +> table was never started. No slip cause was recorded — this note is the record. The +> pre-flight (fresh-machine clone-and-run) has not been run; per Day −1's own rule, +> Day 0 slips until it passes. +> +> **Proposed re-anchor: Day 0 = Thursday 2026-07-16** (runbook-allowed weekday), +> preserving all weekday semantics rather than shifting dates mechanically: +> pre-flight Wed 07-15 → flip + Post 1 Thu 07-16 → X artifact #1 Fri 07-17 → +> rest Sat/Sun → Post 2 + artifacts #2–4 week of Mon 07-20 → demo clip Thu 07-23 → +> **Post 3 + X launch thread Tue 07-28** (12 days of reply history) → HN pre-flight +> Wed 07-29 → **Post 5 + Show HN Thu 07-30** → clone-attack thread + Post 4 + day-7 +> review Mon 08-03. The daily X reply routine starts immediately regardless — the +> launch thread needs the reply history more than it needs any particular date. +> Raw artifacts for posts #1–2 are pre-captured in `docs/marketing/artifacts/`. +> The original table below is preserved unedited as the Day-number playbook; read +> its Date column through this mapping. + **Standing daily items (every non-rest day, not repeated in the table):** - **X reply routine, 30–45 min** — the daily engagement routine from `x.md` §4: saved diff --git a/docs/marketing/artifacts/raw-402-response.txt b/docs/marketing/artifacts/raw-402-response.txt new file mode 100644 index 0000000..fb11caa --- /dev/null +++ b/docs/marketing/artifacts/raw-402-response.txt @@ -0,0 +1,9 @@ +$ curl -i -s localhost:8403/v1/chat/completions -H "content-type: application/json" -d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"Plan a refactor."}]}' +HTTP/1.1 402 Payment Required +content-type: application/json +Content-Length: 458 +Date: Wed, 15 Jul 2026 16:44:25 GMT +Connection: keep-alive +Keep-Alive: timeout=5 + +{"x402Version":1,"error":"X-PAYMENT header is required","accepts":[{"scheme":"exact","network":"base-sepolia","maxAmountRequired":"41000","resource":"http://localhost:8403/v1/chat/completions","description":"per-call model inference (x402 reseller, testnet)","mimeType":"application/json","payTo":"0x25005dFaC23d4bc45C801eAeB6c8b5a2BaB0F189","maxTimeoutSeconds":60,"asset":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","extra":{"name":"USDC","version":"2"}}]} \ No newline at end of file diff --git a/docs/marketing/artifacts/session-ledger-render.txt b/docs/marketing/artifacts/session-ledger-render.txt new file mode 100644 index 0000000..3bfa4e2 --- /dev/null +++ b/docs/marketing/artifacts/session-ledger-render.txt @@ -0,0 +1,3 @@ +$ curl -s localhost:8402/ledger +claude/smoke $0.041 · claude/chat $0.041 · claude/chat $0.041 · claude/chat $0.041 · claude/chat $0.041 · claude/chat $0.041 · claude/chat $0.041 · claude/chat $0.041 + session total $0.328 across 8 paid calls, one wallet \ No newline at end of file diff --git a/docs/ops/kc7-platform-marketplace-review.md b/docs/ops/kc7-platform-marketplace-review.md new file mode 100644 index 0000000..b7828ad --- /dev/null +++ b/docs/ops/kc7-platform-marketplace-review.md @@ -0,0 +1,27 @@ +# KC7 — platform-marketplace monthly review (standing) + +**What this is:** the standing monthly review mandated by kill-criterion 7 +(`docs/PRD.md`, Kill criteria #7, added 2026-07-11). The criterion fires on a +**public announcement or beta by Anthropic, OpenAI, or GitHub of a native +skill marketplace or skill-monetization program with builder payouts** — not +on rumor. On firing: Phase-3 investment stops, the open-Marketplace +optionality is written to zero, the closed-mode compensation product stands +alone (it restructures, not kills). + +**Owner:** Antony Zaki (sole founder — reassign here if that changes). +**Cadence:** monthly, on or before the 15th. Log every review below, including +"nothing found" — an unlogged review didn't happen. + +**What to check:** Anthropic changelog/dev events (incl. Claude Code plugin +marketplaces), OpenAI (GPT Store revenue program changes, DevDay), GitHub +(Marketplace/Copilot extensions monetization). **Adjacent watchlist (do not +fire KC7 on these; they inform the registry strategy in +`docs/plans/2026-07-15-registry-not-marketplace.md`):** Vercel skills.sh +monetization, Story Protocol (Story Skills SDK), Circle Agent Stack, MCP +Hive-class paid-tool marketplaces. + +## Review log + +| Date | Reviewer | KC7 trigger? | Notes | +|---|---|---|---| +| 2026-07-15 | Antony Zaki (via research sweep, 6-agent web verification) | **No** | Anthropic: curated plugin marketplaces (claude-plugins-official/community) exist, **no builder payouts**. OpenAI: GPT Store revenue program still the Jan-2024 limited US pilot, terms unpublished, ~$0.03/conversation third-party estimate — no new program. GitHub: nothing. Adjacent: skills.sh free, no payments (paid-tier rumor uncorroborated — treat as false); MCP Hive launched 2026-07-12 (hosted MCP tools, provider earnings, fiat — not a platform-native skill marketplace); Story Skills SDK live since 2026-05-06; x402 Foundation operational under Linux Foundation 2026-07-14. Next review due **2026-08-15**. | diff --git a/spikes/pi-wielder/.gitignore b/spikes/pi-wielder/.gitignore index 9fc338f..d542e43 100644 --- a/spikes/pi-wielder/.gitignore +++ b/spikes/pi-wielder/.gitignore @@ -2,3 +2,5 @@ session-ledger.jsonl *.jsonl # (.env and node_modules are already ignored by the repo root .gitignore) +# Local pi install artifact — created per RUNBOOK §3 (cp pi-extension/x402.ts .pi/extensions/) +.pi/ From 3506eeb7eb1a6c3f21d1ba62244eeaaa644a506a Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Wed, 15 Jul 2026 12:53:59 -0400 Subject: [PATCH 023/165] Measure x402 overhead distribution (n=48): p50 731ms / p95 1206ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the spike's promised p50/p95 measurement and runs the gpt leg for the first time. 50 paid calls against the real Base Sepolia facilitator, wallet reconciled on-chain to the cent (19.299 -> 16.129 USDC). Bench surfaced and fixed a real gateway bug: newer OpenAI models reject max_tokens (400 unsupported_parameter); the gateway now translates it to max_completion_tokens — without this, pi's gpt leg 500s in the live demo. Two failure modes recorded in the README: pay-then-fail (10 calls settled then 500'd upstream, $0.87 burned — buyer's risk under pay-first-then-run) and settled-but-rejected (1/50 settled on-chain yet returned 402 when the facilitator response to the seller failed; ~4% testnet flake overall). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U2V9gsyEvYTSoZL4YkWYio --- docs/PRD.md | 4 +-- .../2026-07-15-launch-week-handoff.md | 7 ++-- spikes/pi-wielder/README.md | 34 +++++++++++++++++++ spikes/pi-wielder/src/gateway.mjs | 5 ++- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 3f4e93d..f97ffc7 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -89,7 +89,7 @@ Two honest qualifications. First, **every live gateway is a third-party reseller The strategic consequence is ADR-0008: **the Wielder is a wallet, not a harness.** The entire Wielder-side protocol footprint is *answer HTTP 402 with a signed USDC payment and retry* — no Story SDK, no token custody, no chain reads client-side. The invocation-right is exercised by paying, not held. Every wallet the inference gateways install is a Skill-ready Wielder the moment a Collar exists to answer it. -**The validation experiment for this wedge is the Pi-Wielder spike (`spikes/pi-wielder/`).** Pi (earendil-works/pi, formerly badlogic/pi-mono) is a ~70k-star MIT TypeScript coding agent — multi-provider, custom `baseUrl` support, mid-session model switching, and **no wallet or x402 support shipped** (research, 2026-07): the ideal thin-payer testbed. The spike has Pi pay per-call for inference AND invoke one hosted Skill behind a mock Collar, producing one unified attributed session ledger, and it measures x402 payment overhead per call (sign → verify → settle p50/p95) plus end-to-end skill-invocation latency. Testnet-only, zero real money. **Discipline note: the spike proves the thin-payer client by construction and measures overhead; it is *not* demand evidence** — willingness-to-pay remains unvalidated (R12; see [What we have NOT validated](#what-we-have-not-validated)). **Executed (2026-07-12/15): ~781 ms x402 payment overhead per call against the live testnet facilitator (n=1 instrumented; the facilitator leg is 776 ms of it — mainnet Flashblocks claims ~200 ms, so this is likely an upper bound), real Base Sepolia settlements reconciled on-chain to the cent, and an unmodified pi v0.80.6 session paying per-call through the proxy (8 streaming calls, 2026-07-15). The p50/p95 distribution (n≈30, incl. the gpt leg) is still pending.** +**The validation experiment for this wedge is the Pi-Wielder spike (`spikes/pi-wielder/`).** Pi (earendil-works/pi, formerly badlogic/pi-mono) is a ~70k-star MIT TypeScript coding agent — multi-provider, custom `baseUrl` support, mid-session model switching, and **no wallet or x402 support shipped** (research, 2026-07): the ideal thin-payer testbed. The spike has Pi pay per-call for inference AND invoke one hosted Skill behind a mock Collar, producing one unified attributed session ledger, and it measures x402 payment overhead per call (sign → verify → settle p50/p95) plus end-to-end skill-invocation latency. Testnet-only, zero real money. **Discipline note: the spike proves the thin-payer client by construction and measures overhead; it is *not* demand evidence** — willingness-to-pay remains unvalidated (R12; see [What we have NOT validated](#what-we-have-not-validated)). **Executed (2026-07-12/15): ~781 ms x402 payment overhead per call against the live testnet facilitator (n=1 instrumented; the facilitator leg is 776 ms of it — mainnet Flashblocks claims ~200 ms, so this is likely an upper bound), real Base Sepolia settlements reconciled on-chain to the cent, and an unmodified pi v0.80.6 session paying per-call through the proxy (8 streaming calls, 2026-07-15). Distribution measured 2026-07-15: **p50 731 ms / p95 1206 ms** (n=48 settled calls, claude + gpt legs, facilitator-dominated).** ### The GPT Store precedent (the base rate to respect) @@ -700,7 +700,7 @@ Secondary spikes (resolve before the relevant phase): 10. **Live-evolution anti-clone efficacy** — load-bearing for the no-TEE defense; *asserted by analogy, unmeasured* (§7.7). No source quantifies required change cadence (R1; feeds kill-criterion 4). 11. **Off-platform Story enforcement** — real dispute/takedown outcomes for behavioral clones (on-chain provenance vs. off-chain courts) are undocumented (§7.8). 12. **Counsel drafts the Collar architecture read + the closed-mode instrument** — no regulatory source squarely analyzes the per-invocation agent-to-agent fact pattern; the MSB and the non-transferable-outside-Howey analyses are both extrapolated. **Counsel must bless the architecture AND draft the actual deferred-comp/409A instrument (on-demand-withdrawal vs. fixed-payment-events; vesting/clawback/termination) before launch** (R2, R3; feeds kill-criterion 5). -13. **Pi-Wielder spike (`spikes/pi-wielder/`)** — added 2026-07-11; the demand-side wedge's validation experiment. Prove the thin-payer client by construction — a ~100-line paying proxy is the *entire* Wielder-side protocol footprint (ADR-0008) — and measure x402 payment overhead per call (sign → verify → settle, p50/p95) plus end-to-end skill-invocation latency, with one wallet paying across inference calls and a skill invocation into one unified attributed ledger (split correctness checked against `prototype/settlement-engine.mjs`). Testnet-only; zero real money. *Output: measured payment-overhead and latency distributions to feed back into this document's demand-side section — NOT demand evidence (R12 stands).* **Executed: offline e2e green (2026-07-11); real Base Sepolia run 2026-07-12 — 402 roundtrip 3.9 ms + EIP-3009 sign 1.1 ms + facilitator verify/settle 776 ms ≈ 781 ms payment overhead per call (n=1 instrumented; facilitator leg dominates; gpt leg skipped — no OpenAI key that day), settlements reconciled on-chain to the cent; live demo 2026-07-15 — unmodified pi v0.80.6 paid per-call through the proxy (8 streaming calls, $0.328). Remaining: p50/p95 at n≈30 incl. the gpt leg (`spikes/pi-wielder/README.md`).** +13. **Pi-Wielder spike (`spikes/pi-wielder/`)** — added 2026-07-11; the demand-side wedge's validation experiment. Prove the thin-payer client by construction — a ~100-line paying proxy is the *entire* Wielder-side protocol footprint (ADR-0008) — and measure x402 payment overhead per call (sign → verify → settle, p50/p95) plus end-to-end skill-invocation latency, with one wallet paying across inference calls and a skill invocation into one unified attributed ledger (split correctness checked against `prototype/settlement-engine.mjs`). Testnet-only; zero real money. *Output: measured payment-overhead and latency distributions to feed back into this document's demand-side section — NOT demand evidence (R12 stands).* **Executed: offline e2e green (2026-07-11); real Base Sepolia run 2026-07-12 — 402 roundtrip 3.9 ms + EIP-3009 sign 1.1 ms + facilitator verify/settle 776 ms ≈ 781 ms payment overhead per call (n=1 instrumented; facilitator leg dominates; gpt leg skipped — no OpenAI key that day), settlements reconciled on-chain to the cent; live demo 2026-07-15 — unmodified pi v0.80.6 paid per-call through the proxy (8 streaming calls, $0.328). Distribution measured 2026-07-15 (n=48 settled, both legs incl. the first-ever gpt runs): overhead p50 731 ms / p95 1206 ms, facilitator-dominated; wallet reconciled on-chain to the cent; observed failure modes recorded (pay-then-fail after settlement; ~4% testnet facilitator flake) — `spikes/pi-wielder/README.md`.** --- diff --git a/docs/handoffs/2026-07-15-launch-week-handoff.md b/docs/handoffs/2026-07-15-launch-week-handoff.md index 021d368..33c12e7 100644 --- a/docs/handoffs/2026-07-15-launch-week-handoff.md +++ b/docs/handoffs/2026-07-15-launch-week-handoff.md @@ -31,9 +31,10 @@ executed and committed by 2026-07-12; see `docs/plans/2026-07-12-phase-a-finding ## Next tasks (agent-doable, in order) -1. **x402 overhead distribution:** n≈30 paid calls through the spike proxy - (both claude and the never-yet-run gpt leg), report p50/p95, update - `spikes/pi-wielder/README.md` and the PRD spike-13 note. +1. ~~x402 overhead distribution~~ **Done 2026-07-15:** p50 731 ms / p95 + 1206 ms (n=48 settled, both legs); gateway `max_completion_tokens` fix; + pay-then-fail + settled-but-rejected failure modes recorded in + `spikes/pi-wielder/README.md`; PRD updated. 2. **High-N clone-economics run** (`spikes/clone-economics/`) — required before any public copy leans on the N=6 result (LinkedIn Post 2, X clone-attack thread). diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 3f69da6..6fdfdc4 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -162,3 +162,37 @@ against a live facilitator; real USDC moving on a public chain per call; skill executed behind the Collar with output-only response; splits credited per the settlement engine — the protocol's Phase-1 Leg-1 loop, end to end, for $0.33 of play money. + +## Measured results — overhead distribution + live pi session (2026-07-15) + +**x402 payment overhead, n=48 settled calls** (29 claude + 19 gpt, real +`x402.org/facilitator`, Base Sepolia): **p50 731 ms · p95 1206 ms** (mean +830, min 487, max 1859). Decomposition: facilitator verify+settle p50 729 ms +(the whole story); 402-roundtrip p50 1.2 ms; EIP-3009 sign p50 0.9 ms. +End-to-end paid roundtrip including inference (green calls): claude p50 +2.15 s / p95 3.94 s; gpt p50 1.47 s / p95 3.30 s. Wallet reconciled +on-chain to the cent: 19.299 → 16.129 USDC = one pi session ($0.287) + +29×$0.041 + 19×$0.087 + one settled-but-rejected call ($0.041). + +**The gpt leg ran for the first time** (skipped 2026-07-12 for lack of a +key) — after fixing a real gateway bug the bench surfaced: newer OpenAI +models reject `max_tokens` (400 `unsupported_parameter`), so the gateway now +translates it to `max_completion_tokens`. The first 10 gpt attempts +**settled and then failed upstream** — $0.87 paid for ten 500s. Two +protocol observations worth keeping: + +1. **Pay-then-fail is the buyer's risk under pay-first-then-run.** A seller + bug after settlement costs the Wielder real money with no refund path in + x402 v1. (Design note for the Collar: attempt-then-settle ordering, or a + retry-credit convention.) +2. **Settled-but-rejected happens.** 1 of 50 calls settled on-chain but the + facilitator's response to the seller failed, so the seller returned 402 + anyway — buyer charged, no output (confirmed by exact balance + reconciliation). A second 402-after-signing did *not* settle. Testnet + facilitator flake rate over this run: ~4% of calls errored mid-payment. + +**Live pi session (same day):** unmodified pi v0.80.6 with the extension +paid 8 streaming calls ($0.328) through the proxy in a real agentic session +— one human prompt produced 7 paid model turns, a live datapoint that flat +per-call pricing amplifies agentic chattiness (relevant to the PRD's +pricing-model spike). diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index c29a339..4ad9164 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -171,7 +171,10 @@ async function viaOpenAI(body) { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) throw new Error('OPENAI_API_KEY required for gpt-* models unless MOCK_LLM=1'); // Always fetch buffered — the gateway synthesizes its own SSE downstream. - const { stream, stream_options, ...rest } = body; + const { stream, stream_options, max_tokens, ...rest } = body; + // Newer OpenAI models reject max_tokens (400 unsupported_parameter) and + // require max_completion_tokens; clients (pi included) send max_tokens. + if (max_tokens != null && rest.max_completion_tokens == null) rest.max_completion_tokens = max_tokens; const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, From 56228b0834bb6937df1248e2ad7d2da7f5429d5e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Wed, 15 Jul 2026 18:21:51 -0400 Subject: [PATCH 024/165] Day-2 campaign revamp: repo flipped public, numbers refreshed, calendar re-anchored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ground truth recorded: Post 1 shipped Mon 07-13 (= Day 0) with the repo link 404ing; pre-flight PASSED 07-15 (fresh clone e2e green, secrets CLEAN); repo synced (public 84f8da4) and flipped public, verified logged-out. Today = Day 2. - Revamped calendar in campaign-plan §2 (launch thread Thu 07-23, Show HN Tue 07-28); original table kept as superseded reference; kit overrides acknowledged explicitly. - All queued copy refreshed to the n=48 distribution (p50 731ms / p95 1206ms) with cold-start scoped to its n=3 measurement; N=6 clone caveats untouched; Show HN text gains the two failure modes. - Four new compliance-checked X artifacts (distribution, pay-then-fail, settled-but-rejected, pi session ledger) + live-endpoint 402 capture. - Metrics daily log started in hn-and-demo §3 with honest slip note; Day-2 row reconciles 57 settlements / $3.211 to the cent. - pi-wielder README: pi session corrected to 7 calls / $0.287 (8th ledger entry was the pre-demo smoke test) — caught by the verify pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U2V9gsyEvYTSoZL4YkWYio --- .../2026-07-15-launch-week-handoff.md | 12 +++- docs/marketing/2026-07-13-campaign-plan.md | 58 ++++++++++++++----- .../artifacts/raw-402-response-live.txt | 13 +++++ docs/marketing/hn-and-demo.md | 27 ++++++++- docs/marketing/linkedin.md | 6 +- docs/marketing/x.md | 23 ++++++-- spikes/pi-wielder/README.md | 6 +- 7 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 docs/marketing/artifacts/raw-402-response-live.txt diff --git a/docs/handoffs/2026-07-15-launch-week-handoff.md b/docs/handoffs/2026-07-15-launch-week-handoff.md index 33c12e7..3e99e43 100644 --- a/docs/handoffs/2026-07-15-launch-week-handoff.md +++ b/docs/handoffs/2026-07-15-launch-week-handoff.md @@ -47,9 +47,15 @@ executed and committed by 2026-07-12; see `docs/plans/2026-07-12-phase-a-finding ## Human-only (do not attempt) -- Pre-flight + repo flip + LinkedIn Post 1 (proposed Day 0 Thu 07-16); the - daily X reply routine; design-partner LOI outreach (KC1 — the binding - constraint on everything); counsel engagement (KC5 instrument; collar/MSB). +- ~~Pre-flight + repo flip~~ **Done 2026-07-15 PM:** pre-flight PASSED + (fresh clone, offline e2e green, secrets scan CLEAN), public repo synced + (gateway fix + n=48 numbers, commit 84f8da4) and **flipped public** + (verified logged-out). LinkedIn Post 1 had already shipped Mon 07-13; + the revamped calendar (campaign-plan §2) anchors today = Day 2: Post 2 + + X artifact #1 today, launch thread Thu 07-23, Show HN Tue 07-28. +- Still human-only: publish Post 2 + the artifact tweet; the daily X reply + routine; design-partner LOI outreach (KC1 — the binding constraint on + everything); counsel engagement (KC5 instrument; collar/MSB). - Known asset defect: the committed public-repo banner reads "EST.2024" (contradicts the 2026-07-11 corpus) — needs an image edit before flip. `docs/marketing-assets/` (untracked) holds duplicates, one with a space in diff --git a/docs/marketing/2026-07-13-campaign-plan.md b/docs/marketing/2026-07-13-campaign-plan.md index f377e10..76b7e4d 100644 --- a/docs/marketing/2026-07-13-campaign-plan.md +++ b/docs/marketing/2026-07-13-campaign-plan.md @@ -64,23 +64,47 @@ calendar slips before a conversation does. Day 0 = **Tuesday 2026-07-14**: repo flip + soft launch. "Soft" means: repo public, site verified live, LinkedIn Post 1 out — no X thread, no HN. -> **Slip record (2026-07-15).** Day 0 did not execute: as of Wed 07-15 the repo is -> still private (`gh repo view` verified), no pre-flight was logged, and the §3 metrics -> table was never started. No slip cause was recorded — this note is the record. The -> pre-flight (fresh-machine clone-and-run) has not been run; per Day −1's own rule, -> Day 0 slips until it passes. +> **Revamp (2026-07-15, supersedes the 07-15 morning slip note and the table below).** +> What actually happened: **LinkedIn Post 1 shipped Mon 07-13** — but the repo flip did +> not, so Post 1's repo link 404'd for two days (verified: repo still private Wed 07-15, +> 0 stars). Tue 07-14 was silent; the x402 Foundation launched under the Linux Foundation +> that day. Re-anchor: **Post 1 day = Day 0 (Mon 07-13); today Wed 07-15 = Day 2.** +> Pre-flight ran 2026-07-15 and PASSED (fresh clone, offline e2e 20 checks green in +> 1.6 s, all four offline proofs pass, full-history secrets scan CLEAN). > -> **Proposed re-anchor: Day 0 = Thursday 2026-07-16** (runbook-allowed weekday), -> preserving all weekday semantics rather than shifting dates mechanically: -> pre-flight Wed 07-15 → flip + Post 1 Thu 07-16 → X artifact #1 Fri 07-17 → -> rest Sat/Sun → Post 2 + artifacts #2–4 week of Mon 07-20 → demo clip Thu 07-23 → -> **Post 3 + X launch thread Tue 07-28** (12 days of reply history) → HN pre-flight -> Wed 07-29 → **Post 5 + Show HN Thu 07-30** → clone-attack thread + Post 4 + day-7 -> review Mon 08-03. The daily X reply routine starts immediately regardless — the -> launch thread needs the reply history more than it needs any particular date. -> Raw artifacts for posts #1–2 are pre-captured in `docs/marketing/artifacts/`. -> The original table below is preserved unedited as the Day-number playbook; read -> its Date column through this mapping. +> **Acknowledged kit overrides:** (a) x.md §4's "week 1 = reply only, post nothing +> original" is compressed — Post 1 already broke broadcast silence on 07-13, so the +> artifact cadence starts today; (b) Post 2's context prefers mid-week *morning* — +> afternoon-after-the-flip beats morning-before-the-flip, so it ships this afternoon; +> (c) the launch thread's reply history is 8 calendar days / 6 active reply days +> (weekends rest) — start the reply routine today without fail. + +### Revamped calendar (Day 2 = Wed 07-15 → HN) + +| Day | Date | Actions | +|---|---|---| +| 0 | Mon 07-13 | *(done)* LinkedIn Post 1 out, pinned. Repo flip did NOT happen — slip recorded. | +| 1 | Tue 07-14 | *(done)* Silent. x402 Foundation launches under the Linux Foundation — this week's reply-routine entry point. | +| **2** | **Wed 07-15 (today)** | ① **Repo public FIRST** (pre-flight passed; verify from a logged-out browser after the flip). ② Reply to any Post-1 comments that hit the 404 — factual correction in a reply, never silent. ③ **LinkedIn Post 2** (clone story) this afternoon — 2 days after Post 1; repo link in first comment now resolves. ④ **X artifact #1**: the raw 402 from the LIVE endpoint (`docs/marketing/artifacts/raw-402-response-live.txt` — production URL + "output only, never the skill" in the payload); tweet text below. ⑤ Reply routine starts (x402-Foundation news as the entry; measured numbers, never a pitch). ⑥ Metrics table started (`hn-and-demo.md` §3 daily log). | +| 3 | Thu 07-16 | X artifact #2: the ledger line reconciled on-chain to the cent (testnet, play money). Replies. | +| 4 | Fri 07-17 | X artifact #3: "What we have NOT validated" page, screenshotted. Light day. | +| 5–6 | Sat–Sun 07-18/19 | **Rest days** (Sun: optional 15 min of replies if a good conversation is live). | +| 7 | Mon 07-20 | X artifact #4: the kill-criteria arithmetic that killed education mode. Replies. | +| 8 | Tue 07-21 | **LinkedIn Post 3** (design-partner ask) ~8:30am ET + targeted reshares. X artifact #5: the n=48 overhead distribution (new artifact, x.md §4). | +| 9 | Wed 07-22 | **Record the demo clip** (`hn-and-demo.md` §2) — one week of live traffic since the flip; verify basescan links. X artifact #6: pay-then-fail receipt. Prep ecosystem tag reply; hand-verify org @handles. | +| 10 | Thu 07-23 | **X launch thread** (x.md §1, amended numbers) late morning, demo clip on tweet 1, pin, ecosystem tag reply first. Hours 1–3 live in replies. | +| 11 | Fri 07-24 | Thread aftercare. X spacer: the pi session ledger (single tweet — 48h thread spacing holds). | +| 12–13 | Sat–Sun 07-25/26 | **Rest days** (optional launch-thread reply sweep). | +| 14 | Mon 07-27 | X spacer: ~150-line Wielder proxy screenshot. Evening: **HN pre-flight** (re-run fresh-machine test, live 402 + paid invocation, five prepared answers open). | +| 15 | **Tue 07-28** | **LinkedIn Post 5** ~8:00am ET, cross-linked from the repo README. **Show HN** 8:30–10:00am ET (title 1, §1 text as first comment, runbook cadence). **X how-it-works thread** mid-morning (≥48h after launch thread ✓). *(Post 5's context prefers Thursday; staying paired with HN day matters more — if HN slips, both move to Thu 07-30.)* | +| 16 | Wed 07-29 | HN aftercare (hourly sweeps; log unanswerable critiques as corpus defects). X spacer: settled-but-rejected reconciliation — the honesty artifact while HN eyes are on the account. | +| 17 | Thu 07-30 | **X clone-attack thread** (≥48h after how-it-works ✓; may reference HN). **LinkedIn Post 4** (kill-criteria). **Day-7-after-launch-thread review**: count conversations that could become a design-partner LOI — the one derived number that matters. | + +Constraint check: repo flip before anything that links to it ✓ · ≥48h between threads (07-23 / 07-28 / 07-30) ✓ · launch thread + HN on Tue/Thu ✓ · weekends rest ✓ · demo clip after a week of live traffic ✓. + +**Today's X artifact #1 tweet** (attach the live-endpoint 402 capture; ~250 chars, verify at post time; single tweet, no thread, no link — the artifact is the content): + +> POST to a paid endpoint without paying and this is the reply: HTTP 402, with the terms machine-readable in the body — amount, network, asset, payTo. No API key, no account; the settlement receipt becomes the auth. Base Sepolia testnet — play money. **Standing daily items (every non-rest day, not repeated in the table):** @@ -92,6 +116,8 @@ verified live, LinkedIn Post 1 out — no X thread, no HN. critiques). - **LinkedIn comment tending** — reply to substantive comments on whichever posts are live. +**Original pre-slip table (superseded by the revamped calendar above, 2026-07-15 — its dates are no longer valid; kept for the per-day action detail the revamp references):** + | Day | Date | Actions | |---|---|---| | **−1** | Mon 07-13 (today) | Pre-flight checklist from `hn-and-demo.md` §3: LICENSE, README offline-e2e story, secrets scan, fresh-machine clone-and-run with zero keys/funds, live 402 check, both domains serving, compliance pass on every queued post. If the fresh-machine test fails, Day 0 slips — nothing else changes. | diff --git a/docs/marketing/artifacts/raw-402-response-live.txt b/docs/marketing/artifacts/raw-402-response-live.txt new file mode 100644 index 0000000..24b4758 --- /dev/null +++ b/docs/marketing/artifacts/raw-402-response-live.txt @@ -0,0 +1,13 @@ +$ curl -i -s -X POST https://neverhandedover.com/api/invoke/optimizing-claude-code-prompts -H "content-type: application/json" -d '{"input":"optimize this prompt"}' +HTTP/2 402 +cache-control: public, max-age=0, must-revalidate +content-type: application/json +date: Wed, 15 Jul 2026 22:15:11 GMT +server: Vercel +strict-transport-security: max-age=63072000 +vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch +x-matched-path: /api/invoke/[skillId] +x-vercel-cache: MISS +x-vercel-id: iad1::iad1::c862n-1784153710185-f43a1ca063e9 + +{"x402Version":1,"error":"X-PAYMENT header is required","accepts":[{"scheme":"exact","network":"base-sepolia","maxAmountRequired":"250000","resource":"https://neverhandedover.com/api/invoke/optimizing-claude-code-prompts","description":"Run the hosted skill \"optimizing-claude-code-prompts\" — output only, never the skill.","mimeType":"application/json","payTo":"0x25005dFaC23d4bc45C801eAeB6c8b5a2BaB0F189","maxTimeoutSeconds":60,"asset":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","extra":{"name":"USDC","version":"2"}}]} \ No newline at end of file diff --git a/docs/marketing/hn-and-demo.md b/docs/marketing/hn-and-demo.md index 1a2cf08..92dea1c 100644 --- a/docs/marketing/hn-and-demo.md +++ b/docs/marketing/hn-and-demo.md @@ -35,13 +35,18 @@ the result that is most likely to survive HN scrutiny, because it is us attackin > What we measured (testnet, 2026-07-12): one wallet paid per model call AND per skill > invocation over x402. Ledger: claude/plan $0.041, skill $0.25 → creator $0.24375 / > treasury $0.00625, reconciled against on-chain balances to the cent. Payment overhead -> ~781ms/call; hosted-agent cold start ~2.5s to first token. +> p50 731ms / p95 1206ms per call (n=48 settled calls, both model providers); +> hosted-agent cold start ~2.5s to first token. > > What we tried to break: we paid $1.58 to distill a clone of our own skill from its > outputs (distillation itself cost $0.03). The clone failed all 6 held-out fidelity gates — > but N=6, high-N behavior unknown. Modeled break-even if a clone ever passes: 8 invocations. > Cost protects nothing. > +> We also documented two live failure modes: 10 calls that settled then 500'd ($0.87 paid, +> no refund path in x402 v1 — our bug, published), and 1 of 50 that settled on-chain yet +> returned 402 — that one caught only by cent-exact wallet reconciliation. +> > What is unproven: that employers will buy this. We published our kill-criteria and killed > our own education mode with arithmetic. > @@ -147,7 +152,7 @@ Production notes: - No music required; if any, something metronomic and quiet. - Shots 2–4 are one unbroken terminal take — do not cut between 402 and output; the no-cut is the proof. -- Keep real latency visible (the ~781ms payment beat, the ~2.5s cold start). Speeding it up +- Keep real latency visible (the ~731ms-median payment beat, the ~2.5s cold start). Speeding it up would be the only dishonest frame in the clip. - Shot 6's caption carries the compliance load with shot 3: "testnet / play money" must be on screen in both the payment shot and the closing card. @@ -209,3 +214,21 @@ Explicitly **not** tracked as success: impressions, likes, follower counts, HN p the fact. One derived number matters most at day 7: **conversations that could become a design-partner LOI** — because kill-criterion 1 says that if none materialize in the Phase-0 window, we do not build Phase 1 on spec. + +### Daily log (started late — see slip note) + +> **Slip note (recorded 2026-07-15):** the planned Day 0 (repo flip + Post 1 together, +> Tue 07-14) did not execute as designed. Post 1 shipped Mon 07-13 with the repo still +> private — its repo link 404'd for readers from Monday until the flip on Wed 07-15. No +> pre-flight was logged and this table was not started on time; the rows below are +> reconstructed from verifiable sources, with "not logged" where nothing was recorded. +> Day 0's ~$0.328 of gateway-debugging spend is not logged per-call; on-chain receipts +> are pullable from basescan retroactively. All on-chain invocations to date are our own +> wallet (self-traffic): unique external payers = 0. + +| Day | Date | Demo invocations (count / unique payers) | Repo stars / forks / clones | Conversations started | Critiques we couldn't answer | +|---|---|---|---|---|---| +| — | Sun 07-12 | 3 / 1 (self — first real-network run: 2 model legs + 1 skill, $0.332, reconciled on-chain) | n/a (repo private) | 0 | 0 | +| 0 | Mon 07-13 | self only, ~$0.328 (gateway debugging; not logged per-call) | n/a (repo private — Post 1 link 404) | not logged | not logged | +| 1 | Tue 07-14 | 0 | n/a (repo private) | not logged | not logged | +| 2 | Wed 07-15 | 57 settlements / 1 payer (all self): 1 smoke + 7 pi session + 48 bench + 1 settled-but-rejected; $3.211 total, wallet 19.340 → 16.129 reconciled to the cent | flip today — baseline 0 / 0 / 0; first insights readable tomorrow | fill at EOD | fill at EOD | diff --git a/docs/marketing/linkedin.md b/docs/marketing/linkedin.md index c5826d6..1ad6c8b 100644 --- a/docs/marketing/linkedin.md +++ b/docs/marketing/linkedin.md @@ -178,15 +178,15 @@ Week 2–3, mid-week; resonates with founders and diligence-minded operators, so > > Phase 3 — the live endpoint. The manifesto site IS the system. POST without payment and you get an actual HTTP 402. Pay $0.25 in testnet USDC — play money, deliberately — and the hosted skill runs and streams you the output. Never the skill. > -> Numbers from the working demo on Base Sepolia (July 12), one wallet paying per model call AND per skill invocation: +> Numbers from the working demo on Base Sepolia (July 12; distribution re-measured July 15 across 48 settled calls and two model providers), one wallet paying per model call AND per skill invocation: > > — Ledger: $0.041 for the model's planning call, $0.25 for the skill invocation > — Split: $0.24375 credited to the creator, $0.00625 to the treasury > — On-chain balances reconciled to the cent -> — Payment overhead: ~781ms per call +> — Payment overhead: p50 731ms / p95 1206ms per call (n=48 settled calls; the first run's n=1 read was ~781ms) > — Hosted-agent cold start: ~2.5s to first token > -> That 781ms is honest and it isn't free. Neither is the cold start. Both are in the docs, because you'd find them in your first hour anyway. +> That 731ms median is honest and it isn't free. Neither is the cold start. Both are in the docs, because you'd find them in your first hour anyway. > > Everything is Apache-2.0: the collar that holds the sole API key, the metering ledger, the clone-attack harness we ran against ourselves, the kill-criteria, the not-validated list. > diff --git a/docs/marketing/x.md b/docs/marketing/x.md index eb45910..d1048a0 100644 --- a/docs/marketing/x.md +++ b/docs/marketing/x.md @@ -50,10 +50,10 @@ claude/plan $0.041 · skill $0.25 → creator $0.24375 / treasury $0.00625 On-chain balances reconciled to the cent. **5/** -The overhead of paying per call, measured on the same run: +The overhead of paying per call, measured across 48 settled calls: -· payment adds ~781ms per call -· hosted-agent cold start: ~2.5s to first token +· payment adds p50 731ms / p95 1206ms per call (n=48 settled calls) +· hosted-agent cold start: ~2.5s to first token (separate n=3 measurement) Not free. Not prohibitive. Numbers you can build against. @@ -192,9 +192,9 @@ All of the server side fits in a ~150-line proxy we call the Wielder: enforce 40 150 lines, because the rails already exist. **8/** -Measured (Base Sepolia, 2026-07-12, testnet): +Measured (Base Sepolia, 2026-07-12 + 07-15, testnet): -· payment overhead ~781ms/call +· payment overhead p50 731ms / p95 1206ms (n=48 settled calls) · cold start ~2.5s to first token · $0.25/invocation → creator $0.24375 / treasury $0.00625, reconciled on-chain to the cent @@ -232,7 +232,7 @@ Daily routine, 30–45 minutes: question asked, never pitch. Good (someone asks whether x402 latency is workable): -> We measured it on Base Sepolia last week: ~781ms of payment overhead per call, ~2.5s cold start to first token on a hosted agent. Fine for per-task pricing, painful inside a tight loop. +> We measured it across 48 settled calls on Base Sepolia: p50 731ms / p95 1206ms of payment overhead per call; cold start to first token on a hosted agent is ~2.5s (n=3). Fine for per-task pricing, painful inside a tight loop. Good (someone claims per-call pricing stops people cloning your agent): > We tested that against our own skill. $1.58 total to distill a clone from its outputs — the distillation step cost $0.03. The clone failed our 6 fidelity gates, but cost was never the thing protecting it. N=6, so high-N is still an open question. @@ -253,6 +253,17 @@ Single tweets, not threads. One screenshot-sized artifact per day: - Day 12: the ~150-line Wielder proxy, as a code screenshot. - Day 13–14: rest the feed; replies only. Launch thread ships when the demo receipts are final. +**New artifacts (added 2026-07-15; slots per the revamped calendar in `2026-07-13-campaign-plan.md` §2; verify character counts at post time):** + +- **The n=48 overhead distribution** — artifact: the distribution decomposition from `spikes/pi-wielder/README.md`. + > x402 payment overhead, measured across 48 settled calls on Base Sepolia (testnet, play money), two model providers, real facilitator: p50 731ms · p95 1206ms. The facilitator verify+settle leg is the whole story (p50 729ms); the 402 roundtrip + signature add ~2ms. Wallet reconciled on-chain to the cent. +- **The pay-then-fail receipt** — artifact: the ten-500s ledger excerpt. + > We paid $0.87 in testnet USDC (play money) for ten HTTP 500s. Pay-first-then-run means a seller bug after settlement is the buyer's loss — x402 v1 has no refund path. Our bug, our dime. Fixed it, published the receipt. If you're building on 402 rails, design for pay-then-fail. +- **The settled-but-rejected reconciliation** — artifact: the balance-reconciliation lines. + > 1 of 50 calls settled on-chain but the seller still answered 402 — the facilitator's reply to the seller failed mid-flight. Buyer charged, no output. We only caught it because the wallet reconciles to the cent (testnet, play money). The meter is its own audit trail. +- **The pi session ledger** — artifact: `docs/marketing/artifacts/session-ledger-render.txt` (note: the first of its 8 entries is our own pre-demo smoke test). + > An unmodified coding agent (pi v0.80.6) paid its own way through our proxy: 7 streaming calls, $0.287 in testnet USDC (play money). One human prompt → 7 paid model turns. Flat per-call pricing meters agentic chattiness — a live datapoint we're feeding into pricing design. + Reply routine continues daily throughout. The ratio stays lopsided: for every original post, several substantive replies elsewhere. ### Launch sequencing diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 6fdfdc4..f0c2111 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -192,7 +192,9 @@ protocol observations worth keeping: facilitator flake rate over this run: ~4% of calls errored mid-payment. **Live pi session (same day):** unmodified pi v0.80.6 with the extension -paid 8 streaming calls ($0.328) through the proxy in a real agentic session +paid 7 streaming calls ($0.287) through the proxy in a real agentic session — one human prompt produced 7 paid model turns, a live datapoint that flat per-call pricing amplifies agentic chattiness (relevant to the PRD's -pricing-model spike). +pricing-model spike). (The session ledger renders 8 entries / $0.328 +because it also caught the pre-demo smoke call; the wallet reconciliation +attributes $0.287 to pi.) From bad032b3cc4f86e22541439dbd53e4a87563c5d1 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 18:34:25 -0400 Subject: [PATCH 025/165] docs: design adversarial review remediation --- ...7-adversarial-review-remediation-design.md | 564 ++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-adversarial-review-remediation-design.md diff --git a/docs/superpowers/specs/2026-07-17-adversarial-review-remediation-design.md b/docs/superpowers/specs/2026-07-17-adversarial-review-remediation-design.md new file mode 100644 index 0000000..0d0b81c --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-adversarial-review-remediation-design.md @@ -0,0 +1,564 @@ +# Adversarial Review Remediation Design + +**Status:** Approved direction; implementation planning follows after review of this written specification. + +**Date:** 2026-07-17 + +## Goal + +Bring the repository's public claims, experimental evidence, reference accounting, +runtime behavior, provenance language, and terminal Intra-org product into one +coherent and testable contract. + +The terminal Intra-org product will use employer-funded internal Invocations. A +successful qualified internal Invocation creates an employer-sponsored **Invocation +award** for the employee-Creator. An external Wielder may create later Royalty-claim +upside, but external demand is no longer required for the employee-Creator to earn +compensation. + +## Design principles + +1. Stop unsupported public claims before expanding the implementation. +2. A benchmark is invalid unless its source target passes its own acceptance gate. +3. Every measured claim must be reproducible from a sanitized committed evidence + bundle. +4. Money is represented in integer atomic units and must conserve exactly. +5. A settled payment is recorded even when execution fails or a seller response is + lost. +6. The Collar owns authoritative Invocation and settlement accounting. Wielder-side + ledgers are receipt views, not compensation ledgers. +7. Phase 0 attests registration by a wallet and declared ancestry. It does not prove + authorship, originality, or safety without additional evidence. +8. Internal compensation must work without an external customer, real-funds custody + by the platform, or Phase 2 on-chain settlement. +9. Existing testnet-only and no-private-key rules remain binding. +10. `CONTEXT.md`, `docs/PRD.md`, and `docs/adr/` are not edited during remediation + implementation without explicit approval. Proposed canonical changes are kept in + a reviewable amendment document until that approval is given. + +## Scope decomposition + +The remediation is organized into six conceptual projects. Execution is split more +finely so each implementation plan has one testable responsibility and can land without +requiring later plans to be complete. + +1. Launch and evidence integrity. +2. Monetary accounting and settlement lifecycle. +3. Employer-funded internal Invocation flow. +4. Provenance and registration integrity. +5. Registry and public-demo truthfulness. +6. Canonical corpus alignment. + +The projects execute in that order. Projects 1 and 2 are stop-the-line work. Project 3 +tests the proposed terminal product as an accounting spike. Projects 4 and 5 harden +adjacent surfaces. Project 6 is a controlled documentation migration after the behavior +and evidence exist. + +### Implementation-plan boundaries + +1. Claims quarantine and tracked marketing corrections. +2. Clone-economics validity, larger fixtures, and durable evidence. +3. Phase-0 funding, crash-recovery, override, and metadata safety. +4. Atomic integer monetary kernel. +5. Authoritative Collar Invocation journal and signed receipts. +6. Wielder x402 payment policy. +7. COGS-aware quoting and execution. +8. Employer-funded internal Invocation spike. +9. Authorship attestation, duplicate detection, dispute, and revocation model. +10. Registry and public-demo corrections, gated on explicit approval for untracked + `hf-space/` work. +11. Protected-corpus amendment proposal and, only after separate approval, canonical + application. + +## Project 1: Launch and evidence integrity + +### Responsibilities + +- Block clone-economics campaign copy until the benchmark has a valid target baseline + and a genuinely larger training set. +- Correct tracked marketing statements that convert modeled values into paid or + measured values. +- Correct the x402 handshake, Wielder/Collar roles, settlement counts, split-level + reconciliation, and measurement dates. +- Replace absolute extraction language such as "the Skill never leaves" with the + supportable claim that the artifact is not directly returned, while preserving + adversarial extraction testing as a runtime requirement. +- Preserve prior results as historical evidence with their original labels; do not + erase or silently rewrite them. +- Make live evidence independently inspectable without committing secrets, private + prompts, or keys. + +### Evidence bundle contract + +Every live experiment bundle contains: + +- `manifest.json`: experiment identifier, UTC timestamp, git commit, command, runtime + versions, model/provider identifier, evidence label, configuration with secrets + removed, and SHA-256 hashes for every other file. +- `samples.jsonl`: one normalized row per attempted sample, including success/failure, + timing fields, usage fields, cost fields, and a stable sample identifier. +- `summary.json`: statistics recomputed from `samples.jsonl`; never hand-entered. +- `report.md`: human interpretation generated from `summary.json`, with explicit + measured, modeled, synthetic, extrapolated, and unknown labels. +- `README.md`: reproduction command, privacy/redaction statement, and known limits. + +Raw provider payloads that contain private content remain ignored. The committed +bundle contains only the normalized fields needed to reproduce the claims. + +### Clone benchmark validity + +A clone run may produce a report regardless of outcome, but it may not produce a +fidelity conclusion unless all of these conditions hold: + +- the target meets the configured absolute-score threshold; +- the target passes every critical gate; +- training and held-out fixtures remain disjoint by identifier and normalized hash; +- a high-N run uses a preregistered sweep of `N=6,25,50,100`, at least 30 held-out + fixtures, and three independent distillation seeds at each N; only the N=100 bound + may be described as the repository's first high-N result; +- all attempted distillation runs and their provider cost are included in total attack + cost; +- acquisition is labeled modeled unless actual paid Invocation receipts are present; +- labor, deployment, tuning, and measurement costs are reported separately when not + included in the attacker-build total. + +If the target fails, the verdict is `INVALID_BENCHMARK_TARGET_FAILED`. Clone-quality, +moat, and break-even conclusions are suppressed. + +### Acceptance criteria + +- No tracked launch copy says `$1.58 paid`, `six paid runs`, or equivalent. +- The clone campaign remains explicitly blocked until a valid larger-N result exists. +- A clean checkout can recompute every published p50, p95, cost, and sample count from + committed normalized evidence. +- The clone offline suite passes both with and without an existing ignored `runs/` + directory. +- Live provider spend for the preregistered high-N sweep remains a human approval gate. + +## Project 2: Monetary accounting and settlement lifecycle + +### Money representation + +All monetary calculations use integer atomic units. The reference implementation uses +USDC's six-decimal unit and exposes formatting only at UI/report boundaries. + +The following invariants are enforced after every allocation: + +```text +gross = executionCost + settlementCost + protocolFee + royaltyPool + refundReserve +royaltyPool = sum(holderCredits) + sum(ancestorCredits) +internalGross = executionCost + protocolFee + refundReserve + invocationAward +sum(all debits) = sum(all credits) +gross >= 0 +every component >= 0 +``` + +Rounding remainders are assigned deterministically by stable recipient order. Negative, +non-finite, unsafe, or over-precision inputs are rejected before state changes. + +### Authoritative lifecycle + +The Collar persists one record per attempted Invocation. Execution state is independent +from the funding mechanism: + +```text +requested + -> quoted + -> authorized + -> executing + -> succeeded | failed | cancelled + +external payment: offered -> signed -> settled | rejected | unresolved + settled | unresolved -> refunded +internal budget: allocated -> reserved -> consumed | released +``` + +`failed` is reachable after authorization or during execution. A payment that settles +but whose response is lost remains `unresolved` until reconciliation advances it. A +settled external payment remains attached to the Invocation even when execution fails. +No HTTP status deletes or suppresses a settled event. + +External execution requires a settled payment credential. Internal execution requires +a valid reserved-budget credential. Neither path may execute from a quote alone. + +Each record binds: + +- Invocation identifier and idempotency key; +- Skill identifier and immutable version/hash; +- Wielder and Beneficiary identifiers appropriate to the mode; +- Creator, employer, cost center, and effective policy version when applicable; +- quote and currency; +- payment or budget-authorization reference; +- settlement transaction hash when x402 is used; +- execution outcome and failure class; +- model usage and execution COGS; +- protocol fee, settlement cost, refund reserve, and Royalty-claim credits; +- internal budget reservation and Invocation-award state when applicable; +- timestamps and a signed receipt hash. + +The Wielder may cache signed receipts and render a session view, but it never supplies +authoritative splits. Split data comes from the Collar's signed receipt. + +### External x402 policy + +Before signing an x402 offer, a Wielder policy validates: + +- exact supported network and asset contract; +- expected resource and trusted seller/payee; +- maximum per-call amount and remaining session budget; +- timeout and quote freshness; +- amount equality between the accepted quote and retry; +- one retry per authorization. + +An offer that fails any check is rejected without signing. + +### COGS treatment + +The Skill's own model/tool execution cost is part of the quote and ledger. Royalty +credits are calculated only after execution cost, settlement cost, protocol fee, and +refund reserve are allocated. A successful Invocation with negative contribution +margin fails the product acceptance gate even when cash reconciliation succeeds. +Model and token limits are allow-listed, provider pricing is versioned with the quote, +and adversarial extraction tests verify the artifact is not directly serialized or +returned while avoiding any guarantee that model output can never reveal behavior. + +### Acceptance criteria + +- Property tests prove exact conservation across prices, fee rates, claim tables, + ancestry depth, and rounding boundaries. +- Negative and non-finite prices cannot mutate state. +- A settled-then-500 fault creates a ledger record with the transaction hash and + `failed` outcome. +- A lost seller response can be reconciled from the settlement reference without a + duplicate debit. +- Wielder policy tests reject the wrong network, asset, payee, resource, amount, + expired quote, and exhausted budget. +- Every successful hosted-Skill receipt includes actual or explicitly unknown COGS; + unknown is never treated as zero. +- No accepted quote has negative worst-case contribution margin under its model and + token limits. + +## Project 3: Employer-funded internal Invocation flow + +### Canonical mode + +The employer is the Beneficiary and the source of compensation funds for internal +Invocations. The employee-Creator does not need an external customer to earn. + +Because the protected corpus currently defines an external-Wielder-funded model, the +first implementation is an explicitly labeled accounting spike, not silent product +doctrine. It may become the canonical Phase-1 implementation only after Project 6's +amendment set receives separate approval. + +Phase 1 avoids platform custody by using an employer-retained, approved Invocation +budget and a signed payable ledger rather than moving compensation funds through a +platform omnibus wallet. The budget is an authorization and accounting limit, not a +prepaid customer balance held by the Collar. Actual employee payment uses the +employer's payroll or accounts-payable rail at the schedule required by the +counsel-drafted instrument. + +An internal Invocation award and an external Royalty claim are distinct events: + +- An internal Invocation consumes employer budget and may create an employee + compensation obligation under an employer plan. +- An external Invocation receives third-party revenue and distributes the royalty pool + through the co-held Royalty claim. + +`Invocation award` is a proposed ubiquitous-language term for the canonical amendment +set. Until that amendment is approved, this design uses it only to avoid mislabeling an +internal compensation allocation as external royalty revenue. + +### Program and policy model + +The employer compensation program moves through: + +```text +draft -> approved -> active -> suspended + -> expired +``` + +Every active program references an immutable, effective-dated policy version defining +eligible Creators, Skills, Wielders, cost centers, award rate, per-Invocation and +per-period caps, vesting or earning rule, payment dates, termination treatment, and +required approvals. Policy changes apply prospectively; historical Invocation records +retain the policy version they used. + +### Budget model + +An employer account has: + +- a currency; +- an approved budget limit for a defined period; +- allocated, reserved, consumed, and released atomic amounts; +- permitted Skills, Creators, Wielders, and cost centers; +- a maximum quote and maximum award per Invocation; +- an effective and expiry time; +- employer signer identity and signature. + +Before execution, the Collar atomically reserves the quoted maximum. The reservation +is the internal-mode prerequisite for an Execution credential. On completion, the +Collar finalizes actual cost and releases the unused amount. On pre-execution failure, +the full reservation is released. On post-execution failure, unavoidable COGS remains +recorded and the unused reservation is released. + +Budget amounts move through `allocated -> reserved -> consumed`; a failed or cheaper +Invocation releases unused reservation. The maximum reservation is: + +```text +max execution COGS + platform/protocol fee + max Invocation award + refund reserve +``` + +The internal Execution credential is a signed, single-use, non-transferable record +binding the Invocation identifier, reservation identifier, Skill version, policy +version, expiry, and nonce. It is not redeemable for money and cannot be reused after +the reservation is consumed or released. + +### Internal allocation + +For a successful internal Invocation: + +```text +employer gross payable + - execution COGS + - platform/protocol fee + - refund reserve + = employee-Creator Invocation award +``` + +The Invocation award moves through +`measured -> vesting_pending -> earned -> payable -> paid`; policies without vesting +skip `vesting_pending`. The effective policy and counsel-drafted compensation +instrument control the transition dates. A correction is an append-only reversal or +prospective adjustment; historical amounts are never silently overwritten. The +employer does not credit or pay a claim to itself for internal use; doing so would +create circular gross revenue and inflate compensation metrics. The employer's co-held +Royalty-claim share applies only to revenue from an external Beneficiary. External +Invocations, if later enabled, credit both co-holders according to the claim table. + +A successful qualified Invocation may earn an award. A failed or cancelled Invocation +earns no award, although unavoidable execution COGS remains recorded. Creator-generated +self-Invocations require manager approval or are excluded by policy. Atomic concurrent +reservations, authorized Wielder and cost-center lists, caps, and idempotency prevent +budget races and trivial award farming. + +### Trust and audit model + +- The Collar signs an append-only Invocation receipt. +- Employer and employee receive the same receipt and periodic statement root. +- Monotonic sequence numbers expose gaps. +- A statement includes opening balance, reservations, releases, finalized charges, + Invocation awards, employee payables, payments, reversals, refunds, and closing + balance. +- Merkle inclusion proves that a disclosed record is in a statement. Sequence and + cross-party receipt comparison provide the completeness signal; Merkle inclusion + alone is never described as completeness proof. + +### Pilot success criteria + +- One employer configures and signs an internal budget. +- An authorized internal Wielder invokes a registered Skill. +- The Collar reserves budget, executes, records COGS, releases unused budget, and + records the employee Invocation award without any external Wielder. +- Employer and employee independently verify the same signed receipt and statement. +- An unauthorized Wielder, expired budget, exceeded cap, repeated idempotency key, + unapproved self-Invocation, and insufficient remaining budget all fail before + execution. +- A counsel-drafted instrument and an actual employer payment through payroll/AP remain + human launch gates; the software does not claim those gates are complete. +- No platform-held prepaid balance, employee wallet, Story royalty settlement, + on-demand withdrawal, Marketplace, or tradeability enters this Phase-1 slice. +- The spike labels its result as accounting evidence only; it does not claim demand, + employment-law, tax, securities, or custody validation. + +## Project 4: Provenance and registration integrity + +### Attestation levels + +Registration surfaces use explicit evidence levels: + +1. `wallet_asserted`: a wallet registered a content hash and declared ancestry. +2. `repository_control_verified`: the wallet is bound to a signed commit or repository + owner challenge for the registered bytes. +3. `organization_approved`: an authorized organization signer approved the Skill and + Creator relationship. + +No level proves that a Skill is safe. Safety review is a separate status with separate +controls. + +### Duplicate and dispute behavior + +- Duplicate artifact hashes are visible, not silently accepted as independent + originals. +- A later registrant cannot displace an earlier record merely by claiming authorship. +- A challenge references both registrations and records evidence, status, and outcome. +- Revocation marks an attestation invalid without deleting historical chain evidence. +- Declared Derivative ancestry remains distinguishable from verified repository history. + +### Phase 0 reliability + +Before any testnet transaction, Phase 0 verifies an estimated minimum balance. It +persists intent and transaction-submission identifiers before awaiting confirmation, +then reconciles chain state before retrying after a crash. Metadata defaults use a +durable pinned provider; environment overrides are stage-specific and verified against +the exact serialized bytes. + +### Acceptance criteria + +- Public APIs and UI never collapse `wallet_asserted` into `authored by`. +- Two wallets registering identical bytes produce a visible conflict record. +- A signed repository-control challenge can be verified offline. +- Resume after a simulated confirm-before-save crash performs no duplicate write. +- Dust funding fails preflight with the estimated required minimum. + +## Project 5: Registry and public-demo truthfulness + +`hf-space/` is pre-existing untracked user work. Its plan may specify corrections, but +no implementation edits, staging, or publication occur without explicit approval to +include that directory. + +### Registry + +The registry is described as settlement-verifiable, not unfakeable. Settlement proves +that value moved, not that demand was independent or that a Skill is useful or safe. + +Ranking excludes: + +- self-payments; +- known Creator/payee-linked wallets; +- refunded or failed Invocations; +- repeated Sybil-cluster activity; +- gross volume that is immediately recycled. + +Public metrics report total settlements, successful Invocations, settled failures, +unique independent Beneficiaries, refund-adjusted net revenue, and confidence in payer +independence as separate fields. The first registry remains allow-listed until at least +two independent Beneficiaries have paid for successful Invocations. + +### Public demo + +- Intra-org is the default archetype. +- Education is labeled deferred because free re-authoring defeated the tested model. +- Marketplace is labeled Phase-3 optionality. +- The royalty visualizer subtracts costs and protocol fee before showing the claim pool. +- LAP/LRP policy is explicit; the visualizer does not silently substitute one for the + other. +- A live 402 badge requires HTTP 402, supported x402 version, and a valid offer schema. +- Cached responses are visually and textually distinct from live responses. +- Inference-route measurements are not attributed to the hosted-Skill endpoint. +- Implemented credits are distinguished from future withdrawal or on-chain settlement. + +### Acceptance criteria + +- A self-funded Sybil fixture cannot rank as independent demand. +- The demo's displayed allocation exactly matches the accounting core. +- JSON 200 and JSON 500 responses cannot render as live 402 proof. +- Every public measurement links to a committed evidence manifest. + +## Project 6: Canonical corpus alignment + +This project begins only after Projects 1–5 establish the behavior and evidence. + +It produces a proposed amendment set for explicit review covering: + +- employer-funded internal Invocations as the terminal Intra-org billable event; +- internal budget authorization versus external x402 payment; +- internal Invocation awards versus the co-held external-revenue Royalty claim; +- the Collar as authoritative ledger and the Wielder ledger as a receipt view; +- gross-price allocation including COGS and reserves; +- wallet-attested registration versus verified authorship; +- contractual non-transferable claims versus transferable Story royalty tokens; +- settlement-verifiable registry language; +- corrected benchmark and evidence claims; +- Education's final deferred status. + +The canonical amendment set includes a new ADR for employer-funded internal +Invocations. It supersedes the assumption that every Execution credential must arise +from a settled x402 payment, while preserving x402 and txHash credentials for external +Invocations. It also introduces the Invocation-award term and confines ADR-0005's +cross-chain/custody path to externally funded Invocations. + +Closed-mode Phase 0 remains registration-only. It does not distribute native Story +royalty tokens while those tokens are transferable and the closed-mode entitlement is +required to be non-transferable. + +Until explicit approval is given, the amendment set does not modify `CONTEXT.md`, +`docs/PRD.md`, or any ADR. After approval, those canonical files are updated together +in one coherence commit, preserving historical statements where needed and extending +the PRD's unvalidated ledger rather than deleting from it. + +## Review-finding coverage + +- Unsupported launch and extraction claims: Project 1. +- Invalid clone baseline, modeled cost presented as paid, high-N gate, and missing raw + evidence: Project 1. +- Floating-point conservation failure, settled-failure omission, unsafe x402 offer + acceptance, and missing COGS: Project 2. +- External-demand dependency, circular employer self-credit, custody ambiguity, and + internal compensation funding: Project 3. +- Wallet assertion presented as authorship, duplicate registration, dust funding, + confirm-before-save duplication, metadata durability, and override safety: Project 4. +- Wash-tradeable ranking, safety overclaim, incorrect demo allocation, deferred-mode + default, live-402 validation, and evidence misattribution: Project 5. +- Protected-corpus contradictions and transferable Story tokens in a non-transferable + closed mode: Project 6. + +Every material adversarial-review finding maps to an implementation-plan boundary; +there is no uncovered remediation item in this design. + +## Error handling and recovery + +- All write operations use stable idempotency keys. +- Budget reservation and Invocation-record creation are atomic. +- Settlement reconciliation is retryable and never produces a second debit. +- Refunds are first-class ledger entries linked to the original Invocation. +- Unknown provider usage or cost remains `unknown`; it is never coerced to zero. +- Evidence generation fails closed when sample counts, required hashes, or source-target + validity are incomplete. +- Provenance disputes append status changes; historical evidence is not destroyed. + +## Testing strategy + +Each implementation plan uses test-driven development and lands independently. + +- Unit tests: money parsing, allocation, remainder policy, lifecycle transitions, + budget reservations, signature verification, attestation levels, and ranking filters. +- Property tests: conservation, non-negativity, idempotency, and allocation totals. +- Fault injection: settlement success followed by HTTP 500, response loss, crash after + chain confirmation, duplicate retry, expired budget, and missing usage. +- Contract tests: Collar signed receipt schema, Wielder policy, evidence manifest, and + live-402 validation. +- End-to-end tests: external mock x402 Invocation and internal employer-budget + Invocation, both reconciled into the authoritative Collar ledger. +- Fresh-checkout tests: every published metric recomputes from committed evidence. + +No mainnet transaction, funded-wallet operation, social publication, or external +deployment is part of automated verification. + +## Human-only gates + +- Approve and publish marketing or social content. +- Supply any real key or fund any testnet wallet. +- Approve canonical edits to `CONTEXT.md`, `docs/PRD.md`, or ADRs. +- Obtain the design-partner agreement and counsel-drafted compensation instrument. +- Decide the real payroll/AP integration and payment schedule. +- Obtain employer HR, payroll, finance, security, privacy, tax, IP, employment, and + payments approvals for a real employee pilot. +- Authorize any public deployment, repository transfer, or registry launch. +- Approve editing or adding the untracked `hf-space/` directory. + +## Overall definition of done + +The remediation is complete when: + +1. Public claims are evidence-linked and correctly labeled. +2. Clone conclusions cannot publish from an invalid target baseline. +3. Accounting conserves integer atomic units under all tested allocations. +4. Every settled payment, including failure, appears in the authoritative ledger. +5. Internal employer-funded Invocations record employee Invocation awards without + external demand or platform custody, and payroll/AP status is reconciled separately. +6. Registration claims state exactly which evidence level they establish. +7. Registry ranking resists the documented self-payment and Sybil fixtures. +8. The public demo matches implemented accounting and current product sequencing. +9. Canonical corpus amendments are approved and applied as a coherent set. +10. The worktree contains no tracked key, `.env`, raw private provider payload, or + unlabeled synthetic result. From e5a8f916ce39a6bdc9329a25ed8a156c76d71734 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 19:21:59 -0400 Subject: [PATCH 026/165] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 80e9989..a05f42d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ out/ # Local machine state .DS_Store .claude/settings.local.json +.worktrees/ # Run artifacts (belt-and-braces; spikes also ignore locally) runs/ From 754c9513e6973916e616a0e9a096a83827f137b8 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 21:47:52 -0400 Subject: [PATCH 027/165] docs: plan adversarial remediation execution --- .../plans/2026-07-17-atomic-money-kernel.md | 891 +++++ .../2026-07-17-authorship-attestation.md | 831 +++++ .../plans/2026-07-17-claims-quarantine.md | 544 +++ .../2026-07-17-clone-economics-evidence.md | 2210 ++++++++++++ .../plans/2026-07-17-cogs-aware-execution.md | 1625 +++++++++ .../2026-07-17-collar-invocation-journal.md | 3131 +++++++++++++++++ .../2026-07-17-corpus-amendment-proposal.md | 430 +++ ...-07-17-internal-invocation-awards-spike.md | 840 +++++ .../plans/2026-07-17-phase0-proof-safety.md | 1815 ++++++++++ .../plans/2026-07-17-public-surfaces.md | 971 +++++ .../2026-07-17-wielder-payment-policy.md | 1108 ++++++ 11 files changed, 14396 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-atomic-money-kernel.md create mode 100644 docs/superpowers/plans/2026-07-17-authorship-attestation.md create mode 100644 docs/superpowers/plans/2026-07-17-claims-quarantine.md create mode 100644 docs/superpowers/plans/2026-07-17-clone-economics-evidence.md create mode 100644 docs/superpowers/plans/2026-07-17-cogs-aware-execution.md create mode 100644 docs/superpowers/plans/2026-07-17-collar-invocation-journal.md create mode 100644 docs/superpowers/plans/2026-07-17-corpus-amendment-proposal.md create mode 100644 docs/superpowers/plans/2026-07-17-internal-invocation-awards-spike.md create mode 100644 docs/superpowers/plans/2026-07-17-phase0-proof-safety.md create mode 100644 docs/superpowers/plans/2026-07-17-public-surfaces.md create mode 100644 docs/superpowers/plans/2026-07-17-wielder-payment-policy.md diff --git a/docs/superpowers/plans/2026-07-17-atomic-money-kernel.md b/docs/superpowers/plans/2026-07-17-atomic-money-kernel.md new file mode 100644 index 0000000..d38d937 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-atomic-money-kernel.md @@ -0,0 +1,891 @@ +# Atomic Money Kernel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a pure USDC atomic-unit allocation kernel that rejects unsafe inputs and proves exact conservation for external Royalty-claim and internal Invocation-award allocations. + +**Architecture:** Add `prototype/atomic-money.mjs` as the single zero-dependency money boundary: parse display USDC once, calculate only with non-negative `bigint` atomic units, and format only at report/UI edges. The module owns deterministic weighted remainders, recursive Derivative ancestry allocation, and external/internal gross partitions; existing settlement and Collar consumers migrate in later plans so this plan can land as a focused, independently tested kernel. + +**Tech Stack:** Node.js 20+, ECMAScript modules, built-in `node:test`, `node:assert/strict`, `bigint`; offline-only tests with no wallet, funds, provider key, or network access. + +--- + +## File map and public contract + +- Create `prototype/atomic-money.mjs`: parsing, formatting, basis-point math, deterministic allocation, ancestry traversal, and gross-partition functions. +- Create `prototype/tests/atomic-money.test.mjs`: boundary, conservation, remainder, ancestry, mutation-safety, and deterministic property-matrix tests. +- Modify `prototype/package.json`: replace the currently failing test command with the offline Node test command. +- Modify `prototype/README.md`: identify the new kernel as the future accounting source and label `settlement-engine.mjs` as an unmigrated historical consumer until the later runtime plans land. + +All public monetary fields use `bigint` in process and decimal strings only when serialized. No public allocator accepts dollar floats. + +### Task 1: Establish the atomic-USDC boundary + +**Files:** +- Create: `prototype/atomic-money.mjs` +- Create: `prototype/tests/atomic-money.test.mjs` +- Modify: `prototype/package.json:6-9` + +- [ ] **Step 1: Replace the currently failing package test command** + +Change `prototype/package.json` to keep the existing package metadata and use these scripts: + +```json +"scripts": { + "test": "node --test tests/*.test.mjs", + "test:fork-economics": "node spike-fork-economics.mjs" +} +``` + +- [ ] **Step 2: Write failing parse, validation, formatting, and basis-point tests** + +Create `prototype/tests/atomic-money.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ATOMIC_PER_USDC, + BPS_DENOMINATOR, + assertAtomic, + floorBps, + formatUsdc, + parseUsdc, +} from '../atomic-money.mjs'; + +test('parseUsdc converts exact display values to six-decimal atomic units', () => { + assert.equal(ATOMIC_PER_USDC, 1_000_000n); + assert.equal(BPS_DENOMINATOR, 10_000n); + assert.equal(parseUsdc('0'), 0n); + assert.equal(parseUsdc('0.000001'), 1n); + assert.equal(parseUsdc('0.25'), 250_000n); + assert.equal(parseUsdc('9007199254740991.123456'), 9_007_199_254_740_991_123_456n); +}); + +test('parseUsdc rejects every non-string, negative, exponent, and over-precision input', () => { + for (const value of ['-1', '0.0000001', '1e-6', '', 0.25, 0, NaN, Infinity, -0.01, 9_007_199_254.740992, 1n, null]) { + assert.throws(() => parseUsdc(value), (error) => error?.name === 'MoneyInputError'); + } +}); + +test('assertAtomic accepts only non-negative bigint values', () => { + assert.equal(assertAtomic(0n), 0n); + assert.equal(assertAtomic(7n), 7n); + assert.throws(() => assertAtomic(-1n), /must be non-negative/); + assert.throws(() => assertAtomic(1), /must be a bigint/); +}); + +test('formatUsdc is a canonical six-decimal serialization boundary', () => { + assert.equal(formatUsdc(0n), '0.000000'); + assert.equal(formatUsdc(1n), '0.000001'); + assert.equal(formatUsdc(250_000n), '0.250000'); + assert.equal(formatUsdc(1_000_001n), '1.000001'); +}); + +test('floorBps floors deterministically without using floating point', () => { + assert.equal(floorBps(250_000n, 250), 6_250n); + assert.equal(floorBps(1n, 5_000), 0n); + assert.equal(floorBps(3n, 5_000), 1n); + assert.throws(() => floorBps(1n, -1), /between 0 and 10000/); + assert.throws(() => floorBps(1n, 10_001), /between 0 and 10000/); +}); +``` + +- [ ] **Step 3: Run the test and verify the module is missing** + +Run: `npm test --prefix prototype` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `prototype/atomic-money.mjs`. + +- [ ] **Step 4: Implement the input boundary and basis-point primitive** + +Create `prototype/atomic-money.mjs`: + +```js +export const USDC_DECIMALS = 6; +export const ATOMIC_PER_USDC = 10n ** BigInt(USDC_DECIMALS); +export const BPS_DENOMINATOR = 10_000n; + +export class MoneyInputError extends RangeError { + constructor(code, message) { + super(message); + this.name = 'MoneyInputError'; + this.code = code; + } +} + +function fail(code, message) { + throw new MoneyInputError(code, message); +} + +export function assertAtomic(value, label = 'amountAtomic') { + if (typeof value !== 'bigint') fail('ATOMIC_TYPE', `${label} must be a bigint`); + if (value < 0n) fail('ATOMIC_NEGATIVE', `${label} must be non-negative`); + return value; +} + +export function assertBps(value, label = 'bps') { + if (!Number.isSafeInteger(value)) fail('BPS_INTEGER', `${label} must be a safe integer`); + if (value < 0 || value > 10_000) fail('BPS_RANGE', `${label} must be between 0 and 10000`); + return BigInt(value); +} + +export function parseUsdc(value, label = 'USDC amount') { + if (typeof value !== 'string') fail('DISPLAY_TYPE', `${label} must be a decimal string`); + const text = value.trim(); + + const match = /^(0|[1-9]\d*)(?:\.(\d{1,6}))?$/.exec(text); + if (!match) fail('DISPLAY_FORMAT', `${label} must be a non-negative decimal with at most six fractional digits`); + const whole = BigInt(match[1]); + const fraction = BigInt((match[2] ?? '').padEnd(USDC_DECIMALS, '0') || '0'); + return whole * ATOMIC_PER_USDC + fraction; +} + +export function formatUsdc(value) { + const atomic = assertAtomic(value); + const whole = atomic / ATOMIC_PER_USDC; + const fraction = String(atomic % ATOMIC_PER_USDC).padStart(USDC_DECIMALS, '0'); + return `${whole}.${fraction}`; +} + +export function floorBps(amountAtomic, bps) { + return (assertAtomic(amountAtomic) * assertBps(bps)) / BPS_DENOMINATOR; +} +``` + +- [ ] **Step 5: Run the focused test and verify the boundary passes** + +Run: `npm test --prefix prototype` + +Expected: PASS, 5 tests and 0 failures. In particular, no JavaScript `number` +crosses the display-to-atomic boundary; callers must pass an exact decimal string. + +- [ ] **Step 6: Commit the boundary** + +```bash +git add prototype/package.json prototype/atomic-money.mjs prototype/tests/atomic-money.test.mjs +git commit -m "feat: add atomic USDC boundary" +``` + +### Task 2: Add deterministic weighted and basis-point allocation + +**Files:** +- Modify: `prototype/atomic-money.mjs` +- Modify: `prototype/tests/atomic-money.test.mjs` + +- [ ] **Step 1: Append failing deterministic-remainder tests** + +Add these imports to the existing import list in `prototype/tests/atomic-money.test.mjs`: + +```js + allocateByBps, + allocateByWeights, +``` + +Append: + +```js +test('allocateByWeights conserves atomic units and assigns remainder by stable key', () => { + const shares = [ + { key: 'zoe', weight: 1 }, + { key: 'alice', weight: 1 }, + { key: 'mika', weight: 1 }, + ]; + assert.deepEqual(allocateByWeights(10_000n, shares), [ + { key: 'alice', amountAtomic: 3_334n }, + { key: 'mika', amountAtomic: 3_333n }, + { key: 'zoe', amountAtomic: 3_333n }, + ]); + assert.deepEqual(allocateByWeights(1n, [...shares].reverse()), [ + { key: 'alice', amountAtomic: 1n }, + { key: 'mika', amountAtomic: 0n }, + { key: 'zoe', amountAtomic: 0n }, + ]); +}); + +test('allocateByBps requires one complete, unique 10000-bps claim table', () => { + assert.deepEqual(allocateByBps(1n, [ + { key: 'creator', bps: 5_000 }, + { key: 'employer', bps: 5_000 }, + ]), [ + { key: 'creator', amountAtomic: 1n }, + { key: 'employer', amountAtomic: 0n }, + ]); + assert.throws(() => allocateByBps(100n, [{ key: 'creator', bps: 9_999 }]), /sum to 10000/); + assert.throws(() => allocateByBps(100n, [ + { key: 'creator', bps: 5_000 }, + { key: 'creator', bps: 5_000 }, + ]), /duplicate allocation key/); +}); + +test('allocators do not mutate caller-owned frozen inputs', () => { + const shares = Object.freeze([ + Object.freeze({ key: 'b', weight: 1 }), + Object.freeze({ key: 'a', weight: 2 }), + ]); + allocateByWeights(7n, shares); + assert.deepEqual(shares, [{ key: 'b', weight: 1 }, { key: 'a', weight: 2 }]); +}); +``` + +- [ ] **Step 2: Run the test and verify the allocators are undefined** + +Run: `npm test --prefix prototype` + +Expected: FAIL because `allocateByWeights` and `allocateByBps` are not exported. + +- [ ] **Step 3: Implement stable weighted allocation** + +Append to `prototype/atomic-money.mjs`: + +```js +function weightToBigInt(value, label) { + if (typeof value === 'bigint') { + if (value < 0n) fail('WEIGHT_NEGATIVE', `${label} must be non-negative`); + return value; + } + if (!Number.isSafeInteger(value) || value < 0) { + fail('WEIGHT_INTEGER', `${label} must be a non-negative safe integer or bigint`); + } + return BigInt(value); +} + +const compareKeys = (left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0; + +export function allocateByWeights(amountAtomic, shares) { + const amount = assertAtomic(amountAtomic); + if (!Array.isArray(shares) || shares.length === 0) { + fail('ALLOCATIONS_EMPTY', 'shares must contain at least one allocation'); + } + + const seen = new Set(); + const rows = shares.map((share, index) => { + const key = String(share?.key ?? ''); + if (!key) fail('ALLOCATION_KEY', `shares[${index}].key must be non-empty`); + if (seen.has(key)) fail('ALLOCATION_DUPLICATE', `duplicate allocation key '${key}'`); + seen.add(key); + return { key, weight: weightToBigInt(share.weight, `shares[${index}].weight`) }; + }).sort(compareKeys); + + const totalWeight = rows.reduce((sum, row) => sum + row.weight, 0n); + if (totalWeight === 0n) fail('WEIGHTS_ZERO', 'at least one allocation weight must be positive'); + + const allocations = rows.map((row) => ({ + key: row.key, + weight: row.weight, + amountAtomic: (amount * row.weight) / totalWeight, + })); + let remainder = amount - allocations.reduce((sum, row) => sum + row.amountAtomic, 0n); + for (const row of allocations) { + if (remainder === 0n) break; + if (row.weight === 0n) continue; + row.amountAtomic += 1n; + remainder -= 1n; + } + if (remainder !== 0n) throw new Error('internal invariant: weighted remainder was not exhausted'); + return allocations.map(({ key, amountAtomic }) => ({ key, amountAtomic })); +} + +export function allocateByBps(amountAtomic, shares) { + if (!Array.isArray(shares) || shares.length === 0) { + fail('ALLOCATIONS_EMPTY', 'shares must contain at least one allocation'); + } + const normalized = shares.map((share, index) => ({ + key: share?.key, + weight: assertBps(share?.bps, `shares[${index}].bps`), + })); + const total = normalized.reduce((sum, row) => sum + row.weight, 0n); + if (total !== BPS_DENOMINATOR) fail('BPS_TOTAL', `basis-point allocations must sum to 10000 (got ${total})`); + return allocateByWeights(amountAtomic, normalized); +} +``` + +- [ ] **Step 4: Run the test and verify deterministic conservation** + +Run: `npm test --prefix prototype` + +Expected: PASS, 8 tests and 0 failures. + +- [ ] **Step 5: Commit the allocation primitive** + +```bash +git add prototype/atomic-money.mjs prototype/tests/atomic-money.test.mjs +git commit -m "feat: conserve weighted atomic allocations" +``` + +### Task 3: Allocate a Royalty pool through Derivative ancestry + +**Files:** +- Modify: `prototype/atomic-money.mjs` +- Modify: `prototype/tests/atomic-money.test.mjs` + +- [ ] **Step 1: Append failing ancestry and cycle tests** + +Add `allocateRoyaltyGraph` to the test import list, then append: + +```js +const chain = (depth, inheritBps = 3_000) => Object.fromEntries( + Array.from({ length: depth + 1 }, (_, index) => [`skill-${index}`, { + parentIds: index === 0 ? [] : [`skill-${index - 1}`], + inheritBps, + holders: [{ recipientId: `creator-${index}`, bps: 10_000 }], + }]), +); + +test('allocateRoyaltyGraph conserves one-atomic remainders at every ancestry depth', () => { + for (let depth = 0; depth <= 4; depth += 1) { + for (const royaltyPoolAtomic of [0n, 1n, 2n, 9_999n, 250_001n]) { + const result = allocateRoyaltyGraph({ + royaltyPoolAtomic, + leafSkillId: `skill-${depth}`, + skills: chain(depth), + }); + assert.equal( + result.credits.reduce((sum, credit) => sum + credit.amountAtomic, 0n), + royaltyPoolAtomic, + ); + assert.equal( + result.holderCredits.reduce((sum, credit) => sum + credit.amountAtomic, 0n) + + result.ancestorCredits.reduce((sum, credit) => sum + credit.amountAtomic, 0n), + royaltyPoolAtomic, + ); + } + } +}); + +test('allocateRoyaltyGraph splits co-held claims in stable recipient order', () => { + const result = allocateRoyaltyGraph({ + royaltyPoolAtomic: 1n, + leafSkillId: 'root', + skills: { + root: { + parentIds: [], + inheritBps: 0, + holders: [ + { recipientId: 'employee', bps: 5_000 }, + { recipientId: 'employer', bps: 5_000 }, + ], + }, + }, + }); + assert.deepEqual(result.credits, [ + { recipientId: 'employee', viaSkillId: 'root', depth: 0, kind: 'holder', amountAtomic: 1n }, + { recipientId: 'employer', viaSkillId: 'root', depth: 0, kind: 'holder', amountAtomic: 0n }, + ]); +}); + +test('allocateRoyaltyGraph rejects missing nodes, duplicate parents, and cycles even at rounded-zero pools', () => { + for (const royaltyPoolAtomic of [0n, 1n]) { + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic, + leafSkillId: 'missing', + skills: {}, + }), /unknown Skill/); + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic, + leafSkillId: 'a', + skills: { + a: { parentIds: ['b'], inheritBps: 1_000, holders: [{ recipientId: 'a', bps: 10_000 }] }, + b: { parentIds: ['a'], inheritBps: 1_000, holders: [{ recipientId: 'b', bps: 10_000 }] }, + }, + }), /ancestry cycle/); + } + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic: 1n, + leafSkillId: 'leaf', + skills: { + leaf: { parentIds: ['root', 'root'], inheritBps: 1_000, holders: [{ recipientId: 'leaf', bps: 10_000 }] }, + root: { parentIds: [], inheritBps: 0, holders: [{ recipientId: 'root', bps: 10_000 }] }, + }, + }), /duplicate parent/); + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic: 1n, + leafSkillId: 'skill-33', + skills: chain(33), + }), /maximum depth 32/); +}); + +test('shared ancestry cannot hide a path deeper than 32 behind shallow validation memoization', () => { + const holder = (recipientId) => [{ recipientId, bps: 10_000 }]; + const skills = { + leaf: { parentIds: ['a-short', 'b-0'], inheritBps: 5_000, holders: holder('leaf') }, + 'a-short': { parentIds: ['shared'], inheritBps: 10_000, holders: holder('a') }, + shared: { parentIds: ['suffix'], inheritBps: 10_000, holders: holder('shared') }, + suffix: { parentIds: [], inheritBps: 0, holders: holder('suffix') }, + }; + for (let index = 0; index <= 30; index += 1) { + skills[`b-${index}`] = { + parentIds: index === 30 ? ['shared'] : [`b-${index + 1}`], + inheritBps: 10_000, + holders: holder(`b-holder-${index}`), + }; + } + for (const royaltyPoolAtomic of [0n, 1n]) { + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic, + leafSkillId: 'leaf', + skills, + }), /maximum depth 32/); + } +}); +``` + +- [ ] **Step 2: Run the tests and verify graph allocation is missing** + +Run: `npm test --prefix prototype` + +Expected: FAIL because `allocateRoyaltyGraph` is not exported. + +- [ ] **Step 3: Implement recursive, deterministic ancestry allocation** + +Append to `prototype/atomic-money.mjs`: + +```js +export function allocateRoyaltyGraph({ royaltyPoolAtomic, leafSkillId, skills }) { + const pool = assertAtomic(royaltyPoolAtomic, 'royaltyPoolAtomic'); + if (!skills || typeof skills !== 'object' || Array.isArray(skills)) { + fail('SKILLS_TYPE', 'skills must be an object keyed by Skill identifier'); + } + const credits = []; + const visiting = new Set(); + + const deepestValidatedDepth = new Map(); + const validating = new Set(); + const reachableNodes = new Set(); + function validateReachable(skillId, depth) { + if (depth > 32) fail('ANCESTRY_DEPTH', 'Derivative ancestry exceeds maximum depth 32'); + if (validating.has(skillId)) fail('ANCESTRY_CYCLE', `ancestry cycle contains Skill '${skillId}'`); + const priorDepth = deepestValidatedDepth.get(skillId); + // A prior visit at an equal or greater depth had less remaining depth budget and + // is safe to reuse. A deeper new path must be traversed again. + if (priorDepth != null && priorDepth >= depth) return; + const skill = skills[skillId]; + if (!skill) fail('SKILL_UNKNOWN', `unknown Skill '${skillId}'`); + reachableNodes.add(skillId); + if (reachableNodes.size > 128) fail('ANCESTRY_NODES', 'Derivative ancestry exceeds maximum 128 reachable Skills'); + validating.add(skillId); + const parentIds = [...(skill.parentIds ?? [])].map(String).sort(); + if (new Set(parentIds).size !== parentIds.length) { + fail('PARENT_DUPLICATE', `Skill '${skillId}' has a duplicate parent`); + } + if (parentIds.length) assertBps(skill.inheritBps, `${skillId}.inheritBps`); + allocateByBps(0n, (skill.holders ?? []).map((holder) => ({ + key: holder.recipientId, + bps: holder.bps, + }))); + for (const parentId of parentIds) validateReachable(parentId, depth + 1); + validating.delete(skillId); + deepestValidatedDepth.set(skillId, Math.max(priorDepth ?? -1, depth)); + } + validateReachable(String(leafSkillId), 0); + + function distribute(skillId, amountAtomic, depth) { + if (depth > 32) fail('ANCESTRY_DEPTH', 'Derivative ancestry exceeds maximum depth 32'); + const skill = skills[skillId]; + if (!skill) fail('SKILL_UNKNOWN', `unknown Skill '${skillId}'`); + if (visiting.has(skillId)) fail('ANCESTRY_CYCLE', `ancestry cycle contains Skill '${skillId}'`); + visiting.add(skillId); + + const parentIds = [...(skill.parentIds ?? [])].map(String).sort(); + if (new Set(parentIds).size !== parentIds.length) { + fail('PARENT_DUPLICATE', `Skill '${skillId}' has a duplicate parent`); + } + if (parentIds.length) assertBps(skill.inheritBps, `${skillId}.inheritBps`); + const inheritBps = parentIds.length ? skill.inheritBps : 0; + const ancestorPoolAtomic = parentIds.length ? floorBps(amountAtomic, inheritBps) : 0n; + const ownPoolAtomic = amountAtomic - ancestorPoolAtomic; + const holderRows = allocateByBps(ownPoolAtomic, (skill.holders ?? []).map((holder) => ({ + key: holder.recipientId, + bps: holder.bps, + }))); + for (const row of holderRows) { + credits.push({ + recipientId: row.key, + viaSkillId: skillId, + depth, + kind: depth === 0 ? 'holder' : 'ancestor', + amountAtomic: row.amountAtomic, + }); + } + + if (parentIds.length && ancestorPoolAtomic > 0n) { + const parentRows = allocateByWeights( + ancestorPoolAtomic, + parentIds.map((parentId) => ({ key: parentId, weight: 1 })), + ); + for (const row of parentRows) distribute(row.key, row.amountAtomic, depth + 1); + } + visiting.delete(skillId); + } + + distribute(String(leafSkillId), pool, 0); + const credited = credits.reduce((sum, credit) => sum + credit.amountAtomic, 0n); + if (credited !== pool) throw new Error(`internal invariant: credits ${credited} do not equal Royalty pool ${pool}`); + return { + royaltyPoolAtomic: pool, + credits, + holderCredits: credits.filter((credit) => credit.kind === 'holder'), + ancestorCredits: credits.filter((credit) => credit.kind === 'ancestor'), + }; +} +``` + +- [ ] **Step 4: Run the tests and verify ancestry conservation** + +Run: `npm test --prefix prototype` + +Expected: PASS, 12 tests and 0 failures. + +- [ ] **Step 5: Commit the ancestry allocator** + +```bash +git add prototype/atomic-money.mjs prototype/tests/atomic-money.test.mjs +git commit -m "feat: allocate atomic royalties through ancestry" +``` + +### Task 4: Partition external and internal gross amounts exactly + +**Files:** +- Modify: `prototype/atomic-money.mjs` +- Modify: `prototype/tests/atomic-money.test.mjs` + +- [ ] **Step 1: Append failing gross-partition and property-matrix tests** + +Add `allocateExternalGross` and `allocateInternalGross` to the test import list, then append: + +```js +test('allocateExternalGross subtracts costs, fee, and reserve before the Royalty pool', () => { + const result = allocateExternalGross({ + grossAtomic: 250_000n, + executionCostAtomic: 60_000n, + settlementCostAtomic: 1_000n, + protocolFeeBps: 250, + refundReserveAtomic: 2_000n, + leafSkillId: 'skill', + skills: { + skill: { + parentIds: [], + inheritBps: 0, + holders: [{ recipientId: 'creator', bps: 10_000 }], + }, + }, + }); + assert.equal(result.protocolFeeAtomic, 6_250n); + assert.equal(result.royaltyPoolAtomic, 180_750n); + assert.equal(result.holderCredits[0].amountAtomic, 180_750n); + assert.equal( + result.executionCostAtomic + result.settlementCostAtomic + result.protocolFeeAtomic + + result.royaltyPoolAtomic + result.refundReserveAtomic, + result.grossAtomic, + ); + assert.deepEqual(result.journalEntries.map(({ debitAccountId, creditAccountId, amountAtomic }) => ({ + debitAccountId, creditAccountId, amountAtomic, + })), [ + { debitAccountId: 'wielder:external-gross', creditAccountId: 'provider:execution', amountAtomic: 60_000n }, + { debitAccountId: 'wielder:external-gross', creditAccountId: 'provider:settlement', amountAtomic: 1_000n }, + { debitAccountId: 'wielder:external-gross', creditAccountId: 'protocol:treasury', amountAtomic: 6_250n }, + { debitAccountId: 'wielder:external-gross', creditAccountId: 'reserve:refund', amountAtomic: 2_000n }, + { debitAccountId: 'wielder:external-gross', creditAccountId: 'royalty:creator', amountAtomic: 180_750n }, + ]); +}); + +test('allocateInternalGross leaves one exact employee Invocation award', () => { + const result = allocateInternalGross({ + grossAtomic: 200_000n, + executionCostAtomic: 50_000n, + protocolFeeAtomic: 5_000n, + refundReserveAtomic: 5_000n, + recipientId: 'employee-1', + }); + assert.deepEqual(result.awardCredit, { recipientId: 'employee-1', amountAtomic: 140_000n }); + assert.equal( + result.executionCostAtomic + result.protocolFeeAtomic + + result.refundReserveAtomic + result.invocationAwardAtomic, + result.grossAtomic, + ); + assert.deepEqual(result.journalEntries, [ + { category: 'execution-cogs', debitAccountId: 'employer:invocation-gross', creditAccountId: 'provider:execution', amountAtomic: 50_000n }, + { category: 'protocol-fee', debitAccountId: 'employer:invocation-gross', creditAccountId: 'protocol:treasury', amountAtomic: 5_000n }, + { category: 'refund-reserve', debitAccountId: 'employer:invocation-gross', creditAccountId: 'reserve:refund', amountAtomic: 5_000n }, + { category: 'invocation-award', debitAccountId: 'employer:invocation-gross', creditAccountId: 'employee:employee-1', amountAtomic: 140_000n }, + ]); +}); + +test('gross partitions reject impossible economics before mutating inputs', () => { + const input = Object.freeze({ + grossAtomic: 100n, + executionCostAtomic: 99n, + settlementCostAtomic: 0n, + protocolFeeBps: 250, + refundReserveAtomic: 0n, + leafSkillId: 'skill', + skills: Object.freeze({ + skill: Object.freeze({ + parentIds: Object.freeze([]), + inheritBps: 0, + holders: Object.freeze([{ recipientId: 'creator', bps: 10_000 }]), + }), + }), + }); + assert.throws(() => allocateExternalGross(input), /cannot cover costs/); + assert.equal(input.grossAtomic, 100n); +}); + +const branchingClaims = () => ({ + leaf: { + parentIds: ['root-b', 'root-a'], inheritBps: 3_333, + holders: [{ recipientId: 'employee', bps: 3_333 }, { recipientId: 'employer', bps: 6_667 }], + }, + 'root-a': { + parentIds: [], inheritBps: 0, + holders: [{ recipientId: 'alice', bps: 5_001 }, { recipientId: 'acme', bps: 4_999 }], + }, + 'root-b': { + parentIds: [], inheritBps: 0, + holders: [{ recipientId: 'bob', bps: 7_777 }, { recipientId: 'beta', bps: 2_223 }], + }, +}); + +function assertBalanced(result, expectedSourceAccount, grossAtomic) { + const debitTotal = result.journalEntries.reduce((sum, entry) => sum + entry.amountAtomic, 0n); + const creditTotal = result.journalEntries.reduce((sum, entry) => sum + entry.amountAtomic, 0n); + assert.equal(debitTotal, grossAtomic); + assert.equal(creditTotal, grossAtomic); + assert.ok(result.journalEntries.every((entry) => entry.debitAccountId === expectedSourceAccount)); + assert.ok(result.journalEntries.every((entry) => entry.creditAccountId && entry.amountAtomic >= 0n)); +} + +test('deterministic matrix conserves gross and balanced entries across costs, co-holders, branching ancestry, and rounding', () => { + let cases = 0; + const claimGraphs = [ + { leafSkillId: 'skill-2', skills: chain(2) }, + { leafSkillId: 'leaf', skills: branchingClaims() }, + ]; + for (const grossAtomic of [250_001n, 1_000_003n]) { + for (const executionCostAtomic of [0n, 17n]) { + for (const settlementCostAtomic of [0n, 13n]) { + for (const refundReserveAtomic of [0n, 11n]) { + for (const protocolFeeBps of [0, 1, 250, 3_333]) { + for (const graph of claimGraphs) { + const result = allocateExternalGross({ + grossAtomic, executionCostAtomic, settlementCostAtomic, protocolFeeBps, + refundReserveAtomic, ...graph, + }); + assert.equal( + result.executionCostAtomic + result.settlementCostAtomic + result.protocolFeeAtomic + + result.royaltyPoolAtomic + result.refundReserveAtomic, + grossAtomic, + ); + assert.equal(result.credits.reduce((sum, credit) => sum + credit.amountAtomic, 0n), result.royaltyPoolAtomic); + assertBalanced(result, 'wielder:external-gross', grossAtomic); + cases += 1; + } + } + } + } + } + } + for (const grossAtomic of [0n, 1n, 2n]) { + for (const protocolFeeBps of [0, 1, 250, 3_333]) { + for (const graph of claimGraphs) { + const result = allocateExternalGross({ + grossAtomic, + executionCostAtomic: 0n, + settlementCostAtomic: 0n, + protocolFeeBps, + refundReserveAtomic: 0n, + ...graph, + }); + assertBalanced(result, 'wielder:external-gross', grossAtomic); + assert.ok(result.credits.every((credit) => credit.amountAtomic >= 0n)); + cases += 1; + } + } + } + assert.equal(cases, 152); +}); +``` + +- [ ] **Step 2: Run the tests and verify gross allocators are missing** + +Run: `npm test --prefix prototype` + +Expected: FAIL because `allocateExternalGross` and `allocateInternalGross` are not exported. + +- [ ] **Step 3: Implement exact external and internal partitions** + +Append to `prototype/atomic-money.mjs`: + +```js +function requireCoveredGross(grossAtomic, components) { + const required = components.reduce((sum, component) => sum + component, 0n); + if (required > grossAtomic) { + fail('GROSS_INSUFFICIENT', `gross ${grossAtomic} cannot cover costs and reserves ${required}`); + } + return grossAtomic - required; +} + +function journalEntry(category, debitAccountId, creditAccountId, amountAtomic) { + return { category, debitAccountId, creditAccountId, amountAtomic: assertAtomic(amountAtomic) }; +} + +export function allocateExternalGross({ + grossAtomic, + executionCostAtomic, + settlementCostAtomic, + protocolFeeBps, + refundReserveAtomic, + leafSkillId, + skills, +}) { + const gross = assertAtomic(grossAtomic, 'grossAtomic'); + const executionCost = assertAtomic(executionCostAtomic, 'executionCostAtomic'); + const settlementCost = assertAtomic(settlementCostAtomic, 'settlementCostAtomic'); + const refundReserve = assertAtomic(refundReserveAtomic, 'refundReserveAtomic'); + const protocolFee = floorBps(gross, protocolFeeBps); + const royaltyPool = requireCoveredGross(gross, [executionCost, settlementCost, protocolFee, refundReserve]); + const royalty = allocateRoyaltyGraph({ royaltyPoolAtomic: royaltyPool, leafSkillId, skills }); + const debitAccountId = 'wielder:external-gross'; + const journalEntries = [ + journalEntry('execution-cogs', debitAccountId, 'provider:execution', executionCost), + journalEntry('settlement-cogs', debitAccountId, 'provider:settlement', settlementCost), + journalEntry('protocol-fee', debitAccountId, 'protocol:treasury', protocolFee), + journalEntry('refund-reserve', debitAccountId, 'reserve:refund', refundReserve), + ...royalty.credits.map((credit) => journalEntry( + credit.kind === 'holder' ? 'royalty-holder' : 'royalty-ancestor', + debitAccountId, + `royalty:${credit.recipientId}`, + credit.amountAtomic, + )), + ]; + return { + grossAtomic: gross, + executionCostAtomic: executionCost, + settlementCostAtomic: settlementCost, + protocolFeeAtomic: protocolFee, + royaltyPoolAtomic: royaltyPool, + refundReserveAtomic: refundReserve, + credits: royalty.credits, + holderCredits: royalty.holderCredits, + ancestorCredits: royalty.ancestorCredits, + journalEntries, + }; +} + +export function allocateInternalGross({ + grossAtomic, + executionCostAtomic, + protocolFeeAtomic, + refundReserveAtomic, + recipientId, +}) { + const gross = assertAtomic(grossAtomic, 'grossAtomic'); + const executionCost = assertAtomic(executionCostAtomic, 'executionCostAtomic'); + const protocolFee = assertAtomic(protocolFeeAtomic, 'protocolFeeAtomic'); + const refundReserve = assertAtomic(refundReserveAtomic, 'refundReserveAtomic'); + const invocationAward = requireCoveredGross(gross, [executionCost, protocolFee, refundReserve]); + const recipient = String(recipientId ?? ''); + if (!recipient) fail('RECIPIENT_REQUIRED', 'recipientId must be non-empty'); + const debitAccountId = 'employer:invocation-gross'; + return { + grossAtomic: gross, + executionCostAtomic: executionCost, + protocolFeeAtomic: protocolFee, + refundReserveAtomic: refundReserve, + invocationAwardAtomic: invocationAward, + awardCredit: { recipientId: recipient, amountAtomic: invocationAward }, + journalEntries: [ + journalEntry('execution-cogs', debitAccountId, 'provider:execution', executionCost), + journalEntry('protocol-fee', debitAccountId, 'protocol:treasury', protocolFee), + journalEntry('refund-reserve', debitAccountId, 'reserve:refund', refundReserve), + journalEntry('invocation-award', debitAccountId, `employee:${recipient}`, invocationAward), + ], + }; +} +``` + +- [ ] **Step 4: Run the property matrix and verify exact conservation** + +Run: `npm test --prefix prototype` + +Expected: PASS, 16 tests and 0 failures; all 152 matrix cases conserve gross and +produce account-identified balanced entries whose Wielder debit equals every provider, +treasury, reserve, holder, and ancestor credit. The internal case similarly balances +the employer debit against provider, protocol, reserve, and employee-award credits. + +- [ ] **Step 5: Run static syntax validation** + +Run: `node --check prototype/atomic-money.mjs` + +Expected: exit 0 with no output. + +- [ ] **Step 6: Commit the gross allocators** + +```bash +git add prototype/atomic-money.mjs prototype/tests/atomic-money.test.mjs +git commit -m "feat: partition external and internal gross amounts" +``` + +### Task 5: Document the migration boundary and verify the plan slice + +**Files:** +- Modify: `prototype/README.md:1-23` + +- [ ] **Step 1: Add an explicit migration-status section** + +Insert after the opening blockquote in `prototype/README.md`: + +```markdown +## Accounting kernel status + +`atomic-money.mjs` is the tested accounting source for new work. It accepts USDC at +the display boundary, converts it to six-decimal atomic `bigint` values, and proves +exact gross and Royalty-pool conservation under deterministic remainder allocation. + +`settlement-engine.mjs` and its TUI are historical prototype consumers that still use +display-number state. Do not use them for new receipts or public allocation figures. +The Collar and employer-budget plans migrate their runtime consumers to +`atomic-money.mjs`; historical results remain labeled as historical rather than being +silently recomputed. +``` + +- [ ] **Step 2: Run the complete offline kernel suite** + +Run: `npm test --prefix prototype` + +Expected: PASS, 16 tests and 0 failures. + +- [ ] **Step 3: Confirm no floating-point operation exists in the new kernel** + +Run: `rg -n "Math\.|parseFloat|toFixed|Number\(" prototype/atomic-money.mjs` + +Expected: no matches. `parseUsdc` accepts decimal strings only and every allocation uses `bigint`. + +- [ ] **Step 4: Confirm no environment, wallet, or network dependency entered the slice** + +Run: `rg -n "process\.env|PRIVATE_KEY|fetch\(|mainnet" prototype/atomic-money.mjs prototype/tests/atomic-money.test.mjs` + +Expected: no matches. + +- [ ] **Step 5: Commit the migration boundary** + +```bash +git add prototype/README.md +git commit -m "docs: mark atomic accounting migration boundary" +``` + +## Definition of done + +- `prototype/atomic-money.mjs` is the only new public money kernel and exposes the exact contracts used by later plans: + - `parseUsdc(value): bigint` + - `formatUsdc(amountAtomic): string` + - `allocateByWeights(amountAtomic, shares)` + - `allocateByBps(amountAtomic, shares)` + - `allocateRoyaltyGraph({ royaltyPoolAtomic, leafSkillId, skills })` + - `allocateExternalGross({ grossAtomic, executionCostAtomic, settlementCostAtomic, protocolFeeBps, refundReserveAtomic, leafSkillId, skills })` + - `allocateInternalGross({ grossAtomic, executionCostAtomic, protocolFeeAtomic, refundReserveAtomic, recipientId })` +- All calculation fields are `bigint`; JSON/report consumers must serialize them as decimal strings. +- The deterministic matrix covers 150 fee/price/depth cases plus explicit co-hold and remainder boundaries. +- Negative, non-finite, unsafe-number, over-precision, impossible-gross, duplicate-recipient, missing-node, and cyclic-ancestry inputs fail before caller-owned input changes. +- No mainnet, funded-wallet, provider, or network action is performed. diff --git a/docs/superpowers/plans/2026-07-17-authorship-attestation.md b/docs/superpowers/plans/2026-07-17-authorship-attestation.md new file mode 100644 index 0000000..3535025 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-authorship-attestation.md @@ -0,0 +1,831 @@ +# Authorship Attestation and Registration Conflicts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an offline-verifiable attestation sidecar that distinguishes wallet assertion, repository control, and organization approval; exposes duplicate-byte conflicts; and preserves challenge, resolution, and revocation history without claiming originality or safety. + +**Architecture:** Keep Story registration immutable and unchanged, then add an append-only local JSONL attestation log keyed by registration IP ID and artifact SHA-256. A deterministic async reducer derives levels and conflicts only after verifying wallet, organization, challenger, resolver, and revoker signatures against verifier-provisioned trust roots. Repository evidence resolves a signed repository URL through verifier-controlled configuration to a pre-provisioned local checkout and trusted ref; claimant-supplied checkout paths or refs are never trust inputs. CLI output states this local-verifier trust assumption and uses `wallet_asserted` as the floor; no level is called remote-host ownership, originality, or safety proof. + +**Tech Stack:** TypeScript 5.6, Node.js 22, built-in `node:test`, `viem` message signing/verification, Git CLI read operations, append-only JSONL. + +--- + +## File map + +- Create `phase0/src/attestations.ts` — event schemas, canonical statements, signature verification, reducer, duplicate/conflict derivation, revocation. +- Create `phase0/src/attestation-store.ts` — append-only JSONL store with fsync-safe writes and full-log validation. +- Create `phase0/src/attestation-git.ts` — injected Git reader and offline repository-control verifier. +- Create `phase0/src/attestation-config.ts` — strict loader for the ignored local checkout-path mapping used by production CLI verification. +- Create `phase0/src/attestation-cli.ts` — CLI command handlers with machine-readable JSON output. +- Create `phase0/tests/attestations.test.ts` — level, signature, conflict, dispute, and revocation tests. +- Create `phase0/tests/attestation-store.test.ts` — append-only persistence and malformed-log tests. +- Create `phase0/tests/attestation-git.test.ts` — local Git commit and exact-byte verification tests. +- Modify `phase0/src/index.ts` — add read-only/attestation commands without adding a chain write. +- Modify `phase0/package.json` — add command aliases. +- Modify `phase0/README.md` — replace authorship/provenance overclaims with evidence-level language and document offline workflow. +- Create `phase0/attestations.jsonl` — empty tracked sidecar with no fabricated events. +- Create `phase0/organization-signers.json` — committed public trust-root allow-list, initially empty. +- Create `phase0/attestation-admins.json` — committed resolver/revoker wallet trust roots, initially empty. +- Create `phase0/repository-trust.json` — committed normalized repository allow-list and trusted-ref identifiers, initially empty; checkout paths are verifier-provisioned separately. +- Create `phase0/forge-signers.json` — verifier-provisioned forge-observer public keys, initially empty. +- Modify `.gitignore` — unignore the intentionally tracked empty attestation log and ignore only the local checkout-path mapping; never ignore tracked trust-root files. + +## Public schema and semantics + +```ts +export type AttestationLevel = + | "wallet_asserted" + | "repository_control_verified" + | "organization_approved"; + +export type AttestationStatus = "active" | "challenged"; +export type SafetyReviewStatus = "not_reviewed" | "pending" | "approved" | "rejected"; + +export interface RegistrationSubject { + registrationId: `eip155:1315:${string}`; + ipId: `0x${string}`; + wallet: `0x${string}`; + artifactHash: `0x${string}`; + declaredParentIpIds: `0x${string}`[]; +} + +export interface RepositoryControlChallengeV1 { + schemaVersion: 1; + subject: RegistrationSubject; + repositoryUrl: string; + artifactCommitSha: string; + artifactPath: string; + challengePath: string; + nonce: `0x${string}`; + issuedAt: string; + expiresAt: string; +} + +export interface OrganizationApprovalV1 { + schemaVersion: 1; + subject: RegistrationSubject; + organizationId: string; + approverWallet: `0x${string}`; + role: "ip_admin" | "engineering_executive"; + approvedAt: string; + statementHash: `0x${string}`; + signature: `0x${string}`; +} + +export interface ForgeObservationV1 { + schemaVersion: 1; + repositoryId: string; + repositoryUrl: string; + trustedRef: `refs/heads/${string}` | `refs/remotes/${string}`; + proofCommitSha: string; + challengeNonce: `0x${string}`; + observedAt: string; + forgeSignerId: string; + signature: string; +} +``` + +The canonical repository-control statement is UTF-8 with a final newline and fields in this exact order: + +```text +skill-asset-protocol/repository-control/v1 +registration= +ipId= +wallet= +artifactSha256= +repository= +artifactCommit= +artifactPath= +challengePath= +nonce= +issuedAt= +expiresAt= +``` + +The challenge file committed at `challengePath` contains the challenge object, +`statementHash`, and the EIP-191 wallet signature. It intentionally does not contain +its own proof commit hash: that would be self-referential. A verifier-provisioned forge +observer later signs `ForgeObservationV1`, binding the normalized repository URL, +configured ref, exact proof commit OID, wallet challenge nonce, and observation time. +The claimant may transport that observation but cannot choose its signer, checkout, or +ref. The verifier resolves `repositoryId` and URL through its own allow-list to a +pre-provisioned checkout, trusted ref, and permitted forge signer; neither path nor ref +is read from claimant CLI arguments. Verification requires: + +1. `git cat-file -e ^{commit}` and `git cat-file -e ^{commit}` succeed; +2. the forge observation's Ed25519 signature verifies against the configured forge signer and all URL/ref/nonce/OID bindings match; +3. the checkout's normalized `origin` URL equals the two signed `repositoryUrl` values; +4. `artifactCommitSha` is an ancestor of `proofCommitSha`; +5. `proofCommitSha` equals the forge-observed OID and is reachable from the configured trusted ref; +6. `git show :` hashes to `subject.artifactHash`; +7. `git show :` is byte-identical to the supplied signed challenge file; +8. `verifyMessage` recovers `subject.wallet` from the canonical statement; +9. the challenge was valid at `observedAt`, and `occurredAt` is not earlier than that observation. + +The forge observation canonical order is `schemaVersion, repositoryId, repositoryUrl, +trustedRef, proofCommitSha, challengeNonce, observedAt, forgeSignerId`; its Ed25519 +signature is base64 over canonical UTF-8 JSON. This establishes only that a trusted +forge observer reported that commit OID on that configured remote ref at the stated +time, and that a wallet signed matching artifact evidence contained in the exact +commit. Trust in the forge observer remains explicit. It does not prove current forge +account ownership, continuing remote hosting, originality, legal ownership, or safety. + +The canonical organization-approval statement is also UTF-8 with a final newline and +uses this exact order: + +```text +skill-asset-protocol/organization-approval/v1 +registration= +ipId= +wallet= +artifactSha256= +declaredParentIpIds= +organizationId= +approverWallet= +role= +approvedAt= +``` + +`statementHash` is the lowercase `0x`-prefixed Keccak-256 of those bytes. The EIP-191 +signature must recover `approverWallet`, and that wallet must appear in the injected +allow-list for `organizationId`. A signature from a self-declared but unlisted wallet +does not create `organization_approved`. + +The repository-control `statementHash` uses the same lowercase `0x`-prefixed +Keccak-256 convention over its canonical statement bytes. + +The append-only event union is: + +```ts +export type AttestationEvent = + | { type: "repository_control_verified"; eventId: string; sequence: number; occurredAt: string; subject: RegistrationSubject; challenge: RepositoryControlChallengeV1; forgeObservation: ForgeObservationV1; statementHash: `0x${string}`; signature: `0x${string}` } + | { type: "organization_approved"; eventId: string; sequence: number; occurredAt: string; subject: RegistrationSubject; approval: OrganizationApprovalV1 } + | { type: "challenge_opened"; eventId: string; sequence: number; occurredAt: string; conflictId: string; challengedRegistrationId: string; challengerRegistrationId: string; challengerWallet: `0x${string}`; evidenceUris: string[]; reason: "duplicate_bytes" | "misattributed_creator" | "unauthorized_registration"; statementHash: `0x${string}`; signature: `0x${string}` } + | { type: "challenge_resolved"; eventId: string; sequence: number; occurredAt: string; conflictId: string; outcome: "upheld" | "rejected" | "inconclusive"; rationale: string; adminSignerId: string; statementHash: `0x${string}`; signature: `0x${string}` } + | { type: "attestation_revoked"; eventId: string; sequence: number; occurredAt: string; registrationId: string; level: Exclude; reason: string; adminSignerId: string; statementHash: `0x${string}`; signature: `0x${string}` }; +``` + +`safetyReviewStatus` is always stored and rendered separately; no attestation event changes it. + +Challenge-opening signatures use EIP-191 and must recover the wallet of the existing +`challengerRegistrationId`. Resolution and revocation signatures use EIP-191 and must +recover the wallet provisioned for `adminSignerId`. Canonical signed field order is: + +```text +challenge_opened: eventId, sequence, occurredAt, conflictId, +challengedRegistrationId, challengerRegistrationId, challengerWallet, +sorted evidenceUris, reason + +challenge_resolved: eventId, sequence, occurredAt, conflictId, outcome, rationale, +adminSignerId + +attestation_revoked: eventId, sequence, occurredAt, registrationId, level, reason, +adminSignerId +``` + +Each message begins with +`skill-asset-protocol//v1\n`; `statementHash` is Keccak-256 +of those canonical UTF-8 bytes. Unsigned events, embedded admin keys, unknown admins, +and signatures made by another wallet fail before reduction or append. + +### Task 1: Define and reduce the append-only attestation model + +**Files:** +- Create `phase0/src/attestations.ts` +- Create `phase0/tests/attestations.test.ts` + +- [ ] **Step 1: Write failing reducer tests** + +Use runtime-generated viem accounts (`generatePrivateKey()` in the test process) and assert: + +```ts +const walletState = await reduceAttestationEvents([], { baseSubjects: [SUBJECT] }); +assert.equal(walletState.registrations[REGISTRATION_ID].level, "wallet_asserted"); +assert.equal(walletState.registrations[REGISTRATION_ID].claim, "wallet registered these bytes and declared this ancestry"); +assert.equal(walletState.registrations[REGISTRATION_ID].safetyReviewStatus, "not_reviewed"); + +const verifiedState = await reduceAttestationEvents( + [repositoryVerified], + { baseSubjects: [SUBJECT], repositoryVerifier: TEST_REPOSITORY_VERIFIER } +); +assert.equal(verifiedState.registrations[REGISTRATION_ID].level, "repository_control_verified"); + +const revokedState = await reduceAttestationEvents( + [repositoryVerified, revoked], + { + baseSubjects: [SUBJECT], + repositoryVerifier: TEST_REPOSITORY_VERIFIER, + adminSigners: ADMIN_SIGNERS + } +); +assert.equal(revokedState.registrations[REGISTRATION_ID].level, "wallet_asserted"); +assert.equal(revokedState.registrations[REGISTRATION_ID].status, "active"); +assert.equal(revokedState.registrations[REGISTRATION_ID].revocations.at(-1).level, "repository_control_verified"); + +const baseSubjects = registrationSubjectsFromManifest(CONFIRMED_MANIFEST); +const baselineState = await reduceAttestationEvents([], { baseSubjects }); +assert.equal(baselineState.registrations[REGISTRATION_ID].level, "wallet_asserted"); +``` + +Add tests proving a `not-run` manifest yields no base subject, an attempted sidecar +event with `type: "wallet_asserted"` is structurally rejected, and rejecting +non-contiguous sequence, duplicate event ID, level escalation without prerequisite, +organization signer not in the passed allow-list, valid signature from the wrong +wallet, modified organization statement after signing, fabricated or tampered +repository event signature/statement hash, subject drift for one registration ID, +malformed hashes/addresses/URLs, and any display claim containing `authored by`, +`original`, or `safe`. +Explicitly assert a correctly wallet-signed repository event still fails with +`repository verifier context required` when the reducer is called without the injected +repository verifier; structural and wallet verification alone may not upgrade level. + +Generate challenger and admin wallets at runtime. Assert an unsigned challenge, a +challenge signed by a wallet other than the challenger registration wallet, an +unsigned resolution/revocation, a self-declared admin wallet, an unknown admin ID, and +a tampered rationale/reason all fail without changing derived state. A valid admin +signature succeeds only when its signer ID resolves through the injected admin map. +Add a signed `attestation_revoked` event targeting `wallet_asserted` and require +structural rejection before admin-signature verification. Revoking repository or +organization evidence may downgrade the derived level and append revocation history, +but the confirmed-proof floor and base subject remain active and immutable. + +- [ ] **Step 2: Run and verify red** + +Run: `cd phase0 && node --import tsx --test tests/attestations.test.ts` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/attestations`. + +- [ ] **Step 3: Implement the schema, canonicalization, and reducer** + +Export these exact entry points: + +```ts +export function parseAttestationEvent(value: unknown): AttestationEvent; +export function canonicalRepositoryStatement(challenge: RepositoryControlChallengeV1): string; +export function repositoryStatementHash(challenge: RepositoryControlChallengeV1): `0x${string}`; +export function verifyRepositoryEventSignature(event: Extract): Promise; +export function canonicalOrganizationStatement(approval: Omit): string; +export function organizationStatementHash(approval: Omit): `0x${string}`; +export function verifyOrganizationApproval(approval: OrganizationApprovalV1, organizationSigners: Readonly>): Promise; +export function canonicalChallengeEventStatement(event: Extract): string; +export function canonicalAdminEventStatement(event: Extract): string; +export function verifyChallengeEventSignature(event: Extract, subjects: Readonly>): Promise; +export function verifyAdminEventSignature(event: Extract, adminSigners: Readonly>): Promise; +export function registrationSubjectsFromManifest(manifest: RegistrationManifest): RegistrationSubject[]; +export function reduceAttestationEvents(events: readonly AttestationEvent[], trust?: { organizationSigners?: Readonly>; adminSigners?: Readonly>; baseSubjects?: readonly RegistrationSubject[]; repositoryVerifier?: (event: Extract) => Promise }): Promise; +export function displayAttestation(index: AttestationIndex, registrationId: string): { + level: AttestationLevel; + status: AttestationStatus; + claim: string; + safetyReviewStatus: SafetyReviewStatus; + warnings: string[]; +}; +``` + +`registrationSubjectsFromManifest` maps each confirmed `RegistrationProof` to +`registrationId = eip155:1315:`, the manifest wallet, the artifact +`mediaHash`, and its declared parent IP IDs. It returns no subject for a `null` stage. +`reduceAttestationEvents` seeds every `trust.baseSubjects` entry at `wallet_asserted`, then +structurally parses every sidecar event, awaits `verifyRepositoryEventSignature` +and the required `trust.repositoryVerifier` before applying a repository event, and +awaits `verifyOrganizationApproval` before +applying an organization event. It verifies challenge openings against the existing +challenger subject wallet and resolution/revocation against `trust.adminSigners` before +changing conflict or attestation state. Repository-event signature verification recomputes +the canonical statement hash, requires `event.subject` to equal +`event.challenge.subject`, and recovers the subject wallet; it does not claim to repeat +the stronger Git ancestry/reachability checks without a checkout. Level precedence +is `organization_approved > repository_control_verified > wallet_asserted`, but +revocation removes only the named level and all dependent higher levels. +The reducer never accepts `wallet_asserted` as a sidecar event: only confirmed +`RegistrationProof` values mapped through `registrationSubjectsFromManifest` can +establish the base level. A local JSONL writer therefore cannot invent a registration +or a duplicate conflict by appending an unsigned base assertion. +It also never accepts `wallet_asserted` as a revocation target. A higher-level +revocation records `{ level, eventId, occurredAt, reason }` in a frozen `revocations` +array, downgrades to the strongest remaining evidence, and preserves the registration +status implied by open challenges; absent a challenge, the confirmed base remains +`active` rather than `revoked`. +`displayAttestation` uses only these claims: + +```text +wallet_asserted: wallet registered these bytes and declared this ancestry +repository_control_verified: wallet signature and matching bytes were verified against a trusted forge observation and verifier-provisioned Git snapshot +organization_approved: named organization signer approved the Skill and Creator relationship +``` + +Every display includes `Safety review: ; authorship attestation does not prove safety.` +It also includes `Repository evidence relies on the named forge observer and snapshot; +it does not prove current remote account ownership or continuing hosting.` whenever the +repository level is active. + +If any repository event exists and `repositoryVerifier` is absent, reduction throws +`repository verifier context required`. No exported reducer path may derive +`repository_control_verified` from structural parsing or wallet signature alone. + +- [ ] **Step 4: Run the reducer tests** + +Run: `cd phase0 && npm test && npm run typecheck` + +Expected: PASS; existing registration tests remain green. + +- [ ] **Step 5: Commit the model slice** + +```bash +git add phase0/src/attestations.ts phase0/tests/attestations.test.ts +git commit -m "feat: model explicit registration attestation levels" +``` + +### Task 2: Detect duplicate bytes and preserve conflict/dispute history + +**Files:** +- Modify `phase0/src/attestations.ts` +- Modify `phase0/tests/attestations.test.ts` + +- [ ] **Step 1: Add failing duplicate/conflict tests** + +Create two confirmed-manifest base subjects with the same `artifactHash` and different +wallets/registration IDs. Seed them through `baseSubjects`; do not fabricate sidecar +events for the base level. Assert one deterministic conflict: + +```ts +const index = await reduceAttestationEvents([], { baseSubjects: [firstSubject, secondSubject] }); +assert.deepEqual(index.conflicts, [{ + conflictId: deterministicConflictId(firstSubject, secondSubject), + artifactHash: ARTIFACT_HASH, + registrationIds: [FIRST_ID, SECOND_ID].sort(), + status: "open", + reason: "duplicate_bytes", + outcome: null +}]); +assert.equal(index.registrations[FIRST_ID].status, "challenged"); +assert.equal(index.registrations[SECOND_ID].status, "challenged"); +``` + +Assert a later registrant never displaces the earlier record, an admin-signed resolution +appends an outcome without deleting either registration, and an admin-signed +revocation leaves all historical events visible. Unsigned equivalents fail closed. + +- [ ] **Step 2: Run the duplicate tests and verify red** + +Run: `cd phase0 && node --import tsx --test --test-name-pattern='duplicate|conflict|challenge|revocation' tests/attestations.test.ts` + +Expected: FAIL because conflict derivation is not implemented. + +- [ ] **Step 3: Implement deterministic conflicts** + +Export `deterministicConflictId(a, b)` as `sha256:` plus SHA-256 of the sorted registration IDs joined by `\n`. `reduceAttestationEvents` must index all subjects by artifact hash, derive a pairwise conflict for different wallets, merge explicit challenge events by `conflictId`, and preserve `open`, `upheld`, `rejected`, or `inconclusive` outcome. Never use arrival order to choose an owner. + +- [ ] **Step 4: Run all Phase 0 tests** + +Run: `cd phase0 && npm test && npm run typecheck` + +Expected: PASS; duplicate-byte conflict is deterministic across reversed event order. + +- [ ] **Step 5: Commit conflict behavior** + +```bash +git add phase0/src/attestations.ts phase0/tests/attestations.test.ts +git commit -m "feat: surface duplicate Skill registration conflicts" +``` + +### Task 3: Verify repository control offline against exact Git bytes + +**Files:** +- Create `phase0/src/attestation-git.ts` +- Create `phase0/src/attestation-config.ts` +- Create `phase0/tests/attestation-git.test.ts` +- Modify `.gitignore` + +- [ ] **Step 1: Write failing offline-verification tests** + +Create a temporary Git repository with an HTTPS `origin`, configure a local test +identity, write and commit `skills/demo/SKILL.md` as `artifactCommitSha`, generate a +wallet at runtime, sign a challenge bound to that artifact commit, and commit the +signed challenge file as the later proof commit. Generate a separate Ed25519 forge key +at runtime and sign `ForgeObservationV1` over repository/ref/proof OID/nonce. Provision +an injected resolver with that one checkout, URL, ref, and permitted forge signer. +Assert `verifyRepositoryControl` returns `repository_control_verified`. + +Add tamper tests for artifact bytes, challenge bytes, artifact commit, proof commit, +artifact path, recovered wallet, expiry, repository URL, forge-observed OID/ref/nonce, +forge signature, unknown forge signer, proof not descending from artifact, and absent +challenge path. Create a second claimant-controlled local repository with a copied +`origin` string and ref; prove it is never read because it is absent from the verifier +resolver. Replaying the event after changing either wallet or forge signature fails. + +Add local-mapping tests for the production resolver. A valid owner-only mapping at an +absolute canonical path resolves the configured `checkoutKey`. Missing config, a +relative `PHASE0_ATTESTATION_CHECKOUTS_FILE`, symlinked config, permissions broader than +`0600`, unknown keys, relative/non-canonical/missing checkout paths, group/world-writable +checkout directories, an environment override to a non-default in-repository file, +missing trusted checkout keys, and extra keys not referenced by +`repository-trust.json` all fail closed. Use an injected filesystem metadata adapter +for ownership cases that cannot be created portably. Assert neither bundle data nor a +CLI `--repository-path` option can extend or override the mapping. + +- [ ] **Step 2: Run and verify red** + +Run: `cd phase0 && node --import tsx --test tests/attestation-git.test.ts` + +Expected: FAIL because `src/attestation-git.ts` does not exist. + +- [ ] **Step 3: Implement an injected Git reader** + +Define: + +```ts +export interface GitReader { + commitExists(repositoryPath: string, commitSha: string): Promise; + readBlob(repositoryPath: string, commitSha: string, relativePath: string): Promise; + isAncestor(repositoryPath: string, ancestor: string, descendant: string): Promise; + remoteUrl(repositoryPath: string, remoteName: string): Promise; +} + +export interface TrustedRepository { + repositoryId: string; + repositoryUrl: string; + repositoryPath: string; + trustedRef: `refs/heads/${string}` | `refs/remotes/${string}`; + permittedForgeSignerIds: readonly string[]; +} + +export interface TrustedRepositoryResolver { + resolve(repositoryId: string, normalizedRepositoryUrl: string): TrustedRepository; +} + +export interface LocalCheckoutMapV1 { + schemaVersion: 1; + checkouts: Record; +} + +export class ExecGitReader implements GitReader { + commitExists(repositoryPath: string, commitSha: string): Promise; + readBlob(repositoryPath: string, commitSha: string, relativePath: string): Promise; + isAncestor(repositoryPath: string, ancestor: string, descendant: string): Promise; + remoteUrl(repositoryPath: string, remoteName: string): Promise; +} + +export async function verifyRepositoryControl(input: { + challengeFile: Uint8Array; + forgeObservation: ForgeObservationV1; + eventId: string; + sequence: number; + occurredAt: string; + now: Date; + git: GitReader; + repositories: TrustedRepositoryResolver; + forgeSigners: Readonly>; +}): Promise>; + +export function canonicalForgeObservationBytes(observation: Omit): Uint8Array; +export function verifyForgeObservation(observation: ForgeObservationV1, trusted: TrustedRepository, forgeSigners: Readonly>): void; +export function reverifyRepositoryEvent(event: Extract, context: { git: GitReader; repositories: TrustedRepositoryResolver; forgeSigners: Readonly> }): Promise; + +// Exported from attestation-config.ts +export function loadLocalCheckoutMap(input: { + env: Readonly>; + phase0Root: string; + referencedCheckoutKeys: readonly string[]; +}): Promise>>; +export function createTrustedRepositoryResolver(input: { + trustConfig: unknown; + checkoutPaths: Readonly>; +}): TrustedRepositoryResolver; +``` + +The untracked local file schema is exactly: + +```json +{ + "schemaVersion": 1, + "checkouts": { + "example-checkout-key": "/canonical/absolute/path/to/verifier-checkout" + } +} +``` + +Immediately after the existing `*.jsonl` rule, add these exact root `.gitignore` lines +so the intended log is addable while machine paths remain local: + +```gitignore +!phase0/attestations.jsonl +phase0/.attestation-checkouts.local.json +``` + +`loadLocalCheckoutMap` uses the absolute path in +`PHASE0_ATTESTATION_CHECKOUTS_FILE` when set; otherwise it resolves +`.attestation-checkouts.local.json` beneath the injected canonical `phase0Root`. The +environment value is a path to ignored local configuration, never JSON and never a +checkout path itself. If the override resolves inside the repository, it must equal +the exact ignored default path; any other in-repository path is rejected. An override +outside the repository is allowed under the same ownership/permission checks and +cannot become a tracked repository file. The config must be an owner-matched, +non-symlink regular file +with mode `0600`. Every checkout value must already equal `realpath(value)`, be an +absolute existing directory owned by the current UID, and have neither group nor world +write bits. Keys must match `^[a-z0-9][a-z0-9._-]{0,127}$`; the set must equal—not +merely contain—the `checkoutKey` set referenced by tracked `repository-trust.json`. +Unknown schema fields fail. Returned paths are deep-frozen. No local checkout mapping +or absolute machine path is ever committed. + +`repositories.resolve` is the only source of `repositoryPath`, `trustedRef`, and +permitted forge signer IDs. Its production implementation loads fixed verifier +configuration plus `loadLocalCheckoutMap`; no command option or bundle field can add a +mapping. Missing or untrusted local mapping fails before any Git process runs. Use +`execFile`, never a shell string. Pass `git -C cat-file -e +^{commit}`, `git -C show :`, `git -C merge-base +--is-ancestor `, and `git -C remote get-url origin` as +separate argv items. Normalize paths before invocation and reject absolute paths, `..`, +backslashes, NUL, or empty segments. Require configured full trusted refs matching +`^refs/(heads|remotes)/`. +Normalize the local `origin` with the same HTTPS normalizer used by the signed +challenge. Verify the wallet and configured forge signatures and compare exact +bytes/hashes before constructing an event. Call `reverifyRepositoryEvent` before +returning it. That replay verifier reconstructs the signed challenge-file bytes from +the event and repeats resolver, both signatures, Git ancestry, trusted-ref reachability, +artifact hash, and proof-blob checks; store load and append use the same function. + +- [ ] **Step 4: Run verification tests and full Phase 0 suite** + +Run: `cd phase0 && npm test && npm run typecheck` + +Expected: PASS; tests perform local Git operations only and no network calls. + +- [ ] **Step 5: Commit offline repository verification** + +```bash +git add phase0/src/attestation-git.ts phase0/src/attestation-config.ts phase0/tests/attestation-git.test.ts .gitignore +git commit -m "feat: verify repository-control attestations offline" +``` + +### Task 4: Persist valid events append-only + +**Files:** +- Create `phase0/src/attestation-store.ts` +- Create `phase0/tests/attestation-store.test.ts` +- Create `phase0/attestations.jsonl` +- Create `phase0/organization-signers.json` +- Create `phase0/attestation-admins.json` +- Create `phase0/repository-trust.json` +- Create `phase0/forge-signers.json` + +- [ ] **Step 1: Write failing store tests** + +Assert an absent file loads as `[]`; appending two events writes two newline-terminated +JSON objects; reopening revalidates wallet, forge, organization, challenger, and admin +signatures plus repository snapshot evidence; duplicate sequence/event IDs fail without +modifying the file; malformed trailing JSON fails closed; and no API can replace or +delete prior events. Direct generic append of a fabricated repository/admin event, or +construction without the required verifier trust context, must fail without a write. +An attempted `wallet_asserted` sidecar line must also fail replay; base subjects come +only from the confirmed manifest injected by the caller. +Start two append Promises with the same next sequence while holding the first writer at +an injected pre-write barrier. Exactly one may append; the other fails with +`attestation store locked`, and the final file contains one complete line. Test a +crash-left lock: normal append fails closed until explicit stale-lock recovery verifies +the recorded owner is absent and the caller supplies the exact lock token. +Inject a file-handle adapter whose `write` accepts at most three bytes per call. Assert +`writeAll` loops until the entire canonical JSONL record plus newline is durable and +replayable. Inject a zero-byte write and a throw after one short write; the first must +abort immediately, and the second may leave only a malformed trailing fragment that +subsequent replay rejects closed—neither may report a successful append or accept a +partial JSON object. + +- [ ] **Step 2: Run and verify red** + +Run: `cd phase0 && node --import tsx --test tests/attestation-store.test.ts` + +Expected: FAIL because the store does not exist. + +- [ ] **Step 3: Implement the append-only store** + +Export `FileAttestationStore`. Its constructor contract is +`new FileAttestationStore(path, { baseSubjects, organizationSigners, adminSigners, +repositories, forgeSigners, git })`; `baseSubjects` must be the frozen result of +`registrationSubjectsFromManifest` for the confirmed manifest. `load()` returns +`Promise`, `append(event)` returns `Promise`, and +`nextSequence()` returns `Promise`. Also expose +`recoverStaleLock({ expectedToken, isProcessAlive })` for explicit operator recovery. + +`load` parses the entire log, runs `reverifyRepositoryEvent` for every persisted +repository event using the required verifier-controlled checkout/forge context, then +awaits `reduceAttestationEvents(events, { baseSubjects, organizationSigners, adminSigners, +repositoryVerifier })`. +Invalid/missing base subjects, repository/forge/organization/challenger/admin signatures, snapshot evidence, +statement hashes, or trust roots fail closed on every replay—not only ingestion. +`append` loads and validates the entire existing log, requires `event.sequence === +events.length + 1`, rejects duplicate IDs, validates the candidate full log, opens with +append mode, passes one canonical JSON line plus newline to an exported +`writeAll(fileHandle, bytes)` helper, calls `FileHandle.sync()`, closes, and reloads to +confirm. `writeAll` advances a byte offset by each positive `bytesWritten`, repeats +until the full buffer is written, and throws on zero, negative, oversized, or missing +write counts; it never assumes one `FileHandle.write()` consumes the full buffer. The +entire replay/CAS/write/fsync +sequence runs while holding `.lock`, acquired with `open("wx", 0o600)`. The lock +file contains schema version, PID, random token, target path, and acquired UTC time. +Release in `finally` only after rereading and matching this writer's token; fsync the +parent directory after lock creation, data append, and lock removal. An existing lock +never triggers an automatic retry or deletion. + +Public `load()` and `nextSequence()` acquire the same lock before calling a private +`loadUnlocked()`; `append()` acquires it once and calls only the private helper. Readers +therefore never accept a partially visible append, and the implementation never tries +to acquire its own lock recursively. + +`recoverStaleLock` rereads the lock, requires `expectedToken`, and uses the injected +process-liveness checker; it refuses recovery while the recorded PID is alive or when +the token/path differs. README recovery instructions require the operator to inspect +the lock and process first. Track +`phase0/attestations.jsonl` as a zero-byte file; do not invent an attestation for the +not-run manifest. + +Create the trust-root file as: + +```json +{ + "schemaVersion": 1, + "organizations": {} +} +``` + +The loader rejects unknown keys, duplicate normalized wallets, non-address values, and +any field resembling a private key. An empty allow-list means organization approval is +unavailable, not implicitly trusted. + +Create the remaining verifier configuration as: + +```json +// phase0/attestation-admins.json +{ "schemaVersion": 1, "admins": {} } + +// phase0/forge-signers.json +{ "schemaVersion": 1, "forgeSigners": {} } + +// phase0/repository-trust.json +{ "schemaVersion": 1, "repositories": [] } +``` + +JSON files contain no comments in implementation; the labels above identify the three +separate files. A repository entry contains only `repositoryId`, normalized HTTPS URL, +`checkoutKey`, full trusted ref, and permitted forge signer IDs. The verifier supplies +`checkoutKey -> repositoryPath` only through the ignored, permission-checked local +mapping loaded by `attestation-config.ts` at process construction. Empty maps mean +admin actions and repository verification are unavailable. No event or bundle can +extend these trust roots. + +- [ ] **Step 4: Run tests** + +Run: `cd phase0 && npm test && npm run typecheck` + +Expected: PASS with durable append and malformed-log cases green. + +- [ ] **Step 5: Commit persistence** + +```bash +git add phase0/src/attestation-store.ts phase0/tests/attestation-store.test.ts phase0/attestations.jsonl phase0/organization-signers.json phase0/attestation-admins.json phase0/repository-trust.json phase0/forge-signers.json +git commit -m "feat: persist append-only registration attestations" +``` + +### Task 5: Add explicit CLI surfaces and correct Phase 0 language + +**Files:** +- Create `phase0/src/attestation-cli.ts` +- Modify `phase0/src/index.ts` +- Modify `phase0/package.json` +- Modify `phase0/README.md` + +- [ ] **Step 1: Add CLI contract tests to `tests/attestations.test.ts`** + +Test pure command handlers with injected store/Git reader and assert: + +```text +attestation: wallet_asserted +claim: wallet registered these bytes and declared this ancestry +safety review: not_reviewed +warning: registration does not prove authorship, originality, legal ownership, or safety +``` + +`attestation-status --artifact-hash ` must load `registrations.json`, seed every +confirmed proof through `registrationSubjectsFromManifest`, and then overlay the +sidecar events so every confirmed registration has at least `wallet_asserted`. It must +list every matching registration and conflict. `attestation-verify-repository --bundle +` accepts a signed challenge plus forge observation but no repository path/ref; +the handler resolves verifier-provisioned configuration and appends only after full +replay verification succeeds. `attestation-verify-organization --bundle ` must load the +configured organization signer allow-list, verify the statement hash, recovered +wallet, and allow-list membership, and only then append `organization_approved`. +`attestation-append-challenge`, `attestation-resolve`, and `attestation-revoke` each +accept a pre-signed bundle, verify challenger/admin trust, and append rather than edit. +They never accept a private key or synthesize an unsigned privileged event. +`attestation-recover-lock --lock-token ` prints the recorded lock metadata, +checks that PID liveness fails, and calls explicit stale recovery; it refuses active, +mismatched, or unreadable locks. +Add production-wiring CLI tests proving `attestation-verify-repository` fails before +Git execution with `repository snapshot mapping unavailable` when both the default +ignored file and environment override are absent; fails on insecure/untrusted mapping; +and succeeds with a temporary canonical `0600` mapping. `--repository-path` and +`--trusted-ref` must be rejected as unknown options rather than accepted and ignored. +`attestation-status` must continue to work without loading checkout configuration. + +- [ ] **Step 2: Run the command tests and verify red** + +Run: `cd phase0 && node --import tsx --test --test-name-pattern='CLI|status output' tests/attestations.test.ts` + +Expected: FAIL because the CLI handlers do not exist. + +- [ ] **Step 3: Implement command handlers and scripts** + +Add scripts: + +```json +"attestation-status": "node --import tsx src/index.ts attestation-status", +"attestation-verify-repository": "node --import tsx src/index.ts attestation-verify-repository", +"attestation-verify-organization": "node --import tsx src/index.ts attestation-verify-organization", +"attestation-append-challenge": "node --import tsx src/index.ts attestation-append-challenge", +"attestation-resolve": "node --import tsx src/index.ts attestation-resolve", +"attestation-conflicts": "node --import tsx src/index.ts attestation-conflicts", +"attestation-revoke": "node --import tsx src/index.ts attestation-revoke", +"attestation-recover-lock": "node --import tsx src/index.ts attestation-recover-lock" +``` + +Add parseArgs options `artifact-hash`, `registration-id`, `bundle`, and `lock-token`; +do not add repository-path or trusted-ref options. Construct +handlers with the fixed organization/admin/forge/repository configs and +the permission-checked ignored local checkout map, plus base subjects derived from the confirmed +`registrations.json` manifest; claimant arguments cannot override them. Public +keys/addresses are allowed in config, private keys are not. Commands emit +human-readable text by default and canonical JSON under `--json`. None calls +`storyChain()` or any network API. +Only the repository-verification command lazily calls `loadLocalCheckoutMap`; its +startup error names the config path and trust failure without printing checkout +contents. No command writes that local mapping. + +- [ ] **Step 4: Rewrite README claims narrowly** + +Use `registration and declared ancestry` for Phase 0. Include the three evidence +levels, exact verification commands, signed challenge/resolution/revocation behavior, +the forge-observer and verifier-snapshot trust assumptions, and this standing warning: + +```text +An attestation records evidence about who made or approved a registration. It does not prove originality, legal ownership, absence of prior art, or Skill safety. Safety review is a separate status. +``` + +Also state: `repository_control_verified means a trusted forge observer and a +verifier-provisioned Git snapshot matched the wallet-signed bytes at an observation +time. It does not prove current remote account ownership or continuing hosting.` +Document the exact local checkout-map schema, default ignored path, optional +`PHASE0_ATTESTATION_CHECKOUTS_FILE` override, required `chmod 600`, canonical absolute +checkout path/permission rules, and missing-config error. Warn that this machine-local +file must never be staged or copied into an attestation bundle. + +- [ ] **Step 5: Run full verification** + +Run: `cd phase0 && npm test && npm run typecheck && npm run attestation-status -- --artifact-hash 0x$(printf '0%.0s' {1..64}) --json` + +Expected: tests/typecheck PASS; status command returns JSON with an empty `registrations` array and `conflicts` array, not an authorship claim. + +- [ ] **Step 6: Confirm no chain write or protected-corpus edit entered the slice** + +Run: `git diff --exit-code -- CONTEXT.md docs/PRD.md docs/adr && ! rg -n 'authored by|proves? (originality|safety)|safe Skill' phase0/src phase0/README.md` + +Expected: exit 0 and no forbidden overclaim matches. + +Run: + +```bash +git check-ignore phase0/.attestation-checkouts.local.json +! git ls-files | rg 'attestation-checkouts\.local\.json$' +! rg -n '"repositoryPath"\s*:|/(Users|home)/|[A-Za-z]:\\\\' phase0/repository-trust.json phase0/organization-signers.json phase0/attestation-admins.json phase0/forge-signers.json +``` + +Expected: the ignored local path is reported by `git check-ignore`; both negative +scans exit 0 with no tracked machine path. + +- [ ] **Step 7: Commit CLI and documentation** + +```bash +git add phase0/src/attestation-cli.ts phase0/src/index.ts phase0/package.json phase0/README.md phase0/tests/attestations.test.ts +git commit -m "feat: expose honest Phase 0 attestation status" +``` + +## Definition of done + +- Every confirmed registration in `registrations.json` renders at least + `wallet_asserted`, never bare “authored by.” +- `wallet_asserted` can originate only from a confirmed manifest proof; the sidecar + schema rejects unsigned base-assertion events. +- The confirmed `wallet_asserted` floor is not a revocable sidecar level; signed admin + revocation can remove only repository/organization evidence and never erase or mark + the base registration revoked. +- Exact duplicate bytes under different wallets create a visible deterministic conflict. +- Repository evidence is replay-verified against the wallet signature, configured forge-observer signature, exact artifact/proof bytes, and verifier-provisioned checkout/ref; it is labeled snapshot evidence, not remote ownership proof. +- Production repository verification resolves checkout keys only through an ignored, + owner-only, canonical absolute-path local mapping; missing/insecure/untrusted mappings + fail before Git and no machine path is tracked. +- Organization approval is appended only after its canonical signature recovers an allow-listed signer and remains distinct from safety review. +- Challenge openings are signed by the challenger wallet; resolutions and revocations are signed by provisioned admins; unsigned/unknown-signer events never append or replay. +- JSONL append uses a tested write-all loop; short writes complete correctly and + interrupted fragments are never accepted as valid replay. +- All tests generate signing keys at runtime; no `.env`, private key, testnet transaction, or network call is added. +- `CONTEXT.md`, `docs/PRD.md`, and `docs/adr/` remain unchanged. diff --git a/docs/superpowers/plans/2026-07-17-claims-quarantine.md b/docs/superpowers/plans/2026-07-17-claims-quarantine.md new file mode 100644 index 0000000..0da0cd6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-claims-quarantine.md @@ -0,0 +1,544 @@ +# Claims Quarantine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop unsupported launch claims, preserve the unreproducible historical measurements honestly, and make future claim regressions fail an offline check. + +**Architecture:** A dependency-free Node audit treats tracked launch copy as a publication boundary and rejects known unsupported phrasings. Historical clone and x402 results remain in place with explicit quarantine metadata; marketing drafts point to evidence status instead of turning modeled, invalid, or unreproducible results into measured claims. This plan changes tracked documentation and tests only: it does not post content, rerun a paid endpoint, spend provider funds, or edit `CONTEXT.md`, `docs/PRD.md`, or `docs/adr/`. + +**Tech Stack:** Node.js 20+ (`node:test`, `node:fs`), Markdown, JSON + +--- + +## File map + +- Create `scripts/marketing-claims.mjs`: reusable claim-policy audit and CLI. +- Create `scripts/tests/marketing-claims.test.mjs`: offline regression tests for banned claims, quarantine notices, and tombstone shape. +- Create `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`: immutable historical tombstone; it deliberately contains no fabricated samples. +- Modify `spikes/pi-wielder/README.md`: quarantine the unreproducible distribution and point to the tombstone. +- Modify `docs/marketing/linkedin.md`: block the clone post and correct modeled cost, target validity, split, and extraction language. +- Modify `docs/marketing/x.md`: block clone copy; correct x402 roles, retry credential, reconciliation level, dates, and extraction language. +- Modify `docs/marketing/hn-and-demo.md`: remove unsupported clone and latency claims from the HN draft and demo script. +- Modify `docs/marketing/2026-07-13-campaign-plan.md`: replace stale scheduled publication actions with evidence gates while retaining the historical calendar. +- Modify `docs/handoffs/2026-07-15-launch-week-handoff.md`: mark the old n=48 distribution as historical and non-publishable; do not rewrite what happened. + +### Human-only boundary + +The implementation worker may edit and test drafts, but must not publish a post, update an already-published social post, invoke a live endpoint, authorize an LLM run, fund a wallet, or deploy a site. Corrections to already-published content are recommendations for the human operator. + +### Task 1: Add a failing publication-boundary audit + +**Files:** +- Create: `scripts/marketing-claims.mjs` +- Create: `scripts/tests/marketing-claims.test.mjs` + +- [ ] **Step 1: Write the failing audit test** + +Create `scripts/tests/marketing-claims.test.mjs` with the tracked publication surfaces and explicit rule IDs: + +```js +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { auditFiles, readHistoricalTombstone } from '../marketing-claims.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const publicationFiles = [ + 'docs/marketing/linkedin.md', + 'docs/marketing/x.md', + 'docs/marketing/hn-and-demo.md', + 'docs/marketing/2026-07-13-campaign-plan.md', +]; + +test('tracked publication drafts contain no quarantined claims', () => { + assert.deepEqual(auditFiles(repoRoot, publicationFiles), []); +}); + +test('the pi overhead tombstone is historical, unreproducible, and sample-free', () => { + const manifest = readHistoricalTombstone(repoRoot); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.experimentId, '2026-07-15-overhead'); + assert.equal(manifest.evidenceStatus, 'historical_unreproducible'); + assert.equal(manifest.publication.allowed, false); + assert.equal(manifest.rawEvidence.normalizedSamplesCommitted, false); + assert.equal(manifest.rawEvidence.recomputableFromCleanCheckout, false); + assert.equal('samples' in manifest, false); + assert.equal( + fs.existsSync(path.join(repoRoot, 'spikes/pi-wielder/evidence/2026-07-15-overhead/samples.jsonl')), + false, + ); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --test scripts/tests/marketing-claims.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `scripts/marketing-claims.mjs`. + +- [ ] **Step 3: Implement the minimal audit API and CLI** + +Create `scripts/marketing-claims.mjs`: + +```js +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const CLAIM_RULES = [ + { id: 'clone-paid-158', pattern: /(?:paid \$1\.58|\$1\.58 (?:bought|total)|six paid runs)/i }, + { id: 'invalid-clone-conclusion', pattern: /clone failed[\s\S]{0,80}(?:six|6)[\s\S]{0,40}fidelity|clone failed all (?:six|6)/i }, + { id: 'latency-unreproducible', pattern: /p50\s+731\s*ms|p95\s+1206\s*ms|n=48 settled calls/i }, + { id: 'absolute-extraction', pattern: /never (?:get|returns?|leaves|crosses)[\s\S]{0,60}\bskill\b|never (?:the )?skill|\bskill\b[\s\S]{0,40}never (?:leaves|crosses)/i }, + { id: 'txhash-as-retry-credential', pattern: /settlement txHash IS the credential|retries? .*carrying (?:it|the txHash)/i }, + { id: 'wielder-is-server', pattern: /server side.*proxy we call the Wielder|Wielder: enforce 402/i }, + { id: 'split-reconciled-onchain', pattern: /creator .*treasury.*reconciled on-chain|split.*reconciled on-chain/i }, +]; + +export function auditText(file, text) { + return CLAIM_RULES.flatMap(({ id, pattern }) => { + const match = text.match(pattern); + if (!match) return []; + const line = text.slice(0, match.index).split('\n').length; + return [{ file, line, rule: id, excerpt: match[0] }]; + }); +} + +export function auditFiles(repoRoot, relativePaths) { + return relativePaths.flatMap((file) => + auditText(file, fs.readFileSync(path.join(repoRoot, file), 'utf8')), + ); +} + +export function readHistoricalTombstone(repoRoot) { + const file = path.join( + repoRoot, + 'spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json', + ); + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +const invokedAsScript = process.argv[1] + && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedAsScript) { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const files = [ + 'docs/marketing/linkedin.md', + 'docs/marketing/x.md', + 'docs/marketing/hn-and-demo.md', + 'docs/marketing/2026-07-13-campaign-plan.md', + ]; + const findings = auditFiles(repoRoot, files); + if (findings.length > 0) { + for (const item of findings) { + console.error(`${item.file}:${item.line} [${item.rule}] ${item.excerpt}`); + } + process.exitCode = 1; + } else { + console.log(`PASS — ${files.length} publication drafts satisfy claim quarantine.`); + } +} +``` + +- [ ] **Step 4: Run the test to expose the current claim violations** + +Run: `node --test scripts/tests/marketing-claims.test.mjs` + +Expected: FAIL. The first test reports findings including `clone-paid-158`, `latency-unreproducible`, and `absolute-extraction`; the tombstone test fails because the manifest does not exist yet. + +- [ ] **Step 5: Commit the failing guard** + +```bash +git add scripts/marketing-claims.mjs scripts/tests/marketing-claims.test.mjs +git commit -m "test: guard quarantined launch claims" +``` + +### Task 2: Preserve the n=48 result as an immutable historical tombstone + +**Files:** +- Create: `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json` +- Modify: `spikes/pi-wielder/README.md:168-181` +- Modify: `docs/handoffs/2026-07-15-launch-week-handoff.md:33-37` +- Test: `scripts/tests/marketing-claims.test.mjs` + +- [ ] **Step 1: Create the exact tombstone manifest** + +Create `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json` with no `samples` member and no adjacent `samples.jsonl`: + +```json +{ + "schemaVersion": 1, + "experimentId": "2026-07-15-overhead", + "observedAt": "2026-07-15", + "evidenceStatus": "historical_unreproducible", + "evidenceLabel": "HISTORICAL SUMMARY ONLY — normalized samples were not retained", + "source": { + "repositoryPath": "spikes/pi-wielder/README.md", + "runtime": "Base Sepolia testnet", + "funding": "testnet USDC play money", + "providerCountReported": 2 + }, + "historicalSummary": { + "settledCallCountReported": 48, + "paymentOverheadP50MsReported": 731, + "paymentOverheadP95MsReported": 1206 + }, + "rawEvidence": { + "normalizedSamplesCommitted": false, + "recomputableFromCleanCheckout": false, + "reason": "The repository retained only aggregate prose; per-call normalized timing rows and a hashed evidence manifest were not committed." + }, + "publication": { + "allowed": false, + "reason": "Do not use the reported p50, p95, or n=48 in launch copy. A new authorized run must write a new dated evidence directory before publication." + }, + "replacementPolicy": { + "overwriteThisDirectory": false, + "newRunDirectoryPattern": "spikes/pi-wielder/evidence/YYYY-MM-DD-overhead-RUN_ID", + "requiredFiles": ["manifest.json", "samples.jsonl", "summary.json", "report.md", "README.md"] + } +} +``` + +- [ ] **Step 2: Quarantine the README summary without erasing history** + +Replace the `spikes/pi-wielder/README.md` n=48 heading and distribution prose with: + +```markdown +## Historical overhead summary — quarantined (2026-07-15) + +The 2026-07-15 run was previously summarized as 48 settled calls across two +providers. Its per-call normalized samples and evidence hashes were not retained, +so a clean checkout cannot recompute the reported distribution. The historical +aggregate is preserved in +`evidence/2026-07-15-overhead/manifest.json` with +`evidenceStatus: historical_unreproducible`. + +**Publication status:** do not cite the historical sample count, p50, or p95 in +public copy. A future authorized testnet run must use a new dated evidence +directory and must never overwrite the tombstone. No rerun is performed by this +documentation change. +``` + +Keep the later failure-mode narrative (settled-then-500 and settled-but-rejected) below this section, but do not imply that the Wielder session ledger captured every settled failure. + +- [ ] **Step 3: Correct the handoff status line** + +Replace the old n=48 bullet in `docs/handoffs/2026-07-15-launch-week-handoff.md` with: + +```markdown +1. **Pi-Wielder follow-up recorded, distribution quarantined** — the historical + 2026-07-15 aggregate did not retain normalized per-call samples, so its n/p50/p95 + are not publishable. See + `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. A replacement + testnet run is human-authorized work and must use a new dated evidence bundle. +``` + +- [ ] **Step 4: Run the tombstone test** + +Run: `node --test --test-name-pattern='tombstone' scripts/tests/marketing-claims.test.mjs` + +Expected: PASS with one test passing and the publication-draft test skipped by the name filter. + +- [ ] **Step 5: Verify no fabricated normalized evidence exists** + +Run: `find spikes/pi-wielder/evidence/2026-07-15-overhead -maxdepth 1 -type f -print` + +Expected: exactly `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. + +- [ ] **Step 6: Commit the tombstone** + +```bash +git add spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json spikes/pi-wielder/README.md docs/handoffs/2026-07-15-launch-week-handoff.md +git commit -m "docs: quarantine unreproducible overhead summary" +``` + +### Task 3: Quarantine the invalid clone campaign and correct its evidence labels + +**Files:** +- Modify: `docs/marketing/linkedin.md:49-84` +- Modify: `docs/marketing/x.md:60-67,99-145,233-240,275,306-310` +- Modify: `docs/marketing/hn-and-demo.md:14-18,41-44,103-113` +- Modify: `docs/marketing/2026-07-13-campaign-plan.md:67-103,119-143` + +- [ ] **Step 1: Add the same publication gate above every clone draft** + +Insert this block immediately below each clone-post/thread heading in `linkedin.md`, `x.md`, and `hn-and-demo.md`: + +```markdown +> **PUBLICATION BLOCKED — INVALID BENCHMARK.** The 2026-07-12 target scored +> 0.400 and failed its own critical gates, so clone-quality, fidelity-defense, and +> break-even conclusions are suppressed. Acquisition was modeled at $1.50; no +> x402 acquisition payments settled. Unblock only after +> `spikes/clone-economics` produces a valid N=100 result with committed normalized +> evidence and three live-adapter-confirmed independent distillation seeds. +``` + +- [ ] **Step 2: Replace the LinkedIn clone post with evidence-safe draft text** + +Keep its target audience, first-comment links, and posting-context sections, but replace the post body with: + +```markdown +> We ran a six-example clone-economics pilot against our own hosted Skill. +> +> The provider calls were live. The acquisition price was not: six examples at +> $0.25 each contributed a modeled $1.50, no x402 acquisition payments settled, +> and the measured distillation-provider cost was about $0.03. The resulting +> $1.58 attacker-build figure is therefore a modeled lower bound that excludes +> labor and several failed setup attempts, not money paid for six Invocations. +> +> More importantly, the benchmark target failed its own acceptance gate. That +> invalidates any conclusion about whether the clone failed, whether fidelity is +> a defense, or where break-even lands. We preserved the run as historical +> evidence and blocked this post rather than promote an answer the evaluator +> could not support. +> +> The next admissible result requires at least 30 held-out fixtures and a +> preregistered N=6/25/50/100 sweep with three live-adapter-confirmed independent +> distillation seeds. +> No high-N result exists yet. +``` + +Change the heading to `## Post 2 — Clone-economics benchmark: publication blocked` and the posting context to `Do not publish until the gate above is satisfied and a human approves revised copy.` + +- [ ] **Step 3: Replace clone conclusions throughout X and HN copy** + +Use the following compact paragraph wherever the old `$1.58`, six-gate, fidelity-defense, or eight-Invocation conclusion appears: + +```markdown +The historical N=6 run used a modeled $1.50 acquisition cost and measured about +$0.03 of distillation-provider cost; no acquisition payment settled. Its target +failed the benchmark, so clone quality, fidelity defense, and break-even are +unknown. Publication remains blocked pending a valid preregistered N=100 run. +``` + +For the HN title list, replace option 2 with: + +```markdown +2. `Show HN: An invalid clone benchmark and the gate we added after it` +``` + +Do not retain metaphors such as “failed all gates,” “wax figure,” “photograph,” or “cost protects nothing”; each asserts a conclusion the invalid target cannot support. + +- [ ] **Step 4: Turn calendar publication actions into retained historical gates** + +In both calendar tables in `docs/marketing/2026-07-13-campaign-plan.md`, keep the dates and prior events, but replace every Post 2 or clone-thread action with: + +```markdown +**BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own +acceptance gate; the required valid N=100 evidence bundle does not exist. +``` + +Add immediately above the revamped calendar: + +```markdown +> **2026-07-17 evidence override:** all clone-economics publication steps below +> are historical schedule entries and are blocked. A calendar date never +> overrides an evidence gate. +``` + +- [ ] **Step 5: Check that modeled cost is never described as paid** + +Run: + +```bash +rg -n -i 'paid \$1\.58|\$1\.58 bought|six paid runs|clone failed all (six|6)|break-even.*8 invocations|fidelity.*defen' docs/marketing +``` + +Expected: no matches. + +- [ ] **Step 6: Commit clone-copy quarantine** + +```bash +git add docs/marketing/linkedin.md docs/marketing/x.md docs/marketing/hn-and-demo.md docs/marketing/2026-07-13-campaign-plan.md +git commit -m "docs: quarantine invalid clone campaign claims" +``` + +### Task 4: Suppress unreproducible latency and correct x402 accounting claims + +**Files:** +- Modify: `docs/marketing/linkedin.md:177-190` +- Modify: `docs/marketing/x.md:43-58,149-202,233-263` +- Modify: `docs/marketing/hn-and-demo.md:33-50,139-157` +- Modify: `docs/marketing/2026-07-13-campaign-plan.md:88-101` +- Test: `scripts/tests/marketing-claims.test.mjs` + +- [ ] **Step 1: Replace every public n=48 distribution with the tombstone status** + +Use this exact wording in LinkedIn, X, HN, and campaign-plan draft surfaces: + +```markdown +The 2026-07-15 overhead distribution is historical but not reproducible from a +clean checkout because normalized per-call samples were not retained. Its sample +count, p50, and p95 are quarantined from publication; see +`spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. No replacement +measurement has been run. +``` + +Do not preserve the three numeric values elsewhere in those four marketing files. + +- [ ] **Step 2: Correct the x402 handshake in the technical thread** + +Replace steps 3–5 in `docs/marketing/x.md` with: + +```markdown +**3/** +Step 2 — authorization. + +The Wielder-side proxy validates the 402 offer and signs an EIP-3009 +transferWithAuthorization for the exact permitted amount. The retry carries that +signed `X-PAYMENT` authorization, not a transaction hash. + +**4/** +Step 3 — seller-side settlement. + +The Collar's x402 paywall sends the signed authorization to the facilitator. The +facilitator verifies and settles on Base Sepolia before the hosted Skill runs. A +settlement transaction hash is evidence returned after settlement; it is not the +credential carried by the initial retry. + +**5/** +Step 4 — execution and receipt. + +After settlement, the Collar executes the hosted Skill and returns output plus a +receipt. The artifact file is not directly returned. Model-output extraction +remains an adversarial runtime risk, so this is not a secrecy guarantee. +``` + +- [ ] **Step 3: Correct Wielder and Collar responsibilities** + +Replace the old server-side/Wielder paragraph with: + +```markdown +The Wielder is the wallet plus paying client proxy. The Collar is seller-side: it +holds the platform key, enforces the payment gate, runs the hosted Skill, and +writes the seller ledger. The demo's Wielder ledger is a receipt view, not the +authoritative compensation ledger. +``` + +- [ ] **Step 4: Correct split-level reconciliation everywhere** + +Replace statements that the Creator/treasury split reconciled on-chain with: + +```markdown +The aggregate testnet USDC payment to the seller `payTo` address reconciled +on-chain. The Creator/treasury amounts were off-chain reference-ledger credits; +they were not separate on-chain transfers. +``` + +Retain the testnet/play-money label next to every payment amount. + +- [ ] **Step 5: Correct measurement dates** + +Where historical chronology is retained, label the first n=1 run `2026-07-12` and the now-quarantined distribution `2026-07-15`. Remove wording that attributes the distribution to July 12 or merges both dates into one measurement. + +- [ ] **Step 6: Run the claims test** + +Run: `node --test scripts/tests/marketing-claims.test.mjs` + +Expected: the tombstone test passes; the publication audit may still fail only on absolute extraction phrasing addressed in Task 5. It must report no `clone-paid-158`, `latency-unreproducible`, `txhash-as-retry-credential`, `wielder-is-server`, or `split-reconciled-onchain` finding. + +- [ ] **Step 7: Commit x402 and latency corrections** + +```bash +git add docs/marketing/linkedin.md docs/marketing/x.md docs/marketing/hn-and-demo.md docs/marketing/2026-07-13-campaign-plan.md +git commit -m "docs: correct x402 and measurement claims" +``` + +### Task 5: Replace absolute extraction promises with the supportable boundary + +**Files:** +- Modify: `docs/marketing/linkedin.md` +- Modify: `docs/marketing/x.md` +- Modify: `docs/marketing/hn-and-demo.md` +- Test: `scripts/tests/marketing-claims.test.mjs` + +- [ ] **Step 1: Replace launch-copy absolute language** + +Replace every variation of “you never get the Skill,” “never the Skill,” and “the Skill never crosses the wire” in the three marketing files with: + +```text +the artifact file is not directly returned; model-output extraction remains an adversarial runtime risk +``` + +For captions limited by length, use: + +```text +The artifact file is not directly returned. Extraction risk remains. +``` + +- [ ] **Step 2: Preserve raw historical protocol evidence unchanged** + +Do not edit `docs/marketing/artifacts/raw-402-response-live.txt`. It is a captured historical response, not approved future copy. Add this note where the artifact is referenced in `docs/marketing/2026-07-13-campaign-plan.md`: + +```markdown +The captured response preserves its original absolute description as historical +wire evidence. Do not reuse that description as current marketing copy. +``` + +- [ ] **Step 3: Run the focused audit** + +Run: `node scripts/marketing-claims.mjs` + +Expected: `PASS — 4 publication drafts satisfy claim quarantine.` + +- [ ] **Step 4: Run the complete claims test** + +Run: `node --test scripts/tests/marketing-claims.test.mjs` + +Expected: PASS, 2 tests passed, 0 failed. + +- [ ] **Step 5: Commit extraction-language corrections** + +```bash +git add docs/marketing/linkedin.md docs/marketing/x.md docs/marketing/hn-and-demo.md docs/marketing/2026-07-13-campaign-plan.md +git commit -m "docs: bound hosted Skill extraction claims" +``` + +### Task 6: Final read-only verification and human handoff + +**Files:** +- Verify only; no new file changes expected. + +- [ ] **Step 1: Run the offline policy suite** + +Run: `node --test scripts/tests/marketing-claims.test.mjs` + +Expected: PASS, 2 tests passed, 0 failed; no network access, keys, funds, or deployment. + +- [ ] **Step 2: Confirm the historical tombstone was not embellished** + +Run: + +```bash +node -e "const m=require('./spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json'); if(m.evidenceStatus!=='historical_unreproducible'||m.publication.allowed!==false||'samples' in m) process.exit(1); console.log('PASS — historical tombstone remains non-publishable and sample-free')" +``` + +Expected: `PASS — historical tombstone remains non-publishable and sample-free`. + +- [ ] **Step 3: Confirm protected corpus files are untouched by this plan** + +Run: `git diff bad032b -- CONTEXT.md docs/PRD.md docs/adr` + +Expected: no output. + +- [ ] **Step 4: Inspect the worktree without staging user files** + +Run: `git status --short --branch` + +Expected: this plan's tracked edits are committed; pre-existing untracked `docs/marketing-assets/` and `hf-space/` remain untracked and untouched. + +- [ ] **Step 5: Record the human-only publication decision** + +Hand off this exact status without posting anything: + +```text +Tracked launch drafts now fail closed on modeled clone cost, invalid target +conclusions, unreproducible n=48 latency, incorrect x402 roles, off-chain splits, +and absolute extraction claims. Existing public posts, if any, require a human +correction decision. No social post, live rerun, wallet action, or deployment was +performed. +``` diff --git a/docs/superpowers/plans/2026-07-17-clone-economics-evidence.md b/docs/superpowers/plans/2026-07-17-clone-economics-evidence.md new file mode 100644 index 0000000..f603f3d --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-clone-economics-evidence.md @@ -0,0 +1,2210 @@ +# Clone Economics Evidence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make clone-economics conclusions fail closed on an invalid target, support the preregistered N=6/25/50/100 experiment, and produce sanitized evidence bundles whose published metrics recompute from a clean checkout. + +**Architecture:** The harness separates benchmark validity, fixture generation, sweep orchestration, seed semantics, budget enforcement, and evidence serialization into small dependency-free modules. A standalone target-baseline gate runs before clone work, and publication also requires the target benchmark inside every N=100 cell to remain valid; each N uses three preregistered replicates with separate pair-order and provider-distillation seeds. A provider that cannot honor the requested distillation seeds may produce explicitly uncontrolled evidence, but the publishable high-N gate fails and clone/economics conclusions stay suppressed. Live execution requires an exact authorization hash over the approved pricing/token-cap snapshot and sweep configuration plus an exact USD cap. Integer micro-USD preflight runs before adapter or fetch construction, every attempted provider call reserves worst-case spend before fetch, and any exact charge above authorization is fully accrued before a permanent overrun lock. + +**Tech Stack:** Node.js 20+ ESM, `node:test`, SHA-256, JSON/JSONL, Markdown + +--- + +## File map + +- Create `spikes/clone-economics/src/validity.mjs`: target-baseline and conclusion gate. +- Create `spikes/clone-economics/tests/validity.test.mjs`: invalid/valid target contract tests. +- Modify `spikes/clone-economics/src/experiment.mjs`: use the validity result and suppress inadmissible conclusions. +- Modify `spikes/clone-economics/src/reports.mjs`: render an invalid-target verdict without clone, moat, or break-even conclusions. +- Modify `spikes/clone-economics/e2e.mjs`: preserve pre-existing ignored `runs/` content. +- Create `spikes/clone-economics/fixtures/fixture-catalog-v2.json`: preregistered fixture inputs. +- Create `spikes/clone-economics/scripts/generate-fixtures.mjs`: deterministic 100/30 fixture generator. +- Create generated `spikes/clone-economics/fixtures/train-v2.json` and `heldout-v2.json`: committed sweep fixtures. +- Create `spikes/clone-economics/src/fixture-set.mjs`: load and validate fixture identity/hash separation. +- Create `spikes/clone-economics/tests/fixture-set.test.mjs`: counts, hashes, disjointness, and determinism. +- Create `spikes/clone-economics/fixtures/sweep-v1.json`: immutable preregistration for N values and separate pair-order/provider-distillation replicate seeds. +- Create `spikes/clone-economics/fixtures/live-budget-v1.json`: committed pricing/token-cap contract, initially unapproved with no invented prices. +- Create `spikes/clone-economics/src/sweep.mjs`: preflight, seeded replicates, and live guard. +- Create `spikes/clone-economics/src/budget.mjs`: exact cap parsing, conservative request/cost estimation, and per-attempt reservations. +- Create `spikes/clone-economics/src/authorization.mjs`: canonical snapshot/config hashing for exact live approval. +- Create `spikes/clone-economics/sweep.mjs`: CLI entry point. +- Create `spikes/clone-economics/tests/sweep.test.mjs`: offline sweep and spend-gate tests. +- Create `spikes/clone-economics/tests/authorization.test.mjs` and `spikes/clone-economics/tests/fixtures/live-contract.mjs`: exact-hash and shared synthetic-contract tests. +- Create `spikes/clone-economics/tests/budget.test.mjs`: approved-snapshot, pre-construction, unknown-cost, and attempted-call accounting tests. +- Create `spikes/clone-economics/src/evidence.mjs`: normalized samples, summary recomputation, bundle hashing, and verification. +- Create `spikes/clone-economics/scripts/verify-bundle.mjs`: clean-checkout verifier. +- Create `spikes/clone-economics/scripts/import-legacy-run.mjs`: explicit-path, hash-locked sanitized importer for the ignored 2026-07-12 report. +- Create `spikes/clone-economics/tests/import-legacy-run.test.mjs`: byte-hash, no-output-on-failure, and normalization tests. +- Create `spikes/clone-economics/tests/evidence.test.mjs`: schema, hash, redaction, and metric recomputation tests. +- Create `spikes/clone-economics/evidence/2026-07-12-n6-invalid/{manifest.json,samples.jsonl,summary.json,report.md,README.md}`: committed historical invalid-benchmark bundle. +- Modify `spikes/clone-economics/package.json`, `.gitignore`, `README.md`, `RUNBOOK.md`, and `.env.example`: expose safe commands and state the human gates. + +### Human-only boundary + +No implementation or verification step in this plan may set `ALLOW_LIVE_LLM=1`, set a real `APPROVE_LIVE_SWEEP_SHA256`, call a provider, settle x402, fund a wallet, or publish a result. Task 7 documents the exact operator command, but a human must verify current pricing, choose the model and token caps, replace the committed `not_approved` budget snapshot with an `approved` snapshot, commit that review, approve the maximum spend, copy the exact printed authorization hash, and invoke the command. A generated live result is not automatically publishable; its target, independent-distillation-seed, and evidence gates still decide that. + +### Task 1: Fail closed when the benchmark target fails + +**Files:** +- Create: `spikes/clone-economics/src/validity.mjs` +- Create: `spikes/clone-economics/tests/validity.test.mjs` +- Modify: `spikes/clone-economics/src/experiment.mjs:159-270` +- Modify: `spikes/clone-economics/src/reports.mjs:11-75` +- Modify: `spikes/clone-economics/e2e.mjs:108-136,153-172` + +- [ ] **Step 1: Write target-validity tests** + +Create `spikes/clone-economics/tests/validity.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + INVALID_TARGET_VERDICT, + assessBenchmark, +} from '../src/validity.mjs'; + +const score = (absoluteScore, criticalGatePass) => ({ + absoluteScore, + criticalGatePass, + passedThreshold: absoluteScore >= 0.8, +}); + +test('a failed target suppresses every clone and economics conclusion', () => { + const result = assessBenchmark({ + threshold: 0.8, + target: score(0.4, false), + }); + assert.deepEqual(result, { + valid: false, + verdict: INVALID_TARGET_VERDICT, + cloneConclusionAllowed: false, + economicsConclusionAllowed: false, + reason: 'Target score 0.400 is below 0.800 and target critical gates failed.', + }); +}); + +test('a passing target admits a clone result without deciding its meaning', () => { + const result = assessBenchmark({ + threshold: 0.8, + target: score(0.9, true), + }); + assert.equal(result.valid, true); + assert.equal(result.verdict, 'VALID_BENCHMARK'); + assert.equal(result.cloneConclusionAllowed, true); + assert.equal(result.economicsConclusionAllowed, true); +}); +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `cd spikes/clone-economics && node --test tests/validity.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/validity.mjs`. + +- [ ] **Step 3: Implement the validity contract** + +Create `spikes/clone-economics/src/validity.mjs`: + +```js +export const INVALID_TARGET_VERDICT = 'INVALID_BENCHMARK_TARGET_FAILED'; + +export function assessBenchmark({ threshold, target }) { + const scoreFailed = target.absoluteScore < threshold; + const gatesFailed = !target.criticalGatePass; + if (scoreFailed || gatesFailed) { + const failures = [ + scoreFailed ? `Target score ${target.absoluteScore.toFixed(3)} is below ${threshold.toFixed(3)}` : null, + gatesFailed ? 'target critical gates failed' : null, + ].filter(Boolean); + return { + valid: false, + verdict: INVALID_TARGET_VERDICT, + cloneConclusionAllowed: false, + economicsConclusionAllowed: false, + reason: `${failures.join(' and ')}.`, + }; + } + return { + valid: true, + verdict: 'VALID_BENCHMARK', + cloneConclusionAllowed: true, + economicsConclusionAllowed: true, + reason: `Target met ${threshold.toFixed(3)} and every critical gate.`, + }; +} +``` + +- [ ] **Step 4: Run the validity tests** + +Run: `cd spikes/clone-economics && node --test tests/validity.test.mjs` + +Expected: PASS, 2 tests passed. + +- [ ] **Step 5: Attach validity to every experiment report** + +Import and call `assessBenchmark` immediately after `targetScore` and `cloneScore` are computed in `src/experiment.mjs`: + +```js +const benchmark = assessBenchmark({ + threshold: FIDELITY_THRESHOLD, + target: targetScore, +}); +``` + +Add `benchmark` to the report beside `fidelity`. Replace the current `fidelity` +and `economics` literals with this complete field mapping so every existing +observation remains present while only interpretive fields are suppressed: + +```js +const economicsEvidenceLabel = mode === 'mock' + ? 'SYNTHETIC + MODELED' + : `Provider cost ${providerUsageEvidence}; acquisition MODELED`; + +benchmark, +fidelity: { + evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'MEASURED AGAINST DETERMINISTIC RUBRIC', + rubricVersion: RUBRIC_VERSION, + threshold: FIDELITY_THRESHOLD, + target: targetScore, + clone: cloneScore, + retention: benchmark.cloneConclusionAllowed && targetScore.absoluteScore > 0 + ? rounded(cloneScore.absoluteScore / targetScore.absoluteScore) + : null, + badClone: badCloneScore, + scoreDeterminism: { byteIdentical: scoringA === scoringB }, +}, +economics: { + evidenceLabel: economicsEvidenceLabel, + acquisitionFormula: economics.acquisitionFormula, + acquisitionModeledUsd: economics.acquisitionModeledUsd, + distillationProviderUsd: economics.distillationProviderUsd, + tuningEvaluationUsd: economics.tuningEvaluationUsd, + tuningNote: economics.tuningNote, + deployCostUsd: economics.deployCostUsd, + laborCostUsd: economics.laborCostUsd, + laborCostTreatment: economics.laborCostTreatment, + attackerBuildUsd: economics.attackerBuildUsd, + measurementEvaluationUsd: economics.measurementEvaluationUsd, + evaluationExcludedFromBuild: economics.evaluationExcludedFromBuild, + distillationToAcquisition: economics.distillationToAcquisition, + buildToAcquisition: economics.buildToAcquisition, + breakEvenInvocations: benchmark.economicsConclusionAllowed + ? economics.breakEvenInvocations + : null, + cloneServingCostUsd: economics.cloneServingCostUsd, + providerCostsNotAddedToAcquisition: economics.providerCostsNotAddedToAcquisition, + providerCostBreakdown: economics.providerCostBreakdown, + zeroPriceProbe: economics.zeroPriceProbe, + conclusionSuppressed: !benchmark.economicsConclusionAllowed, +}, +``` + +Set live `claimStatus` to `benchmark.verdict` when invalid. Do not delete target/clone observations; suppression applies to interpretation. + +- [ ] **Step 6: Render the invalid verdict explicitly** + +At the start of `renderMarkdown`, compute: + +```js +const conclusion = report.benchmark.valid + ? 'The target passed its own gate; clone and economics interpretation may proceed.' + : `**${report.benchmark.verdict}.** ${report.benchmark.reason} Clone quality, fidelity defense, moat, and break-even conclusions are suppressed.`; +``` + +Render `conclusion` immediately after the verdict. In the economics table, render `suppressed` rather than `undefined` for invalid break-even. Do not emit “clone failed,” “cost protects nothing,” or a fidelity-defense sentence from an invalid report. + +- [ ] **Step 7: Update the offline e2e assertions** + +Add assertions after the known-good mock score checks: + +```js +eq(report.benchmark.verdict, 'VALID_BENCHMARK', 'passing target admits interpretation'); + +// The existing unknown transcript deliberately makes the target fail. +eq(unknown.report.benchmark.verdict, 'INVALID_BENCHMARK_TARGET_FAILED', 'failed target invalidates benchmark'); +eq(unknown.report.fidelity.retention, null, 'invalid target suppresses retention'); +eq(unknown.report.economics.breakEvenInvocations, null, 'invalid target suppresses break-even'); +ok(unknown.markdownReport.includes('Clone quality, fidelity defense, moat, and break-even conclusions are suppressed'), 'invalid report states suppression'); +``` + +- [ ] **Step 8: Run the offline tests** + +Run: + +```bash +cd spikes/clone-economics +node --test tests/validity.test.mjs +npm run e2e +``` + +Expected: validity tests PASS and e2e ends `PASS` with all prior checks plus the new validity checks green. + +- [ ] **Step 9: Commit the target gate** + +```bash +git add spikes/clone-economics/src/validity.mjs spikes/clone-economics/tests/validity.test.mjs spikes/clone-economics/src/experiment.mjs spikes/clone-economics/src/reports.mjs spikes/clone-economics/e2e.mjs +git commit -m "fix: invalidate clone conclusions on failed target" +``` + +### Task 2: Make offline e2e coexist with retained ignored runs + +**Files:** +- Modify: `spikes/clone-economics/e2e.mjs:33-40,153-158` +- Create: `spikes/clone-economics/tests/e2e-coexistence.test.mjs` + +- [ ] **Step 1: Write a regression test with a retained run marker** + +Create `spikes/clone-economics/tests/e2e-coexistence.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('offline e2e preserves an existing ignored runs directory', (t) => { + const marker = path.join(root, 'runs', 'e2e-retained-marker.txt'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, 'retain me\n'); + t.after(() => fs.rmSync(marker, { force: true })); + const output = execFileSync(process.execPath, ['e2e.mjs'], { + cwd: root, + env: { ...process.env, MOCK_LLM: '1', ALLOW_LIVE_LLM: '0' }, + encoding: 'utf8', + }); + assert.match(output, /PASS/); + assert.equal(fs.readFileSync(marker, 'utf8'), 'retain me\n'); +}); +``` + +- [ ] **Step 2: Run it to verify the current assertion fails** + +Run: `cd spikes/clone-economics && node --test tests/e2e-coexistence.test.mjs` + +Expected: FAIL because `e2e.mjs` currently asserts that `runs/` does not exist. + +- [ ] **Step 3: Replace the global absence assertion with a before/after snapshot** + +Add this helper and snapshot near `outputA`/`outputB` in `e2e.mjs`: + +```js +function treeSnapshot(directory) { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory, { recursive: true }).map(String).sort(); +} +const retainedRuns = path.join(here, 'runs'); +const runsBefore = treeSnapshot(retainedRuns); +``` + +Replace +`ok(!fs.existsSync(path.join(here, 'runs')), 'e2e leaves no run artifacts in the tree')` +with: + +```js +eq(treeSnapshot(retainedRuns), runsBefore, 'e2e leaves pre-existing run artifacts unchanged'); +``` + +- [ ] **Step 4: Run both e2e paths** + +Run: + +```bash +cd spikes/clone-economics +npm run e2e +node --test tests/e2e-coexistence.test.mjs +``` + +Expected: both PASS; pre-existing `runs/live/` files and the temporary marker remain unchanged during each run. + +- [ ] **Step 5: Commit the coexistence fix** + +```bash +git add spikes/clone-economics/e2e.mjs spikes/clone-economics/tests/e2e-coexistence.test.mjs +git commit -m "test: preserve retained clone run evidence" +``` + +### Task 3: Generate and validate the preregistered 100/30 fixture set + +**Files:** +- Create: `spikes/clone-economics/fixtures/fixture-catalog-v2.json` +- Create: `spikes/clone-economics/scripts/generate-fixtures.mjs` +- Create: `spikes/clone-economics/fixtures/train-v2.json` +- Create: `spikes/clone-economics/fixtures/heldout-v2.json` +- Create: `spikes/clone-economics/src/fixture-set.mjs` +- Create: `spikes/clone-economics/tests/fixture-set.test.mjs` + +- [ ] **Step 1: Write fixture-set contract tests** + +Create `spikes/clone-economics/tests/fixture-set.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { loadFixtureSet } from '../src/fixture-set.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('v2 fixtures contain 100 train and 30 disjoint heldout cases', () => { + const fixtures = loadFixtureSet(root, 'v2'); + assert.equal(fixtures.train.length, 100); + assert.equal(fixtures.heldout.length, 30); + assert.equal(fixtures.disjoint, true); + assert.equal(new Set(fixtures.train.map((x) => x.id)).size, 100); + assert.equal(new Set(fixtures.heldout.map((x) => x.id)).size, 30); + assert.equal(fixtures.heldout.every((x) => x.rubric && x.rubric.exactPaths.length === 1), true); +}); + +test('fixture generation is byte deterministic', () => { + const train = fs.readFileSync(path.join(root, 'fixtures/train-v2.json'), 'utf8'); + const heldout = fs.readFileSync(path.join(root, 'fixtures/heldout-v2.json'), 'utf8'); + execFileSync(process.execPath, ['scripts/generate-fixtures.mjs', '--check'], { cwd: root }); + assert.equal(fs.readFileSync(path.join(root, 'fixtures/train-v2.json'), 'utf8'), train); + assert.equal(fs.readFileSync(path.join(root, 'fixtures/heldout-v2.json'), 'utf8'), heldout); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd spikes/clone-economics && node --test tests/fixture-set.test.mjs` + +Expected: FAIL because `src/fixture-set.mjs` and the v2 fixtures do not exist. + +- [ ] **Step 3: Create the preregistered fixture catalog** + +Create `fixtures/fixture-catalog-v2.json` with these exact domains; `trainDomains` and `heldoutDomains` must not share a path, command, or constraint: + +```json +{ + "schemaVersion": 1, + "fixtureSet": "v2", + "trainDomains": [ + {"slug":"checkout","path":"@src/checkout/totals.ts","command":"npm test -- src/checkout/totals.test.ts","constraint":"preserve tax rounding exactly"}, + {"slug":"auth","path":"@src/auth/logout.ts","command":"npm test -- src/auth/logout.test.ts","constraint":"reuse the existing session invalidation path"}, + {"slug":"billing","path":"@src/billing/renewal.ts","command":"npm test -- src/billing/renewal.test.ts","constraint":"do not change invoice JSON"}, + {"slug":"worker","path":"@src/jobs/retry.ts","command":"npm test -- src/jobs/retry.test.ts","constraint":"keep retry attempts idempotent"}, + {"slug":"export","path":"@src/export/csv.ts","command":"npm test -- src/export/csv.test.ts","constraint":"add no new dependencies"}, + {"slug":"webhook","path":"@src/webhooks/verify.ts","command":"npm test -- src/webhooks/verify.test.ts","constraint":"reject invalid signatures without logging secrets"}, + {"slug":"search","path":"@src/search/query.ts","command":"npm test -- src/search/query.test.ts","constraint":"preserve the public query API"}, + {"slug":"profile","path":"@src/profile/update.ts","command":"npm test -- src/profile/update.test.ts","constraint":"leave unrelated profile fields untouched"}, + {"slug":"migration","path":"@src/db/migrations/042-orders.ts","command":"npm test -- src/db/migrations/042-orders.test.ts","constraint":"keep the migration reversible"}, + {"slug":"notifications","path":"@src/notifications/digest.ts","command":"npm test -- src/notifications/digest.test.ts","constraint":"send at most one digest per account"} + ], + "heldoutDomains": [ + {"slug":"cache","path":"@src/cache/invalidate.ts","command":"npm test -- src/cache/invalidate.test.ts","constraint":"keep the cache API backward-compatible"}, + {"slug":"session","path":"@src/session/timeout.ts","command":"npm test -- src/session/timeout.test.ts","constraint":"fix the root cause without suppressing the error"}, + {"slug":"report","path":"@src/reports/download.ts","command":"npm test -- src/reports/download.test.ts","constraint":"preserve the download response headers"}, + {"slug":"audit","path":"@src/audit/append.ts","command":"npm test -- src/audit/append.test.ts","constraint":"make audit entries append-only"}, + {"slug":"orders","path":"@src/orders/filter.ts","command":"npm test -- src/orders/filter.test.ts","constraint":"preserve the JSON response shape exactly"}, + {"slug":"upload","path":"@src/uploads/limits.ts","command":"npm test -- src/uploads/limits.test.ts","constraint":"reject oversized files before persistence"}, + {"slug":"flags","path":"@src/flags/evaluate.ts","command":"npm test -- src/flags/evaluate.test.ts","constraint":"keep evaluation deterministic"}, + {"slug":"tokens","path":"@src/tokens/rotate.ts","command":"npm test -- src/tokens/rotate.test.ts","constraint":"never log token material"}, + {"slug":"queue","path":"@src/queue/claim.ts","command":"npm test -- src/queue/claim.test.ts","constraint":"prevent two workers from claiming one job"}, + {"slug":"ledger","path":"@src/ledger/reconcile.ts","command":"npm test -- src/ledger/reconcile.test.ts","constraint":"do not mutate settled entries"} + ], + "trainTemplates": [ + {"mode":"Optimize","text":"Tighten the request for {slug} while preserving behavior."}, + {"mode":"Generate","text":"Write an implementation request for the {slug} change."}, + {"mode":"Diagnose","text":"The {slug} change spread beyond scope; rewrite the request to fix the root cause."}, + {"mode":"Spec","text":"Turn the broad {slug} idea into the next implementation specification."}, + {"mode":"Optimize","text":"Make the {slug} prompt explicit about verification and constraints."}, + {"mode":"Generate","text":"Ask for the smallest test-driven {slug} patch."}, + {"mode":"Diagnose","text":"The first {slug} attempt hid the error; produce a diagnostic request."}, + {"mode":"Spec","text":"Specify the {slug} behavior without starting implementation."}, + {"mode":"Optimize","text":"Remove ambiguity from this {slug} maintenance request."}, + {"mode":"Generate","text":"Create a repository-grounded request for {slug}."} + ], + "heldoutTemplates": [ + {"mode":"Optimize","text":"Optimize this {slug} request without breaking callers.","maxQuestions":0}, + {"mode":"Diagnose","text":"The {slug} implementation masked a failure; rewrite the request.","maxQuestions":0}, + {"mode":"Generate","text":"Generate the smallest verified change request for {slug}.","maxQuestions":0} + ] +} +``` + +- [ ] **Step 4: Implement deterministic generation** + +Create `scripts/generate-fixtures.mjs`. Generate IDs with +`` `tr-v2-${String(domainIndex + 1).padStart(2, '0')}-${String(templateIndex + 1).padStart(2, '0')}` `` +and the same expression with the `ho-v2` prefix. Each generated training row is: + +```js +{ + id, + mode: template.mode, + input: `${template.text.replace('{slug}', domain.slug)} Use ${domain.path}; verify with ${domain.command}; ${domain.constraint}.`, + expectedOutput: `${template.mode}\n${domain.path}\n${domain.command}\n${domain.constraint}\nShow the diff`, +} +``` + +Each held-out row uses the same input construction and this exact rubric: + +```js +{ + expectedMode: template.mode, + maxQuestions: template.maxQuestions, + exactPaths: [{ value: domain.path, weight: 2, critical: true }], + exactCommands: [{ value: domain.command, weight: 2, critical: true }], + requiredAll: [{ value: domain.constraint, dimension: 'constraints', weight: 2, critical: true }], + requiredAny: [{ values: ['Show the diff', 'Return the patch'], dimension: 'output', weight: 1, critical: false }], + forbidden: [{ value: '[', dimension: 'grounding', weight: 1, critical: true }], +} +``` + +Write stable two-space JSON plus a trailing newline. With `--check`, generate in +memory and throw ``new Error(`Generated fixture drift: ${file}`)`` rather than +modifying either file. + +- [ ] **Step 5: Implement fixture loading and disjointness validation** + +Create `src/fixture-set.mjs` with exported `normalizedInputHash` and `loadFixtureSet`: + +```js +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +export const normalizedInputHash = (value) => `sha256:${createHash('sha256') + .update(value.trim().replace(/\s+/g, ' ').toLowerCase()).digest('hex')}`; + +export function loadFixtureSet(root, name) { + const train = JSON.parse(fs.readFileSync(path.join(root, `fixtures/train-${name}.json`), 'utf8')); + const heldout = JSON.parse(fs.readFileSync(path.join(root, `fixtures/heldout-${name}.json`), 'utf8')); + const decorate = (items) => items.map((item) => ({ ...item, inputHash: normalizedInputHash(item.input) })); + const decoratedTrain = decorate(train); + const decoratedHeldout = decorate(heldout); + const trainIds = new Set(decoratedTrain.map((x) => x.id)); + const trainHashes = new Set(decoratedTrain.map((x) => x.inputHash)); + const disjoint = decoratedHeldout.every((x) => !trainIds.has(x.id) && !trainHashes.has(x.inputHash)); + if (!disjoint) throw new Error('Train and heldout fixtures must be disjoint by ID and normalized-input hash'); + return { train: decoratedTrain, heldout: decoratedHeldout, disjoint }; +} +``` + +- [ ] **Step 6: Generate and verify the committed fixtures** + +Run: + +```bash +cd spikes/clone-economics +node scripts/generate-fixtures.mjs +node --test tests/fixture-set.test.mjs +``` + +Expected: `train-v2.json` has 100 rows, `heldout-v2.json` has 30 rows, and both tests PASS. + +- [ ] **Step 7: Commit the preregistered fixture set** + +```bash +git add spikes/clone-economics/fixtures/fixture-catalog-v2.json spikes/clone-economics/fixtures/train-v2.json spikes/clone-economics/fixtures/heldout-v2.json spikes/clone-economics/scripts/generate-fixtures.mjs spikes/clone-economics/src/fixture-set.mjs spikes/clone-economics/tests/fixture-set.test.mjs +git commit -m "feat: preregister larger clone fixture set" +``` + +### Task 4: Add the N sweep and live-spend gate + +**Files:** +- Create: `spikes/clone-economics/fixtures/sweep-v1.json` +- Create: `spikes/clone-economics/fixtures/live-budget-v1.json` +- Create: `spikes/clone-economics/src/authorization.mjs` +- Create: `spikes/clone-economics/src/budget.mjs` +- Create: `spikes/clone-economics/src/sweep.mjs` +- Create: `spikes/clone-economics/sweep.mjs` +- Create: `spikes/clone-economics/tests/authorization.test.mjs` +- Create: `spikes/clone-economics/tests/budget.test.mjs` +- Create: `spikes/clone-economics/tests/fixtures/live-contract.mjs` +- Create: `spikes/clone-economics/tests/sweep.test.mjs` +- Modify: `spikes/clone-economics/src/experiment.mjs:91-157` +- Modify: `spikes/clone-economics/src/adapters.mjs:75-159` +- Modify: `spikes/clone-economics/package.json` + +- [ ] **Step 1: Commit the sweep preregistration before orchestration code** + +Create `fixtures/sweep-v1.json`: + +```json +{ + "schemaVersion": 1, + "experimentFamily": "clone-economics-high-n-v1", + "fixtureSet": "v2", + "nValues": [6, 25, 50, 100], + "heldoutMinimum": 30, + "replicates": [ + { "replicateId": "r1", "pairOrderSeed": 1701, "distillationSeed": 2701 }, + { "replicateId": "r2", "pairOrderSeed": 1702, "distillationSeed": 2702 }, + { "replicateId": "r3", "pairOrderSeed": 1703, "distillationSeed": 2703 } + ], + "highNDefinition": 100, + "targetThreshold": 0.8, + "requireAllTargetCriticalGates": true, + "acquisitionTreatment": "modeled_unless_x402_receipts_attached", + "attemptCostTreatment": "include_every_attempted_provider_call", + "publicationRequiresValidTarget": true, + "publicationRequiresIndependentDistillationSeeds": true +} +``` + +`pairOrderSeed` controls only deterministic acquisition-pair ordering. +`distillationSeed` is a distinct requested stochastic seed for the clone +distillation call. Neither may be relabeled as the other. + +Commit this file by itself so later results cannot rewrite the preregistration: + +```bash +git add spikes/clone-economics/fixtures/sweep-v1.json +git commit -m "docs: preregister clone high-N sweep" +``` + +- [ ] **Step 2: Commit the live budget contract in an explicitly unapproved state** + +Create `fixtures/live-budget-v1.json` exactly as follows. Nulls are intentional: +the repository must not manufacture a current model, price, or token cap. + +```json +{ + "schemaVersion": 1, + "experimentFamily": "clone-economics-high-n-v1", + "approvalStatus": "not_approved", + "provider": "anthropic", + "model": null, + "pricing": { + "currency": "USD", + "unit": "per_million_tokens", + "inputUsdPerMillionTokens": null, + "outputUsdPerMillionTokens": null, + "asOf": null, + "source": null + }, + "tokenCaps": { + "maxInputTokens": null, + "maxOutputTokens": null + } +} +``` + +The offline schema reader permits this `not_approved` state so a clean checkout +can run tests and mock preflight. The live validator must reject it. Before any +human-run live sweep, a human must verify the provider's current model pricing, +replace every null, set `approvalStatus` to `approved`, and commit that reviewed +snapshot. Prices are decimal strings in live snapshots, never JSON numbers; the +implementation converts them to integer micro-USD without floating point. + +Commit the initial contract separately: + +```bash +git add spikes/clone-economics/fixtures/live-budget-v1.json +git commit -m "docs: add unapproved clone sweep budget contract" +``` + +- [ ] **Step 3: Write sweep contract tests** + +Create `tests/sweep.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + classifyHighNSeedValidity, + seededOrder, + validateSweepConfig, +} from '../src/sweep.mjs'; + +const config = { + schemaVersion: 1, + experimentFamily: 'clone-economics-high-n-v1', + fixtureSet: 'v2', + nValues: [6, 25, 50, 100], + heldoutMinimum: 30, + replicates: [ + { replicateId: 'r1', pairOrderSeed: 1701, distillationSeed: 2701 }, + { replicateId: 'r2', pairOrderSeed: 1702, distillationSeed: 2702 }, + { replicateId: 'r3', pairOrderSeed: 1703, distillationSeed: 2703 }, + ], + highNDefinition: 100, +}; + +test('sweep contract requires the exact preregistered dimensions', () => { + assert.doesNotThrow(() => validateSweepConfig(config, { trainCount: 100, heldoutCount: 30 })); + assert.throws(() => validateSweepConfig({ ...config, nValues: [6, 100] }, { trainCount: 100, heldoutCount: 30 }), /N=6,25,50,100/); +}); + +test('three pair-order seeds are deterministic and distinct', () => { + const rows = Array.from({ length: 100 }, (_, i) => `row-${i}`); + const orders = config.replicates.map((replicate) => seededOrder(rows, replicate.pairOrderSeed)); + assert.deepEqual(orders[0], seededOrder(rows, 1701)); + assert.notDeepEqual(orders[0], orders[1]); + assert.notDeepEqual(orders[1], orders[2]); +}); + +test('pair-order and distillation seeds are separate distinct contracts', () => { + assert.deepEqual(config.replicates.map((x) => x.pairOrderSeed), [1701, 1702, 1703]); + assert.deepEqual(config.replicates.map((x) => x.distillationSeed), [2701, 2702, 2703]); + assert.equal(config.replicates.some((x) => x.pairOrderSeed === x.distillationSeed), false); +}); + +test('publishable high-N requires three adapter-confirmed distillation seeds', () => { + const validBenchmark = { valid: true, verdict: 'VALID_BENCHMARK' }; + const invalidBenchmark = { valid: false, verdict: 'INVALID_BENCHMARK_TARGET_FAILED' }; + const honored = config.replicates.map((replicate) => ({ + n: 100, + replicateId: replicate.replicateId, + requestedDistillationSeed: replicate.distillationSeed, + appliedDistillationSeed: replicate.distillationSeed, + distillationSeedStatus: 'honored', + status: 'complete', + benchmark: validBenchmark, + })); + assert.deepEqual(classifyHighNSeedValidity({ + cells: honored, + adapterMode: 'live', + standaloneBenchmark: validBenchmark, + }), { + valid: true, + reason: null, + }); + assert.deepEqual(classifyHighNSeedValidity({ + cells: [ + ...honored.slice(0, 2), + { ...honored[2], appliedDistillationSeed: null, distillationSeedStatus: 'unsupported' }, + ], + adapterMode: 'live', + standaloneBenchmark: validBenchmark, + }), { + valid: false, + reason: 'DISTILLATION_SEEDS_UNCONTROLLED', + }); + assert.deepEqual(classifyHighNSeedValidity({ + cells: honored.map((cell, index) => + index === 1 ? { ...cell, benchmark: invalidBenchmark } : cell), + adapterMode: 'live', + standaloneBenchmark: validBenchmark, + }), { + valid: false, + reason: 'HIGH_N_TARGET_INVALID', + }); + assert.deepEqual(classifyHighNSeedValidity({ + cells: honored, + adapterMode: 'live', + standaloneBenchmark: invalidBenchmark, + }), { + valid: false, + reason: 'STANDALONE_TARGET_INVALID', + }); +}); +``` + +- [ ] **Step 4: Write exact authorization, budget, and pre-construction tests** + +Create `tests/authorization.test.mjs` with synthetic data only: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + liveAuthorizationHash, + validateLiveApproval, +} from '../src/authorization.mjs'; +import { approved, config } from './fixtures/live-contract.mjs'; + +test('live approval binds the exact canonical sweep and budget snapshot', () => { + const authorizationHash = liveAuthorizationHash({ config, snapshot: approved }); + assert.match(authorizationHash, /^sha256:[0-9a-f]{64}$/); + assert.equal(validateLiveApproval({ + APPROVE_LIVE_SWEEP_SHA256: authorizationHash, + MAX_SWEEP_COST_USD: '50.000001', + }, { config, snapshot: approved }), 50_000_001n); +}); + +test('a stale approval fails after any material snapshot or config change', () => { + const stale = liveAuthorizationHash({ config, snapshot: approved }); + const mutations = [ + { config: { ...config, nValues: [6, 25, 50] }, snapshot: approved }, + { config: { + ...config, + replicates: config.replicates.map((x, index) => + index === 0 ? { ...x, distillationSeed: 9999 } : x), + }, snapshot: approved }, + { config, snapshot: { ...approved, model: 'changed-model' } }, + { config, snapshot: { + ...approved, + pricing: { ...approved.pricing, inputUsdPerMillionTokens: '3.01' }, + } }, + { config, snapshot: { + ...approved, + tokenCaps: { ...approved.tokenCaps, maxOutputTokens: 2048 }, + } }, + ]; + for (const changed of mutations) { + assert.throws(() => validateLiveApproval({ + APPROVE_LIVE_SWEEP_SHA256: stale, + MAX_SWEEP_COST_USD: '50', + }, changed), /stale or does not match/i); + } +}); + +test('the old experiment-family token is never accepted as authorization', () => { + assert.throws(() => validateLiveApproval({ + APPROVE_LIVE_SWEEP_SHA256: config.experimentFamily, + MAX_SWEEP_COST_USD: '50', + }, { config, snapshot: approved }), /sha256/); +}); +``` + +Create `tests/fixtures/live-contract.mjs`: + +```js +export const config = { + schemaVersion: 1, + experimentFamily: 'clone-economics-high-n-v1', + fixtureSet: 'v2', + nValues: [6, 25, 50, 100], + heldoutMinimum: 30, + replicates: [ + { replicateId: 'r1', pairOrderSeed: 1701, distillationSeed: 2701 }, + { replicateId: 'r2', pairOrderSeed: 1702, distillationSeed: 2702 }, + { replicateId: 'r3', pairOrderSeed: 1703, distillationSeed: 2703 }, + ], + highNDefinition: 100, + publicationRequiresIndependentDistillationSeeds: true, +}; + +export const approved = { + schemaVersion: 1, + experimentFamily: config.experimentFamily, + approvalStatus: 'approved', + provider: 'anthropic', + model: 'synthetic-budget-test-model', + pricing: { + currency: 'USD', + unit: 'per_million_tokens', + inputUsdPerMillionTokens: '3.00', + outputUsdPerMillionTokens: '15.00', + asOf: '2026-07-17T00:00:00Z', + source: 'https://example.invalid/synthetic-pricing-fixture', + }, + tokenCaps: { maxInputTokens: 4096, maxOutputTokens: 1024 }, +}; +``` + +Import this fixture from both tests so authorization and cost calculations +cannot silently use different contracts. + +Create `tests/budget.test.mjs`. The approved fixture below is synthetic test +data only; it must not replace `fixtures/live-budget-v1.json`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + calculateProviderCostMicroUsd, + conservativeSweepRequestCount, + createAttemptBudget, + estimateLiveSweepMicroUsd, + validateApprovedBudgetSnapshot, +} from '../src/budget.mjs'; +import { liveAuthorizationHash } from '../src/authorization.mjs'; +import { startLiveSweep } from '../src/sweep.mjs'; +import { approved, config } from './fixtures/live-contract.mjs'; + +const counts = { trainCount: 100, heldoutCount: 30, v2Count: 2 }; + +test('live snapshot must be complete, approved, and match the experiment', () => { + assert.doesNotThrow(() => validateApprovedBudgetSnapshot(approved, config)); + assert.throws( + () => validateApprovedBudgetSnapshot({ ...approved, approvalStatus: 'not_approved' }, config), + /not approved/i, + ); + assert.throws( + () => validateApprovedBudgetSnapshot({ + ...approved, + pricing: { ...approved.pricing, inputUsdPerMillionTokens: null }, + }, config), + /input pricing/i, + ); +}); + +test('preflight counts the target gate and every call in all 12 cells', () => { + assert.equal(conservativeSweepRequestCount(config, counts), 1713); + assert.equal(calculateProviderCostMicroUsd({ + inputTokens: 4096, + outputTokens: 1024, + snapshot: approved, + }), 27_648n); + assert.equal(estimateLiveSweepMicroUsd({ config, counts, snapshot: approved }), 47_361_024n); +}); + +test('an under-cap live request constructs neither adapter nor fetch', async () => { + let adapterConstructions = 0; + let fetchConstructions = 0; + await assert.rejects(startLiveSweep({ + env: { + APPROVE_LIVE_SWEEP_SHA256: liveAuthorizationHash({ config, snapshot: approved }), + MAX_SWEEP_COST_USD: '47.00', + }, + config, + counts, + snapshot: approved, + fetchFactory() { + fetchConstructions += 1; + throw new Error('fetch must not be constructed'); + }, + adapterFactory() { + adapterConstructions += 1; + throw new Error('adapter must not be constructed'); + }, + }), /47\.361024.*47\.000000/); + assert.equal(adapterConstructions, 0); + assert.equal(fetchConstructions, 0); +}); + +test('every attempted call is reserved and the next over-cap call is refused', () => { + const budget = createAttemptBudget({ capMicroUsd: 300n, worstCaseCallMicroUsd: 100n }); + const first = budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'one' }); + budget.settleAttempt(first, { knownCostMicroUsd: 80n, success: true }); + const second = budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'two' }); + budget.settleAttempt(second, { knownCostMicroUsd: 90n, success: false }); + const third = budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'three' }); + budget.settleAttempt(third, { knownCostMicroUsd: 100n, success: true }); + assert.deepEqual(budget.state(), { + attemptedCalls: 3, + knownAccruedMicroUsd: 270n, + outstandingReservedMicroUsd: 0n, + lock: null, + }); + assert.throws( + () => budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'four' }), + /would exceed.*cap/i, + ); + assert.equal(budget.state().attemptedCalls, 3); +}); + +test('unknown cost locks its reservation and fails closed', () => { + const budget = createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }); + const attempt = budget.reserveNextAttempt({ kind: 'distill', caseId: null }); + assert.throws( + () => budget.settleAttempt(attempt, { knownCostMicroUsd: null, success: false }), + /unknown live cost.*budget locked/i, + ); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 0n, + outstandingReservedMicroUsd: 100n, + lock: { kind: 'unknown_cost', attemptId: attempt }, + }); + assert.throws( + () => budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'blocked' }), + /budget locked/i, + ); +}); + +test('above-token-cap usage records exact cost and permanently locks as budget_overrun', () => { + const budget = createAttemptBudget({ capMicroUsd: 1_000n, worstCaseCallMicroUsd: 100n }); + const attempt = budget.reserveNextAttempt({ kind: 'distill', caseId: null }); + assert.throws( + () => budget.settleAttempt(attempt, { + knownCostMicroUsd: 140n, + success: false, + budgetViolation: 'token_cap_exceeded', + }), + /budget_overrun.*token cap/i, + ); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 140n, + outstandingReservedMicroUsd: 0n, + lock: { kind: 'budget_overrun', attemptId: attempt, reason: 'token_cap_exceeded' }, + }); + assert.throws(() => budget.reserveNextAttempt({ kind: 'blocked', caseId: null }), /budget_overrun/); +}); + +test('known provider cost above the human cap is accrued before permanent lock', () => { + const budget = createAttemptBudget({ capMicroUsd: 100n, worstCaseCallMicroUsd: 100n }); + const attempt = budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'one' }); + assert.throws( + () => budget.settleAttempt(attempt, { knownCostMicroUsd: 125n, success: true }), + /budget_overrun.*human cap/i, + ); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 125n, + outstandingReservedMicroUsd: 0n, + lock: { kind: 'budget_overrun', attemptId: attempt, reason: 'human_cap_exceeded' }, + }); + assert.throws(() => budget.reserveNextAttempt({ kind: 'blocked', caseId: null }), /budget_overrun/); +}); +``` + +The 1,713-call estimate deliberately includes the 30-call standalone target +gate plus every current per-cell target/clone/control/evolution call. It is a +conservative preflight ceiling; a later optimization may make fewer calls but +must not lower this committed estimate without a new preregistration and test. + +- [ ] **Step 5: Run the tests to verify they fail** + +Run: `cd spikes/clone-economics && node --test tests/sweep.test.mjs tests/authorization.test.mjs tests/budget.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/sweep.mjs`, +`src/authorization.mjs`, or `src/budget.mjs`; no adapter or fetch factory runs. + +- [ ] **Step 6: Implement the deterministic pair-order seed** + +In `src/sweep.mjs`, use a local Mulberry32 PRNG and Fisher-Yates shuffle: + +```js +function mulberry32(seed) { + return () => { + seed |= 0; + seed = seed + 0x6D2B79F5 | 0; + let t = Math.imul(seed ^ seed >>> 15, 1 | seed); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +export function seededOrder(values, seed) { + const result = [...values]; + const random = mulberry32(seed); + for (let i = result.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} +``` + +Call this function only with `pairOrderSeed`. Document that it controls +acquisition-pair order and nothing about provider sampling. The separate +`distillationSeed` travels through the distillation adapter contract in Step 8. + +- [ ] **Step 7: Implement config, approval, and exact budget validation** + +```js +export function validateSweepConfig(config, counts) { + if (JSON.stringify(config.nValues) !== JSON.stringify([6, 25, 50, 100])) { + throw new Error('Sweep must use N=6,25,50,100'); + } + if (!Array.isArray(config.replicates) || config.replicates.length !== 3) { + throw new Error('Sweep must use exactly three preregistered replicates'); + } + const ids = config.replicates.map((x) => x.replicateId); + const pairOrderSeeds = config.replicates.map((x) => x.pairOrderSeed); + const distillationSeeds = config.replicates.map((x) => x.distillationSeed); + for (const [label, values] of [ + ['replicate IDs', ids], + ['pair-order seeds', pairOrderSeeds], + ['distillation seeds', distillationSeeds], + ]) { + if (new Set(values).size !== 3) throw new Error(`Sweep requires three distinct ${label}`); + } + if (![...pairOrderSeeds, ...distillationSeeds].every(Number.isSafeInteger)) { + throw new Error('Sweep seeds must be safe integers'); + } + if (counts.trainCount < 100 || counts.heldoutCount < 30) { + throw new Error('Sweep requires at least 100 train and 30 heldout fixtures'); + } +} +``` + +In `src/authorization.mjs`, canonicalize by recursively sorting object keys +while retaining array order, then hash the complete validated objects: + +```js +import { createHash } from 'node:crypto'; + +import { parseUsdToMicroUsd } from './budget.mjs'; + +function canonicalize(value) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]), + ); + } + throw new Error(`Unsupported authorization value type: ${typeof value}`); +} + +export function liveAuthorizationHash({ config, snapshot }) { + const canonical = JSON.stringify(canonicalize({ + authorizationSchemaVersion: 1, + sweepConfig: config, + liveBudgetSnapshot: snapshot, + })); + return `sha256:${createHash('sha256').update(canonical).digest('hex')}`; +} + +export function validateLiveApproval(env, contract) { + const supplied = env.APPROVE_LIVE_SWEEP_SHA256; + if (typeof supplied !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(supplied)) { + throw new Error('APPROVE_LIVE_SWEEP_SHA256 must be a lowercase sha256 digest'); + } + const expected = liveAuthorizationHash(contract); + if (supplied !== expected) { + throw new Error(`Live approval is stale or does not match ${expected}`); + } + return parseUsdToMicroUsd(env.MAX_SWEEP_COST_USD, 'MAX_SWEEP_COST_USD'); +} +``` + +`parseUsdToMicroUsd` accepts only a plain, +positive USD decimal with at most six fractional digits. It rejects exponent +notation, signs, commas, whitespace, `NaN`, `Infinity`, zero, and excess +precision; it returns a `bigint` number of micro-USD. + +Implement these contracts in `src/budget.mjs`: + +```js +const MICRO_USD_PER_USD = 1_000_000n; + +export function parseUsdToMicroUsd(value, fieldName) { + if (typeof value !== 'string' || !/^(?:0|[1-9]\d*)(?:\.\d{1,6})?$/.test(value)) { + throw new Error(`${fieldName} must be a positive plain USD decimal with at most six places`); + } + const [whole, fraction = ''] = value.split('.'); + const result = BigInt(whole) * MICRO_USD_PER_USD + + BigInt(fraction.padEnd(6, '0')); + if (result <= 0n) throw new Error(`${fieldName} must be positive`); + return result; +} + +const ceilDiv = (numerator, denominator) => + (numerator + denominator - 1n) / denominator; + +export function calculateProviderCostMicroUsd({ inputTokens, outputTokens, snapshot }) { + if (!Number.isSafeInteger(inputTokens) || inputTokens < 0 + || !Number.isSafeInteger(outputTokens) || outputTokens < 0) { + throw new Error('Provider usage must contain non-negative safe integer token counts'); + } + const inputPrice = parseUsdToMicroUsd( + snapshot.pricing.inputUsdPerMillionTokens, + 'input pricing', + ); + const outputPrice = parseUsdToMicroUsd( + snapshot.pricing.outputUsdPerMillionTokens, + 'output pricing', + ); + return ceilDiv(BigInt(inputTokens) * inputPrice, 1_000_000n) + + ceilDiv(BigInt(outputTokens) * outputPrice, 1_000_000n); +} + +export function exceedsCommittedTokenCaps({ inputTokens, outputTokens, snapshot }) { + return inputTokens > snapshot.tokenCaps.maxInputTokens + || outputTokens > snapshot.tokenCaps.maxOutputTokens; +} +``` + +Keep cost calculation and authorization validation separate: valid observed +usage is always priced exactly, while `exceedsCommittedTokenCaps` decides +whether that known charge also creates a permanent `budget_overrun` lock. + +`validateBudgetSnapshotShape` accepts either the exact unapproved/null state or +a complete approved state. `validateApprovedBudgetSnapshot` additionally +requires all of the following before live use: + +- schema version 1 and the exact experiment family; +- `approvalStatus: "approved"`; +- provider and model as nonempty strings; +- currency `USD` and unit `per_million_tokens`; +- positive plain-decimal input/output prices with at most six places; +- an ISO-8601 `asOf` timestamp and an HTTPS `source` URL; +- positive safe-integer input/output token caps. + +Unknown, null, malformed, mismatched, or unapproved data fails closed. The live +adapter receives model, prices, and caps from this committed snapshot; high-N +live execution must not override them with environment values. + +Implement the conservative count exactly: + +```js +export function conservativeSweepRequestCount(config, counts) { + const cells = config.nValues.flatMap((n) => + config.replicates.map(() => ( + n + 1 + counts.heldoutCount * 3 + counts.v2Count * 2 + ))); + return counts.heldoutCount + cells.reduce((sum, value) => sum + value, 0); +} + +export function estimateLiveSweepMicroUsd({ config, counts, snapshot }) { + validateApprovedBudgetSnapshot(snapshot, config); + const perCall = calculateProviderCostMicroUsd({ + inputTokens: snapshot.tokenCaps.maxInputTokens, + outputTokens: snapshot.tokenCaps.maxOutputTokens, + snapshot, + }); + return BigInt(conservativeSweepRequestCount(config, counts)) * perCall; +} +``` + +Use a formatter that renders integer micro-USD with exactly six decimal places +in errors. With the synthetic test snapshot, the worst case is 27,648 +micro-USD per call and 47,361,024 micro-USD across 1,713 calls. + +- [ ] **Step 8: Make experiment fixtures and pair order injectable** + +Change `runExperiment` to accept `options.trainFixtures`, +`options.heldoutFixtures`, `options.pairOrderSeed`, +`options.requestedDistillationSeed`, `options.replicateId`, and +`options.fixtureSet`, falling back to the original v1 files for the old offline +e2e. Before selecting N, order the supplied training rows: + +```js +const orderedTrain = options.pairOrderSeed === undefined + ? trainFixtures + : seededOrder(trainFixtures, options.pairOrderSeed); +const selectedTrain = orderedTrain.slice(0, N); +``` + +Include this exact seed schema in each report: + +```js +seedContract: { + replicateId: options.replicateId ?? null, + pairOrderSeed: options.pairOrderSeed ?? null, + pairOrderSeedStatus: options.pairOrderSeed === undefined ? 'not_requested' : 'honored_locally', + requestedDistillationSeed: options.requestedDistillationSeed ?? null, + appliedDistillationSeed: distilled.seed.appliedSeed, + distillationSeedStatus: distilled.seed.status, + distillationSeedMechanism: distilled.seed.mechanism, +} +``` + +Only the `distill` request receives `requestedDistillationSeed`; acquisition and +evaluation calls set it to null. Extend every adapter response with: + +```js +seed: { + requestedSeed: request.requestedDistillationSeed ?? null, + appliedSeed: null, + status: request.requestedDistillationSeed === undefined ? 'not_requested' : 'unsupported', + mechanism: request.requestedDistillationSeed === undefined + ? 'no_seed_requested' + : 'provider_seed_not_supported_by_adapter', +} +``` + +`requestedSeed` and `appliedSeed` are safe integers or null; `status` is one of +`honored`, `unsupported`, `synthetic_honored`, or `not_requested`; `mechanism` +is a nonempty string. + +An adapter may report `honored` only when it sent the requested integer through +a documented provider seed mechanism for that model/request and did not +silently substitute it. The current `LiveAnthropicAdapter` has no such request +field in this harness, so it reports `unsupported`, `appliedSeed: null`, and +`mechanism: 'provider_seed_not_supported_by_adapter'`; it must not pretend +pair-order determinism controls model sampling. The mock adapter reports +`synthetic_honored` and uses the requested value only to select deterministic +canned distillation output. Synthetic status never satisfies the live +publishability gate. + +Extend `MockLlmAdapter` with an optional `outputFor` callback used only by the +offline v2 sweep. The callback receives the request identifier, not target bytes: + +```js +constructor({ transcript, cloneSkillMd, outputFor = null }) { + this.transcript = transcript; + this.cloneSkillMd = cloneSkillMd; + this.outputFor = outputFor; + this.capturedRequests = []; + this.records = []; + this.attempts = []; + this.pricing = transcript.pricing; +} + +async invoke(request) { + this.capturedRequests.push(structuredClone(request)); + const customOutput = this.outputFor?.(request); + let output = typeof customOutput === 'string' ? customOutput : undefined; + if (output === undefined) { + if (request.kind === 'distill') output = this.cloneSkillMd; + else if (request.kind === 'target-train') output = this.transcript.trainOutputs[request.caseId]; + else if (request.kind.endsWith('-v2-heldout')) { + const profile = request.kind.startsWith('target-') ? 'target' : 'clone'; + output = this.transcript.v2Outputs[request.caseId]?.[profile]; + } else { + const profile = request.kind === 'target-heldout' + ? 'target' + : request.kind === 'clone-heldout' ? 'clone' : 'bad'; + output = this.transcript.heldoutOutputs[request.caseId]?.[profile]; + } + } + if (typeof output !== 'string') { + throw new Error(`Missing SYNTHETIC transcript output for ${request.kind}:${request.caseId ?? 'distill'}`); + } + // Continue with the existing usage-profile validation and normalized record + // construction beginning at `const profile = this.transcript.usageProfiles`. +} +``` + +The mock sweep constructs maps from the v2 fixtures. Training requests return +their committed `expectedOutput`; target and clone held-out requests return: + +```js +const compliantHeldoutOutput = (fixture) => [ + fixture.mode, + fixture.rubric.exactPaths[0].value, + fixture.rubric.exactCommands[0].value, + fixture.rubric.requiredAll[0].value, + 'Show the diff', +].join('\n'); +``` + +Immediately before each adapter return, construct seed evidence. The mock uses: + +```js +const requestedSeed = request.kind === 'distill' + ? request.requestedDistillationSeed ?? null + : null; +const seed = requestedSeed === null + ? { requestedSeed: null, appliedSeed: null, status: 'not_requested', mechanism: 'no_seed_requested' } + : { + requestedSeed, + appliedSeed: requestedSeed, + status: 'synthetic_honored', + mechanism: 'deterministic_mock_fixture_selection', + }; +return { output, ...record, seed }; +``` + +The live adapter uses the unsupported shape above unless its provider-specific +request builder has an implemented and tested seed field. Never infer +`honored` merely because outputs differ across calls. + +`bad-clone-heldout` returns `Unscoped answer`; `distill` and v2-overlay requests +continue using the existing canned transcript. Never add `expectedOutput` or a +rubric to a live request payload. + +- [ ] **Step 9: Guard and record every attempted provider call** + +Implement `createAttemptBudget` in `src/budget.mjs`. Its +`reserveNextAttempt(metadata)` method must: + +1. reject when any permanent `unknown_cost` or `budget_overrun` lock exists; +2. calculate `known accrued + outstanding reservations + worst next call`; +3. reject without incrementing the attempt count if that total exceeds the + human cap; +4. otherwise increment `attemptedCalls`, retain one worst-case reservation, and + return its opaque ID. + +Its +`settleAttempt(id, { knownCostMicroUsd, success, budgetViolation = null })` +method has two distinct fail-closed paths: + +- A null cost keeps the full reservation outstanding, sets the permanent lock + `{ kind: 'unknown_cost', attemptId }`, and throws + `Unknown live cost; budget locked`. Missing or malformed usage takes this + path because no exact charge can be computed. +- A non-negative exact bigint cost always releases the reservation and adds the + entire cost to `knownAccruedMicroUsd`, even when it exceeds the reservation, + committed token caps, or human cap. If `budgetViolation === + 'token_cap_exceeded'`, the cost exceeds its reservation, or cumulative known + cost exceeds the human cap, persist + `{ kind: 'budget_overrun', attemptId, reason }` and throw only after state is + updated. Reason precedence is `token_cap_exceeded`, then + `human_cap_exceeded`, then `reservation_exceeded`. + +Both locks are permanent for the sweep and reject every later reservation. +Never relabel a known overrun as unknown, truncate it to the reservation, or +zero it because the response is rejected. `state()` returns exactly +`attemptedCalls`, `knownAccruedMicroUsd`, `outstandingReservedMicroUsd`, and +`lock` as asserted above. + +The settlement branch inside `createAttemptBudget` is: + +```js +function settleAttempt(attemptId, { + knownCostMicroUsd, + success, + budgetViolation = null, +}) { + const reservation = reservations.get(attemptId); + if (!reservation) throw new Error(`Unknown or already-settled attempt ${attemptId}`); + if (lock) throw new Error(`Budget permanently locked: ${lock.kind}`); + if (knownCostMicroUsd === null) { + lock = { kind: 'unknown_cost', attemptId }; + throw new Error('Unknown live cost; budget locked'); + } + if (typeof knownCostMicroUsd !== 'bigint' || knownCostMicroUsd < 0n) { + lock = { kind: 'unknown_cost', attemptId }; + throw new Error('Malformed live cost; budget locked as unknown_cost'); + } + reservations.delete(attemptId); + outstandingReservedMicroUsd -= reservation.amountMicroUsd; + knownAccruedMicroUsd += knownCostMicroUsd; + const reason = budgetViolation === 'token_cap_exceeded' + ? 'token_cap_exceeded' + : knownAccruedMicroUsd > capMicroUsd + ? 'human_cap_exceeded' + : knownCostMicroUsd > reservation.amountMicroUsd + ? 'reservation_exceeded' + : null; + settled.set(attemptId, { knownCostMicroUsd, success }); + if (reason) { + lock = { kind: 'budget_overrun', attemptId, reason }; + const label = reason.replaceAll('_', ' '); + throw new Error(`budget_overrun: ${label}; exact cost was accrued`); + } +} +``` + +Add `attempts` to both adapters. In the live adapter, perform local request-kind, +prompt-byte-upper-bound, and token-cap validation first. Immediately before the +actual `fetch` expression, call `budget.reserveNextAttempt`; there must be no +await, retry wrapper, or provider action between reservation and fetch. Use the +injected fetch function rather than global `fetch`. + +After a provider response, require non-negative safe-integer input and output +usage and recompute exact cost with `calculateProviderCostMicroUsd` before +enforcing the committed token caps. The cost function prices any valid observed +usage; token caps define authorization, not whether the resulting bill is +knowable. If either observed count exceeds its cap, settle the exact cost with +`budgetViolation: 'token_cap_exceeded'`, retain the observed token counts and +cost in the failed attempt, permanently lock `budget_overrun`, and abort. Do +not trust a provider-supplied dollar field. Missing/malformed usage, or a +network/HTTP failure without valid usage, retains the reservation under +`unknown_cost`; a response with valid usage is settled as known even if the +request otherwise failed. Neither lock permits a later provider call. + +Wrap every `invoke` body in `try/catch`; on success append the normalized +request ID/status/cost. On error append this sanitized attempt before rethrowing: + +```js +this.attempts.push({ + attemptId: `${request.kind}:${request.caseId ?? 'distill'}:${this.attempts.length + 1}`, + kind: request.kind, + caseId: request.caseId ?? null, + success: false, + providerRequestId: null, + latencyMs: performance.now() - started, + inputTokens: observedUsage?.inputTokens ?? null, + outputTokens: observedUsage?.outputTokens ?? null, + providerCostMicroUsd: knownCostMicroUsd?.toString() ?? null, + providerCostUsd: knownCostMicroUsd === null + ? null + : Number(knownCostMicroUsd) / 1_000_000, + failureClass: error instanceof Error ? error.name : 'UnknownError', +}); +throw error; +``` + +Set `providerCostUsd` from the exact micro-USD value only after settlement. On +both success and known-cost failure, persist the exact integer as base-10 +`providerCostMicroUsd` and derive `providerCostUsd` only for display/aggregate +compatibility. On success append the normalized equivalent with `success: +true`. The attempt ID +and budget reservation ID must be correlated internally, but the reservation +does not expose request content. Never include prompt payload, output text, API +keys, serialized request bodies, or headers in `attempts`. + +The catch path must not lose the original provider/cap error when settlement +also reports `unknown_cost` or `budget_overrun`. Record both failure classes in +a sanitized `AggregateError`, with the permanent-lock error first so the CLI +makes the stop condition obvious. Separate adapter tests must prove: missing +usage retains one reservation under `unknown_cost`; above-token usage records +the exact observed tokens/cost with no reservation under `budget_overrun`; a +known charge above the human cap is fully accrued under `budget_overrun`; and +all three cases make zero retry or subsequent provider calls. + +- [ ] **Step 10: Implement sweep preflight and orchestration** + +Export `startLiveSweep` from `src/sweep.mjs`. Its ordering is a security +contract: + +1. validate fixture counts and the sweep preregistration; +2. validate the committed budget snapshot as approved; +3. compute the canonical snapshot/config authorization hash, require exact + `APPROVE_LIVE_SWEEP_SHA256`, and parse the human cap to micro-USD; +4. calculate the 1,713-call worst-case estimate; +5. reject if the estimate exceeds the human cap; +6. validate `ALLOW_LIVE_LLM=1`; +7. create the attempt budget; +8. only then call `fetchFactory`, then `adapterFactory`, then `runSweep`. + +No constructor, API-key-dependent object, fetch wrapper, output directory, or +provider request may be created in steps 1–6. Return the exact authorization +hash, parsed human cap, worst-case estimate, request count, per-call ceiling, +and budget state alongside the sweep result so the evidence bundle can +reconcile preflight and actual attempts. The test-only factories are dependency injection; the CLI supplies +`() => fetch` and a factory that creates `LiveAnthropicAdapter` from the +committed snapshot and budget guard. + +`runSweep` must: + +1. load and validate `sweep-v1.json` plus v2 fixtures; +2. accept the already-created adapter only after `startLiveSweep` completed its + conservative count and cost checks; +3. run the target across all 30 held-out fixtures first and call the same + `assessBenchmark({ threshold, target: targetScore })` used by each cell; +4. if that standalone `benchmark.valid` is false, write an invalid-target result + and make zero distillation calls; +5. otherwise run all 12 `(N, replicate)` cells, passing `pairOrderSeed` only to + local ordering and `distillationSeed` only to the adapter's distill request; +6. retain every successful and failed provider attempt in the returned `samples` array; +7. reconcile `samples.length`, adapter attempt count, and budget + `attemptedCalls`, failing closed on any mismatch; +8. identify N=100 as computationally complete only after all three N=100 cells + finish; +9. set `publishableHighN: true` only when the standalone target benchmark is + valid, every N=100 cell's own `benchmark.valid` is true, and all three N=100 + cells have distinct requested seeds, matching applied seeds, and adapter + status `honored` on a live adapter. + +The target preflight return shape is exact: + +```js +{ + experimentFamily, + benchmark, + targetScore, + cells: [], + samples, + highNComplete: false, +} +``` + +Implement and export the high-N gate as a pure function so report rendering, +bundle generation, and tests cannot disagree: + +```js +export function classifyHighNSeedValidity({ cells, adapterMode, standaloneBenchmark }) { + const highN = cells.filter((cell) => cell.n === 100 && cell.status === 'complete'); + if (standaloneBenchmark?.valid !== true) { + return { valid: false, reason: 'STANDALONE_TARGET_INVALID' }; + } + if (highN.length !== 3) { + return { valid: false, reason: 'HIGH_N_INCOMPLETE' }; + } + if (highN.some((cell) => cell.benchmark?.valid !== true)) { + return { valid: false, reason: 'HIGH_N_TARGET_INVALID' }; + } + if (adapterMode !== 'live') { + return { valid: false, reason: 'HIGH_N_NOT_LIVE' }; + } + const requested = highN.map((cell) => cell.requestedDistillationSeed); + const independentlyHonored = new Set(requested).size === 3 + && highN.every((cell) => + cell.distillationSeedStatus === 'honored' + && cell.appliedDistillationSeed === cell.requestedDistillationSeed); + return independentlyHonored + ? { valid: true, reason: null } + : { valid: false, reason: 'DISTILLATION_SEEDS_UNCONTROLLED' }; +} +``` + +Mock cells use `synthetic_honored`; that proves orchestration determinism but +can never satisfy this publication gate. + +Call this function exactly once after all cells finish and use its result for +`publishableHighN`, report suppression, and bundle metadata. A passing +standalone target never substitutes for a failed target inside one N=100 cell; +the regression above must keep all aggregate clone/economics conclusions +suppressed when any one cell is invalid. + +When valid, each cell is: + +```js +{ + n, + replicateId, + pairOrderSeed, + requestedDistillationSeed, + appliedDistillationSeed, + distillationSeedStatus, + distillationSeedMechanism, + status: 'complete', + benchmark: result.report.benchmark, + targetAbsoluteScore: result.report.fidelity.target.absoluteScore, + cloneAbsoluteScore: result.report.fidelity.clone.absoluteScore, + cloneCriticalGatePass: result.report.fidelity.clone.criticalGatePass, + providerCostUsd: result.report.usage.normalized.providerCostUsd, +} +``` + +If any selected provider reports `unsupported`, retain each cell and attempt as +`stochastic_uncontrolled` evidence, set +`publishableHighN: false`, set suppression reason +`DISTILLATION_SEEDS_UNCONTROLLED`, and render no aggregate clone fidelity, +defensibility, moat, break-even, or economics conclusion. Never call these +“independent seeded replicates.” A provider-support upgrade needs a new adapter +test proving the exact requested seed is sent and reported as applied. + +Apply the same aggregate-conclusion suppression when the gate returns +`STANDALONE_TARGET_INVALID` or `HIGH_N_TARGET_INVALID`. Preserve the invalid +target observations and verdicts in the bundle; never average the two valid +N=100 cells around the invalid one or fall back to the standalone score. + +- [ ] **Step 11: Add a CLI that defaults to preflight/mock** + +Create `sweep.mjs` so `node sweep.mjs --preflight` validates config, fixture +counts, the 1,713-call formula, and the budget-snapshot shape without provider +construction. With the committed `not_approved` snapshot it prints +`live budget: not approved` and exits zero because offline readiness is intact; +it must not invent a dollar estimate or authorization hash from incomplete +pricing. With an approved snapshot, preflight prints the exact canonical line +`` `live authorization: ${liveAuthorizationHash({ config, snapshot })}` `` and +the conservative estimate, but still constructs no provider object and makes +no network request. `--mock` runs the offline sweep. `--live` loads the same +committed config and snapshot and delegates every gate, in order, to +`startLiveSweep`; the supplied hash must equal the line from the unchanged +approved files. Any missing mode flag exits with usage and no output directory +or network action. + +Update `package.json` scripts: + +```json +{ + "scripts": { + "test": "node --test tests/*.test.mjs && npm run e2e", + "fixtures:check": "node scripts/generate-fixtures.mjs --check", + "sweep:preflight": "node sweep.mjs --preflight", + "sweep:mock": "MOCK_LLM=1 ALLOW_LIVE_LLM=0 node sweep.mjs --mock", + "sweep:live": "MOCK_LLM=0 node sweep.mjs --live" + } +} +``` + +Keep the existing `e2e`, `run`, and `real` scripts. + +- [ ] **Step 12: Run the sweep tests without network access** + +Run: + +```bash +cd spikes/clone-economics +node --test tests/sweep.test.mjs +node --test tests/authorization.test.mjs +node --test --test-name-pattern='budget|under-cap|attempted call|unknown cost|token cap|human cap' tests/budget.test.mjs +npm run sweep:preflight +npm run sweep:mock +``` + +Expected: all tests PASS; preflight reports 100 train, 30 heldout, 12 cells, +1,713 conservative live requests, and `live budget: not approved`; the +under-cap test reports zero adapter and fetch constructions; mock completes +with `networkAttempts=0` or the existing network-forbidden stub untouched. No +live environment variable is set. + +- [ ] **Step 13: Commit sweep orchestration** + +```bash +git add spikes/clone-economics/src/authorization.mjs spikes/clone-economics/src/budget.mjs spikes/clone-economics/src/sweep.mjs spikes/clone-economics/sweep.mjs spikes/clone-economics/tests/authorization.test.mjs spikes/clone-economics/tests/budget.test.mjs spikes/clone-economics/tests/sweep.test.mjs spikes/clone-economics/tests/fixtures/live-contract.mjs spikes/clone-economics/src/experiment.mjs spikes/clone-economics/src/adapters.mjs spikes/clone-economics/package.json spikes/clone-economics/package-lock.json +git commit -m "feat: add gated clone high-N sweep" +``` + +### Task 5: Build sanitized, hash-verifiable evidence bundles + +**Files:** +- Create: `spikes/clone-economics/src/evidence.mjs` +- Create: `spikes/clone-economics/scripts/verify-bundle.mjs` +- Create: `spikes/clone-economics/tests/evidence.test.mjs` + +- [ ] **Step 1: Write bundle tests** + +Create `tests/evidence.test.mjs` with a temporary five-file bundle: + +```js +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { recomputeSummary, verifyEvidenceBundle, writeEvidenceBundle } from '../src/evidence.mjs'; + +const samples = [ + { sampleId: 'run:target-heldout:a', phase: 'evaluation', profile: 'target', caseId: 'a', success: true, latencyMs: 10, inputTokens: 3, outputTokens: 2, providerCostUsd: 0.01, score: 0.9, criticalGatePass: true }, + { sampleId: 'run:clone-heldout:a', phase: 'evaluation', profile: 'clone', caseId: 'a', success: true, latencyMs: 30, inputTokens: 3, outputTokens: 2, providerCostUsd: 0.02, score: 0.7, criticalGatePass: false }, + { sampleId: 'run:distill:1', phase: 'distillation', profile: 'clone', caseId: null, success: false, latencyMs: 5, inputTokens: null, outputTokens: null, providerCostUsd: null, score: null, criticalGatePass: null, failureClass: 'ProviderError' }, +]; + +test('bundle hashes and summary recompute from normalized samples', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + writeEvidenceBundle({ + outputDir: dir, + manifest: { experimentId: 'fixture-run', evidenceLabel: 'SYNTHETIC', command: 'npm run sweep:mock' }, + samples, + interpretation: 'Synthetic fixture bundle.', + reproduction: 'node scripts/verify-bundle.mjs evidence/fixture-run', + }); + const verified = verifyEvidenceBundle(dir); + assert.equal(verified.valid, true); + assert.equal(verified.summary.attemptedSamples, 3); + assert.equal(verified.summary.failedSamples, 1); + assert.equal(verified.summary.providerCostUsd, null); + assert.equal(verified.summary.latencyMs.p50, 10); + assert.equal(verified.summary.latencyMs.p95, 30); +}); + +test('redaction rejects private payload fields', () => { + assert.throws(() => recomputeSummary([{ ...samples[0], prompt: 'private' }]), /forbidden sample field: prompt/); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd spikes/clone-economics && node --test tests/evidence.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/evidence.mjs`. + +- [ ] **Step 3: Define the exact normalized sample schema** + +In `src/evidence.mjs`, accept only these keys: + +```js +const SAMPLE_KEYS = new Set([ + 'sampleId', 'phase', 'profile', 'caseId', 'n', 'replicateId', + 'pairOrderSeed', 'requestedDistillationSeed', 'appliedDistillationSeed', + 'distillationSeedStatus', 'distillationSeedMechanism', + 'success', 'latencyMs', 'inputTokens', 'outputTokens', + 'providerCostMicroUsd', 'providerCostUsd', + 'acquisitionCostUsd', 'acquisitionEvidence', 'score', 'criticalGatePass', + 'failureClass', 'providerRequestId', +]); +const FORBIDDEN_KEYS = new Set([ + 'prompt', 'payload', 'output', 'rawResponse', 'apiKey', 'authorization', + 'headers', 'skillText', 'referenceText', +]); +``` + +Reject unknown or forbidden fields. Require a stable `sampleId`, finite non-negative known numeric values, and `null` for unknown usage/cost. `providerCostUsd: null` propagates to the aggregate cost; it never becomes zero. +For every live row with known usage, require `providerCostMicroUsd` to be a +base-10 non-negative integer string and recompute its value from observed +tokens plus the hash-verified pricing snapshot. A known overrun row therefore +remains exactly auditable even when its display-oriented `providerCostUsd` +value is rounded; unknown-cost rows require both cost fields to be null. + +- [ ] **Step 4: Implement deterministic summary recomputation** + +Use nearest-rank percentiles over successful finite latencies: + +```js +const percentile = (values, p) => { + if (values.length === 0) return null; + const ordered = [...values].sort((a, b) => a - b); + return ordered[Math.max(0, Math.ceil(p * ordered.length) - 1)]; +}; +``` + +Return: + +```js +{ + attemptedSamples: samples.length, + successfulSamples: samples.filter((x) => x.success).length, + failedSamples: samples.filter((x) => !x.success).length, + providerCostUsd: samples.every((x) => x.providerCostUsd !== null) + ? rounded(sum(samples.map((x) => x.providerCostUsd))) + : null, + acquisition: { + modeledUsd: rounded(sum(samples.map((x) => x.acquisitionCostUsd ?? 0))), + evidence: [...new Set(samples.map((x) => x.acquisitionEvidence).filter(Boolean))].sort(), + }, + latencyMs: { p50: percentile(latencies, 0.5), p95: percentile(latencies, 0.95) }, + fidelity: summarizeScoresByProfile(samples), +} +``` + +- [ ] **Step 5: Write files in hash-safe order** + +`writeEvidenceBundle` writes, in order: + +1. `samples.jsonl` — one stable-key JSON object per line; +2. `summary.json` — only `recomputeSummary(samples)`; +3. `report.md` — generated from summary and interpretation; +4. `README.md` — reproduction command, redaction statement, limitations; +5. `manifest.json` — written last, with SHA-256 for the previous four files. + +The manifest schema is: + +```js +{ + schemaVersion: 1, + experimentId, + recordedAtUtc, + gitCommit, + command, + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + modelProvider: manifest.modelProvider ?? null, + model: manifest.model ?? null, + evidenceLabel, + sourceEvidence: manifest.sourceEvidence ?? null, + liveBudget: manifest.liveBudget ?? null, + configuration: sanitizedConfiguration, + files: { + 'samples.jsonl': { sha256, bytes }, + 'summary.json': { sha256, bytes }, + 'report.md': { sha256, bytes }, + 'README.md': { sha256, bytes }, + }, +} +``` + +`recordedAtUtc` is an ISO-8601 instant for a newly executed run. It may be +`null` only for a hash-locked historical import whose source recorded no +timestamp; that bundle must instead carry a date-only label and +`sourceTimestamp: "not-recorded"` in sanitized configuration. Never invent +midnight precision. + +For a live candidate, `liveBudget` is required and contains only the snapshot +path and SHA-256, exact `authorizationHash`, human cap, conservative estimate, +worst-case per-call amount, attempted-call count, known accrued cost, retained +reservation, and lock state. The verifier recomputes the authorization hash +from `configuration.sweepConfig` and the bytes parsed from the hash-verified +committed snapshot and requires an exact match. +`configuration.sweepConfig` must be the complete validated +`fixtures/sweep-v1.json` object—not a projection—because omitted publication +flags or seed fields would change what the human authorized. `sourceEvidence`, +when present for an imported +historical bundle, contains only `{ kind, sha256, bytes }`; paths are forbidden. +Serialize every micro-USD bigint as a base-10 string. The verifier hashes the +committed snapshot and requires it to match `snapshotSha256`; it also requires +the manifest attempted-call count to equal `samples.length`. + +Do not read `.env`; caller-supplied configuration is allow-listed to model name, +N values, replicate IDs, pair-order seeds, requested/applied distillation-seed +evidence, token caps, the committed pricing snapshot, evidence labels, and +acquisition treatment. + +- [ ] **Step 6: Implement strict verification** + +`verifyEvidenceBundle(dir)` must hash all four files, parse JSONL, recompute the summary byte-for-byte, and fail if: + +- a hash or byte count differs; +- a required file is absent; +- sample IDs repeat; +- a sample contains a forbidden field; +- `summary.json` differs from recomputation; +- `report.md` contains a numeric p50, p95, cost, or sample count that differs from `summary.json`. + +Create `scripts/verify-bundle.mjs` that prints +`` `PASS — ${manifest.experimentId} recomputes from ${samples.length} normalized samples.` `` +and exits 0, or prints the exact verifier error and exits 1. + +- [ ] **Step 7: Run evidence tests** + +Run: `cd spikes/clone-economics && node --test tests/evidence.test.mjs` + +Expected: PASS, 2 tests passed. + +- [ ] **Step 8: Connect sweep output to the bundle writer** + +After `runSweep` completes or invalidates the target, normalize all adapter +attempts and call `writeEvidenceBundle`. For live runs, add the committed budget +snapshot hash and final attempt-budget state to the manifest. Raw provider +responses and distilled Skill text remain under ignored `runs/`; the committed +candidate directory contains only allow-listed normalized rows. + +When a provider call throws, write its failed row before rethrowing or advancing to the next configured cell. Set report limitations to include incomplete costs whenever any attempted row has `providerCostUsd: null`. + +- [ ] **Step 9: Commit the evidence kernel** + +```bash +git add spikes/clone-economics/src/evidence.mjs spikes/clone-economics/scripts/verify-bundle.mjs spikes/clone-economics/tests/evidence.test.mjs spikes/clone-economics/src/sweep.mjs spikes/clone-economics/sweep.mjs +git commit -m "feat: write reproducible clone evidence bundles" +``` + +### Task 6: Import the 2026-07-12 run as invalid historical evidence + +**Files:** +- Create: `spikes/clone-economics/scripts/import-legacy-run.mjs` +- Create: `spikes/clone-economics/tests/import-legacy-run.test.mjs` +- Create: `spikes/clone-economics/evidence/2026-07-12-n6-invalid/manifest.json` +- Create: `spikes/clone-economics/evidence/2026-07-12-n6-invalid/samples.jsonl` +- Create: `spikes/clone-economics/evidence/2026-07-12-n6-invalid/summary.json` +- Create: `spikes/clone-economics/evidence/2026-07-12-n6-invalid/report.md` +- Create: `spikes/clone-economics/evidence/2026-07-12-n6-invalid/README.md` +- Modify: `spikes/clone-economics/README.md:89-121` +- Modify: `spikes/clone-economics/.gitignore` + +- [ ] **Step 1: Write a hash-locked legacy importer and offline tests** + +`scripts/import-legacy-run.mjs` must accept only named arguments +`--input`, `--expected-sha256`, and `--output`. Export these immutable source +facts: + +```js +export const LEGACY_SOURCE_SHA256 = + '0554779988164651bfe6b037c8b16054e009ee6bac76e61c90af331ac6e85212'; +export const LEGACY_SOURCE_BYTES = 76_631; +``` + +Before parsing JSON or creating the output directory, require the CLI's +`--expected-sha256` to equal `LEGACY_SOURCE_SHA256`, read the input as bytes, +require the exact byte count, hash those bytes with SHA-256, and require an +exact digest match. Do not infer or search for `runs/live/report.json`; the +input path must be explicit. Then parse the verified bytes and assert these +historical facts: + +```js +assert.equal(source.schemaVersion, 1); +assert.equal(source.mode, 'live'); +assert.equal(source.dataset.N, 6); +assert.equal(source.fidelity.target.absoluteScore, 0.4); +assert.equal(source.fidelity.target.criticalGatePass, false); +assert.equal(source.economics.acquisitionModeledUsd, 1.5); +``` + +Join each `source.usage.raw` record to the corresponding per-case fidelity result. Map `target-heldout`, `clone-heldout`, and `bad-clone-heldout` to profiles; keep acquisition and distillation rows without scores. The importer must never copy prompt payloads, target bytes, reference bytes, provider response text, or `distilled-raw.txt`. + +Export the pure `normalizeLegacyReport(source)` helper for tests. In +`tests/import-legacy-run.test.mjs`, cover the 29-row normalization with a +synthetic shape, all six historical assertions, forbidden-field absence, and +subprocess failure for (a) the wrong declared digest and (b) changed source +bytes. Both subprocess failures must occur before the requested output path is +created. The production CLI must also reject an output directory that already +exists rather than overwrite evidence. + +Call `writeEvidenceBundle` with: + +```js +{ + experimentId: '2026-07-12-n6-invalid', + recordedAtUtc: null, + gitCommit: 'historical-source-not-recorded', + command: 'historical live command not retained exactly', + modelProvider: 'Anthropic', + model: source.usage.raw[0]?.model ?? null, + evidenceLabel: 'HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED', + sourceEvidence: { + kind: 'legacy-report-json', + sha256: LEGACY_SOURCE_SHA256, + bytes: LEGACY_SOURCE_BYTES, + }, + configuration: { + historicalRunDate: '2026-07-12', + sourceTimestamp: 'not-recorded', + nValues: [6], + pairOrderSeeds: ['not-recorded'], + requestedDistillationSeeds: ['not-recorded'], + appliedDistillationSeeds: ['not-recorded'], + acquisitionTreatment: 'modeled', + attemptCoverage: 'successful fifth run only; four setup attempts have no normalized records', + }, +} +``` + +- [ ] **Step 2: Run importer tests and commit the importer kernel** + +Run: + +```bash +cd spikes/clone-economics +node --test tests/import-legacy-run.test.mjs +cd ../.. +git add spikes/clone-economics/scripts/import-legacy-run.mjs spikes/clone-economics/tests/import-legacy-run.test.mjs +git commit -m "feat: add hash-locked legacy evidence importer" +``` + +Expected: tests PASS, both wrong-input cases leave no output directory, and the +commit contains no raw report or generated evidence. + +- [ ] **Step 3: Generate from an explicit private copy in a clean worktree** + +The ignored report exists only in the primary checkout, but implementation may +be running in a linked worktree such as `.worktrees/adversarial-remediation`. +Capture that active implementation root before creating the detached audit +worktree. Treat the primary checkout's absolute report path as read-only source +material: copy its exact bytes to a mode-0600 temporary file, verify the copy +independently, and pass only the temporary copy to the committed importer. +Never `cd` to, generate into, stage from, or commit from the primary checkout: + +```bash +set -euo pipefail +implementation_root=$(git rev-parse --show-toplevel) +readonly implementation_root +readonly primary_checkout='/Users/antonyzaki/Documents/Repo/tokenized-assets' +readonly legacy_source="$primary_checkout/spikes/clone-economics/runs/live/report.json" + +test "$(git -C "$implementation_root" rev-parse --show-toplevel)" = "$implementation_root" +git -C "$primary_checkout" worktree list --porcelain \ + | sed -n 's/^worktree //p' \ + | rg -Fx -- "$implementation_root" +git -C "$primary_checkout" check-ignore -q -- spikes/clone-economics/runs/live/report.json +test -f "$legacy_source" +test ! -L "$legacy_source" +test -f "$implementation_root/spikes/clone-economics/scripts/import-legacy-run.mjs" + +audit_base=$(mktemp -d /private/tmp/tokenized-assets-legacy-audit.XXXXXX) +audit_worktree="$audit_base/worktree" +legacy_copy=$(mktemp /private/tmp/clone-economics-legacy.XXXXXX) +evidence_destination="$implementation_root/spikes/clone-economics/evidence/2026-07-12-n6-invalid" +case "$audit_base" in /private/tmp/tokenized-assets-legacy-audit.*) ;; *) exit 1 ;; esac +case "$audit_worktree" in "$audit_base"/worktree) ;; *) exit 1 ;; esac +case "$legacy_copy" in /private/tmp/clone-economics-legacy.*) ;; *) exit 1 ;; esac +case "$evidence_destination" in "$implementation_root"/*) ;; *) exit 1 ;; esac +test ! -e "$evidence_destination" + +git -C "$implementation_root" worktree add --detach "$audit_worktree" HEAD +install -m 600 "$legacy_source" "$legacy_copy" +test "$(stat -f '%Lp' "$legacy_copy")" = 600 +test "$(stat -f '%z' "$legacy_copy")" = 76631 +test "$(shasum -a 256 "$legacy_copy" | cut -d ' ' -f 1)" = 0554779988164651bfe6b037c8b16054e009ee6bac76e61c90af331ac6e85212 +cd "$audit_worktree/spikes/clone-economics" +env -u ANTHROPIC_API_KEY MOCK_LLM=1 ALLOW_LIVE_LLM=0 node scripts/import-legacy-run.mjs \ + --input "$legacy_copy" \ + --expected-sha256 0554779988164651bfe6b037c8b16054e009ee6bac76e61c90af331ac6e85212 \ + --output "$audit_base/evidence" +node scripts/verify-bundle.mjs "$audit_base/evidence" +``` + +Expected: the clean-worktree importer writes exactly five sanitized files, +reports 29 normalized samples, makes no provider call or x402 settlement, and +the manifest records the digest and byte count but no source path. + +Copy only the five verified public files to the captured implementation +worktree, then return there before removing the detached audit worktree. The +primary checkout remains a read-only source throughout: + +```bash +case "$evidence_destination" in "$implementation_root"/*) ;; *) exit 1 ;; esac +test ! -e "$evidence_destination" +install -d "$evidence_destination" +for name in manifest.json samples.jsonl summary.json report.md README.md; do + test -f "$audit_base/evidence/$name" + install -m 644 "$audit_base/evidence/$name" "$evidence_destination/$name" +done +cd "$implementation_root" +test "$(git rev-parse --show-toplevel)" = "$implementation_root" +git -C "$implementation_root" worktree remove "$audit_worktree" +case "$audit_base" in + /private/tmp/tokenized-assets-legacy-audit.*) rm -rf -- "$audit_base" ;; + *) echo 'Refusing unexpected temporary cleanup path' >&2; exit 1 ;; +esac +case "$legacy_copy" in + /private/tmp/clone-economics-legacy.*) rm -f -- "$legacy_copy" ;; + *) echo 'Refusing unexpected source-copy cleanup path' >&2; exit 1 ;; +esac +``` + +The guarded cleanup is required because the temporary source contains raw +provider output. If any earlier command fails, do not blindly rerun: inspect +the exact printed temporary paths, verify their prefixes, remove the worktree +with `git worktree remove`, then delete only those temporary paths. + +- [ ] **Step 4: Mark evidence bundles as trackable while raw runs stay ignored** + +Keep `runs/` in `.gitignore` and add explicit comments: + +```gitignore +# Raw provider artifacts can contain private output and always remain local. +runs/ + +# Sanitized evidence bundles under evidence/ are intentionally tracked. +``` + +Do not add an `evidence/` ignore rule. + +- [ ] **Step 5: Verify the bundle from its public seam** + +Run: `cd spikes/clone-economics && node scripts/verify-bundle.mjs evidence/2026-07-12-n6-invalid` + +Expected: `PASS — 2026-07-12-n6-invalid recomputes from 29 normalized samples.` + +- [ ] **Step 6: Scan the bundle for private fields and paths** + +Run: + +```bash +rg -n -i 'api[_-]?key|authorization|x-api-key|targetSkill|referenceText|distilled-raw|"prompt"|"payload"|"output"|/Users/|/private/tmp|runs/live' evidence/2026-07-12-n6-invalid +``` + +Expected: no matches. + +- [ ] **Step 7: Replace the README conclusion with the invalid historical verdict** + +Replace the existing measured-results section with: + +````markdown +## Historical live run — invalid benchmark (2026-07-12) + +The sanitized normalized evidence is committed at +`evidence/2026-07-12-n6-invalid/`. Provider execution and returned usage were +measured; the $1.50 acquisition component was modeled and no x402 acquisition +payment settled. + +The target scored 0.400 and failed its own critical gates. Therefore the run's +verdict is `INVALID_BENCHMARK_TARGET_FAILED`: clone quality, fidelity defense, +moat, retention, and break-even conclusions are suppressed. Four earlier setup +attempts were described historically but did not retain normalized attempt +records, so total attack cost is also incomplete. + +Verify the retained bundle offline: + +```bash +node scripts/verify-bundle.mjs evidence/2026-07-12-n6-invalid +``` + +No high-N conclusion exists. Only a valid target plus the preregistered +N=6/25/50/100 sweep, 30 held-out fixtures, and three live-adapter-confirmed, +independent distillation seeds at N=100 can produce a publishable high-N +result. Pair-order seeds alone do not establish independent model sampling. +```` + +- [ ] **Step 8: Commit the historical bundle** + +```bash +git add spikes/clone-economics/evidence/2026-07-12-n6-invalid spikes/clone-economics/README.md spikes/clone-economics/.gitignore +git commit -m "docs: preserve invalid clone run as reproducible evidence" +``` + +### Task 7: Document the safe operator flow and full offline suite + +**Files:** +- Modify: `spikes/clone-economics/RUNBOOK.md` +- Modify: `spikes/clone-economics/.env.example` +- Modify: `spikes/clone-economics/package.json` + +- [ ] **Step 1: Replace the old N≤6 runbook sweep** + +Document this sequence: + +````markdown +## High-N sweep: preflight first + +The preregistration is `fixtures/sweep-v1.json`: N=6,25,50,100; 30 held-out +fixtures; pair-order seeds 1701, 1702, and 1703; and distinct requested +distillation seeds 2701, 2702, and 2703. Pair-order seeds control only local +acquisition ordering. A high-N result is publishable only if a live adapter +reports all three requested distillation seeds as independently applied. The +current Anthropic adapter reports seed support as `unsupported`, so it may +produce explicitly uncontrolled evidence but cannot produce clone-fidelity, +defensibility, moat, break-even, or economics conclusions. + +```bash +npm run fixtures:check +npm run sweep:preflight +npm run sweep:mock +``` + +These commands use no key, network, x402 payment, or provider spend. + +## Human-authorized live gate + +The committed `fixtures/live-budget-v1.json` intentionally starts with +`approvalStatus: "not_approved"` and null model, pricing, and token caps. Before +a live run, a human must verify the provider's current official pricing, +replace every null with the selected model, decimal-string prices, timestamped +HTTPS source, and token caps, set `approvalStatus` to `approved`, review the +1,713-call conservative estimate from `npm run sweep:preflight`, and commit the +snapshot and unchanged `fixtures/sweep-v1.json`. The sweep ignores +environment-based model/pricing/token-cap values; the committed files are its +only execution contract. + +Run `npm run sweep:preflight` again from that exact commit. It prints a +`live authorization: sha256:...` digest over the complete sweep config and +approved budget snapshot. After reviewing the printed contract, the human +explicitly approves a maximum at or above the conservative estimate and copies +that exact digest: + +```bash +export APPROVE_LIVE_SWEEP_SHA256='sha256:' +export MAX_SWEEP_COST_USD="$HUMAN_APPROVED_MAX_SWEEP_COST_USD" +export ALLOW_LIVE_LLM=1 +``` + +Any change to N values, either seed family, model, prices, token caps, or the +approval snapshot changes the digest and invalidates the old authorization. + +Then, and only then, the operator may run: + +```bash +npm run sweep:live +``` + +The command writes raw private output only under ignored `runs/` and writes a +sanitized candidate bundle to a new dated directory selected by its generated +experiment identifier under `evidence/`. Never +overwrite a historical bundle. Review and verify the candidate before staging; +do not publish automatically. An `unsupported` distillation-seed result remains +useful only as `stochastic_uncontrolled` evidence and must retain all conclusion +suppressions. +```` + +- [ ] **Step 2: Add the approval variables to `.env.example`** + +Append: + +```dotenv +# High-N live sweep: set only after a human approves current pricing and spend. +# Model, pricing, and token caps come from committed fixtures/live-budget-v1.json. +APPROVE_LIVE_SWEEP_SHA256= +MAX_SWEEP_COST_USD= +ALLOW_LIVE_LLM=0 +``` + +- [ ] **Step 3: Run the complete offline suite** + +Run: + +```bash +cd spikes/clone-economics +npm test +npm run fixtures:check +npm run sweep:preflight +npm run sweep:mock +node scripts/verify-bundle.mjs evidence/2026-07-12-n6-invalid +``` + +Expected: every unit test and legacy e2e check passes; fixture check reports no +drift; preflight reports 100/30/12, 1,713 conservative requests, and the +committed budget as not approved; mock sweep makes zero network calls; the +historical bundle recomputes. + +- [ ] **Step 4: Prove live execution still fails closed by default** + +Run: + +```bash +cd spikes/clone-economics +env -u APPROVE_LIVE_SWEEP_SHA256 -u MAX_SWEEP_COST_USD -u ANTHROPIC_API_KEY MOCK_LLM=0 ALLOW_LIVE_LLM=0 npm run sweep:live +``` + +Expected: nonzero exit at the first gate containing `Live budget snapshot must +be approved`; no fetch/adapter factory runs and no evidence directory is +created. The synthetic `authorization.test.mjs` separately proves that an +approved snapshot with a missing, malformed, family-token, or stale +`APPROVE_LIVE_SWEEP_SHA256` also fails before provider construction. + +- [ ] **Step 5: Confirm no secret or raw provider artifact is tracked** + +Run: + +```bash +git ls-files | rg '(^|/)\.env$|runs/|distilled-raw|raw-provider' +``` + +Expected: no output. + +- [ ] **Step 6: Commit operator documentation** + +```bash +git add spikes/clone-economics/RUNBOOK.md spikes/clone-economics/.env.example spikes/clone-economics/package.json spikes/clone-economics/package-lock.json +git commit -m "docs: gate clone high-N provider spend" +``` + +- [ ] **Step 7: Report the remaining human gate exactly** + +```text +The offline clone benchmark, fixture, sweep, budget, and evidence paths are +ready. No high-N provider run was executed. Publication remains blocked until a +human commits a current approved pricing/token-cap snapshot, reviews and copies +the exact config-plus-snapshot authorization hash, approves a cap at or above +the conservative estimate, and runs the live sweep. The target must pass its +own gate; all three N=100 cells must complete with distinct requested seeds +confirmed as applied by a live adapter; and the sanitized bundle must verify +from a clean checkout. The current Anthropic adapter reports distillation seeds +as unsupported, so its run cannot clear the publication gate. +``` diff --git a/docs/superpowers/plans/2026-07-17-cogs-aware-execution.md b/docs/superpowers/plans/2026-07-17-cogs-aware-execution.md new file mode 100644 index 0000000..a069592 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-cogs-aware-execution.md @@ -0,0 +1,1625 @@ +# COGS-Aware Execution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Quote hosted-Skill Invocations from versioned provider pricing and model limits, record known or explicitly unresolved execution COGS, and finalize Royalty-claim credits only when actual COGS is known. + +**Architecture:** Add a versioned execution catalog and pure quote/accounting functions backed by `prototype/atomic-money.mjs`; the Collar validates model/token limits before offering x402, binds the quote to the Invocation, records provider usage after execution, and finalizes the authoritative journal receipt. A quote is accepted only when worst-case contribution margin is non-negative. Unknown usage makes execution terminal `failed/COGS_UNKNOWN`, preserves the settled payment, emits no output or Royalty credits, and puts full gross in one balanced reconciliation hold. + +**Tech Stack:** Node.js 20+, ECMAScript modules, built-in `node:test`, `node:assert/strict`, Hono, Anthropic Messages mock/live adapter, atomic USDC strings; automated verification is offline/mock and never performs a live provider call or a funded transaction. + +--- + +## Prerequisites and file map + +Complete the atomic-money, Collar-journal, and Wielder-policy plans first. + +- Create `spikes/pi-wielder/src/execution-economics.mjs`: immutable provider catalog, request limits, worst-case quote, actual-cost calculation, and final allocation. +- Create `spikes/pi-wielder/tests/execution-economics.test.mjs`: quote, margin, usage, unknown-cost, and exact allocation tests. +- Create `spikes/pi-wielder/tests/collar-cogs.test.mjs`: offline Collar quote-binding and receipt tests. +- Modify `spikes/pi-wielder/src/collar.mjs`: validate before payment, execute through an injected adapter, and finalize actual/unknown COGS in the journal. +- Modify `spikes/pi-wielder/src/invocation-journal.mjs`: accept the COGS/accounting terminal payload defined here without weakening earlier transitions. +- Modify `spikes/pi-wielder/src/x402-seller.mjs`: quote atomic amounts directly and preserve the quote identifier in payment requirements. +- Modify `spikes/pi-wielder/src/proxy.mjs`: bind policy authorization to the quote identifier. +- Modify `spikes/pi-wielder/e2e.mjs`: assert quote, COGS, margin, and post-cost Royalty credits. +- Modify `spikes/pi-wielder/README.md` and `spikes/pi-wielder/RUNBOOK.md`: replace gross-split language with implemented cost ordering and label historical n=48 overhead evidence unreproducible. +- Verify `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`: Plan 1 owns this tracked `historical_unreproducible` tombstone; this plan reads it and must not overwrite it or treat its p50/p95 as reproducible. +- Modify `spikes/pi-wielder/package.json`: add focused execution-economics tests. + +## Execution economics contract + +The first catalog is deliberately `synthetic_config`, not a claim about current live +provider prices. Before any live run, a human must verify and version the actual price +sheet. The mock rates are useful only to prove the accounting path: + +```js +EXECUTION_CATALOG = { + version: 'synthetic-anthropic-2026-07-17-v1', + evidenceLabel: 'synthetic_config', + models: { + 'claude-sonnet-4-6': { + provider: 'anthropic', + inputAtomicPerMillionTokens: '3000000', + outputAtomicPerMillionTokens: '15000000', + maxInputTokens: 16384, + maxOutputTokens: 2048, + }, + }, +}; +``` + +`contributionMarginAtomic` in this spike means the retained protocol-fee component +after execution COGS, settlement cost, refund reserve, and Royalty pool have each been +separately allocated. It does not include unmodeled company payroll or overhead. + +### Task 1: Build versioned quote and final-accounting functions + +**Files:** +- Create: `spikes/pi-wielder/src/execution-economics.mjs` +- Create: `spikes/pi-wielder/tests/execution-economics.test.mjs` + +- [ ] **Step 1: Write failing quote, COGS, and allocation tests** + +Create `spikes/pi-wielder/tests/execution-economics.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + artifactDigest, + assertFrozenExecutionIdentity, + assertLiveCatalogApproval, + catalogDigest, + conservativeProviderPromptBound, + createPendingExecutionAccounting, + createExecutionQuote, + EXECUTION_CATALOG, + ExecutionEconomicsError, + finalizeExecutionAccounting, + usageCostAtomic, +} from '../src/execution-economics.mjs'; + +const SKILL_ID = 'skill-a'; +const SKILL_VERSION = 'skill-a/2026-07-17-v1'; +const SKILL_ARTIFACT = 'system prompt artifact v1'; +const skills = { + [SKILL_ID]: { + parentIds: [], + inheritBps: 0, + holders: [{ recipientId: 'creator', bps: 10_000 }], + }, +}; + +const quote = (overrides = {}) => createExecutionQuote({ + grossAtomic: '250000', + model: 'claude-sonnet-4-6', + maxInputTokens: 16384, + maxOutputTokens: 2048, + promptBytes: 10_000, + estimatedInputTokens: 10_256, + settlementCostAtomic: '1000', + refundReserveAtomic: '5000', + protocolFeeBps: 250, + leafSkillId: SKILL_ID, + skillId: SKILL_ID, + skillVersion: SKILL_VERSION, + artifactHash: artifactDigest(SKILL_ARTIFACT), + skills, + ...overrides, +}); + +test('usageCostAtomic rounds each versioned provider charge upward', () => { + assert.equal(usageCostAtomic({ + model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42, + }), 756n); + assert.equal(usageCostAtomic({ + model: 'claude-sonnet-4-6', inputTokens: 1, outputTokens: 1, + }), 18n); +}); + +test('complete provider prompt bound is byte-conservative and rejects body/token overflow', () => { + assert.deepEqual(conservativeProviderPromptBound({ + systemPrompt: 'abc', userInput: 'é', requestBodyBytes: 100, + maxRequestBodyBytes: 4096, maxInputTokens: 300, + }), { promptBytes: 5, estimatedInputTokens: 261, requestBodyBytes: 100 }); + assert.throws(() => conservativeProviderPromptBound({ + systemPrompt: 'x', userInput: 'y', requestBodyBytes: 4097, + maxRequestBodyBytes: 4096, maxInputTokens: 300, + }), (error) => error.code === 'REQUEST_BODY_TOO_LARGE'); + assert.throws(() => conservativeProviderPromptBound({ + systemPrompt: 'x'.repeat(100), userInput: 'y', requestBodyBytes: 120, + maxRequestBodyBytes: 4096, maxInputTokens: 300, + }), (error) => error.code === 'PROMPT_TOKEN_BOUND'); +}); + +test('quote reserves worst-case COGS before fee and Royalty pool', () => { + const result = quote(); + assert.equal(result.catalogVersion, EXECUTION_CATALOG.version); + assert.equal(result.evidenceLabel, 'synthetic_config'); + assert.equal(result.worstCaseExecutionCostAtomic, '79872'); + assert.equal(result.protocolFeeAtomic, '6250'); + assert.equal(result.worstCaseRoyaltyPoolAtomic, '157878'); + assert.equal(result.worstCaseContributionMarginAtomic, '6250'); + assert.match(result.quoteId, /^sha256:[0-9a-f]{64}$/); +}); + +test('known usage charges actual COGS before increasing the Royalty pool', () => { + const result = finalizeExecutionAccounting({ + quote: quote(), + usage: { model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, + leafSkillId: SKILL_ID, + skills, + }); + assert.deepEqual(result.executionCogs, { + status: 'known', + actualAtomic: '756', + chargedAtomic: '756', + quotedWorstCaseAtomic: '79872', + catalogVersion: EXECUTION_CATALOG.version, + usage: { model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, + reason: null, + }); + assert.equal(result.royaltyPoolAtomic, '236994'); + assert.equal(result.protocolFeeAtomic, '6250'); + assert.equal(result.contributionMarginAtomic, '6250'); + assert.equal(result.journalEntries.reduce((sum, entry) => sum + BigInt(entry.amountAtomic), 0n), 250_000n); + assert.ok(result.journalEntries.every((entry) => entry.debitAccountId === 'wielder:external-gross')); +}); + +test('unknown usage fails closed into one full-gross hold and finalizes no Royalty claims', () => { + const result = finalizeExecutionAccounting({ + quote: quote(), + usage: null, + unknownReason: 'provider response omitted usage', + leafSkillId: SKILL_ID, + skills, + }); + assert.equal(result.executionCogs.status, 'unknown'); + assert.equal(result.executionCogs.actualAtomic, null); + assert.equal(result.executionCogs.chargedAtomic, null); + assert.equal(result.executionCogs.quotedWorstCaseAtomic, '79872'); + assert.equal(result.executionCogs.reason, 'provider response omitted usage'); + assert.equal(result.allocationState, 'pending_cogs_reconciliation'); + assert.equal(result.royaltyPoolAtomic, '0'); + assert.deepEqual(result.holderCredits, []); + assert.deepEqual(result.ancestorCredits, []); + assert.deepEqual(result.journalEntries, [{ + category: 'unresolved-execution-accounting', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'hold:execution-accounting-reconciliation', + amountAtomic: '250000', + }]); +}); + +test('quotes fail before payment when caps or worst-case economics are invalid', () => { + assert.throws(() => quote({ model: 'unlisted-model' }), (error) => ( + error instanceof ExecutionEconomicsError && error.code === 'MODEL_NOT_ALLOWED' + )); + assert.throws(() => quote({ maxOutputTokens: 2049 }), (error) => error.code === 'TOKEN_LIMIT'); + assert.throws(() => quote({ grossAtomic: '50000' }), (error) => error.code === 'NEGATIVE_WORST_CASE_MARGIN'); +}); + +test('provider usage above the accepted quote fails product acceptance', () => { + assert.throws(() => finalizeExecutionAccounting({ + quote: quote(), + usage: { model: 'claude-sonnet-4-6', inputTokens: 4096, outputTokens: 2049 }, + leafSkillId: SKILL_ID, + skills, + }), (error) => error.code === 'USAGE_EXCEEDS_QUOTE'); +}); + +test('post-provider overrun accounting records known accrued COGS but finalizes no claims', () => { + const result = createPendingExecutionAccounting({ + quote: quote(), + usage: { model: 'claude-sonnet-4-6', inputTokens: 16384, outputTokens: 2049 }, + failureClass: 'USAGE_EXCEEDS_QUOTE', + reason: 'provider exceeded frozen output cap', + }); + assert.equal(result.allocationState, 'pending_cogs_reconciliation'); + assert.equal(result.executionCogs.status, 'known'); + assert.equal(result.executionCogs.actualAtomic, '79887'); + assert.equal(result.executionCogs.accruedOverrunAtomic, '15'); + assert.equal(result.royaltyPoolAtomic, '0'); + assert.deepEqual(result.holderCredits, []); + assert.deepEqual(result.ancestorCredits, []); + assert.equal(result.journalEntries[0].amountAtomic, result.grossAtomic); +}); + +test('live approval binds a separately supplied exact catalog digest and spend cap', () => { + const verifiedCatalog = structuredClone(EXECUTION_CATALOG); + verifiedCatalog.evidenceLabel = 'human_verified'; + verifiedCatalog.source = 'https://provider.example/pricing/2026-07-17'; + verifiedCatalog.asOf = '2026-07-17T00:00:00.000Z'; + const approval = { catalogDigest: catalogDigest(verifiedCatalog), spendCapAtomic: '250000' }; + assert.doesNotThrow(() => assertLiveCatalogApproval({ catalog: verifiedCatalog, approval, grossAtomic: '250000' })); + const mutated = structuredClone(verifiedCatalog); + mutated.models['claude-sonnet-4-6'].outputAtomicPerMillionTokens = '15000001'; + assert.throws(() => assertLiveCatalogApproval({ catalog: mutated, approval, grossAtomic: '250000' }), + (error) => error.code === 'LIVE_CATALOG_DIGEST'); + assert.throws(() => assertLiveCatalogApproval({ + catalog: verifiedCatalog, + approval: { ...approval, spendCapAtomic: '249999' }, + grossAtomic: '250000', + }), (error) => error.code === 'LIVE_SPEND_CAP'); +}); + +test('quote ID freezes Skill, artifact, Royalty graph, and catalog identity', () => { + const frozen = quote(); + assert.deepEqual({ + skillId: frozen.skillId, + skillVersion: frozen.skillVersion, + artifactHash: frozen.artifactHash, + }, { skillId: SKILL_ID, skillVersion: SKILL_VERSION, artifactHash: artifactDigest(SKILL_ARTIFACT) }); + assert.doesNotThrow(() => assertFrozenExecutionIdentity({ + quote: frozen, skillId: SKILL_ID, skillVersion: SKILL_VERSION, + artifactContent: SKILL_ARTIFACT, skills, catalog: EXECUTION_CATALOG, + })); + for (const [expectedCode, overrides] of [ + ['SKILL_IDENTITY_DRIFT', { skillVersion: `${SKILL_VERSION}-changed` }], + ['ARTIFACT_DRIFT', { artifactContent: `${SKILL_ARTIFACT}\nchanged` }], + ['ROYALTY_GRAPH_DRIFT', { skills: { ...skills, extra: { parentIds: [], inheritBps: 0, holders: [] } } }], + ['CATALOG_DIGEST_DRIFT', { catalog: { ...EXECUTION_CATALOG, version: 'changed' } }], + ]) { + assert.throws(() => assertFrozenExecutionIdentity({ + quote: frozen, skillId: SKILL_ID, skillVersion: SKILL_VERSION, + artifactContent: SKILL_ARTIFACT, skills, catalog: EXECUTION_CATALOG, ...overrides, + }), (error) => error.code === expectedCode); + } + const driftedCatalog = structuredClone(EXECUTION_CATALOG); + driftedCatalog.version = 'drifted-current-config'; + const pending = createPendingExecutionAccounting({ + quote: frozen, usage: null, failureClass: 'CATALOG_DIGEST_DRIFT', + reason: 'current catalog changed', catalog: driftedCatalog, + }); + assert.equal(pending.executionCogs.catalogVersion, frozen.catalogVersion); + assert.equal(pending.executionCogs.catalogDigest, frozen.catalogDigest); +}); +``` + +- [ ] **Step 2: Run the test and verify the module is missing** + +Run: `node --test spikes/pi-wielder/tests/execution-economics.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/execution-economics.mjs`. + +- [ ] **Step 3: Implement the pure execution-economics module** + +Create `spikes/pi-wielder/src/execution-economics.mjs`: + +```js +import crypto from 'node:crypto'; + +import { allocateExternalGross } from '../../../prototype/atomic-money.mjs'; + +export class ExecutionEconomicsError extends Error { + constructor(code, message) { + super(message); + this.name = 'ExecutionEconomicsError'; + this.code = code; + } +} + +const fail = (code, message) => { throw new ExecutionEconomicsError(code, message); }; + +export const EXECUTION_CATALOG = Object.freeze({ + version: 'synthetic-anthropic-2026-07-17-v1', + evidenceLabel: 'synthetic_config', + source: null, + asOf: null, + models: Object.freeze({ + 'claude-sonnet-4-6': Object.freeze({ + provider: 'anthropic', + inputAtomicPerMillionTokens: '3000000', + outputAtomicPerMillionTokens: '15000000', + maxInputTokens: 16384, + maxOutputTokens: 2048, + }), + }), +}); + +function integer(value, label) { + if (!Number.isSafeInteger(value) || value < 0) fail('TOKEN_INTEGER', `${label} must be a non-negative safe integer`); + return BigInt(value); +} + +function atomic(value, label) { + const text = String(value ?? ''); + if (!/^(0|[1-9]\d*)$/.test(text)) fail('ATOMIC_FORMAT', `${label} must be a canonical atomic string`); + return BigInt(text); +} + +const ceilDiv = (numerator, denominator) => (numerator + denominator - 1n) / denominator; + +function modelPolicy(model, catalog = EXECUTION_CATALOG) { + const policy = catalog.models[model]; + if (!policy) fail('MODEL_NOT_ALLOWED', `model '${model}' is not in pricing catalog '${catalog.version}'`); + return policy; +} + +export function usageCostAtomic({ model, inputTokens, outputTokens }, catalog = EXECUTION_CATALOG) { + const policy = modelPolicy(model, catalog); + const input = ceilDiv( + integer(inputTokens, 'inputTokens') * atomic(policy.inputAtomicPerMillionTokens, 'input rate'), + 1_000_000n, + ); + const output = ceilDiv( + integer(outputTokens, 'outputTokens') * atomic(policy.outputAtomicPerMillionTokens, 'output rate'), + 1_000_000n, + ); + return input + output; +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); + } + return value; +} +const hash = (value) => `sha256:${crypto.createHash('sha256') + .update(JSON.stringify(canonicalize(value))) + .digest('hex')}`; + +export const artifactDigest = (content) => `sha256:${crypto.createHash('sha256') + .update(String(content)) + .digest('hex')}`; +export const royaltyGraphDigest = (skills) => hash(skills); + +export function catalogDigest(catalog) { + const { approval: ignoredApproval, ...catalogBody } = catalog; + return hash(catalogBody); +} + +export function assertLiveCatalogApproval({ catalog, approval, grossAtomic }) { + if (catalog?.evidenceLabel !== 'human_verified' + || typeof catalog.source !== 'string' || !catalog.source + || !Number.isFinite(Date.parse(catalog.asOf))) { + fail('LIVE_CATALOG_EVIDENCE', 'live catalog requires human_verified evidence, source, and as-of'); + } + if (!approval || typeof approval !== 'object' || Array.isArray(approval) + || Object.keys(approval).sort().join(',') !== 'catalogDigest,spendCapAtomic') { + fail('LIVE_APPROVAL_SHAPE', 'live approval must contain exactly catalogDigest and spendCapAtomic'); + } + const recomputed = catalogDigest(catalog); + if (approval.catalogDigest !== recomputed) fail('LIVE_CATALOG_DIGEST', 'human-approved digest does not match canonical catalog content'); + const cap = atomic(approval.spendCapAtomic, 'live spend cap'); + const gross = atomic(grossAtomic, 'grossAtomic'); + if (gross > cap) fail('LIVE_SPEND_CAP', 'Invocation gross exceeds the separately approved spend cap'); + return { catalogDigest: recomputed, spendCapAtomic: cap.toString() }; +} + +export function assertFrozenExecutionIdentity({ + quote, skillId, skillVersion, artifactContent, skills, catalog, +}) { + if (quote.skillId !== skillId || quote.skillVersion !== skillVersion) { + fail('SKILL_IDENTITY_DRIFT', 'current Skill identity differs from the accepted quote'); + } + if (quote.artifactHash !== artifactDigest(artifactContent)) { + fail('ARTIFACT_DRIFT', 'current hosted Skill bytes differ from the accepted quote'); + } + if (quote.royaltyGraphDigest !== royaltyGraphDigest(skills)) { + fail('ROYALTY_GRAPH_DRIFT', 'current Royalty graph differs from the accepted quote'); + } + if (quote.catalogDigest !== catalogDigest(catalog)) { + fail('CATALOG_DIGEST_DRIFT', 'current pricing catalog differs from the accepted quote'); + } + return true; +} + +const PROVIDER_FRAMING_TOKEN_ALLOWANCE = 256; + +export function conservativeProviderPromptBound({ + systemPrompt, + userInput, + requestBodyBytes, + maxRequestBodyBytes, + maxInputTokens, +}) { + for (const [label, value] of Object.entries({ requestBodyBytes, maxRequestBodyBytes, maxInputTokens })) { + if (!Number.isSafeInteger(value) || value < 0) fail('PROMPT_BOUND_INTEGER', `${label} must be a non-negative safe integer`); + } + if (requestBodyBytes > maxRequestBodyBytes) fail('REQUEST_BODY_TOO_LARGE', 'request body exceeds the pre-payment byte cap'); + const promptBytes = Buffer.byteLength(String(systemPrompt), 'utf8') + + Buffer.byteLength(String(userInput), 'utf8'); + const estimatedInputTokens = promptBytes + PROVIDER_FRAMING_TOKEN_ALLOWANCE; + if (estimatedInputTokens > maxInputTokens) { + fail('PROMPT_TOKEN_BOUND', 'complete provider prompt exceeds the frozen conservative input-token cap'); + } + return { promptBytes, estimatedInputTokens, requestBodyBytes }; +} + +function serializeAllocation(allocation) { + return { + grossAtomic: allocation.grossAtomic.toString(), + executionCostAtomic: allocation.executionCostAtomic.toString(), + settlementCostAtomic: allocation.settlementCostAtomic.toString(), + protocolFeeAtomic: allocation.protocolFeeAtomic.toString(), + royaltyPoolAtomic: allocation.royaltyPoolAtomic.toString(), + refundReserveAtomic: allocation.refundReserveAtomic.toString(), + holderCredits: allocation.holderCredits.map((credit) => ({ ...credit, amountAtomic: credit.amountAtomic.toString() })), + ancestorCredits: allocation.ancestorCredits.map((credit) => ({ ...credit, amountAtomic: credit.amountAtomic.toString() })), + journalEntries: allocation.journalEntries.map((entry) => ({ ...entry, amountAtomic: entry.amountAtomic.toString() })), + }; +} + +export function createExecutionQuote({ + grossAtomic, + model, + maxInputTokens, + maxOutputTokens, + promptBytes, + estimatedInputTokens, + settlementCostAtomic, + refundReserveAtomic, + protocolFeeBps, + leafSkillId, + skillId, + skillVersion, + artifactHash, + skills, + catalog = EXECUTION_CATALOG, +}) { + const policy = modelPolicy(model, catalog); + if (skillId !== leafSkillId || typeof skillVersion !== 'string' || !skillVersion + || !/^sha256:[0-9a-f]{64}$/.test(artifactHash)) { + fail('EXECUTION_IDENTITY', 'quote requires exact Skill id/version and lowercase artifact hash'); + } + if (!Number.isSafeInteger(maxInputTokens) || maxInputTokens < 0 + || !Number.isSafeInteger(maxOutputTokens) || maxOutputTokens < 1 + || maxInputTokens > policy.maxInputTokens || maxOutputTokens > policy.maxOutputTokens) { + fail('TOKEN_LIMIT', `requested token limits exceed catalog policy for '${model}'`); + } + if (!Number.isSafeInteger(promptBytes) || promptBytes < 0 + || !Number.isSafeInteger(estimatedInputTokens) || estimatedInputTokens < promptBytes + || estimatedInputTokens > maxInputTokens) { + fail('PROMPT_TOKEN_BOUND', 'quote prompt bounds must fit the accepted maxInputTokens'); + } + const worstCaseExecutionCost = usageCostAtomic({ model, inputTokens: maxInputTokens, outputTokens: maxOutputTokens }, catalog); + let allocation; + try { + allocation = allocateExternalGross({ + grossAtomic: atomic(grossAtomic, 'grossAtomic'), + executionCostAtomic: worstCaseExecutionCost, + settlementCostAtomic: atomic(settlementCostAtomic, 'settlementCostAtomic'), + protocolFeeBps, + refundReserveAtomic: atomic(refundReserveAtomic, 'refundReserveAtomic'), + leafSkillId, + skills, + }); + } catch (error) { + fail('NEGATIVE_WORST_CASE_MARGIN', `quote cannot cover worst-case costs: ${error.message}`); + } + const body = { + catalogVersion: catalog.version, + evidenceLabel: catalog.evidenceLabel, + skillId, + skillVersion, + artifactHash, + royaltyGraphDigest: royaltyGraphDigest(skills), + catalogDigest: catalogDigest(catalog), + model, + maxInputTokens, + maxOutputTokens, + promptBytes, + estimatedInputTokens, + grossAtomic: allocation.grossAtomic.toString(), + worstCaseExecutionCostAtomic: worstCaseExecutionCost.toString(), + settlementCostAtomic: allocation.settlementCostAtomic.toString(), + refundReserveAtomic: allocation.refundReserveAtomic.toString(), + protocolFeeBps, + protocolFeeAtomic: allocation.protocolFeeAtomic.toString(), + worstCaseRoyaltyPoolAtomic: allocation.royaltyPoolAtomic.toString(), + worstCaseContributionMarginAtomic: allocation.protocolFeeAtomic.toString(), + }; + return { quoteId: hash(body), ...body }; +} + +export function createPendingExecutionAccounting({ + quote, + usage = null, + failureClass, + reason, + catalog = EXECUTION_CATALOG, +}) { + let actual = null; + let normalizedUsage = null; + try { + if (usage && typeof usage === 'object' + && typeof usage.model === 'string' + && Number.isSafeInteger(usage.inputTokens) && usage.inputTokens >= 0 + && Number.isSafeInteger(usage.outputTokens) && usage.outputTokens >= 0) { + actual = usageCostAtomic(usage, catalog); + normalizedUsage = { model: usage.model, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }; + } + } catch { + actual = null; + normalizedUsage = null; + } + const quotedWorstCase = BigInt(quote.worstCaseExecutionCostAtomic); + const overrun = actual != null && actual > quotedWorstCase ? actual - quotedWorstCase : 0n; + return { + quoteId: quote.quoteId, + grossAtomic: quote.grossAtomic, + executionCostAtomic: '0', + settlementCostAtomic: '0', + protocolFeeAtomic: '0', + royaltyPoolAtomic: '0', + refundReserveAtomic: '0', + contributionMarginAtomic: '0', + allocationState: 'pending_cogs_reconciliation', + holderCredits: [], + ancestorCredits: [], + journalEntries: [{ + category: 'unresolved-execution-accounting', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'hold:execution-accounting-reconciliation', + amountAtomic: quote.grossAtomic, + }], + executionCogs: { + status: actual == null ? 'unknown' : 'known', + actualAtomic: actual?.toString() ?? null, + chargedAtomic: null, + quotedWorstCaseAtomic: quote.worstCaseExecutionCostAtomic, + accruedOverrunAtomic: overrun.toString(), + // The hold describes the accepted frozen quote. A drifted current catalog may be + // supplied only to classify valid usage; it must never relabel the receipt. + catalogVersion: quote.catalogVersion, + catalogDigest: quote.catalogDigest, + usage: normalizedUsage, + failureClass: String(failureClass), + reason: String(reason), + }, + }; +} + +export function finalizeExecutionAccounting({ + quote, + usage, + unknownReason = 'provider usage unavailable', + leafSkillId, + skills, + catalog = EXECUTION_CATALOG, +}) { + if (quote.catalogVersion !== catalog.version) fail('CATALOG_VERSION', 'quote pricing version is not loaded'); + if (usage == null) { + return createPendingExecutionAccounting({ + quote, + usage: null, + failureClass: 'COGS_UNKNOWN', + reason: unknownReason, + catalog, + }); + } + let actual = null; + if (usage.model !== quote.model + || usage.inputTokens > quote.maxInputTokens + || usage.outputTokens > quote.maxOutputTokens) { + fail('USAGE_EXCEEDS_QUOTE', 'provider usage exceeds the accepted model or token limits'); + } + actual = usageCostAtomic(usage, catalog); + if (actual > BigInt(quote.worstCaseExecutionCostAtomic)) { + fail('COGS_EXCEEDS_QUOTE', 'actual provider COGS exceeds the accepted reserve'); + } + const charged = actual; + let allocation; + try { + allocation = allocateExternalGross({ + grossAtomic: BigInt(quote.grossAtomic), + executionCostAtomic: charged, + settlementCostAtomic: BigInt(quote.settlementCostAtomic), + protocolFeeBps: quote.protocolFeeBps, + refundReserveAtomic: BigInt(quote.refundReserveAtomic), + leafSkillId, + skills, + }); + } catch (error) { + fail('NEGATIVE_CONTRIBUTION_MARGIN', `actual execution economics do not conserve: ${error.message}`); + } + const serialized = serializeAllocation(allocation); + return { + ...serialized, + quoteId: quote.quoteId, + allocationState: 'finalized', + contributionMarginAtomic: serialized.protocolFeeAtomic, + executionCogs: { + status: 'known', + actualAtomic: actual.toString(), + chargedAtomic: charged.toString(), + quotedWorstCaseAtomic: quote.worstCaseExecutionCostAtomic, + catalogVersion: catalog.version, + usage: usage ?? null, + reason: null, + }, + }; +} +``` + +- [ ] **Step 4: Run the focused economics tests** + +Run: `node --test spikes/pi-wielder/tests/execution-economics.test.mjs` + +Expected: PASS, 10 tests and 0 failures. + +- [ ] **Step 5: Commit quote and final-accounting functions** + +```bash +git add spikes/pi-wielder/src/execution-economics.mjs spikes/pi-wielder/tests/execution-economics.test.mjs +git commit -m "feat: quote hosted Skill execution costs" +``` + +### Task 2: Enforce the artifact serialization boundary without promising extraction-proof output + +**Files:** +- Create: `spikes/pi-wielder/src/artifact-boundary.mjs` +- Create: `spikes/pi-wielder/tests/artifact-boundary.test.mjs` + +- [ ] **Step 1: Write failing direct-serialization tests** + +Create `spikes/pi-wielder/tests/artifact-boundary.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { assertArtifactNotSerialized } from '../src/artifact-boundary.mjs'; + +const artifact = 'A'.repeat(220) + '\nSECRET-RULE\n' + 'B'.repeat(220); + +test('rejects the full artifact and long exact boundary fragments', () => { + assert.throws(() => assertArtifactNotSerialized({ output: artifact, artifact }), /direct artifact serialization/); + assert.throws(() => assertArtifactNotSerialized({ output: artifact.slice(0, 220), artifact }), /direct artifact serialization/); + assert.throws(() => assertArtifactNotSerialized({ output: artifact.slice(-220), artifact }), /direct artifact serialization/); +}); + +test('permits ordinary derived output and states the limit of the check', () => { + assert.equal(assertArtifactNotSerialized({ + output: 'A concise optimized prompt derived from the Skill behavior.', artifact, + }), true); +}); +``` + +- [ ] **Step 2: Run the test and verify the boundary module is missing** + +Run: `node --test spikes/pi-wielder/tests/artifact-boundary.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement the narrow serialization guard** + +Create `spikes/pi-wielder/src/artifact-boundary.mjs`: + +```js +export function assertArtifactNotSerialized({ output, artifact }) { + const result = String(output ?? ''); + const source = String(artifact ?? ''); + const fragments = source.length >= 400 + ? [source, source.slice(0, 200), source.slice(-200)] + : [source]; + if (fragments.some((fragment) => fragment.length > 0 && result.includes(fragment))) { + throw new Error('direct artifact serialization detected in model output'); + } + return true; +} +``` + +- [ ] **Step 4: Run the boundary tests** + +Run: `node --test spikes/pi-wielder/tests/artifact-boundary.test.mjs` + +Expected: PASS, 2 tests and 0 failures. + +- [ ] **Step 5: Commit the direct-serialization guard** + +```bash +git add spikes/pi-wielder/src/artifact-boundary.mjs spikes/pi-wielder/tests/artifact-boundary.test.mjs +git commit -m "feat: detect direct Skill serialization" +``` + +### Task 3: Carry one execution quote through x402 and the journal + +**Files:** +- Modify: `spikes/pi-wielder/src/x402-seller.mjs` +- Modify: `spikes/pi-wielder/src/invocation-journal.mjs` +- Modify: `spikes/pi-wielder/tests/x402-lifecycle.test.mjs` + +- [ ] **Step 1: Add a failing quote-binding assertion to the x402 lifecycle test** + +In `spikes/pi-wielder/tests/x402-lifecycle.test.mjs`, define: + +```js + const executionQuote = { + quoteId: `sha256:${'7'.repeat(64)}`, + grossAtomic: '250000', + model: 'claude-sonnet-4-6', + maxInputTokens: 16384, + maxOutputTokens: 2048, + }; +``` + +Pass this option to `x402Paywall`: + +```js + quote: async () => executionQuote, +``` + +Append these assertions after the existing frozen-requirements check: + +```js + assert.equal(calls[0][1].requirements.extra.quoteId, executionQuote.quoteId); + assert.deepEqual(calls[0][1].executionQuote, executionQuote); + assert.deepEqual(calls[1][1].executionQuote, executionQuote); +``` + +- [ ] **Step 2: Run the lifecycle test and verify execution quote data is absent** + +Run: `node --test spikes/pi-wielder/tests/x402-lifecycle.test.mjs` + +Expected: FAIL because the paywall ignores `quote` and lifecycle hooks do not carry it. + +- [ ] **Step 3: Extend the journal quote schema without changing lifecycle states** + +In `offerExternalPayment` inside `invocation-journal.mjs`, add this final quote field: + +```js + executionQuote: input.executionQuote == null ? null : copy(input.executionQuote), +``` + +Also add `'executionQuote'` to Plan 5's strict `validateQuote` exact-key list and require +`quote.executionQuote.quoteId === quote.quoteId` whenever it is non-null. This keeps +live append and signed replay on the same strict schema. + +The existing canonical quote comparison makes a changed execution quote under the same +idempotency key fail closed. Because `quote` is part of the signed receipt, no separate +receipt-schema change is necessary. + +- [ ] **Step 4: Add an optional execution-quote provider to `x402Paywall`** + +Add `quote = null` to the paywall options. In the Plan-5 frozen-offer block, change the +cache value from `requirements` to: + +```js +{ requirements, executionQuote } +``` + +Use this exact initial-quote branch: + +```js + let executionQuote = null; + try { + executionQuote = quote ? await quote(c) : null; + } catch (error) { + const status = error.code === 'REQUEST_BODY_TOO_LARGE' ? 413 : 400; + return c.json({ error: error.message, code: error.code ?? 'QUOTE_REJECTED' }, status); + } + const priceUsdc = executionQuote == null + ? (typeof price === 'function' ? await price(c) : price) + : null; + const amountAtomic = executionQuote == null + ? usdcToAtomic(priceUsdc) + : String(executionQuote.grossAtomic); +``` + +Set `base.maxAmountRequired` to `amountAtomic`. Choose the quote ID as: + +```js + const quoteId = executionQuote?.quoteId ?? `sha256:${crypto.createHash('sha256') + .update(JSON.stringify({ ...base, requestHash, issuedAt, expiresAt })) + .digest('hex')}`; +``` + +Store and read the cache as: + +```js + frozenOffers.set(idempotencyKey, { requirements, executionQuote }); +``` + +```js + const frozen = frozenOffers.get(idempotencyKey); + let requirements = frozen?.requirements; + let executionQuote = frozen?.executionQuote ?? null; +``` + +Update the restart recovery branch to expect the journal-backed lifecycle hook to return +the same pair and cache it without recomputation: + +```js + const recovered = await lifecycle.loadFrozenOffer?.({ idempotencyKey }); + if (recovered) frozenOffers.set(idempotencyKey, structuredClone(recovered)); +``` + +Pass `executionQuote` through `onOffered`, `onSigned`, and `onSettled`, and include it +in `c.set('x402', ...)`. Do not recompute it on a paid retry. + +- [ ] **Step 5: Bind the execution quote in the Collar offer hook** + +Change the Collar lifecycle signature and journal call to: + +```js + async onOffered({ idempotencyKey, requirements, expiresAt, executionQuote }) { +``` + +```js + journal.offerExternalPayment(idempotencyKey, { + quoteId: requirements.extra.quoteId, + amountAtomic: requirements.maxAmountRequired, + currency: 'USDC', + network: requirements.network, + asset: requirements.asset, + payTo: requirements.payTo, + resource: requirements.resource, + requestHash: requirements.extra.requestHash, + requirementsHash: hash(canonicalJson(requirements)), + expiresAt, + executionQuote, + }); +``` + +The x402 `quoteId`, journal quote ID, and execution quote ID are now identical. + +Change the Collar `loadFrozenOffer` hook to return the complete pair: + +```js +const persistedQuote = journal.getByIdempotencyKey(idempotencyKey)?.quote; +return persistedQuote + ? { requirements: persistedQuote.requirements, executionQuote: persistedQuote.executionQuote } + : null; +``` + +- [ ] **Step 6: Run journal and lifecycle tests** + +Run: `node --test spikes/pi-wielder/tests/invocation-journal.test.mjs spikes/pi-wielder/tests/x402-lifecycle.test.mjs` + +Expected: PASS; the same quote object and byte-identical PaymentRequirements appear on +challenge and retry. + +- [ ] **Step 7: Commit quote propagation** + +```bash +git add spikes/pi-wielder/src/x402-seller.mjs spikes/pi-wielder/src/invocation-journal.mjs spikes/pi-wielder/tests/x402-lifecycle.test.mjs +git commit -m "feat: bind execution quote to x402 payment" +``` + +### Task 4: Finalize actual-or-unknown COGS in Collar receipts + +**Files:** +- Modify: `spikes/pi-wielder/src/collar.mjs` +- Create: `spikes/pi-wielder/tests/collar-cogs.test.mjs` + +- [ ] **Step 1: Write failing Collar economics integration tests** + +Create `spikes/pi-wielder/tests/collar-cogs.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createAnthropicExecutor, createCollar, startCollar, SKILL_ID } from '../src/collar.mjs'; +import { catalogDigest, EXECUTION_CATALOG } from '../src/execution-economics.mjs'; +import { createMockFacilitator } from '../src/facilitator-mock.mjs'; +import { createInvocationJournal, createReceiptSigner } from '../src/invocation-journal.mjs'; +import { payingFetch, startProxy } from '../src/proxy.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; +import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; + +async function stack(collarOptions = {}) { + const facilitatorApp = createMockFacilitator(); + const facilitator = { + transport: createMockFacilitatorTransport((url, init) => facilitatorApp.request(url, init)), + close() {}, + }; + const collar = await startCollar({ facilitatorTransport: facilitator.transport, ...collarOptions }); + const proxy = await startProxy({ + account: throwawayAccount(), + collarUrl: collar.url, + trustedCollarPublicKeyPem: collar.signingPublicKeyPem, + trustedCollarKeyId: collar.signingKeyId, + }); + return { facilitator, collar, proxy }; +} + +async function invoke(proxy, execution = {}) { + const res = await fetch(`${proxy.url}/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'optimize this prompt', execution }), + }); + return { res, body: await res.json() }; +} + +test('known provider usage is charged before the Royalty pool', async () => { + const services = await stack(); + try { + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 200); + const accounting = body.receipt.receipt.accounting; + assert.equal(accounting.executionCogs.status, 'known'); + assert.equal(accounting.executionCogs.actualAtomic, '756'); + assert.equal(accounting.executionCostAtomic, '756'); + assert.equal(accounting.royaltyPoolAtomic, '236994'); + assert.equal(accounting.protocolFeeAtomic, '6250'); + assert.equal(accounting.contributionMarginAtomic, '6250'); + assert.equal(body.receipt.receipt.quote.executionQuote.quoteId, accounting.quoteId); + } finally { + services.proxy.close(); services.collar.close(); services.facilitator.close(); + } +}); + +test('missing usage fails settled execution, emits no output, and holds the full gross', async () => { + const services = await stack({ + executeSkill: async () => ({ output: 'safe output', usage: null }), + }); + try { + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 500); + assert.equal(body.output, undefined); + assert.equal(body.receipt.receipt.execution.failureClass, 'COGS_UNKNOWN'); + const accounting = body.receipt.receipt.accounting; + const cogs = accounting.executionCogs; + assert.equal(cogs.status, 'unknown'); + assert.equal(cogs.actualAtomic, null); + assert.equal(cogs.chargedAtomic, null); + assert.equal(cogs.quotedWorstCaseAtomic, '79872'); + assert.equal(accounting.royaltyPoolAtomic, '0'); + assert.deepEqual(accounting.holderCredits, []); + assert.deepEqual(accounting.ancestorCredits, []); + assert.equal(accounting.journalEntries[0].amountAtomic, accounting.grossAtomic); + } finally { + services.proxy.close(); services.collar.close(); services.facilitator.close(); + } +}); + +test('synthetic pricing blocks live adapter construction even when live mode is requested', () => { + let constructions = 0; + assert.throws(() => createCollar({ + facilitatorTransport: createMockFacilitatorTransport(async () => { throw new Error('must not fetch'); }), + mockLlm: false, + allowLiveProvider: true, + liveExecutorFactory: () => { constructions += 1; return async () => ({ output: '', usage: null }); }, + }), (error) => error.code === 'LIVE_PRICING_UNAPPROVED'); + assert.equal(constructions, 0); +}); + +test('live approval is rechecked against the canonical catalog before adapter construction', () => { + const catalog = structuredClone(EXECUTION_CATALOG); + Object.assign(catalog, { + evidenceLabel: 'human_verified', + source: 'https://provider.example/pricing/2026-07-17', + asOf: '2026-07-17T00:00:00.000Z', + }); + const liveApproval = { catalogDigest: catalogDigest(catalog), spendCapAtomic: '250000' }; + catalog.models['claude-sonnet-4-6'].outputAtomicPerMillionTokens = '15000001'; + let constructions = 0; + assert.throws(() => createCollar({ + facilitatorTransport: createMockFacilitatorTransport(async () => { throw new Error('must not fetch'); }), + mockLlm: false, allowLiveProvider: true, + executionCatalog: catalog, liveApproval, + liveExecutorFactory: () => { constructions += 1; return async () => {}; }, + }), (error) => error.code === 'LIVE_CATALOG_DIGEST'); + assert.equal(constructions, 0); +}); + +test('a restarted Collar rejects persisted quote identity drift before facilitator or provider calls', async () => { + const facilitatorApp = createMockFacilitator(); + let facilitatorCalls = 0; + const transport = createMockFacilitatorTransport((url, init) => { + facilitatorCalls += 1; + return facilitatorApp.request(url, init); + }); + const journal = createInvocationJournal({ signer: createReceiptSigner() }); + const beforeRestart = createCollar({ facilitatorTransport: transport, journal }); + const changedCatalog = structuredClone(EXECUTION_CATALOG); + changedCatalog.models['claude-sonnet-4-6'].outputAtomicPerMillionTokens = '15000001'; + const afterRestart = createCollar({ + facilitatorTransport: transport, journal, executionCatalog: changedCatalog, + }); + let sellerRequests = 0; + const result = await payingFetch(throwawayAccount(), `http://seller.test/invoke/${SKILL_ID}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'same frozen request' }), + }, { + idempotencyKey: 'restart-catalog-drift', + fetchImpl: (url, init) => (++sellerRequests === 1 ? beforeRestart.app : afterRestart.app).request(url, init), + }); + assert.equal(result.res.status, 409); + assert.equal((await result.res.json()).error.includes('catalog differs'), true); + assert.equal(facilitatorCalls, 0); + assert.equal(journal.events.some((event) => event.type === 'payment.signed'), false); +}); + +test('Anthropic adapter sends the frozen model and exact output cap and rejects prompt overflow before fetch', async () => { + const requests = []; + const executor = createAnthropicExecutor({ + apiKey: 'test-only', + fetchImpl: async (_url, init) => { + requests.push(JSON.parse(init.body)); + return { ok: true, json: async () => ({ content: [{ text: 'ok' }], usage: { input_tokens: 11, output_tokens: 2 } }) }; + }, + }); + const frozen = { model: 'claude-sonnet-4-6', maxInputTokens: 300, maxOutputTokens: 17 }; + assert.deepEqual(await executor({ + skillContent: 'system', input: 'hello', ...frozen, promptBytes: 11, estimatedInputTokens: 267, + }), { output: 'ok', usage: { model: frozen.model, inputTokens: 11, outputTokens: 2 } }); + assert.equal(requests[0].model, frozen.model); + assert.equal(requests[0].max_tokens, frozen.maxOutputTokens); + await assert.rejects(executor({ + skillContent: 'x'.repeat(45), input: '', ...frozen, promptBytes: 45, estimatedInputTokens: 301, + }), (error) => error.code === 'PROMPT_TOKEN_BOUND'); + assert.equal(requests.length, 1); +}); + +test('body and complete-prompt caps reject before a 402 offer', async () => { + const facilitator = createMockFacilitator(); + const collar = await startCollar({ + facilitatorTransport: createMockFacilitatorTransport((url, init) => facilitator.request(url, init)), + }); + try { + const cases = [ + [{ input: 'x'.repeat(4097) }, 413, 'REQUEST_BODY_TOO_LARGE'], + [{ input: 'x', execution: { maxInputTokens: 300 } }, 400, 'PROMPT_TOKEN_BOUND'], + ]; + for (const [body, status, code] of cases) { + const res = await fetch(`${collar.url}/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, + body: JSON.stringify(body), + }); + assert.equal(res.status, status); + assert.equal((await res.json()).code, code); + } + assert.equal(collar.journal.events.length, 0); + } finally { + collar.close(); + } +}); + +test('unlisted models and excessive token limits fail before a 402 offer', async () => { + const facilitator = createMockFacilitator(); + const collar = await startCollar({ + facilitatorTransport: createMockFacilitatorTransport((url, init) => facilitator.request(url, init)), + }); + try { + for (const execution of [ + { model: 'unlisted-model' }, + { model: 'claude-sonnet-4-6', maxOutputTokens: 2049 }, + ]) { + const res = await fetch(`${collar.url}/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, + body: JSON.stringify({ input: 'x', execution }), + }); + assert.equal(res.status, 400); + assert.equal((await res.json()).code === 'MODEL_NOT_ALLOWED' || execution.maxOutputTokens === 2049, true); + } + assert.equal(collar.journal.events.length, 0); + } finally { + collar.close(); + } +}); + +test('usage above the accepted limit fails after settlement and returns no output', async () => { + const services = await stack({ + executeSkill: async () => ({ + output: 'must not escape', + usage: { model: 'claude-sonnet-4-6', inputTokens: 4096, outputTokens: 2049 }, + }), + }); + try { + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 500); + assert.equal(body.output, undefined); + assert.equal(body.receipt.receipt.payment.state, 'settled'); + assert.equal(body.receipt.receipt.execution.state, 'failed'); + assert.equal(body.receipt.receipt.execution.failureClass, 'USAGE_EXCEEDS_QUOTE'); + const accounting = body.receipt.receipt.accounting; + assert.equal(accounting.allocationState, 'pending_cogs_reconciliation'); + assert.equal(accounting.executionCogs.status, 'known'); + assert.equal(accounting.executionCogs.actualAtomic, '79887'); + assert.equal(accounting.executionCogs.accruedOverrunAtomic, '15'); + assert.equal(accounting.executionCogs.failureClass, 'USAGE_EXCEEDS_QUOTE'); + assert.equal(accounting.royaltyPoolAtomic, '0'); + assert.deepEqual(accounting.holderCredits, []); + assert.equal(accounting.journalEntries[0].amountAtomic, accounting.grossAtomic); + } finally { + services.proxy.close(); services.collar.close(); services.facilitator.close(); + } +}); + +for (const [name, executeSkill, failureClass, expectedCogs] of [ + ['provider throw', async () => { throw Object.assign(new Error('upstream failed'), { code: 'UPSTREAM_PROVIDER_ERROR' }); }, 'UPSTREAM_PROVIDER_ERROR', 'unknown'], + ['malformed result', async () => ({ output: 42, usage: { model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 } }), 'INVALID_EXECUTOR_RESULT', 'known'], +]) test(`${name} records a balanced pending reconciliation and emits no output`, async () => { + const services = await stack({ executeSkill }); + try { + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 500); + assert.equal(body.output, undefined); + assert.equal(body.receipt.receipt.execution.failureClass, failureClass); + const accounting = body.receipt.receipt.accounting; + assert.equal(accounting.allocationState, 'pending_cogs_reconciliation'); + assert.equal(accounting.executionCogs.status, expectedCogs); + assert.equal(accounting.executionCogs.failureClass, failureClass); + assert.equal(accounting.royaltyPoolAtomic, '0'); + assert.deepEqual(accounting.holderCredits, []); + assert.equal(accounting.journalEntries[0].amountAtomic, accounting.grossAtomic); + } finally { + services.proxy.close(); services.collar.close(); services.facilitator.close(); + } +}); +``` + +Add `import crypto from 'node:crypto';` at the top of this test. + +- [ ] **Step 2: Run the test and verify gross-only accounting fails** + +Run: `node --test spikes/pi-wielder/tests/collar-cogs.test.mjs` + +Expected: FAIL because the Collar does not quote limits or record provider usage. + +- [ ] **Step 3: Add execution-economics dependencies and constants to the Collar** + +Add these imports: + +```js +import { assertArtifactNotSerialized } from './artifact-boundary.mjs'; +import { + artifactDigest, + assertFrozenExecutionIdentity, + assertLiveCatalogApproval, + conservativeProviderPromptBound, + createPendingExecutionAccounting, + createExecutionQuote, + EXECUTION_CATALOG, + ExecutionEconomicsError, + finalizeExecutionAccounting, +} from './execution-economics.mjs'; +``` + +Add these constants near `DEFAULT_PRICE_USDC`: + +```js +const DEFAULT_EXECUTION = Object.freeze({ + model: 'claude-sonnet-4-6', + maxInputTokens: 16384, + maxOutputTokens: 2048, +}); +const SETTLEMENT_COST_ATOMIC = '1000'; +const REFUND_RESERVE_ATOMIC = '5000'; +const MAX_REQUEST_BODY_BYTES = 4096; +const SKILL_VERSION = 'optimizing-claude-code-prompts/2026-07-17-v1'; +``` + +Change the Collar defaults so offline mock execution is the safe default, and add the +catalog/live-factory options: + +```js + mockLlm = process.env.MOCK_LLM !== '0', + allowLiveProvider = process.env.ALLOW_LIVE_PROVIDER === '1', + executionCatalog = EXECUTION_CATALOG, + liveApproval = null, + liveExecutorFactory = () => createAnthropicExecutor(), +``` + +Before constructing the executor, add this fail-closed gate: + +```js + let executor = executeSkill; + if (!executor && mockLlm) { + executor = async ({ input }) => ({ + output: mockSkillOutput(input), + usage: { model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, + }); + } + if (!executor) { + if (!allowLiveProvider) { + throw new ExecutionEconomicsError( + 'LIVE_PRICING_UNAPPROVED', + 'live provider execution requires an explicit gate', + ); + } + assertLiveCatalogApproval({ catalog: executionCatalog, approval: liveApproval, grossAtomic: priceAtomic }); + executor = liveExecutorFactory(); + } +``` + +For standalone live mode, construct `liveApproval` only when both +`LIVE_CATALOG_DIGEST` and `LIVE_SPEND_CAP_ATOMIC` are explicitly present; otherwise +leave it `null` and fail before factory/fetch construction. The digest is recomputed +from canonical catalog content with any catalog-owned `approval` field excluded and +must equal the separately supplied human value exactly. + +Pass `executionCatalog` into both `createExecutionQuote` and +`finalizeExecutionAccounting`; do not silently fall back to the synthetic catalog. + +- [ ] **Step 4: Quote from the frozen request before offering payment** + +Inside `createCollar`, add: + +```js + const readInvocationBody = async (c) => { + const cached = c.get('invocationBody'); + if (cached) return cached; + const raw = await c.req.text(); + const requestBodyBytes = Buffer.byteLength(raw, 'utf8'); + if (requestBodyBytes > MAX_REQUEST_BODY_BYTES) { + throw new ExecutionEconomicsError('REQUEST_BODY_TOO_LARGE', 'request body exceeds the pre-payment byte cap'); + } + let body; + try { body = JSON.parse(raw); } catch { throw new ExecutionEconomicsError('INVALID_REQUEST', 'body must be JSON'); } + if (typeof body?.input !== 'string' || !body.input) { + throw new ExecutionEconomicsError('INVALID_REQUEST', 'body must contain a non-empty string input'); + } + const parsed = { body, requestBodyBytes }; + c.set('invocationBody', parsed); + return parsed; + }; + + const buildQuote = async (c) => { + const { body, requestBodyBytes } = await readInvocationBody(c); + const requested = { ...DEFAULT_EXECUTION, ...(body.execution ?? {}) }; + const promptBound = conservativeProviderPromptBound({ + systemPrompt: skillContent, + userInput: body.input, + requestBodyBytes, + maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + maxInputTokens: requested.maxInputTokens, + }); + return createExecutionQuote({ + grossAtomic: priceAtomic, + model: requested.model, + maxInputTokens: requested.maxInputTokens, + maxOutputTokens: requested.maxOutputTokens, + promptBytes: promptBound.promptBytes, + estimatedInputTokens: promptBound.estimatedInputTokens, + settlementCostAtomic: SETTLEMENT_COST_ATOMIC, + refundReserveAtomic: REFUND_RESERVE_ATOMIC, + protocolFeeBps: 250, + leafSkillId: SKILL_ID, + skillId: SKILL_ID, + skillVersion: SKILL_VERSION, + artifactHash: artifactDigest(skillContent), + skills: royaltyGraph, + catalog: executionCatalog, + }); + }; +``` + +Pass `quote: buildQuote` to `x402Paywall`. Its frozen cache guarantees that the paid +retry receives the same object through `c.get('x402').executionQuote`. +The paid handler calls `readInvocationBody(c)` too (the retry is a new request) and uses +that cached body; it never calls `c.req.json()` independently. + +Extend the lifecycle `onSigned` parameters with `executionQuote` and run this check as +its first statement, before `markExternalPaymentSigned` and therefore before facilitator +verify/settle. Repeat the same check immediately before calling `executor` to close the +configuration-mutation window after settlement: + +```js +assertFrozenExecutionIdentity({ + quote: executionQuote, + skillId: SKILL_ID, + skillVersion: SKILL_VERSION, + artifactContent: skillContent, + skills: royaltyGraph, + catalog: executionCatalog, +}); +``` + +On the post-settlement check, convert any drift error to `createPendingExecutionAccounting` +with that exact failure class, full-gross hold, and no provider call, output, or Royalty +claim. A paid retry recovered after restart never substitutes current identity into the +persisted quote. + +- [ ] **Step 5: Return usage from mock and Anthropic adapters** + +Make the default mock executor return: + +```js +{ + output: mockSkillOutput(input), + usage: { model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, +} +``` + +Replace the live helper with this injectable adapter. It recomputes the complete prompt +bound before fetch and sends the frozen quote's exact model and output limit: + +```js +export function createAnthropicExecutor({ + apiKey = process.env.ANTHROPIC_API_KEY, + fetchImpl = fetch, +} = {}) { + return async ({ + skillContent, input, model, maxInputTokens, maxOutputTokens, + promptBytes, estimatedInputTokens, + }) => { + const rebound = conservativeProviderPromptBound({ + systemPrompt: skillContent, userInput: input, requestBodyBytes: 0, + maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, maxInputTokens, + }); + if (rebound.promptBytes !== promptBytes || rebound.estimatedInputTokens !== estimatedInputTokens) { + throw new ExecutionEconomicsError('FROZEN_PROMPT_MISMATCH', 'provider prompt differs from the accepted quote'); + } + const response = await fetchImpl('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + max_tokens: maxOutputTokens, + system: skillContent, + messages: [{ role: 'user', content: input }], + }), + }); + if (!response.ok) { + throw Object.assign(new Error(`Anthropic returned ${response.status}`), { code: 'UPSTREAM_PROVIDER_ERROR' }); + } + const data = await response.json(); + return { + output: data.content?.map((block) => block.text ?? '').join('') ?? '', + usage: data.usage ? { + model, + inputTokens: data.usage.input_tokens, + outputTokens: data.usage.output_tokens, + } : null, + }; + }; +} +``` + +The live adapter still does not run in automated tests. Its catalog version remains +`synthetic_config` until a human verifies a current price sheet. + +- [ ] **Step 6: Replace zero-cost success allocation with final COGS accounting** + +First replace Plan 5's helper signature/body fields with: + +```js +const finishFailure = (failureClass, message, status, accounting = null) => { + journal.finishExecution(key, { + executionAttemptId, + outcome: 'failed', failureClass, message, outcomeHash: null, accounting, + }); + return c.json({ error: message, receipt: journal.issueReceipt(key) }, status); +}; +``` + +Invoke the executor only with frozen quote fields, then finalize accounting before any +output can escape. Every post-provider failure receives one balanced pending accounting +record; retain valid usage even when the result or accepted caps are violated: + +```js + const { body } = await readInvocationBody(c); + const frozenQuote = payment.executionQuote; + try { + assertFrozenExecutionIdentity({ + quote: frozenQuote, skillId: SKILL_ID, skillVersion: SKILL_VERSION, + artifactContent: skillContent, skills: royaltyGraph, catalog: executionCatalog, + }); + } catch (error) { + const accounting = createPendingExecutionAccounting({ + quote: frozenQuote, usage: null, failureClass: error.code, + reason: error.message, catalog: executionCatalog, + }); + return finishFailure(error.code, error.message, 500, accounting); + } + let execution; + try { + execution = await executor({ + skillContent, + input: body.input, + model: frozenQuote.model, + maxInputTokens: frozenQuote.maxInputTokens, + maxOutputTokens: frozenQuote.maxOutputTokens, + promptBytes: frozenQuote.promptBytes, + estimatedInputTokens: frozenQuote.estimatedInputTokens, + }); + } catch (error) { + const failureClass = error.code ?? 'UPSTREAM_PROVIDER_ERROR'; + const accounting = createPendingExecutionAccounting({ + quote: frozenQuote, + usage: error.usage ?? null, + failureClass, + reason: error.message, + catalog: executionCatalog, + }); + return finishFailure(failureClass, error.message, 500, accounting); + } + + if (!execution || typeof execution.output !== 'string' + || !Object.hasOwn(execution, 'usage') + || !(execution.usage == null || typeof execution.usage === 'object')) { + const failureClass = 'INVALID_EXECUTOR_RESULT'; + const accounting = createPendingExecutionAccounting({ + quote: frozenQuote, + usage: execution?.usage ?? null, + failureClass, + reason: 'executor must return exactly output:string and usage:object|null', + catalog: executionCatalog, + }); + return finishFailure(failureClass, 'invalid executor result', 500, accounting); + } + + try { + const accounting = finalizeExecutionAccounting({ + quote: frozenQuote, + usage: execution.usage ?? null, + unknownReason: execution.usage == null ? 'provider response omitted usage' : undefined, + leafSkillId: SKILL_ID, + skills: royaltyGraph, + catalog: executionCatalog, + }); + if (accounting.executionCogs.status === 'unknown') { + return finishFailure( + 'COGS_UNKNOWN', + 'provider usage is unavailable; full gross held pending trusted COGS reconciliation or refund', + 500, + accounting, + ); + } + assertArtifactNotSerialized({ output: execution.output, artifact: skillContent }); + journal.finishExecution(key, { + executionAttemptId, + outcome: 'succeeded', + failureClass: null, + message: null, + outcomeHash: hash(execution.output), + accounting, + }); + } catch (error) { + if (error instanceof ExecutionEconomicsError || error.message.includes('artifact serialization')) { + const failureClass = error.code ?? 'ARTIFACT_SERIALIZATION'; + const accounting = createPendingExecutionAccounting({ + quote: frozenQuote, + usage: execution.usage, + failureClass, + reason: error.message, + catalog: executionCatalog, + }); + return finishFailure(failureClass, error.message, 500, accounting); + } + throw error; + } + return c.json({ output: execution.output, receipt: journal.issueReceipt(key) }); +``` + +Delete the Plan-5 zero-cost `allocateExternalGross(...)` block and its success response. +Update the earlier executor-result check to accept exactly +`{ output: string, usage: object | null }`. + +- [ ] **Step 7: Run Collar economics and boundary tests** + +Run: `node --test spikes/pi-wielder/tests/execution-economics.test.mjs spikes/pi-wielder/tests/artifact-boundary.test.mjs spikes/pi-wielder/tests/collar-cogs.test.mjs` + +Expected: PASS, 21 tests and 0 failures. + +- [ ] **Step 8: Commit COGS-aware Collar execution** + +```bash +git add spikes/pi-wielder/src/collar.mjs spikes/pi-wielder/tests/collar-cogs.test.mjs +git commit -m "feat: settle hosted Skill COGS before royalties" +``` + +### Task 5: Update the end-to-end proof and evidence labels + +**Files:** +- Modify: `spikes/pi-wielder/e2e.mjs` +- Modify: `spikes/pi-wielder/README.md` +- Modify: `spikes/pi-wielder/RUNBOOK.md` +- Verify: `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json` + +- [ ] **Step 1: Assert actual COGS and exact post-cost allocation in e2e** + +After the existing signed-receipt assertions in `e2e.mjs`, add: + +```js + const skillAccounting = entries[2].receipt.receipt.accounting; + eq(skillAccounting.executionCogs.status, 'known', 'mock provider usage is explicitly known'); + eq(skillAccounting.executionCogs.actualAtomic, '756', 'mock provider COGS uses the versioned catalog'); + eq(skillAccounting.settlementCostAtomic, '1000', 'settlement cost is allocated before royalties'); + eq(skillAccounting.protocolFeeAtomic, '6250', 'protocol fee remains exact'); + eq(skillAccounting.refundReserveAtomic, '5000', 'refund reserve remains explicit'); + eq(skillAccounting.royaltyPoolAtomic, '236994', 'Royalty pool is the exact post-cost residual'); + eq( + BigInt(skillAccounting.executionCostAtomic) + + BigInt(skillAccounting.settlementCostAtomic) + + BigInt(skillAccounting.protocolFeeAtomic) + + BigInt(skillAccounting.royaltyPoolAtomic) + + BigInt(skillAccounting.refundReserveAtomic), + BigInt(skillAccounting.grossAtomic), + 'receipt accounting conserves gross exactly', + ); +``` + +- [ ] **Step 2: Replace gross-split prose with the implemented ordering** + +In `spikes/pi-wielder/README.md`, replace any example that splits the full `$0.25` +only between Creator and treasury with: + +```markdown +For the deterministic mock fixture, a 250,000-atomic-USDC gross Invocation allocates +756 to synthetic-config execution COGS, 1,000 to settlement cost, 6,250 to protocol +fee, 5,000 to refund reserve, and 236,994 to the Royalty pool. These are mock accounting +values, not observed live provider economics. If provider usage is missing, the settled +Invocation fails `COGS_UNKNOWN`, emits no output or Royalty credits, and holds full gross +in `pending_cogs_reconciliation` until trusted reconciliation or refund. +``` + +Replace “the Skill never leaves” with: + +```markdown +The Collar does not directly return or serialize the hosted Skill artifact. A narrow +runtime guard rejects full or long exact artifact fragments. Model-output extraction +can never be ruled out categorically; prompt-injection resistance remains adversarial +test evidence, not a secrecy guarantee. +``` + +- [ ] **Step 3: Add the human live-pricing gate to the runbook** + +Add to `spikes/pi-wielder/RUNBOOK.md`: + +```markdown +Before a live provider run, verify the current provider price sheet, add a new immutable +catalog version with `evidenceLabel: human_verified`, source, and as-of timestamp. Compute +its exact canonical `catalogDigest`, set that separately as `LIVE_CATALOG_DIGEST`, set an +atomic `LIVE_SPEND_CAP_ATOMIC`, then set `ALLOW_LIVE_PROVIDER=1`. Do not embed approval +or spend authorization in the catalog itself. Never relabel +`synthetic-anthropic-2026-07-17-v1` as measured. Automated verification stays on the +mock facilitator and mock model and uses no real funds. +``` + +- [ ] **Step 4: Verify—not recreate—the historical overhead tombstone** + +Plan 1 owns `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. Run: + +```bash +node -e "const m=require('./spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json'); if(m.evidenceStatus!=='historical_unreproducible') process.exit(1)" +``` + +Expected: exit 0. Do not quote, recompute, or validate its historical p50/p95 because +the raw n=48 samples are absent. Do not edit or overwrite this manifest in this plan. + +- [ ] **Step 5: Run focused and end-to-end verification** + +Run: `npm test --prefix prototype && npm test --prefix spikes/pi-wielder && npm run e2e --prefix spikes/pi-wielder` + +Expected: every offline test passes; e2e conserves 250,000 atomic USDC with 756 known +mock COGS and returns one pinned-key Collar receipt. No provider or chain network is used. + +- [ ] **Step 6: Confirm unknown COGS is never zeroed** + +Run: `rg -n "status: 'unknown'|actualAtomic: null|chargedAtomic" spikes/pi-wielder/src/execution-economics.mjs spikes/pi-wielder/tests` + +Expected: the unknown path has `actualAtomic: null`, `chargedAtomic: null`, a non-zero +`quotedWorstCaseAtomic`, no finalized Royalty credits, and one full-gross balanced hold; +no test or implementation assigns zero to unknown actual cost. + +- [ ] **Step 7: Commit runtime truthfulness documentation** + +```bash +git add spikes/pi-wielder/e2e.mjs spikes/pi-wielder/README.md spikes/pi-wielder/RUNBOOK.md +git commit -m "docs: label hosted Skill execution economics" +``` + +## Definition of done + +- A versioned allowlist controls model, token limits, and atomic input/output rates. +- Mock execution is the default; a live adapter is not even constructed without an + explicit gate, a human-verified immutable catalog carrying source/as-of, and separately + supplied exact digest and spend authorization. +- The x402 offer, Wielder authorization, journal quote, and signed receipt share one immutable quote ID and request hash. +- That quote ID also commits to Skill ID/version, hosted artifact hash, canonical Royalty + graph digest, and canonical catalog digest; restart/config drift is rejected before + facilitator settlement and rechecked immediately before provider execution. +- Worst-case COGS, settlement cost, fee, and refund reserve fit inside gross before any offer is signable. +- Known usage charges exact versioned COGS. Missing usage ends `failed/COGS_UNKNOWN`, + releases no output or Royalty claims, and holds full gross for reconciliation/refund. +- Thrown providers and malformed results hold full gross with unknown or retained-known + usage; above-cap valid usage records accrued COGS/overrun. All return failed signed + receipts without output or Royalty claims. +- Royalty holder and ancestor credits are calculated only from the post-cost Royalty pool through `prototype/atomic-money.mjs`. +- The runtime blocks direct artifact serialization but makes no absolute model-extraction guarantee. +- Historical n=48 overhead numbers remain `historical_unreproducible`; this plan does not present them as a validated distribution. +- All verification is offline/mock, Base Sepolia-shaped, and uses no real funds, live provider call, or mainnet transaction. diff --git a/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md b/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md new file mode 100644 index 0000000..c17532a --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md @@ -0,0 +1,3131 @@ +# Collar Invocation Journal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Collar the authoritative append-only source for every external Invocation, including settled failures and response-loss reconciliation, and issue tamper-evident signed receipts. + +**Architecture:** Introduce an append-only JSONL event journal whose reducer owns Invocation, payment, and execution state. The x402 seller emits lifecycle hooks keyed by one client idempotency key; the Collar records the offer, signed authorization, settlement, execution outcome, and signed receipt, while the Wielder stores only the returned receipt view. Every amount is a canonical atomic-USDC string backed by `prototype/atomic-money.mjs`. + +**Tech Stack:** Node.js 20+, ECMAScript modules, Hono, built-in `node:test`, `node:assert/strict`, Node `crypto` Ed25519/SHA-256, JSONL persistence; Base Sepolia/mock facilitator only, never mainnet or real funds. + +--- + +## Prerequisite and file map + +Complete `docs/superpowers/plans/2026-07-17-atomic-money-kernel.md` first. + +- Create `spikes/pi-wielder/src/invocation-journal.mjs`: event schema, transition reducer, JSONL replay, indexes, reconciliation, canonical receipt signing, and verification. +- Create `spikes/pi-wielder/tests/invocation-journal.test.mjs`: transition, idempotency, persistence, receipt, and reconciliation tests. +- Create `spikes/pi-wielder/tests/journal-writer-fixture.mjs`: child-process fixture for same-host writer serialization. +- Create `spikes/pi-wielder/tests/journal-reader-fixture.mjs`: child-process fixture for cross-process frozen-offer visibility. +- Create `spikes/pi-wielder/tests/collar-failure.test.mjs`: offline settled-then-500 integration test. +- Modify `spikes/pi-wielder/src/x402-seller.mjs`: atomic amount context, required idempotency key, and lifecycle hooks. +- Modify `spikes/pi-wielder/src/collar.mjs`: authoritative journal integration and signed terminal receipts. +- Modify `spikes/pi-wielder/src/proxy.mjs`: preserve one idempotency key across challenge/retry and cache returned receipts for success and failure. +- Modify `spikes/pi-wielder/src/ledger.mjs`: render the Wielder-side store explicitly as a receipt view. +- Modify `spikes/pi-wielder/e2e.mjs`: assert Collar authority and receipt equivalence. +- Modify `spikes/pi-wielder/package.json`: add focused offline test scripts. +- Modify `.gitignore`: defensively exclude local journal locks and private-key files. + +## Journal types and transition contract + +The journal serializes atomic values as base-10 strings because JSON cannot encode +`bigint`. It converts them back to `bigint` only when calling `atomic-money.mjs`. + +```js +// InvocationRecord (JSON-safe) +{ + schemaVersion: 1, + invocationId: 'inv-...', + idempotencyKey: 'caller-generated-uuid', + mode: 'external', + skill: { id: 'skill-id', versionHash: 'sha256:...' }, + requestHash: 'sha256:...', + creatorId: 'creator', + wielderId: '0x...' | null, + beneficiaryId: '0x...' | null, + quote: { + quoteId: 'sha256:...', amountAtomic: '250000', currency: 'USDC', + network: 'base-sepolia', asset: '0x...', payTo: '0x...', resource: 'http://...', + requestHash: 'sha256:...', requirementsHash: 'sha256:...', + expiresAt: '2026-07-17T12:00:00.000Z', + requirements: PaymentRequirements // complete frozen JSON envelope, not a reconstruction + } | null, + payment: { + state: null | 'offered' | 'signed' | 'settled' | 'rejected' | 'unresolved' | 'refunded', + settlementReference: '0x...' | null, txHash: '0x...' | null, + payer: '0x...' | null, reason: string | null, + refundReference: string | null, refundAmountAtomic: string | null, + refundAccounting: null | { + priorAllocationState: 'pending_cogs_reconciliation', + reversalEntries: JournalEntry[] // exact derived hold reversal + refund disbursement + } + }, + execution: { + state: 'requested' | 'quoted' | 'authorized' | 'executing' | 'succeeded' | 'failed' | 'cancelled', + executionAttemptId: string | null, + outcomeHash: 'sha256:...' | null, failureClass: string | null, message: string | null + }, + accounting: object | null, + receipt: SignedInvocationReceipt | null, + createdAt: ISODate, + updatedAt: ISODate, + lastSequence: number +} +``` + +Only these transitions are legal: + +```text +requestInvocation -> offerExternalPayment -> markExternalPaymentSigned +markExternalPaymentSigned -> markExternalPaymentSettled | markExternalPaymentUnresolved | rejectExternalPayment +markExternalPaymentUnresolved -> reconcileExternalSettlement +markExternalPaymentSettled -> startExecution +startExecution -> finishExecution(succeeded | failed | cancelled) +finishExecution(failed with full-gross pending reconciliation) -> refundExternalPayment +terminal execution -> issueReceipt +``` + +Repeated calls carrying exactly the same idempotency key and payload are no-ops. A +different payload under an existing key fails closed. + +### Task 1: Build and replay the append-only journal + +**Files:** +- Create: `spikes/pi-wielder/src/invocation-journal.mjs` +- Create: `spikes/pi-wielder/tests/invocation-journal.test.mjs` +- Modify: `spikes/pi-wielder/package.json:7-13` +- Modify: `.gitignore` + +- [ ] **Step 1: Add focused offline test commands** + +Replace the scripts object in `spikes/pi-wielder/package.json` with: + +```json +"scripts": { + "test": "node --test tests/*.test.mjs", + "test:journal": "node --test tests/invocation-journal.test.mjs", + "e2e": "MOCK_FACILITATOR=1 MOCK_LLM=1 node e2e.mjs", + "collar": "node src/collar.mjs", + "gateway": "node src/gateway.mjs", + "proxy": "node src/proxy.mjs" +} +``` + +Append these defensive local-state patterns to the repository `.gitignore`: + +```gitignore +# Collar journal authority and signing material must stay outside the checkout. +*.lock +*.pem +*.key +``` + +The runtime still requires `COLLAR_SIGNING_KEY_FILE` to be an explicit absolute path +outside the checkout; these patterns are belt-and-braces, not the primary control. + +- [ ] **Step 2: Write the failing journal contract tests** + +Create `spikes/pi-wielder/tests/invocation-journal.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + canonicalJson, + createInvocationJournal, + createReceiptSigner, + loadOrCreateReceiptSigner, + verifySignedReceipt, +} from '../src/invocation-journal.mjs'; + +function fixture(overrides = {}) { + let tick = 0; + return createInvocationJournal({ + now: () => new Date(Date.UTC(2026, 6, 17, 12, 0, tick++)).toISOString(), + createId: () => 'inv-0001', + signer: createReceiptSigner(), + ...overrides, + }); +} + +const trustFor = (journal) => ({ + publicKeyPem: journal.signingPublicKeyPem, + keyId: journal.signingKeyId, +}); + +const declaration = { + idempotencyKey: 'idem-0001', + mode: 'external', + skillId: 'skill-a', + skillVersionHash: `sha256:${'a'.repeat(64)}`, + requestHash: `sha256:${'d'.repeat(64)}`, + creatorId: 'creator-a', + beneficiaryId: null, +}; + +const quote = { + quoteId: `sha256:${'b'.repeat(64)}`, + amountAtomic: '250000', + currency: 'USDC', + network: 'base-sepolia', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + payTo: '0x000000000000000000000000000000000000dEaD', + resource: 'http://127.0.0.1:8404/invoke/skill-a', + requestHash: `sha256:${'d'.repeat(64)}`, + requirementsHash: `sha256:${'e'.repeat(64)}`, + expiresAt: '2026-07-17T12:01:00.000Z', + requirements: { + scheme: 'exact', network: 'base-sepolia', maxAmountRequired: '250000', + resource: 'http://127.0.0.1:8404/invoke/skill-a', description: 'Invoke skill-a', + mimeType: 'application/json', payTo: '0x000000000000000000000000000000000000dEaD', + maxTimeoutSeconds: 60, asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + extra: { + name: 'USDC', version: '2', requestHash: `sha256:${'d'.repeat(64)}`, + quoteId: `sha256:${'b'.repeat(64)}`, + issuedAt: '2026-07-17T12:00:00.000Z', expiresAt: '2026-07-17T12:01:00.000Z', + }, + }, +}; + +function settle(journal) { + journal.requestInvocation(declaration); + journal.offerExternalPayment('idem-0001', quote); + journal.markExternalPaymentSigned('idem-0001', { + settlementReference: `0x${'1'.repeat(64)}`, + payer: '0x1000000000000000000000000000000000000000', + }); + journal.markExternalPaymentSettled('idem-0001', { + settlementReference: `0x${'1'.repeat(64)}`, + txHash: `0x${'2'.repeat(64)}`, + payer: '0x1000000000000000000000000000000000000000', + }); +} + +test('a settled execution failure remains attached to its transaction', () => { + const journal = fixture(); + settle(journal); + journal.startExecution('idem-0001'); + journal.finishExecution('idem-0001', { + outcome: 'failed', + failureClass: 'UPSTREAM_500', + message: 'provider returned HTTP 500', + outcomeHash: null, + accounting: null, + }); + const bundle = journal.issueReceipt('idem-0001'); + const record = journal.getByIdempotencyKey('idem-0001'); + + assert.equal(record.payment.state, 'settled'); + assert.equal(record.payment.txHash, `0x${'2'.repeat(64)}`); + assert.equal(record.execution.state, 'failed'); + assert.equal(record.execution.failureClass, 'UPSTREAM_500'); + assert.equal(bundle.receipt.payment.txHash, record.payment.txHash); + assert.equal(verifySignedReceipt(bundle, trustFor(journal)), true); +}); + +test('exact retries are no-ops and conflicting idempotency reuse fails closed', () => { + const journal = fixture(); + const first = journal.requestInvocation(declaration); + const repeated = journal.requestInvocation(declaration); + assert.deepEqual(repeated, first); + assert.equal(journal.events.length, 1); + assert.throws(() => journal.requestInvocation({ + ...declaration, + skillVersionHash: `sha256:${'f'.repeat(64)}`, + }), /idempotency key already binds/); + assert.equal(journal.events.length, 1); +}); + +test('an unresolved settlement reconciles once by its payment reference', () => { + const journal = fixture(); + journal.requestInvocation(declaration); + journal.offerExternalPayment('idem-0001', quote); + journal.markExternalPaymentSigned('idem-0001', { + settlementReference: `0x${'3'.repeat(64)}`, + payer: '0x1000000000000000000000000000000000000000', + }); + journal.markExternalPaymentUnresolved('idem-0001', { reason: 'facilitator response lost' }); + const eventCount = journal.events.length; + journal.reconcileExternalSettlement({ + settlementReference: `0x${'3'.repeat(64)}`, + txHash: `0x${'4'.repeat(64)}`, + payer: '0x1000000000000000000000000000000000000000', + }); + journal.reconcileExternalSettlement({ + settlementReference: `0x${'3'.repeat(64)}`, + txHash: `0x${'4'.repeat(64)}`, + payer: '0x1000000000000000000000000000000000000000', + }); + assert.equal(journal.events.length, eventCount + 1); + assert.equal(journal.getBySettlementReference(`0x${'3'.repeat(64)}`).payment.state, 'settled'); + assert.equal(journal.getByTxHash(`0x${'4'.repeat(64)}`).idempotencyKey, 'idem-0001'); +}); + +test('nonce, address, and tx indexes canonicalize lowercase and reject cross-case collisions', () => { + const journal = fixture(); + for (const key of ['idem-case-a', 'idem-case-b', 'idem-case-c']) { + journal.requestInvocation({ ...declaration, idempotencyKey: key }); + journal.offerExternalPayment(key, quote); + } + const nonce = `0x${'ab'.repeat(32)}`; + const payerMixed = `0x${'Aa'.repeat(20)}`; + const payerCanonical = payerMixed.toLowerCase(); + journal.markExternalPaymentSigned('idem-case-a', { settlementReference: nonce.toUpperCase().replace('0X', '0x'), payer: payerMixed }); + assert.equal(journal.getByIdempotencyKey('idem-case-a').payment.settlementReference, nonce); + assert.equal(journal.getByIdempotencyKey('idem-case-a').payment.payer, payerCanonical); + assert.throws(() => journal.markExternalPaymentSigned('idem-case-b', { + settlementReference: nonce, + payer: payerCanonical, + }), /settlement reference already binds/); + + const otherNonce = `0x${'ef'.repeat(32)}`; + journal.markExternalPaymentSigned('idem-case-c', { settlementReference: otherNonce, payer: payerCanonical }); + const txHash = `0x${'cd'.repeat(32)}`; + journal.markExternalPaymentSettled('idem-case-a', { settlementReference: nonce, txHash: txHash.toUpperCase().replace('0X', '0x'), payer: payerCanonical }); + assert.equal(journal.getByTxHash(txHash.toUpperCase().replace('0X', '0x')).payment.txHash, txHash); + assert.throws(() => journal.markExternalPaymentSettled('idem-case-c', { + settlementReference: otherNonce, + txHash, + payer: payerCanonical, + }), /transaction hash already binds/); +}); + +test('JSONL replay reconstructs the same terminal record and signed receipt', () => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'collar-journal-'))); + const filePath = path.join(dir, 'events.jsonl'); + const signingKeyPath = path.join(dir, 'collar-receipt-key.pem'); + const journal = fixture({ filePath, signer: undefined, signingKeyPath }); + settle(journal); + journal.startExecution('idem-0001'); + journal.finishExecution('idem-0001', { + outcome: 'succeeded', + failureClass: null, + message: null, + outcomeHash: `sha256:${'c'.repeat(64)}`, + accounting: { grossAtomic: '250000' }, + }); + const original = journal.issueReceipt('idem-0001'); + + assert.throws(() => createInvocationJournal({ filePath, signingKeyPath, signer: createReceiptSigner() }), + /persistent journal refuses an ephemeral receipt signer/); + const reopened = createInvocationJournal({ filePath, signingKeyPath }); + assert.deepEqual(reopened.getByIdempotencyKey('idem-0001'), journal.getByIdempotencyKey('idem-0001')); + assert.deepEqual(reopened.getByIdempotencyKey('idem-0001').quote.requirements, quote.requirements); + assert.deepEqual(reopened.issueReceipt('idem-0001'), original); + assert.equal(verifySignedReceipt(reopened.issueReceipt('idem-0001'), trustFor(reopened)), true); + assert.equal(fs.statSync(signingKeyPath).mode & 0o777, 0o600); +}); + +test('recomputed event hashes cannot turn a rewritten journal into Collar authority', () => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'collar-tamper-'))); + const filePath = path.join(dir, 'events.jsonl'); + const signingKeyPath = path.join(dir, 'collar-receipt-key.pem'); + const journal = fixture({ filePath, signer: undefined, signingKeyPath }); + journal.requestInvocation(declaration); + const event = JSON.parse(fs.readFileSync(filePath, 'utf8')); + event.data.creatorId = 'attacker'; + const { eventHash: ignoredHash, eventSignature: preservedSignature, ...unsigned } = event; + event.eventHash = crypto.createHash('sha256').update(canonicalJson(unsigned)).digest('hex'); + event.eventSignature = preservedSignature; + fs.writeFileSync(filePath, `${JSON.stringify(event)}\n`); + assert.throws( + () => createInvocationJournal({ filePath, signingKeyPath }), + /event signature mismatch/, + ); +}); + +test('persistent authority rejects checkout, symlink, non-file, noncanonical, and broad-permission paths', () => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'collar-paths-'))); + const filePath = path.join(dir, 'events.jsonl'); + const signingKeyPath = path.join(dir, 'receipt-key.pem'); + const journal = createInvocationJournal({ filePath, signingKeyPath }); + journal.requestInvocation(declaration); + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600); + assert.equal(fs.statSync(signingKeyPath).mode & 0o777, 0o600); + + fs.chmodSync(filePath, 0o644); + assert.throws(() => createInvocationJournal({ filePath, signingKeyPath }), /exactly 0600/); + fs.chmodSync(filePath, 0o600); + const keyLink = path.join(dir, 'key-link.pem'); + fs.symlinkSync(signingKeyPath, keyLink); + assert.throws(() => createInvocationJournal({ filePath: path.join(dir, 'other.jsonl'), signingKeyPath: keyLink }), /non-symlink/); + const directoryLink = path.join(os.tmpdir(), `collar-dir-link-${crypto.randomUUID()}`); + fs.symlinkSync(dir, directoryLink); + assert.throws(() => createInvocationJournal({ + filePath: path.join(directoryLink, 'through-link.jsonl'), + signingKeyPath, + }), /symlinked directory/); + fs.unlinkSync(directoryLink); + const directoryTarget = path.join(dir, 'not-a-file'); + fs.mkdirSync(directoryTarget); + assert.throws(() => createInvocationJournal({ filePath: directoryTarget, signingKeyPath }), /regular non-symlink file/); + assert.throws(() => createInvocationJournal({ filePath: 'relative.jsonl', signingKeyPath }), /explicit absolute/); + assert.throws(() => createInvocationJournal({ + filePath: path.resolve('spikes/pi-wielder/unsafe-journal.jsonl'), signingKeyPath, + }), /outside the repository checkout/); +}); + +test('tampering invalidates a signed receipt', () => { + const journal = fixture(); + settle(journal); + journal.startExecution('idem-0001'); + journal.finishExecution('idem-0001', { + outcome: 'failed', failureClass: 'FAULT', message: 'fault', outcomeHash: null, accounting: null, + }); + const bundle = journal.issueReceipt('idem-0001'); + const tampered = structuredClone(bundle); + tampered.receipt.payment.txHash = `0x${'9'.repeat(64)}`; + assert.equal(verifySignedReceipt(tampered, trustFor(journal)), false); + + const attacker = fixture({ createId: () => 'inv-attacker' }); + settle(attacker); + attacker.startExecution('idem-0001'); + attacker.finishExecution('idem-0001', { + outcome: 'failed', failureClass: 'FAULT', message: 'fault', outcomeHash: null, accounting: null, + }); + assert.equal(verifySignedReceipt(attacker.issueReceipt('idem-0001'), trustFor(journal)), false); +}); + +test('refund v1 reverses only a terminal failed full-gross reconciliation hold', () => { + const journal = fixture(); + settle(journal); + const request = { + reason: 'settled execution failure', + refundReference: `refund:${'5'.repeat(64)}`, + refundAmountAtomic: '250000', + }; + assert.throws(() => journal.refundExternalPayment('idem-0001', request), /terminal failed/); + journal.startExecution('idem-0001'); + assert.throws(() => journal.refundExternalPayment('idem-0001', request), /terminal failed/); + const pending = { + grossAtomic: '250000', + allocationState: 'pending_cogs_reconciliation', + holderCredits: [], ancestorCredits: [], + journalEntries: [{ + category: 'unresolved-execution-accounting', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'hold:execution-accounting-reconciliation', + amountAtomic: '250000', + }], + }; + journal.finishExecution('idem-0001', { + outcome: 'failed', failureClass: 'COGS_UNKNOWN', message: 'fault', outcomeHash: null, accounting: pending, + }); + const original = journal.issueReceipt('idem-0001'); + assert.throws(() => journal.refundExternalPayment('idem-0001', { ...request, refundAmountAtomic: '249999' }), /full settled gross/); + journal.refundExternalPayment('idem-0001', request); + const revised = journal.issueReceipt('idem-0001'); + assert.equal(revised.receipt.revision, 2); + assert.equal(revised.receipt.supersedesReceiptHash, original.receiptHash); + assert.equal(revised.receipt.payment.state, 'refunded'); + assert.equal(revised.receipt.payment.refundAmountAtomic, '250000'); + assert.deepEqual(revised.receipt.payment.refundAccounting, { + priorAllocationState: 'pending_cogs_reconciliation', + reversalEntries: [{ + category: 'refund-reverse-reconciliation-hold', + debitAccountId: 'hold:execution-accounting-reconciliation', + creditAccountId: 'wielder:external-gross', + amountAtomic: '250000', + }, { + category: 'refund-disbursement', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'refund:0x1000000000000000000000000000000000000000', + amountAtomic: '250000', + }], + }); + const allEntries = [...pending.journalEntries, ...revised.receipt.payment.refundAccounting.reversalEntries]; + const balances = new Map(); + for (const entry of allEntries) { + balances.set(entry.debitAccountId, (balances.get(entry.debitAccountId) ?? 0n) - BigInt(entry.amountAtomic)); + balances.set(entry.creditAccountId, (balances.get(entry.creditAccountId) ?? 0n) + BigInt(entry.amountAtomic)); + } + assert.equal([...balances.values()].reduce((sum, value) => sum + value, 0n), 0n); + assert.equal(balances.get('hold:execution-accounting-reconciliation'), 0n); + assert.equal(balances.get('refund:0x1000000000000000000000000000000000000000'), 250000n); + assert.deepEqual(journal.refundExternalPayment('idem-0001', request).payment.refundAccounting, + revised.receipt.payment.refundAccounting); + assert.equal(verifySignedReceipt(original, trustFor(journal)), true); + assert.equal(verifySignedReceipt(revised, trustFor(journal)), true); + assert.equal(journal.events.filter((event) => event.type === 'receipt.issued').length, 2); +}); + +test('refund v1 rejects successful/finalized credits instead of leaving claims and returning gross', () => { + const journal = fixture(); + settle(journal); + journal.startExecution('idem-0001'); + journal.finishExecution('idem-0001', { + outcome: 'succeeded', failureClass: null, message: null, outcomeHash: `sha256:${'9'.repeat(64)}`, + accounting: { + grossAtomic: '250000', allocationState: 'finalized', + holderCredits: [{ recipientId: 'creator', amountAtomic: '250000' }], + ancestorCredits: [], journalEntries: [], + }, + }); + assert.throws(() => journal.refundExternalPayment('idem-0001', { + reason: 'not legal', refundReference: `refund:${'8'.repeat(64)}`, refundAmountAtomic: '250000', + }), /terminal failed full-gross reconciliation/); +}); + +const waitForExit = (child) => new Promise((resolve, reject) => { + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('exit', (code, signal) => code === 0 + ? resolve() + : reject(new Error(`writer exited ${code ?? signal}: ${stderr}`))); +}); + +async function waitForFiles(paths) { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (paths.every((candidate) => fs.existsSync(candidate))) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`writers did not become ready: ${paths.join(', ')}`); +} + +test('two same-host processes serialize durable hash-chained writes without losing either Invocation', async () => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'collar-writers-'))); + const filePath = path.join(dir, 'events.jsonl'); + const signingKeyPath = path.join(dir, 'collar-receipt-key.pem'); + const barrierPath = path.join(dir, 'start'); + loadOrCreateReceiptSigner(signingKeyPath); + const worker = path.resolve('spikes/pi-wielder/tests/journal-writer-fixture.mjs'); + const children = ['idem-process-a', 'idem-process-b'].map((key) => spawn( + process.execPath, + [worker, filePath, signingKeyPath, barrierPath, key, path.join(dir, `${key}.ready`)], + { stdio: ['ignore', 'ignore', 'pipe'] }, + )); + await waitForFiles(['idem-process-a', 'idem-process-b'].map((key) => path.join(dir, `${key}.ready`))); + fs.writeFileSync(barrierPath, 'go', { flag: 'wx' }); + await Promise.all(children.map(waitForExit)); + + const reopened = createInvocationJournal({ filePath, signingKeyPath }); + assert.ok(reopened.getByIdempotencyKey('idem-process-a')); + assert.ok(reopened.getByIdempotencyKey('idem-process-b')); + assert.deepEqual(reopened.events.map(({ sequence }) => sequence), [1, 2]); + assert.equal(reopened.events[0].previousHash, null); + assert.equal(reopened.events[1].previousHash, reopened.events[0].eventHash); +}); + +test('a second process sees the complete frozen offer written by the first process', async () => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'collar-reader-'))); + const filePath = path.join(dir, 'events.jsonl'); + const signingKeyPath = path.join(dir, 'collar-receipt-key.pem'); + const outputPath = path.join(dir, 'quote.json'); + const writer = createInvocationJournal({ filePath, signingKeyPath }); + writer.requestInvocation(declaration); + writer.offerExternalPayment(declaration.idempotencyKey, quote); + const child = spawn(process.execPath, [ + path.resolve('spikes/pi-wielder/tests/journal-reader-fixture.mjs'), + filePath, signingKeyPath, declaration.idempotencyKey, outputPath, + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + await waitForExit(child); + assert.deepEqual(JSON.parse(fs.readFileSync(outputPath, 'utf8')), quote.requirements); +}); +``` + +Create `spikes/pi-wielder/tests/journal-writer-fixture.mjs`: + +```js +import fs from 'node:fs'; + +import { createInvocationJournal } from '../src/invocation-journal.mjs'; + +const [filePath, signingKeyPath, barrierPath, idempotencyKey, readyPath] = process.argv.slice(2); +const journal = createInvocationJournal({ filePath, signingKeyPath }); +fs.writeFileSync(readyPath, 'ready', { flag: 'wx' }); +while (!fs.existsSync(barrierPath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); +const digit = idempotencyKey.endsWith('-a') ? '1' : '2'; +journal.requestInvocation({ + idempotencyKey, + mode: 'external', + skillId: 'skill-a', + skillVersionHash: `sha256:${'a'.repeat(64)}`, + requestHash: `sha256:${digit.repeat(64)}`, + creatorId: 'creator-a', + beneficiaryId: null, +}); +``` + +Create `spikes/pi-wielder/tests/journal-reader-fixture.mjs`: + +```js +import fs from 'node:fs'; + +import { createInvocationJournal } from '../src/invocation-journal.mjs'; + +const [filePath, signingKeyPath, idempotencyKey, outputPath] = process.argv.slice(2); +const journal = createInvocationJournal({ filePath, signingKeyPath }); +const requirements = journal.getByIdempotencyKey(idempotencyKey)?.quote?.requirements; +if (!requirements) throw new Error('persisted frozen offer is not visible'); +fs.writeFileSync(outputPath, JSON.stringify(requirements), { flag: 'wx' }); +``` + +- [ ] **Step 3: Run the focused test and verify the journal is missing** + +Run: `npm run test:journal --prefix spikes/pi-wielder` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/invocation-journal.mjs`. + +- [ ] **Step 4: Implement the append-only journal and signer** + +Create `spikes/pi-wielder/src/invocation-journal.mjs`: + +```js +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const TERMINAL_EXECUTION = new Set(['succeeded', 'failed', 'cancelled']); +const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../', import.meta.url))); + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); + } + return value; +} + +export const canonicalJson = (value) => JSON.stringify(canonicalize(value)); +const same = (left, right) => canonicalJson(left) === canonicalJson(right); +const copy = (value) => structuredClone(value); + +function requireText(value, label) { + const text = String(value ?? '').trim(); + if (!text) throw new Error(`${label} must be non-empty`); + return text; +} + +function requireAtomicString(value, label) { + const text = requireText(value, label); + if (!/^(0|[1-9]\d*)$/.test(text)) throw new Error(`${label} must be a canonical non-negative atomic string`); + return text; +} + +function canonicalHex(value, bytes, label) { + const text = requireText(value, label); + if (!new RegExp(`^0x[0-9a-fA-F]{${bytes * 2}}$`).test(text)) { + throw new Error(`${label} must be a ${bytes}-byte hex identifier`); + } + return text.toLowerCase(); +} + +const canonicalAddress = (value, label) => canonicalHex(value, 20, label); +const canonicalBytes32 = (value, label) => canonicalHex(value, 32, label); + +function safePersistentPath(input, label, { allowMissing = true } = {}) { + if (!path.isAbsolute(input ?? '')) throw new Error(`${label} must be an explicit absolute path`); + const lexical = path.resolve(input); + const lexicalParent = path.dirname(lexical); + const realParent = fs.realpathSync(lexicalParent); + if (realParent !== lexicalParent) throw new Error(`${label} must not traverse a symlinked directory`); + const candidate = path.join(realParent, path.basename(lexical)); + const relative = path.relative(CHECKOUT_ROOT, candidate); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + throw new Error(`${label} must be outside the repository checkout`); + } + if (fs.existsSync(candidate)) { + const stat = fs.lstatSync(candidate); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a regular non-symlink file`); + if ((stat.mode & 0o777) !== 0o600) throw new Error(`${label} permissions must be exactly 0600`); + if (fs.realpathSync(candidate) !== candidate) throw new Error(`${label} must be canonical`); + } else if (!allowMissing) { + throw new Error(`${label} does not exist`); + } + return candidate; +} + +function requireRecord(records, key) { + const record = records.get(key); + if (!record) throw new Error(`unknown idempotency key '${key}'`); + return record; +} + +function assertState(record, allowed, action) { + if (!allowed.includes(record.execution.state)) { + throw new Error(`${action} cannot run from execution state '${record.execution.state}'`); + } +} + +export function createReceiptSigner(keys = {}, { persistent = false } = {}) { + const pair = keys.privateKey && keys.publicKey + ? { privateKey: keys.privateKey, publicKey: keys.publicKey } + : crypto.generateKeyPairSync('ed25519'); + const publicKeyPem = pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const keyId = `sha256:${crypto.createHash('sha256') + .update(pair.publicKey.export({ type: 'spki', format: 'der' })) + .digest('hex')}`; + return Object.freeze({ + algorithm: 'Ed25519', + publicKeyPem, + keyId, + persistent, + signHash(hashHex) { + return crypto.sign(null, Buffer.from(hashHex, 'hex'), pair.privateKey).toString('base64'); + }, + }); +} + +const lockWait = new Int32Array(new SharedArrayBuffer(4)); + +function withFileLock(lockPath, operation, timeoutMs = 5_000) { + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + const deadline = Date.now() + timeoutMs; + let descriptor; + while (descriptor == null) { + try { + descriptor = fs.openSync(lockPath, 'wx', 0o600); + fs.writeFileSync(descriptor, canonicalJson({ pid: process.pid, acquiredAt: new Date().toISOString() })); + fs.fsyncSync(descriptor); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + try { + const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + process.kill(owner.pid, 0); + } catch (ownerError) { + if (ownerError.code === 'ESRCH') { + fs.unlinkSync(lockPath); + continue; + } + } + if (Date.now() >= deadline) throw new Error(`timed out acquiring journal lock '${lockPath}'`); + Atomics.wait(lockWait, 0, 0, 10); + } + } + try { + return operation(); + } finally { + fs.closeSync(descriptor); + fs.unlinkSync(lockPath); + } +} + +function fsyncDirectory(directory) { + const descriptor = fs.openSync(directory, 'r'); + try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } +} + +export function loadOrCreateReceiptSigner(keyPath, { lockPath = `${keyPath}.lock` } = {}) { + const canonicalKeyPath = safePersistentPath(keyPath, 'persistent receipt key'); + const canonicalLockPath = `${canonicalKeyPath}.lock`; + if (lockPath !== canonicalLockPath) throw new Error('receipt-key lock must be derived from the canonical key path'); + return withFileLock(canonicalLockPath, () => { + let privateKey; + if (fs.existsSync(canonicalKeyPath)) { + privateKey = crypto.createPrivateKey(fs.readFileSync(canonicalKeyPath, 'utf8')); + } else { + fs.mkdirSync(path.dirname(keyPath), { recursive: true }); + const pair = crypto.generateKeyPairSync('ed25519'); + privateKey = pair.privateKey; + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + const temporary = `${canonicalKeyPath}.${process.pid}.${crypto.randomUUID()}.tmp`; + const descriptor = fs.openSync(temporary, 'wx', 0o600); + try { + fs.writeFileSync(descriptor, pem); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + fs.renameSync(temporary, canonicalKeyPath); + fsyncDirectory(path.dirname(canonicalKeyPath)); + } + return createReceiptSigner( + { privateKey, publicKey: crypto.createPublicKey(privateKey) }, + { persistent: true }, + ); + }); +} + +export function verifySignedReceipt(bundle, { publicKeyPem, keyId }) { + try { + if (bundle.algorithm !== 'Ed25519') return false; + if (bundle.keyId !== keyId) return false; + const expectedHash = crypto.createHash('sha256').update(canonicalJson(bundle.receipt)).digest('hex'); + if (expectedHash !== bundle.receiptHash) return false; + return crypto.verify( + null, + Buffer.from(expectedHash, 'hex'), + crypto.createPublicKey(publicKeyPem), + Buffer.from(bundle.signature, 'base64'), + ); + } catch { + return false; + } +} + +function exactKeys(value, expected, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (!same(actual, wanted)) throw new Error(`${label} has unexpected fields`); +} + +const EVENT_DATA_KEYS = Object.freeze({ + 'invocation.requested': ['invocationId', 'mode', 'skill', 'requestHash', 'creatorId', 'beneficiaryId'], + 'payment.offered': ['quote'], + 'payment.signed': ['settlementReference', 'payer'], + 'payment.settled': ['settlementReference', 'txHash', 'payer'], + 'payment.unresolved': ['reason'], + 'payment.rejected': ['reason'], + 'payment.refunded': ['reason', 'refundReference', 'refundAmountAtomic', 'reversalEntries'], + 'execution.started': ['executionAttemptId'], + 'execution.finished': ['executionAttemptId', 'outcome', 'outcomeHash', 'failureClass', 'message', 'accounting'], + 'receipt.issued': ['bundle'], +}); + +function deriveFullGrossRefundReversal(record) { + if (record.payment.state !== 'settled' || record.execution.state !== 'failed' + || record.accounting?.allocationState !== 'pending_cogs_reconciliation') { + throw new Error('refund v1 requires a settled terminal failed full-gross reconciliation'); + } + if ((record.accounting.holderCredits?.length ?? 0) !== 0 + || (record.accounting.ancestorCredits?.length ?? 0) !== 0) { + throw new Error('refund v1 refuses accounting with finalized Royalty claims'); + } + const [hold, ...extra] = record.accounting.journalEntries ?? []; + if (extra.length || !hold + || hold.category !== 'unresolved-execution-accounting' + || hold.debitAccountId !== 'wielder:external-gross' + || hold.creditAccountId !== 'hold:execution-accounting-reconciliation' + || hold.amountAtomic !== record.quote.amountAtomic + || record.accounting.grossAtomic !== record.quote.amountAtomic) { + throw new Error('refund v1 requires one exact full-gross reconciliation hold'); + } + return [{ + category: 'refund-reverse-reconciliation-hold', + debitAccountId: hold.creditAccountId, + creditAccountId: hold.debitAccountId, + amountAtomic: hold.amountAtomic, + }, { + category: 'refund-disbursement', + debitAccountId: 'wielder:external-gross', + creditAccountId: `refund:${record.payment.payer}`, + amountAtomic: record.quote.amountAtomic, + }]; +} + +function receiptPayload(record) { + return { + schemaVersion: 1, + revision: record.receiptHistory.length + 1, + supersedesReceiptHash: record.receiptHistory.at(-1)?.receiptHash ?? null, + sequence: record.lastSequence, + invocationId: record.invocationId, + idempotencyKey: record.idempotencyKey, + mode: record.mode, + skill: record.skill, + requestHash: record.requestHash, + creatorId: record.creatorId, + wielderId: record.wielderId, + beneficiaryId: record.beneficiaryId, + quote: record.quote, + payment: record.payment, + execution: record.execution, + accounting: record.accounting, + createdAt: record.createdAt, + completedAt: record.updatedAt, + }; +} + +export function createInvocationJournal({ + filePath = null, + signingKeyPath = null, + now = () => new Date().toISOString(), + createId = () => `inv-${crypto.randomUUID()}`, + signer = null, +} = {}) { + const journalPath = filePath ? safePersistentPath(filePath, 'persistent journal') : null; + const lockPath = journalPath ? `${journalPath}.lock` : null; + const canonicalSigningKeyPath = journalPath + ? safePersistentPath(signingKeyPath, 'persistent receipt key') + : null; + if (journalPath && journalPath === canonicalSigningKeyPath) throw new Error('journal and signing key paths must differ'); + if (journalPath && signer && signer.persistent !== true) { + throw new Error('persistent journal refuses an ephemeral receipt signer'); + } + const diskSigner = journalPath + ? loadOrCreateReceiptSigner(canonicalSigningKeyPath) + : null; + if (signer && diskSigner && signer.keyId !== diskSigner.keyId) { + throw new Error('injected receipt signer does not match persistent signingKeyPath'); + } + const receiptSigner = signer ?? diskSigner ?? createReceiptSigner(); + const records = new Map(); + const settlementReferences = new Map(); + const transactionHashes = new Map(); + const eventLog = []; + let nextSequence = 1; + let headHash = null; + + function validateQuote(quote) { + exactKeys(quote, [ + 'quoteId', 'amountAtomic', 'currency', 'network', 'asset', 'payTo', 'resource', + 'requestHash', 'requirementsHash', 'expiresAt', 'requirements', + ], 'payment quote'); + requireText(quote.quoteId, 'quoteId'); + requireAtomicString(quote.amountAtomic, 'amountAtomic'); + if (quote.currency !== 'USDC') throw new Error("currency must be 'USDC'"); + for (const field of ['network', 'resource', 'requestHash', 'requirementsHash', 'expiresAt']) { + requireText(quote[field], field); + } + if (canonicalAddress(quote.asset, 'asset') !== quote.asset + || canonicalAddress(quote.payTo, 'payTo') !== quote.payTo) { + throw new Error('indexed quote addresses must use canonical lowercase hex'); + } + exactKeys(quote.requirements, [ + 'scheme', 'network', 'maxAmountRequired', 'resource', 'description', 'mimeType', + 'payTo', 'maxTimeoutSeconds', 'asset', 'extra', + ], 'frozen PaymentRequirements'); + exactKeys(quote.requirements.extra, ['name', 'version', 'requestHash', 'quoteId', 'issuedAt', 'expiresAt'], 'PaymentRequirements.extra'); + if (quote.requirements.maxAmountRequired !== quote.amountAtomic + || quote.requirements.network !== quote.network + || canonicalAddress(quote.requirements.asset, 'requirements.asset') !== quote.asset + || canonicalAddress(quote.requirements.payTo, 'requirements.payTo') !== quote.payTo + || quote.requirements.resource !== quote.resource + || quote.requirements.extra.requestHash !== quote.requestHash + || quote.requirements.extra.quoteId !== quote.quoteId + || quote.requirements.extra.expiresAt !== quote.expiresAt) { + throw new Error('frozen x402 requirements do not match indexed quote fields'); + } + } + + function validateEventForApply(event) { + exactKeys(event, [ + 'schemaVersion', 'eventId', 'sequence', 'previousHash', 'type', 'idempotencyKey', + 'at', 'data', 'keyId', 'eventHash', 'eventSignature', + ], 'journal event'); + if (event.schemaVersion !== 1 || event.eventId !== `event-${String(event.sequence).padStart(8, '0')}`) { + throw new Error('journal event schema or identifier is invalid'); + } + if (!Number.isSafeInteger(event.sequence) || event.sequence < 1 || !Number.isFinite(Date.parse(event.at))) { + throw new Error('journal event sequence or timestamp is invalid'); + } + requireText(event.idempotencyKey, 'event.idempotencyKey'); + const dataKeys = EVENT_DATA_KEYS[event.type]; + if (!dataKeys) throw new Error(`unknown journal event '${event.type}'`); + exactKeys(event.data, dataKeys, `${event.type}.data`); + const record = records.get(event.idempotencyKey); + switch (event.type) { + case 'invocation.requested': + if (record) throw new Error(`duplicate request event for '${event.idempotencyKey}'`); + requireText(event.data.invocationId, 'invocationId'); + if (event.data.mode !== 'external') throw new Error("journal plan supports mode 'external' only"); + exactKeys(event.data.skill, ['id', 'versionHash'], 'skill'); + requireText(event.data.skill.id, 'skill.id'); + requireText(event.data.skill.versionHash, 'skill.versionHash'); + requireText(event.data.requestHash, 'requestHash'); + requireText(event.data.creatorId, 'creatorId'); + if (event.data.beneficiaryId != null) requireText(event.data.beneficiaryId, 'beneficiaryId'); + break; + case 'payment.offered': + if (!record || record.execution.state !== 'requested' || record.payment.state !== null || record.quote !== null) { + throw new Error('payment.offered requires one unquoted requested Invocation'); + } + validateQuote(event.data.quote); + break; + case 'payment.signed': + if (!record || record.payment.state !== 'offered') throw new Error('payment.signed requires offered payment'); + if (canonicalBytes32(event.data.settlementReference, 'settlementReference') !== event.data.settlementReference + || canonicalAddress(event.data.payer, 'payer') !== event.data.payer) { + throw new Error('signed payment identifiers must use canonical lowercase hex'); + } + assertUnique(settlementReferences, event.data.settlementReference, event.idempotencyKey, 'settlement reference'); + break; + case 'payment.settled': + if (!record || !['signed', 'unresolved'].includes(record.payment.state)) throw new Error('payment.settled requires signed or unresolved payment'); + if (record.payment.settlementReference !== event.data.settlementReference || record.payment.payer !== event.data.payer) { + throw new Error('settlement does not match signed payment'); + } + if (canonicalBytes32(event.data.txHash, 'txHash') !== event.data.txHash + || canonicalBytes32(event.data.settlementReference, 'settlementReference') !== event.data.settlementReference + || canonicalAddress(event.data.payer, 'payer') !== event.data.payer) { + throw new Error('settlement identifiers must use canonical lowercase hex'); + } + assertUnique(transactionHashes, event.data.txHash, event.idempotencyKey, 'transaction hash'); + break; + case 'payment.unresolved': + if (!record || record.payment.state !== 'signed') throw new Error('payment.unresolved requires signed payment'); + requireText(event.data.reason, 'reason'); + break; + case 'payment.rejected': + if (!record || !['offered', 'signed', 'unresolved'].includes(record.payment.state)) throw new Error('payment.rejected has invalid predecessor'); + requireText(event.data.reason, 'reason'); + break; + case 'payment.refunded': + if (!record) throw new Error('payment.refunded requires an Invocation'); + requireText(event.data.reason, 'reason'); + requireText(event.data.refundReference, 'refundReference'); + requireAtomicString(event.data.refundAmountAtomic, 'refundAmountAtomic'); + if (event.data.refundAmountAtomic !== record.quote.amountAtomic) { + throw new Error('refund v1 must return the full settled gross'); + } + if (!same(event.data.reversalEntries, deriveFullGrossRefundReversal(record))) { + throw new Error('refund reversal entries do not exactly reverse the pending hold'); + } + break; + case 'execution.started': + if (!record || record.payment.state !== 'settled' || record.execution.state !== 'authorized') throw new Error('execution.started requires authorized settled payment'); + requireText(event.data.executionAttemptId, 'executionAttemptId'); + break; + case 'execution.finished': + if (!record || record.execution.state !== 'executing') throw new Error('execution.finished requires executing state'); + if (event.data.executionAttemptId !== record.execution.executionAttemptId) throw new Error('execution attempt does not match atomic claim'); + if (!TERMINAL_EXECUTION.has(event.data.outcome)) throw new Error('execution outcome is not terminal'); + break; + case 'receipt.issued': { + if (!record || !TERMINAL_EXECUTION.has(record.execution.state) || record.receipt) throw new Error('receipt.issued requires one unreceipted terminal Invocation'); + if (!same(event.data.bundle.receipt, receiptPayload(record))) throw new Error('receipt does not byte-bind the immediately derived Invocation record'); + if (!verifySignedReceipt(event.data.bundle, { publicKeyPem: receiptSigner.publicKeyPem, keyId: receiptSigner.keyId })) { + throw new Error('receipt signature does not match the pinned Collar key'); + } + break; + } + default: + throw new Error(`unknown journal event '${event.type}'`); + } + } + + function apply(event) { + validateEventForApply(event); + let record = records.get(event.idempotencyKey); + switch (event.type) { + case 'invocation.requested': + if (record) throw new Error(`duplicate request event for '${event.idempotencyKey}'`); + record = { + schemaVersion: 1, + invocationId: event.data.invocationId, + idempotencyKey: event.idempotencyKey, + mode: event.data.mode, + skill: event.data.skill, + requestHash: event.data.requestHash, + creatorId: event.data.creatorId, + wielderId: null, + beneficiaryId: event.data.beneficiaryId, + quote: null, + payment: { + state: null, + settlementReference: null, + txHash: null, + payer: null, + reason: null, + refundReference: null, + refundAmountAtomic: null, + refundAccounting: null, + }, + execution: { + state: 'requested', executionAttemptId: null, + outcomeHash: null, failureClass: null, message: null, + }, + accounting: null, + receipt: null, + receiptHistory: [], + createdAt: event.at, + updatedAt: event.at, + lastSequence: event.sequence, + }; + records.set(event.idempotencyKey, record); + break; + case 'payment.offered': + record.quote = event.data.quote; + record.payment.state = 'offered'; + record.execution.state = 'quoted'; + break; + case 'payment.signed': + record.payment = { + ...record.payment, + state: 'signed', + settlementReference: event.data.settlementReference, + payer: event.data.payer, + reason: null, + }; + record.wielderId = event.data.payer; + record.beneficiaryId ??= event.data.payer; + settlementReferences.set(event.data.settlementReference, event.idempotencyKey); + break; + case 'payment.settled': + record.payment = { + ...record.payment, + state: 'settled', + settlementReference: event.data.settlementReference, + txHash: event.data.txHash, + payer: event.data.payer, + reason: null, + }; + record.wielderId = event.data.payer; + record.beneficiaryId ??= event.data.payer; + record.execution.state = 'authorized'; + settlementReferences.set(event.data.settlementReference, event.idempotencyKey); + transactionHashes.set(event.data.txHash, event.idempotencyKey); + break; + case 'payment.unresolved': + record.payment.state = 'unresolved'; + record.payment.reason = event.data.reason; + break; + case 'payment.rejected': + record.payment.state = 'rejected'; + record.payment.reason = event.data.reason; + record.execution.state = 'cancelled'; + break; + case 'payment.refunded': + record.payment.state = 'refunded'; + record.payment.reason = event.data.reason; + record.payment.refundReference = event.data.refundReference; + record.payment.refundAmountAtomic = event.data.refundAmountAtomic; + record.payment.refundAccounting = { + priorAllocationState: 'pending_cogs_reconciliation', + reversalEntries: event.data.reversalEntries, + }; + record.receipt = null; + break; + case 'execution.started': + record.execution.state = 'executing'; + record.execution.executionAttemptId = event.data.executionAttemptId; + break; + case 'execution.finished': + record.execution = { + state: event.data.outcome, + executionAttemptId: event.data.executionAttemptId, + outcomeHash: event.data.outcomeHash, + failureClass: event.data.failureClass, + message: event.data.message, + }; + record.accounting = event.data.accounting; + break; + case 'receipt.issued': + record.receipt = event.data.bundle; + record.receiptHistory.push(event.data.bundle); + break; + default: + throw new Error(`unknown journal event '${event.type}'`); + } + record.updatedAt = event.at; + record.lastSequence = event.sequence; + } + + const calculateEventHash = (eventWithoutHash) => crypto.createHash('sha256') + .update(canonicalJson(eventWithoutHash)).digest('hex'); + + function readVerifiedDiskEvents() { + if (!journalPath || !fs.existsSync(journalPath)) return []; + const text = fs.readFileSync(journalPath, 'utf8'); + if (!text) return []; + if (!text.endsWith('\n')) throw new Error('journal has a torn or unterminated final event'); + const lines = text.slice(0, -1).split('\n'); + let previousHash = null; + return lines.map((line, index) => { + if (!line) throw new Error(`journal contains a blank event at sequence ${index + 1}`); + const event = JSON.parse(line); + if (event.sequence !== index + 1) throw new Error(`journal sequence gap at ${index + 1}`); + if (event.previousHash !== previousHash) throw new Error(`journal hash-chain predecessor mismatch at ${index + 1}`); + const { eventHash, eventSignature, ...unsigned } = event; + const expectedHash = calculateEventHash(unsigned); + if (eventHash !== expectedHash) throw new Error(`journal event hash mismatch at ${index + 1}`); + if (event.keyId !== receiptSigner.keyId || !crypto.verify( + null, + Buffer.from(eventHash, 'hex'), + crypto.createPublicKey(receiptSigner.publicKeyPem), + Buffer.from(eventSignature, 'base64'), + )) throw new Error(`journal event signature mismatch at ${index + 1}`); + previousHash = eventHash; + return event; + }); + } + + function syncFromDisk() { + const diskEvents = readVerifiedDiskEvents(); + for (let index = 0; index < eventLog.length; index += 1) { + if (!same(eventLog[index], diskEvents[index])) throw new Error(`journal history changed at sequence ${index + 1}`); + } + for (const event of diskEvents.slice(eventLog.length)) { + apply(event); + eventLog.push(event); + } + nextSequence = diskEvents.length + 1; + headHash = diskEvents.at(-1)?.eventHash ?? null; + } + + function refreshFromAuthority() { + if (journalPath) withFileLock(lockPath, syncFromDisk); + } + + function append(type, idempotencyKey, data) { + const expectedRecordSequence = records.get(idempotencyKey)?.lastSequence ?? 0; + const write = () => { + if (journalPath) syncFromDisk(); + const currentRecordSequence = records.get(idempotencyKey)?.lastSequence ?? 0; + if (currentRecordSequence !== expectedRecordSequence) { + const error = new Error(`journal compare-and-swap conflict for '${idempotencyKey}'`); + error.name = 'JournalConflictError'; + error.code = 'JOURNAL_CONFLICT'; + throw error; + } + const unsigned = { + schemaVersion: 1, + eventId: `event-${String(nextSequence).padStart(8, '0')}`, + sequence: nextSequence, + previousHash: headHash, + type, + idempotencyKey, + at: now(), + data, + keyId: receiptSigner.keyId, + }; + const eventHash = calculateEventHash(unsigned); + const event = { + ...unsigned, + eventHash, + eventSignature: receiptSigner.signHash(eventHash), + }; + if (journalPath) { + const existed = fs.existsSync(journalPath); + const descriptor = fs.openSync(journalPath, 'a', 0o600); + try { + fs.writeFileSync(descriptor, `${JSON.stringify(event)}\n`); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + if (!existed) fsyncDirectory(path.dirname(journalPath)); + } + apply(event); + eventLog.push(event); + nextSequence += 1; + headHash = event.eventHash; + return event; + }; + return journalPath ? withFileLock(lockPath, write) : write(); + } + + function assertUnique(index, value, key, label) { + const existing = index.get(value); + if (existing && existing !== key) throw new Error(`${label} already binds idempotency key '${existing}'`); + } + + function requestInvocation(input) { + refreshFromAuthority(); + const key = requireText(input.idempotencyKey, 'idempotencyKey'); + const declaration = { + mode: input.mode === 'external' ? 'external' : (() => { throw new Error("journal plan supports mode 'external' only"); })(), + skill: { + id: requireText(input.skillId, 'skillId'), + versionHash: requireText(input.skillVersionHash, 'skillVersionHash'), + }, + requestHash: requireText(input.requestHash, 'requestHash'), + creatorId: requireText(input.creatorId, 'creatorId'), + beneficiaryId: input.beneficiaryId == null ? null : requireText(input.beneficiaryId, 'beneficiaryId'), + }; + const existing = records.get(key); + if (existing) { + const bound = { + mode: existing.mode, + skill: existing.skill, + requestHash: existing.requestHash, + creatorId: existing.creatorId, + beneficiaryId: existing.beneficiaryId, + }; + if (!same(bound, declaration)) throw new Error(`idempotency key already binds a different Invocation declaration`); + return copy(existing); + } + append('invocation.requested', key, { invocationId: createId(), ...declaration }); + return copy(records.get(key)); + } + + function offerExternalPayment(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + if (!input.requirements || typeof input.requirements !== 'object' || Array.isArray(input.requirements)) { + throw new Error('requirements must contain the complete frozen x402 envelope'); + } + const requirements = copy(input.requirements); + const quote = { + quoteId: requireText(input.quoteId, 'quoteId'), + amountAtomic: requireAtomicString(input.amountAtomic, 'amountAtomic'), + currency: input.currency === 'USDC' ? 'USDC' : (() => { throw new Error("currency must be 'USDC'"); })(), + network: requireText(input.network, 'network'), + asset: canonicalAddress(input.asset, 'asset'), + payTo: canonicalAddress(input.payTo, 'payTo'), + resource: requireText(input.resource, 'resource'), + requestHash: requireText(input.requestHash, 'requestHash'), + requirementsHash: requireText(input.requirementsHash, 'requirementsHash'), + expiresAt: requireText(input.expiresAt, 'expiresAt'), + requirements, + }; + if (requirements.maxAmountRequired !== quote.amountAtomic + || requirements.network !== quote.network + || canonicalAddress(requirements.asset, 'requirements.asset') !== quote.asset + || canonicalAddress(requirements.payTo, 'requirements.payTo') !== quote.payTo + || requirements.resource !== quote.resource + || requirements.extra?.requestHash !== quote.requestHash + || requirements.extra?.quoteId !== quote.quoteId + || requirements.extra?.expiresAt !== quote.expiresAt) { + throw new Error('frozen x402 requirements do not match indexed quote fields'); + } + if (record.quote) { + if (!same(record.quote, quote)) throw new Error('idempotency key already binds a different quote'); + return copy(record); + } + assertState(record, ['requested'], 'offerExternalPayment'); + append('payment.offered', key, { quote }); + return copy(records.get(key)); + } + + function markExternalPaymentSigned(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const settlementReference = canonicalBytes32(input.settlementReference, 'settlementReference'); + const payer = canonicalAddress(input.payer, 'payer'); + if (record.payment.settlementReference) { + if (record.payment.settlementReference !== settlementReference || record.payment.payer !== payer) { + throw new Error('idempotency key already binds a different signed payment'); + } + return copy(record); + } + if (record.payment.state !== 'offered') throw new Error(`markExternalPaymentSigned cannot run from payment state '${record.payment.state}'`); + assertUnique(settlementReferences, settlementReference, key, 'settlement reference'); + append('payment.signed', key, { settlementReference, payer }); + return copy(records.get(key)); + } + + function markExternalPaymentSettled(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const settlementReference = canonicalBytes32(input.settlementReference, 'settlementReference'); + const txHash = canonicalBytes32(input.txHash, 'txHash'); + const payer = canonicalAddress(input.payer, 'payer'); + if (record.payment.state === 'settled') { + if (record.payment.settlementReference !== settlementReference || record.payment.txHash !== txHash || record.payment.payer !== payer) { + throw new Error('idempotency key already binds a different settlement'); + } + return copy(record); + } + if (!['signed', 'unresolved'].includes(record.payment.state)) { + throw new Error(`markExternalPaymentSettled cannot run from payment state '${record.payment.state}'`); + } + if (record.payment.settlementReference !== settlementReference) throw new Error('settlement reference does not match signed payment'); + if (record.payment.payer !== payer) throw new Error('settlement payer does not match signed payment'); + assertUnique(transactionHashes, txHash, key, 'transaction hash'); + append('payment.settled', key, { settlementReference, txHash, payer }); + return copy(records.get(key)); + } + + function markExternalPaymentUnresolved(key, { reason }) { + refreshFromAuthority(); + const record = requireRecord(records, key); + if (record.payment.state === 'unresolved' && record.payment.reason === reason) return copy(record); + if (record.payment.state !== 'signed') throw new Error(`markExternalPaymentUnresolved cannot run from payment state '${record.payment.state}'`); + append('payment.unresolved', key, { reason: requireText(reason, 'reason') }); + return copy(records.get(key)); + } + + function reconcileExternalSettlement({ settlementReference, txHash, payer }) { + refreshFromAuthority(); + const reference = canonicalBytes32(settlementReference, 'settlementReference'); + const key = settlementReferences.get(reference); + if (!key) throw new Error(`unknown settlement reference '${reference}'`); + return markExternalPaymentSettled(key, { settlementReference: reference, txHash, payer }); + } + + function rejectExternalPayment(key, { reason }) { + refreshFromAuthority(); + const record = requireRecord(records, key); + if (record.payment.state === 'rejected' && record.payment.reason === reason) return copy(record); + if (!['offered', 'signed', 'unresolved'].includes(record.payment.state)) { + throw new Error(`rejectExternalPayment cannot run from payment state '${record.payment.state}'`); + } + append('payment.rejected', key, { reason: requireText(reason, 'reason') }); + return copy(records.get(key)); + } + + function refundExternalPayment(key, { reason, refundReference, refundAmountAtomic }) { + refreshFromAuthority(); + const record = requireRecord(records, key); + if (record.payment.state === 'refunded') { + const existing = { + reason: record.payment.reason, + refundReference: record.payment.refundReference, + refundAmountAtomic: record.payment.refundAmountAtomic, + reversalEntries: record.payment.refundAccounting.reversalEntries, + }; + const requested = { + reason: requireText(reason, 'reason'), + refundReference: requireText(refundReference, 'refundReference'), + refundAmountAtomic: requireAtomicString(refundAmountAtomic, 'refundAmountAtomic'), + reversalEntries: record.payment.refundAccounting.reversalEntries, + }; + if (!same(existing, requested)) throw new Error('Invocation already binds a different refund'); + return copy(record); + } + const refund = { + reason: requireText(reason, 'reason'), + refundReference: requireText(refundReference, 'refundReference'), + refundAmountAtomic: requireAtomicString(refundAmountAtomic, 'refundAmountAtomic'), + reversalEntries: deriveFullGrossRefundReversal(record), + }; + if (refund.refundAmountAtomic !== record.quote.amountAtomic) { + throw new Error('refund v1 must return the full settled gross'); + } + append('payment.refunded', key, refund); + return copy(records.get(key)); + } + + function startExecution(key, { executionAttemptId = null } = {}) { + refreshFromAuthority(); + const record = requireRecord(records, key); + if (record.execution.state === 'executing') return { started: false, record: copy(record) }; + if (record.payment.state !== 'settled') throw new Error('external execution requires a settled payment'); + assertState(record, ['authorized'], 'startExecution'); + const attempt = executionAttemptId ?? `attempt:${crypto.createHash('sha256') + .update(`${record.invocationId}\n${record.requestHash}`).digest('hex')}`; + append('execution.started', key, { executionAttemptId: requireText(attempt, 'executionAttemptId') }); + return { started: true, record: copy(records.get(key)) }; + } + + function finishExecution(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const outcome = requireText(input.outcome, 'outcome'); + if (!TERMINAL_EXECUTION.has(outcome)) throw new Error(`unsupported execution outcome '${outcome}'`); + const data = { + executionAttemptId: requireText(input.executionAttemptId ?? record.execution.executionAttemptId, 'executionAttemptId'), + outcome, + outcomeHash: input.outcomeHash ?? null, + failureClass: input.failureClass ?? null, + message: input.message ?? null, + accounting: input.accounting ?? null, + }; + if (TERMINAL_EXECUTION.has(record.execution.state)) { + const terminal = { ...record.execution, accounting: record.accounting }; + const expected = { + state: data.outcome, + executionAttemptId: data.executionAttemptId, + outcomeHash: data.outcomeHash, + failureClass: data.failureClass, + message: data.message, + accounting: data.accounting, + }; + if (!same(terminal, expected)) throw new Error('idempotency key already binds a different execution outcome'); + return copy(record); + } + assertState(record, ['executing'], 'finishExecution'); + append('execution.finished', key, data); + return copy(records.get(key)); + } + + function issueReceipt(key) { + refreshFromAuthority(); + const record = requireRecord(records, key); + if (record.receipt) return copy(record.receipt); + if (!TERMINAL_EXECUTION.has(record.execution.state)) throw new Error('receipt requires a terminal execution outcome'); + const receipt = receiptPayload(record); + const receiptHash = crypto.createHash('sha256').update(canonicalJson(receipt)).digest('hex'); + const bundle = { + receipt, + receiptHash, + signature: receiptSigner.signHash(receiptHash), + algorithm: receiptSigner.algorithm, + keyId: receiptSigner.keyId, + }; + append('receipt.issued', key, { bundle }); + return copy(bundle); + } + + if (journalPath) withFileLock(lockPath, syncFromDisk); + return Object.freeze({ + requestInvocation, + offerExternalPayment, + markExternalPaymentSigned, + markExternalPaymentSettled, + markExternalPaymentUnresolved, + reconcileExternalSettlement, + rejectExternalPayment, + refundExternalPayment, + startExecution, + finishExecution, + issueReceipt, + getByIdempotencyKey: (key) => { + refreshFromAuthority(); + return records.has(key) ? copy(records.get(key)) : null; + }, + getBySettlementReference: (reference) => { + refreshFromAuthority(); + const key = settlementReferences.get(canonicalBytes32(reference, 'settlementReference')); + return key ? copy(records.get(key)) : null; + }, + getByTxHash: (txHash) => { + refreshFromAuthority(); + const key = transactionHashes.get(canonicalBytes32(txHash, 'txHash')); + return key ? copy(records.get(key)) : null; + }, + get events() { refreshFromAuthority(); return copy(eventLog); }, + signingPublicKeyPem: receiptSigner.publicKeyPem, + signingKeyId: receiptSigner.keyId, + }); +} +``` + +- [ ] **Step 5: Run the journal tests** + +Run: `npm run test:journal --prefix spikes/pi-wielder` + +Expected: PASS, 12 tests and 0 failures, including two independent Node processes +writing one sequence/hash chain without a lost or torn event and a fresh process reading +the complete frozen offer needed by `loadFrozenOffer`. + +- [ ] **Step 6: Commit the authoritative journal core** + +```bash +git add .gitignore spikes/pi-wielder/package.json spikes/pi-wielder/src/invocation-journal.mjs spikes/pi-wielder/tests/invocation-journal.test.mjs spikes/pi-wielder/tests/journal-writer-fixture.mjs spikes/pi-wielder/tests/journal-reader-fixture.mjs +git commit -m "feat: add authoritative Invocation journal" +``` + +### Task 2: Emit x402 payment lifecycle events under one idempotency key + +**Files:** +- Modify: `spikes/pi-wielder/src/x402-seller.mjs:35-152` +- Modify: `spikes/pi-wielder/src/proxy.mjs:34-81` +- Create: `spikes/pi-wielder/tests/x402-lifecycle.test.mjs` + +- [ ] **Step 1: Write a failing x402 lifecycle contract test** + +Create `spikes/pi-wielder/tests/x402-lifecycle.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import test from 'node:test'; + +import { Hono } from 'hono'; +import { createMockFacilitator } from '../src/facilitator-mock.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; +import { payingFetch } from '../src/proxy.mjs'; +import { + APPROVED_LIVE_FACILITATOR_BASE, + createLiveFacilitatorTransport, + createMockFacilitatorTransport, + x402Paywall, +} from '../src/x402-seller.mjs'; + +test('challenge and retry emit one ordered lifecycle under one idempotency key', async () => { + const facilitator = createMockFacilitator(); + const facilitatorTransport = createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); + const calls = []; + const lifecycle = Object.fromEntries([ + 'onOffered', 'onSigned', 'onSettled', 'onUnresolved', 'onRejected', + ].map((name) => [name, async (payload) => calls.push([name, payload])])); + const app = new Hono(); + app.post('/resource', x402Paywall({ + price: '0.25', + payTo: '0x000000000000000000000000000000000000dEaD', + facilitatorTransport, + lifecycle, + }), (c) => c.json({ ok: true })); + + const result = await payingFetch( + throwawayAccount(), + 'http://seller.test/resource', + { method: 'POST', body: '{}' }, + { fetchImpl: (url, init) => app.request(url, init), idempotencyKey: 'idem-lifecycle' }, + ); + assert.equal(result.res.status, 200); + assert.deepEqual(calls.map(([name]) => name), ['onOffered', 'onSigned', 'onSettled']); + assert.ok(calls.every(([, payload]) => payload.idempotencyKey === 'idem-lifecycle')); + assert.deepEqual(calls[0][1].requirements, calls[1][1].requirements); + assert.match(calls[0][1].requirements.extra.requestHash, /^sha256:[0-9a-f]{64}$/); + assert.equal(calls[2][1].settlementReference, result.settlementReference); + assert.equal(calls[2][1].txHash, result.txHash); +}); + +test('a restarted paywall recovers the complete byte-identical frozen offer before accepting the paid retry', async () => { + const facilitator = createMockFacilitator(); + const facilitatorTransport = createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); + let persistedRequirements = null; + let fetchCount = 0; + const beforeRestart = new Hono(); + beforeRestart.post('/resource', x402Paywall({ + price: '0.25', + payTo: '0x000000000000000000000000000000000000dEaD', + facilitatorTransport, + lifecycle: { + async onOffered({ requirements }) { persistedRequirements = structuredClone(requirements); }, + }, + }), (c) => c.json({ shouldNotExecute: true })); + const afterRestart = new Hono(); + afterRestart.post('/resource', x402Paywall({ + price: '9.99', + payTo: '0x0000000000000000000000000000000000000001', + facilitatorTransport, + lifecycle: { + async loadFrozenOffer() { return structuredClone(persistedRequirements); }, + }, + }), (c) => c.json({ ok: true })); + + const result = await payingFetch( + throwawayAccount(), + 'http://seller.test/resource', + { method: 'POST', body: '{"input":"same bytes"}' }, + { + idempotencyKey: 'idem-restart', + fetchImpl: (url, init) => (++fetchCount === 1 ? beforeRestart : afterRestart).request(url, init), + }, + ); + assert.equal(result.res.status, 200); + assert.equal(fetchCount, 2); + assert.equal(persistedRequirements.maxAmountRequired, '250000'); + assert.equal(persistedRequirements.payTo, '0x000000000000000000000000000000000000dEaD'); +}); + +test('a paid request without Idempotency-Key is rejected before signing or settlement', async () => { + const app = new Hono(); + app.post('/resource', x402Paywall({ + price: '0.25', + payTo: '0x000000000000000000000000000000000000dEaD', + facilitatorTransport: createMockFacilitatorTransport(async () => { throw new Error('must not run'); }), + }), (c) => c.json({ ok: true })); + const res = await app.request('http://seller.test/resource', { method: 'POST', body: '{}' }); + assert.equal(res.status, 400); + assert.match((await res.json()).error, /Idempotency-Key/); +}); + +test('live facilitator configuration pins one exact HTTPS origin and base path before authorization exists', () => { + let networkCalls = 0; + for (const malicious of [ + 'http://x402.org/facilitator', + 'https://user:pass@x402.org/facilitator', + 'https://x402.org:8443/facilitator', + 'https://x402.org/facilitator/', + 'https://x402.org/facilitator/verify', + 'https://x402.org/facilitator?next=https://evil.test', + 'https://x402.org/facilitator#evil', + ]) { + assert.throws(() => createLiveFacilitatorTransport(malicious, async () => { networkCalls += 1; }), + (error) => error.code === 'FACILITATOR_NOT_APPROVED'); + } + assert.equal(networkCalls, 0); + assert.doesNotThrow(() => createLiveFacilitatorTransport( + APPROVED_LIVE_FACILITATOR_BASE, + async () => { networkCalls += 1; }, + )); +}); + +test('verify and settle disable redirects so signed authorization cannot follow a new destination', async () => { + for (const redirectOperation of ['verify', 'settle']) { + const destinations = []; + const transport = createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname.slice(1); + destinations.push([url, init.redirect]); + if (operation === redirectOperation) { + return new Response(null, { status: 302, headers: { location: 'https://evil.test/collect' } }); + } + return new Response(JSON.stringify({ isValid: true }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + }); + const app = new Hono(); + app.post('/resource', x402Paywall({ + price: '0.25', payTo: '0x000000000000000000000000000000000000dEaD', + facilitatorTransport: transport, + }), (c) => c.json({ ok: true })); + const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', + { method: 'POST', body: '{}' }, { fetchImpl: (url, init) => app.request(url, init) }); + assert.equal(result.res.status, 503); + assert.equal(destinations.at(-1)[0], `http://facilitator.invalid/${redirectOperation}`); + assert.ok(destinations.every(([, redirect]) => redirect === 'error')); + assert.ok(destinations.every(([url]) => !url.startsWith('https://evil.test'))); + } +}); +``` + +- [ ] **Step 2: Run the test and verify `payingFetch` is not exported** + +Run: `node --test spikes/pi-wielder/tests/x402-lifecycle.test.mjs` + +Expected: FAIL because `payingFetch` is not exported and the paywall has no lifecycle contract. + +- [ ] **Step 3: Replace float conversion with the atomic boundary** + +At the top of `spikes/pi-wielder/src/x402-seller.mjs`, add: + +```js +import crypto from 'node:crypto'; +import { formatUsdc, parseUsdc } from '../../../prototype/atomic-money.mjs'; +``` + +Replace the two conversion exports with: + +```js +export const usdcToAtomic = (usdc) => parseUsdc(usdc).toString(); +export const atomicToUsdc = (atomic) => formatUsdc(BigInt(atomic)); +``` + +Update the historical display assertion in `tests/atomic-boundary.test.mjs` to expect +`'0.250000'`, not `0.25`. No money value crosses back through a JavaScript `number`; +x402 requirements, receipts, ledger state, and UI display helpers all use canonical strings. + +- [ ] **Step 4: Add the lifecycle hook contract to `x402Paywall`** + +Change the function signature to: + +```js +export function x402Paywall({ + price, + payTo, + facilitatorTransport, + description = '', + lifecycle = {}, +}) { +``` + +Add these constructors above `x402Paywall`; the private `WeakSet` prevents callers from +smuggling an arbitrary structural object into the transport boundary: + +```js +export const APPROVED_LIVE_FACILITATOR_BASE = 'https://x402.org/facilitator'; +const authorizedTransports = new WeakSet(); + +function authorizeTransport(transport) { + authorizedTransports.add(transport); + return Object.freeze(transport); +} + +export function createMockFacilitatorTransport(fetchImpl) { + if (typeof fetchImpl !== 'function') throw new TypeError('mock facilitator requires an injected fetch/app'); + return authorizeTransport({ + mode: 'mock', baseUrl: 'http://facilitator.invalid', fetchImpl, + }); +} + +export function createLiveFacilitatorTransport(rawBaseUrl, fetchImpl = fetch) { + // Exact-string pinning intentionally rejects credentials, explicit ports, query, + // fragment, trailing slash, alternate paths, schemes, and origins before any fetch. + if (rawBaseUrl !== APPROVED_LIVE_FACILITATOR_BASE) { + const error = new Error('live facilitator is not the pinned approved endpoint'); + error.code = 'FACILITATOR_NOT_APPROVED'; + throw error; + } + const parsed = new URL(rawBaseUrl); + if (parsed.protocol !== 'https:' || parsed.username || parsed.password + || parsed.port || parsed.search || parsed.hash || parsed.pathname !== '/facilitator') { + const error = new Error('live facilitator endpoint violates the approved HTTPS contract'); + error.code = 'FACILITATOR_NOT_APPROVED'; + throw error; + } + return authorizeTransport({ mode: 'live', baseUrl: rawBaseUrl, fetchImpl }); +} + +function requireFacilitatorTransport(transport) { + if (!transport || !authorizedTransports.has(transport)) { + throw new Error('facilitatorTransport must come from an approved live or injected-mock constructor'); + } + return transport; +} +``` + +At `x402Paywall` construction—not inside the request handler—call +`const transport = requireFacilitatorTransport(facilitatorTransport);`. Therefore bad +live configuration fails before a Wielder can sign and no authorization can be sent. + +Inside `x402Paywall`, add this cache next to the existing `consumed` set: + +```js + const frozenOffers = new Map(); // idempotency key -> byte-stable PaymentRequirements +``` + +Replace the per-request requirements construction, key check, and no-payment branch +with the complete frozen-envelope block below. Hono caches the request text, so the +downstream JSON handler still receives the same bytes. + +```js + const idempotencyKey = c.req.header('Idempotency-Key')?.trim(); + if (!idempotencyKey) return c.json({ error: 'Idempotency-Key header is required' }, 400); + const paymentHeader = c.req.header('X-PAYMENT'); + const requestBody = await c.req.text(); + const requestHash = `sha256:${crypto.createHash('sha256') + .update(`${c.req.method}\n${c.req.url}\n${requestBody}`) + .digest('hex')}`; + + let requirements = frozenOffers.get(idempotencyKey); + if (!requirements) { + const recovered = await lifecycle.loadFrozenOffer?.({ idempotencyKey }); + if (recovered) { + requirements = structuredClone(recovered); + frozenOffers.set(idempotencyKey, requirements); + } + } + if (requirements) { + if (requirements.extra.requestHash !== requestHash) { + return c.json({ error: 'Idempotency-Key already binds a different request body' }, 409); + } + } else { + if (paymentHeader) return c.json({ error: 'paid retry has no prior frozen x402 offer' }, 409); + const priceUsdc = typeof price === 'function' ? await price(c) : price; + const issuedAt = new Date().toISOString(); + const expiresAt = new Date(Date.now() + 60_000).toISOString(); + const base = { + scheme: 'exact', + network: NETWORK, + maxAmountRequired: usdcToAtomic(priceUsdc), + resource: c.req.url, + description, + mimeType: 'application/json', + payTo, + maxTimeoutSeconds: 60, + asset: USDC_ADDRESS, + }; + const quoteId = `sha256:${crypto.createHash('sha256') + .update(JSON.stringify({ ...base, requestHash, issuedAt, expiresAt })) + .digest('hex')}`; + requirements = { + ...base, + extra: { + name: USDC_EIP712.name, + version: USDC_EIP712.version, + requestHash, + quoteId, + issuedAt, + expiresAt, + }, + }; + frozenOffers.set(idempotencyKey, requirements); + } + + if (!paymentHeader) { + await lifecycle.onOffered?.({ + idempotencyKey, + requirements, + expiresAt: requirements.extra.expiresAt, + }); + return c.json( + { x402Version: X402_VERSION, error: 'X-PAYMENT header is required', accepts: [requirements] }, + 402, + ); + } +``` + +Delete the original `priceUsdc`, `requirements`, `paymentHeader`, and no-payment block. +The exact same requirements object now drives the challenge, retry verification, +journal `requirementsHash`, and Wielder policy fingerprint. + +`loadFrozenOffer` is the restart boundary: the Collar returns the complete persisted +`record.quote.requirements` envelope. Do not regenerate timestamps, `quoteId`, resource, +or any other field from hashes. A post-restart challenge and paid retry therefore use +the same JSON property/value envelope that the original Wielder accepted. + +After decoding `paymentPayload` and before facilitator verification, add: + +```js + const settlementReference = paymentPayload?.payload?.authorization?.nonce; + const payer = paymentPayload?.payload?.authorization?.from; + if (!settlementReference || !payer) { + await lifecycle.onRejected?.({ idempotencyKey, reason: 'payment authorization lacks nonce or payer' }); + return c.json({ x402Version: X402_VERSION, error: 'payment authorization lacks nonce or payer', accepts: [requirements] }, 402); + } + let priorDecision = null; + try { + priorDecision = await lifecycle.onSigned?.({ + idempotencyKey, + settlementReference, + payer, + requirements, + }); + } catch (error) { + return c.json({ error: error.message }, 409); + } + if (priorDecision?.kind === 'terminal') { + const replay = c.json({ replayed: true, receipt: priorDecision.receipt }, 200); + replay.headers.set('X-PAYMENT-RESPONSE', jsonToB64({ + success: true, + transaction: priorDecision.txHash, + network: NETWORK, + payer: priorDecision.payer, + settlementReference, + })); + return replay; + } + if (priorDecision?.kind === 'execution_unresolved') { + return c.json({ + error: 'execution outcome unresolved; trusted executor reconciliation is required', + executionAttemptId: priorDecision.executionAttemptId, + }, 503); + } +``` + +Replace the verify/settle block with this fault-aware and reconciliation-aware version: + +```js + let settle; + let facilitatorMs = 0; + if (priorDecision?.kind === 'settled') { + settle = { + success: true, + transaction: priorDecision.txHash, + payer: priorDecision.payer, + network: NETWORK, + }; + } else { + const tFacilitator = performance.now(); + try { + const verify = await postJson(transport, 'verify', facilitatorBody); + if (!verify?.isValid) { + const reason = `payment verification failed: ${verify?.invalidReason ?? 'unknown'}`; + await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); + return c.json({ x402Version: X402_VERSION, error: reason, accepts: [requirements] }, 402); + } + settle = await postJson(transport, 'settle', facilitatorBody); + } catch (error) { + await lifecycle.onUnresolved?.({ + idempotencyKey, + settlementReference, + payer, + reason: `facilitator response unresolved: ${error.message}`, + }); + return c.json({ error: 'payment settlement unresolved', settlementReference }, 503); + } + facilitatorMs = performance.now() - tFacilitator; + } + if (!settle?.success) { + const reason = `payment settlement failed: ${settle?.errorReason ?? 'unknown'}`; + await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); + return c.json({ x402Version: X402_VERSION, error: reason, accepts: [requirements] }, 402); + } + if (priorDecision?.kind !== 'settled') { + await lifecycle.onSettled?.({ + idempotencyKey, + settlementReference, + txHash: settle.transaction, + payer: settle.payer ?? payer, + amountAtomic: requirements.maxAmountRequired, + requirements, + }); + } +``` + +Remove the old duplicate verify/settle declarations and failure branches. Extend the +existing `c.set('x402', ...)` payload to exactly: + +```js + c.set('x402', { + idempotencyKey, + settlementReference, + txHash: settle.transaction, + payer: settle.payer ?? payer, + amountAtomic: requirements.maxAmountRequired, + requirements, + }); +``` + +- [ ] **Step 5: Export `payingFetch` and preserve one key across both requests** + +Add `import { formatUsdc } from '../../../prototype/atomic-money.mjs';` to +`proxy.mjs`; this is a display-string boundary, not seller-side trust logic. + +Change the `payingFetch` signature in `spikes/pi-wielder/src/proxy.mjs` to: + +```js +export async function payingFetch(account, url, init, { + fetchImpl = fetch, + idempotencyKey = crypto.randomUUID(), +} = {}) { +``` + +Replace both fetch calls with these exact forms: + +```js + const requestHeaders = { ...init.headers, 'Idempotency-Key': idempotencyKey }; + const first = await fetchImpl(url, { ...init, headers: requestHeaders }); +``` + +```js + const res = await fetchImpl(url, { + ...init, + headers: { ...requestHeaders, 'X-PAYMENT': xPayment }, + }); +``` + +Read the settlement response once and extend the returned object: + +```js + const paymentResponse = res.headers.get('X-PAYMENT-RESPONSE'); + const settlement = paymentResponse ? unb64(paymentResponse) : null; + return { + res, + paid: true, + xPayment, + idempotencyKey, + settlementReference: authorization.nonce, + txHash: settlement?.transaction ?? null, + payer: account.address.toLowerCase(), + requestHash: req.extra.requestHash, + quoteId: req.extra.quoteId, + amountAtomic: String(req.maxAmountRequired), + amountDisplay: formatUsdc(BigInt(req.maxAmountRequired)), + timings: { ms402, msSign, msFacilitator, msPaidRoundtrip, msOverhead: ms402 + msSign + (msFacilitator || 0) }, + }; +``` + +Delete the original return block so the retry still occurs exactly once. + +Add `settlementReference` to the normal `X-PAYMENT-RESPONSE` payload as well: + +```js +jsonToB64({ + success: true, + transaction: settle.transaction, + network: NETWORK, + payer: settle.payer ?? payer, + settlementReference, +}) +``` + +Finally, replace `postJson` with a strict injectable transport: + +```js +async function postJson(transport, operation, body) { + if (!['verify', 'settle'].includes(operation)) throw new Error('invalid facilitator operation'); + const url = `${transport.baseUrl}/${operation}`; + const res = await transport.fetchImpl(url, { + method: 'POST', + redirect: 'error', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`facilitator HTTP ${res.status}`); + const json = await res.json().catch(() => null); + if (!json) throw new Error('facilitator returned no JSON result'); + return json; +} +``` + +- [ ] **Step 6: Run the lifecycle tests** + +Run: `node --test spikes/pi-wielder/tests/x402-lifecycle.test.mjs` + +Expected: PASS, 5 tests and 0 failures, including endpoint pinning, redirect refusal, +and full frozen-offer recovery across +a simulated paywall process restart. + +- [ ] **Step 7: Commit lifecycle correlation** + +```bash +git add spikes/pi-wielder/src/x402-seller.mjs spikes/pi-wielder/src/proxy.mjs spikes/pi-wielder/tests/x402-lifecycle.test.mjs +git commit -m "feat: journal x402 payment lifecycle" +``` + +### Task 3: Make the Collar own execution outcomes and signed receipts + +**Files:** +- Modify: `spikes/pi-wielder/src/collar.mjs:1-142` +- Create: `spikes/pi-wielder/tests/collar-failure.test.mjs` + +- [ ] **Step 1: Write the failing settled-then-500 integration test** + +Create `spikes/pi-wielder/tests/collar-failure.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import test from 'node:test'; + +import { chooseFacilitator, startCollar, SKILL_ID } from '../src/collar.mjs'; +import { createMockFacilitator } from '../src/facilitator-mock.mjs'; +import { payingFetch, startProxy } from '../src/proxy.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; +import { verifySignedReceipt } from '../src/invocation-journal.mjs'; +import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; + +test('standalone settlement defaults to an injected offline mock and never a live URL', async () => { + let mockStarts = 0; + const selected = await chooseFacilitator({ + env: {}, + startMock: async () => { mockStarts += 1; return createMockFacilitator(); }, + }); + assert.equal(selected.mode, 'mock'); + assert.equal(typeof selected.transport, 'object'); + assert.equal(mockStarts, 1); + await assert.rejects(() => chooseFacilitator({ env: { ALLOW_LIVE_X402: '1' } }), /requires/); +}); + +test('settled-then-500 remains authoritative and queryable without another debit', async () => { + const facilitator = createMockFacilitator(); + const collar = await startCollar({ + facilitatorTransport: createMockFacilitatorTransport((url, init) => facilitator.request(url, init)), + executeSkill: async () => { throw new Error('injected provider fault'); }, + }); + const proxy = await startProxy({ + account: throwawayAccount(), + collarUrl: collar.url, + trustedCollarPublicKeyPem: collar.signingPublicKeyPem, + trustedCollarKeyId: collar.signingKeyId, + }); + + try { + const res = await fetch(`${proxy.url}/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'trigger the injected failure' }), + }); + const body = await res.json(); + assert.equal(res.status, 500); + assert.equal(verifySignedReceipt(body.receipt, { + publicKeyPem: collar.signingPublicKeyPem, + keyId: collar.signingKeyId, + }), true); + assert.equal(body.receipt.receipt.payment.state, 'settled'); + assert.match(body.receipt.receipt.payment.txHash, /^0x[0-9a-f]{64}$/); + assert.equal(body.receipt.receipt.execution.state, 'failed'); + + const reference = body.receipt.receipt.payment.settlementReference; + const eventCount = collar.journal.events.length; + const reconciled = await fetch(`${collar.url}/receipts/by-settlement/${reference}`); + assert.equal(reconciled.status, 200); + assert.deepEqual((await reconciled.json()).receipt, body.receipt); + assert.equal(collar.journal.events.length, eventCount); + assert.equal(collar.journal.events.filter((event) => event.type === 'payment.settled').length, 1); + } finally { + proxy.close(); + collar.close(); + } +}); + +test('trusted reconciliation resumes once and exact retries return the cached receipt', async () => { + const facilitatorApp = createMockFacilitator(); + let lostSettlement = null; + let settleCalls = 0; + let executions = 0; + const facilitatorFetch = async (url, init) => { + const response = await facilitatorApp.request(url, init); + if (new URL(url).pathname === '/settle') { + settleCalls += 1; + lostSettlement = await response.clone().json(); + throw new Error('injected lost facilitator response'); + } + return response; + }; + const collar = await startCollar({ + facilitatorTransport: createMockFacilitatorTransport(facilitatorFetch), + resolveSettlement: async ({ settlementReference, amountAtomic }) => ({ + settled: true, + settlementReference, + amountAtomic, + txHash: lostSettlement.transaction, + payer: lostSettlement.payer, + }), + executeSkill: async ({ input }) => { + executions += 1; + return { output: `executed ${input}` }; + }, + }); + const account = throwawayAccount(); + const idempotencyKey = 'idem-response-loss'; + const requestBody = JSON.stringify({ input: 'same bytes' }); + + try { + const first = await payingFetch(account, `${collar.url}/invoke/${SKILL_ID}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { idempotencyKey }); + assert.equal(first.res.status, 503); + assert.equal(collar.journal.getBySettlementReference(first.settlementReference).payment.state, 'unresolved'); + + const reconcile = await fetch(`${collar.url}/reconcile/by-settlement/${first.settlementReference}`, { method: 'POST' }); + assert.equal(reconcile.status, 200); + assert.equal((await reconcile.json()).txHash, lostSettlement.transaction); + + const retry = await fetch(`${collar.url}/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': first.xPayment, + }, + body: requestBody, + }); + assert.equal(retry.status, 200); + const terminalBody = await retry.json(); + assert.equal(executions, 1); + assert.equal(verifySignedReceipt(terminalBody.receipt, { + publicKeyPem: collar.signingPublicKeyPem, + keyId: collar.signingKeyId, + }), true); + + const exactRetry = await fetch(`${collar.url}/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': first.xPayment, + }, + body: requestBody, + }); + assert.equal(exactRetry.status, 200); + const cachedBody = await exactRetry.json(); + assert.equal(cachedBody.replayed, true); + assert.deepEqual(cachedBody.receipt, terminalBody.receipt); + assert.equal(executions, 1); + + const conflictingRetry = await fetch(`${collar.url}/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': first.xPayment, + }, + body: JSON.stringify({ input: 'different bytes' }), + }); + assert.equal(conflictingRetry.status, 409); + assert.equal(executions, 1); + assert.equal(settleCalls, 1); + assert.equal(collar.journal.events.filter((event) => event.type === 'payment.settled').length, 1); + assert.equal(collar.journal.events.filter((event) => event.type === 'execution.started').length, 1); + } finally { + collar.close(); + } +}); + +async function prepareReconciledRetry({ executeSkill, lifecycleFaults = {} }) { + const facilitatorApp = createMockFacilitator(); + let lostSettlement; + const facilitatorFetch = async (url, init) => { + const response = await facilitatorApp.request(url, init); + if (new URL(url).pathname === '/settle') { + lostSettlement = await response.clone().json(); + throw new Error('injected response loss before execution'); + } + return response; + }; + const collar = await startCollar({ + facilitatorTransport: createMockFacilitatorTransport(facilitatorFetch), executeSkill, lifecycleFaults, + resolveSettlement: async ({ settlementReference, amountAtomic }) => ({ + settled: true, settlementReference, amountAtomic, + txHash: lostSettlement.transaction, payer: lostSettlement.payer, + }), + }); + const idempotencyKey = `idem-crash-${crypto.randomUUID()}`; + const requestBody = JSON.stringify({ input: 'same bytes' }); + const first = await payingFetch(throwawayAccount(), `${collar.url}/invoke/${SKILL_ID}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { idempotencyKey }); + assert.equal(first.res.status, 503); + const reconcile = await fetch(`${collar.url}/reconcile/by-settlement/${first.settlementReference}`, { method: 'POST' }); + assert.equal(reconcile.status, 200); + const retry = () => fetch(`${collar.url}/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': first.xPayment, + }, + body: requestBody, + }); + return { collar, idempotencyKey, retry }; +} + +test('crash after the provider returns leaves one unresolved attempt and never calls the provider again', async () => { + let executions = 0; + const prepared = await prepareReconciledRetry({ + executeSkill: async () => { executions += 1; return { output: 'completed but not journaled' }; }, + lifecycleFaults: { afterExecutorReturned: async () => { throw new Error('crash after provider return'); } }, + }); + try { + assert.equal((await prepared.retry()).status, 500); + assert.equal(executions, 1); + assert.equal((await prepared.retry()).status, 503); + assert.equal(executions, 1); + const record = prepared.collar.journal.getByIdempotencyKey(prepared.idempotencyKey); + assert.equal(record.execution.state, 'executing'); + assert.match(record.execution.executionAttemptId, /^attempt:/); + } finally { + prepared.collar.close(); + } +}); + +test('crash after finish but before receipt issuance replays the terminal receipt without another provider call', async () => { + let executions = 0; + let crash = true; + const prepared = await prepareReconciledRetry({ + executeSkill: async () => { executions += 1; return { output: 'journaled output' }; }, + lifecycleFaults: { afterExecutionFinished: async () => { + if (crash) { crash = false; throw new Error('crash before receipt append'); } + } }, + }); + try { + assert.equal((await prepared.retry()).status, 500); + assert.equal(executions, 1); + assert.equal(prepared.collar.journal.getByIdempotencyKey(prepared.idempotencyKey).receipt, null); + const replay = await prepared.retry(); + assert.equal(replay.status, 200); + const body = await replay.json(); + assert.equal(body.replayed, true); + assert.equal(executions, 1); + assert.equal(verifySignedReceipt(body.receipt, { + publicKeyPem: prepared.collar.signingPublicKeyPem, + keyId: prepared.collar.signingKeyId, + }), true); + } finally { + prepared.collar.close(); + } +}); + +test('overlapping paid retries atomically claim one execution attempt and call the provider once', async () => { + let executions = 0; + let releaseExecution; + let announceStarted; + const started = new Promise((resolve) => { announceStarted = resolve; }); + const gate = new Promise((resolve) => { releaseExecution = resolve; }); + const prepared = await prepareReconciledRetry({ + executeSkill: async ({ executionAttemptId }) => { + executions += 1; + assert.match(executionAttemptId, /^attempt:/); + announceStarted(); + await gate; + return { output: 'one output' }; + }, + }); + try { + const winner = prepared.retry(); + await started; + const overlap = await prepared.retry(); + assert.equal(overlap.status, 503); + assert.equal(executions, 1); + releaseExecution(); + assert.equal((await winner).status, 200); + assert.equal(executions, 1); + assert.equal(prepared.collar.journal.events.filter((event) => event.type === 'execution.started').length, 1); + } finally { + releaseExecution(); + prepared.collar.close(); + } +}); +``` + +- [ ] **Step 2: Run the test and verify the Collar does not expose a journal** + +Run: `node --test spikes/pi-wielder/tests/collar-failure.test.mjs` + +Expected: FAIL because `startCollar` does not return `journal`, injected execution is +not supported, and settled failures do not issue receipts. + +- [ ] **Step 3: Replace the float settlement-engine imports with the atomic kernel and journal** + +Replace the imports at the top of `spikes/pi-wielder/src/collar.mjs` with: + +```js +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { allocateExternalGross } from '../../../prototype/atomic-money.mjs'; +import { + createLiveFacilitatorTransport, + createMockFacilitatorTransport, + x402Paywall, + usdcToAtomic, +} from './x402-seller.mjs'; +import { + canonicalJson, + createInvocationJournal, +} from './invocation-journal.mjs'; +``` + +Add these helpers below `DEFAULT_PRICE_USDC`: + +```js +const hash = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; + +const royaltyGraph = { + [SKILL_ID]: { + parentIds: [], + inheritBps: 0, + holders: [{ recipientId: 'creator', bps: 10_000 }], + }, +}; + +function serializeAccounting(result) { + return { + grossAtomic: result.grossAtomic.toString(), + executionCostAtomic: result.executionCostAtomic.toString(), + settlementCostAtomic: result.settlementCostAtomic.toString(), + protocolFeeAtomic: result.protocolFeeAtomic.toString(), + royaltyPoolAtomic: result.royaltyPoolAtomic.toString(), + refundReserveAtomic: result.refundReserveAtomic.toString(), + holderCredits: result.holderCredits.map((credit) => ({ + ...credit, + amountAtomic: credit.amountAtomic.toString(), + })), + ancestorCredits: result.ancestorCredits.map((credit) => ({ + ...credit, + amountAtomic: credit.amountAtomic.toString(), + })), + }; +} +``` + +- [ ] **Step 4: Replace `createCollar` with the authoritative lifecycle** + +Replace the complete `createCollar` function with: + +```js +export function createCollar({ + facilitatorTransport, + payTo = process.env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dEaD', + priceUsdc = process.env.SKILL_PRICE_USDC || String(DEFAULT_PRICE_USDC), + mockLlm = process.env.MOCK_LLM === '1', + journal = null, + journalFile = process.env.COLLAR_JOURNAL_FILE || null, + signingKeyFile = process.env.COLLAR_SIGNING_KEY_FILE || null, + receiptSigner = null, + executeSkill = null, + lifecycleFaults = {}, // injected by offline crash-boundary tests only + resolveSettlement = async () => ({ settled: false }), +} = {}) { + if (!journal && Boolean(journalFile) !== Boolean(signingKeyFile)) { + throw new Error('COLLAR_JOURNAL_FILE and COLLAR_SIGNING_KEY_FILE must be set together'); + } + journal ??= createInvocationJournal({ + filePath: journalFile, + signingKeyPath: signingKeyFile || undefined, + signer: receiptSigner, + }); + const skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); + const skillVersionHash = hash(skillContent); + const priceAtomic = usdcToAtomic(priceUsdc); + const executor = executeSkill ?? (mockLlm + ? async ({ input }) => ({ output: mockSkillOutput(input) }) + : async ({ input }) => ({ output: await runSkillViaAnthropic(skillContent, input) })); + + const lifecycle = { + async onOffered({ idempotencyKey, requirements, expiresAt }) { + journal.requestInvocation({ + idempotencyKey, + mode: 'external', + skillId: SKILL_ID, + skillVersionHash, + requestHash: requirements.extra.requestHash, + creatorId: 'creator', + beneficiaryId: null, + }); + journal.offerExternalPayment(idempotencyKey, { + quoteId: requirements.extra.quoteId, + amountAtomic: requirements.maxAmountRequired, + currency: 'USDC', + network: requirements.network, + asset: requirements.asset, + payTo: requirements.payTo, + resource: requirements.resource, + requestHash: requirements.extra.requestHash, + requirementsHash: hash(canonicalJson(requirements)), + expiresAt, + requirements, + }); + }, + async loadFrozenOffer({ idempotencyKey }) { + return journal.getByIdempotencyKey(idempotencyKey)?.quote?.requirements ?? null; + }, + async onSigned({ idempotencyKey, settlementReference, payer, requirements }) { + const existing = journal.getByIdempotencyKey(idempotencyKey); + if (!existing?.quote) throw new Error('paid retry has no prior quoted Invocation'); + if (existing.quote.requirementsHash !== hash(canonicalJson(requirements))) { + throw new Error('paid retry does not match the frozen x402 requirements'); + } + if (existing.quote.requestHash !== requirements.extra.requestHash) { + throw new Error('idempotency key already binds a different request payload'); + } + const record = journal.markExternalPaymentSigned(idempotencyKey, { settlementReference, payer }); + if (['succeeded', 'failed', 'cancelled'].includes(record.execution.state)) { + const receipt = record.receipt ?? journal.issueReceipt(idempotencyKey); + return { + kind: 'terminal', + receipt, + txHash: record.payment.txHash, + payer: record.payment.payer, + }; + } + if (record.execution.state === 'executing') { + return { kind: 'execution_unresolved', executionAttemptId: record.execution.executionAttemptId }; + } + if (record.payment.state === 'settled' && record.execution.state === 'authorized') { + return { kind: 'settled', txHash: record.payment.txHash, payer: record.payment.payer }; + } + return null; + }, + async onSettled({ idempotencyKey, settlementReference, txHash, payer }) { + journal.markExternalPaymentSettled(idempotencyKey, { settlementReference, txHash, payer }); + }, + async onUnresolved({ idempotencyKey, reason }) { + journal.markExternalPaymentUnresolved(idempotencyKey, { reason }); + }, + async onRejected({ idempotencyKey, reason }) { + journal.rejectExternalPayment(idempotencyKey, { reason }); + }, + }; + + const app = new Hono(); + app.get('/healthz', (c) => c.json({ + ok: true, + skill: SKILL_ID, + skillVersionHash, + priceAtomic, + currency: 'USDC', + receiptAlgorithm: 'Ed25519', + signingPublicKeyPem: journal.signingPublicKeyPem, + signingKeyId: journal.signingKeyId, + })); + + app.get('/receipts/by-settlement/:reference', (c) => { + const record = journal.getBySettlementReference(c.req.param('reference')); + if (!record) return c.json({ error: 'unknown settlement reference' }, 404); + if (!record.receipt) { + return c.json({ + invocationId: record.invocationId, + paymentState: record.payment.state, + executionState: record.execution.state, + }, 202); + } + return c.json({ receipt: record.receipt }); + }); + + app.post('/reconcile/by-settlement/:reference', async (c) => { + const settlementReference = c.req.param('reference'); + const record = journal.getBySettlementReference(settlementReference); + if (!record) return c.json({ error: 'unknown settlement reference' }, 404); + if (record.payment.state === 'settled') { + return c.json({ paymentState: 'settled', txHash: record.payment.txHash }); + } + if (record.payment.state !== 'unresolved') { + return c.json({ error: `payment state '${record.payment.state}' is not reconcilable` }, 409); + } + const resolution = await resolveSettlement({ + settlementReference, + payer: record.payment.payer, + amountAtomic: record.quote.amountAtomic, + network: record.quote.network, + asset: record.quote.asset, + payTo: record.quote.payTo, + }); + if (!resolution?.settled) { + return c.json({ paymentState: 'unresolved', settlementReference }, 202); + } + if (resolution.settlementReference !== settlementReference + || resolution.payer?.toLowerCase() !== record.payment.payer?.toLowerCase() + || String(resolution.amountAtomic) !== record.quote.amountAtomic) { + return c.json({ error: 'trusted settlement resolver returned a mismatched proof' }, 502); + } + const reconciled = journal.reconcileExternalSettlement({ + settlementReference, + txHash: resolution.txHash, + payer: resolution.payer, + }); + return c.json({ paymentState: reconciled.payment.state, txHash: reconciled.payment.txHash }); + }); + + app.post( + '/invoke/:skillId', + x402Paywall({ + price: priceUsdc, + payTo, + facilitatorTransport, + description: `hosted-skill invocation: ${SKILL_ID}`, + lifecycle, + }), + async (c) => { + const payment = c.get('x402'); + const key = payment.idempotencyKey; + const claim = journal.startExecution(key); + if (!claim.started) { + return c.json({ + error: 'execution outcome unresolved; trusted executor reconciliation is required', + executionAttemptId: claim.record.execution.executionAttemptId, + }, 503); + } + const executionAttemptId = claim.record.execution.executionAttemptId; + + const finishFailure = (failureClass, message, status) => { + journal.finishExecution(key, { + executionAttemptId, + outcome: 'failed', + failureClass, + message, + outcomeHash: null, + accounting: null, + }); + return c.json({ error: message, receipt: journal.issueReceipt(key) }, status); + }; + + if (c.req.param('skillId') !== SKILL_ID) { + return finishFailure('UNKNOWN_SKILL', `unknown skill '${c.req.param('skillId')}'`, 404); + } + const body = await c.req.json().catch(() => null); + if (!body?.input) { + return finishFailure('INVALID_REQUEST', 'body must be JSON: { "input": "..." }', 400); + } + + let execution; + try { + execution = await executor({ + skillId: SKILL_ID, + skillVersionHash, + skillContent, + input: body.input, + executionAttemptId, + }); + } catch (error) { + return finishFailure('UPSTREAM_500', error.message, 500); + } + if (!execution || typeof execution.output !== 'string') { + return finishFailure('INVALID_EXECUTOR_RESULT', 'executor must return { output: string }', 500); + } + await lifecycleFaults.afterExecutorReturned?.({ idempotencyKey: key, executionAttemptId }); + + const allocation = allocateExternalGross({ + grossAtomic: BigInt(payment.amountAtomic), + executionCostAtomic: 0n, + settlementCostAtomic: 0n, + protocolFeeBps: 250, + refundReserveAtomic: 0n, + leafSkillId: SKILL_ID, + skills: royaltyGraph, + }); + journal.finishExecution(key, { + executionAttemptId, + outcome: 'succeeded', + failureClass: null, + message: null, + outcomeHash: hash(execution.output), + accounting: serializeAccounting(allocation), + }); + await lifecycleFaults.afterExecutionFinished?.({ idempotencyKey: key, executionAttemptId }); + return c.json({ output: execution.output, receipt: journal.issueReceipt(key) }); + }, + ); + + return { app, journal, skillVersionHash }; +} +``` + +The zero execution-cost allocation is temporary and explicitly replaced by the +COGS-aware plan. It is accurate as a synthetic mock assumption and is never used to +claim live margin. + +- [ ] **Step 5: Return the journal from the boot helper** + +Replace `startCollar` with: + +```js +export function startCollar({ port = 0, ...opts } = {}) { + const { app, journal, skillVersionHash } = createCollar(opts); + return new Promise((resolve) => { + const server = serve({ fetch: app.fetch, port }, (info) => { + resolve({ + url: `http://127.0.0.1:${info.port}`, + port: info.port, + journal, + skillVersionHash, + signingPublicKeyPem: journal.signingPublicKeyPem, + signingKeyId: journal.signingKeyId, + close: () => server.close(), + }); + }); + }); +} +``` + +The standalone boot block may continue to consume only `startCollar(...).url`, but +startup now fails unless `COLLAR_JOURNAL_FILE` and `COLLAR_SIGNING_KEY_FILE` are both +absent (ephemeral mock) or both set to explicit absolute paths outside the checkout. + +The atomic journal claim prevents duplicate calls by this Collar after the claim is +visible. `executionAttemptId` is also passed to the provider adapter as an idempotency +token where supported. This is not a categorical provider exactly-once guarantee: a +crash with state `executing` stays unresolved and returns 503 until a future trusted +executor-result resolver reconciles it. + +- [ ] **Step 6: Run the failure test** + +Run: `node --test spikes/pi-wielder/tests/collar-failure.test.mjs` + +Expected: PASS, 6 tests and 0 failures. The fault cases contain one settled event per +Invocation, the atomic execution claim permits only one provider call, a crash after +provider return remains explicitly unresolved, and a crash after terminal persistence +issues the missing receipt on retry without re-execution. + +- [ ] **Step 7: Commit authoritative Collar receipts** + +```bash +git add spikes/pi-wielder/src/collar.mjs spikes/pi-wielder/tests/collar-failure.test.mjs +git commit -m "feat: issue authoritative Collar receipts" +``` + +### Task 4: Demote the Wielder ledger to a receipt view + +**Files:** +- Modify: `spikes/pi-wielder/src/ledger.mjs:1-46` +- Modify: `spikes/pi-wielder/src/proxy.mjs:95-135` +- Modify: `spikes/pi-wielder/e2e.mjs:114-137` +- Create: `spikes/pi-wielder/tests/proxy-trust.test.mjs` + +- [ ] **Step 1: Write failing receipt-view assertions in the e2e** + +Add this import to `spikes/pi-wielder/e2e.mjs`: + +```js +import { verifySignedReceipt } from './src/invocation-journal.mjs'; +``` + +Pass the Collar trust anchor when starting the e2e proxy: + +```js + trustedCollarPublicKeyPem: collar.signingPublicKeyPem, + trustedCollarKeyId: collar.signingKeyId, +``` + +In the direct unpaid-request loop, preserve the 402 contract by adding a unique key: + +```js +headers: { + 'content-type': 'application/json', + 'Idempotency-Key': `unpaid-${name}`, +}, +``` + +After the existing `eq(entries.map((e) => e.leg), ...)` assertion, add: + +```js + ok(entries.every((entry) => entry.view === 'wielder-receipt'), 'Wielder entries identify themselves as receipt views'); + ok(entries.every((entry) => entry.status === 'succeeded'), 'successful calls retain terminal status'); + ok(entries[2].receipt != null, 'Skill entry caches the signed Collar receipt'); + ok(verifySignedReceipt(entries[2].receipt, { + publicKeyPem: collar.signingPublicKeyPem, + keyId: collar.signingKeyId, + }), 'cached Collar receipt verifies against the pinned Collar key'); + eq( + entries[2].receipt.receipt.invocationId, + collar.journal.getByTxHash(entries[2].txHash).invocationId, + 'Wielder view points to the authoritative Collar Invocation', + ); +``` + +Replace the old display-number amount assertion with: + +```js + eq(entries.map((entry) => entry.amountAtomic), [ + usdcToAtomic(MODEL_PRICES_USDC.claude), + usdcToAtomic(MODEL_PRICES_USDC.gpt), + usdcToAtomic('0.25'), + ], 'atomic amounts match the quoted 402 offers'); +``` + +Replace the old split-equivalence assertion with: + +```js + const receiptAccounting = entries[2].receipt.receipt.accounting; + eq(entries[2].splits, [ + ...receiptAccounting.holderCredits.map((credit) => ({ + party: credit.recipientId, + amountAtomic: credit.amountAtomic, + })), + { party: 'treasury', amountAtomic: receiptAccounting.protocolFeeAtomic }, + ], 'rendered splits are derived only from the signed Collar receipt'); + eq(entries[2].receipt, skill.json.receipt, 'response and Wielder cache contain the same signed receipt'); +``` + +Remove imports and setup that recompute the split through +`prototype/settlement-engine.mjs`; the Collar receipt is now the authority. + +- [ ] **Step 2: Run e2e and verify the proxy still drops failure receipts and old split fields differ** + +Run: `npm run e2e --prefix spikes/pi-wielder` + +Expected: FAIL on the new `view`, `status`, or signed-receipt assertions. + +- [ ] **Step 3: Replace `ledger.mjs` with a receipt-view implementation** + +Replace `spikes/pi-wielder/src/ledger.mjs` with: + +```js +import fs from 'node:fs'; +import { formatUsdc } from '../../../prototype/atomic-money.mjs'; + +export function createLedger(filePath = null) { + const entries = []; + return { + entries, + record(entry) { + if (entry.view !== 'wielder-receipt') throw new Error("Wielder ledger entries must use view 'wielder-receipt'"); + const full = { ts: new Date().toISOString(), ...entry }; + entries.push(full); + if (filePath) fs.appendFileSync(filePath, `${JSON.stringify(full)}\n`); + return full; + }, + }; +} + +const display = (amountAtomic) => `$${formatUsdc(BigInt(amountAtomic)).replace(/0+$/, '').replace(/\.$/, '')}`; + +export function renderLedger(entries) { + if (!entries.length) return '(empty Wielder receipt view)'; + const parts = entries.map((entry) => { + let line = `${entry.label} ${display(entry.amountAtomic)} [${entry.status}]`; + if (entry.splits?.length) { + line += ` → ${entry.splits.map((split) => `${split.party} ${display(split.amountAtomic)}`).join(' / ')}`; + } + return line; + }); + const totalAtomic = entries.reduce((sum, entry) => sum + BigInt(entry.amountAtomic), 0n); + return `${parts.join(' · ')}\n session receipt total ${display(totalAtomic)} across ${entries.length} settled calls, one wallet`; +} +``` + +- [ ] **Step 4: Record every settled response, including failures, from returned receipts** + +Import receipt verification in `proxy.mjs`: + +```js +import fs from 'node:fs'; +import path from 'node:path'; +import { verifySignedReceipt } from './invocation-journal.mjs'; +``` + +Add this startup-only trust loader. It reads a configured public key; it never reads +trust material from an x402 challenge, response body, or receipt: + +```js +export function loadPinnedCollarTrust(env = process.env) { + const publicKeyFile = env.COLLAR_PUBLIC_KEY_FILE || null; + const expectedKeyId = env.COLLAR_KEY_ID || null; + if (!publicKeyFile || !expectedKeyId) { + throw new Error('Skill routes require COLLAR_PUBLIC_KEY_FILE and COLLAR_KEY_ID'); + } + if (!path.isAbsolute(publicKeyFile)) throw new Error('COLLAR_PUBLIC_KEY_FILE must be absolute'); + const publicKeyPem = fs.readFileSync(publicKeyFile, 'utf8'); + const publicKey = crypto.createPublicKey(publicKeyPem); + const actualKeyId = `sha256:${crypto.createHash('sha256') + .update(publicKey.export({ type: 'spki', format: 'der' })).digest('hex')}`; + if (actualKeyId !== expectedKeyId) throw new Error('COLLAR_KEY_ID does not match COLLAR_PUBLIC_KEY_FILE'); + return { trustedCollarPublicKeyPem: publicKeyPem, trustedCollarKeyId: expectedKeyId }; +} + +export function assertReceiptMatchesPayment(bundle, expected) { + const receipt = bundle?.receipt; + const lower = (value) => String(value ?? '').toLowerCase(); + if (!receipt + || receipt.idempotencyKey !== expected.idempotencyKey + || receipt.requestHash !== expected.requestHash + || receipt.quote?.requestHash !== expected.requestHash + || receipt.quote?.quoteId !== expected.quoteId + || receipt.quote?.amountAtomic !== expected.amountAtomic + || lower(receipt.wielderId) !== lower(expected.payer) + || lower(receipt.payment?.payer) !== lower(expected.payer) + || lower(receipt.payment?.settlementReference) !== lower(expected.settlementReference) + || lower(receipt.payment?.txHash) !== lower(expected.txHash)) { + throw new Error('signed Collar receipt does not semantically match the current paid request'); + } + return receipt; +} +``` + +Add `trustedCollarPublicKeyPem` and `trustedCollarKeyId` to `createProxy` options and +keep them in its closure. Put the first guard at the start of `createProxy`; this proxy +always exposes `/invoke/*` Skill routes. Put the second guard immediately after +`const receipt = parsed.receipt ?? null;` in the Skill response branch: + +```js + if (!trustedCollarPublicKeyPem || !trustedCollarKeyId) { + throw new Error('Skill routes require a pinned Collar public key and key ID'); + } + + if (leg === 'skill') { + if (!receipt || !trustedCollarPublicKeyPem || !trustedCollarKeyId) { + throw new Error('Skill receipt requires a configured Collar trust anchor'); + } + if (!verifySignedReceipt(receipt, { + publicKeyPem: trustedCollarPublicKeyPem, + keyId: trustedCollarKeyId, + })) { + throw new Error('Skill receipt signature does not match the pinned Collar key'); + } + assertReceiptMatchesPayment(receipt, { + idempotencyKey, requestHash, quoteId, amountAtomic, + payer, settlementReference, txHash, + }); + } +``` + +The receipt bundle's `keyId` is an identifier, not a trust anchor; the proxy never +uses a public key supplied inside the same response. + +Replace the standalone boot call with: + +```js + const trust = loadPinnedCollarTrust(process.env); + const { url, account } = await startProxy({ + port: Number(process.env.PROXY_PORT || 8402), + ...trust, + }); +``` + +In the proxy `forward` handler, destructure these additional `payingFetch` fields: + +```js + const { + res, paid, xPayment, idempotencyKey, amountAtomic, txHash, payer, + requestHash, quoteId, settlementReference, timings, + } = await payingFetch(account, `${upstreamBase}${path}`, { +``` + +Replace the `if (paid && res.ok) { ... }` block with: + +```js + if (paid && txHash) { + let parsed = {}; + try { parsed = JSON.parse(resBody); } catch { /* SSE responses have no receipt body */ } + const model = JSON.parse(bodyText || '{}').model ?? ''; + const label = leg === 'skill' + ? `skill/${path.split('/').pop()}` + : `${model.startsWith('claude') ? 'claude' : 'gpt'}/${c.req.header('x-session-label') || 'chat'}`; + const receipt = parsed.receipt ?? null; + const accounting = receipt?.receipt?.accounting ?? null; + const splits = accounting ? [ + ...(accounting.holderCredits ?? []).map((credit) => ({ + party: credit.recipientId, + amountAtomic: credit.amountAtomic, + })), + ...(accounting.ancestorCredits ?? []).map((credit) => ({ + party: credit.recipientId, + amountAtomic: credit.amountAtomic, + })), + { party: 'treasury', amountAtomic: accounting.protocolFeeAtomic }, + ] : null; + ledger.record({ + view: 'wielder-receipt', + idempotencyKey, + leg, + label, + amountAtomic, + txHash, + status: receipt?.receipt?.execution?.state ?? (res.ok ? 'succeeded' : 'failed'), + receipt, + splits, + }); + } +``` + +Do not calculate or accept caller-supplied splits anywhere in the Wielder. + +- [ ] **Step 5: Run the e2e and failure tests** + +Create `spikes/pi-wielder/tests/proxy-trust.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { canonicalJson, createReceiptSigner, verifySignedReceipt } from '../src/invocation-journal.mjs'; +import { assertReceiptMatchesPayment, createProxy, loadPinnedCollarTrust } from '../src/proxy.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; + +test('proxy startup accepts only an explicitly pinned public key and matching key ID', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'collar-public-')); + const publicKeyFile = path.join(dir, 'collar-public.pem'); + const signer = createReceiptSigner(); + fs.writeFileSync(publicKeyFile, signer.publicKeyPem); + const trust = loadPinnedCollarTrust({ COLLAR_PUBLIC_KEY_FILE: publicKeyFile, COLLAR_KEY_ID: signer.keyId }); + assert.equal(trust.trustedCollarKeyId, signer.keyId); + assert.doesNotThrow(() => createProxy({ account: throwawayAccount(), ...trust })); + assert.throws(() => loadPinnedCollarTrust({ COLLAR_PUBLIC_KEY_FILE: publicKeyFile }), /require/); + assert.throws(() => loadPinnedCollarTrust({ + COLLAR_PUBLIC_KEY_FILE: publicKeyFile, + COLLAR_KEY_ID: `sha256:${'0'.repeat(64)}`, + }), /does not match/); + assert.throws(() => createProxy({ account: throwawayAccount() }), /pinned Collar/); +}); + +test('a validly signed but stale receipt cannot be cached for a different paid request', () => { + const signer = createReceiptSigner(); + const expected = { + idempotencyKey: 'idem-current', + requestHash: `sha256:${'1'.repeat(64)}`, + quoteId: `sha256:${'2'.repeat(64)}`, + amountAtomic: '250000', + payer: `0x${'a'.repeat(40)}`, + settlementReference: `0x${'b'.repeat(64)}`, + txHash: `0x${'c'.repeat(64)}`, + }; + const staleReceipt = { + idempotencyKey: 'idem-previous', + requestHash: expected.requestHash, + quote: { requestHash: expected.requestHash, quoteId: expected.quoteId, amountAtomic: expected.amountAtomic }, + wielderId: expected.payer, + payment: { + payer: expected.payer, + settlementReference: expected.settlementReference, + txHash: expected.txHash, + }, + }; + const receiptHash = crypto.createHash('sha256').update(canonicalJson(staleReceipt)).digest('hex'); + const bundle = { + receipt: staleReceipt, + receiptHash, + signature: signer.signHash(receiptHash), + algorithm: signer.algorithm, + keyId: signer.keyId, + }; + assert.equal(verifySignedReceipt(bundle, { publicKeyPem: signer.publicKeyPem, keyId: signer.keyId }), true); + assert.throws(() => assertReceiptMatchesPayment(bundle, expected), /does not semantically match/); +}); +``` + +Run: `npm run e2e --prefix spikes/pi-wielder && node --test spikes/pi-wielder/tests/collar-failure.test.mjs spikes/pi-wielder/tests/proxy-trust.test.mjs` + +Expected: both commands PASS. The e2e prints a final green check count; the failure +test proves the signed failed receipt is preserved independently of HTTP success. + +- [ ] **Step 6: Commit the receipt view** + +```bash +git add spikes/pi-wielder/src/ledger.mjs spikes/pi-wielder/src/proxy.mjs spikes/pi-wielder/e2e.mjs spikes/pi-wielder/tests/proxy-trust.test.mjs +git commit -m "feat: make Wielder ledger a receipt view" +``` + +### Task 5: Verify persistence, reconciliation, and protected boundaries + +**Files:** +- Modify: `spikes/pi-wielder/README.md:1-35` +- Modify: `spikes/pi-wielder/.env.example` +- Modify: `spikes/pi-wielder/RUNBOOK.md` + +- [ ] **Step 1: Replace “unified ledger authority” wording** + +Insert this section near the start of `spikes/pi-wielder/README.md` and remove any +sentence claiming the Wielder ledger is authoritative or that only successful calls +are paid: + +```markdown +## Accounting authority + +The Collar's append-only Invocation journal is authoritative. It records payment and +execution as independent state machines, so a settled Invocation remains recorded when +execution fails or the seller response is lost. The Wielder's `/ledger` endpoint is a +session receipt view: it caches Collar-signed receipts and renders their allocations, +but it does not calculate Royalty-claim splits. + +Mock receipts use an ephemeral Ed25519 Collar key and deterministic mock settlement. +They are synthetic protocol evidence, not proof of live funds, mainnet readiness, +custody posture, or production key durability. +``` + +Append this safe-default configuration to `.env.example` (leave every path/identifier +blank in the tracked template): + +```dotenv +# Offline mock settlement is the default. Set 1 only for an approved Base Sepolia run. +ALLOW_LIVE_X402=0 +# Persistence is opt-in and paired. Both absolute paths must be outside the checkout. +COLLAR_JOURNAL_FILE= +COLLAR_SIGNING_KEY_FILE= +# Wielder trust is public-key-only and must match the expected key ID. +COLLAR_PUBLIC_KEY_FILE= +COLLAR_KEY_ID= +``` + +In `RUNBOOK.md`, document that a persistent Collar refuses to start unless the journal +and private signing-key paths are both explicit and absolute outside the checkout, the +proxy refuses Skill routes without the public key file plus expected key ID, and private +key material is never copied into the repository. Add this standalone selection helper +to `collar.mjs` and use it in the boot block: + +```js +export async function chooseFacilitator({ + env = process.env, + startMock = async () => (await import('./facilitator-mock.mjs')).createMockFacilitator(), +} = {}) { + if (env.ALLOW_LIVE_X402 !== '1') { + const app = await startMock(); + return { + transport: createMockFacilitatorTransport((url, init) => app.request(url, init)), + mode: 'mock', + }; + } + if (!env.FACILITATOR_URL) throw new Error('ALLOW_LIVE_X402=1 requires an explicit Base Sepolia FACILITATOR_URL'); + return { + transport: createLiveFacilitatorTransport(env.FACILITATOR_URL), + mode: 'approved-base-sepolia', + }; +} +``` + +The standalone block calls `chooseFacilitator()`; remove the old fallback to +`https://x402.org/facilitator`, passes only `selected.transport` into `createCollar`, and +never passes a URL from env directly. Add a focused test with an injected `startMock` +spy that proves empty/default env selects the injected app and never constructs a live +facilitator call. Live env succeeds only when `FACILITATOR_URL` equals +`APPROVED_LIVE_FACILITATOR_BASE` byte-for-byte. + +- [ ] **Step 2: Run all focused tests** + +Run: `npm test --prefix spikes/pi-wielder` + +Expected: PASS for journal, x402 lifecycle, and Collar failure tests; 0 failures. + +- [ ] **Step 3: Run the original offline proof** + +Run: `npm run e2e --prefix spikes/pi-wielder` + +Expected: PASS with three settled calls, one signed Skill receipt, exact atomic split +conservation, and no network/key/fund requirement. + +- [ ] **Step 4: Prove a response lookup does not append or debit** + +Run: `node --test --test-name-pattern="settled-then-500" spikes/pi-wielder/tests/collar-failure.test.mjs` + +Expected: PASS; the assertion finds one and only one `payment.settled` event before +and after the receipt lookup. + +- [ ] **Step 5: Confirm secrets and mainnet did not enter tracked files** + +Run: `! git ls-files -z | xargs -0 rg -n --pcre2 '-----BEGIN (?:PRIVATE|ENCRYPTED PRIVATE) KEY-----|PRIVATE_KEY\s*=\s*(?:0x)?[0-9a-fA-F]{64}'` + +Expected: no output. The existing ignored local `.env` remains untouched. + +- [ ] **Step 6: Commit the authority documentation** + +```bash +git add spikes/pi-wielder/README.md spikes/pi-wielder/RUNBOOK.md spikes/pi-wielder/.env.example spikes/pi-wielder/src/collar.mjs +git commit -m "docs: define Collar journal authority" +``` + +## Definition of done + +- Every external Invocation uses one client-generated idempotency key from 402 challenge through retry and receipt. +- The Collar journal is durably appended under a same-host process lock/CAS, fsynced, + hash-chained, signed per event, strict-transition replayed, and indexes settlement/tx references. +- A settled-then-500 record retains the transaction hash and terminal `failed` outcome. +- `GET /receipts/by-settlement/:reference` is read-only. An unresolved settlement advances only through `POST /reconcile/by-settlement/:reference`, whose injected trusted resolver verifies reference, payer, amount, and transaction evidence without accepting a client-supplied txHash. +- After trusted reconciliation, an exact retry reuses the settled credential once; later exact retries return the identical cached signed receipt without facilitator settlement or Skill execution. +- One atomic `executionAttemptId` claim permits one local provider call; overlapping + retries and restart while `executing` return unresolved rather than re-executing. +- Method, resource URL, and exact body bytes are bound into `requestHash`; a conflicting body under the same idempotency key fails before settlement or execution. +- Exact duplicate operations are no-ops; conflicting reuse of an idempotency key, settlement reference, or transaction hash fails closed. +- The signed receipt binds Skill/version, quote, payer, payment, execution, accounting, timestamps, and terminal sequence. +- Receipt verification pins a separately configured Collar public key and key ID; a key named by the receipt cannot authenticate itself. Persistent journals require paired explicit absolute journal/private-key paths outside the checkout; ephemeral signing is in-memory mock-only. +- A confirmed refund carries its trusted reference and exact atomic amount in a new signed receipt revision that supersedes—but does not delete—the prior receipt. +- Refund v1 is serialized through the journal append lock and is legal only for a settled, + terminal failed Invocation whose accounting contains one full-gross reconciliation hold + and no finalized claims. The signed revision carries the exact balanced hold reversal + and refund-disbursement entries; authorized, executing, succeeded, partial, and + unresolved-payment refunds fail closed. +- Live facilitator traffic is restricted to the one byte-exact approved HTTPS base and + `/verify`/`/settle` paths with redirects disabled. Offline mock settlement is reachable + only through the injected-app transport constructor; arbitrary env URLs never enter fetch. +- The Wielder cache displays signed receipts and never supplies authoritative splits. +- All automated verification uses the mock facilitator, a throwaway wallet, and an ephemeral signing key. No mainnet or real funds are used. diff --git a/docs/superpowers/plans/2026-07-17-corpus-amendment-proposal.md b/docs/superpowers/plans/2026-07-17-corpus-amendment-proposal.md new file mode 100644 index 0000000..b6d1cd9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-corpus-amendment-proposal.md @@ -0,0 +1,430 @@ +# Protected Corpus Amendment Proposal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Produce one reviewable, non-canonical amendment proposal that reconciles employer-funded internal Invocations, accounting authority, authorship language, registry evidence, benchmark status, and Education status without editing `CONTEXT.md`, `docs/PRD.md`, or any ADR. + +**Architecture:** After Projects 1–5 pass their recorded verification gates, create a single proposal under `docs/proposals/` containing normative replacement language, a file/section amendment map, a complete proposed ADR, preserved-history rules, new unvalidated-ledger entries, and an explicit approval gate. This plan ends after committing the proposal; applying it to protected files requires a separate explicit user approval and a new execution plan. + +**Tech Stack:** Markdown, `rg`, `git diff`, existing repository terminology and evidence artifacts. + +--- + +## Hard boundary + +This plan creates exactly one content file: + +- Create `docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md` + +It must not modify, stage, or commit: + +- `CONTEXT.md` +- `docs/PRD.md` +- any file under `docs/adr/` + +The proposal may quote short current anchors and include proposed replacement text. It is not canonical, does not supersede an ADR, and does not authorize product implementation, real funds, payroll, testnet/mainnet transactions, publication, or deployment. + +Do not create the proposal file, declare this plan ready, or run Task 1 until every +Projects 1–5 dependency below has passed its own final verification commands. A plan +file existing is not evidence of completion. If any dependency is incomplete or +failing, leave this plan `not_ready`, report the exact failing gate, and make no +proposal or protected-corpus change. + +## Evidence prerequisites referenced by the proposal + +The proposal records these implementation-plan dependencies and does not claim they are complete until their tests/evidence exist: + +1. `docs/superpowers/plans/2026-07-17-claims-quarantine.md` +2. `docs/superpowers/plans/2026-07-17-clone-economics-evidence.md` +3. `docs/superpowers/plans/2026-07-17-phase0-proof-safety.md` +4. `docs/superpowers/plans/2026-07-17-atomic-money-kernel.md` +5. `docs/superpowers/plans/2026-07-17-collar-invocation-journal.md` +6. `docs/superpowers/plans/2026-07-17-wielder-payment-policy.md` +7. `docs/superpowers/plans/2026-07-17-cogs-aware-execution.md` +8. `docs/superpowers/plans/2026-07-17-internal-invocation-awards-spike.md` +9. `docs/superpowers/plans/2026-07-17-authorship-attestation.md` +10. `docs/superpowers/plans/2026-07-17-public-surfaces.md` + +Project mapping follows the remediation design: Project 1 is plans 1–2; Project 2 is +plans 4–7; Project 3 is plan 8; Project 4 is plans 3 and 9; and Project 5 is plan 10. + +### Readiness gate before Task 1 + +- [ ] Run every prerequisite plan's final verification commands and confirm its full + definition of done, including clean-checkout or fixture-drift checks where specified. +- [ ] Require all ten prerequisite implementation commits and evidence artifacts to be + present; a skipped live/human-only gate must remain explicitly `blocked`, `not-run`, + or historical and cannot be relabeled as passing. +- [ ] Run `git diff --exit-code -- CONTEXT.md docs/PRD.md docs/adr` and require no change + attributable to Projects 1–5. +- [ ] Record the ten plan paths, verified commit IDs, exact verification commands, and + pass/fail results in the execution handoff. Do not place this mutable run ledger in + the proposal itself. + +Expected: all automated gates for Projects 1–5 pass and every human-only or external +gate retains its honest state. Only then may the proposal be generated. Any failure +stops this plan before Task 1. + +### Task 1: Create the complete non-canonical proposal + +**Files:** +- Create `docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md` + +- [ ] **Step 1: Create the proposal with this exact structure and normative content** + +Use the following document body. Preserve the `PROPOSED / NOT CANONICAL` labels verbatim. + +````markdown +# Protected Corpus Amendment Proposal — Employer-Funded Internal Invocations + +**Status:** PROPOSED / NOT CANONICAL + +**Date:** 2026-07-17 + +**Approval state:** Pending explicit user approval. No protected corpus file is changed by this proposal. + +**Protected targets:** `CONTEXT.md`, `docs/PRD.md`, `docs/adr/0003-payment-gated-execution.md`, `docs/adr/0005-two-leg-cross-chain-settlement.md`, `docs/adr/0006-phased-rollout-closed-modes-first.md`, `docs/adr/0007-closed-mode-compensation-layer-as-terminal-product.md`, and `docs/adr/0008-the-wielder-is-a-wallet.md`. + +## Why an amendment is proposed + +The protected corpus currently makes external Wielder revenue the source of Intra-org compensation. That leaves the terminal compensation product dependent on an external customer and makes the central design-partner pitch read like an internal usage award even though the architecture does not fund one. The remediation design proposes a different terminal-mode accounting event: a successful qualified internal Invocation consumes an employer-approved budget and may create an employer-sponsored Invocation award for the employee-Creator. External Invocations remain optional later upside distributed through the co-held Royalty claim. + +This proposal also aligns the corpus with seven evidence boundaries established by the adversarial review: + +1. the Collar is the authoritative Invocation and settlement ledger; +2. gross price is allocated only after COGS, settlement cost, protocol fee, and refund reserve; +3. a settled failure remains recorded; +4. Phase 0 proves wallet registration and declared ancestry, not authorship, originality, or safety; +5. registry telemetry is settlement-verifiable, not unfakeable; +6. historical results without committed normalized samples are preserved but suppressed from publication; +7. post-start execution cost is never defaulted to zero: unknown cost holds the full reservation for reconciliation. + +## Proposed decision summary + +1. **Terminal Intra-org event.** The employer is the Beneficiary and compensation-fund source for internal Invocations. An authorized internal Wielder uses a signed, single-use budget credential issued by a provisioned policy-permitted credential authorizer. A successful qualified Invocation creates an employer-sponsored Invocation award for the employee-Creator under an effective-dated employer policy. +2. **No platform custody.** The approved budget is an authorization and accounting limit retained by the employer, not a prepaid balance held by the platform. Employee payment occurs through employer payroll or accounts payable under a counsel-drafted instrument. +3. **Two distinct entitlements.** An internal Invocation award is an employer compensation obligation. A Royalty claim is an entitlement to external Invocation revenue. The employer receives no self-credit on internal use. +4. **Two credential sources.** External Invocations retain a settled x402 payment credential bound to the settlement transaction hash. Internal Invocations use budget-reservation credentials signed by a provisioned credential authorizer whose identifier is permitted by the effective policy. Request-supplied keys are never trust roots. Neither executes from a quote alone. +5. **Accounting authority.** The Collar owns append-only authoritative records and signed receipts. A Wielder ledger is a receipt view and never supplies authoritative splits. +6. **Registration language.** Phase 0 starts at `wallet_asserted`. `repository_control_verified` means a trusted forge signer observed the wallet-signed challenge in the exact proof commit of a verifier-provisioned repository snapshot; it does not prove current remote-repository ownership or legal authorship. Organization approval is a separately signed higher evidence level. None is a safety review. +7. **Closed-mode chain boundary.** Phase 0 remains registration-only for closed modes. It does not distribute native transferable Story royalty tokens while the contractual closed-mode entitlement must remain non-transferable. +8. **Registry language.** Settlement proves value moved. Independent/linked payer classification comes only from a verifier-controlled billing registry; caller claims are audit-only, and unknown relationships remain allow-listed with low confidence. Settlement does not prove independent demand, quality, usefulness, authorship, originality, or safety. +9. **Evidence language.** Measured claims require a committed normalized evidence bundle. A pinned historical receipt proves only the documented transaction fields and the repository's historical label. Historical unreproducible results remain historical and non-publishable. +10. **Education.** Education remains deferred because free re-authoring dominated every positive tested inherit rate under the stated deterministic baseline. Only living school-maintained value or direct school-to-employer licensing remains eligible for a future experiment. +11. **Post-start cost uncertainty.** A successful or known failed-after-start internal Invocation must carry validated actual execution COGS. A thrown executor, malformed outcome, or unknown post-start COGS becomes `unresolved` with the full reservation `held_unresolved`; it creates no award and cannot be reported as zero-cost or automatically released. + +## Proposed ubiquitous-language additions + +### Invocation award + +An **Invocation award** is an employer-sponsored compensation allocation created by a successful qualified internal Invocation under an approved, effective-dated employer policy. It is not external revenue, not a Royalty claim, not a transferable instrument, not an on-chain token, and not paid until the employer's payroll or accounts-payable process marks it paid. + +### Internal Execution credential + +An **internal Execution credential** is a signed, single-use, non-transferable authorization binding one Invocation, one employer-budget reservation, one immutable Skill version, one policy version, one credential-authorizer identifier, one expiry, and one nonce. Its signature must verify against the provisioned authorizer map and the authorizer identifier must be permitted by the effective policy; a request-supplied public key is ignored and rejected. It authorizes execution but is not money and cannot be redeemed or reused. + +### Registration attestation + +A **registration attestation** states the evidence attached to a Skill registration: + +- `wallet_asserted`: a wallet registered a content hash and declared ancestry; +- `repository_control_verified`: a trusted forge signer observed the wallet-signed challenge and exact bytes in the named immutable proof commit of a verifier-provisioned repository snapshot; +- `organization_approved`: an authorized organization signer approved the Skill and Creator relationship. + +Repository evidence does not establish current remote ownership or control; it establishes only the signed snapshot/commit observation above. Registration attestation does not prove originality, legal ownership, absence of prior art, or safety. Safety review is a separate status. + +## Proposed `CONTEXT.md` amendment map + +### Opening identity and archetypes + +Replace the Intra-org funding sentence with: + +> **Intra-org**: employee-Creator and employer co-hold the Royalty claim on external Invocation revenue. For internal use, the employer-Beneficiary authorizes an Invocation budget; a successful qualified internal Invocation may create an employer-sponsored Invocation award for the employee-Creator. Internal awards do not require external demand and do not credit the employer back to itself. + +Preserve Marketplace as future optionality and Education as deferred. + +### Wielder and Beneficiary + +Add: + +> An external Wielder proves authorization with a settled x402 payment credential. An internal Wielder proves authorization with an employer-budget credential signed by a provisioned policy-permitted credential authorizer. The Beneficiary funds the relevant path: an external Beneficiary funds external revenue; the employer-Beneficiary funds internal Invocation awards. + +### Collar + +Replace “off-chain meter” with: + +> The Collar is the authoritative append-only Invocation, funding, execution, cost, and allocation ledger. It signs receipts delivered to the Wielder, Beneficiary, Creator, and employer as applicable. Wielder-side ledgers are receipt views, not compensation ledgers. + +### Relationships + +Add: + +> A successful qualified internal Invocation may create an Invocation award under an employer policy. A successful externally funded Invocation may create Royalty-claim credits. These are separate events and are never reported as one revenue stream. + +### Execution credential + +Replace the payment-only definition with: + +> A single-use authorization required before a Skill runs. External Invocations use a settled x402 payment reference; internal Invocations use an employer-budget reservation signed by a provisioned policy-permitted credential authorizer. Finance and manager approvals likewise resolve provisioned signer identifiers, never request-supplied public keys. No accepted quote alone authorizes execution. + +### Flagged ambiguities + +Add: + +> Employer-funded internal compensation is an accounting and policy design until an employer agreement, counsel-drafted instrument, and payroll/AP integration exist. A successful accounting spike is not demand, tax, employment-law, securities, custody, or payment validation. + +## Proposed `docs/PRD.md` amendment map + +### Executive Summary + +Replace the statement that both Intra-org co-holders earn only from external Invocations with: + +> Intra-org compensation works without an external customer. The employer-Beneficiary authorizes a bounded internal Invocation budget. A successful qualified Invocation records actual COGS and fees and creates an employee-Creator Invocation award under the effective employer policy. External Wielders, if later enabled, create separate third-party revenue distributed through the employee/employer co-held Royalty claim. + +### Shared product loop + +Split `Invoke + pay` into two paths: + +> **External path:** quote -> Wielder policy validation -> signed x402 authorization -> settlement -> external Execution credential -> execute -> authoritative receipt. +> +> **Internal path:** quote -> employer policy validation -> serialized compare-and-swap budget reservation -> credential payload returned -> provisioned policy-permitted authorizer signature -> atomic executing transition and nonce consumption -> execute outside the lock -> compare-and-swap outcome. Success with validated actual COGS finalizes the Invocation award and releases exact unused reservation. Known `failed_after_start` with validated actual COGS records that COGS, creates no award, and releases only the exact unused amount. A thrown executor, malformed outcome, or unknown post-start COGS becomes `unresolved`; the full reservation becomes `held_unresolved` for reconciliation, with no award, zero-cost substitution, or automated release. + +Replace `Each payment lands in an auditable off-chain ledger` with: + +> Every attempted Invocation lands in the Collar's append-only authoritative journal. Settled payments remain recorded when execution fails or a seller response is lost. Internal reservations record allocation, reservation, execution, consumption, exact release, unresolved hold, and award state independently of external settlement. + +### Intra-org walkthrough + +Use this normative example: + +> MegaCorp approves a July budget for `ledger-recon`, Sam, named internal Wielders, the Platform Engineering cost center, and provisioned finance, manager, and credential-authorizer signer identifiers. The Collar serializes a compare-and-swap reservation of the maximum quoted COGS, fee, refund reserve, and award before execution. Reserved, executing, and held-unresolved maximum awards count toward the period cap. A successful Invocation with validated actual COGS finalizes exactly once, releases exact unused budget, and records Sam's Invocation award using kernel-returned account-identified journal entries. A known failed-after-start outcome records exact COGS and releases only its exact remainder; an unknown-cost outcome holds the full reservation and records no award. MegaCorp receives no employer self-credit. Sam and MegaCorp receive the same signed receipt. Payroll/AP payment remains a separate employer-controlled state. + +Retain OtherCo only as the external optionality example. On an OtherCo Invocation, third-party revenue may be distributed to Sam and MegaCorp through the co-held Royalty claim. + +### Architecture and trust model + +Add the lifecycle contract: + +```text +requested -> quoted -> authorized -> executing -> succeeded | failed | unresolved | cancelled +external: offered -> signed -> settled | rejected | unresolved -> refunded +internal reservation: allocated -> reserved -> executing -> consumed | released | held_unresolved +award: measured -> vesting_pending -> earned -> payable -> paid +``` + +State that monotonic sequence numbers plus cross-party receipt comparison provide a completeness signal; Merkle inclusion alone proves inclusion, not completeness. + +### Economic design + +Replace gross-to-royalty examples with: + +```text +external gross = execution COGS + settlement cost + protocol fee + refund reserve + Royalty-claim pool +internal gross payable = execution COGS + protocol fee + refund reserve + Invocation award +``` + +All monetary calculations use integer atomic units, reject negative/non-finite/over-precision input, and assign rounding remainders deterministically. Consumers persist the accounting kernel's account-identified journal entries verbatim and do not reconstruct splits. Success and known failed-after-start outcomes require validated actual COGS. Unknown post-start COGS remains unknown, cannot be treated as zero, and keeps the full reservation `held_unresolved` until an authorized reconciliation path exists. + +### Phase definitions + +Clarify: + +- Phase 0 closed mode: wallet-attested registration and declared ancestry only; no native transferable Story royalty-token distribution. +- Phase 1 terminal Intra-org: employer-retained budget authorization, internal Execution credential, authoritative Collar journal, signed receipts, Invocation-award payable ledger, employer payroll/AP payment. +- External x402 Invocation and co-held external Royalty-claim distribution: optional adjacent path, not required for internal compensation. +- Phase 2 Story settlement and Phase 3 tradeability: external-revenue optionality only. + +### Registration, disputes, and registry trust inputs + +Add: + +> `wallet_asserted` verifies only the registering wallet's signature over declared bytes and ancestry. `repository_control_verified` additionally requires a verifier-provisioned repository snapshot, a trusted-ref proof commit containing the wallet-signed challenge bound to the artifact hash, and a signed observation from a provisioned forge signer. Replay revalidates both signatures and the snapshot bytes. This status does not prove current remote-repository ownership or legal authorship. Challenge opening requires the challenger's wallet signature; resolution and revocation require a provisioned admin trust root. Same-host storage serializes replay-plus-append under an exclusive lock and fails closed on an active lock. +> +> Registry relationship and payer-cluster classifications derive only from a verifier-controlled billing registry. Event-supplied Beneficiary, relationship, or cluster claims are retained for audit and ignored for ranking. An unknown payer remains allow-listed with low confidence; it cannot self-declare independence. + +### Kill criteria and pilot acceptance + +Replace the LOI-only success gate with two separate gates: + +1. employer willingness to approve a policy, bounded Invocation budget, and counsel-drafted compensation instrument; +2. pilot evidence that authorized internal Invocations produce receipts and payroll/AP-reconcilable Invocation awards without platform custody. + +External willingness to pay remains a separate optionality gate and cannot validate the internal compensation product. + +## Proposed ADR amendment map + +### ADR-0003 — payment-gated execution + +Scope “no credential, no run” to both credential sources. Preserve x402 settlement as mandatory for externally funded execution. Add reserved-budget credentials signed by provisioned policy-permitted authorizers for internal execution. Reject request-supplied trust keys. Remove any implication that every credential must originate in a payment. + +### ADR-0005 — two-leg cross-chain settlement + +Constrain Base-to-Story cross-chain settlement and custody analysis to externally funded Invocation revenue. Internal Invocation awards stay in the employer's signed payable ledger and payroll/AP rail; they do not bridge or swap through Story. + +### ADR-0006 — phased rollout + +Define Phase 1 as the employer-funded internal compensation terminal state. Keep Phase 0 registration-only for closed modes. Preserve Phase 2/3 as external-revenue optionality. + +### ADR-0007 — closed mode as terminal product + +Replace external-demand dependence with employer-funded internal Invocations. Preserve compensation/retention positioning, non-transferability, counsel gate, vesting, clawback, termination, and “when Sam quits” requirements. + +### ADR-0008 — the Wielder is a wallet, not a harness + +Keep the thin-wallet decision for external Wielders. Add that an internal Wielder may be a non-wallet agent presenting an employer-budget credential signed by a provisioned policy-permitted authorizer through the same thin request/retry surface. The Collar remains authoritative in both modes. + +## Proposed new ADR + +# Employer-Funded Internal Invocations Create Invocation Awards + +**Status:** Proposed + +**Date:** 2026-07-17 + +### Context + +The accepted corpus made Intra-org compensation depend on an external Wielder buying access to an employer-owned Skill. That contradicts the terminal compensation pitch: most internal Skills may never be exposed to an external Beneficiary, and an employer design partner could sign a co-hold agreement while the employee-Creator earns nothing. Treating the employer's own internal use as external royalty revenue would create circular self-credit and inflated revenue. + +The product also must avoid platform custody, transferable closed-mode instruments, and dependence on Phase-2 Story settlement. Employers already operate payroll and accounts-payable rails and can approve bounded compensation budgets without transferring prepaid funds to the Collar. + +### Decision + +For a qualified internal Invocation, the employer is the Beneficiary and compensation-fund source. Before execution, the Collar validates an active effective-dated employer policy and serializes a compare-and-swap reservation of the maximum quoted amount from an employer-retained Invocation budget. A provisioned policy-permitted credential authorizer signs the single-use internal Execution credential, which binds the Invocation, reservation, Skill version, policy version, credential-authorizer identifier, expiry, and nonce. Finance, manager, and credential-authorizer keys are provisioned trust roots; request-supplied public keys are rejected. No quote alone authorizes execution. + +On success with validated actual execution COGS, the Collar persists the accounting kernel's account-identified journal entries for COGS, protocol fee, refund reserve, and the employee-Creator Invocation award, then releases the exact unused reservation. It never reconstructs those entries in a consumer. A known `failed_after_start` outcome must carry validated actual COGS; it creates no award, records that exact unavoidable cost, and releases only the exact unused remainder. A thrown executor, malformed outcome, or unknown post-start COGS transitions the Invocation to `unresolved` and the full reservation to `held_unresolved` for reconciliation. It creates no award, records no invented monetary entry, substitutes no zero cost, and performs no automated release. Reserved, executing, and held-unresolved maximum awards count toward the period cap. The employer receives no self-credit. + +Invocation-award states are `measured -> vesting_pending -> earned -> payable -> paid`; a no-vesting policy skips `vesting_pending`. Payroll/AP controls `payable -> paid`. Corrections are append-only reversals or prospective adjustments. + +External Invocation revenue remains separate. An external Wielder uses x402 and a settled transaction reference; the external Royalty-claim pool may credit employee and employer co-holders. External demand is not required for an internal award. + +The Collar is authoritative and signs append-only receipts. Employer and employee receive the same receipt and statement. Merkle roots prove inclusion; monotonic sequence numbers and cross-party receipt comparison provide the completeness signal. + +### Consequences + +- The terminal Intra-org product can compensate an employee-Creator without an external customer. +- Employer willingness to fund an internal program becomes the demand gate. +- The platform does not hold prepaid employer funds or pay employees. +- Payroll/AP, employment, tax, 409A, vesting, clawback, termination, and dispute terms remain human/counsel gates. +- Internal awards are not Royalty claims, securities, tokens, or on-chain settlement events. +- External x402 and Story settlement remain available optionality with their existing custody and compliance constraints. +- Self-Invocations require manager approval or exclusion, and caps/idempotency/authorized-Wielder lists prevent trivial award farming. +- Unknown post-start COGS reduces available budget and award-cap headroom through a full unresolved hold until authorized reconciliation; operational resolution remains unvalidated. + +### Rejected alternatives + +- **External Wielder revenue as the only Intra-org funding source:** rejected because it leaves compensation dependent on a second unvalidated market. +- **Employer pays itself and splits the gross:** rejected as circular revenue and metric inflation. +- **Platform prepaid omnibus balance:** rejected because it expands custody and money-transmission exposure. +- **One Story royalty token per internal Invocation:** rejected because it is unnecessary, transferable by default, and incompatible with the closed-mode contractual entitlement. +- **Unmetered discretionary bonus pool:** rejected because it removes the Invocation-level attribution and audit contract the product exists to supply. + +## Historical statements and evidence preservation + +- Do not erase the prior external-Wielder-funded Intra-org model. Mark it superseded by the approved amendment date if approval occurs. +- Preserve historical n=48 latency and clone results with their original dates and labels. If normalized samples are absent or target validity failed, mark publication disallowed; do not fabricate evidence or silently restate them as measured. +- Preserve Education arithmetic as deterministic model evidence, not observed behavior. +- Extend the PRD's “What we have NOT validated” ledger; never delete prior open assumptions. + +## Proposed additions to “What we have NOT validated” + +1. Employer willingness to approve and fund an Invocation-award budget. +2. Counsel's treatment of the exact policy, earning, vesting, termination, and payroll/AP timing. +3. Whether qualified Invocation rules resist low-value repetition and manager-approved self-use abuse. +4. Whether employees trust Collar receipts and statement completeness enough for compensation. +5. Actual operational cost of payroll/AP reconciliation and disputes. +6. Whether verifier-provisioned repository-snapshot and organization-approval attestations improve adoption, and whether trusted forge/admin operation is sustainable. +7. Whether the verifier-controlled billing registry classifies independent Beneficiaries and payer clusters accurately enough for public registry ranking. +8. Any future external demand for paid Skill Invocations. +9. The authorized evidence, operator role, and dispute controls required to reconcile a `held_unresolved` reservation without inventing COGS or releasing value prematurely. + +## Approval and application gate + +This proposal does not change canonical doctrine. Its generation requires verified completion of all ten implementation-plan dependencies across Projects 1–5. Application requires one explicit user instruction approving the amendment set after that evidence is reviewed. Once approved, create a separate execution plan that updates all protected files in one coherence commit, runs link/terminology/contradiction scans, and preserves historical statements. Partial application is not allowed. + +**Current decision:** pending explicit approval. +```` + +- [ ] **Step 2: Confirm the proposal contains every protected target and boundary** + +Run: + +```bash +rg -n 'PROPOSED / NOT CANONICAL|Pending explicit user approval|Invocation award|internal Execution credential|policy-permitted credential authorizer|wallet_asserted|verifier-provisioned repository snapshot|verifier-controlled billing registry|settlement-verifiable|historical_unreproducible|held_unresolved|unknown post-start COGS|Education remains deferred|all ten implementation-plan dependencies|Employer-Funded Internal Invocations Create Invocation Awards|Partial application is not allowed' docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md +``` + +Expected: at least one match for every required phrase; command exits 0. + +- [ ] **Step 3: Verify the proposal has no unfinished markers** + +Run: `! rg -n '\b(T''BD|TO''DO|FIX''ME|fi''ll in|to be dec''ided|place''holder)\b|<[^>]+>' docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md` + +Expected: exit 0 and no output. + +### Task 2: Verify protected corpus isolation and internal consistency + +**Files:** +- Read: `CONTEXT.md` +- Read: `docs/PRD.md` +- Read: `docs/adr/0003-payment-gated-execution.md` +- Read: `docs/adr/0005-two-leg-cross-chain-settlement.md` +- Read: `docs/adr/0006-phased-rollout-closed-modes-first.md` +- Read: `docs/adr/0007-closed-mode-compensation-layer-as-terminal-product.md` +- Read: `docs/adr/0008-the-wielder-is-a-wallet.md` +- Modify: `docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md` only if verification finds a missing anchor or contradiction. + +- [ ] **Step 1: Map every proposed change to a current section** + +Run: + +```bash +rg -n 'external Wielder|Execution credential|Collar|Intra-org|What we have NOT validated' CONTEXT.md docs/PRD.md +rg -n 'payment|credential|internal|Phase 1|Wielder|external|settlement' docs/adr/0003-payment-gated-execution.md docs/adr/0005-two-leg-cross-chain-settlement.md docs/adr/0006-phased-rollout-closed-modes-first.md docs/adr/0007-closed-mode-compensation-layer-as-terminal-product.md docs/adr/0008-the-wielder-is-a-wallet.md +``` + +Expected: each proposal subsection has at least one current anchor; no protected file is edited. + +- [ ] **Step 2: Check term separation and invariants** + +Run: + +```bash +rg -n 'Invocation award.*not.*Royalty claim|employer receives no self-credit|quote alone|policy-permitted credential authorizer|request-supplied public keys|payroll/AP|Merkle.*inclusion|sequence.*completeness|held_unresolved|no automated release|does not prove current remote|verifier-controlled billing registry|external demand is not required' docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md +``` + +Expected: all invariants match in the proposal and proposed ADR. + +- [ ] **Step 3: Prove protected files have no diff** + +Run: `git diff --exit-code -- CONTEXT.md docs/PRD.md docs/adr` + +Expected: exit 0 and no output. If pre-existing user changes appear, stop and preserve them; do not overwrite or stage them. + +- [ ] **Step 4: Review the staged scope before commit** + +Run: `git status --short docs/proposals docs/superpowers/plans CONTEXT.md docs/PRD.md docs/adr` + +Expected: the proposal and plan may appear; protected canonical files do not appear because of this task. + +- [ ] **Step 5: Commit only the proposal** + +```bash +git add docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md +git commit -m "docs: propose protected corpus alignment" +``` + +Do not stage or commit `CONTEXT.md`, `docs/PRD.md`, or `docs/adr/`. + +## Definition of done + +- One proposal file contains exact amendment language, file/section maps, a full proposed ADR, evidence preservation, unvalidated-ledger additions, and an approval gate. +- The proposal says `PROPOSED / NOT CANONICAL` and `Pending explicit user approval`. +- Proposal generation remains blocked until every plan across Projects 1–5 passes its recorded verification; all ten dependencies and their evidence are enumerated. +- Internal Invocation awards, external Royalty claims, and employer self-credit are unambiguously separated. +- Internal and external Execution credentials are distinct, internal signer trust is provisioned and policy-permitted, request-supplied keys are rejected, and quotes never authorize execution. +- The proposed lifecycle distinguishes known-cost success/failure from unresolved + post-start cost: unknown cost holds the full reservation, earns no award, and cannot + become zero or release automatically. +- Collar authority, atomic cost ordering, registration evidence levels, settlement-verifiable registry language, historical evidence suppression, and Education deferral are covered. +- Repository evidence is limited to the verifier-provisioned snapshot and trusted forge observation; public payer classification is limited to the verifier-controlled billing registry and unknown callers cannot self-upgrade. +- No protected canonical file is modified, staged, or committed. +- Applying the proposal is explicitly deferred to a separate approval and coherence plan. diff --git a/docs/superpowers/plans/2026-07-17-internal-invocation-awards-spike.md b/docs/superpowers/plans/2026-07-17-internal-invocation-awards-spike.md new file mode 100644 index 0000000..98ecdfd --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-internal-invocation-awards-spike.md @@ -0,0 +1,840 @@ +# Employer-Funded Internal Invocation Awards Spike Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an offline accounting spike proving that an employer-funded internal Invocation can reserve an approved budget, execute a registered Skill, record unavoidable COGS, and create an employee-Creator Invocation award without an external Wielder, platform custody, or real funds. + +**Architecture:** Add an isolated Node ESM package under `spikes/internal-invocation-awards/`. Its pure state machine imports the shared `allocateInternalGross` boundary from `prototype/atomic-money.mjs`, serializes reservation and finalization compare-and-swap transitions, verifies signed single-use budget credentials, and emits append-only signed receipts and statements; an injected fake executor keeps every automated path offline. This remains explicitly labeled an accounting spike and does not change `CONTEXT.md`, `docs/PRD.md`, or any ADR. + +**Tech Stack:** Node.js 20+, ECMAScript modules, built-in `node:test`, `node:assert/strict`, `node:crypto`, BigInt, JSONL. + +--- + +## Prerequisite and shared accounting contract + +Complete `docs/superpowers/plans/2026-07-17-atomic-money-kernel.md` first. This spike +must import, not reimplement, the kernel's internal partition: + +```js +allocateInternalGross({ + grossAtomic, + executionCostAtomic, + protocolFeeAtomic, + refundReserveAtomic, + recipientId +}) +``` + +It returns `grossAtomic`, `executionCostAtomic`, `protocolFeeAtomic`, +`refundReserveAtomic`, `invocationAwardAtomic`, one `awardCredit`, and account-identified +`journalEntries`; every amount is `bigint`. Local decimal-string helpers exist only at +the JSON boundary. They may not perform fee, award, remainder, or account allocation. + +## File map + +- Create `spikes/internal-invocation-awards/package.json` — isolated offline scripts. +- Create `spikes/internal-invocation-awards/src/schema.mjs` — frozen schema constants, decimal-string/BigInt boundary helpers, and validators. +- Create `spikes/internal-invocation-awards/src/budget.mjs` — budget reservation, shared-kernel finalization, release, caps, and revision checks. +- Create `spikes/internal-invocation-awards/src/credentials.mjs` — canonical signed internal Execution credentials and one-use nonce verification. +- Create `spikes/internal-invocation-awards/src/engine.mjs` — authoritative internal Invocation lifecycle and Invocation-award transitions. +- Create `spikes/internal-invocation-awards/src/store.mjs` — serialized in-memory transactions with revision compare-and-swap for the offline spike. +- Create `spikes/internal-invocation-awards/src/statements.mjs` — receipt and whole-statement canonicalization/signing/verification, sequence/root construction, and JSONL rendering. +- Create `spikes/internal-invocation-awards/test/budget.test.mjs` — policy, authorization, cap, expiry, and concurrency tests. +- Create `spikes/internal-invocation-awards/test/engine.test.mjs` — success, failure, farming, idempotency, and conservation tests. +- Create `spikes/internal-invocation-awards/test/statements.test.mjs` — employer/employee receipt and statement verification tests. +- Create `spikes/internal-invocation-awards/demo.mjs` — deterministic no-network proof run. +- Create `spikes/internal-invocation-awards/README.md` — scope, commands, evidence label, invariants, and human-only gates. + +## Contract locked by this plan + +All persisted atomic amounts are non-negative decimal strings. In-memory arithmetic converts them to `bigint`; no `number` participates in money arithmetic. + +```js +// ProgramPolicyV1 +{ + schemaVersion: 1, + policyId: "policy-megacorp-ledger-recon", + version: 1, + status: "active", // draft | approved | active | suspended | expired + currency: "USD", + atomicScale: 6, + employerId: "megacorp", + effectiveAt: "2026-07-17T00:00:00.000Z", + expiresAt: "2026-08-01T00:00:00.000Z", + permittedSkillIds: ["ledger-recon"], + permittedCreatorIds: ["sam"], + permittedWielderIds: ["megacorp-internal-agent"], + permittedCostCenters: ["platform-engineering"], + maxQuoteAtomic: "4000000", + awardRule: { + type: "residual_after_execution_fee_and_reserve", + awardRateBps: 10000, + rateBase: "post_cost_residual", + rounding: "floor_atomic" + }, + maxAwardPerInvocationAtomic: "2000000", + maxAwardPerPeriodAtomic: "100000000", + selfInvocation: "manager_approval_required", // excluded | manager_approval_required + permittedManagerSignerIds: ["manager-alex"], + permittedCredentialAuthorizerIds: ["megacorp-collar-authorizer"], + permittedFinanceSignerIds: ["megacorp-finance"], + vestingRule: "none", + paymentSchedule: "monthly_in_arrears", + terminationTreatment: "earned_remains_payable_unearned_cancelled", + paymentRail: "employer_payroll_or_ap" +} + +// EmployerBudgetV1 +{ + schemaVersion: 1, + budgetId: "budget-megacorp-2026-07", + policyId: "policy-megacorp-ledger-recon", + policyVersion: 1, + period: "2026-07", + currency: "USD", + allocatedAtomic: "1000000000", + reservedAtomic: "0", + consumedAtomic: "0", + releasedAtomic: "0", + revision: 0, + signerId: "megacorp-finance", + signature: TEST_SIGNATURE_BASE64 +} + +// InternalQuoteV1 +{ + quoteId: "quote-inv-001", + invocationId: "inv-001", + idempotencyKey: "run-ledger-recon-001", + skillId: "ledger-recon", + skillVersionHash: "sha256:1111111111111111111111111111111111111111111111111111111111111111", + creatorId: "sam", + wielderId: "megacorp-internal-agent", + beneficiaryId: "megacorp", + costCenter: "platform-engineering", + policyId: "policy-megacorp-ledger-recon", + policyVersion: 1, + maxExecutionCostAtomic: "1000000", + protocolFeeAtomic: "25000", + refundReserveAtomic: "25000", + maxInvocationAwardAtomic: "2000000", + maxGrossAtomic: "3050000", + selfInvocationApproval: null, + expiresAt: "2026-07-17T00:05:00.000Z" +} +``` + +For a self-Invocation, `selfInvocationApproval` is a signed object with exactly: +`schemaVersion`, `approvalId`, `managerSignerId`, `invocationId`, `creatorId`, +`policyId`, `policyVersion`, `issuedAt`, `expiresAt`, and `signature`. It is `null` for +non-self Invocations. The manager signature is Ed25519 over canonical JSON of all +fields except `signature`, in the order just listed. The engine resolves the manager +signer through its provisioned trust map and never accepts manager key material in the +approval or request. + +`TEST_SIGNATURE_BASE64` is generated from a throwaway Ed25519 key pair inside each +test process. No signed budget contains public-key material; `signerId` resolves only +through engine-provisioned finance trust roots. + +`maxGrossAtomic` must equal the other four maximum components. On successful finalization: + +```text +internalGross = executionCost + protocolFee + refundReserve + invocationAward +reserved = internalGross + releasedUnused +sum(all debits) = sum(all credits) +``` + +The authoritative state values are: + +```text +Invocation: requested -> quoted -> authorized -> executing -> succeeded | failed | unresolved | cancelled +Reservation: reserved -> executing -> consumed | released | held_unresolved +Budget: allocated -> reserved -> consumed | released; held_unresolved remains fully reserved +Award: measured -> vesting_pending -> earned -> payable -> paid +Program: draft -> approved -> active -> suspended | expired +``` + +Policies with `vestingRule: "none"` transition `measured -> earned`; no automated test or demo advances `payable -> paid` because payroll/AP is human-controlled. + +Once the engine atomically enters `executing`, it treats the executor as having started. +The executor must return exactly one validated `ExecutorOutcomeV1`: + +```ts +type ExecutorOutcomeV1 = + | { + kind: "succeeded"; + executionCostAtomic: string; + outputHash: `sha256:${string}`; + } + | { + kind: "failed_after_start"; + executionCostAtomic: string; + failureClass: "provider_error" | "skill_error" | "invalid_output"; + } + | { + kind: "unresolved_after_start"; + reason: "executor_threw" | "malformed_outcome" | "cost_unknown"; + }; +``` + +No object with unknown keys qualifies as a validated union member; the parser maps it +to the canonical unresolved sentinel. `succeeded` and `failed_after_start` require a +non-negative decimal `executionCostAtomic` at or below the quote maximum; success also +requires a lowercase SHA-256 output hash. A thrown executor, unknown outcome kind, +missing/invalid/out-of-cap execution cost, or explicit `unresolved_after_start` never +becomes zero cost. It CAS-finalizes the Invocation as `unresolved`, changes the +reservation to `held_unresolved`, leaves the entire maximum gross in `reservedAtomic`, +creates no award, releases nothing, and emits an append-only +`execution_cost_unresolved` event. The consumed nonce and idempotency binding remain in +place. This v1 spike has no automated release path from `held_unresolved`; operator +reconciliation is a human-only future gate. + +### Task 1: Scaffold the spike and validate immutable policy/quote schemas + +**Files:** +- Create: `spikes/internal-invocation-awards/package.json` +- Create: `spikes/internal-invocation-awards/src/schema.mjs` +- Create: `spikes/internal-invocation-awards/test/budget.test.mjs` + +- [ ] **Step 1: Write failing schema tests** + +Add tests that import `validatePolicy`, `validateQuote`, `toAtomic`, and `sumAtomic` and assert: + +```js +assert.equal(toAtomic("3050000"), 3_050_000n); +assert.equal(sumAtomic(["1000000", "25000", "25000", "2000000"]), 3_050_000n); +assert.throws(() => toAtomic("-1"), /non-negative decimal string/); +assert.throws(() => toAtomic("1.5"), /non-negative decimal string/); +assert.throws(() => validatePolicy({ ...ACTIVE_POLICY, status: "draft" }, NOW), /must be active/); +assert.throws( + () => validatePolicy({ ...ACTIVE_POLICY, awardRule: { ...ACTIVE_POLICY.awardRule, awardRateBps: 9000 } }, NOW), + /unsupported award rule.*awardRateBps must equal 10000/, +); +assert.throws(() => validateQuote({ ...QUOTE, maxGrossAtomic: "3049999" }, ACTIVE_POLICY, NOW), /maxGrossAtomic.*3050000/); +assert.throws(() => validateQuote({ ...QUOTE, wielderId: "unknown-agent" }, ACTIVE_POLICY, NOW), /Wielder is not permitted/); +``` + +- [ ] **Step 2: Run the focused test and verify red** + +Run: `cd spikes/internal-invocation-awards && node --test test/budget.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/schema.mjs`. + +- [ ] **Step 3: Add the package and schema implementation** + +Use this package contract: + +```json +{ + "name": "internal-invocation-awards-spike", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Offline accounting spike for employer-funded internal Invocation awards; no real funds.", + "scripts": { + "test": "node --test test/*.test.mjs", + "demo": "node demo.mjs" + } +} +``` + +Implement and export these exact functions from `src/schema.mjs`: + +```js +export const AWARD_STATES = Object.freeze(["measured", "vesting_pending", "earned", "payable", "paid"]); +export const INVOCATION_STATES = Object.freeze(["requested", "quoted", "authorized", "executing", "succeeded", "failed", "unresolved", "cancelled"]); + +export function toAtomic(value) { + if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) { + throw new Error("atomic amount must be a non-negative decimal string"); + } + return BigInt(value); +} + +export function fromAtomic(value) { + if (typeof value !== "bigint" || value < 0n) throw new Error("atomic amount must be a non-negative bigint"); + return value.toString(); +} + +export function sumAtomic(values) { + return values.reduce((sum, value) => sum + toAtomic(value), 0n); +} + +``` + +Also export `validatePolicy(policy, now)`, `validateQuote(quote, policy, now)`, and +`parseExecutorOutcome(value, quote)`. +The two validators must reject unknown keys, wrong schema versions, +expired/not-yet-effective policy, disallowed identifiers, invalid ISO timestamps, +Skill hashes outside `^sha256:[0-9a-f]{64}$`, and any quote component above the +effective policy cap. Each returns `Object.freeze(structuredClone(input))`; neither +mutates caller data. +`parseExecutorOutcome` implements the strict union above. It returns a frozen validated +success/failure outcome, and returns the frozen canonical +`{ kind: "unresolved_after_start", reason: "malformed_outcome" }` sentinel for every +unknown kind, unknown key, missing cost, malformed cost/hash, or cost above the quote +maximum. It never supplies a default cost. + +This v1 spike accepts only the explicit immutable award rule shown above: +`type === "residual_after_execution_fee_and_reserve"`, `awardRateBps === 10000`, +`rateBase === "post_cost_residual"`, and `rounding === "floor_atomic"`. The kernel +therefore assigns 100% of the post-cost residual to the Invocation award, subject to +per-Invocation and period caps. Any other rate/type/base/rounding combination fails +closed before reservation; the v1 implementation must not silently ignore or accept a +different rate. Variable rates and the destination of any non-award residual remain +unvalidated/proposed and require a future effective-dated policy version plus a new +kernel contract; historical policy is never mutated. + +- [ ] **Step 4: Run the schema tests and type-free syntax check** + +Run: `cd spikes/internal-invocation-awards && npm test` + +Expected: PASS with all schema assertions green, including rejection of +`awardRateBps: 9000`, and zero network access. + +- [ ] **Step 5: Commit the schema slice** + +```bash +git add spikes/internal-invocation-awards/package.json spikes/internal-invocation-awards/src/schema.mjs spikes/internal-invocation-awards/test/budget.test.mjs +git commit -m "spike: define internal Invocation award schemas" +``` + +### Task 2: Implement atomic budget reservation and release + +**Files:** +- Create: `spikes/internal-invocation-awards/src/budget.mjs` +- Modify: `spikes/internal-invocation-awards/test/budget.test.mjs` + +- [ ] **Step 1: Add failing budget tests** + +Cover the exact API: + +```js +const budget = createBudget(SIGNED_BUDGET, { trustedFinanceSigners: FINANCE_SIGNERS, policy: ACTIVE_POLICY, now: NOW }); +const reserved = reserveBudget(budget, QUOTE, { expectedRevision: 0, reservationId: "res-001", now: NOW }); +assert.equal(reserved.budget.reservedAtomic, "3050000"); +assert.equal(reserved.budget.revision, 1); +assert.equal(reserved.reservation.state, "reserved"); + +assert.throws( + () => reserveBudget(reserved.budget, QUOTE_2, { expectedRevision: 0, reservationId: "res-002", now: NOW }), + /stale budget revision/, +); +assert.throws(() => reserveBudget(SMALL_BUDGET, QUOTE, { expectedRevision: 0, reservationId: "res-003", now: NOW }), /insufficient remaining budget/); + +const finalized = finalizeReservation(reserved.budget, reserved.reservation, { + grossAtomic: "2750000", + executionCostAtomic: "700000", + protocolFeeAtomic: "25000", + refundReserveAtomic: "25000", + recipientId: "sam" +}); +assert.equal(finalized.budget.consumedAtomic, "2750000"); +assert.equal(finalized.budget.releasedAtomic, "300000"); +assert.equal(finalized.budget.reservedAtomic, "0"); +``` + +Also assert full release before execution, partial release after a failed execution with unavoidable COGS, duplicate finalization rejection, and non-negativity/conservation after every transition. + +Assert `finalized.allocation.invocationAwardAtomic === 2_000_000n` and +`finalized.allocation.awardCredit` equals `{ recipientId: "sam", amountAtomic: +2_000_000n }`. Assert its four `journalEntries` all debit +`employer:invocation-gross`, credit the provider/protocol/reserve/employee account IDs +returned by the kernel, and sum to `grossAtomic`; persist those returned entries +verbatim in the budget event. Add a source-boundary test that imports the same +`allocateInternalGross` export from `../../../prototype/atomic-money.mjs`; do not copy +its subtraction logic into the spike. + +Generate the signer key in the test process. Add cases proving a self-signed budget +whose key is not in `FINANCE_SIGNERS`, a changed amount after signing, an unknown or +policy-disallowed `signerId`, an added self-declared `signerPublicKeyPem`, and an +expired policy version all fail before any reservation is created. + +- [ ] **Step 2: Run the focused test and verify red** + +Run: `cd spikes/internal-invocation-awards && node --test --test-name-pattern='budget|reservation' test/budget.test.mjs` + +Expected: FAIL because `src/budget.mjs` does not exist. + +- [ ] **Step 3: Implement the budget API** + +Export these exact call contracts: + +| Export | Input | Result | +| --- | --- | --- | +| `canonicalBudgetBytes(unsignedBudget)` | every `EmployerBudgetV1` field except `signature`, in the schema order above | UTF-8 canonical JSON bytes | +| `signBudget(unsignedBudget, privateKey)` | unsigned budget plus a throwaway test signer | frozen signed budget with a base64 Ed25519 signature and no public key field | +| `createBudget(signedBudget, { trustedFinanceSigners, policy, now })` | signed budget, provisioned `Record`, and trusted policy | verified frozen budget state | +| `remainingAtomic(budget)` | verified budget | `allocatedAtomic - reservedAtomic - consumedAtomic` as `bigint` | +| `reserveBudget(budget, quote, { expectedRevision, reservationId, now })` | exact revision and active quote | `{ budget, reservation, event }` | +| `finalizeReservation(budget, reservation, actual)` | actual gross, execution cost, exact quote-final fee, reserve, and recipient | `{ budget, reservation, allocation, event }` | +| `releaseReservation(budget, reservation, { executionCostAtomic, reason })` | pre-execution cancellation or validated `failed_after_start` data | `{ budget, reservation, event }` | +| `holdUnresolvedReservation(budget, reservation, { reason })` | post-start execution whose actual COGS cannot be validated | `{ budget, reservation, event }` with unchanged budget amounts and `held_unresolved` state | + +`createBudget` rejects every unknown field, resolves `signerId` only through +`trustedFinanceSigners`, requires the ID in `policy.permittedFinanceSignerIds`, and +verifies the signature. Embedded/self-reported key material is always invalid. + +Every transition returns a new frozen budget plus an append-only event; it never +mutates its input. `reserveBudget` requires exact revision equality and reserves +`quote.maxGrossAtomic`. `finalizeReservation` converts validated persisted strings to +`bigint`, calls `allocateInternalGross` exactly once, enforces the kernel result's +`grossAtomic <= reserved`, requires the actual fee to equal the quote-final +`protocolFeeAtomic`, verifies COGS/reserve/award remain within their quote and policy +caps, marks the reservation consumed, increments revision, moves gross to consumed, +moves the remainder to the cumulative `releasedAtomic` audit counter, and subtracts +the entire reservation from `reservedAtomic`. The spendable invariant is +`remainingAtomic = allocatedAtomic - reservedAtomic - consumedAtomic`; released is a +cumulative flow counter and is not subtracted twice. `releaseReservation` records +unavoidable execution COGS only when `reason === "failed_after_start"` and the caller +supplies the validated executor-reported cost; all other components, including the +award, release. It has no branch for unknown post-start cost and may not accept a +defaulted zero. `held_unresolved` reservations are rejected by both release and award +finalization APIs. +`holdUnresolvedReservation` permits only the enumerated unresolved reasons, increments +the budget revision without changing allocated/reserved/consumed/released amounts, and +emits `execution_cost_unresolved`; it is idempotency-protected and cannot be called on a +pre-start reservation. + +- [ ] **Step 4: Run all budget tests** + +Run: `cd spikes/internal-invocation-awards && npm test` + +Expected: PASS; stale revision, insufficient budget, caps, and release cases are green. + +- [ ] **Step 5: Commit the budget slice** + +```bash +git add spikes/internal-invocation-awards/src/budget.mjs spikes/internal-invocation-awards/test/budget.test.mjs +git commit -m "spike: reserve employer Invocation budgets atomically" +``` + +### Task 3: Add signed one-use credentials and the authoritative Invocation engine + +**Files:** +- Create: `spikes/internal-invocation-awards/src/credentials.mjs` +- Create: `spikes/internal-invocation-awards/src/engine.mjs` +- Create: `spikes/internal-invocation-awards/src/store.mjs` +- Create: `spikes/internal-invocation-awards/test/engine.test.mjs` + +- [ ] **Step 1: Write failing credential and lifecycle tests** + +Generate Ed25519 keys at test runtime with `generateKeyPairSync("ed25519")`; do not commit any key. Assert: + +```js +const credentialAuthorizers = { + "megacorp-collar-authorizer": publicKey.export({ type: "spki", format: "pem" }) +}; +const store = new InMemoryEngineStore(createEngineState({ + signedBudget: SIGNED_BUDGET, + policies: { "policy-megacorp-ledger-recon@1": ACTIVE_POLICY }, + financeSigners: FINANCE_SIGNERS, + managerSigners: MANAGER_SIGNERS, + credentialAuthorizers, + now: NOW +})); +const signed = signCredential(CREDENTIAL_PAYLOAD, privateKey); +assert.equal(verifyCredential(signed, publicKey, NOW).invocationId, "inv-001"); +assert.throws(() => verifyCredential({ ...signed, skillVersionHash: OTHER_HASH }, publicKey, NOW), /signature/); +assert.throws(() => verifyCredential(signed, publicKey, AFTER_EXPIRY), /expired/); + +const authorized = await authorizeInternalInvocation({ + store, + quote: QUOTE, + expectedRevision: 0, + expectedBudgetRevision: 0, + reservationId: "res-001", + credentialNonce: NONCE, + credentialIssuedAt: NOW, + credentialExpiresAt: FIVE_MINUTES_LATER, + credentialAuthorizerId: "megacorp-collar-authorizer", + managerApproval: null +}); +assert.equal(authorized.reservation.state, "reserved"); +const authorizedCredential = signCredential(authorized.credentialPayload, privateKey); +const executor = async () => ({ + kind: "succeeded", + executionCostAtomic: "700000", + outputHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +}); +const result = await executeAuthorizedInvocation({ + store, + quote: QUOTE, + credential: authorizedCredential, + executor, + now: NOW +}); +assert.equal(result.invocation.state, "succeeded"); +assert.equal(result.award.amountAtomic, "2000000"); +assert.equal(result.award.state, "earned"); +assert.equal(result.invocation.externalRoyaltyCreditsAtomic, "0"); +assert.equal(result.invocation.employerSelfCreditAtomic, "0"); +await assert.rejects( + executeAuthorizedInvocation({ store, quote: QUOTE, credential: authorizedCredential, executor, now: NOW }), + /credential already consumed|idempotency key/, +); +``` + +Add rejection tests for unauthorized Wielder, expired budget, exceeded quote cap, +period award cap, repeated idempotency key, insufficient budget, self-Invocation +without manager approval, self-approval, manager not permitted by the policy, expired +manager approval, approval bound to another Invocation, and executor failure. Assert +every rejection before `executing` calls the executor zero times; post-execution +`failed_after_start` records the exact executor-reported COGS and creates no award. + +Add table-driven executor-outcome tests: + +1. `succeeded` with valid `executionCostAtomic` finalizes the kernel allocation and + award. +2. `failed_after_start` with valid `executionCostAtomic` consumes only that COGS, + releases the remaining reservation, and creates no award. +3. A thrown executor, explicit `unresolved_after_start`, unknown `kind`, missing cost, + negative/decimal/non-string cost, cost above `maxExecutionCostAtomic`, unknown key, + or malformed success `outputHash` transitions to `unresolved` plus + `held_unresolved`. + +For every case in item 3, assert the entire original reservation remains in +`budget.reservedAtomic`, `consumedAtomic` and `releasedAtomic` do not change, no award or +money journal entry exists, the nonce remains consumed, retry cannot execute, and an +`execution_cost_unresolved` event is appended. Explicitly prove none of those cases is +converted to `executionCostAtomic: "0"`. + +Add sequencing tests proving: a correctly signed credential cannot execute against a +state with no persisted reservation; a reserved authorization cannot execute with a +missing credential; a credential for reservation A cannot execute reservation B; a +cancelled/released reservation rejects its previously signed credential; and signing +happens only after `authorizeInternalInvocation` returns the exact credential payload. +Generate a second Ed25519 key pair and prove an unknown authorizer ID, a signature made +by an untrusted key, an authorizer disallowed by the active policy, and a signed object +carrying a self-declared `publicKeyPem` all fail before the executor runs. No execution +API accepts a public key parameter. +For self-Invocation approval, add the same regression with a valid signature from an +unprovisioned manager and with an embedded manager key; neither may reserve budget. + +Add an overlapping-Promise regression: start two +`authorizeInternalInvocation` calls against `expectedBudgetRevision: 0` before either +settles and await `Promise.allSettled`. Exactly one must fulfill, one must reject with +`stale engine revision`, and the final snapshot must contain one reservation and one +idempotency binding. Add the same race around duplicate execution start/finalization; +the executor and award finalizer run once. Finally, prove the period-cap exposure is +`earned non-reversed awards + maxInvocationAwardAtomic for every reserved, executing, +or held-unresolved +authorization` under that policy/version/period. Two reservations whose maximum +awards would exceed the period cap must not coexist even before either executes. + +- [ ] **Step 2: Run engine tests and verify red** + +Run: `cd spikes/internal-invocation-awards && node --test test/engine.test.mjs` + +Expected: FAIL with missing `credentials.mjs`, `store.mjs`, or `engine.mjs`. + +- [ ] **Step 3: Implement canonical credential signing** + +`src/credentials.mjs` must canonicalize keys in this exact order: + +```text +schemaVersion, credentialAuthorizerId, invocationId, reservationId, idempotencyKey, skillId, +skillVersionHash, policyId, policyVersion, nonce, issuedAt, expiresAt +``` + +Export `canonicalCredentialBytes(payload)`, `signCredential(payload, privateKey)`, +`verifyCredential(signed, trustedPublicKey, now)`, `canonicalManagerApprovalBytes(approval)`, +`signManagerApproval(approval, privateKey)`, and `verifyManagerApproval(approval, { +policy, quote, managerSigners, now })`. Signatures are base64 Ed25519 over UTF-8 +canonical JSON. Credential verification rejects extra keys, invalid timestamps, bad +signatures, embedded/self-declared key material, and expiry. The engine compares the verified credential's +policy/version/hash/reservation bindings to the active inputs, then consumes the nonce +before execution. Manager verification requires an expected key for a +policy-permitted signer, rejects self-approval and mismatched/expired bindings, and +never reads a public key from the approval payload. + +- [ ] **Step 4: Implement the engine** + +Export: + +```js +export function createEngineState({ signedBudget, policies, financeSigners, managerSigners, credentialAuthorizers, now }) { + return Object.freeze({ + revision: 0, + budget: createBudget(signedBudget, { + trustedFinanceSigners: financeSigners, + policy: policies[`${signedBudget.policyId}@${signedBudget.policyVersion}`], + now + }), + policies, + financeSigners, + managerSigners, + credentialAuthorizers, + invocations: Object.freeze({}), + reservations: Object.freeze({}), + awards: Object.freeze({}), + consumedNonces: Object.freeze({}), + idempotency: Object.freeze({}), + events: Object.freeze([]) + }); +} + +``` + +In `src/store.mjs`, export `InMemoryEngineStore`. `snapshot()` returns the current +frozen state. `transact(expectedRevision, transition)` queues transitions on one +private Promise tail, checks `expectedRevision` only after acquiring the queue, +requires the returned state's revision to be exactly current + 1, freezes it, and +publishes it atomically. A throwing transition leaves state/revision unchanged and +does not poison the queue. This is the spike's executable single-process CAS boundary; +the README must not imply it is a distributed database lock. + +Also expose `transactRecord(recordId, expectedRecordRevision, transition)`. It acquires +the same queue, checks the named Invocation/reservation revision and immutable +`executionAttemptId` against the latest global snapshot, then applies one global +revision. This lets finalization coexist with unrelated reservations while preventing +two completions of the same execution attempt. + +Export three lifecycle functions: + +| Export | Contract | +| --- | --- | +| `authorizeInternalInvocation(input)` | Runs one store transaction that validates policy/quote, total award exposure, manager approval, idempotency, and expected revision; atomically persists reservation plus expected credential payload; returns `{ state, invocation, reservation, credentialPayload, events }` without executing or signing. | +| `cancelInternalAuthorization({ store, expectedRevision, reservationId, reason, now })` | Runs one transaction that releases a still-reserved authorization, appends `cancelled`, and makes every credential for that reservation unusable. | +| `executeAuthorizedInvocation(input)` | Transaction 1 verifies the already-persisted reservation/credential and atomically marks executing plus consumes nonce; it awaits the executor outside the lock; transaction 2 CAS-finalizes success, validated post-start failure, or unresolved hold exactly once. | + +`authorizeInternalInvocation` takes the provisioned `store`, `expectedRevision`, +`expectedBudgetRevision`, `reservationId`, a +caller-generated lowercase 32-byte `credentialNonce`, issued/expiry times, +`credentialAuthorizerId`, and `managerApproval`. Non-self Invocations pass +`managerApproval: null`. It resolves the immutable effective-dated policy and manager +key from engine state, validates the quote, and enforces the period cap by summing prior non-reversed awards +plus the quote maximums of all reserved/executing/held-unresolved authorizations for the same +policy/version/period, verifies any required manager approval, atomically reserves, +stores the canonical credential payload, and creates requested/quoted/authorized +events. It never receives a private key and never signs a credential. + +`createEngineState` is the only trust-root provisioning boundary. It validates and +deep-freezes immutable `policies: Record`, +`financeSigners`, `managerSigners`, and `credentialAuthorizers` maps, verifies the +signed budget through the finance map and policy allow-list, and rejects incomplete or +extra-key configuration. None of these maps comes from a quote, approval, credential, +or lifecycle call. Authorization requires manager/authorizer IDs in both the relevant +state map and the resolved policy allow-list, and binds the credential authorizer ID +into the canonical payload. + +The caller signs only the exact returned payload. `executeAuthorizedInvocation` +resolves `credentialAuthorizerId` from the signed payload against the trusted map +already stored in engine state, verifies with that key, byte-compares +the verified canonical payload with the reserved record, requires the reservation to +still be `reserved`, consumes the nonce, emits executing, and awaits the injected +executor outside the store transaction. It then reacquires the store and uses +`transactRecord` on the persisted execution-attempt revision. It calls +`parseExecutorOutcome` on the return value. Only a validated success passes the +executor's reported COGS, derived actual gross, exact quote-final fee, reserve, and +Creator recipient to the shared kernel through `finalizeReservation`. A validated +`failed_after_start` passes its exact reported COGS to `releaseReservation` and creates +no award. A throw or any unresolved/malformed outcome calls +`holdUnresolvedReservation`; it does not invoke either monetary finalizer. Success +finalizes an award; validated failure records unavoidable COGS and releases unused +budget; unresolved cost holds the full reservation. It rejects any external +Royalty-claim or employer-self-credit input. +Corrections are new reversal/adjustment events; no event is overwritten. + +The engine and statement layers consume `allocation.journalEntries` returned by +`allocateInternalGross`. They do not reconstruct debit/credit account IDs or monetary +entries from component fields. + +- [ ] **Step 5: Run all tests** + +Run: `cd spikes/internal-invocation-awards && npm test` + +Expected: PASS, including failure-before-execution and post-execution-failure cases. + +- [ ] **Step 6: Commit the engine slice** + +```bash +git add spikes/internal-invocation-awards/src/credentials.mjs spikes/internal-invocation-awards/src/store.mjs spikes/internal-invocation-awards/src/engine.mjs spikes/internal-invocation-awards/test/engine.test.mjs +git commit -m "spike: execute budget-backed internal Invocations" +``` + +### Task 4: Produce identical signed receipts and statements for employer and employee + +**Files:** +- Create: `spikes/internal-invocation-awards/src/statements.mjs` +- Create: `spikes/internal-invocation-awards/test/statements.test.mjs` + +- [ ] **Step 1: Write failing receipt/statement tests** + +Assert one successful receipt includes invocation, reservation, Skill hash, effective +policy, the kernel-returned account-identified journal entries, execution COGS, fee, +reserve, award, sequence, and no external settlement hash. Employer and employee +verify the same bytes and signature. Build a statement containing opening, +reservations, releases, charges, awards, reversals, payments, and closing balance; +assert contiguous sequences and a deterministic SHA-256 binary Merkle root. A gap from +sequence 1 to 3 must throw `statement sequence gap: expected 2, received 3`. +Also build an unresolved receipt and assert it contains the held reservation amount, +`executionCostStatus: "unresolved"`, no award, and no monetary journal entries; it may +not claim zero COGS or a released reservation. + +Sign the entire built statement and verify the same canonical bytes as employer and +employee. Add one-at-a-time tamper cases for `statementId`, employer/Creator/period, +`openingAtomic`, each payment field and amount, each reversal field and amount, +reservation/release/charge/award/payment/reversal totals, `closingAtomic`, ordered +receipt hashes, receipt sequence bounds, and receipt Merkle root. Every mutation must +fail either the statement signature or deterministic recomputation. A valid set of +individually signed receipts with an unsigned or attacker-resigned statement is not +accepted by the trusted statement verifier. + +- [ ] **Step 2: Run and verify red** + +Run: `cd spikes/internal-invocation-awards && node --test test/statements.test.mjs` + +Expected: FAIL because `src/statements.mjs` is absent. + +- [ ] **Step 3: Implement receipt and statement functions** + +Export these exact functions: `canonicalReceiptBytes(receipt)`, +`signReceipt(receipt, privateKey)`, `verifyReceipt(signedReceipt, publicKey)`, +`receiptHash(signedReceipt)`, `buildStatement({ statementId, employerId, creatorId, +period, openingAtomic, receipts, payments, reversals, statementSignerId })`, +`canonicalStatementBytes(unsignedStatement)`, +`signStatement(unsignedStatement, privateKey)`, +`verifyStatement(signedStatement, { signedReceipts, publicKey })`, and +`renderJsonl(events)`. +Canonicalizers return `Uint8Array`; hashes are lowercase `sha256:` strings; signers +return frozen objects with base64 Ed25519 signatures; verifiers return the validated +unsigned object; `renderJsonl` returns newline-terminated canonical JSON records. + +`buildStatement` returns an unsigned frozen `StatementV1` with these canonical fields +in this exact order: + +```text +schemaVersion, statementId, employerId, creatorId, period, currency, atomicScale, +openingAtomic, firstReceiptSequence, lastReceiptSequence, receiptHashes, +receiptMerkleRoot, reservationTotalAtomic, releaseTotalAtomic, chargeTotalAtomic, +awardTotalAtomic, reversals, reversalTotalAtomic, payments, paymentTotalAtomic, +closingAtomic, statementSignerId +``` + +Each payment is exactly `paymentId, amountAtomic, paidAt, railReference`; each reversal +is exactly `reversalId, receiptHash, amountAtomic, reason, occurredAt`. IDs are unique; +timestamps are UTC; amounts are non-negative decimal strings; payments sort by +`paymentId` and reversals by `reversalId`. `receiptHashes` are the ordered hashes of the +complete signed receipts at contiguous sequences. Totals are recomputed from signed +receipt events and the full payment/reversal arrays. Payable closing balance is exactly +`openingAtomic + awardTotalAtomic - reversalTotalAtomic - paymentTotalAtomic` and may +not be negative. Reservation, release, and charge totals remain separately signed audit +totals and are not silently folded into that payable equation. + +`canonicalStatementBytes` rejects extra/missing keys and serializes every field above +except the final `signature`; it therefore authenticates the full economic statement, +not merely receipt leaves or their Merkle root. `signStatement` adds only the base64 +Ed25519 `signature`. `verifyStatement` uses the caller-provisioned trusted public key, +first verifies that whole-statement signature, then independently verifies every +receipt signature/hash/sequence, recomputes the Merkle root, all totals, and closing +balance, and byte-compares the recomputed unsigned statement. It never trusts a public +key embedded in the statement. + +Merkle leaves are the binary SHA-256 digest of each canonical signed receipt, sorted by contiguous sequence. Odd nodes duplicate the last hash. The signed statement calls this an inclusion root and explicitly does not call it completeness proof; sequence continuity, the authenticated receipt-hash list, and cross-party statement comparison supply the completeness signal. + +- [ ] **Step 4: Run all tests** + +Run: `cd spikes/internal-invocation-awards && npm test` + +Expected: PASS with receipt-signature, whole-statement-signature, every economic-field +tamper, root-tamper, and sequence-gap rejection green. + +- [ ] **Step 5: Commit the audit slice** + +```bash +git add spikes/internal-invocation-awards/src/statements.mjs spikes/internal-invocation-awards/test/statements.test.mjs +git commit -m "spike: sign internal award receipts and statements" +``` + +### Task 5: Add the deterministic demonstration and honest spike report + +**Files:** +- Create: `spikes/internal-invocation-awards/demo.mjs` +- Create: `spikes/internal-invocation-awards/README.md` + +- [ ] **Step 1: Add the offline demo** + +The demo generates throwaway Ed25519 keys in memory, activates the exact example +policy, allocates a simulated `$1,000.000000` employer accounting budget, calls +`authorizeInternalInvocation` to reserve `$3.050000`, signs only the returned +credential payload, and calls `executeAuthorizedInvocation` with a fake successful +executor returning `{ kind: "succeeded", executionCostAtomic: "700000", +outputHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }`. It records `$0.700000` COGS, +`$0.025000` protocol fee, `$0.025000` reserve, +and `$2.000000` Invocation award, releases `$0.300000`, verifies the same receipt for +employer and employee, builds and signs the full zero-opening statement, verifies its +receipt hashes/totals/closing balance for both parties, and prints: + +```text +INTERNAL INVOCATION AWARD SPIKE — SIMULATED ACCOUNTING, NO REAL FUNDS +invocation inv-001: succeeded +reserved 3.050000 USD +consumed 2.750000 USD +released 0.300000 USD +employee-Creator Invocation award 2.000000 USD: earned, not paid +external Wielder required: no +external Royalty-claim credits: 0 +platform-held balance: 0 +receipt signature: verified by employer and employee +statement signature and economic totals: verified by employer and employee +RESULT: accounting path demonstrated; demand, payroll, tax, employment-law, securities, and custody validation remain not-run +``` + +- [ ] **Step 2: Document scope and human gates** + +The README must state at the top: `SPIKE — deterministic accounting evidence only`. It must say no network, wallet, chain, API key, funded account, payroll transfer, or real money is used. It must distinguish the internal Invocation award from external Royalty-claim revenue, list every tested rejection, and state that counsel-drafted instrument, employer agreement, and payroll/AP payment are human-only gates. +It must also document the strict executor outcome union and state that thrown, +malformed, or unknown-cost post-start execution holds the full reservation as +unresolved; the spike never substitutes zero COGS, releases that hold, or creates an +award automatically. +Document that receipt signatures authenticate individual events while the separate +whole-statement signature authenticates opening balance, full payment/reversal arrays, +derived totals, closing balance, and ordered receipt hashes; a Merkle inclusion root by +itself does not authenticate or prove completeness of the surrounding statement. + +- [ ] **Step 3: Run the complete verification** + +Run: `cd spikes/internal-invocation-awards && npm test && npm run demo` + +Expected: tests PASS; demo prints the exact accounting totals above and contains `NO REAL FUNDS` plus `not paid`. + +Run: `! rg -n 'grossAtomic\s*-|protocolFeeAtomic\s*-|invocationAwardAtomic\s*=' spikes/internal-invocation-awards/src` + +Expected: exit 0 and no duplicated allocation expression; gross partitioning appears +only in `prototype/atomic-money.mjs`. + +- [ ] **Step 4: Confirm protected corpus and secrets are untouched** + +Run: `git diff --exit-code -- CONTEXT.md docs/PRD.md docs/adr && ! git diff --cached --name-only | rg '(^|/)\.env$|private.*key'` + +Expected: exit 0 and no output. + +- [ ] **Step 5: Commit the completed spike** + +```bash +git add spikes/internal-invocation-awards/demo.mjs spikes/internal-invocation-awards/README.md +git commit -m "docs: report internal Invocation award spike" +``` + +## Definition of done + +- `npm test` and `npm run demo` pass with no network or secrets. +- The success path needs no external Wielder and creates no employer self-credit. +- Authorization reserves budget before credential signing; execution rejects absent, mismatched, consumed, cancelled, or released reservations. +- Execution resolves the payload's policy-permitted authorizer ID from engine-provisioned trust roots; it never accepts credential key material from the request. +- Reservation, consumption, release, COGS, fee, reserve, and award conserve atomic units exactly. +- Successful finalization uses `prototype/atomic-money.mjs#allocateInternalGross`; the spike contains no second allocation implementation. +- V1 accepts only the immutable 100%-residual award rule (`awardRateBps === 10000`); every other rate fails before reservation and is not silently ignored. +- Unauthorized, expired, capped, duplicate, self-farmed, and underfunded requests fail before execution. +- Serialized CAS permits only one reservation at a stale revision and only one finalization per execution attempt; reserved, executing, and held-unresolved maximum awards count toward the period cap. +- Successful and post-start failed outcomes require validated executor-reported COGS; + failed execution earns no award while unavoidable COGS remains visible. +- Thrown, malformed, or unknown-cost post-start execution becomes an unresolved full + reservation hold with no release, no award, and no zero-cost substitution. +- Employer and employee verify the same signed receipt and contiguous statement. +- Employer and employee verify one whole-statement signature binding opening balance, + complete payments/reversals, every economic total, closing balance, and ordered + receipt hashes; signed receipts alone cannot authenticate a mutable statement shell. +- The README labels the result `spike`, `simulated`, `no real funds`, and `accounting evidence only`. +- `CONTEXT.md`, `docs/PRD.md`, and `docs/adr/` remain unchanged. diff --git a/docs/superpowers/plans/2026-07-17-phase0-proof-safety.md b/docs/superpowers/plans/2026-07-17-phase0-proof-safety.md new file mode 100644 index 0000000..68bf12f --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-phase0-proof-safety.md @@ -0,0 +1,1815 @@ +# Phase 0 Proof Safety Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Aeneid registration demo fail before underfunded native-gas or WIP-fee writes, survive every transaction-confirmation crash without duplicate registration, and publish stage-correct durable metadata whose exact bytes are verified. + +**Architecture:** The write path becomes prepare/sign/durably-persist/broadcast/confirm: a mode-0600 signed testnet transaction, its hash, and every immutable proof-building field are journaled before broadcast under an exclusive same-host lease and compare-and-swap revision. On rerun, a matching pending transaction is reconciled before any native-IP or WIP readiness read because the broadcast may already have consumed those balances. Confirmed proof JSON is written completely to a temp file, temp-fsynced, atomically renamed, and directory-fsynced before the journal may clear. Only remaining new prepares receive the conservative native-gas gate, and only a new Derivative prepare receives the WIP balance/allowance gate. Metadata sends the Pinata JWT only to one fixed upload endpoint and permits paired stage overrides only on a strictly validated allow-listed public-IPFS gateway; every returned URI is fetched without credentials and byte-compared before transaction preparation. + +**Tech Stack:** TypeScript, Node.js 20+ (`node:test`), viem, Story Protocol SDK 1.4.4, Pinata public IPFS HTTP API + +--- + +## File map + +- Create `phase0/src/funding.ts`: conservative native-gas estimate for remaining new writes and diagnostic formatting. +- Create `phase0/tests/funding.test.ts`: zero-remaining, boundary, and estimate tests. +- Create `phase0/src/transactions.ts`: pending-operation schema, exclusive lease, CAS revision, mode-0600 durable journal, intent hashing, and resume state machine. +- Create `phase0/tests/transactions.test.ts`: lease, permissions, durability, CAS, and prepare/broadcast/confirm crash tests. +- Modify `phase0/src/demo.ts`: use the funding preflight and transaction journal for all four writes. +- Modify `phase0/src/story.ts`: encode with the Story SDK, expose WIP fee readiness, sign before broadcast, broadcast raw bytes, wait for receipts, and decode proof events. +- Modify `phase0/src/client.ts`: expose a viem wallet client and the public-client transaction methods. +- Modify `phase0/src/registrations.ts`: persist the confirmed license-template address and durably fsync each confirmed-only manifest before journal clear. +- Modify `phase0/tests/registrations.test.ts`: write-all, fsync/rename ordering, and crash-durability tests. +- Modify `phase0/tests/demo.test.ts` and `phase0/tests/story.test.ts`: new chain seam and no-duplicate recovery coverage. +- Modify `phase0/src/metadata.ts`: Pinata-backed public IPFS publisher and paired stage-specific overrides. +- Modify `phase0/tests/metadata.test.ts`: pin/fetch verification and override-isolation tests. +- Modify `phase0/src/index.ts`: construct the journal/provider and report native-gas and WIP readiness separately. +- Modify `phase0/.env.example`, `.gitignore`, `README.md`, and `package.json`: document safe configuration and wallet-attested scope. + +### Safety and human-only boundary + +Automated verification uses injected fakes and must not read `phase0/.env`, use a private key, call Pinata, call Story RPC, fund a wallet, wrap IP to WIP, approve WIP, or broadcast a transaction. `pending-transactions.json`, its temporary files, and its process-lock files are always ignored and must never be staged. The journal contains replayable signed bytes even though it contains no private key, so it is local sensitive material. A real Aeneid wallet, Pinata JWT, pinning operation, faucet funding, manual IP-to-WIP wrap, manual WIP approval, and testnet write remain human actions. Mainnet is out of scope and forbidden. + +### Task 1: Model native gas for only the remaining new writes + +**Files:** +- Create: `phase0/src/funding.ts` +- Create: `phase0/tests/funding.test.ts` +- Modify: `phase0/src/demo.ts:13-16,38-45,162-173` +- Modify: `phase0/src/story.ts:21-24,47-53` +- Modify: `phase0/tests/demo.test.ts:69-138` + +- [ ] **Step 1: Write funding tests** + +Create `phase0/tests/funding.test.ts`: + +```ts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEMO_GAS_UNITS_ENVELOPE, + estimateRemainingDemoGasMinimum, +} from "../src/funding"; + +test("no remaining new write needs no native-gas estimate", () => { + assert.equal(estimateRemainingDemoGasMinimum({ gasPrice: 0n, remainingNewWrites: 0 }), 0n); +}); + +test("any remaining new write retains the full conservative gas envelope", () => { + const minimum = estimateRemainingDemoGasMinimum({ gasPrice: 2n, remainingNewWrites: 1 }); + assert.equal(minimum, 2n * DEMO_GAS_UNITS_ENVELOPE); + assert.equal( + estimateRemainingDemoGasMinimum({ gasPrice: 2n, remainingNewWrites: 4 }), + minimum, + ); +}); + +test("invalid gas prices fail closed", () => { + assert.throws( + () => estimateRemainingDemoGasMinimum({ gasPrice: 0n, remainingNewWrites: 1 }), + /gas price must be positive/i, + ); + assert.throws( + () => estimateRemainingDemoGasMinimum({ gasPrice: 1n, remainingNewWrites: -1 }), + /remaining new writes/i, + ); +}); +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `cd phase0 && node --import tsx --test --test-name-pattern='remaining new write|gas prices' tests/funding.test.ts` + +Expected: FAIL with `Cannot find module '../src/funding'`. + +- [ ] **Step 3: Implement the estimate** + +Create `phase0/src/funding.ts`: + +```ts +export const DEMO_GAS_UNITS_ENVELOPE = 6_000_000n; + +export function estimateRemainingDemoGasMinimum(input: { + gasPrice: bigint; + remainingNewWrites: number; +}): bigint { + if (!Number.isSafeInteger(input.remainingNewWrites) || input.remainingNewWrites < 0) { + throw new Error("Remaining new writes must be a non-negative safe integer"); + } + if (input.remainingNewWrites === 0) return 0n; + if (input.gasPrice <= 0n) throw new Error("Gas price must be positive"); + return input.gasPrice * DEMO_GAS_UNITS_ENVELOPE; +} +``` + +The gas envelope is deliberately conservative and must be described as an +estimate, not a measured exact fee. Retaining the full envelope for one or more +remaining writes avoids inventing per-stage gas allocations. Do not add +`DEMO_ROOT_MINTING_FEE` to this native-IP amount: that Derivative fee is +denominated in WIP and has its own balance/allowance gate in Task 4. + +- [ ] **Step 4: Extend the chain seam with gas price** + +Add `getGasPrice(): Promise` to `DemoChain`. Add it to `StoryPublicClientBoundary` and implement: + +```ts +getGasPrice(): Promise { + return this.publicClient.getGasPrice(); +} +``` + +Update `FakeChain` with `gasPrice = 2n` and a matching method. + +Do not call `getBalance` or `getGasPrice` from `runDemo` yet. Task 4 integrates +those reads only after a signed pending operation has been reconciled and only +when at least one genuinely new stage remains. + +- [ ] **Step 5: Run Phase 0 tests and typecheck** + +Run: + +```bash +cd phase0 +npm test +npm run typecheck +``` + +Expected: all tests PASS and TypeScript exits 0. + +- [ ] **Step 6: Commit the pure estimator and read seam** + +```bash +git add phase0/src/funding.ts phase0/tests/funding.test.ts phase0/src/demo.ts phase0/src/story.ts phase0/tests/demo.test.ts phase0/tests/story.test.ts +git commit -m "feat: estimate gas for remaining Phase 0 writes" +``` + +### Task 2: Add a private, leased, CAS-safe durable transaction journal + +**Files:** +- Create: `phase0/src/transactions.ts` +- Create: `phase0/tests/transactions.test.ts` +- Modify: `phase0/.gitignore` + +- [ ] **Step 1: Write permissions, lease, CAS, and durability tests** + +Create `phase0/tests/transactions.test.ts` with a temporary file. Add the +production hash function and types to its imports: + +```ts +import { + operationIntentHash, + type CanonicalOperationIntent, + type PendingOperation, +} from "../src/transactions"; +``` + +Use a complete valid canonical intent for every lifecycle/CAS/durability test; +the ordinary record must never rely on a fabricated intent hash: + +```ts +const INTENT: CanonicalOperationIntent = { + stage: "root", + chainId: 1315, + wallet: "0x00000000000000000000000000000000000000aa", + registrationName: "demo-research-skill", + artifactPath: "fixtures/demo-base/SKILL.md", + spgNftContract: "0x00000000000000000000000000000000000000bb", + parentIpId: null, + licenseTermsId: null, + licenseTemplate: null, + currencyToken: null, + defaultMintingFee: null, + maxMintingFee: null, + metadata: { + ipMetadataURI: "ipfs://root-ip-metadata", + ipMetadataHash: `0x${"4".repeat(64)}`, + nftMetadataURI: "ipfs://root-nft-metadata", + nftMetadataHash: `0x${"5".repeat(64)}`, + artifactMediaHash: `0x${"6".repeat(64)}`, + artifactMediaType: "text/markdown", + }, + runConfigHash: `0x${"7".repeat(64)}`, +}; + +const RECORD: PendingOperation = { + schemaVersion: 1, + operationId: "phase0:0x00000000000000000000000000000000000000aa:root", + stage: "root", + intent: INTENT, + intentHash: operationIntentHash(INTENT), + transactionHash: `0x${"2".repeat(64)}`, + serializedTransaction: `0x${"3".repeat(128)}`, + state: "prepared", +}; + +const INTENT_HASH_MISMATCH: PendingOperation = { + ...RECORD, + intentHash: `0x${"0".repeat(64)}`, +}; +``` + +Add the exact lifecycle test: + +```ts +await journal.withExclusiveLease(async (leased) => { + const empty = await leased.load(); + assert.deepEqual(empty, { revision: 0, operation: null }); + const saved = await leased.save(RECORD, empty.revision); + assert.equal(saved.revision, 1); + assert.deepEqual(saved.operation, RECORD); + const cleared = await leased.clear(RECORD.operationId, saved.revision); + assert.deepEqual(cleared, { revision: 2, operation: null }); +}); +const stat = await fs.stat(journalPath); +assert.equal(stat.mode & 0o777, 0o600); +assert.equal((await fs.readFile(journalPath, "utf8")).endsWith("\n"), true); +assert.deepEqual((await fs.readdir(directory)).filter((name) => name.includes(".tmp")), []); +``` + +Add separate tests proving: + +1. while one `withExclusiveLease` callback is paused, a second journal instance + on the same path rejects with `Pending transaction journal is locked`; +2. `save(RECORD, 0)` followed by another `save(RECORD, 0)` rejects as a stale + CAS revision and leaves revision 1 intact; +3. `clear` rejects the wrong operation ID and stale revision; +4. changing the journal to mode `0644` makes `load` fail before returning signed + bytes; +5. an injected crash after temporary-file `sync()` but before `rename()` leaves + the previous final snapshot byte-identical; a normal retry removes its own + temporary file and advances exactly one revision; +6. saving `INTENT_HASH_MISMATCH`, or loading an on-disk copy whose intent was + changed without recomputing its hash, fails as intent corruption; use this + mismatched record only in these corruption tests; +7. malformed hashes, odd-length signed hex, malformed lock owner JSON, and a + lock owned by a live same-host PID all fail closed. + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `cd phase0 && node --import tsx --test tests/transactions.test.ts` + +Expected: FAIL with `Cannot find module '../src/transactions'`. + +- [ ] **Step 3: Define the pending-operation schema** + +Create `phase0/src/transactions.ts`: + +```ts +export type OperationStage = "collection" | "root" | "child" | "grandchild"; +export type OperationState = "prepared" | "broadcast"; + +export interface CanonicalOperationIntent { + stage: OperationStage; + chainId: 1315; + wallet: `0x${string}`; + registrationName: string | null; + artifactPath: string | null; + spgNftContract: `0x${string}` | null; + parentIpId: `0x${string}` | null; + licenseTermsId: string | null; + licenseTemplate: `0x${string}` | null; + currencyToken: `0x${string}` | null; + defaultMintingFee: string | null; + maxMintingFee: string | null; + metadata: { + ipMetadataURI: string; + ipMetadataHash: `0x${string}`; + nftMetadataURI: string; + nftMetadataHash: `0x${string}`; + artifactMediaHash: `0x${string}`; + artifactMediaType: string; + } | null; + runConfigHash: `0x${string}`; +} + +export interface PendingOperation { + schemaVersion: 1; + operationId: string; + stage: OperationStage; + intent: CanonicalOperationIntent; + intentHash: `0x${string}`; + transactionHash: `0x${string}`; + serializedTransaction: `0x${string}`; + state: OperationState; +} + +export interface JournalSnapshot { + revision: number; + operation: PendingOperation | null; +} + +export interface LeasedOperationJournal { + load(): Promise; + save(operation: PendingOperation, expectedRevision: number): Promise; + clear(operationId: string, expectedRevision: number): Promise; +} + +export interface OperationJournal { + withExclusiveLease(callback: (journal: LeasedOperationJournal) => Promise): Promise; +} +``` + +Use strict regex validation for 32-byte hashes and nonempty even-length +serialized transaction hex. On every load, recompute `operationIntentHash` from +the persisted intent and require equality with `intentHash`. The intent contains +no prompt, artifact bytes, credential, or private key; it deliberately retains +the registration name, artifact path, metadata URI/hashes, and expected +collection/parent/license fields needed to confirm and save the complete proof +without consulting changed local definitions or republishing metadata after a +crash. Require `registrationName` and `artifactPath` to be nonempty for root, +child, and grandchild intents and exactly null for collection intent. + +- [ ] **Step 4: Implement deterministic intent hashing** + +Export: + +```ts +export function operationIntentHash(value: unknown): `0x${string}` { + const canonical = JSON.stringify(value, (_key, item) => + typeof item === "bigint" ? { $bigint: item.toString() } : item, + ); + return `0x${createHash("sha256").update(canonical).digest("hex")}`; +} +``` + +Callers must build `CanonicalOperationIntent` in its declared field order. The +hash binds stage, chain, wallet, registration name, artifact path, +collection/parent, license terms, fee currency and cap, all four metadata +URI/hash fields, and `runConfigHash`. Export a second +helper, `runConfigHash`, over a canonical object containing chain ID, wallet, +and each stage's name, description, artifact path, and artifact SHA-256. This +local fingerprint requires no metadata upload or RPC read. + +- [ ] **Step 5: Implement the exclusive lease, CAS, and durable mode-0600 write** + +`FileOperationJournal.withExclusiveLease` creates +`pending-transactions.json.lock` using `open(path, "wx", 0o600)`. Write one +trailing-newline JSON owner record containing a random 128-bit `leaseId`, +`hostname()`, `process.pid`, and `startedAtUtc`; sync and close it before reading +the journal. If it exists, report the recorded host/PID/lease ID and fail. Hold +the lease for the entire `runDemo` callback. Release only after rereading the +lock and matching the exact lease ID; a mismatched owner is a CAS failure and +must not be unlinked. + +Never auto-delete a lock after a timeout. Document an explicit same-host stale +lock recovery command that requires the recorded lease ID and verifies +`process.kill(pid, 0)` returns `ESRCH` before unlinking. `EPERM`, a different +host, malformed owner data, a live PID, or a changed lease ID fails closed. The +supported boundary is one local filesystem host; network filesystems are out of +scope. + +Persist this exact top-level shape: + +```ts +interface JournalFile { + schemaVersion: 1; + revision: number; + operation: PendingOperation | null; +} +``` + +Every `save` or `clear` reloads under the lease and requires +`current.revision === expectedRevision`. Increment exactly once. `clear` also +requires the matching operation ID and writes `operation: null`; retaining the +revision is necessary for CAS. + +For every write: + +1. create a unique same-directory temporary file with flags `wx` and mode + `0o600`; +2. write the complete trailing-newline JSON; +3. call the temporary file handle's `sync()` and close it; +4. atomically `rename()` it over the journal; +5. open the containing directory read-only, call `sync()`, and close it; +6. `stat()` the final journal and require `(mode & 0o777) === 0o600`. + +On failure, remove only the unique temporary file created by this call. On +load, reject the journal before parsing signed bytes unless its exact mode is +`0600`. Never log `serializedTransaction`. + +- [ ] **Step 6: Ignore the journal explicitly** + +Append to `phase0/.gitignore`: + +```gitignore +# Contains a signed, testnet-only transaction while crash recovery is in flight. +pending-transactions.json +pending-transactions.json.*.tmp +pending-transactions.json.lock +``` + +Do not put the private key in the journal. The serialized transaction contains +no key material but is replayable authorization and must be treated as local +sensitive data. Add a test that `git check-ignore` covers all three patterns and +`git ls-files` returns none of them. + +- [ ] **Step 7: Run journal tests** + +Run: `cd phase0 && node --import tsx --test tests/transactions.test.ts && npm run typecheck` + +Expected: all journal tests PASS and TypeScript exits 0. + +- [ ] **Step 8: Commit the journal** + +```bash +git add phase0/src/transactions.ts phase0/tests/transactions.test.ts phase0/.gitignore +git commit -m "feat: journal pending Aeneid transactions" +``` + +### Task 3: Split Story writes into prepare, broadcast, and confirm + +**Files:** +- Modify: `phase0/src/client.ts:1-40` +- Modify: `phase0/src/demo.ts:38-70` +- Modify: `phase0/src/story.ts:1-133` +- Modify: `phase0/tests/story.test.ts` + +- [ ] **Step 1: Write Story transaction-boundary tests** + +Update `tests/story.test.ts` fakes and add tests proving: + +1. `prepareCollection` calls the SDK with `txOptions: { encodedTxDataOnly: true }`, signs the returned `to/data`, and returns the locally computed transaction hash without broadcasting; +2. `broadcastPrepared` sends the exact serialized bytes and requires the RPC hash to equal the prepared hash; +3. `confirmCollection` decodes `CollectionCreated`; +4. `confirmSkill` decodes `IPRegistered` plus `LicenseTermsAttached`; +5. `nonce too low` with the exact expected transaction/receipt reconciles, + while `nonce too low` with the expected hash absent fails as unresolved or + replaced; +6. `confirmDerivative` requires matching `IPRegistered` and + `DerivativeRegistered` child, parent, license terms, and license template; +7. a reverted receipt or any mismatched Derivative event fails without a proof. + +Use deterministic fixture values: + +```ts +const ENCODED = { to: SPG, data: "0x1234" as const }; +const SERIALIZED = `0x${"ab".repeat(64)}` as const; +const TX_HASH = keccak256(SERIALIZED); +``` + +- [ ] **Step 2: Run Story tests to verify the old seam fails** + +Run: `cd phase0 && node --import tsx --test tests/story.test.ts` + +Expected: FAIL because `prepareCollection`, `broadcastPrepared`, and confirm methods do not exist. + +- [ ] **Step 3: Expose a wallet client without exposing the key** + +In `src/client.ts`, add: + +```ts +export function getWalletClient() { + return createWalletClient({ + account: getAccount(), + chain: aeneidChain, + transport: http(RPC), + }); +} +``` + +Never log the account's private key or a serialized pending transaction. + +- [ ] **Step 4: Replace the DemoChain write seam** + +Use these exact types in `src/demo.ts`: + +```ts +export interface PreparedChainTransaction { + transactionHash: `0x${string}`; + serializedTransaction: `0x${string}`; +} + +export interface DemoChain { + getChainId(): Promise; + getBalance(address: `0x${string}`): Promise; + getGasPrice(): Promise; + prepareCollection(input: CollectionInput): Promise; + prepareSkill(input: SkillInput): Promise; + prepareDerivative(input: DerivativeInput): Promise; + broadcastPrepared(input: PreparedChainTransaction): Promise; + confirmCollection(txHash: `0x${string}`): Promise; + confirmSkill(txHash: `0x${string}`): Promise; + confirmDerivative(input: { + transactionHash: `0x${string}`; + expectedCollection: `0x${string}`; + expectedParentIpId: `0x${string}`; + expectedLicenseTermsId: bigint; + expectedLicenseTemplate: `0x${string}`; + }): Promise; + predictMintingLicenseFee(input: PredictFeeInput): Promise<{ tokenAmount: bigint }>; +} +``` + +Move the existing input/result inline types into named exported interfaces so journal helpers and fakes use one contract. + +- [ ] **Step 5: Encode, sign, and hash before broadcast** + +Extend `StoryChain` constructor with a wallet boundary supporting +`prepareTransactionRequest` and `signTransaction`, and a public boundary +supporting `sendRawTransaction`, `getTransaction`, `getTransactionReceipt`, and +`waitForTransactionReceipt`. + +For each prepare method: + +```ts +const response = await this.sdk.nftClient.createNFTCollection({ + ...input, + isPublicMinting: true, + mintOpen: true, + contractURI: "", + txOptions: { encodedTxDataOnly: true }, +}); +const encoded = required(response.encodedTxData, "encoded collection transaction"); +const request = await this.wallet.prepareTransactionRequest({ + account: this.wallet.account, + chain: this.wallet.chain, + to: encoded.to, + data: encoded.data, +}); +const serializedTransaction = await this.wallet.signTransaction(request); +return { + serializedTransaction, + transactionHash: keccak256(serializedTransaction), +}; +``` + +Apply the same `encodedTxDataOnly` pattern to root and Derivative SDK methods. Preserve existing LAP/LRP, fee cap, metadata, and validation behavior. + +Treat `encodedTxDataOnly` as an encoding boundary, not a fee-readiness feature. +In SDK 1.4.4 the early encoded-data return bypasses +`handleRegistrationWithFees`, including its automatic IP-to-WIP wrapping and +WIP approval. Task 4 adds the required explicit read-only WIP balance/allowance +gate before any Derivative prepare. + +- [ ] **Step 6: Broadcast or reconcile only the exact expected hash** + +Implement: + +```ts +async broadcastPrepared(input: PreparedChainTransaction): Promise { + try { + const observed = await this.publicClient.sendRawTransaction({ + serializedTransaction: input.serializedTransaction, + }); + if (observed.toLowerCase() !== input.transactionHash.toLowerCase()) { + throw new Error(`RPC returned ${observed}; expected ${input.transactionHash}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/already known|known transaction|nonce too low/i.test(message)) throw error; + const exactTransaction = await this.findExactTransaction(input.transactionHash); + if (!exactTransaction) { + throw new Error( + `Prepared transaction ${input.transactionHash} is unresolved; its nonce may have been replaced`, + { cause: error }, + ); + } + } +} +``` + +`findExactTransaction` queries both `getTransaction({ hash: expected })` and +`getTransactionReceipt({ hash: expected })`, treating only the clients' +documented not-found errors as absence. It returns true only when a response's +own hash equals the expected hash. Any other RPC error propagates. An +“already known” message or consumed nonce is never sufficient by itself; if the +exact expected hash cannot be found, fail as unresolved/replaced. The next step +still waits for and validates the expected receipt. + +- [ ] **Step 7: Decode only successful receipts** + +Use viem `parseAbiItem`/`parseEventLogs` with these exact events: + +```ts +const COLLECTION_CREATED = parseAbiItem("event CollectionCreated(address indexed spgNftContract)"); +const IP_REGISTERED = parseAbiItem("event IPRegistered(address ipId, uint256 indexed chainId, address indexed tokenContract, uint256 indexed tokenId, string name, string uri, uint256 registrationDate)"); +const LICENSE_TERMS_ATTACHED = parseAbiItem("event LicenseTermsAttached(address indexed caller, address indexed ipId, address licenseTemplate, uint256 licenseTermsId)"); +const DERIVATIVE_REGISTERED = parseAbiItem("event DerivativeRegistered(address indexed caller, address indexed childIpId, uint256[] licenseTokenIds, address[] parentIpIds, uint256[] licenseTermsIds, address licenseTemplate)"); +``` + +Require `receipt.status === "success"` and +`receipt.transactionHash === expectedHash`, then require exactly one relevant +registration event for the expected chain/collection. Root confirmation also +requires the license event for the same `ipId`; return and persist its +`licenseTemplate` with the root proof. + +Derivative confirmation parses both event types from the same exact receipt and +requires: + +- `DerivativeRegistered.childIpId` equals the `IPRegistered.ipId`; +- `parentIpIds` is exactly `[expectedParentIpId]`, in order; +- `licenseTermsIds` is exactly `[expectedLicenseTermsId]`, in order; +- `licenseTemplate` equals the template persisted from the parent proof; +- the IP registration's chain and token contract equal Aeneid and the expected + SPG collection. + +Return the event-derived child IP, token ID, matched license terms ID, template, +and supplied transaction hash. Normalize root and Derivative confirmation +results so both expose `licenseTermsId: bigint` and +`licenseTemplate: 0x${string}` to the proof builder. Never construct Derivative +ancestry from local inputs alone. Extend +`RegistrationProof` and its strict parser with required `licenseTemplate`; child +and grandchild persist the template emitted by their own +`DerivativeRegistered` event. Add negative tests for mismatched child, parent, +terms ID, template, duplicate events, and missing events. + +- [ ] **Step 8: Run Story tests and typecheck** + +Run: `cd phase0 && node --import tsx --test tests/story.test.ts && npm run typecheck` + +Expected: Story tests PASS; no fake's `sendRawTransaction` runs during a prepare-only assertion. + +- [ ] **Step 9: Commit the two-phase chain seam** + +```bash +git add phase0/src/client.ts phase0/src/demo.ts phase0/src/story.ts phase0/src/registrations.ts phase0/tests/story.test.ts +git commit -m "refactor: separate Story submission from confirmation" +``` + +### Task 4: Resume the exact signed transaction after every crash point + +**Files:** +- Modify: `phase0/src/demo.ts:72-300` +- Modify: `phase0/src/story.ts:1-180` +- Modify: `phase0/src/registrations.ts:1-255` +- Modify: `phase0/src/index.ts:158-165` +- Modify: `phase0/.gitignore` +- Modify: `phase0/tests/demo.test.ts` +- Modify: `phase0/tests/registrations.test.ts` +- Modify: `phase0/tests/story.test.ts` + +- [ ] **Step 1: Add a reusable crash-safe operation helper test** + +In `tests/demo.test.ts`, extend fakes with counters for `prepare`, `broadcast`, and `confirm`. Add a table-driven test for crashes: + +```ts +for (const crashAfter of ["journal", "broadcast", "confirm", "manifest-save"] as const) { + test(`resume after ${crashAfter} reuses one root transaction hash`, async () => { + const journal = new MemoryOperationJournal(); + const chain = new CrashableChain(crashAfter); + await assert.rejects(runDemo({ wallet: WALLET, chain, metadata, store, journal }), /simulated crash/); + const preparedHash = journal.operation?.transactionHash; + const resumed = chain.resumedWithoutCrash(); + await runDemo({ wallet: WALLET, chain: resumed, metadata, store, journal }); + assert.equal(resumed.preparedHashes.filter((x) => x === preparedHash).length, 0); + assert.equal(resumed.broadcastHashes.every((x) => x === preparedHash), true); + assert.equal(resumed.confirmHashes.includes(preparedHash), true); + assert.equal(store.manifest.registrations.root?.txHash, preparedHash); + }); +} +``` + +Also assert a persisted intent/hash mismatch aborts before broadcast as journal +corruption. A valid pending journal whose persisted wallet/config differs from +the current invocation still reconciles only its exact signed hash, saves that +proof, then blocks every new stage with the explicit configuration-mismatch +error. + +For the existing `"manifest-save"` crash row, use a store whose durable-save +hook throws after rename but before directory fsync. Assert +`journal.clearCalls === 0`, the exact pending operation and CAS revision remain, +and no replacement prepare occurs on resume. Whether the post-crash filesystem +exposes the old manifest or the renamed manifest, resume must either re-confirm +the same hash and save it durably or observe the same manifest hash and clear; +both branches forbid a second signed transaction. + +In `tests/registrations.test.ts`, add a real-filesystem ordering regression +using the `ManifestWriteHooks` introduced in Step 5: + +```ts +test("manifest save resolves only after file and directory fsync", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-durable-manifest-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const events: string[] = []; + const store = new FileRegistrationStore(join(directory, "registrations.json"), { + afterTempSync: () => { events.push("temp-fsync"); }, + afterRename: () => { events.push("rename"); }, + afterDirectorySync: () => { events.push("directory-fsync"); }, + }); + await store.save(createEmptyRegistrationManifest()); + events.push("resolved"); + assert.deepEqual(events, ["temp-fsync", "rename", "directory-fsync", "resolved"]); + assert.deepEqual(await readdir(directory), ["registrations.json"]); +}); + +test("crash after temp fsync preserves the previous complete manifest", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-durable-manifest-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, "registrations.json"); + const previous = createEmptyRegistrationManifest(); + await new FileRegistrationStore(path).save(previous); + const previousBytes = await readFile(path); + const next = createEmptyRegistrationManifest(); + next.wallet = WALLET; + next.spgNftContract = SPG; + next.collectionTxHash = TX_HASH; + next.status = "partial"; + const crashing = new FileRegistrationStore(path, { + afterTempSync: () => { throw new Error("simulated crash after temp fsync"); }, + }); + await assert.rejects(crashing.save(next), /simulated crash/); + assert.deepEqual(await readFile(path), previousBytes); + assert.deepEqual(await readdir(directory), ["registrations.json"]); +}); + +test("rename interruption exposes only a parseable complete manifest", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-durable-manifest-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, "registrations.json"); + const next = createEmptyRegistrationManifest(); + next.wallet = WALLET; + next.spgNftContract = SPG; + next.collectionTxHash = TX_HASH; + next.status = "partial"; + const crashing = new FileRegistrationStore(path, { + afterRename: () => { throw new Error("simulated crash before directory fsync"); }, + }); + await assert.rejects(crashing.save(next), /simulated crash/); + assert.deepEqual(await new FileRegistrationStore(path).load(), next); + assert.deepEqual(await readdir(directory), ["registrations.json"]); +}); +``` + +In the same test file, make `rootOnlyConfirmedStore()` return a manifest whose +collection and root proof are already confirmed and whose stored root metadata +matches the injected metadata provider. Extend `CrashableChain` with +`predictedCurrencyToken`, `predictedFee`, `wipBalance`, `wipAllowance`, +`derivativeFeeSpender`, `feeReadinessCalls`, per-stage prepare counts, and +broadcast counts. Also count `balanceReads`, `gasPriceReads`, and metadata +provider calls. Add these two focused cases: + +```ts +test("insufficient WIP balance stops before Derivative prepare", async () => { + const chain = new CrashableChain(null); + chain.predictedFee = DEMO_ROOT_MINTING_FEE; + chain.wipBalance = DEMO_ROOT_MINTING_FEE - 1n; + chain.wipAllowance = DEMO_ROOT_MINTING_FEE; + await assert.rejects( + runDemo({ + wallet: WALLET, + chain, + metadata, + store: rootOnlyConfirmedStore(), + journal: new MemoryOperationJournal(), + }), + /WIP balance.*required/i, + ); + assert.equal(chain.feeReadinessCalls, 1); + assert.equal(chain.prepareCounts.child, 0); + assert.equal(chain.broadcastHashes.length, 0); +}); + +test("insufficient WIP allowance stops before Derivative prepare", async () => { + const chain = new CrashableChain(null); + chain.predictedFee = DEMO_ROOT_MINTING_FEE; + chain.wipBalance = DEMO_ROOT_MINTING_FEE; + chain.wipAllowance = DEMO_ROOT_MINTING_FEE - 1n; + await assert.rejects( + runDemo({ + wallet: WALLET, + chain, + metadata, + store: rootOnlyConfirmedStore(), + journal: new MemoryOperationJournal(), + }), + /WIP allowance.*required/i, + ); + assert.equal(chain.feeReadinessCalls, 1); + assert.equal(chain.prepareCounts.child, 0); + assert.equal(chain.broadcastHashes.length, 0); +}); +``` + +Add a second table for `crashAfter` values `"broadcast"` and `"confirm"` using a +root-only manifest. The first chain run prepares the child, consumes its WIP in +the simulated broadcast, and crashes while the child journal remains. Resume +with `wipBalance = 0n` and `wipAllowance = 0n`; use a store fake that persists +the recovered child proof and then throws `stop after recovered child` so the +test does not begin the unrelated grandchild. Assert all of the following: + +```ts +assert.equal(resumed.feeReadinessCalls, 0); +assert.equal(resumed.prepareCounts.child, 0); +assert.equal(resumed.broadcastHashes[0], pendingChildHash); +assert.equal(resumed.confirmHashes[0], pendingChildHash); +assert.equal(store.manifest.registrations.child?.txHash, pendingChildHash); +``` + +This regression proves depleted current WIP cannot block confirmation of the +already-signed transaction that consumed it. + +Add a final-stage regression for both `"broadcast"` and `"confirm"` crash +points. Start with collection, root, and child confirmed, prepare the +grandchild, and have the simulated broadcast consume the wallet's remaining IP +and WIP. On resume set native balance, WIP balance, and allowance to zero, and +use a metadata provider whose `prepare` throws `Pinata unavailable on resume`. +The pending grandchild is the final stage, so recovery must complete without +any readiness or metadata call: + +```ts +assert.equal(resumed.balanceReads, 0); +assert.equal(resumed.gasPriceReads, 0); +assert.equal(resumed.feeReadinessCalls, 0); +assert.equal(resumeMetadata.calls, 0); +assert.equal(resumed.prepareCounts.grandchild, 0); +assert.equal(resumed.broadcastHashes[0], pendingGrandchildHash); +assert.equal(resumed.confirmHashes[0], pendingGrandchildHash); +assert.equal(result.registrations.grandchild?.txHash, pendingGrandchildHash); +assert.equal(result.status, "complete"); +``` + +Add a configuration-drift variant. Let the exact pending hash reconcile and +persist its proof from `pending.intent`, then change the current local +definition's name and artifact path as well as the run-config fingerprint. +Give the journal distinctive values such as `journal-child-name` and +`fixtures/journal-child/SKILL.md`; assert the recovered manifest proof uses +those exact values, not the changed current definition. Then assert the command reports +`Recovered pending transaction, but current run configuration differs` before +publishing metadata or preparing another transaction. It must never construct +or broadcast replacement bytes. + +```ts +const changedDefinition: DemoSkillDefinition = { + stage: "child", + name: "changed-current-child-name", + description: "Changed only to prove recovery does not consult current proof fields.", + artifactPath: "fixtures/changed-current-child/SKILL.md", +}; +assert.equal(store.manifest.registrations.child?.name, "journal-child-name"); +assert.equal( + store.manifest.registrations.child?.metadata.artifact.path, + "fixtures/journal-child/SKILL.md", +); +assert.notEqual(store.manifest.registrations.child?.name, changedDefinition.name); +assert.notEqual( + store.manifest.registrations.child?.metadata.artifact.path, + changedDefinition.artifactPath, +); +``` + +Add a Story-boundary test whose fake SDK exposes WIP `balanceOf` and +`allowance` plus a DerivativeWorkflows address. Assert the method reads the +wallet's WIP balance, reads allowance for that exact spender, and returns both. +An unexpected predicted currency must reject before either read. + +- [ ] **Step 2: Run the crash tests to verify they fail** + +Run: `cd phase0 && node --import tsx --test --test-name-pattern='resume after|intent-hash|manifest save|directory fsync|WIP balance|WIP allowance|Pinata unavailable|current run configuration' tests/demo.test.ts tests/registrations.test.ts tests/story.test.ts` + +Expected: FAIL because `runDemo` does not accept/reconcile an operation journal +and the chain seam has no WIP readiness method. + +- [ ] **Step 3: Add the journal to RunDemoInput** + +```ts +export interface RunDemoInput { + wallet: `0x${string}`; + chain: DemoChain; + metadata: DemoMetadataProvider; + store: RegistrationStore; + journal: LeasedOperationJournal; + skills?: readonly DemoSkillDefinition[]; +} +``` + +Construct `new FileOperationJournal(fileURLToPath(new URL("../pending-transactions.json", import.meta.url)))` in `index.ts`, then hold its lease around the entire command: + +```ts +const journal = new FileOperationJournal(pendingTransactionsPath); +return journal.withExclusiveLease((leasedJournal) => runDemo({ + wallet: account.address, + chain: storyChain(), + metadata: new HttpMetadataProvider(), + store: new FileRegistrationStore(registrationsPath), + journal: leasedJournal, +})); +``` + +Tests pass a `MemoryLeasedOperationJournal` directly. No production call to +`runDemo` may bypass `withExclusiveLease`. + +- [ ] **Step 4: Implement one generic execute-or-resume helper** + +Add to `demo.ts`: + +```ts +async function executeOrResume(input: { + journal: LeasedOperationJournal; + operationId: string; + stage: OperationStage; + intent: CanonicalOperationIntent; + prepare(): Promise; + broadcast(tx: PreparedChainTransaction): Promise; + confirm(operation: PendingOperation): Promise; +}): Promise<{ + result: T; + operation: PendingOperation; + journalRevision: number; +}> { + const intentHash = operationIntentHash(input.intent); + let snapshot = await input.journal.load(); + let pending = snapshot.operation; + if (pending) { + if (pending.operationId !== input.operationId + || pending.stage !== input.stage + || pending.intentHash !== intentHash) { + throw new Error(`Pending ${pending.stage} transaction does not match the current ${input.stage} intent`); + } + } else { + const prepared = await input.prepare(); + pending = { + schemaVersion: 1, + operationId: input.operationId, + stage: input.stage, + intent: input.intent, + intentHash, + transactionHash: prepared.transactionHash, + serializedTransaction: prepared.serializedTransaction, + state: "prepared", + }; + snapshot = await input.journal.save(pending, snapshot.revision); + } + await input.broadcast({ + transactionHash: pending.transactionHash, + serializedTransaction: pending.serializedTransaction, + }); + if (pending.state !== "broadcast") { + pending = { ...pending, state: "broadcast" }; + snapshot = await input.journal.save(pending, snapshot.revision); + } + const result = await input.confirm(pending); + return { result, operation: pending, journalRevision: snapshot.revision }; +} +``` + +The caller clears with the returned revision only after the confirmed result has +been inserted into the manifest and `store.save(manifest)` succeeds. Therefore +a crash after confirmation but before manifest save re-confirms the same hash, +and a stale process cannot clear or overwrite a newer journal revision. + +- [ ] **Step 5: Make manifest persistence durable before journal clear** + +In `src/registrations.ts`, replace `writeFile` with an explicit write-all and +durability sequence. Add these test-only observation hooks; production callers +use the default empty object: + +```ts +import { + mkdir, + open, + readFile, + rename, + unlink, + type FileHandle, +} from "node:fs/promises"; + +export interface ManifestWriteHooks { + afterTempSync?(): void | Promise; + afterRename?(): void | Promise; + afterDirectorySync?(): void | Promise; +} + +async function writeAll(handle: FileHandle, bytes: Uint8Array): Promise { + let offset = 0; + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write( + bytes, + offset, + bytes.byteLength - offset, + offset, + ); + if (bytesWritten === 0) throw new Error("Manifest temporary write made no progress"); + offset += bytesWritten; + } +} +``` + +Change the constructor to +`constructor(private readonly path: string, private readonly hooks: +ManifestWriteHooks = {})`. Implement `save` in this exact order: + +```ts +async save(manifest: RegistrationManifest): Promise { + parseRegistrationManifest(manifest); + const directory = dirname(this.path); + await mkdir(directory, { recursive: true }); + const temporaryPath = `${this.path}.${process.pid}.${randomUUID()}.tmp`; + const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + let temporaryHandle: FileHandle | null = null; + let renamed = false; + try { + temporaryHandle = await open(temporaryPath, "wx", 0o600); + await writeAll(temporaryHandle, bytes); + await temporaryHandle.sync(); + await this.hooks.afterTempSync?.(); + await temporaryHandle.close(); + temporaryHandle = null; + await rename(temporaryPath, this.path); + renamed = true; + await this.hooks.afterRename?.(); + const directoryHandle = await open(directory, "r"); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + await this.hooks.afterDirectorySync?.(); + } catch (error) { + if (temporaryHandle) await temporaryHandle.close().catch(() => undefined); + if (!renamed) await unlink(temporaryPath).catch(() => undefined); + throw error; + } +} +``` + +Do not unlink `this.path` after rename if directory fsync fails: the final name +contains a complete, temp-fsynced JSON image and recovery can safely accept +either the prior or renamed directory state after a real crash. In `demo.ts`, +the only permitted ordering is: + +```ts +await input.store.save(manifest); +await input.journal.clear(executed.operation.operationId, executed.journalRevision); +``` + +Never place journal clear in `finally`, start it concurrently, or treat rename +alone as a successful save. `RegistrationStore.save` resolves only after +directory fsync, so its resolution is the durability barrier. +Add `registrations.json.*.tmp` to `phase0/.gitignore` so a process-killed +write-all temporary can never be staged as proof. + +- [ ] **Step 6: Reconcile a persisted operation before readiness or metadata publication** + +After chain-ID validation and manifest load, load the journal before calling +`getBalance`, `getGasPrice`, `predictMintingLicenseFee`, WIP reads, or +`metadata.prepare`. If `snapshot.operation` exists, route solely by its +persisted stage and canonical intent: + +1. validate the intent hash, chain ID, stage ordering, and that any already + persisted prerequisite proof matches the journal-bound collection/parent; +2. call `executeOrResume` with `intent: pending.intent` and a `prepare` callback + that throws if invoked; +3. broadcast/reconcile only `pending.serializedTransaction` and confirm only + `pending.transactionHash`; +4. pass the persisted expected collection/parent/license/template fields into + the confirm method; +5. reconstruct `PreparedMetadata` and the `RegistrationProof` name/artifact + path from `pending.intent.metadata`, `pending.intent.registrationName`, and + `pending.intent.artifactPath`, never from a current `skills` definition, new + upload, or override fetch; +6. durably save the confirmed proof to the manifest using the fsync/rename + protocol below; +7. clear with the returned CAS revision. + +Refactor the proof helper to accept `PendingOperation`, not a current +`DemoSkillDefinition`. Its immutable-field mapping is exact: + +```ts +function requiredAddress( + value: `0x${string}` | null, + label: string, +): `0x${string}` { + if (!value) throw new Error(`Pending intent is missing ${label}`); + return value; +} + +function requiredString(value: string | null, label: string): string { + if (!value) throw new Error(`Pending intent is missing ${label}`); + return value; +} + +const intent = operation.intent; +if (operation.stage === "collection" || !intent.metadata) { + throw new Error("A registration proof requires journal-bound metadata"); +} +const registrationName = requiredString(intent.registrationName, "registration name"); +const artifactPath = requiredString(intent.artifactPath, "artifact path"); +const proof: RegistrationProof = { + stage: operation.stage, + kind: operation.stage === "root" ? "Skill" : "Derivative", + name: registrationName, + ipId: confirmed.ipId, + tokenId: confirmed.tokenId.toString(), + txHash: operation.transactionHash, + licenseTermsId: confirmed.licenseTermsId.toString(), + licenseTemplate: confirmed.licenseTemplate, + parentIpIds: operation.stage === "root" + ? [] + : [requiredAddress(intent.parentIpId, "parent IP")], + defaultMintingFee: intent.defaultMintingFee, + maxMintingFee: intent.maxMintingFee, + metadata: { + ip: { uri: intent.metadata.ipMetadataURI, hash: intent.metadata.ipMetadataHash }, + nft: { uri: intent.metadata.nftMetadataURI, hash: intent.metadata.nftMetadataHash }, + artifact: { + path: artifactPath, + mediaHash: intent.metadata.artifactMediaHash, + mediaType: intent.metadata.artifactMediaType, + }, + }, +}; +``` + +The current definition remains relevant only when computing the post-recovery +`runConfigHash` and deciding whether new stages may start. + +For a journal whose transaction hash is already present in the manifest, +require exact hash equality and clear it by CAS without a second broadcast. If +the manifest hash differs, stop as corruption. + +Only after pending reconciliation, compute the current local `runConfigHash`. +If it differs from the recovered operation's persisted hash, keep the recovered +proof but throw `Recovered pending transaction, but current run configuration +differs` before any new metadata publication or transaction preparation. This +allows an authorized signed transaction to be recovered when Pinata is down or +local files changed without silently continuing under a different config. + +Recompute the exact remaining new stages from the now-current manifest. If none +remain, return immediately with zero native or WIP readiness reads. Otherwise, +and only otherwise, perform the native gate: + +```ts +const remainingStages = missingOperationStages(manifest); +if (remainingStages.length > 0) { + const [balance, gasPrice] = await Promise.all([ + input.chain.getBalance(input.wallet), + input.chain.getGasPrice(), + ]); + const requiredMinimum = estimateRemainingDemoGasMinimum({ + gasPrice, + remainingNewWrites: remainingStages.length, + }); + if (balance < requiredMinimum) { + throw new Error( + `Wallet ${input.wallet} has ${formatEther(balance)} IP; ` + + `estimated native-gas minimum for ${remainingStages.join(",")} is ` + + `${formatEther(requiredMinimum)} IP. Fund it manually at ${AENEID_FAUCET_URL}`, + ); + } +} +``` + +After this gate, verify completed stages using only local artifact bytes against +their stored artifact hashes. Call `metadata.prepare` only for missing stages. +For each new stage, build a complete `CanonicalOperationIntent`, execute it, +save the confirmed proof, and clear by CAS. The root intent sets +`parentIpId`, `licenseTermsId`, `licenseTemplate`, `currencyToken`, and `maxMintingFee` to null; +Derivative intents bind the persisted parent/template, predicted currency and +fee cap, metadata fields, and current `runConfigHash`. Collection intent sets +`registrationName`, `artifactPath`, and `metadata` to null. Every registration +intent copies the exact definition name and artifact path into the journal +before signing; proof construction after confirmation reads those journal +fields exclusively. + +- [ ] **Step 7: Fail closed on WIP balance and allowance before every Derivative prepare** + +Extend the `DemoChain` seam in `demo.ts`: + +```ts +export interface DerivativeFeeReadiness { + currencyToken: `0x${string}`; + spender: `0x${string}`; + requiredAmount: bigint; + balance: bigint; + allowance: bigint; +} + +predictMintingLicenseFee(input: PredictFeeInput): Promise<{ + currencyToken: `0x${string}`; + tokenAmount: bigint; +}>; +getDerivativeFeeReadiness(input: { + wallet: `0x${string}`; + currencyToken: `0x${string}`; + requiredAmount: bigint; +}): Promise; +``` + +Update `StorySdkBoundary.ipAsset` so its `Pick` also includes `wipClient` and +`derivativeWorkflowsClient`. Preserve both values returned by fee prediction: + +```ts +return { + currencyToken: required(response.currencyToken, "predicted currencyToken"), + tokenAmount: required(response.tokenAmount, "predicted tokenAmount"), +}; +``` + +Implement the read-only readiness method in `StoryChain`: + +```ts +async getDerivativeFeeReadiness(input: { + wallet: Address; + currencyToken: Address; + requiredAmount: bigint; +}) { + const wip = this.sdk.ipAsset.wipClient; + const spender = this.sdk.ipAsset.derivativeWorkflowsClient.address; + if (input.currencyToken.toLowerCase() !== WIP_TOKEN_ADDRESS.toLowerCase() + || wip.address.toLowerCase() !== WIP_TOKEN_ADDRESS.toLowerCase()) { + throw new Error( + `Derivative fee currency ${input.currencyToken} is not supported WIP ${WIP_TOKEN_ADDRESS}`, + ); + } + const [balanceResult, allowanceResult] = await Promise.all([ + wip.balanceOf({ owner: input.wallet }), + wip.allowance({ owner: input.wallet, spender }), + ]); + return { + currencyToken: wip.address, + spender, + requiredAmount: input.requiredAmount, + balance: balanceResult.result, + allowance: allowanceResult.result, + }; +} +``` + +This spender is not guessed: Story SDK 1.4.4 passes +`derivativeWorkflowsClient.address` as `spgSpenderAddress` to +`handleRegistrationWithFees`. The `encodedTxDataOnly` branch returns before +that helper, so the new prepare/sign path does not auto-wrap IP or auto-approve +WIP. + +In `demo.ts`, add one helper that validates the chain response matches the +predicted currency and amount, then checks balance and allowance independently: + +```ts +async function requireDerivativeFeeReadiness(input: { + chain: DemoChain; + wallet: `0x${string}`; + predicted: { currencyToken: `0x${string}`; tokenAmount: bigint }; +}) { + const readiness = await input.chain.getDerivativeFeeReadiness({ + wallet: input.wallet, + currencyToken: input.predicted.currencyToken, + requiredAmount: input.predicted.tokenAmount, + }); + if (readiness.currencyToken.toLowerCase() !== input.predicted.currencyToken.toLowerCase() + || readiness.requiredAmount !== input.predicted.tokenAmount) { + throw new Error("Derivative WIP readiness response does not match the predicted fee"); + } + if (readiness.balance < input.predicted.tokenAmount) { + throw new Error( + `WIP balance ${readiness.balance} is below required ${input.predicted.tokenAmount} ` + + `for Derivative fee token ${readiness.currencyToken}`, + ); + } + if (readiness.allowance < input.predicted.tokenAmount) { + throw new Error( + `WIP allowance ${readiness.allowance} is below required ${input.predicted.tokenAmount} ` + + `for DerivativeWorkflows spender ${readiness.spender}`, + ); + } + return readiness; +} +``` + +For child and grandchild separately, predict the fee and include its currency +and amount in the stable intent. Put the readiness check strictly inside the +`prepare` callback: + +```ts +const executed = await executeOrResume({ + journal: input.journal, + operationId, + stage, + intent, + prepare: async () => { + await requireDerivativeFeeReadiness({ + chain: input.chain, + wallet: input.wallet, + predicted, + }); + return input.chain.prepareDerivative(derivativeInput); + }, + broadcast: (transaction) => input.chain.broadcastPrepared(transaction), + confirm: (operation) => input.chain.confirmDerivative({ + transactionHash: operation.transactionHash, + expectedCollection: requiredAddress(operation.intent.spgNftContract, "SPG collection"), + expectedParentIpId: requiredAddress(operation.intent.parentIpId, "parent IP"), + expectedLicenseTermsId: BigInt(requiredString(operation.intent.licenseTermsId, "license terms ID")), + expectedLicenseTemplate: requiredAddress(operation.intent.licenseTemplate, "license template"), + }), +}); +``` + +`executeOrResume` loads and validates a matching pending journal before it ever +calls `prepare`. Therefore a persisted signed Derivative must be +rebroadcast/reconciled and confirmed from its exact bytes without consulting +current WIP balance or allowance. The transaction may already have consumed +that WIP; rechecking would deadlock crash recovery. Only a genuinely new child +or grandchild prepare reads readiness. Re-read for the grandchild after the +child proof is durably saved; do not assume the child's readiness also covers +the next transaction. For a new operation, zero or insufficient WIP/allowance +must cause zero Derivative prepare, sign, journal, and broadcast calls. + +Do not add automatic deposit or approval transactions. A human testnet operator +must wrap enough Aeneid IP into WIP and approve the reported +DerivativeWorkflows spender before the demo. Those prerequisite actions remain +outside the four-operation journal and outside automated verification. + +- [ ] **Step 8: Separate pending-proof recovery from new-stage drift checks** + +At the beginning of the pending route, handle a journal left behind after a +successful manifest save: + +```ts +const snapshot = await input.journal.load(); +const pending = snapshot.operation; +if (pending) { + const confirmedHash = pending.stage === "collection" + ? manifest.collectionTxHash + : manifest.registrations[pending.stage]?.txHash ?? null; + if (confirmedHash) { + if (confirmedHash.toLowerCase() !== pending.transactionHash.toLowerCase()) { + throw new Error(`Confirmed ${pending.stage} proof does not match the pending transaction hash`); + } + await input.journal.clear(pending.operationId, snapshot.revision); + } +} +``` + +This cleanup requires no metadata provider and no balance read. If the pending +proof is absent from the manifest, recover it from the exact receipt plus the +journal-bound intent as described in Step 6. + +Only after pending recovery, compare each already-confirmed stage's current +local artifact bytes with its stored artifact media hash. Do not upload or fetch +metadata for a confirmed stage. A mismatch blocks new prepares but never blocks +saving proof for the exact pending transaction that was already signed. The +`manifest-save`, `Pinata unavailable`, and run-config-drift tests must prove +these orderings. + +- [ ] **Step 9: Run all crash, resume, durability, and WIP-readiness tests** + +Run: + +```bash +cd phase0 +npm test +npm run typecheck +``` + +Expected: all tests PASS. Each crash scenario records one transaction hash; +rerun performs no second prepare/sign and finalizes the original proof. +Insufficient WIP balance and insufficient allowance each report zero Derivative +prepare and broadcast calls. No test reads RPC state or performs a WIP action. +The depleted-after-broadcast/confirm regressions recover the same child hash +with zero readiness reads and zero child re-prepare. +The final-grandchild regressions also make zero native-balance, gas-price, WIP, +and metadata-provider calls while confirming the exact persisted hash. + +- [ ] **Step 10: Commit crash recovery, durable proof storage, and derivative fee readiness** + +```bash +git add phase0/src/demo.ts phase0/src/story.ts phase0/src/registrations.ts phase0/src/index.ts phase0/tests/demo.test.ts phase0/tests/registrations.test.ts phase0/tests/story.test.ts phase0/.gitignore +git commit -m "fix: reconcile Phase 0 transactions and WIP fees" +``` + +### Task 5: Publish exact metadata bytes to durable public IPFS + +**Files:** +- Modify: `phase0/src/metadata.ts:1-124` +- Modify: `phase0/tests/metadata.test.ts` +- Modify: `phase0/.env.example` + +- [ ] **Step 1: Write pinning and stage-isolation tests** + +Replace the httpbin-default test with these tests: + +1. With no override, two multipart uploads go to the exact + `https://uploads.pinata.cloud/v3/files`, use `Authorization: Bearer + fixture-token`, `redirect: "error"`, and `network=public`, and return + distinct IP/NFT CIDs. Both gateway URIs are fetched and byte-matched; GET + requests carry no `Authorization` header. +2. `root` uses only `ROOT_IP_METADATA_URI`/`ROOT_NFT_METADATA_URI`; a root URI cannot leak into child or grandchild. +3. Supplying only one URI in a stage pair fails before upload/fetch. +4. Table-test `http:`, embedded username/password, query, fragment, + `gateway.pinata.cloud.evil`, and poisoned paths (`/not-ipfs/`, an extra path + segment, and percent-encoded traversal) for paired stage overrides. Every + case rejects with `fetchCalls === 0`. +5. Apply the same table to `publicGatewayBaseUrl`. Permit only + `https://gateway.pinata.cloud/ipfs/` or an HTTPS subdomain of + `mypinata.cloud` with exact `/ipfs/` path, no port, credentials, query, or + fragment; every invalid gateway rejects before upload with `fetchCalls === + 0`. Add one positive dedicated-gateway case using + `https://team.mypinata.cloud/ipfs/` and same-origin stage CIDs. +6. Set a fake `PINATA_UPLOAD_URL` environment value and assert it is ignored: + the only POST still targets the exact constant endpoint. No production + option or environment variable may redirect the JWT-bearing request. + +Use these exact adversarial tables in `metadata.test.ts`: + +```ts +const INVALID_STAGE_URIS = [ + "http://gateway.pinata.cloud/ipfs/bafyvalidcid123", + "https://user:pass@gateway.pinata.cloud/ipfs/bafyvalidcid123", + "https://gateway.pinata.cloud/ipfs/bafyvalidcid123?download=1", + "https://gateway.pinata.cloud/ipfs/bafyvalidcid123#fragment", + "https://gateway.pinata.cloud.evil/ipfs/bafyvalidcid123", + "https://gateway.pinata.cloud/not-ipfs/bafyvalidcid123", + "https://gateway.pinata.cloud/ipfs/bafyvalidcid123/extra", + "https://gateway.pinata.cloud/ipfs/%2e%2e/bafyvalidcid123", +]; + +const INVALID_GATEWAY_BASES = [ + "http://gateway.pinata.cloud/ipfs/", + "https://user:pass@gateway.pinata.cloud/ipfs/", + "https://gateway.pinata.cloud:444/ipfs/", + "https://gateway.pinata.cloud/ipfs/?query=1", + "https://gateway.pinata.cloud/ipfs/#fragment", + "https://gateway.pinata.cloud.evil/ipfs/", + "https://evil-mypinata.cloud/ipfs/", + "https://gateway.pinata.cloud/not-ipfs/", + "https://gateway.pinata.cloud/ipfs/extra/", +]; +``` + +For each stage URI, supply it as both members of the root pair; for each gateway +base, leave stage overrides absent and supply a fixture JWT. Construct/prepare +with an injected fetcher that increments `fetchCalls` and throws if invoked. +Assert rejection and `fetchCalls === 0` after every row. + +Use injected fetch responses; no test contacts Pinata or a gateway. + +- [ ] **Step 2: Run metadata tests to verify current behavior fails** + +Run: `cd phase0 && node --import tsx --test tests/metadata.test.ts` + +Expected: FAIL because the current provider defaults to httpbin and uses global, non-stage-specific overrides. + +- [ ] **Step 3: Define stage-specific configuration** + +Replace `ipMetadataURI`/`nftMetadataURI` options with the following single +TypeScript block: + +```ts +export type StageMetadataUris = Partial>; + +export interface HttpMetadataProviderOptions { + fetcher?: typeof fetch; + stageUris?: StageMetadataUris; + pinataJwt?: string; + publicGatewayBaseUrl?: string; +} +``` + +There is deliberately no upload-URL option. Define and use this module-private +constant for every JWT-bearing request: + +```ts +const PINATA_PUBLIC_UPLOAD_URL = "https://uploads.pinata.cloud/v3/files"; +const DEFAULT_PUBLIC_GATEWAY_BASE_URL = "https://gateway.pinata.cloud/ipfs/"; +``` + +Load env pairs with exact names: + +```text +ROOT_IP_METADATA_URI / ROOT_NFT_METADATA_URI +CHILD_IP_METADATA_URI / CHILD_NFT_METADATA_URI +GRANDCHILD_IP_METADATA_URI / GRANDCHILD_NFT_METADATA_URI +``` + +If one member of a pair exists without the other, throw +`` `${stage.toUpperCase()} metadata overrides must provide both IP and NFT URIs` ``. +Parse and validate the complete gateway and all supplied stage pairs before +calling `fetcher` even once. Do not read `PINATA_UPLOAD_URL`. + +- [ ] **Step 4: Implement public Pinata upload as the default** + +Use the documented public upload endpoint and built-in `FormData`/`Blob`: + +```ts +async function pinPublicJson(input: { + fetcher: typeof fetch; + jwt: string; + gatewayBaseUrl: string; + name: string; + bytes: Uint8Array; +}): Promise { + const form = new FormData(); + form.set("network", "public"); + form.set("name", input.name); + form.set("file", new Blob([input.bytes], { type: "application/json" }), input.name); + const response = await input.fetcher(PINATA_PUBLIC_UPLOAD_URL, { + method: "POST", + headers: { Authorization: `Bearer ${input.jwt}` }, + body: form, + redirect: "error", + }); + if (!response.ok) throw new Error(`Metadata pin failed (${response.status})`); + const body = await response.json() as { data?: { cid?: string } }; + const cid = body.data?.cid; + if (!cid || !/^b[a-z0-9]+$/.test(cid)) throw new Error("Pinata response is missing a public CID"); + return new URL(cid, input.gatewayBaseUrl.endsWith("/") ? input.gatewayBaseUrl : `${input.gatewayBaseUrl}/`).toString(); +} +``` + +Defaults: + +```ts +const gatewayBaseUrl = validatePublicGatewayBaseUrl( + options.publicGatewayBaseUrl ?? DEFAULT_PUBLIC_GATEWAY_BASE_URL, +); +``` + +The `Authorization` header exists only inside this exact POST expression. Do +not put it in shared headers or a fetch wrapper, and do not manually follow +redirects. The injected-fetch test must fail if any JWT-bearing request URL is +not byte-for-byte `PINATA_PUBLIC_UPLOAD_URL`. + +If a stage has no override and `PINATA_JWT` is absent, fail with +`` `PINATA_JWT is required to publish durable metadata for ${stage}` `` before +any chain preparation. Never log the JWT. + +- [ ] **Step 5: Verify uploaded or overridden bytes identically** + +Add these validators. They return normalized URLs only after all origin/path +rules pass: + +```ts +function strictHttpsUrl(value: string, label: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be a valid HTTPS URL`); + } + if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS`); + if (url.username || url.password) throw new Error(`${label} must not contain credentials`); + if (url.search) throw new Error(`${label} must not contain a query`); + if (url.hash) throw new Error(`${label} must not contain a fragment`); + if (!/^https:\/\/[^/:?#]+(?:\/|$)/.test(value)) { + throw new Error(`${label} must not contain an explicit port or malformed authority`); + } + return url; +} + +function isAllowedPinataGatewayHost(hostname: string): boolean { + return hostname === "gateway.pinata.cloud" + || /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.mypinata\.cloud$/.test(hostname); +} + +export function validatePublicGatewayBaseUrl(value: string): string { + const url = strictHttpsUrl(value, "IPFS public gateway base URL"); + if (!isAllowedPinataGatewayHost(url.hostname)) { + throw new Error("IPFS public gateway must use gateway.pinata.cloud or a mypinata.cloud subdomain"); + } + if (url.pathname !== "/ipfs/") { + throw new Error("IPFS public gateway path must be exactly /ipfs/"); + } + return url.toString(); +} + +export function validateStageMetadataUri( + value: string, + gatewayBaseUrl: string, + label: string, +): string { + const base = new URL(validatePublicGatewayBaseUrl(gatewayBaseUrl)); + const url = strictHttpsUrl(value, label); + if (url.origin !== base.origin) { + throw new Error(`${label} origin must exactly match the configured public gateway`); + } + if (!url.pathname.startsWith(base.pathname)) { + throw new Error(`${label} path must start with ${base.pathname}`); + } + const cid = url.pathname.slice(base.pathname.length); + if (!/^b[a-z0-9]+$/.test(cid)) { + throw new Error(`${label} must end in exactly one lowercase CID and no extra path`); + } + return url.toString(); +} +``` + +Validate both members of every supplied stage pair with +`validateStageMetadataUri` during provider construction or at the start of +`prepare`, before upload or verification fetch. This deliberately forbids +arbitrary web origins: overrides are alternate already-pinned objects on the +configured, allow-listed Pinata public gateway. + +Keep `verifyExactBytes`. Whether a URI came from a validated stage override or +a fresh pin, fetch it without `Authorization` and compare exact serialized +bytes and SHA-256 before returning `PreparedMetadata`. Delete `inlineHttpsUri` +and every httpbin reference. + +- [ ] **Step 6: Update `.env.example` without secrets** + +Replace global override fields with: + +```dotenv +# Default durable metadata path. Never commit the JWT. +PINATA_JWT= +IPFS_PUBLIC_GATEWAY_BASE_URL=https://gateway.pinata.cloud/ipfs/ + +# Optional paired, stage-specific public-IPFS overrides. Both values in a pair +# must use the exact configured Pinata gateway origin, /ipfs/, and return +# the exact serialized bytes the CLI generates. +ROOT_IP_METADATA_URI= +ROOT_NFT_METADATA_URI= +CHILD_IP_METADATA_URI= +CHILD_NFT_METADATA_URI= +GRANDCHILD_IP_METADATA_URI= +GRANDCHILD_NFT_METADATA_URI= +``` + +Document that the upload endpoint is intentionally not configurable and that +`IPFS_PUBLIC_GATEWAY_BASE_URL` accepts only the default host or an HTTPS +`*.mypinata.cloud` host with exact `/ipfs/` path. + +- [ ] **Step 7: Run metadata tests and typecheck** + +Run: `cd phase0 && node --import tsx --test tests/metadata.test.ts && npm run typecheck` + +Expected: all metadata tests PASS; injected fetch sees exactly two public +uploads for an unoverridden stage, every JWT-bearing request uses only the +exact Pinata constant with redirects disabled, gateway GETs contain no JWT, +and every invalid URL table row reports zero injected-fetch calls. No real +network call occurs. + +- [ ] **Step 8: Commit metadata safety** + +```bash +git add phase0/src/metadata.ts phase0/tests/metadata.test.ts phase0/.env.example +git commit -m "fix: pin stage-specific Phase 0 metadata" +``` + +### Task 6: Align CLI wording and operator documentation with the proof boundary + +**Files:** +- Modify: `phase0/src/index.ts:62-174,185-203` +- Modify: `phase0/README.md` +- Modify: `phase0/package.json` + +- [ ] **Step 1: Correct provenance wording in CLI output** + +Replace “provenance demo”/“authored” implications with: + +```ts +console.log("✓ Phase 0 wallet-attested registration status:", manifest.status); +console.log("evidence level : wallet_asserted"); +console.log("scope : wallet registration + declared Derivative ancestry; not authorship, originality, or safety"); +``` + +Advanced `register-skill` output becomes `Skill registered by the connected wallet as a Story IP Asset`. + +- [ ] **Step 2: Update the check command** + +Have `npm run check` report native IP and WIP as two independent readiness +domains: + +```text +pending recovery : none|required (, ) +remaining new writes : <0..4> +native IP balance : IP +gas price : +estimated gas minimum : IP +native gas ready : yes|no +Derivative fee token : +configured fee estimate : 0.001 WIP per Derivative +WIP balance : WIP +WIP allowance : WIP +DerivativeWorkflows spender:
+next Derivative WIP ready : yes|no +``` + +Acquire the journal lease, load and validate the manifest and journal, and +derive `remainingNewWrites` from confirmed manifest stages. With no pending +operation, calculate native readiness with +`estimateRemainingDemoGasMinimum({ gasPrice, remainingNewWrites })`; zero +remaining writes requires zero gas and performs no gas-price or balance read. +Obtain WIP balance, allowance, token, and spender through +`getDerivativeFeeReadiness` with `WIP_TOKEN_ADDRESS` and +`DEMO_ROOT_MINTING_FEE` only when the next new stage is a Derivative. Label the +latter a configured fee estimate: the demo still predicts and rechecks the +actual fee immediately before each new Derivative prepare. + +If a pending operation exists, print only its stage/hash plus `pending recovery +: required`; print all readiness values as `deferred until exact-hash recovery` +and perform zero native-balance, gas-price, WIP, allowance, or metadata calls. +Those balances may already have been consumed by the pending transaction. +`check` never reconciles or clears a transaction because it is read-only; the +operator runs the demo to recover the exact persisted hash under the same +lease. Do not collapse the domains into one `funding ready` value, and do not +claim an IP balance covers a WIP fee. `check` must not upload metadata, wrap IP, +approve WIP, sign, broadcast, or mutate the journal. + +- [ ] **Step 3: Rewrite README setup and recovery sections** + +Document: + +```markdown +This testnet demo proves `wallet_asserted` registration and declared Derivative +ancestry. It does not prove authorship, originality, repository control, or +safety. + +Before broadcast, the demo signs the transaction locally and atomically saves +its hash, serialized testnet transaction, and canonical operation intent to the +mode-0600 ignored `pending-transactions.json`. The whole demo holds an exclusive +same-host journal lease and updates it with compare-and-swap revisions. A rerun +validates the journal-bound intent and prerequisite proofs, reconciles or +rebroadcasts the exact persisted bytes/hash without a Pinata or funding read, +waits for that same hash, saves confirmed proof from persisted metadata, then +clears the matching journal revision. Only after recovery does it compare the +current local run configuration and permit another prepare. Never delete the +journal or `.lock` merely to force progress; an intent/config mismatch or an +unresolved/replaced nonce requires operator investigation. Stale-lock recovery +is explicit and allowed only for a same-host PID proven absent while the lease +record remains unchanged. + +Metadata defaults to public IPFS pinning. Pinata credentials and any wallet key +remain local. The JWT-bearing upload URL is fixed to +`https://uploads.pinata.cloud/v3/files`, redirects are disabled, and gateway +verification requests never carry the JWT. Stage-specific URI overrides must +be supplied in complete IP/NFT pairs, use the exact configured allow-listed +Pinata gateway origin and `/ipfs/` path, and return byte-identical content. + +The native-IP estimate covers gas only. Derivative minting fees use WIP. Because +the crash-safe path requests `encodedTxDataOnly`, the Story SDK does not perform +its normal automatic IP-to-WIP wrapping or WIP approval. Before a real testnet +demo, a human must use supported Story testnet tooling to wrap sufficient IP to +WIP and approve the exact DerivativeWorkflows spender printed by `npm run +check`. The demo checks the predicted fee, current WIP balance, and current +allowance again before each Derivative prepare and fails closed if either is +insufficient. If a matching signed Derivative is already in the journal, the +demo reconciles that exact transaction without rechecking current WIP; the +pending transaction may already have consumed it. These prerequisite +wrap/approval transactions are not journaled by this demo. +``` + +State that the native gas figure is a conservative preflight estimate, not a +guarantee of final gas use. Because the four-write envelope has no validated +per-stage allocation, any positive number of remaining new writes retains the +full conservative envelope; zero remaining writes requires zero. State that +the configured WIP figure is not a substitute for the per-parent on-chain +prediction. Preserve the existing Aeneid/mainnet boundary and human faucet +step. + +- [ ] **Step 4: Update package description** + +Set `phase0/package.json` description to: + +```json +"description": "Phase 0 testnet spike: wallet-attested Skill registration and declared Derivative ancestry on Story Aeneid." +``` + +- [ ] **Step 5: Run the full Phase 0 suite** + +Run: + +```bash +cd phase0 +npm test +npm run typecheck +``` + +Expected: all tests PASS with no network, wallet key, Pinata credential, +journal residue, WIP wrap/approval, or transaction. + +- [ ] **Step 6: Verify ignored and tracked boundaries** + +Run: + +```bash +git check-ignore -v phase0/.env phase0/pending-transactions.json phase0/pending-transactions.json.audit.tmp phase0/pending-transactions.json.lock phase0/registrations.json.audit.tmp +git ls-files phase0/.env phase0/pending-transactions.json phase0/pending-transactions.json.audit.tmp phase0/pending-transactions.json.lock phase0/registrations.json.audit.tmp +``` + +Expected: all five sensitive/local paths are reported ignored by the first +command; the second command prints nothing. + +- [ ] **Step 7: Verify protected corpus and mainnet boundaries** + +Run: + +```bash +git diff bad032b -- CONTEXT.md docs/PRD.md docs/adr +rg -n '1514|mainnet' phase0/src +``` + +Expected: protected-corpus diff is empty. Any `1514`/mainnet source match is a rejection or explanatory guard, never a transaction target. + +- [ ] **Step 8: Commit operator documentation** + +```bash +git add phase0/src/index.ts phase0/README.md phase0/package.json phase0/package-lock.json +git commit -m "docs: define Phase 0 wallet-attested proof boundary" +``` + +- [ ] **Step 9: Hand off the human-only run status** + +```text +Phase 0 now rejects native-IP gas dust, rejects insufficient WIP balance or +allowance before Derivative prepare, persists a signed testnet transaction before +broadcast, reconciles the same hash after crashes, and verifies durable +stage-specific metadata bytes. Automated tests used fakes only. No Pinata +upload, wallet funding, IP-to-WIP wrap, WIP approval, Aeneid write, private-key +operation, or mainnet transaction was performed; a real testnet proof remains +an explicit human-run gate. +``` diff --git a/docs/superpowers/plans/2026-07-17-public-surfaces.md b/docs/superpowers/plans/2026-07-17-public-surfaces.md new file mode 100644 index 0000000..cc3b7ef --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-public-surfaces.md @@ -0,0 +1,971 @@ +# Registry and Public Demo Truthfulness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make registry metrics settlement-verifiable rather than “unfakeable,” reject self-funded/Sybil demand, and—under the recorded explicit approval to edit and add the untracked user work—make both `hf-space/` demos match the atomic accounting core and honest evidence status. + +**Architecture:** Add an offline registry-ranking spike whose pure reducer separates settlements, successful Invocations, failures, independent Beneficiaries, refunds, recycling, and confidence. Public-demo allocations are generated—not reimplemented—from `prototype/atomic-money.mjs` into canonical checked fixtures, then copied byte-for-byte with SHA-256 integrity manifests into each independently deployable Gradio/static Space root; neither runtime reaches a sibling directory. A narrow immutable manifest records one independently rechecked historical Base Sepolia receipt and states exactly what it does not prove. The historical n=48 inference-route record remains at its immutable manifest path with `historical_unreproducible` status, so all p50/p95 publication is suppressed until a new authorized, dated, reproducible run exists. + +**Tech Stack:** Node.js 20+, ESM, built-in `node:test`, BigInt atomic units, Python 3.12 `unittest`, Gradio, browser JavaScript modules, JSON fixtures, Markdown. + +--- + +## Prerequisites and hard boundaries + +Complete these plans first: + +1. `docs/superpowers/plans/2026-07-17-claims-quarantine.md` +2. `docs/superpowers/plans/2026-07-17-atomic-money-kernel.md` + +The required shared interfaces are: + +- `prototype/atomic-money.mjs#allocateExternalGross({ grossAtomic, executionCostAtomic, settlementCostAtomic, protocolFeeBps, refundReserveAtomic, leafSkillId, skills })` — derives the external fee and Royalty-claim pool. +- `prototype/atomic-money.mjs#allocateInternalGross({ grossAtomic, executionCostAtomic, protocolFeeAtomic, refundReserveAtomic, recipientId })` — consumes the exact quote-final internal fee and derives the Invocation award. +- `prototype/atomic-money.mjs#formatUsdc(amountAtomic)` — the only display formatter. No public demo may duplicate any of this arithmetic. +- `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json` — immutable historical record with `evidenceStatus: "historical_unreproducible"` and `publication.allowed: false`. + +**`hf-space/` APPROVAL STATUS:** `hf-space/` is pre-existing untracked user work. The +user's 2026-07-17 instruction to execute the approved remediation design authorizes +editing and adding the reviewed `hf-space/` files in this plan. That instruction does +not authorize deployment or publication. If this plan is reused outside that approved +execution, Tasks 1–3 may proceed, but Task 4 becomes a stop gate until the user again +approves editing and adding `hf-space/`. In every case, do not deploy a Space, push a +branch, make an HTTP publication call, or describe the demo as published. + +## File map + +Always in scope: + +- Create `spikes/registry-ranking/package.json` — offline test scripts. +- Create `spikes/registry-ranking/src/metrics.mjs` — event validation, exclusion decisions, aggregation, eligibility, confidence, and ranking. +- Create `spikes/registry-ranking/test/metrics.test.mjs` — self-payment, related-wallet, Sybil, refund, failure, and honest-ranking tests. +- Create `spikes/registry-ranking/fixtures/settlements.json` — explicit synthetic fixture, labeled synthetic. +- Create `spikes/registry-ranking/fixtures/verified-billing-registry.json` — synthetic verifier-controlled payer relationships/clusters used only by the spike. +- Create `spikes/registry-ranking/README.md` — metric definitions, limits, and reproduction. +- Create `spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json` — narrow historical Base Sepolia receipt evidence for the already documented Skill-leg transaction. +- Modify `docs/plans/2026-07-15-registry-not-marketplace.md` — replace “unfakeable” and safety/demand overclaims; add allow-list and field contract. + +Only after explicit `hf-space/` approval: + +- Create `hf-space/scripts/generate-accounting-fixture.mjs` — imports `prototype/atomic-money.mjs` and writes the deterministic fixture. +- Create `hf-space/scripts/test-generate-accounting-fixture.mjs` — proves both internal and external scenarios use the shared core and conserve gross. +- Create `hf-space/scripts/package-space-fixtures.mjs` — copies canonical fixtures byte-for-byte into each standalone Space root and writes deterministic integrity manifests. +- Create `hf-space/scripts/test-package-space-fixtures.mjs` — proves packaged bytes/hashes and isolated-root loading. +- Create `hf-space/scripts/verify-local-scope.mjs` — enforces the exact reviewed `hf-space/` path allowlist before staging. +- Create `hf-space/shared/public-demo-allocation.json` — generated scenarios; never hand-edited. +- Create `hf-space/shared/evidence.json` — publication-safe evidence links and suppression status. +- Create `hf-space/gradio/demo_logic.py` — validates 402 responses and reads generated allocation/evidence fixtures. +- Create `hf-space/gradio/test_demo_logic.py` — Python standard-library tests. +- Create `hf-space/gradio/test_app_smoke.py` — imports and exercises the actual Gradio wiring with HTTP fully stubbed. +- Modify `hf-space/gradio/app.py` — use shared fixtures, correct mode/evidence/status language. +- Modify `hf-space/gradio/README.md` — implemented/future and evidence boundaries. +- Modify `hf-space/gradio/requirements.txt` — retain only the reviewed, pinned runtime dependency set; do not add publication tooling. +- Create `hf-space/gradio/data/public-demo-allocation.json` — byte-identical packaged allocation fixture available inside the Gradio Space root. +- Create `hf-space/gradio/data/evidence.json` — byte-identical packaged evidence fixture available inside the Gradio Space root. +- Create `hf-space/gradio/data/fixture-integrity.json` — deterministic hashes for the two packaged Gradio files. +- Create `hf-space/static/demo-logic.mjs` — validates 402 responses and renders shared fixtures without doing money math. +- Create `hf-space/static/test-demo-logic.mjs` — Node tests for 402 and fixture rendering. +- Create `hf-space/static/test-index-smoke.mjs` — parses the actual HTML into a DOM and mounts the actual module with stubbed fetch. +- Modify `hf-space/static/index.html` — use module, corrected modes, allocation, evidence, and labels. +- Modify `hf-space/static/README.md` — implemented/future and evidence boundaries. +- Create `hf-space/static/package.json` — pinned DOM-smoke dependency and offline test scripts; no deployment script. +- Create `hf-space/static/package-lock.json` — committed dependency resolution for the DOM smoke. +- Create `hf-space/static/data/public-demo-allocation.json` — byte-identical packaged allocation fixture available inside the static Space root. +- Create `hf-space/static/data/evidence.json` — byte-identical packaged evidence fixture available inside the static Space root. +- Create `hf-space/static/data/fixture-integrity.json` — deterministic hashes for the two packaged static files. + +## Registry event and public metric contract + +```js +// SettlementMetricEventV1 — every field is required. +{ + schemaVersion: 1, + settlementId: "settlement-001", + invocationId: "invocation-001", + skillId: "ledger-recon", + creatorWallet: "0x-lowercase-40-hex", + payeeWallet: "0x-lowercase-40-hex", + payerWallet: "0x-lowercase-40-hex", + untrustedPayerClaims: { + beneficiaryId: "otherco", + payerClusterId: "cluster-otherco", + relationship: "independent" + }, + grossAtomic: "250000", + refundedAtomic: "0", + recycledAtomic: "0", + outcome: "succeeded", // succeeded | failed | unresolved + settledAt: "2026-07-17T00:00:00.000Z" +} + +// PublicSkillMetricsV1 +{ + schemaVersion: 1, + skillId: "ledger-recon", + totalSettlements: 7, + successfulInvocations: 3, + settledFailures: 2, + unresolvedSettlements: 1, + refundedSettlements: 1, + uniquePayerWallets: 6, + uniqueIndependentBeneficiaries: 2, + refundAdjustedNetAtomic: "450000", + independentNetAtomic: "400000", + independenceConfidence: "high", // low | medium | high + registryStatus: "eligible", // allow_listed | eligible | ineligible + exclusionCounts: { + self_payment: 1, + linked_wallet: 1, + failed_invocation: 2, + unresolved_settlement: 1, + refunded: 1, + recycled_value: 1, + sybil_cluster: 1, + unknown_relationship: 1 + } +} +``` + +The event's payer claims are retained for audit but never drive a public metric. The +service injects a verifier-controlled classifier built from this schema: + +```js +// VerifiedBillingRegistryV1 +{ + schemaVersion: 1, + entries: { + "0x-lowercase-payer-wallet": { + beneficiaryId: "otherco", + payerClusterId: "verified-billing-owner:otherco", + relationship: "independent", // linked | independent + evidenceRef: "billing-review:otherco:2026-07-17", + reviewedAt: "2026-07-17T00:00:00.000Z" + } + } +} + +// DerivedPayerClassificationV1 +{ + relationship: "independent", // self | linked | independent | unknown + beneficiaryId: "otherco", + payerClusterId: "verified-billing-owner:otherco", + evidenceRef: "billing-review:otherco:2026-07-17" +} +``` + +Only successful, unrefunded, unrecycled events that the injected trusted classifier +derives as independent contribute to `independentNetAtomic`. The classifier derives +`self` when `payerWallet` equals either `creatorWallet` or `payeeWallet`, and `linked` +or independent payer ownership/clusters from the +verified billing registry, and `unknown` otherwise. Event claims never override a +derived result. Two derived records sharing a `payerClusterId` count as one independence +cluster even if their event claims differ. A Skill stays `allow_listed` until it has at +least two classifier-verified successful independent Beneficiaries in distinct +clusters. `unknown` never upgrades itself to independent. Sort eligible Skills by +`independentNetAtomic` descending, then independent Beneficiaries descending, then +successful Invocations descending, then `skillId` ascending. + +Process events in `settledAt`, then `settlementId`, order before assigning the first +accepted event in a payer cluster; input array order never changes the result. +An independent classifier record requires a non-empty evidence reference, but the +registry remains an explicit operator trust input rather than proof of ultimate +beneficial ownership. Set +`independenceConfidence` to `low` for zero accepted independent clusters, `medium` for +one, and `high` for at least two. Set `registryStatus` to `eligible` only for at least +two successful independent Beneficiaries in distinct accepted clusters and positive +independent net; use `allow_listed` when any successful unrefunded/unrecycled +settlement exists but that gate is unmet, and `ineligible` otherwise. + +### Task 1: Implement honest registry metrics and exclusion reasons + +**Files:** +- Create `spikes/registry-ranking/package.json` +- Create `spikes/registry-ranking/src/metrics.mjs` +- Create `spikes/registry-ranking/test/metrics.test.mjs` +- Create `spikes/registry-ranking/fixtures/settlements.json` +- Create `spikes/registry-ranking/fixtures/verified-billing-registry.json` + +- [ ] **Step 1: Write failing metric tests** + +The fixture must contain exactly these synthetic cases: + +1. Creator wallet pays its own Skill — `self_payment`. +2. A known Creator-linked wallet pays — `linked_wallet`. +3. Two payer wallets resolve through the trusted fixture to `cluster-sybil-a` — one cluster, not two Beneficiaries. +4. A settled Invocation fails — `failed_invocation`. +5. A settlement is unresolved — `unresolved_settlement`. +6. A successful payment is fully refunded — `refunded`. +7. Gross is immediately recycled — `recycled_value`. +8. Relationship is unknown — `unknown_relationship`. +9. OtherCo and ThirdCo each complete one successful independent Invocation from distinct clusters — eligible. + +Assert: + +```js +const classifier = createVerifiedBillingClassifier(verifiedBillingRegistry); +const metrics = computeSkillMetrics(events, { classifier }); +assert.equal(metrics.totalSettlements, 10); +assert.equal(metrics.uniqueIndependentBeneficiaries, 2); +assert.equal(metrics.registryStatus, "eligible"); +assert.equal(metrics.exclusionCounts.self_payment, 1); +assert.equal(metrics.exclusionCounts.sybil_cluster, 1); +assert.equal(metrics.independenceConfidence, "high"); + +const sybilOnly = computeSkillMetrics(events.filter((event) => SYBIL_SETTLEMENT_IDS.has(event.settlementId)), { classifier }); +assert.equal(sybilOnly.registryStatus, "allow_listed"); +assert.equal(sybilOnly.uniqueIndependentBeneficiaries, 1); +``` + +Also assert invalid atomic values, duplicate settlement IDs, duplicate invocation +success, refunded greater than gross, malformed trusted-registry evidence, and non-UTC +timestamps fail closed. Add spoof cases: an unregistered payer claims `independent` +with a unique beneficiary/cluster, and a trusted linked wallet claims independent. +Both claims are ignored; the first derives `unknown` and remains `allow_listed` with +`low` confidence, while the second is excluded as `linked_wallet`. Changing only +caller claim strings never changes metrics. + +- [ ] **Step 2: Run and verify red** + +Run: `cd spikes/registry-ranking && node --test test/metrics.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/metrics.mjs`. + +- [ ] **Step 3: Implement the public metric API** + +Use this package file: + +```json +{ + "name": "registry-ranking-spike", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Offline settlement-verifiable registry ranking spike.", + "scripts": { + "test": "node --test test/*.test.mjs", + "report": "node src/report.mjs fixtures/settlements.json fixtures/verified-billing-registry.json" + } +} +``` + +Export `parseSettlementMetricEvent(value)`, +`createVerifiedBillingClassifier(registry)`, `exclusionReasons(event, classification, +{ seenIndependentClusters })`, `computeSkillMetrics(events, { classifier })`, and +`rankEligibleSkills(metrics)`. The parser returns a +frozen validated event; the exclusion function returns a stable sorted array of the +enumerated reason strings; the metric reducer returns `PublicSkillMetricsV1`; and the +ranker returns a new frozen array without mutating caller-owned metrics. The classifier +deep-freezes its validated operator-controlled registry, derives self-payments before +registry lookup, and returns `unknown` for every absent wallet. Metrics never read +`untrustedPayerClaims` except to emit a non-ranking audit warning when claims disagree. + +All money parsing uses decimal strings and `bigint`. Preserve separate raw totals and independent totals. Return a frozen serializable result with decimal strings. Never infer quality, safety, usefulness, or demand from settlement alone. + +- [ ] **Step 4: Run tests** + +Run: `cd spikes/registry-ranking && npm test` + +Expected: PASS; the self-funded/Sybil-only fixture remains `allow_listed`. + +- [ ] **Step 5: Commit the metric slice** + +```bash +git add spikes/registry-ranking/package.json spikes/registry-ranking/src/metrics.mjs spikes/registry-ranking/test/metrics.test.mjs spikes/registry-ranking/fixtures/settlements.json spikes/registry-ranking/fixtures/verified-billing-registry.json +git commit -m "spike: rank registry entries by independent settled use" +``` + +### Task 2: Add a reproducible report and honest registry documentation + +**Files:** +- Create `spikes/registry-ranking/src/report.mjs` +- Create `spikes/registry-ranking/README.md` +- Modify `docs/plans/2026-07-15-registry-not-marketplace.md` + +- [ ] **Step 1: Add a failing report snapshot test** + +Extend `test/metrics.test.mjs` to call `renderRegistryReport` and require headings for total settlements, successful Invocations, settled failures, unresolved, refunds, unique independent Beneficiaries, net revenue, independence confidence, eligibility, and exclusions. Assert the report contains `settlement-verifiable` and does not contain `unfakeable`, `proof of demand`, `proves quality`, or `supply-chain safety`. + +- [ ] **Step 2: Run and verify red** + +Run: `cd spikes/registry-ranking && node --test --test-name-pattern='report' test/metrics.test.mjs` + +Expected: FAIL because the renderer is absent. + +- [ ] **Step 3: Implement the report and README** + +`src/report.mjs` parses the settlement fixture plus the explicitly supplied trusted +billing-registry fixture, constructs the classifier once, groups by Skill, computes +metrics, ranks only eligible Skills, and prints stable JSON under `--json` or Markdown +otherwise. It never constructs classifications from event claims. README opening: + +```text +SPIKE — synthetic registry-accounting evidence only. Settlement proves that value moved. It does not prove independent demand, usefulness, authorship, originality, or safety. +``` + +Document the exact schema, exclusion algorithm, sort order, allow-list threshold, command, and known limits. + +- [ ] **Step 4: Correct the tracked registry plan** + +Replace `unfakeable` with `settlement-verifiable`. Replace gross volume/unique-payer ranking with the public metric contract above. State that the first registry is allow-listed until two independent Beneficiaries have successful Invocations. Replace “buyers get supply-chain safety” with: `buyers get wallet-attested registration and declared ancestry; authorship evidence and safety review are separate statuses.` Preserve the dated research record by adding an amendment note rather than silently deleting its original context. + +- [ ] **Step 5: Verify report and tracked wording** + +Run: `cd spikes/registry-ranking && npm test && npm run report -- --json` + +Expected: PASS; JSON includes all separate metrics and the eligible fixture has two independent Beneficiaries. + +Run: `! rg -n '\bunfakeable\b|proof of demand|proves quality|supply-chain safety' docs/plans/2026-07-15-registry-not-marketplace.md spikes/registry-ranking` + +Expected: exit 0 and no output. + +- [ ] **Step 6: Commit the documented registry correction** + +```bash +git add spikes/registry-ranking/src/report.mjs spikes/registry-ranking/README.md spikes/registry-ranking/test/metrics.test.mjs docs/plans/2026-07-15-registry-not-marketplace.md +git commit -m "docs: define settlement-verifiable registry metrics" +``` + +### Task 3: Commit narrow transaction evidence and lock the n=48 result out of publication + +**Files:** +- Read: `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json` +- Read: `spikes/pi-wielder/README.md` +- Create: `spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json` +- Create after approval in Task 4: `hf-space/shared/evidence.json` + +- [ ] **Step 1: Verify the prerequisite tombstone** + +Run: + +```bash +node -e 'const m=require("./spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json"); if(m.evidenceStatus!=="historical_unreproducible"||m.publication.allowed!==false) process.exit(1)' +``` + +Expected: exit 0. If it fails or the file is absent, stop and complete the claims-quarantine plan; do not synthesize samples or copy p50/p95 into another file. + +- [ ] **Step 2: Write the immutable historical transaction manifest** + +Create `spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json` +with these exact rechecked receipt fields: + +```json +{ + "schemaVersion": 1, + "evidenceId": "base-sepolia-skill-settlement-2026-07-12", + "evidenceStatus": "historical_transaction_receipt_verified", + "network": { + "name": "base-sepolia", + "chainId": 84532 + }, + "transaction": { + "txHash": "0xaf1ba2fe508ee9d6bfe0823e25a05fc8b05c8dbac007b40b7d36dbbe447af522", + "status": "success", + "blockNumber": 44053992, + "blockHash": "0x7aad94c78a3c7a4eda90c70b510bd1f27a8b44d2c135d98a95473a561d48f56f", + "blockTimestamp": "2026-07-12T17:11:12.000Z", + "to": "0x036cbd53842c5426634e7929541ec2318f3dcf7e" + }, + "usdcTransfer": { + "from": "0xdddf065692ae373266a921f028ba6666a583053f", + "to": "0x25005dfac23d4bc45c801eaeb6c8b5a2bab0f189", + "amountAtomic": "250000" + }, + "verification": { + "method": "eth_getTransactionReceipt", + "rpc": "https://sepolia.base.org", + "verifiedOn": "2026-07-17", + "repositorySourceCommit": "69e7c6c17ba92792e1e0a8fee15fc90efc998c84", + "repositorySourcePath": "spikes/pi-wielder/README.md" + }, + "publication": { + "allowed": true, + "publicClaim": "One successful Base Sepolia USDC transfer transaction exists; the repository's 2026-07-12 historical run log labels it as the Skill-leg settlement.", + "doesNotProve": [ + "current endpoint behavior", + "latency", + "Royalty-claim split correctness", + "Skill execution output", + "independent demand", + "production readiness" + ] + } +} +``` + +The manifest is evidence about the historical receipt plus the repository's historical +label—not direct proof that a hosted Skill produced output. Before committing, make one +read-only JSON-RPC call to `https://sepolia.base.org`: require `eth_chainId` to return +`0x14a34`, fetch the receipt by the exact transaction hash, and compare status, block +number, block hash, contract address, and the single USDC Transfer log against the +manifest. Fetch the referenced block and compare its timestamp too. If any field +differs, stop and report the mismatch; never send or sign a transaction. + +- [ ] **Step 3: Define the only permitted public-demo evidence entries** + +The later `hf-space/shared/evidence.json` must use: + +```json +{ + "schemaVersion": 1, + "historicalOverhead": { + "manifestPath": "spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json", + "evidenceStatus": "historical_unreproducible", + "publicationAllowed": false, + "publicText": "A historical 2026-07-15 inference-route run reported latency percentiles, but normalized samples were not retained. Percentiles are suppressed until a new dated reproducible run is authorized and committed." + }, + "historicalSkillLegTransactions": [ + { + "manifestPath": "spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json", + "evidenceStatus": "historical_transaction_receipt_verified", + "label": "one successful historical Base Sepolia USDC transfer; the 2026-07-12 repository log labels it as the Skill leg", + "doesNotProve": [ + "current endpoint behavior", + "latency", + "Royalty-claim split correctness", + "Skill execution output" + ] + } + ] +} +``` + +Do not call `0x01daa723f23a6e2bbfb67b5077a25b37e6b97827b82013152c96da9d0638ff49` a Skill-endpoint settlement; the repository identifies it as a model-gateway payment. Any future rerun writes a new dated directory under `spikes/pi-wielder/evidence/`; it never overwrites or upgrades the 2026-07-15 manifest in place. + +- [ ] **Step 4: Test and commit the narrow manifest** + +Run: + +```bash +node -e 'const m=require("./spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json"); if(m.network.chainId!==84532||m.transaction.status!=="success"||m.usdcTransfer.amountAtomic!=="250000"||m.publication.allowed!==true||m.publication.doesNotProve.length<6) process.exit(1)' +git add -- spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json +git commit -m "docs: capture narrow Base Sepolia transaction evidence" +``` + +Expected: validation exits 0. The commit contains only the new manifest. + +- [ ] **Step 5: Verify the recorded `hf-space/` approval applies** + +For the current execution, cite the user's 2026-07-17 instruction to execute the +approved remediation design and continue. Confirm that no later instruction revoked +permission to edit or add `hf-space/`. + +Expected: approval is present and Task 4 may begin. On a later reuse where that approval +is absent, ask: `Do you approve editing and adding the currently untracked hf-space/ +directory for the reviewed demo corrections? I will not deploy or publish it.` Stop +with no `hf-space/` diff unless the answer is explicitly affirmative. + +### Task 4: Generate one public-demo allocation fixture from the atomic core — approval required + +**Gate:** Do not begin unless Task 3 received explicit approval. + +**Files:** +- Create `hf-space/scripts/generate-accounting-fixture.mjs` +- Create `hf-space/scripts/test-generate-accounting-fixture.mjs` +- Create `hf-space/scripts/package-space-fixtures.mjs` +- Create `hf-space/scripts/test-package-space-fixtures.mjs` +- Create `hf-space/shared/public-demo-allocation.json` +- Create `hf-space/shared/evidence.json` +- Create `hf-space/gradio/data/public-demo-allocation.json` +- Create `hf-space/gradio/data/evidence.json` +- Create `hf-space/gradio/data/fixture-integrity.json` +- Create `hf-space/static/data/public-demo-allocation.json` +- Create `hf-space/static/data/evidence.json` +- Create `hf-space/static/data/fixture-integrity.json` + +- [ ] **Step 1: Write a failing shared-core fixture test** + +Import `buildFixture` from `generate-accounting-fixture.mjs`. Assert the default +scenario is `intra-org`; its `allocationKind` is `internal_invocation_award`; its only +Creator-directed credit is the employee-Creator award; Education and Marketplace use +`external_royalty_claim`; and every scenario reports `protocolFeeAtomic === "6250"`. +Assert each scenario serializes the exact account-identified `journalEntries` returned +by its kernel call, every entry has decimal-string `amountAtomic`, every entry debits +the kernel's expected gross source account, and the entry amounts sum exactly to +`grossAtomic === "250000"`. Assert the external scenarios report +`royaltyPoolAtomic === "193750"` while Intra-org reports +`invocationAwardAtomic === "193750"` and has no employer self-credit. Add a mutation +test proving the generator rejects a supplied or reconstructed journal entry that is +not present in the kernel result. + +- [ ] **Step 2: Run and verify red** + +Run: `node --test hf-space/scripts/test-generate-accounting-fixture.mjs` + +Expected: FAIL because `generate-accounting-fixture.mjs` does not exist. + +- [ ] **Step 3: Write the generator in check/write modes** + +Import `allocateExternalGross`, `allocateInternalGross`, and `formatUsdc` from +`../../prototype/atomic-money.mjs`. Generate three scenarios with the same gross, +COGS, derived fee amount, and reserve inputs and explicit mode status: + +```js +const SCENARIOS = [ + { id: "intra-org", allocationKind: "internal_invocation_award", status: "terminal_product_spike", label: "Intra-org — employer-funded internal Invocation award", policy: "internal_award" }, + { id: "education", allocationKind: "external_royalty_claim", status: "deferred", label: "Education — deferred after free re-authoring dominated the tested model", policy: "LRP" }, + { id: "marketplace", allocationKind: "external_royalty_claim", status: "phase_3_optionality", label: "Marketplace — Phase-3 optionality", policy: "LRP" } +]; +const COMMON_INPUT = { + grossAtomic: 250000n, + executionCostAtomic: 50000n, + refundReserveAtomic: 0n +}; +const EXTERNAL_INPUT = { + ...COMMON_INPUT, + settlementCostAtomic: 0n, + protocolFeeBps: 250 +}; +const INTERNAL_INPUT = { + ...COMMON_INPUT, + protocolFeeAtomic: 6250n +}; +const EXTERNAL_SKILLS = { + "derived-skill": { + parentIds: ["source-skill"], + inheritBps: 1500, + holders: [{ recipientId: "derived-creator", bps: 10000 }] + }, + "source-skill": { + parentIds: [], + inheritBps: 0, + holders: [{ recipientId: "source-creator", bps: 10000 }] + } +}; +``` + +Call `allocateExternalGross({ ...EXTERNAL_INPUT, leafSkillId: "derived-skill", +skills: EXTERNAL_SKILLS })` first and assert it derives `protocolFeeAtomic === 6250n`. +Pass that quote-final exact amount to `allocateInternalGross({ ...INTERNAL_INPUT, +recipientId: "employee-creator" })` for Intra-org. Call +`allocateExternalGross({ ...EXTERNAL_INPUT, leafSkillId: "derived-skill", skills: +EXTERNAL_SKILLS })` for Education and Marketplace. The external graph implements 15% +LRP at the one declared ancestry hop. Do not pass a precomputed Royalty pool or award +to either allocator; assert the external core derives `6250n` and both modes derive +`193750n` as their Royalty pool or Invocation award respectively. +For Intra-org, label the result illustrative until the internal-award amendment becomes +canonical and do not show an employer self-credit. + +For every scenario, serialize `allocation.journalEntries` directly from the kernel +result, preserving entry order, `category`, `debitAccountId`, `creditAccountId`, and +`amountAtomic`. Do not infer account IDs from component names and do not rebuild the +journal in the generator, Python, or browser code. The generator fails if a returned +entry lacks an account ID, if the sum of entry amounts differs from gross, or if a +debit account differs from `employer:invocation-gross` for internal allocation or +`wielder:external-gross` for external allocation. + +Export `buildFixture()`, `canonicalFixtureBytes(fixture)`, and `main(argv)`. Serialize +every `bigint` as a decimal string and use `formatUsdc` for display strings. Include +`generatedBy`, `corePath`, exact inputs, policy, status, allocations, and conservation +equation. Define `fixtureSha256` as SHA-256 over canonical JSON of the fixture with the +`fixtureSha256` field omitted; the checker recomputes that same preimage before comparing +whole-file bytes. `--write` writes a same-directory temporary file, fsyncs it, and +renames it over the target. `--check` only reads and regenerates in memory, then exits 1 +with `public demo accounting fixture drift` if bytes differ. + +- [ ] **Step 4: Generate and check the fixture** + +Run: `node --test hf-space/scripts/test-generate-accounting-fixture.mjs && node hf-space/scripts/generate-accounting-fixture.mjs --write && node hf-space/scripts/generate-accounting-fixture.mjs --check` + +Expected: PASS and exit 0. External scenarios satisfy `grossAtomic === +executionCostAtomic + settlementCostAtomic + protocolFeeAtomic + refundReserveAtomic + +royaltyPoolAtomic`; Intra-org satisfies `grossAtomic === executionCostAtomic + +protocolFeeAtomic + refundReserveAtomic + invocationAwardAtomic`. In both cases, the +same equality is independently asserted from the kernel-returned `journalEntries`. + +- [ ] **Step 5: Write the evidence fixture exactly as specified in Task 3** + +Run: `node -e 'const e=require("./hf-space/shared/evidence.json"); if(e.historicalOverhead.publicationAllowed!==false||e.historicalSkillLegTransactions.length!==1) process.exit(1)'` + +Expected: exit 0. + +- [ ] **Step 6: Write a failing standalone-root packaging test** + +`test-package-space-fixtures.mjs` imports `buildPackagePlan` and `main` from the absent +packager. It must assert: + +- each root receives local `data/public-demo-allocation.json`, `data/evidence.json`, and + `data/fixture-integrity.json`; +- each packaged fixture is byte-identical to its canonical `hf-space/shared/` source; +- both integrity manifests are byte-identical canonical JSON and contain the exact + SHA-256 plus byte length of each packaged file; +- check mode succeeds when invoked from a different working directory; +- changing one byte in a temporary packaged copy or its hash makes check mode fail; +- no packaged loader/import/fetch path contains `../shared`, an absolute repository + path, or a sibling Space root. + +Run: `node --test hf-space/scripts/test-package-space-fixtures.mjs` + +Expected: FAIL because `package-space-fixtures.mjs` does not exist. + +- [ ] **Step 7: Implement deterministic packaged copies** + +Export `buildPackagePlan({ canonicalRoot, spaceRoots })`, +`canonicalIntegrityBytes(value)`, and `main(argv)` from +`package-space-fixtures.mjs`. Resolve production source/target paths from +`import.meta.url`, never `cwd`. Read the canonical allocation/evidence files as raw +bytes and require one final newline. For each source compute lowercase +`sha256:<64-hex>` over those exact bytes and byte length. Use this manifest schema in +both roots: + +```json +{ + "schemaVersion": 1, + "generatedBy": "hf-space/scripts/package-space-fixtures.mjs", + "files": { + "evidence.json": { + "sha256": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "bytes": 1 + }, + "public-demo-allocation.json": { + "sha256": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "bytes": 1 + } + } +} +``` + +The shown hashes/lengths illustrate shape only; generated values must match the exact +source bytes. Sort object keys canonically. `--write` uses same-directory temporary +files, full write loops, fsync, and rename for all six targets. `--check` performs no +write and compares all six complete byte sequences, failing with +`standalone Space fixture drift: ${relativePath}`. Neither mode accesses a network. + +- [ ] **Step 8: Generate and verify every standalone-root package** + +Run: + +```bash +node --test hf-space/scripts/test-package-space-fixtures.mjs +node hf-space/scripts/package-space-fixtures.mjs --write +node hf-space/scripts/package-space-fixtures.mjs --check +! rg -n '\.\./shared|hf-space/(gradio|static)' hf-space/gradio/data hf-space/static/data +``` + +Expected: tests pass, all six generated files check byte-for-byte, and neither root +contains a sibling/repository-relative runtime dependency. + +### Task 5: Make Gradio consume validated fixtures — approval required + +**Files:** +- Create `hf-space/gradio/demo_logic.py` +- Create `hf-space/gradio/test_demo_logic.py` +- Create `hf-space/gradio/test_app_smoke.py` +- Modify `hf-space/gradio/app.py` +- Modify `hf-space/gradio/README.md` +- Modify `hf-space/gradio/requirements.txt` + +- [ ] **Step 1: Write failing Python tests** + +Using `unittest`, assert `validate_live_402(402, valid_body)` returns live, while JSON 200, JSON 500, unsupported x402 version, empty accepts, wrong scheme, invalid atomic amount, and missing payTo/asset return non-live with an error. Assert the default scenario is `intra-org`, Education status is `deferred`, Marketplace is `phase_3_optionality`, each scenario's displayed credits plus cost allocations conserve gross under its allocation kind, and evidence rendering contains neither `p50` nor `p95`. +The conservation assertion must sum only the serialized kernel `journalEntries`; it +must not construct entries or account IDs from display components. +Load fixtures only from `Path(__file__).resolve().parent / "data"`; verify both raw file +hashes/lengths against local `fixture-integrity.json` before JSON parsing. Tests copy +only the Gradio root into a temporary directory, import that copied module from a +different working directory, and prove it works without `hf-space/shared/`. + +In `test_app_smoke.py`, patch `httpx.post` to raise if called, then import the actual +`app.py` with pinned Gradio/httpx installed. Assert import performs no network or +launch, `app.demo` is a `gr.Blocks`, and `demo.get_config_file()` contains the +Intra-org default plus dependency `api_name: "check_live_402"`. Replace the stub with a +fake valid 402 response, call the actual wired handler once, and require the returned +view model to be live; fake 200 and thrown request render non-live/cached status. + +- [ ] **Step 2: Run and verify red** + +Run: `python3 -B -m unittest hf-space/gradio/test_demo_logic.py hf-space/gradio/test_app_smoke.py` + +Expected: FAIL because `demo_logic.py` and revised app wiring are absent. + +- [ ] **Step 3: Implement fixture-only logic** + +`demo_logic.py` exports `load_allocation_fixture`, `load_evidence_fixture`, `validate_live_402`, `scenario_by_id`, and `render_allocation`. It performs no allocation arithmetic beyond summing parsed integer strings for a conservation assertion. `validate_live_402` requires status 402, x402 version 1, first offer scheme `exact`, network `base-sepolia`, decimal `maxAmountRequired`, and non-empty resource/payTo/asset. +It renders the kernel-returned debit/credit account IDs verbatim and rejects a fixture +whose entry amount sum or expected gross-source debit account fails validation. +Both loaders resolve only the local `data/` directory, hash the raw bytes before +parsing, require exact keys/length/hash from `fixture-integrity.json`, and fail with +`packaged fixture integrity mismatch: ${fileName}` on drift. They contain no fallback +to `../shared`, the repository root, or embedded fixture constants. + +- [ ] **Step 4: Correct Gradio presentation** + +Use Intra-org as default. Show Education and Marketplace statuses inline. Replace sliders that could create unverified math with the generated scenarios. Label the allocation `generated from prototype/atomic-money.mjs; synthetic accounting illustration`. Replace `claimable on demand` with `credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo`. Display actual HTTP status and never call a malformed/non-402 JSON response live. Show only the single historical transaction manifest and its narrow repository-log label, plus the suppressed-evidence notice; remove all n=48 p50/p95 text and “two transactions from this exact endpoint.” Never call the historical receipt current hosted-Skill endpoint proof. +Refactor to `build_demo()` plus module-level `demo = build_demo()`; call `demo.launch()` +only under `if __name__ == "__main__"`. Wire the live button with +`api_name="check_live_402"`. Importing the module builds the UI from verified packaged +fixtures but performs no HTTP request and starts no server. + +- [ ] **Step 5: Review and pin the existing runtime requirements** + +Keep the pre-existing requirements file in scope and make its complete contents: + +```text +gradio==6.20.0 +httpx==0.28.1 +``` + +Do not add `huggingface_hub`, deployment CLIs, tokens, repository URLs, post-install +scripts, or any publication dependency. Import both packages in the same Python +environment used for the tests and record their resolved versions in the local test +output; this review does not install, deploy, or publish anything. + +- [ ] **Step 6: Run Gradio tests and fixture drift check** + +Run: + +```bash +python3 -c 'from importlib.metadata import version; assert version("gradio")=="6.20.0"; assert version("httpx")=="0.28.1"; print("gradio=6.20.0 httpx=0.28.1")' +python3 -B -m unittest hf-space/gradio/test_demo_logic.py hf-space/gradio/test_app_smoke.py +node hf-space/scripts/generate-accounting-fixture.mjs --check +node hf-space/scripts/package-space-fixtures.mjs --check +``` + +Expected: version assertion and tests PASS; the actual app imports and one callback is +exercised entirely through HTTP stubs, with no server, network, or sibling fixture +access. + +### Task 6: Make static demo consume the same fixtures — approval required + +**Files:** +- Create `hf-space/static/demo-logic.mjs` +- Create `hf-space/static/test-demo-logic.mjs` +- Create `hf-space/static/test-index-smoke.mjs` +- Modify `hf-space/static/index.html` +- Modify `hf-space/static/README.md` +- Create `hf-space/static/package.json` +- Create `hf-space/static/package-lock.json` +- Create `hf-space/static/data/public-demo-allocation.json` +- Create `hf-space/static/data/evidence.json` +- Create `hf-space/static/data/fixture-integrity.json` + +- [ ] **Step 1: Write failing JavaScript tests** + +Mirror the Python validation matrix. Import the generated JSON using `readFileSync` in tests and assert `renderScenarioModel` returns display rows whose integer totals conserve gross. Assert no output string contains p50/p95 and default is Intra-org. +The rows come directly from serialized `journalEntries`; tests fail if browser logic +derives or substitutes any debit/credit account ID. +The fixture loader fetches only `./data/fixture-integrity.json`, +`./data/public-demo-allocation.json`, and `./data/evidence.json`, hashes the raw bytes +with injected Web Crypto before parsing, and fails on length/hash mismatch. A test +copies only the static root to a temporary directory and runs there with no sibling +fixture directory. + +`test-index-smoke.mjs` must parse the actual `index.html` with pinned `linkedom`, install +that window/document plus stubbed `fetch` and Web Crypto on `globalThis`, then +dynamically import the actual production `demo-logic.mjs` entrypoint with no manual +`mountDemo` call. Await its exported `browserBootstrapPromise`. The fetch stub serves +only packaged local data and fake endpoint responses. Assert the actual DOM contains +the module script and required controls, automatic bootstrap marks the document mounted +exactly once, renders Intra-org by default, a click wired to a fake valid 402 renders +live, and fake 200/500 render non-live. Any unstubbed URL throws, proving no network +access. A separate import with no browser globals must leave +`browserBootstrapPromise === null` and perform no fetch. + +- [ ] **Step 2: Run and verify red** + +Run: `node --test hf-space/static/test-demo-logic.mjs hf-space/static/test-index-smoke.mjs` + +Expected: FAIL because `demo-logic.mjs`, DOM wiring, or pinned dependency is absent. + +- [ ] **Step 3: Implement static fixture consumption** + +Export `validateLive402`, `loadScenario`, `renderScenarioModel`, `mountDemo`, and +`browserBootstrapPromise`. Do no fee, COGS, ancestry, percentage, or rounding math in +browser code; display decimal strings already generated by the core. Use the module +script in `index.html` to load only hash-verified files beneath local `./data/`. +Validate conservation only by summing the kernel-returned journal entry amounts and +requiring the expected gross-source debit account for the selected allocation kind. +Implement `loadPackagedFixtures({ fetchImpl, cryptoImpl })`, which +fetches only the three local `./data/` resources, validates exact manifest keys, +lengths, and SHA-256 before JSON parsing, and returns frozen fixtures. No runtime path +contains `..`, `shared`, or the repository name. + +- [ ] **Step 4: Correct static presentation** + +Apply the same mode statuses, default, implemented/future language, live-402 validation, +one narrowly labeled historical transaction manifest, evidence suppression, and +no-arbitrary-slider behavior as Gradio. A fetch that returns JSON 200/500 must render +`live endpoint did not return a valid 402 offer`, never a green live badge. +The actual module exports async `mountDemo({ document, fetchImpl = fetch, cryptoImpl = +crypto })`, binds the real controls, loads verified local fixtures, and performs no +endpoint request until the user clicks. At module evaluation it guards +`typeof window !== "undefined" && typeof document !== "undefined"`; in a browser it +sets `browserBootstrapPromise` to a DOM-ready promise that invokes `mountDemo` exactly +once with production globals and renders a visible fatal state on rejection. Outside a +browser it sets the export to `null` and performs no fetch or DOM access. Duplicate +mount attempts fail or no-op without adding duplicate handlers. `index.html` contains exactly +`` and no inline duplicate logic. + +- [ ] **Step 5: Pin and verify the DOM-smoke dependency** + +Create: + +```json +{ + "name": "skill-asset-protocol-static-space", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test-demo-logic.mjs test-index-smoke.mjs" + }, + "devDependencies": { + "linkedom": "0.18.12" + } +} +``` + +Run `cd hf-space/static && npm install --ignore-scripts` once to create the committed +lockfile, then `npm ci --ignore-scripts`. Do not add preinstall/postinstall, publish, +deploy, or upload scripts. Verify the exact direct version with: + +```bash +node -e 'const p=require("./hf-space/static/node_modules/linkedom/package.json"); if(p.version!=="0.18.12") process.exit(1); console.log(`linkedom=${p.version}`)' +``` + +Expected: prints `linkedom=0.18.12`. Dependency installation is setup for the local +smoke only; it does not deploy or publish the Space. + +- [ ] **Step 6: Run all public-surface tests** + +Run: + +```bash +cd spikes/registry-ranking && npm test +cd ../.. +node hf-space/scripts/generate-accounting-fixture.mjs --check +node hf-space/scripts/package-space-fixtures.mjs --check +node --test hf-space/scripts/test-generate-accounting-fixture.mjs +node --test hf-space/scripts/test-package-space-fixtures.mjs +python3 -c 'from importlib.metadata import version; assert version("gradio")=="6.20.0"; assert version("httpx")=="0.28.1"' +python3 -B -m unittest hf-space/gradio/test_demo_logic.py +python3 -B -m unittest hf-space/gradio/test_app_smoke.py +node -e 'const p=require("./hf-space/static/node_modules/linkedom/package.json"); if(p.version!=="0.18.12") process.exit(1)' +cd hf-space/static && npm test +``` + +Expected: every command PASS; no network, provider, wallet, or deployment action occurs. + +### Task 7: Verify boundaries and commit locally — approval required for `hf-space/` + +**Files:** all files above, plus: +- Create `hf-space/scripts/verify-local-scope.mjs` + +The complete reviewed `hf-space/` allowlist is exactly: + +```text +hf-space/scripts/generate-accounting-fixture.mjs +hf-space/scripts/test-generate-accounting-fixture.mjs +hf-space/scripts/package-space-fixtures.mjs +hf-space/scripts/test-package-space-fixtures.mjs +hf-space/scripts/verify-local-scope.mjs +hf-space/shared/public-demo-allocation.json +hf-space/shared/evidence.json +hf-space/gradio/demo_logic.py +hf-space/gradio/test_demo_logic.py +hf-space/gradio/test_app_smoke.py +hf-space/gradio/app.py +hf-space/gradio/README.md +hf-space/gradio/requirements.txt +hf-space/gradio/data/public-demo-allocation.json +hf-space/gradio/data/evidence.json +hf-space/gradio/data/fixture-integrity.json +hf-space/static/demo-logic.mjs +hf-space/static/test-demo-logic.mjs +hf-space/static/test-index-smoke.mjs +hf-space/static/index.html +hf-space/static/README.md +hf-space/static/package.json +hf-space/static/package-lock.json +hf-space/static/data/public-demo-allocation.json +hf-space/static/data/evidence.json +hf-space/static/data/fixture-integrity.json +``` + +- [ ] **Step 1: Implement the exact-path scope guard** + +`verify-local-scope.mjs` exports the frozen sorted constant +`HF_SPACE_ALLOWED_PATHS` containing exactly the 26 paths above. Its normal mode runs +`git status --porcelain=v1 --untracked-files=all -- hf-space`, parses every complete +status line, rejects rename/copy records, and fails on any path outside the allowlist +or any required allowlisted path absent from status. Its `--cached` mode runs +`git diff --cached --name-only -- hf-space` and requires the cached path set to equal +the allowlist exactly. Both modes print the sorted compared path set. Never collapse +an untracked directory to one path and never silently accept a directory path. + +Run: + +```bash +git status --short --untracked-files=all -- hf-space +node hf-space/scripts/verify-local-scope.mjs +``` + +Expected: the status output and guard contain exactly the 26 file paths above. Any +extra or missing path is a stop gate for review, not a reason to broaden the allowlist. + +- [ ] **Step 2: Scan the exact public files and protected corpus** + +Run: + +```bash +! rg -n '\bp(50|95)\b|two real testnet settlements from this exact endpoint|claimable on demand|current hosted-Skill endpoint proof|\bunfakeable\b|supply-chain safety' hf-space/scripts/generate-accounting-fixture.mjs hf-space/scripts/test-generate-accounting-fixture.mjs hf-space/scripts/package-space-fixtures.mjs hf-space/scripts/test-package-space-fixtures.mjs hf-space/scripts/verify-local-scope.mjs hf-space/shared/public-demo-allocation.json hf-space/shared/evidence.json hf-space/gradio/demo_logic.py hf-space/gradio/test_demo_logic.py hf-space/gradio/test_app_smoke.py hf-space/gradio/app.py hf-space/gradio/README.md hf-space/gradio/requirements.txt hf-space/gradio/data/public-demo-allocation.json hf-space/gradio/data/evidence.json hf-space/gradio/data/fixture-integrity.json hf-space/static/demo-logic.mjs hf-space/static/test-demo-logic.mjs hf-space/static/test-index-smoke.mjs hf-space/static/index.html hf-space/static/README.md hf-space/static/package.json hf-space/static/package-lock.json hf-space/static/data/public-demo-allocation.json hf-space/static/data/evidence.json hf-space/static/data/fixture-integrity.json docs/plans/2026-07-15-registry-not-marketplace.md +git diff --exit-code -- CONTEXT.md docs/PRD.md docs/adr +``` + +Expected: exit 0 and no output. This scans untracked files directly; do not substitute +`git diff`, which omits untracked contents. + +- [ ] **Step 3: Confirm no publication command, secret, or endpoint is present** + +Run: + +```bash +! rg -n 'huggingface-cli upload|hf upload|huggingface_hub|git push|gradio deploy|vercel deploy|netlify deploy|requests\.(post|put)|httpx\.(post|put).*huggingface|HF_TOKEN|HUGGING_FACE_HUB_TOKEN' hf-space/scripts/generate-accounting-fixture.mjs hf-space/scripts/test-generate-accounting-fixture.mjs hf-space/scripts/package-space-fixtures.mjs hf-space/scripts/test-package-space-fixtures.mjs hf-space/scripts/verify-local-scope.mjs hf-space/shared/public-demo-allocation.json hf-space/shared/evidence.json hf-space/gradio/demo_logic.py hf-space/gradio/test_demo_logic.py hf-space/gradio/test_app_smoke.py hf-space/gradio/app.py hf-space/gradio/README.md hf-space/gradio/requirements.txt hf-space/gradio/data/public-demo-allocation.json hf-space/gradio/data/evidence.json hf-space/gradio/data/fixture-integrity.json hf-space/static/demo-logic.mjs hf-space/static/test-demo-logic.mjs hf-space/static/test-index-smoke.mjs hf-space/static/index.html hf-space/static/README.md hf-space/static/package.json hf-space/static/package-lock.json hf-space/static/data/public-demo-allocation.json hf-space/static/data/evidence.json hf-space/static/data/fixture-integrity.json +``` + +Expected: exit 0 and no output. The scan reads the files themselves and therefore +covers untracked content. + +- [ ] **Step 4: Stage any remaining registry work by exact file path** + +Earlier task commits should normally leave this slice clean. If any planned registry +path remains modified, stage only the exact paths below—never either parent directory: + +```bash +git add -- spikes/registry-ranking/package.json spikes/registry-ranking/src/metrics.mjs spikes/registry-ranking/src/report.mjs spikes/registry-ranking/test/metrics.test.mjs spikes/registry-ranking/fixtures/settlements.json spikes/registry-ranking/fixtures/verified-billing-registry.json spikes/registry-ranking/README.md docs/plans/2026-07-15-registry-not-marketplace.md +git diff --cached --name-only +git commit -m "spike: add settlement-verifiable registry ranking" +``` + +Expected: cached paths are a subset of that exact list and contain no `hf-space/`, +protected corpus, or unrelated file. If there is no remaining registry diff, skip the +commit instead of creating an empty one. + +- [ ] **Step 5: Stage the approved demo by exact file path and commit locally** + +Only if the explicit approval included adding `hf-space/`: + +```bash +git add -- hf-space/scripts/generate-accounting-fixture.mjs hf-space/scripts/test-generate-accounting-fixture.mjs hf-space/scripts/package-space-fixtures.mjs hf-space/scripts/test-package-space-fixtures.mjs hf-space/scripts/verify-local-scope.mjs hf-space/shared/public-demo-allocation.json hf-space/shared/evidence.json hf-space/gradio/demo_logic.py hf-space/gradio/test_demo_logic.py hf-space/gradio/test_app_smoke.py hf-space/gradio/app.py hf-space/gradio/README.md hf-space/gradio/requirements.txt hf-space/gradio/data/public-demo-allocation.json hf-space/gradio/data/evidence.json hf-space/gradio/data/fixture-integrity.json hf-space/static/demo-logic.mjs hf-space/static/test-demo-logic.mjs hf-space/static/test-index-smoke.mjs hf-space/static/index.html hf-space/static/README.md hf-space/static/package.json hf-space/static/package-lock.json hf-space/static/data/public-demo-allocation.json hf-space/static/data/evidence.json hf-space/static/data/fixture-integrity.json +node hf-space/scripts/verify-local-scope.mjs --cached +git commit -m "fix: align public demo with verified accounting" +``` + +Expected: the cached scope guard reports exactly the 26 allowlisted paths before the +commit. Never stage `hf-space/` or any child directory as a directory argument. Do not +run `git push`, a Hugging Face upload, Vercel, Netlify, or any deploy command. + +## Definition of done + +- Self-payments, linked wallets, refunded/failed Invocations, recycling, and repeated Sybil clusters cannot count as independent demand; caller-supplied relationship claims cannot change classification. +- Registry output reports all required metrics separately and stays allow-listed until two independent Beneficiaries succeed. +- Tracked registry language says settlement-verifiable and does not imply quality, authorship, or safety. +- The historical transaction manifest pins one rechecked Base Sepolia receipt and limits its public claim to transaction existence plus the repository's historical Skill-leg label; it does not imply execution, latency, split correctness, or demand. +- If `hf-space/` approval is absent, no file beneath it is modified, staged, or committed. +- If approval is present, both demos consume the kernel-returned account-identified `journalEntries` from deterministic hash-verified copies packaged inside their own standalone roots, pass canonical and packaged drift checks, default to Intra-org, label Education and Marketplace honestly, reject JSON 200/500 as live 402, and distinguish credits from withdrawal/settlement. +- Gradio's actual app imports with pinned versions and a network stub; the static + HTML/module mounts against a real test DOM with all fetches stubbed. Neither runtime + references `hf-space/shared` or its sibling Space. +- The scope guard and cached-scope check prove that only the 26 reviewed `hf-space/` files are staged, including both roots' packaged fixtures, smoke tests, and pinned dependency files. +- The n=48 p50/p95 result remains historical and suppressed; no public surface treats it as reproducible. +- No deployment or publication occurs. +- `CONTEXT.md`, `docs/PRD.md`, and `docs/adr/` remain unchanged. diff --git a/docs/superpowers/plans/2026-07-17-wielder-payment-policy.md b/docs/superpowers/plans/2026-07-17-wielder-payment-policy.md new file mode 100644 index 0000000..64b6b6b --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-wielder-payment-policy.md @@ -0,0 +1,1108 @@ +# Wielder Payment Policy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent the thin Wielder from signing an x402 offer unless its network, asset, seller, payee, resource, amount, freshness, timeout, and session budget satisfy an explicit local policy. + +**Architecture:** Add a pure stateful payment-policy object that validates an offer, atomically reserves session budget, permits exactly one retry with the accepted amount, and tracks settled, rejected, or unresolved authorizations. `payingFetch` becomes an exported orchestration function that asks the policy before signing and never recursively pays a changed second offer. + +**Tech Stack:** Node.js 20+, ECMAScript modules, built-in `node:test`, `node:assert/strict`, viem local signing, Hono; mock/testnet verification only, with zero funded-wallet or network requirements. + +--- + +## Prerequisites and file map + +Complete the atomic-money and Collar-journal plans first. + +- Create `spikes/pi-wielder/src/payment-policy.mjs`: seller rules, offer validation, authorization reservation, retry/settlement state, and budget snapshots. +- Create `spikes/pi-wielder/tests/payment-policy.test.mjs`: one rejection test per required check plus concurrency and lifecycle cases. +- Create `spikes/pi-wielder/tests/paying-fetch.test.mjs`: orchestration tests proving no forbidden offer is signed and no second retry occurs. +- Modify `spikes/pi-wielder/src/proxy.mjs`: inject/use the policy and export `payingFetch` for focused tests. +- Modify `spikes/pi-wielder/e2e.mjs`: configure trusted mock sellers and verify session spend. +- Modify `spikes/pi-wielder/.env.example`: document testnet-only policy variables without adding any key. +- Modify `spikes/pi-wielder/package.json`: add focused policy test scripts. + +## Policy interface + +```js +const policy = createPaymentPolicy({ + network: 'base-sepolia', + asset: '0x036C...CF7e', + sessionBudgetAtomic: '1000000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + sellers: [{ + origin: 'http://127.0.0.1:8404', + pathPrefix: '/invoke/', + payTo: '0x0000...dEaD', + maxPerCallAtomic: '500000', + }], +}); + +policy.reserveAuthorization({ authorizationId, requestUrl, method, bodyBytes, offer, receivedAtMs }); +policy.beginRetry(authorizationId, { amountAtomic, offerFingerprint }); +policy.assertRetryOffer(authorizationId, secondOffer); +policy.markSettled(authorizationId, { txHash }); +policy.markRejected(authorizationId, { reason }); +policy.markUnresolved(authorizationId, { reason }); +policy.snapshot(); +``` + +The policy reserves budget before signing so concurrent calls cannot overspend. An +unresolved authorization retains its reservation until trusted reconciliation marks it +settled or rejected. + +### Task 1: Implement pure offer validation and session-budget state + +**Files:** +- Create: `spikes/pi-wielder/src/payment-policy.mjs` +- Create: `spikes/pi-wielder/tests/payment-policy.test.mjs` + +- [ ] **Step 1: Write failing policy rejection and lifecycle tests** + +Create `spikes/pi-wielder/tests/payment-policy.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + canonicalRequestHash, + createPaymentPolicy, + PaymentPolicyError, +} from '../src/payment-policy.mjs'; + +const ASSET = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; +const PAYEE = '0x000000000000000000000000000000000000dEaD'; +const URL = 'https://trusted.example/invoke/skill-a'; +const REQUEST_HASH = canonicalRequestHash({ method: 'POST', requestUrl: URL, bodyBytes: '{}' }); + +const offer = (overrides = {}) => ({ + scheme: 'exact', + network: 'base-sepolia', + maxAmountRequired: '250000', + resource: URL, + payTo: PAYEE, + maxTimeoutSeconds: 60, + asset: ASSET, + extra: { + name: 'USDC', + version: '2', + requestHash: REQUEST_HASH, + quoteId: `sha256:${'b'.repeat(64)}`, + issuedAt: new Date(9_000).toISOString(), + expiresAt: new Date(69_000).toISOString(), + }, + ...overrides, +}); + +function policy(overrides = {}) { + return createPaymentPolicy({ + network: 'base-sepolia', + asset: ASSET, + sessionBudgetAtomic: '500000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + now: () => 10_000, + sellers: [{ + origin: 'https://trusted.example', + pathPrefix: '/invoke/', + payTo: PAYEE, + maxPerCallAtomic: '300000', + }], + ...overrides, + }); +} + +const rejectionCases = [ + ['wrong scheme', offer({ scheme: 'upto' }), URL, 9_000, 'SCHEME'], + ['wrong network', offer({ network: 'base' }), URL, 9_000, 'NETWORK'], + ['wrong asset', offer({ asset: '0x1111111111111111111111111111111111111111' }), URL, 9_000, 'ASSET'], + ['wrong payee', offer({ payTo: '0x2222222222222222222222222222222222222222' }), URL, 9_000, 'PAYEE'], + ['wrong resource', offer({ resource: 'https://trusted.example/invoke/other' }), URL, 9_000, 'RESOURCE'], + ['untrusted seller', offer({ resource: 'https://evil.example/invoke/skill-a' }), 'https://evil.example/invoke/skill-a', 9_000, 'SELLER'], + ['zero amount', offer({ maxAmountRequired: '0' }), URL, 9_000, 'AMOUNT'], + ['over per-call cap', offer({ maxAmountRequired: '300001' }), URL, 9_000, 'PER_CALL'], + ['expired local quote', offer(), URL, 4_999, 'FRESHNESS'], + ['excess timeout', offer({ maxTimeoutSeconds: 61 }), URL, 9_000, 'TIMEOUT'], + ['mismatched request bytes', offer({ extra: { ...offer().extra, requestHash: `sha256:${'f'.repeat(64)}` } }), URL, 9_000, 'REQUEST_HASH'], + ['wrong EIP-712 name', offer({ extra: { ...offer().extra, name: 'FakeUSDC' } }), URL, 9_000, 'EIP712'], + ['wrong EIP-712 version', offer({ extra: { ...offer().extra, version: '1' } }), URL, 9_000, 'EIP712'], + ['expired server quote', offer({ extra: { ...offer().extra, expiresAt: new Date(9_999).toISOString() } }), URL, 9_000, 'QUOTE_EXPIRY'], +]; + +for (const [name, candidate, requestUrl, receivedAtMs, code] of rejectionCases) { + test(`rejects ${name} before reservation`, () => { + const subject = policy(); + assert.throws( + () => subject.reserveAuthorization({ + authorizationId: `auth-${code}`, + requestUrl, + offer: candidate, + receivedAtMs, + method: 'POST', + bodyBytes: '{}', + }), + (error) => error instanceof PaymentPolicyError && error.code.includes(code), + ); + assert.equal(subject.snapshot().reservedAtomic, '0'); + }); +} + +test('concurrent reservations cannot exceed the remaining session budget', () => { + const subject = policy({ sessionBudgetAtomic: '400000' }); + subject.reserveAuthorization({ authorizationId: 'auth-1', requestUrl: URL, method: 'POST', bodyBytes: '{}', offer: offer(), receivedAtMs: 9_000 }); + assert.throws(() => subject.reserveAuthorization({ + authorizationId: 'auth-2', requestUrl: URL, method: 'POST', bodyBytes: '{}', offer: offer(), receivedAtMs: 9_000, + }), (error) => error.code === 'SESSION_BUDGET'); + assert.deepEqual(subject.snapshot(), { + sessionBudgetAtomic: '400000', + reservedAtomic: '250000', + spentAtomic: '0', + remainingAtomic: '150000', + authorizations: [{ + authorizationId: 'auth-1', + amountAtomic: '250000', + state: 'reserved', + retryCount: 0, + txHash: null, + reason: null, + }], + }); +}); + +test('accepted amount is immutable and one authorization permits one retry', () => { + const subject = policy(); + const auth = subject.reserveAuthorization({ + authorizationId: 'auth-1', requestUrl: URL, method: 'POST', bodyBytes: '{}', offer: offer(), receivedAtMs: 9_000, + }); + assert.equal(subject.claimSignature('auth-1', { offerFingerprint: auth.offerFingerprint }).claimed, true); + assert.throws(() => subject.beginRetry('auth-1', { + amountAtomic: '250001', offerFingerprint: auth.offerFingerprint, + }), (error) => error.code === 'AMOUNT_DRIFT'); + subject.beginRetry('auth-1', { + amountAtomic: '250000', offerFingerprint: auth.offerFingerprint, + }); + assert.throws(() => subject.beginRetry('auth-1', { + amountAtomic: '250000', offerFingerprint: auth.offerFingerprint, + }), (error) => error.code === 'RETRY_LIMIT'); +}); + +test('a changed second offer is rejected and cannot receive another signature', () => { + const subject = policy(); + const auth = subject.reserveAuthorization({ + authorizationId: 'auth-1', requestUrl: URL, method: 'POST', bodyBytes: '{}', offer: offer(), receivedAtMs: 9_000, + }); + subject.claimSignature('auth-1', { offerFingerprint: auth.offerFingerprint }); + subject.beginRetry('auth-1', { + amountAtomic: auth.amountAtomic, offerFingerprint: auth.offerFingerprint, + }); + assert.throws( + () => subject.assertRetryOffer('auth-1', offer({ maxAmountRequired: '260000' })), + (error) => error.code === 'QUOTE_CHANGED', + ); +}); + +test('settled spend consumes budget while a pre-sign rejection releases it', () => { + const subject = policy(); + const first = subject.reserveAuthorization({ + authorizationId: 'auth-1', requestUrl: URL, method: 'POST', bodyBytes: '{}', offer: offer(), receivedAtMs: 9_000, + }); + subject.claimSignature('auth-1', { offerFingerprint: first.offerFingerprint }); + subject.beginRetry('auth-1', { amountAtomic: first.amountAtomic, offerFingerprint: first.offerFingerprint }); + subject.markSettled('auth-1', { txHash: `0x${'1'.repeat(64)}` }); + + const second = subject.reserveAuthorization({ + authorizationId: 'auth-2', requestUrl: URL, method: 'POST', bodyBytes: '{}', offer: offer({ maxAmountRequired: '100000' }), receivedAtMs: 9_000, + }); + subject.markRejected('auth-2', { reason: 'local signing failed before authorization' }); + assert.equal(subject.snapshot().spentAtomic, '250000'); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().remainingAtomic, '250000'); +}); + +test('post-sign rejection requires an injected trusted rejection proof', () => { + const subject = policy(); + const auth = subject.reserveAuthorization({ + authorizationId: 'auth-1', requestUrl: URL, method: 'POST', bodyBytes: '{}', offer: offer(), receivedAtMs: 9_000, + }); + subject.claimSignature('auth-1', { offerFingerprint: auth.offerFingerprint }); + subject.beginRetry('auth-1', { amountAtomic: auth.amountAtomic, offerFingerprint: auth.offerFingerprint }); + assert.throws( + () => subject.markRejected('auth-1', { reason: 'untrusted seller said no' }), + (error) => error.code === 'REJECTION_PROOF', + ); + assert.equal(subject.snapshot().reservedAtomic, '250000'); +}); + +test('unresolved spend remains reserved until trusted settlement reconciliation', () => { + const subject = policy(); + const auth = subject.reserveAuthorization({ + authorizationId: 'auth-1', requestUrl: URL, method: 'POST', bodyBytes: '{}', offer: offer(), receivedAtMs: 9_000, + }); + subject.claimSignature('auth-1', { offerFingerprint: auth.offerFingerprint }); + subject.beginRetry('auth-1', { amountAtomic: auth.amountAtomic, offerFingerprint: auth.offerFingerprint }); + subject.markUnresolved('auth-1', { reason: 'seller response lost' }); + assert.equal(subject.snapshot().reservedAtomic, '250000'); + subject.markSettled('auth-1', { txHash: `0x${'2'.repeat(64)}` }); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().spentAtomic, '250000'); +}); + +test('signature claim is atomic and an unsigned failure releases its reservation exactly once', () => { + const subject = policy(); + const auth = subject.reserveAuthorization({ + authorizationId: 'auth-claim', requestUrl: URL, method: 'POST', bodyBytes: '{}', offer: offer(), receivedAtMs: 9_000, + }); + assert.deepEqual(subject.claimSignature('auth-claim', { offerFingerprint: auth.offerFingerprint }).claimed, true); + assert.equal(subject.claimSignature('auth-claim', { offerFingerprint: auth.offerFingerprint }).claimed, false); + subject.releaseUnsigned('auth-claim', { reason: 'wallet declined before producing a signature' }); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().authorizations[0].state, 'released'); + assert.throws(() => subject.releaseUnsigned('auth-claim', { reason: 'double release' }), + (error) => error.code === 'UNSIGNED_RELEASE_STATE'); +}); +``` + +- [ ] **Step 2: Run the test and verify the policy module is missing** + +Run: `node --test spikes/pi-wielder/tests/payment-policy.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/payment-policy.mjs`. + +- [ ] **Step 3: Implement validation, reservation, and lifecycle state** + +Create `spikes/pi-wielder/src/payment-policy.mjs`: + +```js +import crypto from 'node:crypto'; + +export class PaymentPolicyError extends Error { + constructor(code, message) { + super(message); + this.name = 'PaymentPolicyError'; + this.code = code; + } +} + +const fail = (code, message) => { throw new PaymentPolicyError(code, message); }; +const copy = (value) => structuredClone(value); + +function atomic(value, label) { + const text = String(value ?? ''); + if (!/^(0|[1-9]\d*)$/.test(text)) fail('AMOUNT_FORMAT', `${label} must be a canonical atomic string`); + return { text, value: BigInt(text) }; +} + +function address(value, label) { + const text = String(value ?? ''); + if (!/^0x[0-9a-fA-F]{40}$/.test(text)) fail(`${label.toUpperCase()}_FORMAT`, `${label} must be a 20-byte hex address`); + return text.toLowerCase(); +} + +function request(value) { + try { + const url = new URL(value); + url.hash = ''; + return url; + } catch { + fail('RESOURCE_URL', `invalid request URL '${value}'`); + } +} + +export function canonicalRequestHash({ method, requestUrl, bodyBytes }) { + const verb = String(method ?? '').toUpperCase(); + if (!verb) fail('REQUEST_METHOD', 'request method must be non-empty'); + const target = request(requestUrl).href; + let body; + if (typeof bodyBytes === 'string') body = Buffer.from(bodyBytes, 'utf8'); + else if (bodyBytes instanceof Uint8Array) body = Buffer.from(bodyBytes); + else if (bodyBytes == null) body = Buffer.alloc(0); + else fail('REQUEST_BODY', 'request body must be a string, Uint8Array, or null'); + const prefix = Buffer.from(`${verb}\n${target}\n`, 'utf8'); + return `sha256:${crypto.createHash('sha256').update(Buffer.concat([prefix, body])).digest('hex')}`; +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); + } + return value; +} + +function fingerprint(offer) { + return `sha256:${crypto.createHash('sha256').update(JSON.stringify(canonicalize(offer))).digest('hex')}`; +} + +export function createPaymentPolicy({ + network, + asset, + sellers, + sessionBudgetAtomic, + maxQuoteAgeMs, + maxAuthorizationSeconds, + now = () => Date.now(), + verifyRejectionProof = () => false, +}) { + const expectedNetwork = String(network); + const expectedAsset = address(asset, 'asset'); + const budget = atomic(sessionBudgetAtomic, 'sessionBudgetAtomic').value; + if (!Number.isSafeInteger(maxQuoteAgeMs) || maxQuoteAgeMs < 0) fail('FRESHNESS_CONFIG', 'maxQuoteAgeMs must be a non-negative safe integer'); + if (!Number.isSafeInteger(maxAuthorizationSeconds) || maxAuthorizationSeconds <= 0) { + fail('TIMEOUT_CONFIG', 'maxAuthorizationSeconds must be a positive safe integer'); + } + const rules = (sellers ?? []).map((seller) => { + const origin = request(seller.origin).origin; + const pathPrefix = String(seller.pathPrefix ?? ''); + if (!pathPrefix.startsWith('/')) fail('SELLER_PATH', 'seller pathPrefix must start with /'); + return { + origin, + pathPrefix, + payTo: address(seller.payTo, 'payee'), + maxPerCallAtomic: atomic(seller.maxPerCallAtomic, 'maxPerCallAtomic').value, + }; + }).sort((left, right) => right.pathPrefix.length - left.pathPrefix.length); + if (!rules.length) fail('SELLER_CONFIG', 'at least one trusted seller rule is required'); + + const authorizations = new Map(); + let reservedAtomic = 0n; + let spentAtomic = 0n; + + function validateOffer({ requestUrl, method, bodyBytes, offer, receivedAtMs }) { + const target = request(requestUrl); + const seller = rules.find((rule) => rule.origin === target.origin && target.pathname.startsWith(rule.pathPrefix)); + if (!seller) fail('SELLER_UNTRUSTED', `no trusted seller rule covers ${target.origin}${target.pathname}`); + if (offer?.scheme !== 'exact') fail('SCHEME_UNSUPPORTED', "x402 scheme must be 'exact'"); + if (offer?.network !== expectedNetwork) fail('NETWORK_MISMATCH', `x402 network must be '${expectedNetwork}'`); + if (address(offer?.asset, 'asset') !== expectedAsset) fail('ASSET_MISMATCH', 'x402 asset does not match policy'); + if (address(offer?.payTo, 'payee') !== seller.payTo) fail('PAYEE_MISMATCH', 'x402 payee does not match trusted seller'); + if (request(offer?.resource).href !== target.href) fail('RESOURCE_MISMATCH', 'x402 resource does not match the requested URL'); + const localRequestHash = canonicalRequestHash({ method, requestUrl: target.href, bodyBytes }); + if (offer?.extra?.requestHash !== localRequestHash) { + fail('REQUEST_HASH_MISMATCH', 'x402 requestHash does not bind the outgoing method, URL, and body bytes'); + } + if (offer?.extra?.name !== 'USDC' || offer?.extra?.version !== '2') { + fail('EIP712_DOMAIN', 'x402 offer must use the canonical USDC EIP-712 name and version'); + } + if (!/^sha256:[0-9a-f]{64}$/.test(offer?.extra?.quoteId ?? '')) { + fail('QUOTE_ID', 'x402 offer must contain a valid immutable quoteId'); + } + const issuedAtMs = Date.parse(offer?.extra?.issuedAt ?? ''); + const expiresAtMs = Date.parse(offer?.extra?.expiresAt ?? ''); + if (!Number.isFinite(issuedAtMs) || !Number.isFinite(expiresAtMs) + || issuedAtMs > now() || now() - issuedAtMs > maxQuoteAgeMs + || expiresAtMs <= now() || issuedAtMs >= expiresAtMs) { + fail('QUOTE_EXPIRY', 'x402 server quote is not currently valid'); + } + + const amount = atomic(offer?.maxAmountRequired, 'maxAmountRequired'); + if (amount.value <= 0n) fail('AMOUNT_ZERO', 'x402 amount must be positive'); + if (amount.value > seller.maxPerCallAtomic) fail('PER_CALL_LIMIT', 'x402 amount exceeds the seller per-call cap'); + if (!Number.isSafeInteger(offer?.maxTimeoutSeconds) || offer.maxTimeoutSeconds <= 0 + || offer.maxTimeoutSeconds > maxAuthorizationSeconds) { + fail('TIMEOUT_LIMIT', 'x402 timeout exceeds policy'); + } + if (!Number.isFinite(receivedAtMs) || receivedAtMs > now() || now() - receivedAtMs > maxQuoteAgeMs) { + fail('FRESHNESS_EXPIRED', 'x402 quote is stale or has an invalid local receipt time'); + } + return { + amountAtomic: amount.text, + requestUrl: target.href, + requestHash: localRequestHash, + offerFingerprint: fingerprint(offer), + }; + } + + function reserveAuthorization({ authorizationId, requestUrl, method, bodyBytes, offer, receivedAtMs }) { + const id = String(authorizationId ?? '').trim(); + if (!id) fail('AUTHORIZATION_ID', 'authorizationId must be non-empty'); + const validated = validateOffer({ requestUrl, method, bodyBytes, offer, receivedAtMs }); + const existing = authorizations.get(id); + if (existing) { + if (existing.offerFingerprint !== validated.offerFingerprint || existing.requestUrl !== validated.requestUrl) { + fail('AUTHORIZATION_CONFLICT', 'authorizationId already binds a different offer'); + } + return copy(existing); + } + const amountValue = BigInt(validated.amountAtomic); + if (spentAtomic + reservedAtomic + amountValue > budget) fail('SESSION_BUDGET', 'x402 offer exceeds remaining session budget'); + const record = { + authorizationId: id, + ...validated, + state: 'reserved', + retryCount: 0, + txHash: null, + reason: null, + }; + authorizations.set(id, record); + reservedAtomic += amountValue; + return copy(record); + } + + function get(id) { + const record = authorizations.get(id); + if (!record) fail('AUTHORIZATION_UNKNOWN', `unknown authorization '${id}'`); + return record; + } + + function claimSignature(id, { offerFingerprint }) { + const record = get(id); + if (offerFingerprint !== record.offerFingerprint) fail('AUTHORIZATION_CONFLICT', 'signature claim does not match the reserved offer'); + if (record.state === 'reserved') { + record.state = 'signing'; + return { claimed: true, authorization: copy(record) }; + } + return { claimed: false, authorization: copy(record) }; + } + + function releaseUnsigned(id, { reason }) { + const record = get(id); + if (!['reserved', 'signing'].includes(record.state)) { + fail('UNSIGNED_RELEASE_STATE', `cannot release unsigned authorization from '${record.state}'`); + } + reservedAtomic -= BigInt(record.amountAtomic); + record.state = 'released'; + record.reason = String(reason); + return copy(record); + } + + function beginRetry(id, { amountAtomic, offerFingerprint }) { + const record = get(id); + if (record.retryCount !== 0 || record.state !== 'signing') fail('RETRY_LIMIT', 'authorization permits exactly one claimed signature and retry'); + if (String(amountAtomic) !== record.amountAtomic) fail('AMOUNT_DRIFT', 'retry amount differs from accepted quote'); + if (offerFingerprint !== record.offerFingerprint) fail('QUOTE_CHANGED', 'retry fingerprint differs from accepted quote'); + record.retryCount = 1; + record.state = 'retrying'; + return copy(record); + } + + function assertRetryOffer(id, secondOffer) { + const record = get(id); + if (fingerprint(secondOffer) !== record.offerFingerprint) fail('QUOTE_CHANGED', 'seller changed the offer after authorization'); + return copy(record); + } + + function markSettled(id, { txHash }) { + const record = get(id); + if (record.state === 'settled') { + if (record.txHash !== txHash) fail('SETTLEMENT_CONFLICT', 'authorization already binds a different transaction'); + return copy(record); + } + if (!['retrying', 'unresolved'].includes(record.state)) fail('SETTLEMENT_STATE', `cannot settle authorization from '${record.state}'`); + if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) fail('SETTLEMENT_HASH', 'txHash must be a 32-byte hex string'); + const value = BigInt(record.amountAtomic); + reservedAtomic -= value; + spentAtomic += value; + record.state = 'settled'; + record.txHash = txHash; + record.reason = null; + return copy(record); + } + + function markRejected(id, { reason, proof = null }) { + const record = get(id); + if (record.state === 'rejected') return copy(record); + if (!['reserved', 'retrying', 'unresolved'].includes(record.state)) fail('REJECTION_STATE', `cannot reject authorization from '${record.state}'`); + if (record.state !== 'reserved' && !verifyRejectionProof({ authorization: copy(record), proof })) { + fail('REJECTION_PROOF', 'post-sign rejection requires trusted facilitator or chain proof'); + } + reservedAtomic -= BigInt(record.amountAtomic); + record.state = 'rejected'; + record.reason = String(reason); + return copy(record); + } + + function markUnresolved(id, { reason }) { + const record = get(id); + if (record.state === 'unresolved') return copy(record); + if (record.state !== 'retrying') fail('UNRESOLVED_STATE', `cannot mark unresolved from '${record.state}'`); + record.state = 'unresolved'; + record.reason = String(reason); + return copy(record); + } + + function snapshot() { + return { + sessionBudgetAtomic: budget.toString(), + reservedAtomic: reservedAtomic.toString(), + spentAtomic: spentAtomic.toString(), + remainingAtomic: (budget - reservedAtomic - spentAtomic).toString(), + authorizations: [...authorizations.values()] + .sort((left, right) => left.authorizationId.localeCompare(right.authorizationId)) + .map(({ authorizationId, amountAtomic, state, retryCount, txHash, reason }) => ({ + authorizationId, amountAtomic, state, retryCount, txHash, reason, + })), + }; + } + + return Object.freeze({ + validateOffer, + reserveAuthorization, + claimSignature, + releaseUnsigned, + beginRetry, + assertRetryOffer, + markSettled, + markRejected, + markUnresolved, + snapshot, + }); +} +``` + +- [ ] **Step 4: Run the policy tests** + +Run: `node --test spikes/pi-wielder/tests/payment-policy.test.mjs` + +Expected: PASS, 21 tests and 0 failures. + +- [ ] **Step 5: Commit the pure policy** + +```bash +git add spikes/pi-wielder/src/payment-policy.mjs spikes/pi-wielder/tests/payment-policy.test.mjs +git commit -m "feat: enforce Wielder payment policy" +``` + +### Task 2: Put policy authorization before every signature + +**Files:** +- Modify: `spikes/pi-wielder/src/proxy.mjs:37-82` +- Create: `spikes/pi-wielder/tests/paying-fetch.test.mjs` + +- [ ] **Step 1: Write failing orchestration tests with a signature spy** + +Create `spikes/pi-wielder/tests/paying-fetch.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { canonicalRequestHash, createPaymentPolicy } from '../src/payment-policy.mjs'; +import { payingFetch } from '../src/proxy.mjs'; + +const ASSET = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; +const PAYEE = '0x000000000000000000000000000000000000dEaD'; +const URL = 'https://trusted.example/invoke/skill-a'; +const NOW = Date.UTC(2026, 6, 17, 12, 0, 10); +const REQUEST_HASH = canonicalRequestHash({ method: 'POST', requestUrl: URL, bodyBytes: '{}' }); +const baseOffer = (overrides = {}) => ({ + scheme: 'exact', network: 'base-sepolia', maxAmountRequired: '250000', resource: URL, + payTo: PAYEE, maxTimeoutSeconds: 60, asset: ASSET, + extra: { + name: 'USDC', + version: '2', + requestHash: REQUEST_HASH, + quoteId: `sha256:${'b'.repeat(64)}`, + issuedAt: new Date(NOW - 1_000).toISOString(), + expiresAt: new Date(NOW + 59_000).toISOString(), + }, + ...overrides, +}); +const challenge = (offer) => new Response(JSON.stringify({ x402Version: 1, accepts: [offer] }), { + status: 402, headers: { 'content-type': 'application/json' }, +}); +const paymentHeader = ({ txHash, payer, settlementReference, network = 'base-sepolia', success = true }) => Buffer.from(JSON.stringify({ + success, transaction: txHash, network, payer, settlementReference, +})).toString('base64'); + +function setup() { + let signatures = 0; + const account = { + address: '0x1000000000000000000000000000000000000000', + async signTypedData() { signatures += 1; return `0x${'1'.repeat(130)}`; }, + }; + const paymentPolicy = createPaymentPolicy({ + network: 'base-sepolia', asset: ASSET, sessionBudgetAtomic: '500000', + maxQuoteAgeMs: 5_000, maxAuthorizationSeconds: 60, + now: () => NOW, + sellers: [{ origin: 'https://trusted.example', pathPrefix: '/invoke/', payTo: PAYEE, maxPerCallAtomic: '300000' }], + }); + return { account, paymentPolicy, signatureCount: () => signatures }; +} + +test('a forbidden first offer is never signed or retried', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let fetches = 0; + const fetchImpl = async () => { fetches += 1; return challenge(baseOffer({ network: 'base' })); }; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: '{}' }, { + fetchImpl, idempotencyKey: 'idem-1', paymentPolicy, receivedAtMs: NOW - 500, + }), (error) => error.code === 'NETWORK_MISMATCH'); + assert.equal(fetches, 1); + assert.equal(signatureCount(), 0); +}); + +test('an offer for different request bytes is rejected with zero signatures', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + const mismatched = baseOffer(); + mismatched.extra = { ...mismatched.extra, requestHash: `sha256:${'f'.repeat(64)}` }; + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: '{}' }, { + fetchImpl: async () => { fetches += 1; return challenge(mismatched); }, + idempotencyKey: 'idem-request-hash', paymentPolicy, receivedAtMs: NOW - 500, + }), (error) => error.code === 'REQUEST_HASH_MISMATCH'); + assert.equal(fetches, 1); + assert.equal(signatureCount(), 0); +}); + +test('a changed second offer gets no second signature or third request', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + const responses = [challenge(baseOffer()), challenge(baseOffer({ maxAmountRequired: '260000' }))]; + let fetches = 0; + const fetchImpl = async () => responses[fetches++]; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: '{}' }, { + fetchImpl, idempotencyKey: 'idem-2', paymentPolicy, receivedAtMs: NOW - 500, + }), (error) => error.code === 'QUOTE_CHANGED'); + assert.equal(fetches, 2); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); +}); + +test('a settled HTTP 500 still consumes exactly the signed amount', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + const txHash = `0x${'2'.repeat(64)}`; + let fetches = 0; + const fetchImpl = async (_url, init) => { + fetches += 1; + if (fetches === 1) return challenge(baseOffer()); + const payment = JSON.parse(Buffer.from(init.headers['X-PAYMENT'], 'base64').toString('utf8')); + const authorization = payment.payload.authorization; + return new Response(JSON.stringify({ error: 'execution failed' }), { + status: 500, + headers: { + 'X-PAYMENT-RESPONSE': paymentHeader({ + txHash, + payer: authorization.from, + settlementReference: authorization.nonce, + }), + 'X-402-FACILITATOR-MS': '1.0', + }, + }); + }; + const result = await payingFetch(account, URL, { method: 'POST', body: '{}' }, { + fetchImpl, idempotencyKey: 'idem-3', paymentPolicy, receivedAtMs: NOW - 500, + }); + assert.equal(result.res.status, 500); + assert.equal(result.txHash, txHash); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().spentAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); +}); + +test('a malformed or mismatched settlement header remains unresolved', async () => { + const { account, paymentPolicy } = setup(); + let fetches = 0; + const fetchImpl = async () => { + fetches += 1; + if (fetches === 1) return challenge(baseOffer()); + return new Response('{}', { + status: 200, + headers: { 'X-PAYMENT-RESPONSE': Buffer.from('{"success":true}').toString('base64') }, + }); + }; + const result = await payingFetch(account, URL, { method: 'POST', body: '{}' }, { + fetchImpl, idempotencyKey: 'idem-4', paymentPolicy, receivedAtMs: NOW - 500, + }); + assert.equal(result.txHash, null); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); +}); + +test('concurrent and already-used copies of one idempotency key produce exactly one signature', async () => { + const { account, paymentPolicy } = setup(); + let releaseSignature; + let announceSignature; + const signatureStarted = new Promise((resolve) => { announceSignature = resolve; }); + const signatureGate = new Promise((resolve) => { releaseSignature = resolve; }); + account.signTypedData = async () => { + account.__signatures = (account.__signatures ?? 0) + 1; + announceSignature(); + await signatureGate; + return `0x${'1'.repeat(130)}`; + }; + let paidRetries = 0; + const fetchImpl = async (_url, init) => { + if (!init.headers?.['X-PAYMENT']) return challenge(baseOffer()); + paidRetries += 1; + const payment = JSON.parse(Buffer.from(init.headers['X-PAYMENT'], 'base64').toString('utf8')); + const authorization = payment.payload.authorization; + return new Response('{}', { status: 200, headers: { + 'X-PAYMENT-RESPONSE': paymentHeader({ + txHash: `0x${'3'.repeat(64)}`, + payer: authorization.from, + settlementReference: authorization.nonce, + }), + } }); + }; + const options = { fetchImpl, idempotencyKey: 'idem-atomic-claim', paymentPolicy, receivedAtMs: NOW - 500 }; + const first = payingFetch(account, URL, { method: 'POST', body: '{}' }, options); + await signatureStarted; + await assert.rejects( + () => payingFetch(account, URL, { method: 'POST', body: '{}' }, options), + (error) => error.code === 'AUTHORIZATION_ALREADY_USED', + ); + releaseSignature(); + assert.equal((await first).res.status, 200); + await assert.rejects( + () => payingFetch(account, URL, { method: 'POST', body: '{}' }, options), + (error) => error.code === 'AUTHORIZATION_ALREADY_USED', + ); + assert.equal(account.__signatures, 1); + assert.equal(paidRetries, 1); +}); +``` + +- [ ] **Step 2: Run the tests and verify policy is not consulted** + +Run: `node --test spikes/pi-wielder/tests/paying-fetch.test.mjs` + +Expected: FAIL because `payingFetch` does not accept or enforce `paymentPolicy`. + +- [ ] **Step 3: Validate and reserve before `signTypedData`** + +Add `paymentPolicy` to the options accepted by `payingFetch`: + +```js +export async function payingFetch(account, url, init, { + fetchImpl = fetch, + idempotencyKey = crypto.randomUUID(), + paymentPolicy, + receivedAtMs = Date.now(), +} = {}) { +``` + +Immediately after selecting `req`, replace the old scheme-only check with: + +```js + if (!req || firstBody.x402Version !== 1) throw new Error('402 without a usable x402 v1 payment offer'); + if (!paymentPolicy) throw new Error('paymentPolicy is required before signing an x402 offer'); + const authorizationRecord = paymentPolicy.reserveAuthorization({ + authorizationId: idempotencyKey, + requestUrl: url, + method: init.method ?? 'GET', + bodyBytes: init.body ?? null, + offer: req, + receivedAtMs, + }); + const signatureClaim = paymentPolicy.claimSignature(idempotencyKey, { + offerFingerprint: authorizationRecord.offerFingerprint, + }); + if (!signatureClaim.claimed) { + throw new PaymentPolicyError( + 'AUTHORIZATION_ALREADY_USED', + 'idempotency key already has a signature claim or terminal authorization', + ); + } +``` + +There is no `await` between reservation and `claimSignature`; the claim is the atomic +same-process boundary that makes concurrent copies of one idempotency key single-signer. + +To support that block, parse the challenge once as: + +```js + const firstBody = await first.json(); + const req = firstBody.accepts?.[0]; +``` + +- [ ] **Step 4: Reject signing failures and bind the one retry** + +When constructing the EIP-3009 authorization, replace `validBefore` with the frozen +server expiry bound: + +```js + validBefore: String(Math.min( + now + req.maxTimeoutSeconds, + Math.floor(Date.parse(req.extra.expiresAt) / 1_000), + )), +``` + +The policy has already validated that this expiry is current and that the EIP-712 +domain is exactly USDC v2. + +Wrap authorization construction and `account.signTypedData(...)` in one `try/catch`. +If anything fails before a signature is returned, call: + +```js + paymentPolicy.releaseUnsigned(idempotencyKey, { reason: `pre-sign failure: ${error.message}` }); + throw error; +``` + +Do not release after `signTypedData` resolves: from that point settlement may exist and +only `unresolved`, trusted rejection proof, or settlement can release/move the reserve. + +After the signature succeeds and before the retry fetch, add: + +```js + paymentPolicy.beginRetry(idempotencyKey, { + amountAtomic: authorization.value, + offerFingerprint: authorizationRecord.offerFingerprint, + }); +``` + +Wrap the retry fetch in `try/catch`. On a transport exception, call +`paymentPolicy.markUnresolved(idempotencyKey, { reason: error.message })`, then rethrow. + +- [ ] **Step 5: Forbid a second challenge and settle only from a receipt** + +Add this validator above `payingFetch`: + +```js +function validatePaymentResponse(header, { network, payer, settlementReference }) { + let receipt; + try { + receipt = unb64(header); + } catch { + throw new PaymentPolicyError('SETTLEMENT_RECEIPT', 'X-PAYMENT-RESPONSE is not valid base64 JSON'); + } + if (receipt?.success !== true + || receipt.network !== network + || receipt.payer?.toLowerCase() !== payer.toLowerCase() + || receipt.settlementReference !== settlementReference + || !/^0x[0-9a-fA-F]{64}$/.test(receipt.transaction ?? '')) { + throw new PaymentPolicyError('SETTLEMENT_RECEIPT', 'X-PAYMENT-RESPONSE does not match the signed authorization'); + } + return receipt; +} +``` + +Immediately after the retry response, add: + +```js + if (res.status === 402) { + const secondBody = await res.clone().json().catch(() => ({})); + try { + paymentPolicy.assertRetryOffer(idempotencyKey, secondBody.accepts?.[0] ?? {}); + throw new PaymentPolicyError('SECOND_PAYMENT_REQUIRED', 'seller requested a second payment after the one permitted retry'); + } catch (error) { + paymentPolicy.markUnresolved(idempotencyKey, { reason: error.message }); + throw error; + } + } + const paymentResponse = res.headers.get('X-PAYMENT-RESPONSE'); + let settlement = null; + try { + if (!paymentResponse) throw new PaymentPolicyError('SETTLEMENT_RECEIPT', 'retry returned without X-PAYMENT-RESPONSE'); + settlement = validatePaymentResponse(paymentResponse, { + network: req.network, + payer: account.address, + settlementReference: authorization.nonce, + }); + paymentPolicy.markSettled(idempotencyKey, { txHash: settlement.transaction }); + } catch (error) { + paymentPolicy.markUnresolved(idempotencyKey, { reason: error.message }); + } +``` + +Add this import at the top of `proxy.mjs`: + +```js +import { PaymentPolicyError } from './payment-policy.mjs'; +``` + +Use the already parsed `settlement` in the return block; do not decode the header a +second time. + +- [ ] **Step 6: Run orchestration and pure-policy tests** + +Run: `node --test spikes/pi-wielder/tests/payment-policy.test.mjs spikes/pi-wielder/tests/paying-fetch.test.mjs` + +Expected: PASS, 27 tests and 0 failures. + +- [ ] **Step 7: Commit policy-gated signing** + +```bash +git add spikes/pi-wielder/src/proxy.mjs spikes/pi-wielder/tests/paying-fetch.test.mjs +git commit -m "feat: gate x402 signatures with local policy" +``` + +### Task 3: Configure trusted testnet sellers and assert total session spend + +**Files:** +- Modify: `spikes/pi-wielder/src/proxy.mjs:84-151` +- Modify: `spikes/pi-wielder/e2e.mjs:38-154` +- Modify: `spikes/pi-wielder/.env.example` + +- [ ] **Step 1: Add a default testnet policy factory** + +Add these imports in `spikes/pi-wielder/src/proxy.mjs`: + +```js +import { parseUsdc } from '../../../prototype/atomic-money.mjs'; +import { createPaymentPolicy } from './payment-policy.mjs'; +import { NETWORK, USDC_ADDRESS } from './x402-seller.mjs'; +``` + +Add this exported factory above `createProxy`: + +```js +export function createDefaultPaymentPolicy({ gatewayUrl, collarUrl, env = process.env }) { + const payTo = env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dEaD'; + return createPaymentPolicy({ + network: NETWORK, + asset: USDC_ADDRESS, + sessionBudgetAtomic: parseUsdc(env.WIELDER_SESSION_BUDGET_USDC || '1.00').toString(), + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + sellers: [ + { + origin: new URL(gatewayUrl).origin, + pathPrefix: '/v1/', + payTo, + maxPerCallAtomic: parseUsdc(env.WIELDER_MODEL_MAX_USDC || '0.10').toString(), + }, + { + origin: new URL(collarUrl).origin, + pathPrefix: '/invoke/', + payTo, + maxPerCallAtomic: parseUsdc(env.WIELDER_SKILL_MAX_USDC || '0.50').toString(), + }, + ], + }); +} +``` + +- [ ] **Step 2: Refactor `createProxy` to inject one policy** + +Replace the `createProxy` signature and initial declarations with: + +```js +export function createProxy(options = {}) { + const account = options.account ?? loadAccount(); + const gatewayUrl = options.gatewayUrl ?? process.env.GATEWAY_URL ?? 'http://127.0.0.1:8403'; + const collarUrl = options.collarUrl ?? process.env.COLLAR_URL ?? 'http://127.0.0.1:8404'; + const ledgerFile = options.ledgerFile ?? process.env.LEDGER_FILE ?? null; + const trustedCollarPublicKeyPem = options.trustedCollarPublicKeyPem ?? null; + const trustedCollarKeyId = options.trustedCollarKeyId ?? null; + const paymentPolicy = options.paymentPolicy ?? createDefaultPaymentPolicy({ gatewayUrl, collarUrl }); +``` + +Pass `paymentPolicy` into every `payingFetch` call: + +```js + }, { paymentPolicy }); +``` + +Return it with the existing app dependencies: + +```js + return { app, ledger, account, paymentPolicy }; +``` + +Update `startProxy` to destructure and return `paymentPolicy` alongside `ledger` and +`account`. + +- [ ] **Step 3: Assert the e2e session budget and authorization count** + +After fetching the three Wielder receipt entries in `spikes/pi-wielder/e2e.mjs`, add: + +```js + const policySnapshot = proxy.paymentPolicy.snapshot(); + eq(policySnapshot.spentAtomic, usdcToAtomic('0.378'), 'policy records exact session spend'); + eq(policySnapshot.reservedAtomic, '0', 'no successful authorization remains reserved'); + eq(policySnapshot.authorizations.length, 3, 'one authorization exists per paid call'); + ok(policySnapshot.authorizations.every((authorization) => authorization.retryCount === 1), 'every authorization retried exactly once'); + ok(policySnapshot.authorizations.every((authorization) => authorization.state === 'settled'), 'every e2e authorization settled'); +``` + +- [ ] **Step 4: Document explicit testnet policy variables** + +Insert only these `WIELDER_*` controls after the existing `PAY_TO_ADDRESS=` line in +`spikes/pi-wielder/.env.example`; do not add a second payee variable or a tracked address: + +```dotenv +# Wielder-side payment policy. Base Sepolia only; never point this spike at mainnet. +WIELDER_SESSION_BUDGET_USDC=1.00 +WIELDER_MODEL_MAX_USDC=0.10 +WIELDER_SKILL_MAX_USDC=0.50 +``` + +Do not add `PRIVATE_KEY` values or copy the ignored local `.env`. + +- [ ] **Step 5: Run the complete offline proof** + +Run: `npm test --prefix spikes/pi-wielder && npm run e2e --prefix spikes/pi-wielder` + +Expected: all focused tests PASS; e2e reports exact spend `378000` atomic USDC, +three settled authorizations, one retry each, and no live network or funded wallet. + +- [ ] **Step 6: Commit default testnet policy wiring** + +```bash +git add spikes/pi-wielder/src/proxy.mjs spikes/pi-wielder/e2e.mjs spikes/pi-wielder/.env.example +git commit -m "feat: configure testnet Wielder spending limits" +``` + +### Task 4: Verify every required rejection independently + +**Files:** +- Modify: `spikes/pi-wielder/README.md` + +- [ ] **Step 1: Document the payment-policy boundary** + +Add this section to `spikes/pi-wielder/README.md`: + +```markdown +## Wielder payment policy + +The Wielder does not accept the first x402 offer blindly. Before signing, it requires +the configured Base Sepolia network and USDC contract, an exact trusted seller route +and payee, an exact resource match, a fresh bounded-time quote, a per-call cap, and +remaining session budget. Budget is reserved before signing. One authorization permits +one retry; a changed second offer aborts without another signature and leaves the +already-signed authorization `unresolved` with budget reserved for reconciliation. + +An unresolved payment keeps its budget reservation until trusted reconciliation. This +is intentionally conservative: an unknown settlement is never treated as free and +never silently retried. + +This spike's Wielder policy is an in-memory, one-process session control. Restarting +the proxy loses its policy snapshot, so it is not production spend enforcement. A +durable deployment must persist/replay authorizations and reconcile unresolved state +before it can advertise cross-restart budget guarantees. +``` + +- [ ] **Step 2: Run the named policy suite** + +Run: `node --test --test-reporter=spec spikes/pi-wielder/tests/payment-policy.test.mjs` + +Expected: named PASS cases for scheme, network, asset, payee, resource, seller, +zero amount, per-call limit, freshness, timeout, concurrent session budget, immutable +amount, one retry, changed offer, settled/rejected accounting, and unresolved accounting. + +- [ ] **Step 3: Prove forbidden offers cause zero signatures** + +Run: `node --test --test-name-pattern="forbidden first offer|different request bytes|changed second offer" spikes/pi-wielder/tests/paying-fetch.test.mjs` + +Expected: PASS; assertions report zero signatures for the forbidden first offer and +one total signature/two total HTTP requests for the changed second offer. + +- [ ] **Step 4: Confirm testnet-only constants and no key leakage** + +Run: `rg -n "base-sepolia|84532|WIELDER_.*_USDC" spikes/pi-wielder/src spikes/pi-wielder/.env.example` + +Expected: policy and x402 code refer to Base Sepolia/test limits; no mainnet network is configured. + +Run: `git diff --check && ! git ls-files | rg '(^|/)\.env$'` + +Expected: `git diff --check` exits 0 and no tracked `.env` path is printed. + +- [ ] **Step 5: Commit policy documentation** + +```bash +git add spikes/pi-wielder/README.md +git commit -m "docs: explain Wielder payment policy" +``` + +## Definition of done + +- No x402 signature occurs before all network, asset, seller, payee, resource, amount, freshness, timeout, and budget checks pass. +- Session budget includes both settled and unresolved/reserved authorizations, preventing concurrent overspend. +- The EIP-3009 amount equals the accepted offer exactly. +- One authorization produces at most one paid retry; a second 402 never produces a second signature. +- Changed retry offers abort, the accepted quote fingerprint remains immutable, and a + post-sign mismatch stays `unresolved` with its budget reservation intact. +- Settled HTTP failures count as spend; missing settlement evidence remains unresolved rather than zero-cost. +- The evidence proves one-process session enforcement only. No cross-restart or production spending-control claim is made until policy state is durably replayed and reconciled. +- All tests are mock/offline and all defaults are Base Sepolia. No mainnet transaction or real funding is permitted. From bf5a0db6545c66f61575639a6d42565df7d83435 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 21:53:28 -0400 Subject: [PATCH 028/165] test: guard quarantined launch claims --- scripts/marketing-claims.mjs | 57 +++++++++++++++++++++++++ scripts/tests/marketing-claims.test.mjs | 34 +++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 scripts/marketing-claims.mjs create mode 100644 scripts/tests/marketing-claims.test.mjs diff --git a/scripts/marketing-claims.mjs b/scripts/marketing-claims.mjs new file mode 100644 index 0000000..3c86ec7 --- /dev/null +++ b/scripts/marketing-claims.mjs @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const CLAIM_RULES = [ + { id: 'clone-paid-158', pattern: /(?:paid \$1\.58|\$1\.58 (?:bought|total)|six paid runs)/i }, + { id: 'invalid-clone-conclusion', pattern: /clone failed[\s\S]{0,80}(?:six|6)[\s\S]{0,40}fidelity|clone failed all (?:six|6)/i }, + { id: 'latency-unreproducible', pattern: /p50\s+731\s*ms|p95\s+1206\s*ms|n=48 settled calls/i }, + { id: 'absolute-extraction', pattern: /never (?:get|returns?|leaves|crosses)[\s\S]{0,60}\bskill\b|never (?:the )?skill|\bskill\b[\s\S]{0,40}never (?:leaves|crosses)/i }, + { id: 'txhash-as-retry-credential', pattern: /settlement txHash IS the credential|retries? .*carrying (?:it|the txHash)/i }, + { id: 'wielder-is-server', pattern: /server side.*proxy we call the Wielder|Wielder: enforce 402/i }, + { id: 'split-reconciled-onchain', pattern: /creator .*treasury.*reconciled on-chain|split.*reconciled on-chain/i }, +]; + +export function auditText(file, text) { + return CLAIM_RULES.flatMap(({ id, pattern }) => { + const match = text.match(pattern); + if (!match) return []; + const line = text.slice(0, match.index).split('\n').length; + return [{ file, line, rule: id, excerpt: match[0] }]; + }); +} + +export function auditFiles(repoRoot, relativePaths) { + return relativePaths.flatMap((file) => + auditText(file, fs.readFileSync(path.join(repoRoot, file), 'utf8')), + ); +} + +export function readHistoricalTombstone(repoRoot) { + const file = path.join( + repoRoot, + 'spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json', + ); + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +const invokedAsScript = process.argv[1] + && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedAsScript) { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const files = [ + 'docs/marketing/linkedin.md', + 'docs/marketing/x.md', + 'docs/marketing/hn-and-demo.md', + 'docs/marketing/2026-07-13-campaign-plan.md', + ]; + const findings = auditFiles(repoRoot, files); + if (findings.length > 0) { + for (const item of findings) { + console.error(`${item.file}:${item.line} [${item.rule}] ${item.excerpt}`); + } + process.exitCode = 1; + } else { + console.log(`PASS — ${files.length} publication drafts satisfy claim quarantine.`); + } +} diff --git a/scripts/tests/marketing-claims.test.mjs b/scripts/tests/marketing-claims.test.mjs new file mode 100644 index 0000000..084717f --- /dev/null +++ b/scripts/tests/marketing-claims.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { auditFiles, readHistoricalTombstone } from '../marketing-claims.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const publicationFiles = [ + 'docs/marketing/linkedin.md', + 'docs/marketing/x.md', + 'docs/marketing/hn-and-demo.md', + 'docs/marketing/2026-07-13-campaign-plan.md', +]; + +test('tracked publication drafts contain no quarantined claims', () => { + assert.deepEqual(auditFiles(repoRoot, publicationFiles), []); +}); + +test('the pi overhead tombstone is historical, unreproducible, and sample-free', () => { + const manifest = readHistoricalTombstone(repoRoot); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.experimentId, '2026-07-15-overhead'); + assert.equal(manifest.evidenceStatus, 'historical_unreproducible'); + assert.equal(manifest.publication.allowed, false); + assert.equal(manifest.rawEvidence.normalizedSamplesCommitted, false); + assert.equal(manifest.rawEvidence.recomputableFromCleanCheckout, false); + assert.equal('samples' in manifest, false); + assert.equal( + fs.existsSync(path.join(repoRoot, 'spikes/pi-wielder/evidence/2026-07-15-overhead/samples.jsonl')), + false, + ); +}); From 610658f9b4eba240c4e2d72f39936b8e22b18301 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 21:54:30 -0400 Subject: [PATCH 029/165] docs: quarantine unreproducible overhead summary --- .../2026-07-15-launch-week-handoff.md | 9 +++--- spikes/pi-wielder/README.md | 23 +++++++------ .../2026-07-15-overhead/manifest.json | 32 +++++++++++++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json diff --git a/docs/handoffs/2026-07-15-launch-week-handoff.md b/docs/handoffs/2026-07-15-launch-week-handoff.md index 3e99e43..f903fda 100644 --- a/docs/handoffs/2026-07-15-launch-week-handoff.md +++ b/docs/handoffs/2026-07-15-launch-week-handoff.md @@ -31,10 +31,11 @@ executed and committed by 2026-07-12; see `docs/plans/2026-07-12-phase-a-finding ## Next tasks (agent-doable, in order) -1. ~~x402 overhead distribution~~ **Done 2026-07-15:** p50 731 ms / p95 - 1206 ms (n=48 settled, both legs); gateway `max_completion_tokens` fix; - pay-then-fail + settled-but-rejected failure modes recorded in - `spikes/pi-wielder/README.md`; PRD updated. +1. **Pi-Wielder follow-up recorded, distribution quarantined** — the historical + 2026-07-15 aggregate did not retain normalized per-call samples, so its n/p50/p95 + are not publishable. See + `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. A replacement + testnet run is human-authorized work and must use a new dated evidence bundle. 2. **High-N clone-economics run** (`spikes/clone-economics/`) — required before any public copy leans on the N=6 result (LinkedIn Post 2, X clone-attack thread). diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index f0c2111..c9da754 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -163,16 +163,19 @@ skill executed behind the Collar with output-only response; splits credited per the settlement engine — the protocol's Phase-1 Leg-1 loop, end to end, for $0.33 of play money. -## Measured results — overhead distribution + live pi session (2026-07-15) - -**x402 payment overhead, n=48 settled calls** (29 claude + 19 gpt, real -`x402.org/facilitator`, Base Sepolia): **p50 731 ms · p95 1206 ms** (mean -830, min 487, max 1859). Decomposition: facilitator verify+settle p50 729 ms -(the whole story); 402-roundtrip p50 1.2 ms; EIP-3009 sign p50 0.9 ms. -End-to-end paid roundtrip including inference (green calls): claude p50 -2.15 s / p95 3.94 s; gpt p50 1.47 s / p95 3.30 s. Wallet reconciled -on-chain to the cent: 19.299 → 16.129 USDC = one pi session ($0.287) + -29×$0.041 + 19×$0.087 + one settled-but-rejected call ($0.041). +## Historical overhead summary — quarantined (2026-07-15) + +The 2026-07-15 run was previously summarized as 48 settled calls across two +providers. Its per-call normalized samples and evidence hashes were not retained, +so a clean checkout cannot recompute the reported distribution. The historical +aggregate is preserved in +`evidence/2026-07-15-overhead/manifest.json` with +`evidenceStatus: historical_unreproducible`. + +**Publication status:** do not cite the historical sample count, p50, or p95 in +public copy. A future authorized testnet run must use a new dated evidence +directory and must never overwrite the tombstone. No rerun is performed by this +documentation change. **The gpt leg ran for the first time** (skipped 2026-07-12 for lack of a key) — after fixing a real gateway bug the bench surfaced: newer OpenAI diff --git a/spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json b/spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json new file mode 100644 index 0000000..889dd02 --- /dev/null +++ b/spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "experimentId": "2026-07-15-overhead", + "observedAt": "2026-07-15", + "evidenceStatus": "historical_unreproducible", + "evidenceLabel": "HISTORICAL SUMMARY ONLY — normalized samples were not retained", + "source": { + "repositoryPath": "spikes/pi-wielder/README.md", + "runtime": "Base Sepolia testnet", + "funding": "testnet USDC play money", + "providerCountReported": 2 + }, + "historicalSummary": { + "settledCallCountReported": 48, + "paymentOverheadP50MsReported": 731, + "paymentOverheadP95MsReported": 1206 + }, + "rawEvidence": { + "normalizedSamplesCommitted": false, + "recomputableFromCleanCheckout": false, + "reason": "The repository retained only aggregate prose; per-call normalized timing rows and a hashed evidence manifest were not committed." + }, + "publication": { + "allowed": false, + "reason": "Do not use the reported p50, p95, or n=48 in launch copy. A new authorized run must write a new dated evidence directory before publication." + }, + "replacementPolicy": { + "overwriteThisDirectory": false, + "newRunDirectoryPattern": "spikes/pi-wielder/evidence/YYYY-MM-DD-overhead-RUN_ID", + "requiredFiles": ["manifest.json", "samples.jsonl", "summary.json", "report.md", "README.md"] + } +} From 73234f9db0f3fb11e2bdca1abff377624f62c03a Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 21:59:23 -0400 Subject: [PATCH 030/165] docs: quarantine invalid clone campaign claims --- docs/marketing/2026-07-13-campaign-plan.md | 22 +++--- docs/marketing/hn-and-demo.md | 39 +++++----- docs/marketing/linkedin.md | 48 +++++++------ docs/marketing/x.md | 83 +++++++--------------- 4 files changed, 86 insertions(+), 106 deletions(-) diff --git a/docs/marketing/2026-07-13-campaign-plan.md b/docs/marketing/2026-07-13-campaign-plan.md index 76b7e4d..411a488 100644 --- a/docs/marketing/2026-07-13-campaign-plan.md +++ b/docs/marketing/2026-07-13-campaign-plan.md @@ -33,7 +33,7 @@ they only quote names they've seen show up usefully. **Show HN is the neutral-ground, high-leverage event — deliberately not Day 0.** HN is where the claims get stress-tested by strangers with no reason to be kind, and where -the honesty ledger (kill-criteria, "What we have NOT validated", the failed clone attack) is +the honesty ledger (kill-criteria, "What we have NOT validated", the invalid clone benchmark) is worth the most. We go there only after the demo is battle-tested: a week of live paid invocations, the fresh-machine clone-and-run verified again the night before, and the five prepared answers (`hn-and-demo.md` §1) open in a tab. HN lands Day 9. @@ -74,18 +74,22 @@ verified live, LinkedIn Post 1 out — no X thread, no HN. > > **Acknowledged kit overrides:** (a) x.md §4's "week 1 = reply only, post nothing > original" is compressed — Post 1 already broke broadcast silence on 07-13, so the -> artifact cadence starts today; (b) Post 2's context prefers mid-week *morning* — -> afternoon-after-the-flip beats morning-before-the-flip, so it ships this afternoon; +> artifact cadence starts today; (b) Post 2's historical context preferred mid-week +> *morning*, but that scheduled action is now blocked by the evidence override below; > (c) the launch thread's reply history is 8 calendar days / 6 active reply days > (weekends rest) — start the reply routine today without fail. +> **2026-07-17 evidence override:** all clone-economics publication steps below +> are historical schedule entries and are blocked. A calendar date never +> overrides an evidence gate. + ### Revamped calendar (Day 2 = Wed 07-15 → HN) | Day | Date | Actions | |---|---|---| | 0 | Mon 07-13 | *(done)* LinkedIn Post 1 out, pinned. Repo flip did NOT happen — slip recorded. | | 1 | Tue 07-14 | *(done)* Silent. x402 Foundation launches under the Linux Foundation — this week's reply-routine entry point. | -| **2** | **Wed 07-15 (today)** | ① **Repo public FIRST** (pre-flight passed; verify from a logged-out browser after the flip). ② Reply to any Post-1 comments that hit the 404 — factual correction in a reply, never silent. ③ **LinkedIn Post 2** (clone story) this afternoon — 2 days after Post 1; repo link in first comment now resolves. ④ **X artifact #1**: the raw 402 from the LIVE endpoint (`docs/marketing/artifacts/raw-402-response-live.txt` — production URL + "output only, never the skill" in the payload); tweet text below. ⑤ Reply routine starts (x402-Foundation news as the entry; measured numbers, never a pitch). ⑥ Metrics table started (`hn-and-demo.md` §3 daily log). | +| **2** | **Wed 07-15 (today)** | ① **Repo public FIRST** (pre-flight passed; verify from a logged-out browser after the flip). ② Reply to any Post-1 comments that hit the 404 — factual correction in a reply, never silent. ③ **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. ④ **X artifact #1**: the raw 402 from the LIVE endpoint (`docs/marketing/artifacts/raw-402-response-live.txt` — production URL + "output only, never the skill" in the payload); tweet text below. ⑤ Reply routine starts (x402-Foundation news as the entry; measured numbers, never a pitch). ⑥ Metrics table started (`hn-and-demo.md` §3 daily log). | | 3 | Thu 07-16 | X artifact #2: the ledger line reconciled on-chain to the cent (testnet, play money). Replies. | | 4 | Fri 07-17 | X artifact #3: "What we have NOT validated" page, screenshotted. Light day. | | 5–6 | Sat–Sun 07-18/19 | **Rest days** (Sun: optional 15 min of replies if a good conversation is live). | @@ -98,7 +102,7 @@ verified live, LinkedIn Post 1 out — no X thread, no HN. | 14 | Mon 07-27 | X spacer: ~150-line Wielder proxy screenshot. Evening: **HN pre-flight** (re-run fresh-machine test, live 402 + paid invocation, five prepared answers open). | | 15 | **Tue 07-28** | **LinkedIn Post 5** ~8:00am ET, cross-linked from the repo README. **Show HN** 8:30–10:00am ET (title 1, §1 text as first comment, runbook cadence). **X how-it-works thread** mid-morning (≥48h after launch thread ✓). *(Post 5's context prefers Thursday; staying paired with HN day matters more — if HN slips, both move to Thu 07-30.)* | | 16 | Wed 07-29 | HN aftercare (hourly sweeps; log unanswerable critiques as corpus defects). X spacer: settled-but-rejected reconciliation — the honesty artifact while HN eyes are on the account. | -| 17 | Thu 07-30 | **X clone-attack thread** (≥48h after how-it-works ✓; may reference HN). **LinkedIn Post 4** (kill-criteria). **Day-7-after-launch-thread review**: count conversations that could become a design-partner LOI — the one derived number that matters. | +| 17 | Thu 07-30 | **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. **LinkedIn Post 4** (kill-criteria). **Day-7-after-launch-thread review**: count conversations that could become a design-partner LOI — the one derived number that matters. | Constraint check: repo flip before anything that links to it ✓ · ≥48h between threads (07-23 / 07-28 / 07-30) ✓ · launch thread + HN on Tue/Thu ✓ · weekends rest ✓ · demo clip after a week of live traffic ✓. @@ -123,7 +127,7 @@ Constraint check: repo flip before anything that links to it ✓ · ≥48h betwe | **−1** | Mon 07-13 (today) | Pre-flight checklist from `hn-and-demo.md` §3: LICENSE, README offline-e2e story, secrets scan, fresh-machine clone-and-run with zero keys/funds, live 402 check, both domains serving, compliance pass on every queued post. If the fresh-machine test fails, Day 0 slips — nothing else changes. | | **0** | Tue 07-14 | **Repo public first** (runbook step 1), verify clone-and-run from a logged-out browser. **LinkedIn Post 1** ("Never handed over", `linkedin.md`) at ~8:30am ET, link in first comment, pin to profile. X: replies only. Log day-0 measurements. | | **1** | Wed 07-15 | X build-in-public artifact #1: screenshot of the raw HTTP 402 response (`x.md` §4, week-2 list). Single tweet, no thread. | -| **2** | Thu 07-16 | **LinkedIn Post 2** (clone story, "$1.58 to steal my own product") — 2 days after Post 1, mid-week per its posting context. X artifact #2: the ledger line, reconciled on-chain to the cent (testnet, play money). | +| **2** | Thu 07-16 | **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. X artifact #2: the ledger line, reconciled on-chain to the cent (testnet, play money). | | **3** | Fri 07-17 | X artifact #3: the "What we have NOT validated" page, screenshotted. Light day otherwise. | | **4** | Sat 07-18 | **Rest day.** Nothing posted anywhere. No replies. | | **5** | Sun 07-19 | **Rest day** (light): optional 15 min of X replies if a good conversation is live; otherwise nothing. | @@ -134,7 +138,7 @@ Constraint check: repo flip before anything that links to it ✓ · ≥48h betwe | **10** | Fri 07-24 | HN aftercare: hourly sweeps while the thread is warm; log any critique we couldn't answer as a corpus defect. X spacer: one standalone from `x.md` §5 (suggest D — "Output crosses the wire. The skill never does."). | | **11** | Sat 07-25 | **Rest day.** Nothing posted. | | **12** | Sun 07-26 | **Rest day** (light): optional HN/X reply sweep only if threads are still live. | -| **13** | Mon 07-27 | **X clone-attack thread** (`x.md` §2) — pushed past the weekend to keep the ≥48h thread spacing; it can reference the HN discussion if cloning came up there. **LinkedIn Post 4** (kill-criteria / honesty post) — its context says week 2–3 mid-week and it's the let-it-sit-and-compound post, so sliding it to Wed 07-29 is equally fine. **Day-7-after-launch review:** count conversations that could become a design-partner LOI — the one derived number that matters (`hn-and-demo.md` §3). Remaining `x.md` §5 spacers (A, B, C, E, F) feed week 3+ as-needed. | +| **13** | Mon 07-27 | **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. **LinkedIn Post 4** (kill-criteria / honesty post) — its context says week 2–3 mid-week and it's the let-it-sit-and-compound post, so sliding it to Wed 07-29 is equally fine. **Day-7-after-launch review:** count conversations that could become a design-partner LOI — the one derived number that matters (`hn-and-demo.md` §3). Remaining `x.md` §5 spacers (A, B, C, E, F) feed week 3+ as-needed. | Reconciliation note: `hn-and-demo.md` §3 sequences repo → LinkedIn → X thread → HN as one list. This plan keeps that ordering but stretches it across Days 0–9, per the inverted @@ -153,8 +157,8 @@ factual error in a live post — correct it in a reply immediately, never silent **If HN turns hostile: engage the top critique honestly, never defensively.** Find the highest-voted critical comment and answer it first, conceding whatever is valid in the first -sentence — the corpus was built for this (kill-criteria, the not-validated ledger, the failed -clone attack). Use the five prepared answers in `hn-and-demo.md` §1 as the base; for the +sentence — the corpus was built for this (kill-criteria, the not-validated ledger, the invalid +clone benchmark). Use the five prepared answers in `hn-and-demo.md` §1 as the base; for the known secondary flak (GPT Store, x402-volume-is-bots, "Anthropic will ship this"), use the one-line stances at the end of that section. Reply to content, never to tone; do not chase every commenter; never ask for votes. If the thread dies anyway, the end-of-day job stands: diff --git a/docs/marketing/hn-and-demo.md b/docs/marketing/hn-and-demo.md index 92dea1c..585a67b 100644 --- a/docs/marketing/hn-and-demo.md +++ b/docs/marketing/hn-and-demo.md @@ -10,15 +10,22 @@ language anywhere.* ## 1. Show HN draft +> **PUBLICATION BLOCKED — INVALID BENCHMARK.** The 2026-07-12 target scored +> 0.400 and failed its own critical gates, so clone-quality, fidelity-defense, and +> break-even conclusions are suppressed. Acquisition was modeled at $1.50; no +> x402 acquisition payments settled. Unblock only after +> `spikes/clone-economics` produces a valid N=100 result with committed normalized +> evidence and three live-adapter-confirmed independent distillation seeds. + ### Title options (pick one; all under 80 chars) 1. `Show HN: A manifesto that is also a paid API endpoint (HTTP 402)` -2. `Show HN: We paid $1.58 to clone our own AI skill. It failed all 6 gates` +2. `Show HN: An invalid clone benchmark and the gate we added after it` 3. `Show HN: Metering AI skills per invocation instead of handing them over` Recommendation: title 1. It describes the artifact, not the thesis, and the artifact is the -novel thing. Title 2 is the fallback if a second submission is ever warranted — it leads with -the result that is most likely to survive HN scrutiny, because it is us attacking ourselves. +novel thing. Title 2 remains blocked with the clone-economics copy until the evidence gate +above is satisfied and a human approves revised copy. ### Post text (submit as a text post with the URL, or as first comment — ~230 words) @@ -38,10 +45,10 @@ the result that is most likely to survive HN scrutiny, because it is us attackin > p50 731ms / p95 1206ms per call (n=48 settled calls, both model providers); > hosted-agent cold start ~2.5s to first token. > -> What we tried to break: we paid $1.58 to distill a clone of our own skill from its -> outputs (distillation itself cost $0.03). The clone failed all 6 held-out fidelity gates — -> but N=6, high-N behavior unknown. Modeled break-even if a clone ever passes: 8 invocations. -> Cost protects nothing. +> The historical N=6 run used a modeled $1.50 acquisition cost and measured about +> $0.03 of distillation-provider cost; no acquisition payment settled. Its target +> failed the benchmark, so clone quality, fidelity defense, and break-even are +> unknown. Publication remains blocked pending a valid preregistered N=100 run. > > We also documented two live failure modes: 10 calls that settled then 500'd ($0.87 paid, > no refund path in x402 v1 — our bug, published), and 1 of 50 that settled on-chain yet @@ -53,9 +60,9 @@ the result that is most likely to survive HN scrutiny, because it is us attackin > Code (Apache-2.0): https://github.com/Aznatkoiny/skill-asset-protocol — the end-to-end > demo runs offline with zero API keys and zero funds. -Notes on register: no adjectives doing sales work, every claim has a number, and the two -weakest points (N=6, no demand evidence) are volunteered before anyone finds them. On HN -the honesty ledger IS the pitch. +Notes on register: no adjectives doing sales work, every claim has a number, and the invalid +clone benchmark plus lack of demand evidence are volunteered before anyone finds them. On +HN the honesty ledger IS the pitch. ### First-hour comment strategy: the 5 hardest questions, with prepared answers @@ -103,14 +110,10 @@ Post these as replies, verbatim or trimmed. Never argue tone; concede fast and l **Q4. "Anyone can distill your skill from its own outputs for pennies. Your economics are dead."** -> We ran exactly that attack on ourselves before launch and published it. Total cost $1.58, -> and the distillation step itself was $0.03; modeled break-even is 8 invocations if a clone -> ever passes. So yes: cost protects nothing, and we say so in those words. What we observed -> is that the clone failed all 6 held-out fidelity gates, and a synthetic evolution pass -> doubled the target–clone gap in one revision — fidelity and live evolution are the only -> defenses we've seen work. Big caveat we volunteer: N=6, high-N behavior unknown, we won't -> cite it as resolved. And in the intra-org frame the employer already has the file, so -> clone-resistance isn't what's being sold there — attribution is. +> The historical N=6 run used a modeled $1.50 acquisition cost and measured about +> $0.03 of distillation-provider cost; no acquisition payment settled. Its target +> failed the benchmark, so clone quality, fidelity defense, and break-even are +> unknown. Publication remains blocked pending a valid preregistered N=100 run. **Q5. "Who would actually pay for this?"** diff --git a/docs/marketing/linkedin.md b/docs/marketing/linkedin.md index 1ad6c8b..f921183 100644 --- a/docs/marketing/linkedin.md +++ b/docs/marketing/linkedin.md @@ -46,43 +46,47 @@ Launch day, Tuesday–Thursday ~8:30am ET; pin to profile and leave it pinned th --- -## Post 2 — The clone story: "$1.58 to steal my own product" +## Post 2 — Clone-economics benchmark: publication blocked + +> **PUBLICATION BLOCKED — INVALID BENCHMARK.** The 2026-07-12 target scored +> 0.400 and failed its own critical gates, so clone-quality, fidelity-defense, and +> break-even conclusions are suppressed. Acquisition was modeled at $1.50; no +> x402 acquisition payments settled. Unblock only after +> `spikes/clone-economics` produces a valid N=100 result with committed normalized +> evidence and three live-adapter-confirmed independent distillation seeds. **Target audience:** business readers broadly — the moat lesson travels beyond the ICP. ### Post text -> Last week I paid $1.58 to steal my own product. -> -> The product is a hosted AI skill — expertise packaged as software, metered per use. You send a request, you get the output. You never see the skill itself. -> -> The obvious attack: buy enough outputs, train a copycat on them, stop paying. So I ran the attack on myself. -> -> Six paid runs. $1.58 total (play money on a public test network, but the ratios are what matter). The distillation step — turning those outputs into a working clone — cost three cents. -> -> The copying was over 40x cheaper than the buying, and the buying was under two dollars. -> -> Then the clone failed. All six held-out fidelity checks. Zero passes. It resembled my skill the way a wax figure resembles a person. -> -> Here's the uncomfortable math anyway. I modeled the break-even: if a clone ever DOES pass, it pays for itself in 8 invocations. Eight. Cost is not a moat. It never was — not for skills, not for playbooks, not for anything made of text. -> -> Honest caveat: six samples is a small test. Whether a clone passes at 600 or 6,000 samples is unknown, and the published write-up says so instead of pretending otherwise. +> We ran a six-example clone-economics pilot against our own hosted Skill. > -> The business lesson survives the caveat. If your defense is "copying is expensive," you don't have a defense. What held up wasn't price — it was fidelity (the parts of a skill that never show up in any single output) and the fact that a live skill keeps evolving while a clone is a photograph of it. +> The provider calls were live. The acquisition price was not: six examples at +> $0.25 each contributed a modeled $1.50, no x402 acquisition payments settled, +> and the measured distillation-provider cost was about $0.03. The resulting +> $1.58 attacker-build figure is therefore a modeled lower bound that excludes +> labor and several failed setup attempts, not money paid for six Invocations. > -> That's true of your company's internal expertise too, whether or not you ever touch my product. +> More importantly, the benchmark target failed its own acceptance gate. That +> invalidates any conclusion about whether the clone failed, whether fidelity is +> a defense, or where break-even lands. We preserved the run as historical +> evidence and blocked this post rather than promote an answer the evaluator +> could not support. > -> Full numbers and method in the first comment. +> The next admissible result requires at least 30 held-out fixtures and a +> preregistered N=6/25/50/100 sweep with three live-adapter-confirmed independent +> distillation seeds. +> No high-N result exists yet. ### First comment -> The clone-attack harness, raw numbers, and all six fidelity gates are in the open-source repo: https://github.com/Aznatkoiny/skill-asset-protocol +> The clone-attack harness, historical numbers, and invalid benchmark target are in the open-source repo: https://github.com/Aznatkoiny/skill-asset-protocol > -> The skill it failed to clone is live (testnet, play money): https://neverhandedover.com +> The hosted Skill used by the pilot is live (testnet, play money): https://neverhandedover.com ### Posting context -2–4 days after Post 1, mid-week morning; this hook stands alone, so it's the best post of the five for reaching past the existing network. +Do not publish until the gate above is satisfied and a human approves revised copy. --- diff --git a/docs/marketing/x.md b/docs/marketing/x.md index d1048a0..6ba219b 100644 --- a/docs/marketing/x.md +++ b/docs/marketing/x.md @@ -58,13 +58,10 @@ The overhead of paying per call, measured across 48 settled calls: Not free. Not prohibitive. Numbers you can build against. **6/** -We attacked our own skill before launch. - -$1.58 bought a clone distilled from its own outputs (the distillation step itself: $0.03). - -The clone failed all 6 held-out fidelity gates. - -Cost protects nothing. Fidelity and live evolution are the defense. +The historical N=6 run used a modeled $1.50 acquisition cost and measured about +$0.03 of distillation-provider cost; no acquisition payment settled. Its target +failed the benchmark, so clone quality, fidelity defense, and break-even are +unknown. Publication remains blocked pending a valid preregistered N=100 run. **7/** We published kill-criteria before launch and already used them on ourselves. @@ -96,53 +93,19 @@ If you author skills, this is about who gets credited and compensated for them. --- -## 2. Clone-attack thread (7 tweets) - -**1/** -We paid $1.58 to steal our own product. - -Here's the experiment, the numbers, and the uncomfortable conclusion about what actually protects an AI skill. - -**2/** -Setup: our skill is a paid endpoint. $0.25 per invocation in testnet USDC (play money), output only — the skill itself never crosses the wire. - -The obvious attack: pay it, collect outputs, distill a clone, stop paying. - -So we ran that attack against ourselves. N=6. - -**3/** -The bill: - -· total attack cost: $1.58 -· the distillation step itself: $0.03 - -Three cents. The expensive part was buying our own outputs to distill from. If you think per-call pricing is a moat, that's the number that should bother you. +## 2. Clone-attack thread — publication blocked -**4/** -The result: the clone failed all 6 held-out fidelity gates. - -Every single one. - -It resembled our skill the way a photo of a bridge resembles a bridge. You can look at it. You can't drive across it. - -**5/** -We also modeled the case where a clone eventually passes the gates: break-even lands at 8 invocations. - -Eight. If price is your only defense, anyone who can afford 8 calls can afford the attack. - -**6/** -The conclusion we're publishing: cost protects nothing. - -What holds up: +> **PUBLICATION BLOCKED — INVALID BENCHMARK.** The 2026-07-12 target scored +> 0.400 and failed its own critical gates, so clone-quality, fidelity-defense, and +> break-even conclusions are suppressed. Acquisition was modeled at $1.50; no +> x402 acquisition payments settled. Unblock only after +> `spikes/clone-economics` produces a valid N=100 result with committed normalized +> evidence and three live-adapter-confirmed independent distillation seeds. -· fidelity — held-out gates a clone has to pass, not resemble -· live evolution — a skill that keeps changing is a moving target for distillation - -**7/** -The honest caveat: N=6 is small. High-N behavior is unknown — someone patient, with hundreds of outputs, might distill a passing clone. We don't know yet. - -The experiment is public so someone can prove us wrong: -github.com/Aznatkoiny/skill-asset-protocol +The historical N=6 run used a modeled $1.50 acquisition cost and measured about +$0.03 of distillation-provider cost; no acquisition payment settled. Its target +failed the benchmark, so clone quality, fidelity defense, and break-even are +unknown. Publication remains blocked pending a valid preregistered N=100 run. --- @@ -235,7 +198,10 @@ Good (someone asks whether x402 latency is workable): > We measured it across 48 settled calls on Base Sepolia: p50 731ms / p95 1206ms of payment overhead per call; cold start to first token on a hosted agent is ~2.5s (n=3). Fine for per-task pricing, painful inside a tight loop. Good (someone claims per-call pricing stops people cloning your agent): -> We tested that against our own skill. $1.58 total to distill a clone from its outputs — the distillation step cost $0.03. The clone failed our 6 fidelity gates, but cost was never the thing protecting it. N=6, so high-N is still an open question. +> The historical N=6 run used a modeled $1.50 acquisition cost and measured about +> $0.03 of distillation-provider cost; no acquisition payment settled. Its target +> failed the benchmark, so clone quality, fidelity defense, and break-even are +> unknown. Publication remains blocked pending a valid preregistered N=100 run. Good (Claude Code author asks who owns the skills they write at work): > Under standard work-for-hire, the default is 100/0 — employer gets the artifact, author gets salary. We've been building infrastructure to meter use and split per invocation instead. Happy to share the numbers if useful. @@ -272,7 +238,9 @@ Reply routine continues daily throughout. The ratio stays lopsided: for every or - **Day 0, first reply under the thread:** the ecosystem tag reply (see below). - **Day 0, hours 1–3:** live in the replies. Every substantive response gets a substantive answer. This window decides whether the thread travels. - **Day 2:** How-it-works thread. Quote or link the pinned launch thread from the last tweet. -- **Day 4–5:** Clone-attack thread. +- **Day 4–5:** **BLOCKED:** do not publish clone-economics copy. The N=6 target + failed its own acceptance gate; the required valid N=100 evidence bundle does + not exist. - **Between threads:** the standalone tweets (section 5), one at a time, as spacers. Never two threads within 48 hours. ### Tag strategy @@ -305,9 +273,10 @@ Most manifestos ask for your agreement. Ours asks for a quarter. (A testnet quar https://neverhandedover.com **C.** -We paid $1.58 to clone our own skill. The clone failed all 6 fidelity gates. - -Cost protects nothing. Fidelity and live evolution do. +The historical N=6 run used a modeled $1.50 acquisition cost and measured about +$0.03 of distillation-provider cost; no acquisition payment settled. Its target +failed the benchmark, so clone quality, fidelity defense, and break-even are +unknown. Publication remains blocked pending a valid preregistered N=100 run. **D.** Output crosses the wire. The skill never does. From 590fc98252dd69c133e1645f1376e63c31996b08 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:07:20 -0400 Subject: [PATCH 031/165] docs: correct x402 and measurement claims --- docs/marketing/2026-07-13-campaign-plan.md | 25 +++++- docs/marketing/hn-and-demo.md | 57 +++++++++----- docs/marketing/linkedin.md | 21 +++-- docs/marketing/x.md | 92 +++++++++++++++------- 4 files changed, 136 insertions(+), 59 deletions(-) diff --git a/docs/marketing/2026-07-13-campaign-plan.md b/docs/marketing/2026-07-13-campaign-plan.md index 411a488..3faed5a 100644 --- a/docs/marketing/2026-07-13-campaign-plan.md +++ b/docs/marketing/2026-07-13-campaign-plan.md @@ -90,11 +90,11 @@ verified live, LinkedIn Post 1 out — no X thread, no HN. | 0 | Mon 07-13 | *(done)* LinkedIn Post 1 out, pinned. Repo flip did NOT happen — slip recorded. | | 1 | Tue 07-14 | *(done)* Silent. x402 Foundation launches under the Linux Foundation — this week's reply-routine entry point. | | **2** | **Wed 07-15 (today)** | ① **Repo public FIRST** (pre-flight passed; verify from a logged-out browser after the flip). ② Reply to any Post-1 comments that hit the 404 — factual correction in a reply, never silent. ③ **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. ④ **X artifact #1**: the raw 402 from the LIVE endpoint (`docs/marketing/artifacts/raw-402-response-live.txt` — production URL + "output only, never the skill" in the payload); tweet text below. ⑤ Reply routine starts (x402-Foundation news as the entry; measured numbers, never a pitch). ⑥ Metrics table started (`hn-and-demo.md` §3 daily log). | -| 3 | Thu 07-16 | X artifact #2: the ledger line reconciled on-chain to the cent (testnet, play money). Replies. | +| 3 | Thu 07-16 | X artifact #2: the aggregate seller `payTo` transfer reconciled on-chain; the Creator/treasury credits remained off-chain (testnet, play money). Replies. | | 4 | Fri 07-17 | X artifact #3: "What we have NOT validated" page, screenshotted. Light day. | | 5–6 | Sat–Sun 07-18/19 | **Rest days** (Sun: optional 15 min of replies if a good conversation is live). | | 7 | Mon 07-20 | X artifact #4: the kill-criteria arithmetic that killed education mode. Replies. | -| 8 | Tue 07-21 | **LinkedIn Post 3** (design-partner ask) ~8:30am ET + targeted reshares. X artifact #5: the n=48 overhead distribution (new artifact, x.md §4). | +| 8 | Tue 07-21 | **LinkedIn Post 3** (design-partner ask) ~8:30am ET + targeted reshares. **BLOCKED:** do not publish the historical overhead distribution; use the 2026-07-15 quarantine status below. | | 9 | Wed 07-22 | **Record the demo clip** (`hn-and-demo.md` §2) — one week of live traffic since the flip; verify basescan links. X artifact #6: pay-then-fail receipt. Prep ecosystem tag reply; hand-verify org @handles. | | 10 | Thu 07-23 | **X launch thread** (x.md §1, amended numbers) late morning, demo clip on tweet 1, pin, ecosystem tag reply first. Hours 1–3 live in replies. | | 11 | Fri 07-24 | Thread aftercare. X spacer: the pi session ledger (single tweet — 48h thread spacing holds). | @@ -104,11 +104,28 @@ verified live, LinkedIn Post 1 out — no X thread, no HN. | 16 | Wed 07-29 | HN aftercare (hourly sweeps; log unanswerable critiques as corpus defects). X spacer: settled-but-rejected reconciliation — the honesty artifact while HN eyes are on the account. | | 17 | Thu 07-30 | **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. **LinkedIn Post 4** (kill-criteria). **Day-7-after-launch-thread review**: count conversations that could become a design-partner LOI — the one derived number that matters. | +**Historical overhead artifact status:** + +The 2026-07-15 overhead distribution is historical but not reproducible from a +clean checkout because normalized per-call samples were not retained. Its sample +count, p50, and p95 are quarantined from publication; see +`spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. No replacement +measurement has been run. + +**Accounting boundary for ledger artifacts:** + +The aggregate testnet USDC payment to the seller `payTo` address reconciled +on-chain. The Creator/treasury amounts were off-chain reference-ledger credits; +they were not separate on-chain transfers. + Constraint check: repo flip before anything that links to it ✓ · ≥48h between threads (07-23 / 07-28 / 07-30) ✓ · launch thread + HN on Tue/Thu ✓ · weekends rest ✓ · demo clip after a week of live traffic ✓. **Today's X artifact #1 tweet** (attach the live-endpoint 402 capture; ~250 chars, verify at post time; single tweet, no thread, no link — the artifact is the content): -> POST to a paid endpoint without paying and this is the reply: HTTP 402, with the terms machine-readable in the body — amount, network, asset, payTo. No API key, no account; the settlement receipt becomes the auth. Base Sepolia testnet — play money. +> POST to a paid endpoint without paying and this is the reply: HTTP 402, with +> machine-readable terms — amount, network, asset, payTo. The retry carries a +> signed `X-PAYMENT` authorization; the settlement transaction hash is evidence +> returned afterward, not the retry credential. Base Sepolia testnet — play money. **Standing daily items (every non-rest day, not repeated in the table):** @@ -127,7 +144,7 @@ Constraint check: repo flip before anything that links to it ✓ · ≥48h betwe | **−1** | Mon 07-13 (today) | Pre-flight checklist from `hn-and-demo.md` §3: LICENSE, README offline-e2e story, secrets scan, fresh-machine clone-and-run with zero keys/funds, live 402 check, both domains serving, compliance pass on every queued post. If the fresh-machine test fails, Day 0 slips — nothing else changes. | | **0** | Tue 07-14 | **Repo public first** (runbook step 1), verify clone-and-run from a logged-out browser. **LinkedIn Post 1** ("Never handed over", `linkedin.md`) at ~8:30am ET, link in first comment, pin to profile. X: replies only. Log day-0 measurements. | | **1** | Wed 07-15 | X build-in-public artifact #1: screenshot of the raw HTTP 402 response (`x.md` §4, week-2 list). Single tweet, no thread. | -| **2** | Thu 07-16 | **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. X artifact #2: the ledger line, reconciled on-chain to the cent (testnet, play money). | +| **2** | Thu 07-16 | **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. X artifact #2: the aggregate seller `payTo` transfer reconciled on-chain; the Creator/treasury credits remained off-chain (testnet, play money). | | **3** | Fri 07-17 | X artifact #3: the "What we have NOT validated" page, screenshotted. Light day otherwise. | | **4** | Sat 07-18 | **Rest day.** Nothing posted anywhere. No replies. | | **5** | Sun 07-19 | **Rest day** (light): optional 15 min of X replies if a good conversation is live; otherwise nothing. | diff --git a/docs/marketing/hn-and-demo.md b/docs/marketing/hn-and-demo.md index 585a67b..536dee0 100644 --- a/docs/marketing/hn-and-demo.md +++ b/docs/marketing/hn-and-demo.md @@ -39,18 +39,29 @@ above is satisfied and a human approves revised copy. > and splits the metered revenue to a co-held claim. Carta for AI work artifacts. The > marketplace angle is future optionality, not the product. > -> What we measured (testnet, 2026-07-12): one wallet paid per model call AND per skill -> invocation over x402. Ledger: claude/plan $0.041, skill $0.25 → creator $0.24375 / -> treasury $0.00625, reconciled against on-chain balances to the cent. Payment overhead -> p50 731ms / p95 1206ms per call (n=48 settled calls, both model providers); -> hosted-agent cold start ~2.5s to first token. +> What we measured (testnet, 2026-07-12): one wallet paid per model call AND per Skill +> Invocation over x402. The first instrumented payment-overhead read was ~781 ms +> (n=1, Base Sepolia testnet, play money); hosted-agent cold start was ~2.5s to +> first token in a separate measurement. Ledger (testnet USDC, play money): +> claude/plan $0.041, Skill $0.25 → Creator $0.24375 / treasury $0.00625. +> +> The aggregate testnet USDC payment to the seller `payTo` address reconciled +> on-chain. The Creator/treasury amounts were off-chain reference-ledger credits; +> they were not separate on-chain transfers. +> +> The 2026-07-15 overhead distribution is historical but not reproducible from a +> clean checkout because normalized per-call samples were not retained. Its sample +> count, p50, and p95 are quarantined from publication; see +> `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. No replacement +> measurement has been run. > > The historical N=6 run used a modeled $1.50 acquisition cost and measured about > $0.03 of distillation-provider cost; no acquisition payment settled. Its target > failed the benchmark, so clone quality, fidelity defense, and break-even are > unknown. Publication remains blocked pending a valid preregistered N=100 run. > -> We also documented two live failure modes: 10 calls that settled then 500'd ($0.87 paid, +> We also documented two live failure modes: 10 calls that settled then 500'd ($0.87 of +> testnet USDC play money paid, > no refund path in x402 v1 — our bug, published), and 1 of 50 that settled on-chain yet > returned 402 — that one caught only by cent-exact wallet reconciliation. > @@ -92,10 +103,15 @@ Post these as replies, verbatim or trimmed. Never argue tone; concede fast and l > Stripe's card-and-account rails can't do machine-to-machine 25-cent calls without an > onboarding relationship (Stripe itself now ships an x402 integration). > Provenance: fork ancestry lives on a neutral registry (Story Protocol) that neither -> employer nor employee administers. And the receipts in the demo are publicly auditable — -> that's how we reconciled the ledger to the cent. We're explicit in the docs that the -> idealized atomic loop does not compose (wrong chain, wrong token, wrong primitive) and -> settlement is two-leg and eventually consistent. +> employer nor employee administers. +> +> The aggregate testnet USDC payment to the seller `payTo` address reconciled +> on-chain. The Creator/treasury amounts were off-chain reference-ledger credits; +> they were not separate on-chain transfers. +> +> We're explicit in the docs that the idealized atomic loop does not compose +> (wrong chain, wrong token, wrong primitive) and settlement is two-leg and +> eventually consistent. **Q3. "A skill is a markdown file. Prompts are worthless; the model does the work."** @@ -137,8 +153,9 @@ monitored monthly; platforms won't ship 409A-structured co-held comp instruments ## 2. Demo clip script (45–60s screen recording, no voiceover, big captions) -One continuous story: read → blocked → pay → output → receipt → split → thesis. Captions in -a large mono face, bottom third, one sentence max. Terminal at large font size (18pt+). +One continuous story: read → blocked → pay → output → receipt → off-chain split credits → +thesis. Captions in a large mono face, bottom third, one sentence max. Terminal at large +font size (18pt+). Target total: **57s**. | # | Sec | On screen | Caption text | @@ -148,15 +165,16 @@ Target total: **57s**. | 3 | 13–21 (8s) | Same terminal: client retries with x402 payment; a `$0.25` testnet USDC payment line and settle confirmation appear | `Pay $0.25 — testnet USDC. Play money.` | | 4 | 21–31 (10s) | Skill output streams into the terminal, token by token (real speed; ~2.5s pause to first token left in — it is honest and reads as live) | `You get the output. Never the skill.` | | 5 | 31–40 (9s) | Browser: the transaction on sepolia.basescan.org — highlight the transfer to the payTo address, cursor circles the amount | `Every invocation is an on-chain receipt.` | -| 6 | 40–49 (9s) | The ledger, zoomed on one line: `claude/plan $0.041 · skill $0.25 → creator $0.24375 / treasury $0.00625` — then a second frame: on-chain balances matching | `Metered, split, reconciled to the cent.` | +| 6 | 40–49 (9s) | BaseScan, zoomed on the aggregate testnet USDC transfer to the seller `payTo` address; then the off-chain reference-ledger Creator/treasury credits | `Seller payment on-chain. Split credits off-chain.` | | 7 | 49–57 (8s) | Cut to black. Two lines of text, then URLs fade in: `neverhandedover.com` / `github.com/Aznatkoiny/skill-asset-protocol` | `The artifact was never handed over.` (line 2, smaller: `Testnet demo. Open source, Apache-2.0.`) | Production notes: - No music required; if any, something metronomic and quiet. - Shots 2–4 are one unbroken terminal take — do not cut between 402 and output; the no-cut is the proof. -- Keep real latency visible (the ~731ms-median payment beat, the ~2.5s cold start). Speeding it up - would be the only dishonest frame in the clip. +- If the 2026-07-12 take is used, keep its ~781 ms instrumented payment beat + (n=1, Base Sepolia testnet, play money) and the ~2.5s cold start visible. Do not + caption it with the quarantined 2026-07-15 distribution. - Shot 6's caption carries the compliance load with shot 3: "testnet / play money" must be on screen in both the payment shot and the closing card. - Export 1080p or better; the basescan and ledger text must be legible on a phone. @@ -225,13 +243,14 @@ Phase-0 window, we do not build Phase 1 on spec. > private — its repo link 404'd for readers from Monday until the flip on Wed 07-15. No > pre-flight was logged and this table was not started on time; the rows below are > reconstructed from verifiable sources, with "not logged" where nothing was recorded. -> Day 0's ~$0.328 of gateway-debugging spend is not logged per-call; on-chain receipts +> Day 0's ~$0.328 of testnet USDC play-money gateway-debugging spend is not logged +> per-call; on-chain receipts > are pullable from basescan retroactively. All on-chain invocations to date are our own > wallet (self-traffic): unique external payers = 0. | Day | Date | Demo invocations (count / unique payers) | Repo stars / forks / clones | Conversations started | Critiques we couldn't answer | |---|---|---|---|---|---| -| — | Sun 07-12 | 3 / 1 (self — first real-network run: 2 model legs + 1 skill, $0.332, reconciled on-chain) | n/a (repo private) | 0 | 0 | -| 0 | Mon 07-13 | self only, ~$0.328 (gateway debugging; not logged per-call) | n/a (repo private — Post 1 link 404) | not logged | not logged | +| — | Sun 2026-07-12 | 3 / 1 (self — first real-network run: 2 model legs + 1 Skill, $0.332 of testnet USDC play money; aggregate seller payment reconciled on-chain) | n/a (repo private) | 0 | 0 | +| 0 | Mon 07-13 | self only, ~$0.328 of testnet USDC play money (gateway debugging; not logged per-call) | n/a (repo private — Post 1 link 404) | not logged | not logged | | 1 | Tue 07-14 | 0 | n/a (repo private) | not logged | not logged | -| 2 | Wed 07-15 | 57 settlements / 1 payer (all self): 1 smoke + 7 pi session + 48 bench + 1 settled-but-rejected; $3.211 total, wallet 19.340 → 16.129 reconciled to the cent | flip today — baseline 0 / 0 / 0; first insights readable tomorrow | fill at EOD | fill at EOD | +| 2 | Wed 2026-07-15 | Self-traffic only. The overhead batch's sample count and distribution are quarantined under the historical tombstone; separate retained events include 1 smoke, 7 pi-session calls, and 1 settled-but-rejected call. Aggregate wallet reconciliation is not normalized latency evidence. | flip today — baseline 0 / 0 / 0; first insights readable tomorrow | fill at EOD | fill at EOD | diff --git a/docs/marketing/linkedin.md b/docs/marketing/linkedin.md index f921183..76623f4 100644 --- a/docs/marketing/linkedin.md +++ b/docs/marketing/linkedin.md @@ -182,15 +182,24 @@ Week 2–3, mid-week; resonates with founders and diligence-minded operators, so > > Phase 3 — the live endpoint. The manifesto site IS the system. POST without payment and you get an actual HTTP 402. Pay $0.25 in testnet USDC — play money, deliberately — and the hosted skill runs and streams you the output. Never the skill. > -> Numbers from the working demo on Base Sepolia (July 12; distribution re-measured July 15 across 48 settled calls and two model providers), one wallet paying per model call AND per skill invocation: +> Numbers from the first working demo on Base Sepolia (2026-07-12; testnet, +> play money), one wallet paying per model call AND per Skill Invocation: > -> — Ledger: $0.041 for the model's planning call, $0.25 for the skill invocation -> — Split: $0.24375 credited to the creator, $0.00625 to the treasury -> — On-chain balances reconciled to the cent -> — Payment overhead: p50 731ms / p95 1206ms per call (n=48 settled calls; the first run's n=1 read was ~781ms) +> — Ledger (testnet USDC, play money): $0.041 for the model's planning call, +> $0.25 for the Skill Invocation +> — First instrumented payment-overhead read: ~781 ms (n=1, 2026-07-12; +> Base Sepolia testnet, play money) > — Hosted-agent cold start: ~2.5s to first token > -> That 731ms median is honest and it isn't free. Neither is the cold start. Both are in the docs, because you'd find them in your first hour anyway. +> The aggregate testnet USDC payment to the seller `payTo` address reconciled +> on-chain. The Creator/treasury amounts were off-chain reference-ledger credits; +> they were not separate on-chain transfers. +> +> The 2026-07-15 overhead distribution is historical but not reproducible from a +> clean checkout because normalized per-call samples were not retained. Its sample +> count, p50, and p95 are quarantined from publication; see +> `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. No replacement +> measurement has been run. > > Everything is Apache-2.0: the collar that holds the sole API key, the metering ledger, the clone-attack harness we ran against ourselves, the kill-criteria, the not-validated list. > diff --git a/docs/marketing/x.md b/docs/marketing/x.md index 6ba219b..8654e19 100644 --- a/docs/marketing/x.md +++ b/docs/marketing/x.md @@ -43,19 +43,24 @@ The compensation and attribution layer is the product. A marketplace is future o **4/** Receipts from the live demo (Base Sepolia, 2026-07-12 — testnet, play money): -One wallet paid per model call AND per skill invocation, over x402. +One wallet paid per model call AND per Skill Invocation, over x402. -claude/plan $0.041 · skill $0.25 → creator $0.24375 / treasury $0.00625 +claude/plan $0.041 · Skill $0.25 → Creator $0.24375 / treasury $0.00625 +(testnet USDC, play money) -On-chain balances reconciled to the cent. +The aggregate testnet USDC payment to the seller `payTo` address reconciled +on-chain. The Creator/treasury amounts were off-chain reference-ledger credits; +they were not separate on-chain transfers. **5/** -The overhead of paying per call, measured across 48 settled calls: +The 2026-07-15 overhead distribution is historical but not reproducible from a +clean checkout because normalized per-call samples were not retained. Its sample +count, p50, and p95 are quarantined from publication; see +`spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. No replacement +measurement has been run. -· payment adds p50 731ms / p95 1206ms per call (n=48 settled calls) -· hosted-agent cold start: ~2.5s to first token (separate n=3 measurement) - -Not free. Not prohibitive. Numbers you can build against. +The hosted-agent cold start was ~2.5s to first token in a separate n=3 +measurement. **6/** The historical N=6 run used a modeled $1.50 acquisition cost and measured about @@ -124,23 +129,26 @@ Client POSTs with no payment. Server answers HTTP 402 Payment Required, with the The status code finally has a job. **3/** -Step 2 — the signature. +Step 2 — authorization. -The client signs an EIP-3009 transferWithAuthorization for the exact amount. Off-chain signature, no gas from the buyer, no custody handoff — just signed authorization to move $0.25 of testnet USDC. +The Wielder-side proxy validates the 402 offer and signs an EIP-3009 +transferWithAuthorization for the exact permitted amount. The retry carries that +signed `X-PAYMENT` authorization, not a transaction hash. **4/** -Step 3 — settlement. - -The signed authorization goes to an x402 facilitator, which settles it on-chain. x402 is a Linux Foundation standard; the rails did ~75M transactions in the last 30 days. +Step 3 — seller-side settlement. -We didn't build payment infrastructure. We built on it. +The Collar's x402 paywall sends the signed authorization to the facilitator. The +facilitator verifies and settles on Base Sepolia before the hosted Skill runs. A +settlement transaction hash is evidence returned after settlement; it is not the +credential carried by the initial retry. **5/** -Step 4 — the credential. - -The settlement txHash IS the credential. The client retries the POST carrying it; the server verifies settlement on-chain and executes. +Step 4 — execution and receipt. -No API keys. No accounts. The receipt is the auth. +After settlement, the Collar executes the hosted Skill and returns output plus a +receipt. The artifact file is not directly returned. Model-output extraction +remains an adversarial runtime risk, so this is not a secrecy guarantee. **6/** Step 5 — output only. @@ -150,16 +158,25 @@ The server runs the hosted skill and sends back the result. The skill artifact n That's the design constraint the whole protocol hangs on: metered use, never handover. **7/** -All of the server side fits in a ~150-line proxy we call the Wielder: enforce 402, verify settlement, run the hosted skill, split revenue to the ledger. - -150 lines, because the rails already exist. +The Wielder is the wallet plus paying client proxy. The Collar is seller-side: it +holds the platform key, enforces the payment gate, runs the hosted Skill, and +writes the seller ledger. The demo's Wielder ledger is a receipt view, not the +authoritative compensation ledger. **8/** -Measured (Base Sepolia, 2026-07-12 + 07-15, testnet): +The first instrumented payment-overhead read was ~781 ms (n=1, 2026-07-12; +Base Sepolia testnet, play money). Cold start was ~2.5s to first token in a +separate n=3 measurement. + +The 2026-07-15 overhead distribution is historical but not reproducible from a +clean checkout because normalized per-call samples were not retained. Its sample +count, p50, and p95 are quarantined from publication; see +`spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. No replacement +measurement has been run. -· payment overhead p50 731ms / p95 1206ms (n=48 settled calls) -· cold start ~2.5s to first token -· $0.25/invocation → creator $0.24375 / treasury $0.00625, reconciled on-chain to the cent +The aggregate testnet USDC payment to the seller `payTo` address reconciled +on-chain. The Creator/treasury amounts were off-chain reference-ledger credits; +they were not separate on-chain transfers. Code, Apache-2.0: github.com/Aznatkoiny/skill-asset-protocol @@ -186,7 +203,9 @@ Daily routine, 30–45 minutes: **Where the conversations are (saved searches to build):** - **x402 ecosystem** — searches: "x402", "HTTP 402", "402 Payment Required", "facilitator". This is the home crowd; the how-it-works thread is written for them. -- **Base builders** — searches: "Base Sepolia", "onchain agents", Base ecosystem hashtags. They care about the on-chain ledger reconciling to the cent. +- **Base builders** — searches: "Base Sepolia", "onchain agents", Base ecosystem + hashtags. They care about the aggregate seller `payTo` transfer reconciling + on-chain while Creator/treasury credits remain off-chain. - **Story Protocol / provenance** — searches: "Story Protocol", "IP provenance", "attribution onchain". They care about the authorship thesis. - **Claude Code community** — searches: "Claude Code skills", "Claude plugins", "agent skills". These are the authors the protocol exists for. This is the most important room. - **Agent-payments discourse** — searches: "agents paying agents", "agentic payments", "machine-to-machine payments", "pay per call". Broadest, noisiest; only reply where we have a measurement. @@ -195,7 +214,11 @@ Daily routine, 30–45 minutes: question asked, never pitch. Good (someone asks whether x402 latency is workable): -> We measured it across 48 settled calls on Base Sepolia: p50 731ms / p95 1206ms of payment overhead per call; cold start to first token on a hosted agent is ~2.5s (n=3). Fine for per-task pricing, painful inside a tight loop. +> The 2026-07-15 overhead distribution is historical but not reproducible from a +> clean checkout because normalized per-call samples were not retained. Its sample +> count, p50, and p95 are quarantined from publication; see +> `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. No replacement +> measurement has been run. Good (someone claims per-call pricing stops people cloning your agent): > The historical N=6 run used a modeled $1.50 acquisition cost and measured about @@ -213,7 +236,11 @@ Bad (any variation of): "Great point! We're building exactly this — check out Single tweets, not threads. One screenshot-sized artifact per day: - Day 8: screenshot of the raw HTTP 402 response from the manifesto endpoint. -- Day 9: the ledger line — claude/plan $0.041 · skill $0.25 → creator $0.24375 / treasury $0.00625 — with the note that it reconciled on-chain to the cent (testnet, play money). +- Day 9: the ledger line — claude/plan $0.041 · Skill $0.25 → Creator $0.24375 / + treasury $0.00625 (testnet USDC, play money). The aggregate testnet USDC + payment to the seller `payTo` address reconciled on-chain. The Creator/treasury + amounts were off-chain reference-ledger credits; they were not separate + on-chain transfers. - Day 10: the "What we have NOT validated" page, screenshotted. - Day 11: the kill-criteria arithmetic that killed our education mode. - Day 12: the ~150-line Wielder proxy, as a code screenshot. @@ -221,8 +248,13 @@ Single tweets, not threads. One screenshot-sized artifact per day: **New artifacts (added 2026-07-15; slots per the revamped calendar in `2026-07-13-campaign-plan.md` §2; verify character counts at post time):** -- **The n=48 overhead distribution** — artifact: the distribution decomposition from `spikes/pi-wielder/README.md`. - > x402 payment overhead, measured across 48 settled calls on Base Sepolia (testnet, play money), two model providers, real facilitator: p50 731ms · p95 1206ms. The facilitator verify+settle leg is the whole story (p50 729ms); the 402 roundtrip + signature add ~2ms. Wallet reconciled on-chain to the cent. +- **Historical overhead distribution — quarantined (2026-07-15)** — artifact: + the tombstone from `spikes/pi-wielder/README.md`. + > The 2026-07-15 overhead distribution is historical but not reproducible from a + > clean checkout because normalized per-call samples were not retained. Its sample + > count, p50, and p95 are quarantined from publication; see + > `spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json`. No replacement + > measurement has been run. - **The pay-then-fail receipt** — artifact: the ten-500s ledger excerpt. > We paid $0.87 in testnet USDC (play money) for ten HTTP 500s. Pay-first-then-run means a seller bug after settlement is the buyer's loss — x402 v1 has no refund path. Our bug, our dime. Fixed it, published the receipt. If you're building on 402 rails, design for pay-then-fail. - **The settled-but-rejected reconciliation** — artifact: the balance-reconciliation lines. From 616917c6adc9a7313f70301f5d39f4e91ee0d0c9 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:09:46 -0400 Subject: [PATCH 032/165] docs: bound hosted Skill extraction claims --- docs/marketing/2026-07-13-campaign-plan.md | 7 +++++-- docs/marketing/hn-and-demo.md | 10 ++++++---- docs/marketing/linkedin.md | 15 ++++++++++++--- docs/marketing/x.md | 16 ++++++++++------ 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/marketing/2026-07-13-campaign-plan.md b/docs/marketing/2026-07-13-campaign-plan.md index 3faed5a..129a58c 100644 --- a/docs/marketing/2026-07-13-campaign-plan.md +++ b/docs/marketing/2026-07-13-campaign-plan.md @@ -89,7 +89,7 @@ verified live, LinkedIn Post 1 out — no X thread, no HN. |---|---|---| | 0 | Mon 07-13 | *(done)* LinkedIn Post 1 out, pinned. Repo flip did NOT happen — slip recorded. | | 1 | Tue 07-14 | *(done)* Silent. x402 Foundation launches under the Linux Foundation — this week's reply-routine entry point. | -| **2** | **Wed 07-15 (today)** | ① **Repo public FIRST** (pre-flight passed; verify from a logged-out browser after the flip). ② Reply to any Post-1 comments that hit the 404 — factual correction in a reply, never silent. ③ **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. ④ **X artifact #1**: the raw 402 from the LIVE endpoint (`docs/marketing/artifacts/raw-402-response-live.txt` — production URL + "output only, never the skill" in the payload); tweet text below. ⑤ Reply routine starts (x402-Foundation news as the entry; measured numbers, never a pitch). ⑥ Metrics table started (`hn-and-demo.md` §3 daily log). | +| **2** | **Wed 07-15 (today)** | ① **Repo public FIRST** (pre-flight passed; verify from a logged-out browser after the flip). ② Reply to any Post-1 comments that hit the 404 — factual correction in a reply, never silent. ③ **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. ④ **X artifact #1**: the raw 402 from the LIVE endpoint (`docs/marketing/artifacts/raw-402-response-live.txt` — production URL + its original absolute description in the payload; see the historical-wire note below); tweet text below. ⑤ Reply routine starts (x402-Foundation news as the entry; measured numbers, never a pitch). ⑥ Metrics table started (`hn-and-demo.md` §3 daily log). | | 3 | Thu 07-16 | X artifact #2: the aggregate seller `payTo` transfer reconciled on-chain; the Creator/treasury credits remained off-chain (testnet, play money). Replies. | | 4 | Fri 07-17 | X artifact #3: "What we have NOT validated" page, screenshotted. Light day. | | 5–6 | Sat–Sun 07-18/19 | **Rest days** (Sun: optional 15 min of replies if a good conversation is live). | @@ -122,6 +122,9 @@ Constraint check: repo flip before anything that links to it ✓ · ≥48h betwe **Today's X artifact #1 tweet** (attach the live-endpoint 402 capture; ~250 chars, verify at post time; single tweet, no thread, no link — the artifact is the content): +The captured response preserves its original absolute description as historical +wire evidence. Do not reuse that description as current marketing copy. + > POST to a paid endpoint without paying and this is the reply: HTTP 402, with > machine-readable terms — amount, network, asset, payTo. The retry carries a > signed `X-PAYMENT` authorization; the settlement transaction hash is evidence @@ -152,7 +155,7 @@ Constraint check: repo flip before anything that links to it ✓ · ≥48h betwe | **7** | Tue 07-21 | **LinkedIn Post 3** (retention / the design-partner ask) at ~8:30am ET, then reshare to specific VP Eng / Head of People contacts with one-line personal notes per its posting context. **X launch thread** (`x.md` §1) late morning, demo clip attached to tweet 1 (per runbook step 3), pin it, ecosystem tag reply as the first reply. Hours 1–3: live in the X replies — this window decides whether the thread travels. | | **8** | Wed 07-22 | X spacer: the ~150-line Wielder proxy code screenshot (`x.md` §4 artifact list) — a natural bridge to tomorrow's technical thread. Evening: **HN pre-flight** — re-run the fresh-machine test, re-verify the live 402 and a paid invocation, open the five prepared answers (`hn-and-demo.md` §1) in a tab. | | **9** | Thu 07-23 | **LinkedIn Post 5** (build story — the technical post HN readers will arrive at) at ~8:00am ET, cross-linked from the repo README. **Show HN** at 8:30–10:00am ET: submit neverhandedover.com with title 1, §1 text as immediate first comment. Responding cadence per runbook step 5 (hour 1: every substantive comment within ~15 min; then 30-min sweeps; then hourly). **X how-it-works thread** (`x.md` §3) mid-morning — written for the same technical crowd, 48h after the launch thread, quoting the pinned thread from its last tweet. End of day: link the best critical HN thread from the site/X ("the hardest question we got today"). | -| **10** | Fri 07-24 | HN aftercare: hourly sweeps while the thread is warm; log any critique we couldn't answer as a corpus defect. X spacer: one standalone from `x.md` §5 (suggest D — "Output crosses the wire. The skill never does."). | +| **10** | Fri 07-24 | HN aftercare: hourly sweeps while the thread is warm; log any critique we couldn't answer as a corpus defect. X spacer: one standalone from `x.md` §5 (suggest D — "The artifact file is not directly returned. Extraction risk remains."). | | **11** | Sat 07-25 | **Rest day.** Nothing posted. | | **12** | Sun 07-26 | **Rest day** (light): optional HN/X reply sweep only if threads are still live. | | **13** | Mon 07-27 | **BLOCKED:** do not publish clone-economics copy. The N=6 target failed its own acceptance gate; the required valid N=100 evidence bundle does not exist. **LinkedIn Post 4** (kill-criteria / honesty post) — its context says week 2–3 mid-week and it's the let-it-sit-and-compound post, so sliding it to Wed 07-29 is equally fine. **Day-7-after-launch review:** count conversations that could become a design-partner LOI — the one derived number that matters (`hn-and-demo.md` §3). Remaining `x.md` §5 spacers (A, B, C, E, F) feed week 3+ as-needed. | diff --git a/docs/marketing/hn-and-demo.md b/docs/marketing/hn-and-demo.md index 536dee0..c734d89 100644 --- a/docs/marketing/hn-and-demo.md +++ b/docs/marketing/hn-and-demo.md @@ -31,7 +31,8 @@ above is satisfied and a human approves revised copy. > https://neverhandedover.com is a manifesto that is literally a paid endpoint. POST to it > without payment and you get HTTP 402. Pay $0.25 in testnet USDC (play money, Base Sepolia) -> and the hosted skill runs and streams you output. You never get the skill itself. +> and the hosted Skill runs and streams you output. The artifact file is not directly +> returned; model-output extraction remains an adversarial runtime risk. > > The thesis: authored AI skills (Claude Code skills, plugins, agent definitions) are work > artifacts, and work-for-hire's default split is 100/0 — employer gets everything, author @@ -116,7 +117,8 @@ Post these as replies, verbatim or trimmed. Never argue tone; concede fast and l **Q3. "A skill is a markdown file. Prompts are worthless; the model does the work."** > A skill is plaintext and trivially copyable — that's the first ADR in the repo, not a -> gotcha. We don't sell secrecy: the wielder gets output only, but the host sees the skill in +> gotcha. We don't sell secrecy: the artifact file is not directly returned and +> model-output extraction remains an adversarial runtime risk; the host sees the Skill in > plaintext, and in the intra-org mode the employer already possesses it. What's for sale is > attribution and metered compensation, the way Carta doesn't make cap tables secret. On > "worthless": some skills are — each frontier model release absorbs packaged prompting, so a @@ -163,10 +165,10 @@ Target total: **57s**. | 1 | 0–6 (6s) | Slow scroll of neverhandedover.com — the manifesto text, ending on the URL bar | `This manifesto is a paid API endpoint.` | | 2 | 6–13 (7s) | Terminal: `curl -X POST https://neverhandedover.com/...` → response renders, `HTTP/1.1 402 Payment Required` highlighted | `POST without payment → HTTP 402.` | | 3 | 13–21 (8s) | Same terminal: client retries with x402 payment; a `$0.25` testnet USDC payment line and settle confirmation appear | `Pay $0.25 — testnet USDC. Play money.` | -| 4 | 21–31 (10s) | Skill output streams into the terminal, token by token (real speed; ~2.5s pause to first token left in — it is honest and reads as live) | `You get the output. Never the skill.` | +| 4 | 21–31 (10s) | Skill output streams into the terminal, token by token (real speed; ~2.5s pause to first token left in — it is honest and reads as live) | `The artifact file is not directly returned. Extraction risk remains.` | | 5 | 31–40 (9s) | Browser: the transaction on sepolia.basescan.org — highlight the transfer to the payTo address, cursor circles the amount | `Every invocation is an on-chain receipt.` | | 6 | 40–49 (9s) | BaseScan, zoomed on the aggregate testnet USDC transfer to the seller `payTo` address; then the off-chain reference-ledger Creator/treasury credits | `Seller payment on-chain. Split credits off-chain.` | -| 7 | 49–57 (8s) | Cut to black. Two lines of text, then URLs fade in: `neverhandedover.com` / `github.com/Aznatkoiny/skill-asset-protocol` | `The artifact was never handed over.` (line 2, smaller: `Testnet demo. Open source, Apache-2.0.`) | +| 7 | 49–57 (8s) | Cut to black. Two lines of text, then URLs fade in: `neverhandedover.com` / `github.com/Aznatkoiny/skill-asset-protocol` | `The artifact file is not directly returned. Extraction risk remains.` (line 2, smaller: `Testnet demo. Open source, Apache-2.0.`) | Production notes: - No music required; if any, something metronomic and quiet. diff --git a/docs/marketing/linkedin.md b/docs/marketing/linkedin.md index 76623f4..057654a 100644 --- a/docs/marketing/linkedin.md +++ b/docs/marketing/linkedin.md @@ -28,7 +28,10 @@ Five posts for the launch of the Skill Asset Protocol / neverhandedover.com. > > I've spent the last months building the alternative: infrastructure that meters each use of a skill and splits the revenue to a claim the author holds jointly with the employer. Think Carta, but for AI work artifacts. > -> It's live at neverhandedover.com, and the site is the demo. Reading is free. Running the hosted skill costs a quarter in play money on a public test network — and you get the output, never the skill. That one sentence is the whole architecture. +> It's live at neverhandedover.com, and the site is the demo. Reading is free. +> Running the hosted Skill costs a quarter in play money on a public test network +> — the artifact file is not directly returned; model-output extraction remains +> an adversarial runtime risk. That boundary is the architecture. > > What I have not proven: that employers will buy this. That's written on the site too, because a launch post that only lists what works is an ad, not evidence. > @@ -174,13 +177,19 @@ Week 2–3, mid-week; resonates with founders and diligence-minded operators, so > How we went from research question to a live paid endpoint — in three phases. > -> The question: if authored AI skills are assets, can you meter their use and split compensation per invocation, without ever shipping the skill file to the caller? +> The question: if authored AI Skills are assets, can you meter their use and split +> compensation per Invocation while not directly returning the artifact file and +> treating model-output extraction as an adversarial runtime risk? > > Phase 1 — research. Before product code, a findings document: what's real, what's vapor, what's unmeasured. x402, the HTTP 402 payment standard now under the Linux Foundation, turned out to be very real — ~75M transactions in the last 30 days. Story Protocol handles provenance. Several things we assumed were solved were not, and we wrote that down too. > > Phase 2 — adversarial review. We red-teamed our own PRD. This is where the Education mode died (free re-authoring beats every royalty rate — one page of arithmetic) and where the kill-criteria were written. The red-team output ships in the repo as a first-class artifact, not a postmortem. > -> Phase 3 — the live endpoint. The manifesto site IS the system. POST without payment and you get an actual HTTP 402. Pay $0.25 in testnet USDC — play money, deliberately — and the hosted skill runs and streams you the output. Never the skill. +> Phase 3 — the live endpoint. The manifesto site IS the system. POST without +> payment and you get an actual HTTP 402. Pay $0.25 in testnet USDC — play money, +> deliberately — and the hosted Skill runs and streams you the output. The +> artifact file is not directly returned; model-output extraction remains an +> adversarial runtime risk. > > Numbers from the first working demo on Base Sepolia (2026-07-12; testnet, > play money), one wallet paying per model call AND per Skill Invocation: diff --git a/docs/marketing/x.md b/docs/marketing/x.md index 8654e19..c0fcb31 100644 --- a/docs/marketing/x.md +++ b/docs/marketing/x.md @@ -22,7 +22,9 @@ We published a manifesto that is also a paid API. POST to it without paying and you get HTTP 402. -Pay $0.25 in testnet USDC (play money) and it runs the skill and sends back the output. You never get the skill. +Pay $0.25 in testnet USDC (play money) and it runs the Skill and sends back the +output. The artifact file is not directly returned; model-output extraction +remains an adversarial runtime risk. https://neverhandedover.com @@ -151,11 +153,13 @@ receipt. The artifact file is not directly returned. Model-output extraction remains an adversarial runtime risk, so this is not a secrecy guarantee. **6/** -Step 5 — output only. +Step 5 — the hosted-output boundary. -The server runs the hosted skill and sends back the result. The skill artifact never crosses the wire. +The server runs the hosted Skill and sends back the result. The artifact file is +not directly returned; model-output extraction remains an adversarial runtime risk. -That's the design constraint the whole protocol hangs on: metered use, never handover. +That's the bounded design constraint: metered hosted use without directly +returning the artifact file, while extraction remains an adversarial risk. **7/** The Wielder is the wallet plus paying client proxy. The Collar is seller-side: it @@ -311,9 +315,9 @@ failed the benchmark, so clone quality, fidelity defense, and break-even are unknown. Publication remains blocked pending a valid preregistered N=100 run. **D.** -Output crosses the wire. The skill never does. +The artifact file is not directly returned. Extraction risk remains. -That single constraint is the whole protocol. +That bounded claim is the protocol's hosted-delivery constraint. **E.** The most useful page we published is the list of things we haven't proven. It's shorter than the manifesto and it was harder to write. From 7b281932672565f87187c7799fda61849e8de2ff Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:14:52 -0400 Subject: [PATCH 033/165] fix: invalidate clone conclusions on failed target --- spikes/clone-economics/e2e.mjs | 6 ++- spikes/clone-economics/src/experiment.mjs | 42 +++++++++++++++++-- spikes/clone-economics/src/reports.mjs | 7 +++- spikes/clone-economics/src/validity.mjs | 26 ++++++++++++ .../clone-economics/tests/validity.test.mjs | 38 +++++++++++++++++ 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 spikes/clone-economics/src/validity.mjs create mode 100644 spikes/clone-economics/tests/validity.test.mjs diff --git a/spikes/clone-economics/e2e.mjs b/spikes/clone-economics/e2e.mjs index c9dde15..f4f7c20 100644 --- a/spikes/clone-economics/e2e.mjs +++ b/spikes/clone-economics/e2e.mjs @@ -108,6 +108,7 @@ try { eq(report.fidelity.rubricVersion, 'contract-v1', 'versioned deterministic rubric recorded'); eq(report.fidelity.target.absoluteScore, 1, 'known literal target score'); eq(report.fidelity.clone.absoluteScore, 0.9, 'known literal good-clone score'); + eq(report.benchmark.verdict, 'VALID_BENCHMARK', 'passing target admits interpretation'); ok(report.fidelity.clone.passedThreshold && report.fidelity.clone.criticalGatePass, 'good clone clears 0.80 and critical gates'); eq(report.fidelity.retention, 0.9, 'clone/target retention secondary metric'); eq(report.fidelity.badClone.absoluteScore, 0.2, 'known literal bad-clone score'); @@ -168,7 +169,10 @@ try { eq(unknown.report.economics.buildToAcquisition, null, 'unknown B propagates to B/A'); eq(unknown.report.usage.normalized.inputTokens, null, 'missing raw input usage keeps normalized input total unknown'); eq(unknown.report.usage.normalized.providerCostUsd, null, 'missing request cost keeps normalized provider total unknown'); - eq(unknown.report.fidelity.retention, null, 'zero target score makes retention undefined'); + eq(unknown.report.benchmark.verdict, 'INVALID_BENCHMARK_TARGET_FAILED', 'failed target invalidates benchmark'); + eq(unknown.report.fidelity.retention, null, 'invalid target suppresses retention'); + eq(unknown.report.economics.breakEvenInvocations, null, 'invalid target suppresses break-even'); + ok(unknown.markdownReport.includes('Clone quality, fidelity defense, moat, and break-even conclusions are suppressed'), 'invalid report states suppression'); ok(unknown.markdownReport.includes('unknown'), 'Markdown renders unknown values without crashing'); const replayTranscript = JSON.parse(fs.readFileSync(path.join(here, 'fixtures/mock-transcript.json'), 'utf8')); diff --git a/spikes/clone-economics/src/experiment.mjs b/spikes/clone-economics/src/experiment.mjs index 55a1d08..1a86a44 100644 --- a/spikes/clone-economics/src/experiment.mjs +++ b/spikes/clone-economics/src/experiment.mjs @@ -7,6 +7,7 @@ import { MockLlmAdapter, LiveAnthropicAdapter } from './adapters.mjs'; import { computeEconomics } from './economics.mjs'; import { renderJson, renderMarkdown } from './reports.mjs'; import { FIDELITY_THRESHOLD, RUBRIC_VERSION, scoreEvaluation } from './scoring.mjs'; +import { assessBenchmark } from './validity.mjs'; const srcDir = path.dirname(fileURLToPath(import.meta.url)); const spikeRoot = path.resolve(srcDir, '..'); @@ -158,6 +159,10 @@ export async function runExperiment(options = {}) { const targetScore = scoreEvaluation(targetOutputs, heldoutFixtures); const cloneScore = scoreEvaluation(cloneOutputs, heldoutFixtures); + const benchmark = assessBenchmark({ + threshold: FIDELITY_THRESHOLD, + target: targetScore, + }); const badCloneScore = scoreEvaluation(badOutputs, heldoutFixtures); const updatedTargetScore = scoreEvaluation(targetV2Outputs, v2Fixtures); const frozenCloneScore = scoreEvaluation(cloneV2Outputs, v2Fixtures); @@ -198,7 +203,12 @@ export async function runExperiment(options = {}) { : `MIXED — provider execution measured; usage/cost ${providerUsageEvidence}; paid-pair acquisition MODELED; fixtures SYNTHETIC`; const claimStatus = mode === 'mock' ? 'LIVE RUN NOT EXECUTED — no key/explicit opt-in; no measured clone-economics result.' - : `LIVE RUN EXECUTED — provider calls executed; usage/cost ${providerUsageEvidence}; paid-pair acquisition remains MODELED unless separately settled.`; + : benchmark.valid + ? `LIVE RUN EXECUTED — provider calls executed; usage/cost ${providerUsageEvidence}; paid-pair acquisition remains MODELED unless separately settled.` + : benchmark.verdict; + const economicsEvidenceLabel = mode === 'mock' + ? 'SYNTHETIC + MODELED' + : `Provider cost ${providerUsageEvidence}; acquisition MODELED`; const report = { schemaVersion: 1, @@ -218,13 +228,16 @@ export async function runExperiment(options = {}) { targetAndCloneSharedContextHash: sha256(JSON.stringify(sharedExecutor)), }, generatedClone: { sha256: sha256(cloneSkillMd), validSkillMd: true }, + benchmark, fidelity: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'MEASURED AGAINST DETERMINISTIC RUBRIC', rubricVersion: RUBRIC_VERSION, threshold: FIDELITY_THRESHOLD, target: targetScore, clone: cloneScore, - retention: targetScore.absoluteScore > 0 ? rounded(cloneScore.absoluteScore / targetScore.absoluteScore) : null, + retention: benchmark.cloneConclusionAllowed && targetScore.absoluteScore > 0 + ? rounded(cloneScore.absoluteScore / targetScore.absoluteScore) + : null, badClone: badCloneScore, scoreDeterminism: { byteIdentical: scoringA === scoringB }, }, @@ -238,7 +251,30 @@ export async function runExperiment(options = {}) { staleFidelityDelta: rounded(updatedTargetScore.absoluteScore - frozenCloneScore.absoluteScore), statement: evolutionOverlay.statement, }, - economics: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC + MODELED' : `Provider cost ${providerUsageEvidence}; acquisition MODELED`, ...economics }, + economics: { + evidenceLabel: economicsEvidenceLabel, + acquisitionFormula: economics.acquisitionFormula, + acquisitionModeledUsd: economics.acquisitionModeledUsd, + distillationProviderUsd: economics.distillationProviderUsd, + tuningEvaluationUsd: economics.tuningEvaluationUsd, + tuningNote: economics.tuningNote, + deployCostUsd: economics.deployCostUsd, + laborCostUsd: economics.laborCostUsd, + laborCostTreatment: economics.laborCostTreatment, + attackerBuildUsd: economics.attackerBuildUsd, + measurementEvaluationUsd: economics.measurementEvaluationUsd, + evaluationExcludedFromBuild: economics.evaluationExcludedFromBuild, + distillationToAcquisition: economics.distillationToAcquisition, + buildToAcquisition: economics.buildToAcquisition, + breakEvenInvocations: benchmark.economicsConclusionAllowed + ? economics.breakEvenInvocations + : null, + cloneServingCostUsd: economics.cloneServingCostUsd, + providerCostsNotAddedToAcquisition: economics.providerCostsNotAddedToAcquisition, + providerCostBreakdown: economics.providerCostBreakdown, + zeroPriceProbe: economics.zeroPriceProbe, + conclusionSuppressed: !benchmark.economicsConclusionAllowed, + }, usage: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : `Provider usage ${providerUsageEvidence}`, raw: adapter.records, normalized: normalizedUsage(adapter.records) }, pricing: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'OPERATOR-SUPPLIED', ...adapter.pricing }, timing: { diff --git a/spikes/clone-economics/src/reports.mjs b/spikes/clone-economics/src/reports.mjs index 1da7e4c..821a2dc 100644 --- a/spikes/clone-economics/src/reports.mjs +++ b/spikes/clone-economics/src/reports.mjs @@ -9,12 +9,17 @@ function scoreRows(target, clone) { } export function renderMarkdown(report) { + const conclusion = report.benchmark.valid + ? 'The target passed its own gate; clone and economics interpretation may proceed.' + : `**${report.benchmark.verdict}.** ${report.benchmark.reason} Clone quality, fidelity defense, moat, and break-even conclusions are suppressed.`; return `# Clone-economics spike report **Evidence:** ${report.evidenceLabel}
**Mode:** ${report.mode}
**Verdict:** ${report.claimStatus} +${conclusion} + ## Question ${report.question} @@ -58,7 +63,7 @@ ${report.evolution.statement} | E_measure — benchmark overhead, excluded from B | ${number(report.economics.measurementEvaluationUsd)} | | D/A | ${report.economics.distillationToAcquisition ?? 'undefined'} | | B/A | ${report.economics.buildToAcquisition ?? 'undefined'} | -| Break-even Invocations | ${report.economics.breakEvenInvocations ?? 'undefined'} | +| Break-even Invocations | ${report.benchmark.valid ? report.economics.breakEvenInvocations ?? 'undefined' : 'suppressed'} | Acquisition is MODELED as N × listed Invocation price; no x402 payment settled. Provider/harness costs are listed separately and not double-counted into A. diff --git a/spikes/clone-economics/src/validity.mjs b/spikes/clone-economics/src/validity.mjs new file mode 100644 index 0000000..2290b05 --- /dev/null +++ b/spikes/clone-economics/src/validity.mjs @@ -0,0 +1,26 @@ +export const INVALID_TARGET_VERDICT = 'INVALID_BENCHMARK_TARGET_FAILED'; + +export function assessBenchmark({ threshold, target }) { + const scoreFailed = target.absoluteScore < threshold; + const gatesFailed = !target.criticalGatePass; + if (scoreFailed || gatesFailed) { + const failures = [ + scoreFailed ? `Target score ${target.absoluteScore.toFixed(3)} is below ${threshold.toFixed(3)}` : null, + gatesFailed ? 'target critical gates failed' : null, + ].filter(Boolean); + return { + valid: false, + verdict: INVALID_TARGET_VERDICT, + cloneConclusionAllowed: false, + economicsConclusionAllowed: false, + reason: `${failures.join(' and ')}.`, + }; + } + return { + valid: true, + verdict: 'VALID_BENCHMARK', + cloneConclusionAllowed: true, + economicsConclusionAllowed: true, + reason: `Target met ${threshold.toFixed(3)} and every critical gate.`, + }; +} diff --git a/spikes/clone-economics/tests/validity.test.mjs b/spikes/clone-economics/tests/validity.test.mjs new file mode 100644 index 0000000..c183733 --- /dev/null +++ b/spikes/clone-economics/tests/validity.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + INVALID_TARGET_VERDICT, + assessBenchmark, +} from '../src/validity.mjs'; + +const score = (absoluteScore, criticalGatePass) => ({ + absoluteScore, + criticalGatePass, + passedThreshold: absoluteScore >= 0.8, +}); + +test('a failed target suppresses every clone and economics conclusion', () => { + const result = assessBenchmark({ + threshold: 0.8, + target: score(0.4, false), + }); + assert.deepEqual(result, { + valid: false, + verdict: INVALID_TARGET_VERDICT, + cloneConclusionAllowed: false, + economicsConclusionAllowed: false, + reason: 'Target score 0.400 is below 0.800 and target critical gates failed.', + }); +}); + +test('a passing target admits a clone result without deciding its meaning', () => { + const result = assessBenchmark({ + threshold: 0.8, + target: score(0.9, true), + }); + assert.equal(result.valid, true); + assert.equal(result.verdict, 'VALID_BENCHMARK'); + assert.equal(result.cloneConclusionAllowed, true); + assert.equal(result.economicsConclusionAllowed, true); +}); From 95a3dda58793b33fc19fd049f839787c4a32971d Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:15:46 -0400 Subject: [PATCH 034/165] test: preserve retained clone run evidence --- spikes/clone-economics/e2e.mjs | 8 ++++++- .../tests/e2e-coexistence.test.mjs | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 spikes/clone-economics/tests/e2e-coexistence.test.mjs diff --git a/spikes/clone-economics/e2e.mjs b/spikes/clone-economics/e2e.mjs index f4f7c20..95c0161 100644 --- a/spikes/clone-economics/e2e.mjs +++ b/spikes/clone-economics/e2e.mjs @@ -35,6 +35,12 @@ const targetPath = path.resolve(here, '../../.claude/skills/optimizing-claude-co const referencePath = path.resolve(here, '../../.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md'); const targetText = fs.readFileSync(targetPath, 'utf8'); const referenceText = fs.readFileSync(referencePath, 'utf8'); +function treeSnapshot(directory) { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory, { recursive: true }).map(String).sort(); +} +const retainedRuns = path.join(here, 'runs'); +const runsBefore = treeSnapshot(retainedRuns); const outputA = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-economics-a-')); const outputB = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-economics-b-')); @@ -156,7 +162,7 @@ try { ok(!first.markdownReport.split('\n').some((line) => /[ \t]+$/.test(line)), 'Markdown report contains no trailing whitespace'); eq(fs.readFileSync(first.outputFiles.json, 'utf8'), fs.readFileSync(second.outputFiles.json, 'utf8'), 'two JSON report runs are byte-identical'); eq(fs.readFileSync(first.outputFiles.markdown, 'utf8'), fs.readFileSync(second.outputFiles.markdown, 'utf8'), 'two Markdown report runs are byte-identical'); - ok(!fs.existsSync(path.join(here, 'runs')), 'e2e leaves no run artifacts in the tree'); + eq(treeSnapshot(retainedRuns), runsBefore, 'e2e leaves pre-existing run artifacts unchanged'); const unknownTranscript = JSON.parse(fs.readFileSync(path.join(here, 'fixtures/mock-transcript.json'), 'utf8')); unknownTranscript.usageProfiles.distill.inputTokens = null; diff --git a/spikes/clone-economics/tests/e2e-coexistence.test.mjs b/spikes/clone-economics/tests/e2e-coexistence.test.mjs new file mode 100644 index 0000000..bb96629 --- /dev/null +++ b/spikes/clone-economics/tests/e2e-coexistence.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('offline e2e preserves an existing ignored runs directory', (t) => { + const marker = path.join(root, 'runs', 'e2e-retained-marker.txt'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, 'retain me\n'); + t.after(() => fs.rmSync(marker, { force: true })); + const output = execFileSync(process.execPath, ['e2e.mjs'], { + cwd: root, + env: { ...process.env, MOCK_LLM: '1', ALLOW_LIVE_LLM: '0' }, + encoding: 'utf8', + }); + assert.match(output, /PASS/); + assert.equal(fs.readFileSync(marker, 'utf8'), 'retain me\n'); +}); From 57250779d21ac67f83333ca31792cbb580a01b25 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:17:18 -0400 Subject: [PATCH 035/165] feat: preregister larger clone fixture set --- .../fixtures/fixture-catalog-v2.json | 45 + .../clone-economics/fixtures/heldout-v2.json | 1502 +++++++++++++++++ spikes/clone-economics/fixtures/train-v2.json | 602 +++++++ .../scripts/generate-fixtures.mjs | 46 + spikes/clone-economics/src/fixture-set.mjs | 19 + .../tests/fixture-set.test.mjs | 28 + 6 files changed, 2242 insertions(+) create mode 100644 spikes/clone-economics/fixtures/fixture-catalog-v2.json create mode 100644 spikes/clone-economics/fixtures/heldout-v2.json create mode 100644 spikes/clone-economics/fixtures/train-v2.json create mode 100644 spikes/clone-economics/scripts/generate-fixtures.mjs create mode 100644 spikes/clone-economics/src/fixture-set.mjs create mode 100644 spikes/clone-economics/tests/fixture-set.test.mjs diff --git a/spikes/clone-economics/fixtures/fixture-catalog-v2.json b/spikes/clone-economics/fixtures/fixture-catalog-v2.json new file mode 100644 index 0000000..e0f07ab --- /dev/null +++ b/spikes/clone-economics/fixtures/fixture-catalog-v2.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "fixtureSet": "v2", + "trainDomains": [ + {"slug":"checkout","path":"@src/checkout/totals.ts","command":"npm test -- src/checkout/totals.test.ts","constraint":"preserve tax rounding exactly"}, + {"slug":"auth","path":"@src/auth/logout.ts","command":"npm test -- src/auth/logout.test.ts","constraint":"reuse the existing session invalidation path"}, + {"slug":"billing","path":"@src/billing/renewal.ts","command":"npm test -- src/billing/renewal.test.ts","constraint":"do not change invoice JSON"}, + {"slug":"worker","path":"@src/jobs/retry.ts","command":"npm test -- src/jobs/retry.test.ts","constraint":"keep retry attempts idempotent"}, + {"slug":"export","path":"@src/export/csv.ts","command":"npm test -- src/export/csv.test.ts","constraint":"add no new dependencies"}, + {"slug":"webhook","path":"@src/webhooks/verify.ts","command":"npm test -- src/webhooks/verify.test.ts","constraint":"reject invalid signatures without logging secrets"}, + {"slug":"search","path":"@src/search/query.ts","command":"npm test -- src/search/query.test.ts","constraint":"preserve the public query API"}, + {"slug":"profile","path":"@src/profile/update.ts","command":"npm test -- src/profile/update.test.ts","constraint":"leave unrelated profile fields untouched"}, + {"slug":"migration","path":"@src/db/migrations/042-orders.ts","command":"npm test -- src/db/migrations/042-orders.test.ts","constraint":"keep the migration reversible"}, + {"slug":"notifications","path":"@src/notifications/digest.ts","command":"npm test -- src/notifications/digest.test.ts","constraint":"send at most one digest per account"} + ], + "heldoutDomains": [ + {"slug":"cache","path":"@src/cache/invalidate.ts","command":"npm test -- src/cache/invalidate.test.ts","constraint":"keep the cache API backward-compatible"}, + {"slug":"session","path":"@src/session/timeout.ts","command":"npm test -- src/session/timeout.test.ts","constraint":"fix the root cause without suppressing the error"}, + {"slug":"report","path":"@src/reports/download.ts","command":"npm test -- src/reports/download.test.ts","constraint":"preserve the download response headers"}, + {"slug":"audit","path":"@src/audit/append.ts","command":"npm test -- src/audit/append.test.ts","constraint":"make audit entries append-only"}, + {"slug":"orders","path":"@src/orders/filter.ts","command":"npm test -- src/orders/filter.test.ts","constraint":"preserve the JSON response shape exactly"}, + {"slug":"upload","path":"@src/uploads/limits.ts","command":"npm test -- src/uploads/limits.test.ts","constraint":"reject oversized files before persistence"}, + {"slug":"flags","path":"@src/flags/evaluate.ts","command":"npm test -- src/flags/evaluate.test.ts","constraint":"keep evaluation deterministic"}, + {"slug":"tokens","path":"@src/tokens/rotate.ts","command":"npm test -- src/tokens/rotate.test.ts","constraint":"never log token material"}, + {"slug":"queue","path":"@src/queue/claim.ts","command":"npm test -- src/queue/claim.test.ts","constraint":"prevent two workers from claiming one job"}, + {"slug":"ledger","path":"@src/ledger/reconcile.ts","command":"npm test -- src/ledger/reconcile.test.ts","constraint":"do not mutate settled entries"} + ], + "trainTemplates": [ + {"mode":"Optimize","text":"Tighten the request for {slug} while preserving behavior."}, + {"mode":"Generate","text":"Write an implementation request for the {slug} change."}, + {"mode":"Diagnose","text":"The {slug} change spread beyond scope; rewrite the request to fix the root cause."}, + {"mode":"Spec","text":"Turn the broad {slug} idea into the next implementation specification."}, + {"mode":"Optimize","text":"Make the {slug} prompt explicit about verification and constraints."}, + {"mode":"Generate","text":"Ask for the smallest test-driven {slug} patch."}, + {"mode":"Diagnose","text":"The first {slug} attempt hid the error; produce a diagnostic request."}, + {"mode":"Spec","text":"Specify the {slug} behavior without starting implementation."}, + {"mode":"Optimize","text":"Remove ambiguity from this {slug} maintenance request."}, + {"mode":"Generate","text":"Create a repository-grounded request for {slug}."} + ], + "heldoutTemplates": [ + {"mode":"Optimize","text":"Optimize this {slug} request without breaking callers.","maxQuestions":0}, + {"mode":"Diagnose","text":"The {slug} implementation masked a failure; rewrite the request.","maxQuestions":0}, + {"mode":"Generate","text":"Generate the smallest verified change request for {slug}.","maxQuestions":0} + ] +} diff --git a/spikes/clone-economics/fixtures/heldout-v2.json b/spikes/clone-economics/fixtures/heldout-v2.json new file mode 100644 index 0000000..1797e65 --- /dev/null +++ b/spikes/clone-economics/fixtures/heldout-v2.json @@ -0,0 +1,1502 @@ +[ + { + "id": "ho-v2-01-01", + "mode": "Optimize", + "input": "Optimize this cache request without breaking callers. Use @src/cache/invalidate.ts; verify with npm test -- src/cache/invalidate.test.ts; keep the cache API backward-compatible.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/cache/invalidate.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/cache/invalidate.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "keep the cache API backward-compatible", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-01-02", + "mode": "Diagnose", + "input": "The cache implementation masked a failure; rewrite the request. Use @src/cache/invalidate.ts; verify with npm test -- src/cache/invalidate.test.ts; keep the cache API backward-compatible.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/cache/invalidate.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/cache/invalidate.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "keep the cache API backward-compatible", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-01-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for cache. Use @src/cache/invalidate.ts; verify with npm test -- src/cache/invalidate.test.ts; keep the cache API backward-compatible.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/cache/invalidate.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/cache/invalidate.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "keep the cache API backward-compatible", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-02-01", + "mode": "Optimize", + "input": "Optimize this session request without breaking callers. Use @src/session/timeout.ts; verify with npm test -- src/session/timeout.test.ts; fix the root cause without suppressing the error.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/session/timeout.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/session/timeout.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "fix the root cause without suppressing the error", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-02-02", + "mode": "Diagnose", + "input": "The session implementation masked a failure; rewrite the request. Use @src/session/timeout.ts; verify with npm test -- src/session/timeout.test.ts; fix the root cause without suppressing the error.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/session/timeout.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/session/timeout.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "fix the root cause without suppressing the error", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-02-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for session. Use @src/session/timeout.ts; verify with npm test -- src/session/timeout.test.ts; fix the root cause without suppressing the error.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/session/timeout.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/session/timeout.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "fix the root cause without suppressing the error", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-03-01", + "mode": "Optimize", + "input": "Optimize this report request without breaking callers. Use @src/reports/download.ts; verify with npm test -- src/reports/download.test.ts; preserve the download response headers.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/reports/download.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/reports/download.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "preserve the download response headers", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-03-02", + "mode": "Diagnose", + "input": "The report implementation masked a failure; rewrite the request. Use @src/reports/download.ts; verify with npm test -- src/reports/download.test.ts; preserve the download response headers.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/reports/download.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/reports/download.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "preserve the download response headers", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-03-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for report. Use @src/reports/download.ts; verify with npm test -- src/reports/download.test.ts; preserve the download response headers.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/reports/download.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/reports/download.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "preserve the download response headers", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-04-01", + "mode": "Optimize", + "input": "Optimize this audit request without breaking callers. Use @src/audit/append.ts; verify with npm test -- src/audit/append.test.ts; make audit entries append-only.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/audit/append.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/audit/append.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "make audit entries append-only", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-04-02", + "mode": "Diagnose", + "input": "The audit implementation masked a failure; rewrite the request. Use @src/audit/append.ts; verify with npm test -- src/audit/append.test.ts; make audit entries append-only.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/audit/append.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/audit/append.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "make audit entries append-only", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-04-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for audit. Use @src/audit/append.ts; verify with npm test -- src/audit/append.test.ts; make audit entries append-only.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/audit/append.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/audit/append.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "make audit entries append-only", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-05-01", + "mode": "Optimize", + "input": "Optimize this orders request without breaking callers. Use @src/orders/filter.ts; verify with npm test -- src/orders/filter.test.ts; preserve the JSON response shape exactly.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/orders/filter.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/orders/filter.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "preserve the JSON response shape exactly", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-05-02", + "mode": "Diagnose", + "input": "The orders implementation masked a failure; rewrite the request. Use @src/orders/filter.ts; verify with npm test -- src/orders/filter.test.ts; preserve the JSON response shape exactly.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/orders/filter.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/orders/filter.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "preserve the JSON response shape exactly", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-05-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for orders. Use @src/orders/filter.ts; verify with npm test -- src/orders/filter.test.ts; preserve the JSON response shape exactly.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/orders/filter.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/orders/filter.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "preserve the JSON response shape exactly", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-06-01", + "mode": "Optimize", + "input": "Optimize this upload request without breaking callers. Use @src/uploads/limits.ts; verify with npm test -- src/uploads/limits.test.ts; reject oversized files before persistence.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/uploads/limits.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/uploads/limits.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "reject oversized files before persistence", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-06-02", + "mode": "Diagnose", + "input": "The upload implementation masked a failure; rewrite the request. Use @src/uploads/limits.ts; verify with npm test -- src/uploads/limits.test.ts; reject oversized files before persistence.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/uploads/limits.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/uploads/limits.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "reject oversized files before persistence", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-06-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for upload. Use @src/uploads/limits.ts; verify with npm test -- src/uploads/limits.test.ts; reject oversized files before persistence.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/uploads/limits.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/uploads/limits.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "reject oversized files before persistence", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-07-01", + "mode": "Optimize", + "input": "Optimize this flags request without breaking callers. Use @src/flags/evaluate.ts; verify with npm test -- src/flags/evaluate.test.ts; keep evaluation deterministic.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/flags/evaluate.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/flags/evaluate.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "keep evaluation deterministic", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-07-02", + "mode": "Diagnose", + "input": "The flags implementation masked a failure; rewrite the request. Use @src/flags/evaluate.ts; verify with npm test -- src/flags/evaluate.test.ts; keep evaluation deterministic.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/flags/evaluate.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/flags/evaluate.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "keep evaluation deterministic", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-07-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for flags. Use @src/flags/evaluate.ts; verify with npm test -- src/flags/evaluate.test.ts; keep evaluation deterministic.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/flags/evaluate.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/flags/evaluate.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "keep evaluation deterministic", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-08-01", + "mode": "Optimize", + "input": "Optimize this tokens request without breaking callers. Use @src/tokens/rotate.ts; verify with npm test -- src/tokens/rotate.test.ts; never log token material.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/tokens/rotate.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/tokens/rotate.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "never log token material", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-08-02", + "mode": "Diagnose", + "input": "The tokens implementation masked a failure; rewrite the request. Use @src/tokens/rotate.ts; verify with npm test -- src/tokens/rotate.test.ts; never log token material.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/tokens/rotate.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/tokens/rotate.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "never log token material", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-08-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for tokens. Use @src/tokens/rotate.ts; verify with npm test -- src/tokens/rotate.test.ts; never log token material.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/tokens/rotate.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/tokens/rotate.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "never log token material", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-09-01", + "mode": "Optimize", + "input": "Optimize this queue request without breaking callers. Use @src/queue/claim.ts; verify with npm test -- src/queue/claim.test.ts; prevent two workers from claiming one job.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/queue/claim.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/queue/claim.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "prevent two workers from claiming one job", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-09-02", + "mode": "Diagnose", + "input": "The queue implementation masked a failure; rewrite the request. Use @src/queue/claim.ts; verify with npm test -- src/queue/claim.test.ts; prevent two workers from claiming one job.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/queue/claim.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/queue/claim.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "prevent two workers from claiming one job", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-09-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for queue. Use @src/queue/claim.ts; verify with npm test -- src/queue/claim.test.ts; prevent two workers from claiming one job.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/queue/claim.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/queue/claim.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "prevent two workers from claiming one job", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-10-01", + "mode": "Optimize", + "input": "Optimize this ledger request without breaking callers. Use @src/ledger/reconcile.ts; verify with npm test -- src/ledger/reconcile.test.ts; do not mutate settled entries.", + "rubric": { + "expectedMode": "Optimize", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/ledger/reconcile.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/ledger/reconcile.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "do not mutate settled entries", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-10-02", + "mode": "Diagnose", + "input": "The ledger implementation masked a failure; rewrite the request. Use @src/ledger/reconcile.ts; verify with npm test -- src/ledger/reconcile.test.ts; do not mutate settled entries.", + "rubric": { + "expectedMode": "Diagnose", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/ledger/reconcile.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/ledger/reconcile.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "do not mutate settled entries", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + }, + { + "id": "ho-v2-10-03", + "mode": "Generate", + "input": "Generate the smallest verified change request for ledger. Use @src/ledger/reconcile.ts; verify with npm test -- src/ledger/reconcile.test.ts; do not mutate settled entries.", + "rubric": { + "expectedMode": "Generate", + "maxQuestions": 0, + "exactPaths": [ + { + "value": "@src/ledger/reconcile.ts", + "weight": 2, + "critical": true + } + ], + "exactCommands": [ + { + "value": "npm test -- src/ledger/reconcile.test.ts", + "weight": 2, + "critical": true + } + ], + "requiredAll": [ + { + "value": "do not mutate settled entries", + "dimension": "constraints", + "weight": 2, + "critical": true + } + ], + "requiredAny": [ + { + "values": [ + "Show the diff", + "Return the patch" + ], + "dimension": "output", + "weight": 1, + "critical": false + } + ], + "forbidden": [ + { + "value": "[", + "dimension": "grounding", + "weight": 1, + "critical": true + } + ] + } + } +] diff --git a/spikes/clone-economics/fixtures/train-v2.json b/spikes/clone-economics/fixtures/train-v2.json new file mode 100644 index 0000000..809c934 --- /dev/null +++ b/spikes/clone-economics/fixtures/train-v2.json @@ -0,0 +1,602 @@ +[ + { + "id": "tr-v2-01-01", + "mode": "Optimize", + "input": "Tighten the request for checkout while preserving behavior. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Optimize\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-01-02", + "mode": "Generate", + "input": "Write an implementation request for the checkout change. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Generate\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-01-03", + "mode": "Diagnose", + "input": "The checkout change spread beyond scope; rewrite the request to fix the root cause. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Diagnose\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-01-04", + "mode": "Spec", + "input": "Turn the broad checkout idea into the next implementation specification. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Spec\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-01-05", + "mode": "Optimize", + "input": "Make the checkout prompt explicit about verification and constraints. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Optimize\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-01-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven checkout patch. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Generate\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-01-07", + "mode": "Diagnose", + "input": "The first checkout attempt hid the error; produce a diagnostic request. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Diagnose\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-01-08", + "mode": "Spec", + "input": "Specify the checkout behavior without starting implementation. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Spec\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-01-09", + "mode": "Optimize", + "input": "Remove ambiguity from this checkout maintenance request. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Optimize\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-01-10", + "mode": "Generate", + "input": "Create a repository-grounded request for checkout. Use @src/checkout/totals.ts; verify with npm test -- src/checkout/totals.test.ts; preserve tax rounding exactly.", + "expectedOutput": "Generate\n@src/checkout/totals.ts\nnpm test -- src/checkout/totals.test.ts\npreserve tax rounding exactly\nShow the diff" + }, + { + "id": "tr-v2-02-01", + "mode": "Optimize", + "input": "Tighten the request for auth while preserving behavior. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Optimize\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-02-02", + "mode": "Generate", + "input": "Write an implementation request for the auth change. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Generate\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-02-03", + "mode": "Diagnose", + "input": "The auth change spread beyond scope; rewrite the request to fix the root cause. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Diagnose\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-02-04", + "mode": "Spec", + "input": "Turn the broad auth idea into the next implementation specification. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Spec\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-02-05", + "mode": "Optimize", + "input": "Make the auth prompt explicit about verification and constraints. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Optimize\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-02-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven auth patch. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Generate\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-02-07", + "mode": "Diagnose", + "input": "The first auth attempt hid the error; produce a diagnostic request. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Diagnose\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-02-08", + "mode": "Spec", + "input": "Specify the auth behavior without starting implementation. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Spec\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-02-09", + "mode": "Optimize", + "input": "Remove ambiguity from this auth maintenance request. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Optimize\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-02-10", + "mode": "Generate", + "input": "Create a repository-grounded request for auth. Use @src/auth/logout.ts; verify with npm test -- src/auth/logout.test.ts; reuse the existing session invalidation path.", + "expectedOutput": "Generate\n@src/auth/logout.ts\nnpm test -- src/auth/logout.test.ts\nreuse the existing session invalidation path\nShow the diff" + }, + { + "id": "tr-v2-03-01", + "mode": "Optimize", + "input": "Tighten the request for billing while preserving behavior. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Optimize\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-03-02", + "mode": "Generate", + "input": "Write an implementation request for the billing change. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Generate\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-03-03", + "mode": "Diagnose", + "input": "The billing change spread beyond scope; rewrite the request to fix the root cause. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Diagnose\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-03-04", + "mode": "Spec", + "input": "Turn the broad billing idea into the next implementation specification. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Spec\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-03-05", + "mode": "Optimize", + "input": "Make the billing prompt explicit about verification and constraints. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Optimize\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-03-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven billing patch. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Generate\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-03-07", + "mode": "Diagnose", + "input": "The first billing attempt hid the error; produce a diagnostic request. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Diagnose\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-03-08", + "mode": "Spec", + "input": "Specify the billing behavior without starting implementation. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Spec\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-03-09", + "mode": "Optimize", + "input": "Remove ambiguity from this billing maintenance request. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Optimize\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-03-10", + "mode": "Generate", + "input": "Create a repository-grounded request for billing. Use @src/billing/renewal.ts; verify with npm test -- src/billing/renewal.test.ts; do not change invoice JSON.", + "expectedOutput": "Generate\n@src/billing/renewal.ts\nnpm test -- src/billing/renewal.test.ts\ndo not change invoice JSON\nShow the diff" + }, + { + "id": "tr-v2-04-01", + "mode": "Optimize", + "input": "Tighten the request for worker while preserving behavior. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Optimize\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-04-02", + "mode": "Generate", + "input": "Write an implementation request for the worker change. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Generate\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-04-03", + "mode": "Diagnose", + "input": "The worker change spread beyond scope; rewrite the request to fix the root cause. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Diagnose\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-04-04", + "mode": "Spec", + "input": "Turn the broad worker idea into the next implementation specification. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Spec\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-04-05", + "mode": "Optimize", + "input": "Make the worker prompt explicit about verification and constraints. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Optimize\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-04-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven worker patch. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Generate\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-04-07", + "mode": "Diagnose", + "input": "The first worker attempt hid the error; produce a diagnostic request. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Diagnose\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-04-08", + "mode": "Spec", + "input": "Specify the worker behavior without starting implementation. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Spec\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-04-09", + "mode": "Optimize", + "input": "Remove ambiguity from this worker maintenance request. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Optimize\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-04-10", + "mode": "Generate", + "input": "Create a repository-grounded request for worker. Use @src/jobs/retry.ts; verify with npm test -- src/jobs/retry.test.ts; keep retry attempts idempotent.", + "expectedOutput": "Generate\n@src/jobs/retry.ts\nnpm test -- src/jobs/retry.test.ts\nkeep retry attempts idempotent\nShow the diff" + }, + { + "id": "tr-v2-05-01", + "mode": "Optimize", + "input": "Tighten the request for export while preserving behavior. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Optimize\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-05-02", + "mode": "Generate", + "input": "Write an implementation request for the export change. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Generate\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-05-03", + "mode": "Diagnose", + "input": "The export change spread beyond scope; rewrite the request to fix the root cause. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Diagnose\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-05-04", + "mode": "Spec", + "input": "Turn the broad export idea into the next implementation specification. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Spec\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-05-05", + "mode": "Optimize", + "input": "Make the export prompt explicit about verification and constraints. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Optimize\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-05-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven export patch. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Generate\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-05-07", + "mode": "Diagnose", + "input": "The first export attempt hid the error; produce a diagnostic request. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Diagnose\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-05-08", + "mode": "Spec", + "input": "Specify the export behavior without starting implementation. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Spec\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-05-09", + "mode": "Optimize", + "input": "Remove ambiguity from this export maintenance request. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Optimize\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-05-10", + "mode": "Generate", + "input": "Create a repository-grounded request for export. Use @src/export/csv.ts; verify with npm test -- src/export/csv.test.ts; add no new dependencies.", + "expectedOutput": "Generate\n@src/export/csv.ts\nnpm test -- src/export/csv.test.ts\nadd no new dependencies\nShow the diff" + }, + { + "id": "tr-v2-06-01", + "mode": "Optimize", + "input": "Tighten the request for webhook while preserving behavior. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Optimize\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-06-02", + "mode": "Generate", + "input": "Write an implementation request for the webhook change. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Generate\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-06-03", + "mode": "Diagnose", + "input": "The webhook change spread beyond scope; rewrite the request to fix the root cause. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Diagnose\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-06-04", + "mode": "Spec", + "input": "Turn the broad webhook idea into the next implementation specification. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Spec\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-06-05", + "mode": "Optimize", + "input": "Make the webhook prompt explicit about verification and constraints. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Optimize\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-06-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven webhook patch. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Generate\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-06-07", + "mode": "Diagnose", + "input": "The first webhook attempt hid the error; produce a diagnostic request. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Diagnose\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-06-08", + "mode": "Spec", + "input": "Specify the webhook behavior without starting implementation. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Spec\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-06-09", + "mode": "Optimize", + "input": "Remove ambiguity from this webhook maintenance request. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Optimize\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-06-10", + "mode": "Generate", + "input": "Create a repository-grounded request for webhook. Use @src/webhooks/verify.ts; verify with npm test -- src/webhooks/verify.test.ts; reject invalid signatures without logging secrets.", + "expectedOutput": "Generate\n@src/webhooks/verify.ts\nnpm test -- src/webhooks/verify.test.ts\nreject invalid signatures without logging secrets\nShow the diff" + }, + { + "id": "tr-v2-07-01", + "mode": "Optimize", + "input": "Tighten the request for search while preserving behavior. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Optimize\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-07-02", + "mode": "Generate", + "input": "Write an implementation request for the search change. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Generate\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-07-03", + "mode": "Diagnose", + "input": "The search change spread beyond scope; rewrite the request to fix the root cause. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Diagnose\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-07-04", + "mode": "Spec", + "input": "Turn the broad search idea into the next implementation specification. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Spec\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-07-05", + "mode": "Optimize", + "input": "Make the search prompt explicit about verification and constraints. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Optimize\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-07-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven search patch. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Generate\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-07-07", + "mode": "Diagnose", + "input": "The first search attempt hid the error; produce a diagnostic request. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Diagnose\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-07-08", + "mode": "Spec", + "input": "Specify the search behavior without starting implementation. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Spec\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-07-09", + "mode": "Optimize", + "input": "Remove ambiguity from this search maintenance request. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Optimize\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-07-10", + "mode": "Generate", + "input": "Create a repository-grounded request for search. Use @src/search/query.ts; verify with npm test -- src/search/query.test.ts; preserve the public query API.", + "expectedOutput": "Generate\n@src/search/query.ts\nnpm test -- src/search/query.test.ts\npreserve the public query API\nShow the diff" + }, + { + "id": "tr-v2-08-01", + "mode": "Optimize", + "input": "Tighten the request for profile while preserving behavior. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Optimize\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-08-02", + "mode": "Generate", + "input": "Write an implementation request for the profile change. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Generate\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-08-03", + "mode": "Diagnose", + "input": "The profile change spread beyond scope; rewrite the request to fix the root cause. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Diagnose\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-08-04", + "mode": "Spec", + "input": "Turn the broad profile idea into the next implementation specification. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Spec\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-08-05", + "mode": "Optimize", + "input": "Make the profile prompt explicit about verification and constraints. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Optimize\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-08-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven profile patch. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Generate\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-08-07", + "mode": "Diagnose", + "input": "The first profile attempt hid the error; produce a diagnostic request. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Diagnose\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-08-08", + "mode": "Spec", + "input": "Specify the profile behavior without starting implementation. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Spec\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-08-09", + "mode": "Optimize", + "input": "Remove ambiguity from this profile maintenance request. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Optimize\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-08-10", + "mode": "Generate", + "input": "Create a repository-grounded request for profile. Use @src/profile/update.ts; verify with npm test -- src/profile/update.test.ts; leave unrelated profile fields untouched.", + "expectedOutput": "Generate\n@src/profile/update.ts\nnpm test -- src/profile/update.test.ts\nleave unrelated profile fields untouched\nShow the diff" + }, + { + "id": "tr-v2-09-01", + "mode": "Optimize", + "input": "Tighten the request for migration while preserving behavior. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Optimize\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-09-02", + "mode": "Generate", + "input": "Write an implementation request for the migration change. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Generate\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-09-03", + "mode": "Diagnose", + "input": "The migration change spread beyond scope; rewrite the request to fix the root cause. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Diagnose\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-09-04", + "mode": "Spec", + "input": "Turn the broad migration idea into the next implementation specification. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Spec\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-09-05", + "mode": "Optimize", + "input": "Make the migration prompt explicit about verification and constraints. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Optimize\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-09-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven migration patch. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Generate\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-09-07", + "mode": "Diagnose", + "input": "The first migration attempt hid the error; produce a diagnostic request. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Diagnose\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-09-08", + "mode": "Spec", + "input": "Specify the migration behavior without starting implementation. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Spec\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-09-09", + "mode": "Optimize", + "input": "Remove ambiguity from this migration maintenance request. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Optimize\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-09-10", + "mode": "Generate", + "input": "Create a repository-grounded request for migration. Use @src/db/migrations/042-orders.ts; verify with npm test -- src/db/migrations/042-orders.test.ts; keep the migration reversible.", + "expectedOutput": "Generate\n@src/db/migrations/042-orders.ts\nnpm test -- src/db/migrations/042-orders.test.ts\nkeep the migration reversible\nShow the diff" + }, + { + "id": "tr-v2-10-01", + "mode": "Optimize", + "input": "Tighten the request for notifications while preserving behavior. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Optimize\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + }, + { + "id": "tr-v2-10-02", + "mode": "Generate", + "input": "Write an implementation request for the notifications change. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Generate\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + }, + { + "id": "tr-v2-10-03", + "mode": "Diagnose", + "input": "The notifications change spread beyond scope; rewrite the request to fix the root cause. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Diagnose\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + }, + { + "id": "tr-v2-10-04", + "mode": "Spec", + "input": "Turn the broad notifications idea into the next implementation specification. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Spec\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + }, + { + "id": "tr-v2-10-05", + "mode": "Optimize", + "input": "Make the notifications prompt explicit about verification and constraints. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Optimize\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + }, + { + "id": "tr-v2-10-06", + "mode": "Generate", + "input": "Ask for the smallest test-driven notifications patch. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Generate\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + }, + { + "id": "tr-v2-10-07", + "mode": "Diagnose", + "input": "The first notifications attempt hid the error; produce a diagnostic request. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Diagnose\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + }, + { + "id": "tr-v2-10-08", + "mode": "Spec", + "input": "Specify the notifications behavior without starting implementation. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Spec\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + }, + { + "id": "tr-v2-10-09", + "mode": "Optimize", + "input": "Remove ambiguity from this notifications maintenance request. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Optimize\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + }, + { + "id": "tr-v2-10-10", + "mode": "Generate", + "input": "Create a repository-grounded request for notifications. Use @src/notifications/digest.ts; verify with npm test -- src/notifications/digest.test.ts; send at most one digest per account.", + "expectedOutput": "Generate\n@src/notifications/digest.ts\nnpm test -- src/notifications/digest.test.ts\nsend at most one digest per account\nShow the diff" + } +] diff --git a/spikes/clone-economics/scripts/generate-fixtures.mjs b/spikes/clone-economics/scripts/generate-fixtures.mjs new file mode 100644 index 0000000..237d81e --- /dev/null +++ b/spikes/clone-economics/scripts/generate-fixtures.mjs @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const catalog = JSON.parse(fs.readFileSync(path.join(root, 'fixtures/fixture-catalog-v2.json'), 'utf8')); + +function inputFor(template, domain) { + return `${template.text.replace('{slug}', domain.slug)} Use ${domain.path}; verify with ${domain.command}; ${domain.constraint}.`; +} + +const train = catalog.trainDomains.flatMap((domain, domainIndex) => + catalog.trainTemplates.map((template, templateIndex) => ({ + id: `tr-v2-${String(domainIndex + 1).padStart(2, '0')}-${String(templateIndex + 1).padStart(2, '0')}`, + mode: template.mode, + input: inputFor(template, domain), + expectedOutput: `${template.mode}\n${domain.path}\n${domain.command}\n${domain.constraint}\nShow the diff`, + }))); + +const heldout = catalog.heldoutDomains.flatMap((domain, domainIndex) => + catalog.heldoutTemplates.map((template, templateIndex) => ({ + id: `ho-v2-${String(domainIndex + 1).padStart(2, '0')}-${String(templateIndex + 1).padStart(2, '0')}`, + mode: template.mode, + input: inputFor(template, domain), + rubric: { + expectedMode: template.mode, + maxQuestions: template.maxQuestions, + exactPaths: [{ value: domain.path, weight: 2, critical: true }], + exactCommands: [{ value: domain.command, weight: 2, critical: true }], + requiredAll: [{ value: domain.constraint, dimension: 'constraints', weight: 2, critical: true }], + requiredAny: [{ values: ['Show the diff', 'Return the patch'], dimension: 'output', weight: 1, critical: false }], + forbidden: [{ value: '[', dimension: 'grounding', weight: 1, critical: true }], + }, + }))); + +for (const [file, data] of [['train-v2.json', train], ['heldout-v2.json', heldout]]) { + const output = `${JSON.stringify(data, null, 2)}\n`; + const target = path.join(root, 'fixtures', file); + if (process.argv.includes('--check')) { + if (!fs.existsSync(target) || fs.readFileSync(target, 'utf8') !== output) { + throw new Error(`Generated fixture drift: ${file}`); + } + } else { + fs.writeFileSync(target, output); + } +} diff --git a/spikes/clone-economics/src/fixture-set.mjs b/spikes/clone-economics/src/fixture-set.mjs new file mode 100644 index 0000000..54e4318 --- /dev/null +++ b/spikes/clone-economics/src/fixture-set.mjs @@ -0,0 +1,19 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +export const normalizedInputHash = (value) => `sha256:${createHash('sha256') + .update(value.trim().replace(/\s+/g, ' ').toLowerCase()).digest('hex')}`; + +export function loadFixtureSet(root, name) { + const train = JSON.parse(fs.readFileSync(path.join(root, `fixtures/train-${name}.json`), 'utf8')); + const heldout = JSON.parse(fs.readFileSync(path.join(root, `fixtures/heldout-${name}.json`), 'utf8')); + const decorate = (items) => items.map((item) => ({ ...item, inputHash: normalizedInputHash(item.input) })); + const decoratedTrain = decorate(train); + const decoratedHeldout = decorate(heldout); + const trainIds = new Set(decoratedTrain.map((x) => x.id)); + const trainHashes = new Set(decoratedTrain.map((x) => x.inputHash)); + const disjoint = decoratedHeldout.every((x) => !trainIds.has(x.id) && !trainHashes.has(x.inputHash)); + if (!disjoint) throw new Error('Train and heldout fixtures must be disjoint by ID and normalized-input hash'); + return { train: decoratedTrain, heldout: decoratedHeldout, disjoint }; +} diff --git a/spikes/clone-economics/tests/fixture-set.test.mjs b/spikes/clone-economics/tests/fixture-set.test.mjs new file mode 100644 index 0000000..6e6f25b --- /dev/null +++ b/spikes/clone-economics/tests/fixture-set.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { loadFixtureSet } from '../src/fixture-set.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('v2 fixtures contain 100 train and 30 disjoint heldout cases', () => { + const fixtures = loadFixtureSet(root, 'v2'); + assert.equal(fixtures.train.length, 100); + assert.equal(fixtures.heldout.length, 30); + assert.equal(fixtures.disjoint, true); + assert.equal(new Set(fixtures.train.map((x) => x.id)).size, 100); + assert.equal(new Set(fixtures.heldout.map((x) => x.id)).size, 30); + assert.equal(fixtures.heldout.every((x) => x.rubric && x.rubric.exactPaths.length === 1), true); +}); + +test('fixture generation is byte deterministic', () => { + const train = fs.readFileSync(path.join(root, 'fixtures/train-v2.json'), 'utf8'); + const heldout = fs.readFileSync(path.join(root, 'fixtures/heldout-v2.json'), 'utf8'); + execFileSync(process.execPath, ['scripts/generate-fixtures.mjs', '--check'], { cwd: root }); + assert.equal(fs.readFileSync(path.join(root, 'fixtures/train-v2.json'), 'utf8'), train); + assert.equal(fs.readFileSync(path.join(root, 'fixtures/heldout-v2.json'), 'utf8'), heldout); +}); From 30efdeaeca8a0556820d363a8164d3d1673673e1 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:17:40 -0400 Subject: [PATCH 036/165] docs: preregister clone high-N sweep --- spikes/clone-economics/fixtures/sweep-v1.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 spikes/clone-economics/fixtures/sweep-v1.json diff --git a/spikes/clone-economics/fixtures/sweep-v1.json b/spikes/clone-economics/fixtures/sweep-v1.json new file mode 100644 index 0000000..1a8d9ec --- /dev/null +++ b/spikes/clone-economics/fixtures/sweep-v1.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "experimentFamily": "clone-economics-high-n-v1", + "fixtureSet": "v2", + "nValues": [6, 25, 50, 100], + "heldoutMinimum": 30, + "replicates": [ + { "replicateId": "r1", "pairOrderSeed": 1701, "distillationSeed": 2701 }, + { "replicateId": "r2", "pairOrderSeed": 1702, "distillationSeed": 2702 }, + { "replicateId": "r3", "pairOrderSeed": 1703, "distillationSeed": 2703 } + ], + "highNDefinition": 100, + "targetThreshold": 0.8, + "requireAllTargetCriticalGates": true, + "acquisitionTreatment": "modeled_unless_x402_receipts_attached", + "attemptCostTreatment": "include_every_attempted_provider_call", + "publicationRequiresValidTarget": true, + "publicationRequiresIndependentDistillationSeeds": true +} From 75e818f2829ed92b9551a15b81d77f73b02dd2dc Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:17:51 -0400 Subject: [PATCH 037/165] docs: add unapproved clone sweep budget contract --- .../fixtures/live-budget-v1.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 spikes/clone-economics/fixtures/live-budget-v1.json diff --git a/spikes/clone-economics/fixtures/live-budget-v1.json b/spikes/clone-economics/fixtures/live-budget-v1.json new file mode 100644 index 0000000..d56d8ff --- /dev/null +++ b/spikes/clone-economics/fixtures/live-budget-v1.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "experimentFamily": "clone-economics-high-n-v1", + "approvalStatus": "not_approved", + "provider": "anthropic", + "model": null, + "pricing": { + "currency": "USD", + "unit": "per_million_tokens", + "inputUsdPerMillionTokens": null, + "outputUsdPerMillionTokens": null, + "asOf": null, + "source": null + }, + "tokenCaps": { + "maxInputTokens": null, + "maxOutputTokens": null + } +} From ef0ae80f6726d17a738b48448fc0f1a5e2010731 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:23:01 -0400 Subject: [PATCH 038/165] fix: harden clone validity and retained evidence checks --- spikes/clone-economics/e2e.mjs | 13 ++++---- spikes/clone-economics/src/tree-snapshot.mjs | 32 +++++++++++++++++++ spikes/clone-economics/src/validity.mjs | 10 ++++++ .../tests/e2e-coexistence.test.mjs | 23 ++++++++++--- .../clone-economics/tests/validity.test.mjs | 31 ++++++++++++++++++ 5 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 spikes/clone-economics/src/tree-snapshot.mjs diff --git a/spikes/clone-economics/e2e.mjs b/spikes/clone-economics/e2e.mjs index 95c0161..1dc4203 100644 --- a/spikes/clone-economics/e2e.mjs +++ b/spikes/clone-economics/e2e.mjs @@ -17,6 +17,7 @@ globalThis.fetch = async () => { }; const { runExperiment } = await import('./src/experiment.mjs'); +const { treeSnapshot } = await import('./src/tree-snapshot.mjs'); let checks = 0; function ok(condition, label) { @@ -35,10 +36,6 @@ const targetPath = path.resolve(here, '../../.claude/skills/optimizing-claude-co const referencePath = path.resolve(here, '../../.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md'); const targetText = fs.readFileSync(targetPath, 'utf8'); const referenceText = fs.readFileSync(referencePath, 'utf8'); -function treeSnapshot(directory) { - if (!fs.existsSync(directory)) return []; - return fs.readdirSync(directory, { recursive: true }).map(String).sort(); -} const retainedRuns = path.join(here, 'runs'); const runsBefore = treeSnapshot(retainedRuns); const outputA = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-economics-a-')); @@ -56,7 +53,9 @@ const config = { console.log('\nClone-economics e2e — MOCK_LLM=1, network disabled\n'); try { const first = await runExperiment({ ...config, outputDir: outputA }); + eq(treeSnapshot(retainedRuns), runsBefore, 'first mock scenario preserves retained run bytes'); const second = await runExperiment({ ...config, outputDir: outputB }); + eq(treeSnapshot(retainedRuns), runsBefore, 'repeat mock scenario preserves retained run bytes'); const report = first.report; eq(report.mode, 'mock', 'mock mode recorded'); @@ -162,13 +161,12 @@ try { ok(!first.markdownReport.split('\n').some((line) => /[ \t]+$/.test(line)), 'Markdown report contains no trailing whitespace'); eq(fs.readFileSync(first.outputFiles.json, 'utf8'), fs.readFileSync(second.outputFiles.json, 'utf8'), 'two JSON report runs are byte-identical'); eq(fs.readFileSync(first.outputFiles.markdown, 'utf8'), fs.readFileSync(second.outputFiles.markdown, 'utf8'), 'two Markdown report runs are byte-identical'); - eq(treeSnapshot(retainedRuns), runsBefore, 'e2e leaves pre-existing run artifacts unchanged'); - const unknownTranscript = JSON.parse(fs.readFileSync(path.join(here, 'fixtures/mock-transcript.json'), 'utf8')); unknownTranscript.usageProfiles.distill.inputTokens = null; unknownTranscript.usageProfiles.distill.costUsd = null; for (const outputs of Object.values(unknownTranscript.heldoutOutputs)) outputs.target = 'Wrong [placeholder]???'; const unknown = await runExperiment({ ...config, outputDir: outputA, mockTranscript: unknownTranscript }); + eq(treeSnapshot(retainedRuns), runsBefore, 'invalid-target scenario preserves retained run bytes'); eq(unknown.report.economics.distillationProviderUsd, null, 'missing provider usage keeps D unknown, never zero'); eq(unknown.report.economics.attackerBuildUsd, null, 'unknown D propagates to B'); eq(unknown.report.economics.distillationToAcquisition, null, 'unknown D propagates to D/A'); @@ -219,6 +217,7 @@ try { outputDir: outputA, adapter: missingUsageReplayAdapter, }); + eq(treeSnapshot(retainedRuns), runsBefore, 'missing-usage replay preserves retained run bytes'); ok(missingLiveUsage.report.evidenceLabel.includes('measured where returned; unknown otherwise'), 'live summary labels incomplete usage as measured where returned and unknown otherwise'); ok(missingLiveUsage.report.economics.evidenceLabel.includes('measured where returned; unknown otherwise'), 'live economics labels incomplete cost as measured where returned and unknown otherwise'); ok(missingLiveUsage.report.usage.evidenceLabel.includes('measured where returned; unknown otherwise'), 'live usage labels incomplete fields as measured where returned and unknown otherwise'); @@ -244,6 +243,7 @@ try { checks += 1; await assert.rejects(runExperiment(liveConfig), /ALLOW_LIVE_LLM=1/, 'explicit live opt-in blocks a fully configured run'); console.log(' ✓ explicit live opt-in blocks a fully configured run'); + eq(treeSnapshot(retainedRuns), runsBefore, 'live opt-in rejection preserves retained run bytes'); eq(networkAttempts, 0, 'opt-in rejection occurs before fetch'); process.env.ALLOW_LIVE_LLM = '1'; @@ -254,6 +254,7 @@ try { 'configured input-token bound aborts before fetch', ); console.log(' ✓ configured input-token bound aborts before fetch'); + eq(treeSnapshot(retainedRuns), runsBefore, 'input-bound rejection preserves retained run bytes'); eq(networkAttempts, 0, 'input-bound rejection occurs before fetch'); process.env.MOCK_LLM = '1'; process.env.ALLOW_LIVE_LLM = '0'; diff --git a/spikes/clone-economics/src/tree-snapshot.mjs b/spikes/clone-economics/src/tree-snapshot.mjs new file mode 100644 index 0000000..f200f03 --- /dev/null +++ b/spikes/clone-economics/src/tree-snapshot.mjs @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +export function treeSnapshot(directory) { + if (!fs.existsSync(directory)) return []; + const entries = []; + + function visit(current, relative) { + const stats = fs.lstatSync(current); + if (stats.isSymbolicLink()) { + const target = fs.readlinkSync(current); + entries.push({ path: relative, type: 'symlink', bytes: Buffer.byteLength(target), sha256: sha256(target) }); + return; + } + if (stats.isDirectory()) { + if (relative) entries.push({ path: relative, type: 'directory' }); + for (const name of fs.readdirSync(current).sort()) { + visit(path.join(current, name), relative ? `${relative}/${name}` : name); + } + return; + } + if (!stats.isFile()) throw new Error(`Unsupported retained-run entry type: ${relative}`); + const bytes = fs.readFileSync(current); + entries.push({ path: relative, type: 'file', bytes: bytes.length, sha256: sha256(bytes) }); + } + + visit(directory, ''); + return entries; +} diff --git a/spikes/clone-economics/src/validity.mjs b/spikes/clone-economics/src/validity.mjs index 2290b05..4f5da76 100644 --- a/spikes/clone-economics/src/validity.mjs +++ b/spikes/clone-economics/src/validity.mjs @@ -1,6 +1,16 @@ export const INVALID_TARGET_VERDICT = 'INVALID_BENCHMARK_TARGET_FAILED'; export function assessBenchmark({ threshold, target }) { + if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) { + throw new TypeError('Benchmark threshold must be a finite number greater than 0 and at most 1'); + } + if (!target || !Number.isFinite(target.absoluteScore) + || target.absoluteScore < 0 || target.absoluteScore > 1) { + throw new TypeError('Benchmark target absoluteScore must be a finite number from 0 to 1'); + } + if (typeof target.criticalGatePass !== 'boolean') { + throw new TypeError('Benchmark target criticalGatePass must be boolean'); + } const scoreFailed = target.absoluteScore < threshold; const gatesFailed = !target.criticalGatePass; if (scoreFailed || gatesFailed) { diff --git a/spikes/clone-economics/tests/e2e-coexistence.test.mjs b/spikes/clone-economics/tests/e2e-coexistence.test.mjs index bb96629..3336444 100644 --- a/spikes/clone-economics/tests/e2e-coexistence.test.mjs +++ b/spikes/clone-economics/tests/e2e-coexistence.test.mjs @@ -5,18 +5,33 @@ import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { treeSnapshot } from '../src/tree-snapshot.mjs'; + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); test('offline e2e preserves an existing ignored runs directory', (t) => { - const marker = path.join(root, 'runs', 'e2e-retained-marker.txt'); + const retainedRoot = path.join(root, 'runs', 'e2e-retained-regression'); + const marker = path.join(retainedRoot, 'live', 'nested', 'e2e-retained-marker.bin'); + const expected = Buffer.from([0, 1, 2, 10, 13, 255]); fs.mkdirSync(path.dirname(marker), { recursive: true }); - fs.writeFileSync(marker, 'retain me\n'); - t.after(() => fs.rmSync(marker, { force: true })); + fs.writeFileSync(marker, expected); + t.after(() => fs.rmSync(retainedRoot, { recursive: true, force: true })); const output = execFileSync(process.execPath, ['e2e.mjs'], { cwd: root, env: { ...process.env, MOCK_LLM: '1', ALLOW_LIVE_LLM: '0' }, encoding: 'utf8', }); assert.match(output, /PASS/); - assert.equal(fs.readFileSync(marker, 'utf8'), 'retain me\n'); + assert.deepEqual(fs.readFileSync(marker), expected); +}); + +test('tree snapshots detect changed bytes at an unchanged nested path', (t) => { + const directory = fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'clone-tree-snapshot-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const marker = path.join(directory, 'nested', 'marker.bin'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, Buffer.from('before')); + const before = treeSnapshot(directory); + fs.writeFileSync(marker, Buffer.from('after!')); + assert.notDeepEqual(treeSnapshot(directory), before); }); diff --git a/spikes/clone-economics/tests/validity.test.mjs b/spikes/clone-economics/tests/validity.test.mjs index c183733..d3ebae3 100644 --- a/spikes/clone-economics/tests/validity.test.mjs +++ b/spikes/clone-economics/tests/validity.test.mjs @@ -36,3 +36,34 @@ test('a passing target admits a clone result without deciding its meaning', () = assert.equal(result.cloneConclusionAllowed, true); assert.equal(result.economicsConclusionAllowed, true); }); + +test('a score-only target failure invalidates the benchmark', () => { + const result = assessBenchmark({ threshold: 0.8, target: score(0.4, true) }); + assert.equal(result.valid, false); + assert.equal(result.reason, 'Target score 0.400 is below 0.800.'); +}); + +test('a critical-gate-only target failure invalidates the benchmark', () => { + const result = assessBenchmark({ threshold: 0.8, target: score(0.9, false) }); + assert.equal(result.valid, false); + assert.equal(result.reason, 'target critical gates failed.'); +}); + +test('malformed scores and thresholds throw instead of failing open', () => { + for (const malformed of [Number.NaN, Number.POSITIVE_INFINITY, '0.9', null]) { + assert.throws( + () => assessBenchmark({ threshold: 0.8, target: score(malformed, true) }), + /target absoluteScore must be a finite number from 0 to 1/, + ); + } + for (const malformed of [Number.NaN, Number.POSITIVE_INFINITY, '0.8', 0, 1.1]) { + assert.throws( + () => assessBenchmark({ threshold: malformed, target: score(0.9, true) }), + /threshold must be a finite number greater than 0 and at most 1/, + ); + } + assert.throws( + () => assessBenchmark({ threshold: 0.8, target: { ...score(0.9, true), criticalGatePass: 'yes' } }), + /criticalGatePass must be boolean/, + ); +}); From bf98732cfa95588abd883c8bc1ad0e42a0efc76e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:34:07 -0400 Subject: [PATCH 039/165] feat: add gated clone high-N sweep --- spikes/clone-economics/package.json | 5 + spikes/clone-economics/src/adapters.mjs | 343 ++++++++++++++---- spikes/clone-economics/src/authorization.mjs | 36 ++ spikes/clone-economics/src/budget.mjs | 217 +++++++++++ spikes/clone-economics/src/experiment.mjs | 90 ++++- spikes/clone-economics/src/sweep.mjs | 291 +++++++++++++++ spikes/clone-economics/sweep.mjs | 100 +++++ .../tests/adapters-budget.test.mjs | 193 ++++++++++ .../tests/authorization.test.mjs | 51 +++ spikes/clone-economics/tests/budget.test.mjs | 140 +++++++ .../tests/fixtures/live-contract.mjs | 31 ++ .../clone-economics/tests/sweep-cli.test.mjs | 52 +++ spikes/clone-economics/tests/sweep.test.mjs | 139 +++++++ 13 files changed, 1598 insertions(+), 90 deletions(-) create mode 100644 spikes/clone-economics/src/authorization.mjs create mode 100644 spikes/clone-economics/src/budget.mjs create mode 100644 spikes/clone-economics/src/sweep.mjs create mode 100644 spikes/clone-economics/sweep.mjs create mode 100644 spikes/clone-economics/tests/adapters-budget.test.mjs create mode 100644 spikes/clone-economics/tests/authorization.test.mjs create mode 100644 spikes/clone-economics/tests/budget.test.mjs create mode 100644 spikes/clone-economics/tests/fixtures/live-contract.mjs create mode 100644 spikes/clone-economics/tests/sweep-cli.test.mjs create mode 100644 spikes/clone-economics/tests/sweep.test.mjs diff --git a/spikes/clone-economics/package.json b/spikes/clone-economics/package.json index 49e07b6..6d69caf 100644 --- a/spikes/clone-economics/package.json +++ b/spikes/clone-economics/package.json @@ -6,6 +6,11 @@ "description": "Throwaway offline-first harness for Skill clone economics and staleness.", "scripts": { "e2e": "MOCK_LLM=1 ALLOW_LIVE_LLM=0 node e2e.mjs", + "test": "node --test tests/*.test.mjs && npm run e2e", + "fixtures:check": "node scripts/generate-fixtures.mjs --check", + "sweep:preflight": "node sweep.mjs --preflight", + "sweep:mock": "MOCK_LLM=1 ALLOW_LIVE_LLM=0 node sweep.mjs --mock", + "sweep:live": "MOCK_LLM=0 node sweep.mjs --live", "run": "MOCK_LLM=1 node run.mjs", "real": "MOCK_LLM=0 node run.mjs --live" } diff --git a/spikes/clone-economics/src/adapters.mjs b/spikes/clone-economics/src/adapters.mjs index 44703c0..ce97ea4 100644 --- a/spikes/clone-economics/src/adapters.mjs +++ b/spikes/clone-economics/src/adapters.mjs @@ -1,5 +1,11 @@ import { performance } from 'node:perf_hooks'; +import { + calculateProviderCostMicroUsd, + createAttemptBudget, + exceedsCommittedTokenCaps, +} from './budget.mjs'; + const clone = (value) => structuredClone(value); const rounded = (value) => Number(value.toFixed(12)); @@ -13,28 +19,73 @@ const LIVE_KIND_INSTRUCTIONS = { distill: 'Using only payload.instructions and payload.pairs, author one valid SKILL.md that reproduces the demonstrated capability. Return SKILL.md only.', }; +function seedEvidence(request, mode) { + const requestedSeed = request.kind === 'distill' + ? request.requestedDistillationSeed ?? null + : null; + if (requestedSeed !== null && !Number.isSafeInteger(requestedSeed)) { + throw new Error('Requested distillation seed must be a safe integer'); + } + if (requestedSeed === null) { + return { + requestedSeed: null, + appliedSeed: null, + status: 'not_requested', + mechanism: 'no_seed_requested', + }; + } + if (mode === 'mock') { + return { + requestedSeed, + appliedSeed: requestedSeed, + status: 'synthetic_honored', + mechanism: 'deterministic_mock_fixture_selection', + }; + } + return { + requestedSeed, + appliedSeed: null, + status: 'unsupported', + mechanism: 'provider_seed_not_supported_by_adapter', + }; +} + export class MockLlmAdapter { - constructor({ transcript, cloneSkillMd }) { + constructor({ transcript, cloneSkillMd, outputFor = null }) { this.transcript = transcript; this.cloneSkillMd = cloneSkillMd; + this.outputFor = outputFor; this.capturedRequests = []; this.records = []; + this.attempts = []; this.pricing = transcript.pricing; } async invoke(request) { this.capturedRequests.push(clone(request)); - let output; - if (request.kind === 'distill') output = this.cloneSkillMd; - else if (request.kind === 'target-train') output = this.transcript.trainOutputs[request.caseId]; - else if (request.kind.endsWith('-v2-heldout')) { - const profile = request.kind.startsWith('target-') ? 'target' : 'clone'; - output = this.transcript.v2Outputs[request.caseId]?.[profile]; - } else { - const profile = request.kind === 'target-heldout' ? 'target' : request.kind === 'clone-heldout' ? 'clone' : 'bad'; - output = this.transcript.heldoutOutputs[request.caseId]?.[profile]; + const requestIdentifier = { + kind: request.kind, + caseId: request.caseId ?? null, + requestedDistillationSeed: request.requestedDistillationSeed ?? null, + }; + const customOutput = this.outputFor?.(requestIdentifier); + let output = typeof customOutput === 'string' ? customOutput : undefined; + if (output === undefined) { + if (request.kind === 'distill') output = this.cloneSkillMd; + else if (request.kind === 'target-train') output = this.transcript.trainOutputs[request.caseId]; + else if (request.kind.endsWith('-v2-heldout')) { + const profile = request.kind.startsWith('target-') ? 'target' : 'clone'; + output = this.transcript.v2Outputs[request.caseId]?.[profile]; + } else { + const profile = request.kind === 'target-heldout' + ? 'target' + : request.kind === 'clone-heldout' ? 'clone' : 'bad'; + output = this.transcript.heldoutOutputs[request.caseId]?.[profile]; + } + } + if (typeof output !== 'string') { + throw new Error(`Missing SYNTHETIC transcript output for ${request.kind}:${request.caseId ?? 'distill'}`); } - if (typeof output !== 'string') throw new Error(`Missing SYNTHETIC transcript output for ${request.kind}:${request.caseId ?? 'distill'}`); const profile = this.transcript.usageProfiles[request.kind]; if (!profile) throw new Error(`Missing SYNTHETIC usage profile for ${request.kind}`); const derivedCostUsd = Number.isFinite(profile.inputTokens) && Number.isFinite(profile.outputTokens) @@ -43,9 +94,11 @@ export class MockLlmAdapter { + profile.outputTokens * this.pricing.outputUsdPerMillion ) / 1_000_000) : null; - if (profile.costUsd !== null && (!Number.isFinite(derivedCostUsd) || Math.abs(profile.costUsd - derivedCostUsd) > 1e-12)) { + if (profile.costUsd !== null && (!Number.isFinite(derivedCostUsd) + || Math.abs(profile.costUsd - derivedCostUsd) > 1e-12)) { throw new Error(`SYNTHETIC cost does not reconcile with usage and pricing for ${request.kind}`); } + const seed = seedEvidence(request, 'mock'); const record = { requestId: `mock-${String(this.records.length + 1).padStart(3, '0')}`, kind: request.kind, @@ -58,7 +111,23 @@ export class MockLlmAdapter { latencyMs: profile.latencyMs, }; this.records.push(record); - return { output, ...record }; + this.attempts.push({ + attemptId: `${request.kind}:${request.caseId ?? 'distill'}:${this.attempts.length + 1}`, + kind: request.kind, + caseId: request.caseId ?? null, + success: true, + providerRequestId: record.requestId, + latencyMs: record.latencyMs, + inputTokens: record.normalizedUsage.inputTokens, + outputTokens: record.normalizedUsage.outputTokens, + providerCostMicroUsd: derivedCostUsd === null + ? null + : String(Math.round(derivedCostUsd * 1_000_000)), + providerCostUsd: derivedCostUsd, + failureClass: null, + ...seed, + }); + return { output, ...record, seed }; } } @@ -72,89 +141,213 @@ function requiredPositive(value, name) { return value; } +function legacySnapshot(config) { + return { + schemaVersion: 1, + experimentFamily: 'legacy-single-clone-run', + approvalStatus: 'approved', + provider: 'anthropic', + model: requiredString(config.model, 'MODEL'), + pricing: { + currency: 'USD', + unit: 'per_million_tokens', + inputUsdPerMillionTokens: String(requiredPositive(config.inputUsdPerMillion, 'INPUT_USD_PER_MILLION')), + outputUsdPerMillionTokens: String(requiredPositive(config.outputUsdPerMillion, 'OUTPUT_USD_PER_MILLION')), + asOf: requiredString(config.pricingAsOf, 'PRICING_AS_OF'), + source: requiredString(config.pricingSource, 'PRICING_SOURCE'), + }, + tokenCaps: { + maxInputTokens: requiredPositive(config.maxInputTokens, 'MAX_INPUT_TOKENS'), + maxOutputTokens: requiredPositive(config.maxTokens, 'MAX_TOKENS'), + }, + }; +} + export class LiveAnthropicAdapter { constructor(config) { - if (config.mode !== 'live' || process.env.MOCK_LLM === '1') throw new Error('Live adapter requires non-mock live mode'); - if (process.env.ALLOW_LIVE_LLM !== '1') throw new Error('ALLOW_LIVE_LLM=1 is required before any live adapter construction'); + if (config.mode !== 'live' || process.env.MOCK_LLM === '1') { + throw new Error('Live adapter requires non-mock live mode'); + } + const syntheticTestTransport = config.testOnlyNoNetwork === true + && typeof config.fetchImpl === 'function' + && String(config.apiKey).startsWith('synthetic-'); + if (process.env.ALLOW_LIVE_LLM !== '1' && !syntheticTestTransport) { + throw new Error('ALLOW_LIVE_LLM=1 is required before any live adapter construction'); + } this.apiKey = requiredString(config.apiKey, 'ANTHROPIC_API_KEY'); - this.model = requiredString(config.model, 'MODEL'); - this.N = requiredPositive(config.N, 'N'); - this.maxInputTokens = requiredPositive(config.maxInputTokens, 'MAX_INPUT_TOKENS'); - this.maxTokens = requiredPositive(config.maxTokens, 'MAX_TOKENS'); + this.snapshot = config.snapshot ?? legacySnapshot(config); + this.model = requiredString(this.snapshot.model, 'MODEL'); + this.maxInputTokens = requiredPositive(this.snapshot.tokenCaps.maxInputTokens, 'MAX_INPUT_TOKENS'); + this.maxTokens = requiredPositive(this.snapshot.tokenCaps.maxOutputTokens, 'MAX_TOKENS'); this.pricing = { - inputUsdPerMillion: requiredPositive(config.inputUsdPerMillion, 'INPUT_USD_PER_MILLION'), - outputUsdPerMillion: requiredPositive(config.outputUsdPerMillion, 'OUTPUT_USD_PER_MILLION'), - asOf: requiredString(config.pricingAsOf, 'PRICING_AS_OF'), - source: requiredString(config.pricingSource, 'PRICING_SOURCE'), + inputUsdPerMillion: Number(this.snapshot.pricing.inputUsdPerMillionTokens), + outputUsdPerMillion: Number(this.snapshot.pricing.outputUsdPerMillionTokens), + asOf: this.snapshot.pricing.asOf, + source: this.snapshot.pricing.source, }; - this.maxRunCostUsd = requiredPositive(config.maxRunCostUsd, 'MAX_RUN_COST_USD'); - const perRequestCap = ( - this.maxInputTokens * this.pricing.inputUsdPerMillion - + this.maxTokens * this.pricing.outputUsdPerMillion - ) / 1_000_000; - this.conservativeMaxCostUsd = perRequestCap * requiredPositive(config.estimatedRequests, 'estimatedRequests'); - if (this.conservativeMaxCostUsd > this.maxRunCostUsd) { - throw new Error(`Conservative maximum $${this.conservativeMaxCostUsd.toFixed(6)} exceeds MAX_RUN_COST_USD $${this.maxRunCostUsd.toFixed(6)}`); + const legacyCapMicroUsd = config.maxRunCostUsd === undefined + ? null + : BigInt(Math.round(requiredPositive(config.maxRunCostUsd, 'MAX_RUN_COST_USD') * 1_000_000)); + const legacyWorstCaseMicroUsd = calculateProviderCostMicroUsd({ + inputTokens: this.maxInputTokens, + outputTokens: this.maxTokens, + snapshot: this.snapshot, + }); + if (!config.budget) { + const requests = requiredPositive(config.estimatedRequests, 'estimatedRequests'); + const conservative = legacyWorstCaseMicroUsd * BigInt(requests); + if (conservative > legacyCapMicroUsd) { + throw new Error(`Conservative maximum $${(Number(conservative) / 1_000_000).toFixed(6)} exceeds MAX_RUN_COST_USD $${(Number(legacyCapMicroUsd) / 1_000_000).toFixed(6)}`); + } } + this.budget = config.budget ?? createAttemptBudget({ + capMicroUsd: legacyCapMicroUsd, + worstCaseCallMicroUsd: legacyWorstCaseMicroUsd, + }); + this.fetchImpl = config.fetchImpl ?? globalThis.fetch; + if (typeof this.fetchImpl !== 'function') throw new Error('A fetch implementation is required'); this.capturedRequests = []; this.records = []; - this.measuredSpendUsd = 0; + this.attempts = []; } async invoke(request) { this.capturedRequests.push(clone(request)); const instruction = LIVE_KIND_INSTRUCTIONS[request.kind]; if (!instruction) throw new Error(`Unsupported live request kind: ${request.kind}`); + const seed = seedEvidence(request, 'live'); const prompt = JSON.stringify({ instruction, payload: request.payload }); - // UTF-8 bytes are a deliberately conservative upper bound for tokenizer - // units: abort before fetch if even that bound exceeds the operator cap. const inputTokenUpperBound = Buffer.byteLength(prompt, 'utf8'); if (inputTokenUpperBound > this.maxInputTokens) { throw new Error(`Input token upper bound ${inputTokenUpperBound} exceeds MAX_INPUT_TOKENS ${this.maxInputTokens}`); } + const started = performance.now(); - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'anthropic-version': '2023-06-01', - 'x-api-key': this.apiKey, - }, - body: JSON.stringify({ + let reservationId = null; + let observedUsage = null; + let knownCostMicroUsd = null; + let providerRequestId = null; + let originalError = null; + let capError = null; + try { + reservationId = this.budget.reserveNextAttempt({ kind: request.kind, caseId: request.caseId ?? null }); + const response = await this.fetchImpl('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': this.apiKey, + }, + body: JSON.stringify({ + model: this.model, + max_tokens: this.maxTokens, + messages: [{ role: 'user', content: prompt }], + }), + }); + let json; + try { + json = await response.json(); + } catch (error) { + throw new Error(`Anthropic response JSON failed: ${error instanceof Error ? error.message : 'unknown parse failure'}`); + } + providerRequestId = typeof json.id === 'string' ? json.id : null; + const inputTokens = json.usage?.input_tokens; + const outputTokens = json.usage?.output_tokens; + if (Number.isSafeInteger(inputTokens) && inputTokens >= 0 + && Number.isSafeInteger(outputTokens) && outputTokens >= 0) { + observedUsage = { inputTokens, outputTokens }; + knownCostMicroUsd = calculateProviderCostMicroUsd({ inputTokens, outputTokens, snapshot: this.snapshot }); + } + if (!response.ok) throw new Error(`Anthropic request failed with HTTP ${response.status}`); + if (!observedUsage) throw new Error('Anthropic response omitted valid usage'); + const tokenCapExceeded = exceedsCommittedTokenCaps({ ...observedUsage, snapshot: this.snapshot }); + if (tokenCapExceeded) capError = new Error('Observed provider usage exceeded committed token cap'); + this.budget.settleAttempt(reservationId, { + knownCostMicroUsd, + success: !tokenCapExceeded, + budgetViolation: tokenCapExceeded ? 'token_cap_exceeded' : null, + }); + const latencyMs = performance.now() - started; + const costUsd = Number(knownCostMicroUsd) / 1_000_000; + const record = { + requestId: providerRequestId ?? `live-${this.records.length + 1}`, + kind: request.kind, + caseId: request.caseId ?? null, + evidenceLabel: 'MEASURED', model: this.model, - max_tokens: this.maxTokens, - messages: [{ role: 'user', content: prompt }], - }), - }); - if (!response.ok) throw new Error(`Anthropic request failed with HTTP ${response.status}`); - const json = await response.json(); - const inputTokens = json.usage?.input_tokens; - const outputTokens = json.usage?.output_tokens; - const costUsd = Number.isFinite(inputTokens) && Number.isFinite(outputTokens) - ? inputTokens * this.pricing.inputUsdPerMillion / 1_000_000 + outputTokens * this.pricing.outputUsdPerMillion / 1_000_000 - : null; - const usageEvidenceLabel = costUsd === null - ? 'PROVIDER RESPONSE MEASURED; USAGE/COST UNKNOWN' - : 'MEASURED'; - if (costUsd !== null) { - this.measuredSpendUsd += costUsd; - if (this.measuredSpendUsd > this.maxRunCostUsd) throw new Error('Cumulative measured spend exceeded MAX_RUN_COST_USD'); + rawUsage: { input_tokens: observedUsage.inputTokens, output_tokens: observedUsage.outputTokens }, + normalizedUsage: { ...observedUsage }, + costUsd, + latencyMs, + }; + this.records.push(record); + this.attempts.push({ + attemptId: `${request.kind}:${request.caseId ?? 'distill'}:${this.attempts.length + 1}`, + kind: request.kind, + caseId: request.caseId ?? null, + success: true, + providerRequestId, + latencyMs, + inputTokens: observedUsage.inputTokens, + outputTokens: observedUsage.outputTokens, + providerCostMicroUsd: knownCostMicroUsd.toString(), + providerCostUsd: costUsd, + failureClass: null, + ...seed, + }); + return { + output: json.content?.find((item) => item.type === 'text')?.text ?? '', + ...record, + seed, + }; + } catch (error) { + originalError = error; + // A refusal before reservation means no provider attempt occurred. Keep + // adapter attempt accounting exactly aligned with budget reservations. + if (reservationId === null) throw originalError; + let settlementError = null; + const currentLock = this.budget.state().lock; + if (currentLock?.attemptId === reservationId) { + settlementError = error; + originalError = capError ?? originalError; + } else { + try { + const tokenCapExceeded = observedUsage + ? exceedsCommittedTokenCaps({ ...observedUsage, snapshot: this.snapshot }) + : false; + this.budget.settleAttempt(reservationId, { + knownCostMicroUsd, + success: false, + budgetViolation: tokenCapExceeded ? 'token_cap_exceeded' : null, + }); + } catch (settleError) { + settlementError = settleError; + } + } + const latencyMs = performance.now() - started; + this.attempts.push({ + attemptId: `${request.kind}:${request.caseId ?? 'distill'}:${this.attempts.length + 1}`, + kind: request.kind, + caseId: request.caseId ?? null, + success: false, + providerRequestId: null, + latencyMs, + inputTokens: observedUsage?.inputTokens ?? null, + outputTokens: observedUsage?.outputTokens ?? null, + providerCostMicroUsd: knownCostMicroUsd?.toString() ?? null, + providerCostUsd: knownCostMicroUsd === null + ? null + : Number(knownCostMicroUsd) / 1_000_000, + failureClass: error instanceof Error ? error.name : 'UnknownError', + ...seed, + }); + if (settlementError) { + throw new AggregateError( + [settlementError, originalError], + `${settlementError instanceof Error ? settlementError.message : 'Budget lock'}; ${originalError instanceof Error ? originalError.message : 'provider failure'}`, + ); + } + throw originalError; } - const record = { - requestId: json.id ?? `live-${this.records.length + 1}`, - kind: request.kind, - caseId: request.caseId ?? null, - evidenceLabel: usageEvidenceLabel, - model: this.model, - rawUsage: json.usage ?? null, - normalizedUsage: { - inputTokens: Number.isFinite(inputTokens) ? inputTokens : null, - outputTokens: Number.isFinite(outputTokens) ? outputTokens : null, - }, - costUsd, - latencyMs: performance.now() - started, - }; - this.records.push(record); - return { output: json.content?.find((item) => item.type === 'text')?.text ?? '', ...record }; } } diff --git a/spikes/clone-economics/src/authorization.mjs b/spikes/clone-economics/src/authorization.mjs new file mode 100644 index 0000000..1c34003 --- /dev/null +++ b/spikes/clone-economics/src/authorization.mjs @@ -0,0 +1,36 @@ +import { createHash } from 'node:crypto'; + +import { parseUsdToMicroUsd } from './budget.mjs'; + +function canonicalize(value) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]), + ); + } + throw new Error(`Unsupported authorization value type: ${typeof value}`); +} + +export function liveAuthorizationHash({ config, snapshot }) { + const canonical = JSON.stringify(canonicalize({ + authorizationSchemaVersion: 1, + sweepConfig: config, + liveBudgetSnapshot: snapshot, + })); + return `sha256:${createHash('sha256').update(canonical).digest('hex')}`; +} + +export function validateLiveApproval(env, contract) { + const supplied = env.APPROVE_LIVE_SWEEP_SHA256; + if (typeof supplied !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(supplied)) { + throw new Error('APPROVE_LIVE_SWEEP_SHA256 must be a lowercase sha256 digest'); + } + const expected = liveAuthorizationHash(contract); + if (supplied !== expected) { + throw new Error(`Live approval is stale or does not match ${expected}`); + } + return parseUsdToMicroUsd(env.MAX_SWEEP_COST_USD, 'MAX_SWEEP_COST_USD'); +} diff --git a/spikes/clone-economics/src/budget.mjs b/spikes/clone-economics/src/budget.mjs new file mode 100644 index 0000000..55eddf9 --- /dev/null +++ b/spikes/clone-economics/src/budget.mjs @@ -0,0 +1,217 @@ +const MICRO_USD_PER_USD = 1_000_000n; + +export function parseUsdToMicroUsd(value, fieldName) { + if (typeof value !== 'string' || !/^(?:0|[1-9]\d*)(?:\.\d{1,6})?$/.test(value)) { + throw new Error(`${fieldName} must be a positive plain USD decimal with at most six places`); + } + const [whole, fraction = ''] = value.split('.'); + const result = BigInt(whole) * MICRO_USD_PER_USD + + BigInt(fraction.padEnd(6, '0')); + if (result <= 0n) throw new Error(`${fieldName} must be positive`); + return result; +} + +export function formatMicroUsd(value) { + if (typeof value !== 'bigint' || value < 0n) throw new Error('micro-USD value must be a non-negative bigint'); + return `${value / MICRO_USD_PER_USD}.${String(value % MICRO_USD_PER_USD).padStart(6, '0')}`; +} + +const ceilDiv = (numerator, denominator) => + (numerator + denominator - 1n) / denominator; + +export function calculateProviderCostMicroUsd({ inputTokens, outputTokens, snapshot }) { + if (!Number.isSafeInteger(inputTokens) || inputTokens < 0 + || !Number.isSafeInteger(outputTokens) || outputTokens < 0) { + throw new Error('Provider usage must contain non-negative safe integer token counts'); + } + const inputPrice = parseUsdToMicroUsd( + snapshot.pricing.inputUsdPerMillionTokens, + 'input pricing', + ); + const outputPrice = parseUsdToMicroUsd( + snapshot.pricing.outputUsdPerMillionTokens, + 'output pricing', + ); + return ceilDiv(BigInt(inputTokens) * inputPrice, 1_000_000n) + + ceilDiv(BigInt(outputTokens) * outputPrice, 1_000_000n); +} + +export function exceedsCommittedTokenCaps({ inputTokens, outputTokens, snapshot }) { + return inputTokens > snapshot.tokenCaps.maxInputTokens + || outputTokens > snapshot.tokenCaps.maxOutputTokens; +} + +function assertExactKeys(value, keys, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${label} has unexpected or missing fields`); + } +} + +export function validateBudgetSnapshotShape(snapshot, config = null) { + assertExactKeys(snapshot, [ + 'schemaVersion', 'experimentFamily', 'approvalStatus', 'provider', 'model', 'pricing', 'tokenCaps', + ], 'Live budget snapshot'); + assertExactKeys(snapshot.pricing, [ + 'currency', 'unit', 'inputUsdPerMillionTokens', 'outputUsdPerMillionTokens', 'asOf', 'source', + ], 'Live budget pricing'); + assertExactKeys(snapshot.tokenCaps, ['maxInputTokens', 'maxOutputTokens'], 'Live budget token caps'); + if (snapshot.schemaVersion !== 1) throw new Error('Live budget snapshot schemaVersion must be 1'); + if (typeof snapshot.experimentFamily !== 'string' || snapshot.experimentFamily === '') { + throw new Error('Live budget snapshot experiment family is required'); + } + if (config && snapshot.experimentFamily !== config.experimentFamily) { + throw new Error('Live budget snapshot experiment family does not match sweep config'); + } + if (snapshot.approvalStatus === 'not_approved') { + if (typeof snapshot.provider !== 'string' || snapshot.provider === '') { + throw new Error('Unapproved budget provider is required'); + } + const expectedNulls = [ + snapshot.model, + snapshot.pricing.inputUsdPerMillionTokens, + snapshot.pricing.outputUsdPerMillionTokens, + snapshot.pricing.asOf, + snapshot.pricing.source, + snapshot.tokenCaps.maxInputTokens, + snapshot.tokenCaps.maxOutputTokens, + ]; + if (expectedNulls.some((value) => value !== null) + || snapshot.pricing.currency !== 'USD' + || snapshot.pricing.unit !== 'per_million_tokens') { + throw new Error('Unapproved live budget snapshot must retain the exact null contract'); + } + return snapshot; + } + if (snapshot.approvalStatus !== 'approved') { + throw new Error('Live budget approvalStatus must be approved or not_approved'); + } + validateApprovedBudgetSnapshot(snapshot, config ?? { experimentFamily: snapshot.experimentFamily }); + return snapshot; +} + +export function validateApprovedBudgetSnapshot(snapshot, config) { + if (!snapshot || snapshot.schemaVersion !== 1) throw new Error('Live budget snapshot schemaVersion must be 1'); + if (snapshot.experimentFamily !== config.experimentFamily) throw new Error('Live budget snapshot experiment family mismatch'); + if (snapshot.approvalStatus !== 'approved') { + throw new Error('Live budget snapshot must be approved; current snapshot is not approved'); + } + if (typeof snapshot.provider !== 'string' || snapshot.provider.trim() === '') throw new Error('Live budget provider is required'); + if (typeof snapshot.model !== 'string' || snapshot.model.trim() === '') throw new Error('Live budget model is required'); + if (snapshot.pricing?.currency !== 'USD') throw new Error('Live budget currency must be USD'); + if (snapshot.pricing?.unit !== 'per_million_tokens') throw new Error('Live budget unit must be per_million_tokens'); + parseUsdToMicroUsd(snapshot.pricing?.inputUsdPerMillionTokens, 'input pricing'); + parseUsdToMicroUsd(snapshot.pricing?.outputUsdPerMillionTokens, 'output pricing'); + if (typeof snapshot.pricing?.asOf !== 'string' + || Number.isNaN(Date.parse(snapshot.pricing.asOf)) + || !/^\d{4}-\d{2}-\d{2}T/.test(snapshot.pricing.asOf)) { + throw new Error('Live budget pricing asOf must be an ISO-8601 timestamp'); + } + try { + const source = new URL(snapshot.pricing.source); + if (source.protocol !== 'https:') throw new Error('not HTTPS'); + } catch { + throw new Error('Live budget pricing source must be an HTTPS URL'); + } + for (const [name, value] of Object.entries({ + maxInputTokens: snapshot.tokenCaps?.maxInputTokens, + maxOutputTokens: snapshot.tokenCaps?.maxOutputTokens, + })) { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`); + } + return snapshot; +} + +export function conservativeSweepRequestCount(config, counts) { + const cells = config.nValues.flatMap((n) => + config.replicates.map(() => ( + n + 1 + counts.heldoutCount * 3 + counts.v2Count * 2 + ))); + return counts.heldoutCount + cells.reduce((sum, value) => sum + value, 0); +} + +export function estimateLiveSweepMicroUsd({ config, counts, snapshot }) { + validateApprovedBudgetSnapshot(snapshot, config); + const perCall = calculateProviderCostMicroUsd({ + inputTokens: snapshot.tokenCaps.maxInputTokens, + outputTokens: snapshot.tokenCaps.maxOutputTokens, + snapshot, + }); + return BigInt(conservativeSweepRequestCount(config, counts)) * perCall; +} + +export function createAttemptBudget({ capMicroUsd, worstCaseCallMicroUsd }) { + if (typeof capMicroUsd !== 'bigint' || capMicroUsd <= 0n) throw new Error('capMicroUsd must be a positive bigint'); + if (typeof worstCaseCallMicroUsd !== 'bigint' || worstCaseCallMicroUsd <= 0n) { + throw new Error('worstCaseCallMicroUsd must be a positive bigint'); + } + let attemptedCalls = 0; + let knownAccruedMicroUsd = 0n; + let outstandingReservedMicroUsd = 0n; + let lock = null; + const reservations = new Map(); + const settled = new Map(); + + function lockError() { + return lock.kind === 'budget_overrun' + ? new Error(`Budget permanently locked: budget_overrun (${lock.reason})`) + : new Error(`Budget locked: ${lock.kind}`); + } + + function reserveNextAttempt(metadata) { + if (lock) throw lockError(); + const projected = knownAccruedMicroUsd + outstandingReservedMicroUsd + worstCaseCallMicroUsd; + if (projected > capMicroUsd) { + throw new Error(`Next live attempt would exceed human cap: ${formatMicroUsd(projected)} > ${formatMicroUsd(capMicroUsd)}`); + } + attemptedCalls += 1; + const attemptId = `attempt-${String(attemptedCalls).padStart(6, '0')}`; + reservations.set(attemptId, { amountMicroUsd: worstCaseCallMicroUsd, metadata: structuredClone(metadata) }); + outstandingReservedMicroUsd += worstCaseCallMicroUsd; + return attemptId; + } + + function settleAttempt(attemptId, { + knownCostMicroUsd, + success, + budgetViolation = null, + }) { + const reservation = reservations.get(attemptId); + if (!reservation) throw new Error(`Unknown or already-settled attempt ${attemptId}`); + if (lock) throw new Error(`Budget permanently locked: ${lock.kind}`); + if (knownCostMicroUsd === null) { + lock = { kind: 'unknown_cost', attemptId }; + throw new Error('Unknown live cost; budget locked'); + } + if (typeof knownCostMicroUsd !== 'bigint' || knownCostMicroUsd < 0n) { + lock = { kind: 'unknown_cost', attemptId }; + throw new Error('Malformed live cost; budget locked as unknown_cost'); + } + reservations.delete(attemptId); + outstandingReservedMicroUsd -= reservation.amountMicroUsd; + knownAccruedMicroUsd += knownCostMicroUsd; + const reason = budgetViolation === 'token_cap_exceeded' + ? 'token_cap_exceeded' + : knownAccruedMicroUsd > capMicroUsd + ? 'human_cap_exceeded' + : knownCostMicroUsd > reservation.amountMicroUsd + ? 'reservation_exceeded' + : null; + settled.set(attemptId, { knownCostMicroUsd, success }); + if (reason) { + lock = { kind: 'budget_overrun', attemptId, reason }; + const label = reason.replaceAll('_', ' '); + throw new Error(`budget_overrun: ${label}; exact cost was accrued`); + } + } + + function state() { + return { attemptedCalls, knownAccruedMicroUsd, outstandingReservedMicroUsd, lock: lock ? { ...lock } : null }; + } + + return { reserveNextAttempt, settleAttempt, state }; +} diff --git a/spikes/clone-economics/src/experiment.mjs b/spikes/clone-economics/src/experiment.mjs index 1a86a44..2c74594 100644 --- a/spikes/clone-economics/src/experiment.mjs +++ b/spikes/clone-economics/src/experiment.mjs @@ -7,6 +7,7 @@ import { MockLlmAdapter, LiveAnthropicAdapter } from './adapters.mjs'; import { computeEconomics } from './economics.mjs'; import { renderJson, renderMarkdown } from './reports.mjs'; import { FIDELITY_THRESHOLD, RUBRIC_VERSION, scoreEvaluation } from './scoring.mjs'; +import { seededOrder } from './sweep.mjs'; import { assessBenchmark } from './validity.mjs'; const srcDir = path.dirname(fileURLToPath(import.meta.url)); @@ -71,6 +72,7 @@ function buildAdapter(mode, options) { return new MockLlmAdapter({ transcript: options.mockTranscript ?? readJson('mock-transcript.json'), cloneSkillMd: fs.readFileSync(fixturePath('good-clone/SKILL.md'), 'utf8'), + outputFor: options.outputFor ?? null, }); } return new LiveAnthropicAdapter({ @@ -89,12 +91,40 @@ function buildAdapter(mode, options) { }); } +export async function runTargetBenchmark({ adapter, heldoutFixtures, threshold = FIDELITY_THRESHOLD }) { + const repoInventory = readJson('repo-inventory.json'); + const executorSettings = readJson('executor-settings.json'); + const targetText = fs.readFileSync( + path.join(repoRoot, '.claude/skills/optimizing-claude-code-prompts/SKILL.md'), + 'utf8', + ); + const referenceText = fs.readFileSync( + path.join(repoRoot, '.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md'), + 'utf8', + ); + const sharedExecutor = { repoInventory, executorSettings }; + const targetOutputs = await collect( + adapter, + heldoutFixtures, + 'target-heldout', + (fixture) => ({ + input: fixture.input, + targetSkill: targetText, + reference: referenceText, + ...sharedExecutor, + }), + ); + const targetScore = scoreEvaluation(targetOutputs, heldoutFixtures, threshold); + const benchmark = assessBenchmark({ threshold, target: targetScore }); + return { benchmark, targetScore }; +} + export async function runExperiment(options = {}) { const mode = options.mode ?? (process.env.MOCK_LLM === '1' ? 'mock' : 'live'); if (!['mock', 'live'].includes(mode)) throw new Error('mode must be mock or live'); - const trainFixtures = readJson('train.json'); - const heldoutFixtures = readJson('heldout.json'); - const v2Fixtures = readJson('v2-heldout.json'); + const trainFixtures = options.trainFixtures ?? readJson('train.json'); + const heldoutFixtures = options.heldoutFixtures ?? readJson('heldout.json'); + const v2Fixtures = options.v2Fixtures ?? readJson('v2-heldout.json'); const repoInventory = readJson('repo-inventory.json'); const executorSettings = readJson('executor-settings.json'); const evolutionOverlay = readJson('evolution-v2.json'); @@ -102,7 +132,10 @@ export async function runExperiment(options = {}) { if (!Number.isInteger(N) || N <= 0 || N > trainFixtures.length) { throw new Error(`N must be an integer from 1 to ${trainFixtures.length}`); } - const selectedTrain = trainFixtures.slice(0, N); + const orderedTrain = options.pairOrderSeed === undefined + ? trainFixtures + : seededOrder(trainFixtures, options.pairOrderSeed); + const selectedTrain = orderedTrain.slice(0, N); const targetFile = path.join(repoRoot, '.claude/skills/optimizing-claude-code-prompts/SKILL.md'); const referenceFile = path.join(repoRoot, '.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md'); const targetText = fs.readFileSync(targetFile, 'utf8'); @@ -125,6 +158,7 @@ export async function runExperiment(options = {}) { const estimatedRequests = N + 1 + heldoutFixtures.length * 3 + v2Fixtures.length * 2; const adapter = buildAdapter(mode, { ...options, N, estimatedRequests }); + const recordStart = adapter.records.length; const sharedExecutor = { repoInventory, executorSettings }; const acquisitionPairs = []; for (const fixture of selectedTrain) { @@ -142,7 +176,11 @@ export async function runExperiment(options = {}) { instructions: 'Author one valid SKILL.md that reproduces the demonstrated input-to-output capability. Use only the supplied examples. Return the raw file content only — no code fences, no preamble — starting with YAML frontmatter exactly like:\n---\nname: \ndescription: \n---\nfollowed by the markdown body.', pairs: acquisitionPairs, }; - const distilled = await adapter.invoke({ kind: 'distill', payload: distillationPayload }); + const distilled = await adapter.invoke({ + kind: 'distill', + requestedDistillationSeed: options.requestedDistillationSeed, + payload: distillationPayload, + }); // Persist the raw distillation output BEFORE validation so a failed live run // leaves evidence instead of discarding paid model output. const rawDumpDir = path.resolve(options.outputDir ?? path.join(spikeRoot, 'runs', mode)); @@ -169,10 +207,11 @@ export async function runExperiment(options = {}) { const scoringA = JSON.stringify({ target: scoreEvaluation(targetOutputs, heldoutFixtures), clone: scoreEvaluation(cloneOutputs, heldoutFixtures) }); const scoringB = JSON.stringify({ target: scoreEvaluation(targetOutputs, heldoutFixtures), clone: scoreEvaluation(cloneOutputs, heldoutFixtures) }); - const acquisitionProviderUsd = sumCosts(adapter.records, ['target-train']); - const distillationProviderUsd = sumCosts(adapter.records, ['distill']); + const runRecords = adapter.records.slice(recordStart); + const acquisitionProviderUsd = sumCosts(runRecords, ['target-train']); + const distillationProviderUsd = sumCosts(runRecords, ['distill']); const evaluationKinds = ['target-heldout', 'clone-heldout', 'bad-clone-heldout', 'target-v2-heldout', 'clone-v2-heldout']; - const measurementEvaluationUsd = sumCosts(adapter.records, evaluationKinds); + const measurementEvaluationUsd = sumCosts(runRecords, evaluationKinds); const economics = computeEconomics({ N, invocationPriceUsd, @@ -188,11 +227,11 @@ export async function runExperiment(options = {}) { benchmarkEvaluationProviderUsd: measurementEvaluationUsd, }, }); - const trainRecords = adapter.records.filter((item) => item.kind === 'target-train'); - const distillRecord = adapter.records.find((item) => item.kind === 'distill'); + const trainRecords = runRecords.filter((item) => item.kind === 'target-train'); + const distillRecord = runRecords.find((item) => item.kind === 'distill'); const sequentialBuildMs = rounded(trainRecords.reduce((sum, item) => sum + item.latencyMs, 0) + distillRecord.latencyMs); const parallelAcquisitionLowerBoundMs = rounded(Math.max(...trainRecords.map((item) => item.latencyMs)) + distillRecord.latencyMs); - const completeProviderUsage = adapter.records.every((item) => ( + const completeProviderUsage = runRecords.every((item) => ( Number.isFinite(item.normalizedUsage.inputTokens) && Number.isFinite(item.normalizedUsage.outputTokens) && Number.isFinite(item.costUsd) @@ -220,7 +259,15 @@ export async function runExperiment(options = {}) { skill: { path: '.claude/skills/optimizing-claude-code-prompts/SKILL.md', sha256: sha256(targetText) }, reference: { path: '.claude/skills/optimizing-claude-code-prompts/references/claude-code-prompting-guide.md', sha256: sha256(referenceText) }, }, - dataset: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'SYNTHETIC FIXTURES + MEASURED OUTPUTS', N, H: heldoutFixtures.length, train: datasetTrain, heldout: datasetHeldout, disjoint }, + dataset: { + evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'SYNTHETIC FIXTURES + MEASURED OUTPUTS', + fixtureSet: options.fixtureSet ?? 'v1', + N, + H: heldoutFixtures.length, + train: datasetTrain, + heldout: datasetHeldout, + disjoint, + }, isolation: { distillationPairCount: acquisitionPairs.length, distillationPayloadSha256: sha256(JSON.stringify(distillationPayload)), @@ -228,6 +275,19 @@ export async function runExperiment(options = {}) { targetAndCloneSharedContextHash: sha256(JSON.stringify(sharedExecutor)), }, generatedClone: { sha256: sha256(cloneSkillMd), validSkillMd: true }, + seedContract: { + replicateId: options.replicateId ?? null, + pairOrderSeed: options.pairOrderSeed ?? null, + pairOrderSeedStatus: options.pairOrderSeed === undefined ? 'not_requested' : 'honored_locally', + requestedDistillationSeed: options.requestedDistillationSeed ?? null, + appliedDistillationSeed: distilled.seed?.appliedSeed ?? null, + distillationSeedStatus: distilled.seed?.status + ?? (options.requestedDistillationSeed === undefined ? 'not_requested' : 'unsupported'), + distillationSeedMechanism: distilled.seed?.mechanism + ?? (options.requestedDistillationSeed === undefined + ? 'no_seed_requested' + : 'provider_seed_not_supported_by_adapter'), + }, benchmark, fidelity: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'MEASURED AGAINST DETERMINISTIC RUBRIC', @@ -275,16 +335,16 @@ export async function runExperiment(options = {}) { zeroPriceProbe: economics.zeroPriceProbe, conclusionSuppressed: !benchmark.economicsConclusionAllowed, }, - usage: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : `Provider usage ${providerUsageEvidence}`, raw: adapter.records, normalized: normalizedUsage(adapter.records) }, + usage: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : `Provider usage ${providerUsageEvidence}`, raw: runRecords, normalized: normalizedUsage(runRecords) }, pricing: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'OPERATOR-SUPPLIED', ...adapter.pricing }, timing: { evidenceLabel: mode === 'mock' ? 'SYNTHETIC' : 'MEASURED + DERIVED LOWER BOUND', - requestLatencies: adapter.records.map((item) => ({ requestId: item.requestId, kind: item.kind, caseId: item.caseId, latencyMs: item.latencyMs, evidenceLabel: item.evidenceLabel })), + requestLatencies: runRecords.map((item) => ({ requestId: item.requestId, kind: item.kind, caseId: item.caseId, latencyMs: item.latencyMs, evidenceLabel: item.evidenceLabel })), acquisitionSequentialMs: rounded(trainRecords.reduce((sum, item) => sum + item.latencyMs, 0)), distillationMs: distillRecord.latencyMs, sequentialBuildMs, parallelAcquisitionLowerBoundMs, - evaluationMs: rounded(adapter.records.filter((item) => evaluationKinds.includes(item.kind)).reduce((sum, item) => sum + item.latencyMs, 0)), + evaluationMs: rounded(runRecords.filter((item) => evaluationKinds.includes(item.kind)).reduce((sum, item) => sum + item.latencyMs, 0)), requiredUpdateCadence: { label: 'HYPOTHESIS/EXTRAPOLATION', statement: 'A static synthetic overlay gives no calendar cadence; dated live Skill revisions and repeated clone freezes are required.', diff --git a/spikes/clone-economics/src/sweep.mjs b/spikes/clone-economics/src/sweep.mjs new file mode 100644 index 0000000..309a0b9 --- /dev/null +++ b/spikes/clone-economics/src/sweep.mjs @@ -0,0 +1,291 @@ +import { + calculateProviderCostMicroUsd, + conservativeSweepRequestCount, + createAttemptBudget, + estimateLiveSweepMicroUsd, + formatMicroUsd, + validateApprovedBudgetSnapshot, +} from './budget.mjs'; +import { liveAuthorizationHash, validateLiveApproval } from './authorization.mjs'; + +function mulberry32(seed) { + return () => { + seed |= 0; + seed = seed + 0x6D2B79F5 | 0; + let t = Math.imul(seed ^ seed >>> 15, 1 | seed); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +// This seed controls acquisition-pair ordering only. It does not control +// provider sampling or substitute for a provider-confirmed distillation seed. +export function seededOrder(values, seed) { + const result = [...values]; + const random = mulberry32(seed); + for (let i = result.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +export function validateSweepConfig(config, counts) { + if (!config || config.schemaVersion !== 1 + || config.experimentFamily !== 'clone-economics-high-n-v1' + || config.fixtureSet !== 'v2') { + throw new Error('Sweep must use the v1 high-N preregistration and v2 fixtures'); + } + if (JSON.stringify(config.nValues) !== JSON.stringify([6, 25, 50, 100])) { + throw new Error('Sweep must use N=6,25,50,100'); + } + if (!Array.isArray(config.replicates) || config.replicates.length !== 3) { + throw new Error('Sweep must use exactly three preregistered replicates'); + } + const ids = config.replicates.map((x) => x.replicateId); + const pairOrderSeeds = config.replicates.map((x) => x.pairOrderSeed); + const distillationSeeds = config.replicates.map((x) => x.distillationSeed); + for (const [label, values] of [ + ['replicate IDs', ids], + ['pair-order seeds', pairOrderSeeds], + ['distillation seeds', distillationSeeds], + ]) { + if (new Set(values).size !== 3) throw new Error(`Sweep requires three distinct ${label}`); + } + if (![...pairOrderSeeds, ...distillationSeeds].every(Number.isSafeInteger)) { + throw new Error('Sweep seeds must be safe integers'); + } + if (pairOrderSeeds.some((value) => distillationSeeds.includes(value))) { + throw new Error('Pair-order and distillation seeds must be separate'); + } + if (config.highNDefinition !== 100 || config.heldoutMinimum !== 30) { + throw new Error('Sweep high-N and heldout minimum do not match preregistration'); + } + if (counts.trainCount < 100 || counts.heldoutCount < 30) { + throw new Error('Sweep requires at least 100 train and 30 heldout fixtures'); + } +} + +export function classifyHighNSeedValidity({ cells, adapterMode, standaloneBenchmark }) { + const highN = cells.filter((cell) => cell.n === 100 && cell.status === 'complete'); + if (standaloneBenchmark?.valid !== true) { + return { valid: false, reason: 'STANDALONE_TARGET_INVALID' }; + } + if (highN.length !== 3) { + return { valid: false, reason: 'HIGH_N_INCOMPLETE' }; + } + if (highN.some((cell) => cell.benchmark?.valid !== true)) { + return { valid: false, reason: 'HIGH_N_TARGET_INVALID' }; + } + if (adapterMode !== 'live') { + return { valid: false, reason: 'HIGH_N_NOT_LIVE' }; + } + const requested = highN.map((cell) => cell.requestedDistillationSeed); + const independentlyHonored = new Set(requested).size === 3 + && highN.every((cell) => + cell.distillationSeedStatus === 'honored' + && cell.appliedDistillationSeed === cell.requestedDistillationSeed); + return independentlyHonored + ? { valid: true, reason: null } + : { valid: false, reason: 'DISTILLATION_SEEDS_UNCONTROLLED' }; +} + +export const compliantHeldoutOutput = (fixture) => [ + `Mode: ${fixture.mode}`, + fixture.rubric.exactPaths[0].value, + fixture.rubric.exactCommands[0].value, + fixture.rubric.requiredAll[0].value, + 'Show the diff', +].join('\n'); + +export async function startLiveSweep({ + env, + config, + counts, + snapshot, + fetchFactory, + adapterFactory, + runSweep: runSweepImplementation = runSweep, + sweepOptions = {}, +}) { + validateSweepConfig(config, counts); + validateApprovedBudgetSnapshot(snapshot, config); + const authorizationHash = liveAuthorizationHash({ config, snapshot }); + const capMicroUsd = validateLiveApproval(env, { config, snapshot }); + const requestCount = conservativeSweepRequestCount(config, counts); + const perCallMicroUsd = calculateProviderCostMicroUsd({ + inputTokens: snapshot.tokenCaps.maxInputTokens, + outputTokens: snapshot.tokenCaps.maxOutputTokens, + snapshot, + }); + const estimateMicroUsd = estimateLiveSweepMicroUsd({ config, counts, snapshot }); + if (estimateMicroUsd > capMicroUsd) { + throw new Error(`Conservative live estimate $${formatMicroUsd(estimateMicroUsd)} exceeds human cap $${formatMicroUsd(capMicroUsd)}`); + } + if (env.ALLOW_LIVE_LLM !== '1') throw new Error('ALLOW_LIVE_LLM=1 is required for a live sweep'); + const budget = createAttemptBudget({ capMicroUsd, worstCaseCallMicroUsd: perCallMicroUsd }); + const fetchImpl = fetchFactory(); + const adapter = adapterFactory({ fetchImpl, budget, snapshot }); + const result = await runSweepImplementation({ + ...sweepOptions, + mode: 'live', + config, + adapter, + budget, + counts, + }); + return { + authorizationHash, + capMicroUsd, + estimateMicroUsd, + requestCount, + perCallMicroUsd, + budgetState: budget.state(), + result, + }; +} + +export async function runSweep(options) { + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const { MockLlmAdapter } = await import('./adapters.mjs'); + const { runExperiment, runTargetBenchmark } = await import('./experiment.mjs'); + const { loadFixtureSet } = await import('./fixture-set.mjs'); + + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const config = options.config + ?? JSON.parse(fs.readFileSync(path.join(root, 'fixtures/sweep-v1.json'), 'utf8')); + const fixtures = options.fixtures ?? loadFixtureSet(root, config.fixtureSet); + const v2Fixtures = JSON.parse(fs.readFileSync(path.join(root, 'fixtures/v2-heldout.json'), 'utf8')); + const counts = options.counts ?? { + trainCount: fixtures.train.length, + heldoutCount: fixtures.heldout.length, + v2Count: v2Fixtures.length, + }; + validateSweepConfig(config, counts); + const mode = options.mode ?? 'mock'; + if (!['mock', 'live'].includes(mode)) throw new Error('Sweep mode must be mock or live'); + + const trainById = new Map(fixtures.train.map((fixture) => [fixture.id, fixture])); + const heldoutById = new Map(fixtures.heldout.map((fixture) => [fixture.id, fixture])); + const outputFor = ({ kind, caseId }) => { + if (kind === 'target-train') return trainById.get(caseId)?.expectedOutput; + if (kind === 'target-heldout' || kind === 'clone-heldout') { + const fixture = heldoutById.get(caseId); + return fixture ? compliantHeldoutOutput(fixture) : undefined; + } + if (kind === 'bad-clone-heldout' && heldoutById.has(caseId)) return 'Unscoped answer'; + return undefined; + }; + const adapter = options.adapter ?? new MockLlmAdapter({ + transcript: JSON.parse(fs.readFileSync(path.join(root, 'fixtures/mock-transcript.json'), 'utf8')), + cloneSkillMd: fs.readFileSync(path.join(root, 'fixtures/good-clone/SKILL.md'), 'utf8'), + outputFor, + }); + const adapterMode = mode; + const { benchmark, targetScore } = await runTargetBenchmark({ + adapter, + heldoutFixtures: fixtures.heldout, + threshold: config.targetThreshold ?? 0.8, + }); + const samples = adapter.attempts.map((attempt) => structuredClone(attempt)); + const reconcile = () => { + if (samples.length !== adapter.attempts.length) { + throw new Error('Sweep sample count does not reconcile with adapter attempts'); + } + const budgetAttempts = options.budget?.state().attemptedCalls; + if (budgetAttempts !== undefined && budgetAttempts !== samples.length) { + throw new Error('Sweep sample count does not reconcile with budget attemptedCalls'); + } + }; + if (!benchmark.valid) { + reconcile(); + return { + experimentFamily: config.experimentFamily, + benchmark, + targetScore, + cells: [], + samples, + highNComplete: false, + }; + } + + const cells = []; + let stop = false; + for (const n of config.nValues) { + for (const replicate of config.replicates) { + const attemptStart = adapter.attempts.length; + const cellOutputDir = path.join( + options.outputDir ?? path.join(root, 'runs', `${mode}-sweep`), + `n${n}-${replicate.replicateId}`, + ); + try { + const result = await runExperiment({ + mode, + adapter, + outputDir: cellOutputDir, + N: n, + trainFixtures: fixtures.train, + heldoutFixtures: fixtures.heldout, + v2Fixtures, + fixtureSet: config.fixtureSet, + pairOrderSeed: replicate.pairOrderSeed, + requestedDistillationSeed: replicate.distillationSeed, + replicateId: replicate.replicateId, + invocationPriceUsd: options.invocationPriceUsd ?? 0.25, + cloneServingCostUsd: options.cloneServingCostUsd ?? 0.05, + deployCostUsd: options.deployCostUsd ?? 0.05, + laborCostUsd: options.laborCostUsd ?? 0, + }); + const seed = result.report.seedContract; + cells.push({ + n, + replicateId: replicate.replicateId, + pairOrderSeed: replicate.pairOrderSeed, + requestedDistillationSeed: replicate.distillationSeed, + appliedDistillationSeed: seed.appliedDistillationSeed, + distillationSeedStatus: seed.distillationSeedStatus, + distillationSeedMechanism: seed.distillationSeedMechanism, + status: 'complete', + benchmark: result.report.benchmark, + targetAbsoluteScore: result.report.fidelity.target.absoluteScore, + cloneAbsoluteScore: result.report.fidelity.clone.absoluteScore, + cloneCriticalGatePass: result.report.fidelity.clone.criticalGatePass, + providerCostUsd: result.report.usage.normalized.providerCostUsd, + }); + } catch (error) { + cells.push({ + n, + replicateId: replicate.replicateId, + pairOrderSeed: replicate.pairOrderSeed, + requestedDistillationSeed: replicate.distillationSeed, + appliedDistillationSeed: null, + distillationSeedStatus: 'unsupported', + distillationSeedMechanism: 'cell_failed_before_seed_evidence', + status: 'failed', + benchmark: null, + failureClass: error instanceof Error ? error.name : 'UnknownError', + }); + stop = true; + } finally { + for (const attempt of adapter.attempts.slice(attemptStart)) samples.push(structuredClone(attempt)); + } + if (stop) break; + } + if (stop) break; + } + reconcile(); + const highNComplete = cells.filter((cell) => cell.n === 100 && cell.status === 'complete').length === 3; + const highNGate = classifyHighNSeedValidity({ cells, adapterMode, standaloneBenchmark: benchmark }); + return { + experimentFamily: config.experimentFamily, + benchmark, + targetScore, + cells, + samples, + highNComplete, + publishableHighN: highNGate.valid, + suppressionReason: highNGate.reason, + }; +} diff --git a/spikes/clone-economics/sweep.mjs b/spikes/clone-economics/sweep.mjs new file mode 100644 index 0000000..cf0238e --- /dev/null +++ b/spikes/clone-economics/sweep.mjs @@ -0,0 +1,100 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { LiveAnthropicAdapter } from './src/adapters.mjs'; +import { + conservativeSweepRequestCount, + estimateLiveSweepMicroUsd, + formatMicroUsd, + validateBudgetSnapshotShape, +} from './src/budget.mjs'; +import { liveAuthorizationHash } from './src/authorization.mjs'; +import { loadFixtureSet } from './src/fixture-set.mjs'; +import { runSweep, startLiveSweep, validateSweepConfig } from './src/sweep.mjs'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const readJson = (name) => JSON.parse(fs.readFileSync(path.join(root, 'fixtures', name), 'utf8')); +const config = readJson('sweep-v1.json'); +const snapshot = readJson('live-budget-v1.json'); +const fixtures = loadFixtureSet(root, config.fixtureSet); +const v2Count = readJson('v2-heldout.json').length; +const counts = { + trainCount: fixtures.train.length, + heldoutCount: fixtures.heldout.length, + v2Count, +}; + +function printDimensions() { + console.log(`train fixtures: ${counts.trainCount}`); + console.log(`heldout fixtures: ${counts.heldoutCount}`); + console.log(`sweep cells: ${config.nValues.length * config.replicates.length}`); + console.log(`conservative live requests: ${conservativeSweepRequestCount(config, counts)}`); +} + +async function main() { + const flags = process.argv.slice(2); + if (flags.length !== 1 || !['--preflight', '--mock', '--live'].includes(flags[0])) { + throw new Error('Usage: node sweep.mjs --preflight|--mock|--live'); + } + const mode = flags[0]; + validateSweepConfig(config, counts); + + if (mode === '--preflight') { + validateBudgetSnapshotShape(snapshot, config); + printDimensions(); + if (snapshot.approvalStatus === 'not_approved') { + console.log('live budget: not approved'); + return; + } + const authorizationHash = liveAuthorizationHash({ config, snapshot }); + const estimate = estimateLiveSweepMicroUsd({ config, counts, snapshot }); + console.log(`live authorization: ${authorizationHash}`); + console.log(`conservative live estimate USD: ${formatMicroUsd(estimate)}`); + return; + } + + if (mode === '--mock') { + let networkAttempts = 0; + const priorFetch = globalThis.fetch; + globalThis.fetch = async () => { + networkAttempts += 1; + throw new Error('NETWORK FORBIDDEN IN MOCK SWEEP'); + }; + try { + const result = await runSweep({ mode: 'mock' }); + console.log(`cells complete: ${result.cells.filter((cell) => cell.status === 'complete').length}/${result.cells.length}`); + console.log(`publishable high-N: ${result.publishableHighN}`); + console.log(`suppression: ${result.suppressionReason}`); + console.log(`networkAttempts=${networkAttempts}`); + } finally { + globalThis.fetch = priorFetch; + } + return; + } + + const live = await startLiveSweep({ + env: process.env, + config, + counts, + snapshot, + fetchFactory: () => globalThis.fetch, + adapterFactory: ({ fetchImpl, budget, snapshot: committedSnapshot }) => new LiveAnthropicAdapter({ + mode: 'live', + apiKey: process.env.ANTHROPIC_API_KEY, + snapshot: committedSnapshot, + budget, + fetchImpl, + }), + sweepOptions: { outputDir: path.join(root, 'runs', 'live-sweep') }, + }); + console.log(`live authorization: ${live.authorizationHash}`); + console.log(`attempted calls: ${live.budgetState.attemptedCalls}`); + console.log(`publishable high-N: ${live.result.publishableHighN}`); + console.log(`suppression: ${live.result.suppressionReason}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/spikes/clone-economics/tests/adapters-budget.test.mjs b/spikes/clone-economics/tests/adapters-budget.test.mjs new file mode 100644 index 0000000..3a9bfab --- /dev/null +++ b/spikes/clone-economics/tests/adapters-budget.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { LiveAnthropicAdapter, MockLlmAdapter } from '../src/adapters.mjs'; +import { createAttemptBudget } from '../src/budget.mjs'; + +const snapshot = (overrides = {}) => ({ + schemaVersion: 1, + experimentFamily: 'clone-economics-high-n-v1', + approvalStatus: 'approved', + provider: 'anthropic', + model: 'synthetic-adapter-test-model', + pricing: { + currency: 'USD', + unit: 'per_million_tokens', + inputUsdPerMillionTokens: '1.00', + outputUsdPerMillionTokens: '1.00', + asOf: '2026-07-17T00:00:00Z', + source: 'https://example.invalid/pricing', + }, + tokenCaps: { maxInputTokens: 4096, maxOutputTokens: 1024 }, + ...overrides, +}); + +function response(json, { ok = true, status = 200 } = {}) { + return { ok, status, async json() { return structuredClone(json); } }; +} + +function live({ budget, fetchImpl, contract = snapshot() }) { + return new LiveAnthropicAdapter({ + mode: 'live', + apiKey: 'synthetic-never-sent-to-network', + snapshot: contract, + budget, + fetchImpl, + testOnlyNoNetwork: true, + }); +} + +test('mock seed evidence is synthetic and output callback receives no payload bytes', async () => { + let callbackRequest; + const adapter = new MockLlmAdapter({ + transcript: { + pricing: { inputUsdPerMillion: 1, outputUsdPerMillion: 1 }, + usageProfiles: { distill: { inputTokens: 1, outputTokens: 1, costUsd: 0.000002, latencyMs: 1 } }, + }, + cloneSkillMd: 'unused', + outputFor(request) { + callbackRequest = request; + return 'synthetic clone'; + }, + }); + const result = await adapter.invoke({ + kind: 'distill', + caseId: null, + requestedDistillationSeed: 2701, + payload: { targetSkill: 'private target bytes' }, + }); + assert.deepEqual(callbackRequest, { + kind: 'distill', + caseId: null, + requestedDistillationSeed: 2701, + }); + assert.deepEqual(result.seed, { + requestedSeed: 2701, + appliedSeed: 2701, + status: 'synthetic_honored', + mechanism: 'deterministic_mock_fixture_selection', + }); +}); + +test('missing usage retains a reservation, locks unknown_cost, and permits no later fetch', async () => { + const budget = createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }); + let fetches = 0; + const adapter = live({ + budget, + fetchImpl: async () => { + fetches += 1; + return response({ id: 'synthetic-1', content: [{ type: 'text', text: 'answer' }] }); + }, + }); + await assert.rejects( + adapter.invoke({ kind: 'target-heldout', caseId: 'one', payload: { input: 'small' } }), + /unknown live cost.*budget locked/i, + ); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 0n, + outstandingReservedMicroUsd: 100n, + lock: { kind: 'unknown_cost', attemptId: 'attempt-000001' }, + }); + assert.equal(adapter.attempts[0].providerCostMicroUsd, null); + await assert.rejects( + adapter.invoke({ kind: 'target-heldout', caseId: 'two', payload: { input: 'small' } }), + /budget locked/i, + ); + assert.equal(fetches, 1); + assert.equal(adapter.attempts.length, 1); +}); + +test('above-token usage accrues exact cost, locks budget_overrun, and permits no later fetch', async () => { + const contract = snapshot({ tokenCaps: { maxInputTokens: 500, maxOutputTokens: 10 } }); + const budget = createAttemptBudget({ capMicroUsd: 10_000n, worstCaseCallMicroUsd: 510n }); + let fetches = 0; + const adapter = live({ + contract, + budget, + fetchImpl: async () => { + fetches += 1; + return response({ + id: 'synthetic-2', + usage: { input_tokens: 501, output_tokens: 10 }, + content: [{ type: 'text', text: 'answer' }], + }); + }, + }); + await assert.rejects( + adapter.invoke({ kind: 'target-heldout', caseId: 'one', payload: { input: 'x' } }), + /budget_overrun.*token cap/i, + ); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 511n, + outstandingReservedMicroUsd: 0n, + lock: { kind: 'budget_overrun', attemptId: 'attempt-000001', reason: 'token_cap_exceeded' }, + }); + assert.equal(adapter.attempts[0].inputTokens, 501); + assert.equal(adapter.attempts[0].providerCostMicroUsd, '511'); + await assert.rejects( + adapter.invoke({ kind: 'target-heldout', caseId: 'two', payload: { input: 'x' } }), + /budget_overrun/, + ); + assert.equal(fetches, 1); + assert.equal(adapter.attempts.length, 1); +}); + +test('known cost above the human cap is fully accrued and blocks all later calls', async () => { + const contract = snapshot({ tokenCaps: { maxInputTokens: 4096, maxOutputTokens: 1 } }); + const budget = createAttemptBudget({ capMicroUsd: 100n, worstCaseCallMicroUsd: 100n }); + let fetches = 0; + const adapter = live({ + contract, + budget, + fetchImpl: async () => { + fetches += 1; + return response({ + id: 'synthetic-3', + usage: { input_tokens: 125, output_tokens: 0 }, + content: [{ type: 'text', text: 'answer' }], + }); + }, + }); + await assert.rejects( + adapter.invoke({ kind: 'target-heldout', caseId: 'one', payload: { input: 'x' } }), + /budget_overrun.*human cap/i, + ); + assert.equal(budget.state().knownAccruedMicroUsd, 125n); + assert.equal(budget.state().outstandingReservedMicroUsd, 0n); + await assert.rejects( + adapter.invoke({ kind: 'target-heldout', caseId: 'two', payload: { input: 'x' } }), + /budget_overrun/, + ); + assert.equal(fetches, 1); + assert.equal(adapter.attempts.length, 1); +}); + +test('live distillation records unsupported seed evidence and sends no seed field', async () => { + const budget = createAttemptBudget({ capMicroUsd: 1_000n, worstCaseCallMicroUsd: 200n }); + let body; + const adapter = live({ + budget, + fetchImpl: async (_url, init) => { + body = JSON.parse(init.body); + return response({ + id: 'synthetic-4', + usage: { input_tokens: 10, output_tokens: 10 }, + content: [{ type: 'text', text: 'answer' }], + }); + }, + }); + const result = await adapter.invoke({ + kind: 'distill', + requestedDistillationSeed: 2701, + payload: { instructions: 'small', pairs: [] }, + }); + assert.equal(Object.hasOwn(body, 'seed'), false); + assert.deepEqual(result.seed, { + requestedSeed: 2701, + appliedSeed: null, + status: 'unsupported', + mechanism: 'provider_seed_not_supported_by_adapter', + }); +}); diff --git a/spikes/clone-economics/tests/authorization.test.mjs b/spikes/clone-economics/tests/authorization.test.mjs new file mode 100644 index 0000000..8f787a7 --- /dev/null +++ b/spikes/clone-economics/tests/authorization.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + liveAuthorizationHash, + validateLiveApproval, +} from '../src/authorization.mjs'; +import { approved, config } from './fixtures/live-contract.mjs'; + +test('live approval binds the exact canonical sweep and budget snapshot', () => { + const authorizationHash = liveAuthorizationHash({ config, snapshot: approved }); + assert.match(authorizationHash, /^sha256:[0-9a-f]{64}$/); + assert.equal(validateLiveApproval({ + APPROVE_LIVE_SWEEP_SHA256: authorizationHash, + MAX_SWEEP_COST_USD: '50.000001', + }, { config, snapshot: approved }), 50_000_001n); +}); + +test('a stale approval fails after any material snapshot or config change', () => { + const stale = liveAuthorizationHash({ config, snapshot: approved }); + const mutations = [ + { config: { ...config, nValues: [6, 25, 50] }, snapshot: approved }, + { config: { + ...config, + replicates: config.replicates.map((x, index) => + index === 0 ? { ...x, distillationSeed: 9999 } : x), + }, snapshot: approved }, + { config, snapshot: { ...approved, model: 'changed-model' } }, + { config, snapshot: { + ...approved, + pricing: { ...approved.pricing, inputUsdPerMillionTokens: '3.01' }, + } }, + { config, snapshot: { + ...approved, + tokenCaps: { ...approved.tokenCaps, maxOutputTokens: 2048 }, + } }, + ]; + for (const changed of mutations) { + assert.throws(() => validateLiveApproval({ + APPROVE_LIVE_SWEEP_SHA256: stale, + MAX_SWEEP_COST_USD: '50', + }, changed), /stale or does not match/i); + } +}); + +test('the old experiment-family token is never accepted as authorization', () => { + assert.throws(() => validateLiveApproval({ + APPROVE_LIVE_SWEEP_SHA256: config.experimentFamily, + MAX_SWEEP_COST_USD: '50', + }, { config, snapshot: approved }), /sha256/); +}); diff --git a/spikes/clone-economics/tests/budget.test.mjs b/spikes/clone-economics/tests/budget.test.mjs new file mode 100644 index 0000000..05fc3f8 --- /dev/null +++ b/spikes/clone-economics/tests/budget.test.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + calculateProviderCostMicroUsd, + conservativeSweepRequestCount, + createAttemptBudget, + estimateLiveSweepMicroUsd, + validateApprovedBudgetSnapshot, +} from '../src/budget.mjs'; +import { liveAuthorizationHash } from '../src/authorization.mjs'; +import { startLiveSweep } from '../src/sweep.mjs'; +import { approved, config } from './fixtures/live-contract.mjs'; + +const counts = { trainCount: 100, heldoutCount: 30, v2Count: 2 }; + +test('live snapshot must be complete, approved, and match the experiment', () => { + assert.doesNotThrow(() => validateApprovedBudgetSnapshot(approved, config)); + assert.throws( + () => validateApprovedBudgetSnapshot({ ...approved, approvalStatus: 'not_approved' }, config), + /not approved/i, + ); + assert.throws( + () => validateApprovedBudgetSnapshot({ + ...approved, + pricing: { ...approved.pricing, inputUsdPerMillionTokens: null }, + }, config), + /input pricing/i, + ); +}); + +test('preflight counts the target gate and every call in all 12 cells', () => { + assert.equal(conservativeSweepRequestCount(config, counts), 1713); + assert.equal(calculateProviderCostMicroUsd({ + inputTokens: 4096, + outputTokens: 1024, + snapshot: approved, + }), 27_648n); + assert.equal(estimateLiveSweepMicroUsd({ config, counts, snapshot: approved }), 47_361_024n); +}); + +test('an under-cap live request constructs neither adapter nor fetch', async () => { + let adapterConstructions = 0; + let fetchConstructions = 0; + await assert.rejects(startLiveSweep({ + env: { + APPROVE_LIVE_SWEEP_SHA256: liveAuthorizationHash({ config, snapshot: approved }), + MAX_SWEEP_COST_USD: '47.00', + }, + config, + counts, + snapshot: approved, + fetchFactory() { + fetchConstructions += 1; + throw new Error('fetch must not be constructed'); + }, + adapterFactory() { + adapterConstructions += 1; + throw new Error('adapter must not be constructed'); + }, + }), /47\.361024.*47\.000000/); + assert.equal(adapterConstructions, 0); + assert.equal(fetchConstructions, 0); +}); + +test('every attempted call is reserved and the next over-cap call is refused', () => { + const budget = createAttemptBudget({ capMicroUsd: 300n, worstCaseCallMicroUsd: 100n }); + const first = budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'one' }); + budget.settleAttempt(first, { knownCostMicroUsd: 80n, success: true }); + const second = budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'two' }); + budget.settleAttempt(second, { knownCostMicroUsd: 90n, success: false }); + const third = budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'three' }); + budget.settleAttempt(third, { knownCostMicroUsd: 100n, success: true }); + assert.deepEqual(budget.state(), { + attemptedCalls: 3, + knownAccruedMicroUsd: 270n, + outstandingReservedMicroUsd: 0n, + lock: null, + }); + assert.throws( + () => budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'four' }), + /would exceed.*cap/i, + ); + assert.equal(budget.state().attemptedCalls, 3); +}); + +test('unknown cost locks its reservation and fails closed', () => { + const budget = createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }); + const attempt = budget.reserveNextAttempt({ kind: 'distill', caseId: null }); + assert.throws( + () => budget.settleAttempt(attempt, { knownCostMicroUsd: null, success: false }), + /unknown live cost.*budget locked/i, + ); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 0n, + outstandingReservedMicroUsd: 100n, + lock: { kind: 'unknown_cost', attemptId: attempt }, + }); + assert.throws( + () => budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'blocked' }), + /budget locked/i, + ); +}); + +test('above-token-cap usage records exact cost and permanently locks as budget_overrun', () => { + const budget = createAttemptBudget({ capMicroUsd: 1_000n, worstCaseCallMicroUsd: 100n }); + const attempt = budget.reserveNextAttempt({ kind: 'distill', caseId: null }); + assert.throws( + () => budget.settleAttempt(attempt, { + knownCostMicroUsd: 140n, + success: false, + budgetViolation: 'token_cap_exceeded', + }), + /budget_overrun.*token cap/i, + ); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 140n, + outstandingReservedMicroUsd: 0n, + lock: { kind: 'budget_overrun', attemptId: attempt, reason: 'token_cap_exceeded' }, + }); + assert.throws(() => budget.reserveNextAttempt({ kind: 'blocked', caseId: null }), /budget_overrun/); +}); + +test('known provider cost above the human cap is accrued before permanent lock', () => { + const budget = createAttemptBudget({ capMicroUsd: 100n, worstCaseCallMicroUsd: 100n }); + const attempt = budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'one' }); + assert.throws( + () => budget.settleAttempt(attempt, { knownCostMicroUsd: 125n, success: true }), + /budget_overrun.*human cap/i, + ); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 125n, + outstandingReservedMicroUsd: 0n, + lock: { kind: 'budget_overrun', attemptId: attempt, reason: 'human_cap_exceeded' }, + }); + assert.throws(() => budget.reserveNextAttempt({ kind: 'blocked', caseId: null }), /budget_overrun/); +}); diff --git a/spikes/clone-economics/tests/fixtures/live-contract.mjs b/spikes/clone-economics/tests/fixtures/live-contract.mjs new file mode 100644 index 0000000..5207f60 --- /dev/null +++ b/spikes/clone-economics/tests/fixtures/live-contract.mjs @@ -0,0 +1,31 @@ +export const config = { + schemaVersion: 1, + experimentFamily: 'clone-economics-high-n-v1', + fixtureSet: 'v2', + nValues: [6, 25, 50, 100], + heldoutMinimum: 30, + replicates: [ + { replicateId: 'r1', pairOrderSeed: 1701, distillationSeed: 2701 }, + { replicateId: 'r2', pairOrderSeed: 1702, distillationSeed: 2702 }, + { replicateId: 'r3', pairOrderSeed: 1703, distillationSeed: 2703 }, + ], + highNDefinition: 100, + publicationRequiresIndependentDistillationSeeds: true, +}; + +export const approved = { + schemaVersion: 1, + experimentFamily: config.experimentFamily, + approvalStatus: 'approved', + provider: 'anthropic', + model: 'synthetic-budget-test-model', + pricing: { + currency: 'USD', + unit: 'per_million_tokens', + inputUsdPerMillionTokens: '3.00', + outputUsdPerMillionTokens: '15.00', + asOf: '2026-07-17T00:00:00Z', + source: 'https://example.invalid/synthetic-pricing-fixture', + }, + tokenCaps: { maxInputTokens: 4096, maxOutputTokens: 1024 }, +}; diff --git a/spikes/clone-economics/tests/sweep-cli.test.mjs b/spikes/clone-economics/tests/sweep-cli.test.mjs new file mode 100644 index 0000000..c0285a5 --- /dev/null +++ b/spikes/clone-economics/tests/sweep-cli.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function run(args, env = {}) { + return spawnSync(process.execPath, ['sweep.mjs', ...args], { + cwd: root, + env: { + ...process.env, + ...env, + ALLOW_LIVE_LLM: '0', + MOCK_LLM: args.includes('--mock') ? '1' : '0', + ANTHROPIC_API_KEY: '', + APPROVE_LIVE_SWEEP_SHA256: '', + MAX_SWEEP_COST_USD: '', + }, + encoding: 'utf8', + }); +} + +test('preflight reports dimensions and explicit unapproved live budget', () => { + const result = run(['--preflight']); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /train fixtures: 100/); + assert.match(result.stdout, /heldout fixtures: 30/); + assert.match(result.stdout, /sweep cells: 12/); + assert.match(result.stdout, /conservative live requests: 1713/); + assert.match(result.stdout, /live budget: not approved/); + assert.doesNotMatch(result.stdout, /live authorization:/); +}); + +test('mock CLI completes without network and stays unpublishable', () => { + const result = run(['--mock']); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /cells complete: 12\/12/); + assert.match(result.stdout, /publishable high-N: false/); + assert.match(result.stdout, /suppression: HIGH_N_NOT_LIVE/); + assert.match(result.stdout, /networkAttempts=0/); +}); + +test('missing mode and default live contract both fail before construction', () => { + const missing = run([]); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /Usage:/); + const live = run(['--live']); + assert.notEqual(live.status, 0); + assert.match(live.stderr, /Live budget snapshot must be approved/); +}); diff --git a/spikes/clone-economics/tests/sweep.test.mjs b/spikes/clone-economics/tests/sweep.test.mjs new file mode 100644 index 0000000..6f05bc6 --- /dev/null +++ b/spikes/clone-economics/tests/sweep.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + classifyHighNSeedValidity, + compliantHeldoutOutput, + runSweep, + seededOrder, + validateSweepConfig, +} from '../src/sweep.mjs'; +import { scoreEvaluation } from '../src/scoring.mjs'; + +const config = { + schemaVersion: 1, + experimentFamily: 'clone-economics-high-n-v1', + fixtureSet: 'v2', + nValues: [6, 25, 50, 100], + heldoutMinimum: 30, + replicates: [ + { replicateId: 'r1', pairOrderSeed: 1701, distillationSeed: 2701 }, + { replicateId: 'r2', pairOrderSeed: 1702, distillationSeed: 2702 }, + { replicateId: 'r3', pairOrderSeed: 1703, distillationSeed: 2703 }, + ], + highNDefinition: 100, +}; + +test('sweep contract requires the exact preregistered dimensions', () => { + assert.doesNotThrow(() => validateSweepConfig(config, { trainCount: 100, heldoutCount: 30 })); + assert.throws(() => validateSweepConfig({ ...config, nValues: [6, 100] }, { trainCount: 100, heldoutCount: 30 }), /N=6,25,50,100/); +}); + +test('three pair-order seeds are deterministic and distinct', () => { + const rows = Array.from({ length: 100 }, (_, i) => `row-${i}`); + const orders = config.replicates.map((replicate) => seededOrder(rows, replicate.pairOrderSeed)); + assert.deepEqual(orders[0], seededOrder(rows, 1701)); + assert.notDeepEqual(orders[0], orders[1]); + assert.notDeepEqual(orders[1], orders[2]); +}); + +test('pair-order and distillation seeds are separate distinct contracts', () => { + assert.deepEqual(config.replicates.map((x) => x.pairOrderSeed), [1701, 1702, 1703]); + assert.deepEqual(config.replicates.map((x) => x.distillationSeed), [2701, 2702, 2703]); + assert.equal(config.replicates.some((x) => x.pairOrderSeed === x.distillationSeed), false); +}); + +test('publishable high-N requires three adapter-confirmed distillation seeds', () => { + const validBenchmark = { valid: true, verdict: 'VALID_BENCHMARK' }; + const invalidBenchmark = { valid: false, verdict: 'INVALID_BENCHMARK_TARGET_FAILED' }; + const honored = config.replicates.map((replicate) => ({ + n: 100, + replicateId: replicate.replicateId, + requestedDistillationSeed: replicate.distillationSeed, + appliedDistillationSeed: replicate.distillationSeed, + distillationSeedStatus: 'honored', + status: 'complete', + benchmark: validBenchmark, + })); + assert.deepEqual(classifyHighNSeedValidity({ + cells: honored, + adapterMode: 'live', + standaloneBenchmark: validBenchmark, + }), { + valid: true, + reason: null, + }); + assert.deepEqual(classifyHighNSeedValidity({ + cells: [ + ...honored.slice(0, 2), + { ...honored[2], appliedDistillationSeed: null, distillationSeedStatus: 'unsupported' }, + ], + adapterMode: 'live', + standaloneBenchmark: validBenchmark, + }), { + valid: false, + reason: 'DISTILLATION_SEEDS_UNCONTROLLED', + }); + assert.deepEqual(classifyHighNSeedValidity({ + cells: honored.map((cell, index) => + index === 1 ? { ...cell, benchmark: invalidBenchmark } : cell), + adapterMode: 'live', + standaloneBenchmark: validBenchmark, + }), { + valid: false, + reason: 'HIGH_N_TARGET_INVALID', + }); + assert.deepEqual(classifyHighNSeedValidity({ + cells: honored, + adapterMode: 'live', + standaloneBenchmark: invalidBenchmark, + }), { + valid: false, + reason: 'STANDALONE_TARGET_INVALID', + }); +}); + +test('synthetic heldout output satisfies the committed expected-mode gate', () => { + const fixture = { + id: 'fixture', + mode: 'Optimize', + rubric: { + expectedMode: 'Optimize', + maxQuestions: 0, + exactPaths: [{ value: '@src/example.ts', weight: 2, critical: true }], + exactCommands: [{ value: 'npm test -- example', weight: 2, critical: true }], + requiredAll: [{ value: 'preserve behavior', dimension: 'constraints', weight: 2, critical: true }], + requiredAny: [{ values: ['Show the diff'], dimension: 'output', weight: 1, critical: false }], + forbidden: [{ value: '[', dimension: 'grounding', weight: 1, critical: true }], + }, + }; + const score = scoreEvaluation({ fixture: compliantHeldoutOutput(fixture) }, [fixture]); + assert.equal(score.absoluteScore, 1); + assert.equal(score.criticalGatePass, true); +}); + +test('offline sweep completes all 12 cells without a publishable live conclusion', async (t) => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-mock-sweep-')); + t.after(() => fs.rmSync(outputDir, { recursive: true, force: true })); + let networkAttempts = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + networkAttempts += 1; + throw new Error('network forbidden in mock sweep'); + }; + t.after(() => { globalThis.fetch = originalFetch; }); + + const result = await runSweep({ mode: 'mock', outputDir }); + assert.equal(result.benchmark.valid, true); + assert.equal(result.cells.length, 12); + assert.equal(result.cells.every((cell) => cell.status === 'complete'), true); + assert.equal(result.highNComplete, true); + assert.equal(result.publishableHighN, false); + assert.equal(result.suppressionReason, 'HIGH_N_NOT_LIVE'); + assert.equal(result.samples.length, 1713); + assert.equal(result.cells.every((cell) => cell.distillationSeedStatus === 'synthetic_honored'), true); + assert.equal(networkAttempts, 0); +}); From e43ff43b87b9cfe96439e0487a00d92728f3ebdc Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:40:48 -0400 Subject: [PATCH 040/165] feat: write reproducible clone evidence bundles --- .../clone-economics/scripts/verify-bundle.mjs | 17 + spikes/clone-economics/src/evidence.mjs | 348 ++++++++++++++++++ spikes/clone-economics/src/sweep.mjs | 149 +++++++- spikes/clone-economics/sweep.mjs | 51 ++- .../clone-economics/tests/evidence.test.mjs | 180 +++++++++ 5 files changed, 741 insertions(+), 4 deletions(-) create mode 100644 spikes/clone-economics/scripts/verify-bundle.mjs create mode 100644 spikes/clone-economics/src/evidence.mjs create mode 100644 spikes/clone-economics/tests/evidence.test.mjs diff --git a/spikes/clone-economics/scripts/verify-bundle.mjs b/spikes/clone-economics/scripts/verify-bundle.mjs new file mode 100644 index 0000000..ad55f65 --- /dev/null +++ b/spikes/clone-economics/scripts/verify-bundle.mjs @@ -0,0 +1,17 @@ +import path from 'node:path'; + +import { verifyEvidenceBundle } from '../src/evidence.mjs'; + +const input = process.argv[2]; +if (!input || process.argv.length !== 3) { + console.error('Usage: node scripts/verify-bundle.mjs '); + process.exitCode = 1; +} else { + try { + const verified = verifyEvidenceBundle(path.resolve(input)); + console.log(`PASS — ${verified.manifest.experimentId} recomputes from ${verified.samples.length} normalized samples.`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/spikes/clone-economics/src/evidence.mjs b/spikes/clone-economics/src/evidence.mjs new file mode 100644 index 0000000..d0ea1aa --- /dev/null +++ b/spikes/clone-economics/src/evidence.mjs @@ -0,0 +1,348 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { liveAuthorizationHash } from './authorization.mjs'; +import { calculateProviderCostMicroUsd } from './budget.mjs'; + +const SAMPLE_KEYS = new Set([ + 'sampleId', 'phase', 'profile', 'caseId', 'n', 'replicateId', + 'pairOrderSeed', 'requestedDistillationSeed', 'appliedDistillationSeed', + 'distillationSeedStatus', 'distillationSeedMechanism', + 'success', 'latencyMs', 'inputTokens', 'outputTokens', + 'providerCostMicroUsd', 'providerCostUsd', + 'acquisitionCostUsd', 'acquisitionEvidence', 'score', 'criticalGatePass', + 'failureClass', 'providerRequestId', +]); +const FORBIDDEN_KEYS = new Set([ + 'prompt', 'payload', 'output', 'rawResponse', 'apiKey', 'authorization', + 'headers', 'skillText', 'referenceText', +]); +const CONFIGURATION_KEYS = new Set([ + 'sweepConfig', 'nValues', 'replicateIds', 'pairOrderSeeds', + 'requestedDistillationSeeds', 'appliedDistillationSeeds', 'distillationSeedEvidence', + 'tokenCaps', 'pricingSnapshot', 'evidenceLabels', 'acquisitionTreatment', + 'historicalRunDate', 'sourceTimestamp', 'attemptCoverage', 'benchmarkVerdict', + 'publicationGate', 'suppressionReason', 'fixtureSet', +]); +const REQUIRED_BUNDLE_FILES = ['samples.jsonl', 'summary.json', 'report.md', 'README.md']; +const rounded = (value) => Number(value.toFixed(12)); +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); + +function canonicalize(value) { + if (typeof value === 'bigint') return value.toString(); + if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return value; + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]), + ); + } + throw new Error(`Unsupported evidence value type: ${typeof value}`); +} + +const stableJson = (value) => `${JSON.stringify(canonicalize(value), null, 2)}\n`; +const stableLine = (value) => JSON.stringify(canonicalize(value)); + +function finiteNonNegative(value, label, { nullable = false } = {}) { + if (nullable && value === null) return; + if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a finite non-negative number or null`); +} + +function validateSample(sample) { + if (!sample || typeof sample !== 'object' || Array.isArray(sample)) throw new Error('Sample must be an object'); + for (const key of Object.keys(sample)) { + if (FORBIDDEN_KEYS.has(key)) throw new Error(`forbidden sample field: ${key}`); + if (!SAMPLE_KEYS.has(key)) throw new Error(`unknown sample field: ${key}`); + } + if (typeof sample.sampleId !== 'string' || sample.sampleId.trim() === '') throw new Error('sampleId is required'); + if (typeof sample.phase !== 'string' || sample.phase.trim() === '') throw new Error('sample phase is required'); + if (typeof sample.profile !== 'string' || sample.profile.trim() === '') throw new Error('sample profile is required'); + if (!(sample.caseId === null || typeof sample.caseId === 'string')) throw new Error('sample caseId must be string or null'); + if (typeof sample.success !== 'boolean') throw new Error('sample success must be boolean'); + finiteNonNegative(sample.latencyMs, 'sample latencyMs'); + for (const key of ['inputTokens', 'outputTokens']) { + const value = sample[key]; + if (!(value === null || (Number.isSafeInteger(value) && value >= 0))) { + throw new Error(`${key} must be a non-negative safe integer or null`); + } + } + finiteNonNegative(sample.providerCostUsd, 'sample providerCostUsd', { nullable: true }); + if (sample.providerCostMicroUsd !== undefined && sample.providerCostMicroUsd !== null + && !/^(?:0|[1-9]\d*)$/.test(sample.providerCostMicroUsd)) { + throw new Error('providerCostMicroUsd must be a base-10 non-negative integer string or null'); + } + if (sample.acquisitionCostUsd !== undefined) { + finiteNonNegative(sample.acquisitionCostUsd, 'sample acquisitionCostUsd'); + } + if (sample.score !== undefined && sample.score !== null + && (!Number.isFinite(sample.score) || sample.score < 0 || sample.score > 1)) { + throw new Error('sample score must be a finite number from 0 to 1 or null'); + } + if (sample.criticalGatePass !== undefined && sample.criticalGatePass !== null + && typeof sample.criticalGatePass !== 'boolean') { + throw new Error('sample criticalGatePass must be boolean or null'); + } + for (const key of ['n', 'pairOrderSeed', 'requestedDistillationSeed', 'appliedDistillationSeed']) { + if (sample[key] !== undefined && sample[key] !== null && !Number.isSafeInteger(sample[key])) { + throw new Error(`${key} must be a safe integer or null`); + } + } + if ((sample.inputTokens === null || sample.outputTokens === null) && sample.providerCostMicroUsd != null) { + throw new Error('Unknown usage requires providerCostMicroUsd to be null'); + } + if (sample.providerCostMicroUsd === null && sample.providerCostUsd !== null) { + throw new Error('Unknown exact provider cost requires providerCostUsd to be null'); + } + return sample; +} + +const percentile = (values, p) => { + if (values.length === 0) return null; + const ordered = [...values].sort((a, b) => a - b); + return ordered[Math.max(0, Math.ceil(p * ordered.length) - 1)]; +}; + +function sum(values) { + return values.reduce((total, value) => total + value, 0); +} + +function summarizeScoresByProfile(samples) { + const profiles = [...new Set(samples.map((sample) => sample.profile))].sort(); + return Object.fromEntries(profiles.map((profile) => { + const scored = samples.filter((sample) => sample.profile === profile && Number.isFinite(sample.score)); + return [profile, { + scoredSamples: scored.length, + meanScore: scored.length === 0 ? null : rounded(sum(scored.map((sample) => sample.score)) / scored.length), + criticalGatePass: scored.length === 0 + ? null + : scored.every((sample) => sample.criticalGatePass === true), + }]; + })); +} + +export function recomputeSummary(samples) { + if (!Array.isArray(samples)) throw new Error('samples must be an array'); + for (const sample of samples) validateSample(sample); + const ids = new Set(); + for (const sample of samples) { + if (ids.has(sample.sampleId)) throw new Error(`duplicate sampleId: ${sample.sampleId}`); + ids.add(sample.sampleId); + } + const latencies = samples + .filter((sample) => sample.success && Number.isFinite(sample.latencyMs)) + .map((sample) => sample.latencyMs); + return { + attemptedSamples: samples.length, + successfulSamples: samples.filter((sample) => sample.success).length, + failedSamples: samples.filter((sample) => !sample.success).length, + providerCostUsd: samples.every((sample) => sample.providerCostUsd !== null) + ? rounded(sum(samples.map((sample) => sample.providerCostUsd))) + : null, + acquisition: { + modeledUsd: rounded(sum(samples.map((sample) => sample.acquisitionCostUsd ?? 0))), + evidence: [...new Set(samples.map((sample) => sample.acquisitionEvidence).filter(Boolean))].sort(), + }, + latencyMs: { p50: percentile(latencies, 0.5), p95: percentile(latencies, 0.95) }, + fidelity: summarizeScoresByProfile(samples), + }; +} + +function sanitizeConfiguration(configuration = {}) { + if (!configuration || typeof configuration !== 'object' || Array.isArray(configuration)) { + throw new Error('Evidence configuration must be an object'); + } + for (const key of Object.keys(configuration)) { + if (!CONFIGURATION_KEYS.has(key)) throw new Error(`Unsupported evidence configuration field: ${key}`); + } + return canonicalize(configuration); +} + +function validateSourceEvidence(sourceEvidence) { + if (sourceEvidence === null || sourceEvidence === undefined) return null; + if (!sourceEvidence || typeof sourceEvidence !== 'object' + || JSON.stringify(Object.keys(sourceEvidence).sort()) !== JSON.stringify(['bytes', 'kind', 'sha256'])) { + throw new Error('sourceEvidence must contain only kind, sha256, and bytes'); + } + if (typeof sourceEvidence.kind !== 'string' || sourceEvidence.kind === '') throw new Error('sourceEvidence kind is required'); + if (!/^[0-9a-f]{64}$/.test(sourceEvidence.sha256)) throw new Error('sourceEvidence sha256 must be a lowercase digest'); + if (!Number.isSafeInteger(sourceEvidence.bytes) || sourceEvidence.bytes <= 0) throw new Error('sourceEvidence bytes must be positive'); + return { ...sourceEvidence }; +} + +function renderReport(summary, interpretation) { + const value = (input) => input === null ? 'unknown' : String(input); + return `# Clone-economics evidence report + +${interpretation} + +- Attempted samples: ${summary.attemptedSamples} +- Successful samples: ${summary.successfulSamples} +- Failed samples: ${summary.failedSamples} +- Provider cost USD: ${value(summary.providerCostUsd)} +- Latency p50 ms: ${value(summary.latencyMs.p50)} +- Latency p95 ms: ${value(summary.latencyMs.p95)} +`; +} + +function renderReadme(reproduction) { + return `# Evidence bundle + +Verify and reproduce: + +\`\`\`bash +${reproduction} +\`\`\` + +Samples are normalized and allow-listed. Prompt payloads, output text, API keys, +headers, target Skill bytes, and reference bytes are excluded. + +Unknown usage or cost remains null and makes aggregate provider cost unknown. +This bundle does not by itself authorize publication or a live benchmark claim. +`; +} + +export function writeEvidenceBundle({ + outputDir, + manifest, + samples, + interpretation, + reproduction, +}) { + if (!manifest || typeof manifest.experimentId !== 'string' || manifest.experimentId === '') { + throw new Error('Evidence manifest experimentId is required'); + } + if (typeof manifest.evidenceLabel !== 'string' || manifest.evidenceLabel === '') { + throw new Error('Evidence manifest evidenceLabel is required'); + } + if (typeof manifest.command !== 'string' || manifest.command === '') throw new Error('Evidence manifest command is required'); + if (typeof interpretation !== 'string' || interpretation === '') throw new Error('Evidence interpretation is required'); + if (typeof reproduction !== 'string' || reproduction === '') throw new Error('Evidence reproduction command is required'); + const summary = recomputeSummary(samples); + fs.mkdirSync(outputDir, { recursive: true }); + if (fs.readdirSync(outputDir).length !== 0) throw new Error('Evidence output directory must be empty'); + + const contents = { + 'samples.jsonl': `${samples.map(stableLine).join('\n')}\n`, + 'summary.json': stableJson(summary), + 'report.md': renderReport(summary, interpretation), + 'README.md': renderReadme(reproduction), + }; + for (const name of REQUIRED_BUNDLE_FILES) fs.writeFileSync(path.join(outputDir, name), contents[name]); + + const recordedAtUtc = manifest.recordedAtUtc === undefined ? new Date().toISOString() : manifest.recordedAtUtc; + if (!(recordedAtUtc === null || (typeof recordedAtUtc === 'string' + && Number.isFinite(Date.parse(recordedAtUtc)) + && /^\d{4}-\d{2}-\d{2}T/.test(recordedAtUtc)))) { + throw new Error('recordedAtUtc must be an ISO-8601 instant or null'); + } + const sourceEvidence = validateSourceEvidence(manifest.sourceEvidence); + const configuration = sanitizeConfiguration(manifest.configuration); + if (recordedAtUtc === null + && !(configuration.historicalRunDate && configuration.sourceTimestamp === 'not-recorded')) { + throw new Error('A null recordedAtUtc requires a historical date and sourceTimestamp not-recorded'); + } + const finalManifest = { + schemaVersion: 1, + experimentId: manifest.experimentId, + recordedAtUtc, + gitCommit: manifest.gitCommit ?? 'not-recorded', + command: manifest.command, + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + modelProvider: manifest.modelProvider ?? null, + model: manifest.model ?? null, + evidenceLabel: manifest.evidenceLabel, + sourceEvidence, + liveBudget: canonicalize(manifest.liveBudget ?? null), + configuration, + files: Object.fromEntries(REQUIRED_BUNDLE_FILES.map((name) => [name, { + sha256: sha256(contents[name]), + bytes: Buffer.byteLength(contents[name]), + }])), + }; + fs.writeFileSync(path.join(outputDir, 'manifest.json'), stableJson(finalManifest)); + return finalManifest; +} + +function assertReportMatchesSummary(report, summary) { + const fields = { + 'Attempted samples': summary.attemptedSamples, + 'Successful samples': summary.successfulSamples, + 'Failed samples': summary.failedSamples, + 'Provider cost USD': summary.providerCostUsd, + 'Latency p50 ms': summary.latencyMs.p50, + 'Latency p95 ms': summary.latencyMs.p95, + }; + for (const [label, expected] of Object.entries(fields)) { + const match = report.match(new RegExp(`^- ${label}: (.+)$`, 'm')); + if (!match) throw new Error(`report.md is missing ${label}`); + const actual = match[1] === 'unknown' ? null : Number(match[1]); + if (!Object.is(actual, expected)) throw new Error(`report.md ${label} differs from summary.json`); + } +} + +function verifyLiveRows(samples, manifest, dir) { + if (manifest.liveBudget === null) return; + const allowed = [ + 'snapshotPath', 'snapshotSha256', 'authorizationHash', 'humanCapMicroUsd', + 'conservativeEstimateMicroUsd', 'worstCasePerCallMicroUsd', 'attemptedCalls', + 'knownAccruedMicroUsd', 'outstandingReservedMicroUsd', 'lock', + ]; + if (JSON.stringify(Object.keys(manifest.liveBudget).sort()) !== JSON.stringify(allowed.sort())) { + throw new Error('liveBudget contains unexpected or missing fields'); + } + if (path.isAbsolute(manifest.liveBudget.snapshotPath) || manifest.liveBudget.snapshotPath.includes('..')) { + throw new Error('liveBudget snapshotPath must be repository-relative'); + } + const snapshotPath = path.resolve(dir, '..', '..', manifest.liveBudget.snapshotPath); + const snapshotBytes = fs.readFileSync(snapshotPath); + if (sha256(snapshotBytes) !== manifest.liveBudget.snapshotSha256) throw new Error('Live budget snapshot hash mismatch'); + const snapshot = JSON.parse(snapshotBytes); + const expectedAuthorization = liveAuthorizationHash({ + config: manifest.configuration.sweepConfig, + snapshot, + }); + if (expectedAuthorization !== manifest.liveBudget.authorizationHash) throw new Error('Live authorization hash mismatch'); + if (manifest.liveBudget.attemptedCalls !== samples.length) throw new Error('Live attempted-call count differs from samples'); + for (const sample of samples) { + if (sample.inputTokens === null || sample.outputTokens === null) { + if (sample.providerCostMicroUsd !== null || sample.providerCostUsd !== null) { + throw new Error('Unknown live usage requires both provider costs to be null'); + } + continue; + } + const expected = calculateProviderCostMicroUsd({ + inputTokens: sample.inputTokens, + outputTokens: sample.outputTokens, + snapshot, + }); + if (sample.providerCostMicroUsd !== expected.toString()) throw new Error(`Live exact provider cost mismatch for ${sample.sampleId}`); + } +} + +export function verifyEvidenceBundle(dir) { + const manifestPath = path.join(dir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) throw new Error('Missing required evidence file: manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (manifest.schemaVersion !== 1) throw new Error('Unsupported evidence manifest schemaVersion'); + for (const name of REQUIRED_BUNDLE_FILES) { + const filePath = path.join(dir, name); + if (!fs.existsSync(filePath)) throw new Error(`Missing required evidence file: ${name}`); + const bytes = fs.readFileSync(filePath); + const expected = manifest.files?.[name]; + if (!expected || expected.bytes !== bytes.length || expected.sha256 !== sha256(bytes)) { + throw new Error(`Evidence hash or byte count mismatch: ${name}`); + } + } + const sampleText = fs.readFileSync(path.join(dir, 'samples.jsonl'), 'utf8'); + if (!sampleText.endsWith('\n')) throw new Error('samples.jsonl must end with a newline'); + const lines = sampleText.slice(0, -1).split('\n'); + const samples = lines.length === 1 && lines[0] === '' ? [] : lines.map((line) => JSON.parse(line)); + const summary = recomputeSummary(samples); + if (fs.readFileSync(path.join(dir, 'summary.json'), 'utf8') !== stableJson(summary)) { + throw new Error('summary.json differs from recomputation'); + } + assertReportMatchesSummary(fs.readFileSync(path.join(dir, 'report.md'), 'utf8'), summary); + verifyLiveRows(samples, manifest, dir); + return { valid: true, manifest, summary, samples }; +} diff --git a/spikes/clone-economics/src/sweep.mjs b/spikes/clone-economics/src/sweep.mjs index 309a0b9..88119df 100644 --- a/spikes/clone-economics/src/sweep.mjs +++ b/spikes/clone-economics/src/sweep.mjs @@ -98,6 +98,128 @@ export const compliantHeldoutOutput = (fixture) => [ 'Show the diff', ].join('\n'); +function attemptPhase(kind) { + if (kind === 'target-train') return 'acquisition'; + if (kind === 'distill') return 'distillation'; + return 'evaluation'; +} + +function attemptProfile(kind) { + if (kind.startsWith('target-')) return 'target'; + if (kind.startsWith('bad-clone-')) return 'bad-clone'; + return 'clone'; +} + +export function normalizeSweepSamples({ experimentId, attempts }) { + return attempts.map((attempt) => { + const normalized = { + sampleId: `${experimentId}:${attempt.attemptId}`, + phase: attemptPhase(attempt.kind), + profile: attemptProfile(attempt.kind), + caseId: attempt.caseId ?? null, + n: attempt.n ?? null, + replicateId: attempt.replicateId ?? null, + pairOrderSeed: attempt.pairOrderSeed ?? null, + requestedDistillationSeed: attempt.requestedDistillationSeed ?? attempt.requestedSeed ?? null, + appliedDistillationSeed: attempt.appliedDistillationSeed ?? attempt.appliedSeed ?? null, + distillationSeedStatus: attempt.distillationSeedStatus ?? attempt.status ?? 'not_requested', + distillationSeedMechanism: attempt.distillationSeedMechanism ?? attempt.mechanism ?? 'no_seed_requested', + success: attempt.success, + latencyMs: attempt.latencyMs, + inputTokens: attempt.inputTokens ?? null, + outputTokens: attempt.outputTokens ?? null, + providerCostMicroUsd: attempt.providerCostMicroUsd ?? null, + providerCostUsd: attempt.providerCostUsd ?? null, + acquisitionCostUsd: attempt.acquisitionCostUsd ?? 0, + acquisitionEvidence: attempt.acquisitionEvidence ?? null, + score: attempt.score ?? null, + criticalGatePass: attempt.criticalGatePass ?? null, + failureClass: attempt.failureClass ?? null, + providerRequestId: attempt.providerRequestId ?? null, + }; + return normalized; + }); +} + +export async function writeSweepEvidenceBundle({ + result, + config, + outputDir, + experimentId, + evidenceLabel, + command, + recordedAtUtc, + gitCommit, + modelProvider = null, + model = null, + liveBudget = null, + reproduction = null, +}) { + const { verifyEvidenceBundle, writeEvidenceBundle } = await import('./evidence.mjs'); + const samples = normalizeSweepSamples({ experimentId, attempts: result.samples }); + const incompleteCosts = samples.some((sample) => sample.providerCostUsd === null); + const interpretation = [ + result.publishableHighN + ? 'The preregistered high-N publication gate passed.' + : `Aggregate clone and economics conclusions are suppressed: ${result.suppressionReason ?? result.benchmark?.verdict ?? 'INCOMPLETE'}.`, + incompleteCosts + ? 'Limitations: at least one attempted provider call has unknown cost, so aggregate provider cost is incomplete.' + : 'All normalized attempted-call costs are present.', + ].join(' '); + const manifest = writeEvidenceBundle({ + outputDir, + manifest: { + experimentId, + recordedAtUtc, + gitCommit, + command, + modelProvider, + model, + evidenceLabel, + liveBudget, + configuration: { + sweepConfig: config, + fixtureSet: config.fixtureSet, + acquisitionTreatment: config.acquisitionTreatment, + publicationGate: { + publishableHighN: result.publishableHighN ?? false, + suppressionReason: result.suppressionReason ?? result.benchmark?.verdict ?? null, + }, + }, + }, + samples, + interpretation, + reproduction: reproduction ?? `node scripts/verify-bundle.mjs ${outputDir}`, + }); + return { manifest, verified: verifyEvidenceBundle(outputDir) }; +} + +function scoreMap(score) { + return new Map((score?.cases ?? []).map((item) => [item.id, item])); +} + +function annotateAttempt(attempt, metadata, scores = {}) { + const score = attempt.kind === 'target-heldout' + ? scores.target?.get(attempt.caseId) + : attempt.kind === 'clone-heldout' + ? scores.clone?.get(attempt.caseId) + : attempt.kind === 'bad-clone-heldout' + ? scores.bad?.get(attempt.caseId) + : null; + return { + ...structuredClone(attempt), + ...metadata, + requestedDistillationSeed: metadata.requestedDistillationSeed ?? attempt.requestedSeed ?? null, + appliedDistillationSeed: attempt.appliedSeed ?? null, + distillationSeedStatus: attempt.status ?? 'not_requested', + distillationSeedMechanism: attempt.mechanism ?? 'no_seed_requested', + acquisitionCostUsd: attempt.kind === 'target-train' ? metadata.invocationPriceUsd : 0, + acquisitionEvidence: attempt.kind === 'target-train' ? 'MODELED' : null, + score: score?.score ?? null, + criticalGatePass: score?.criticalGatePass ?? null, + }; +} + export async function startLiveSweep({ env, config, @@ -189,7 +311,14 @@ export async function runSweep(options) { heldoutFixtures: fixtures.heldout, threshold: config.targetThreshold ?? 0.8, }); - const samples = adapter.attempts.map((attempt) => structuredClone(attempt)); + const standaloneScores = { target: scoreMap(targetScore) }; + const samples = adapter.attempts.map((attempt) => annotateAttempt(attempt, { + n: null, + replicateId: null, + pairOrderSeed: null, + requestedDistillationSeed: null, + invocationPriceUsd: 0, + }, standaloneScores)); const reconcile = () => { if (samples.length !== adapter.attempts.length) { throw new Error('Sweep sample count does not reconcile with adapter attempts'); @@ -220,8 +349,9 @@ export async function runSweep(options) { options.outputDir ?? path.join(root, 'runs', `${mode}-sweep`), `n${n}-${replicate.replicateId}`, ); + let result = null; try { - const result = await runExperiment({ + result = await runExperiment({ mode, adapter, outputDir: cellOutputDir, @@ -269,7 +399,20 @@ export async function runSweep(options) { }); stop = true; } finally { - for (const attempt of adapter.attempts.slice(attemptStart)) samples.push(structuredClone(attempt)); + const scores = result ? { + target: scoreMap(result.report.fidelity.target), + clone: scoreMap(result.report.fidelity.clone), + bad: scoreMap(result.report.fidelity.badClone), + } : {}; + for (const attempt of adapter.attempts.slice(attemptStart)) { + samples.push(annotateAttempt(attempt, { + n, + replicateId: replicate.replicateId, + pairOrderSeed: replicate.pairOrderSeed, + requestedDistillationSeed: replicate.distillationSeed, + invocationPriceUsd: options.invocationPriceUsd ?? 0.25, + }, scores)); + } } if (stop) break; } diff --git a/spikes/clone-economics/sweep.mjs b/spikes/clone-economics/sweep.mjs index cf0238e..5f1a2cd 100644 --- a/spikes/clone-economics/sweep.mjs +++ b/spikes/clone-economics/sweep.mjs @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createHash } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -11,7 +12,12 @@ import { } from './src/budget.mjs'; import { liveAuthorizationHash } from './src/authorization.mjs'; import { loadFixtureSet } from './src/fixture-set.mjs'; -import { runSweep, startLiveSweep, validateSweepConfig } from './src/sweep.mjs'; +import { + runSweep, + startLiveSweep, + validateSweepConfig, + writeSweepEvidenceBundle, +} from './src/sweep.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); const readJson = (name) => JSON.parse(fs.readFileSync(path.join(root, 'fixtures', name), 'utf8')); @@ -63,10 +69,22 @@ async function main() { }; try { const result = await runSweep({ mode: 'mock' }); + const experimentId = `mock-high-n-${new Date().toISOString().replaceAll(/[-:.]/g, '')}-${process.pid}`; + const evidenceRelative = path.join('runs', 'mock-sweep', 'evidence', experimentId); + await writeSweepEvidenceBundle({ + result, + config, + outputDir: path.join(root, evidenceRelative), + experimentId, + evidenceLabel: 'SYNTHETIC', + command: 'npm run sweep:mock', + reproduction: `node scripts/verify-bundle.mjs ${evidenceRelative}`, + }); console.log(`cells complete: ${result.cells.filter((cell) => cell.status === 'complete').length}/${result.cells.length}`); console.log(`publishable high-N: ${result.publishableHighN}`); console.log(`suppression: ${result.suppressionReason}`); console.log(`networkAttempts=${networkAttempts}`); + console.log(`verified evidence: ${evidenceRelative}`); } finally { globalThis.fetch = priorFetch; } @@ -88,10 +106,41 @@ async function main() { }), sweepOptions: { outputDir: path.join(root, 'runs', 'live-sweep') }, }); + const recordedAtUtc = new Date().toISOString(); + const experimentId = `live-high-n-${recordedAtUtc.replaceAll(/[-:.]/g, '')}`; + const evidenceRelative = path.join('evidence', experimentId); + const snapshotBytes = fs.readFileSync(path.join(root, 'fixtures/live-budget-v1.json')); + await writeSweepEvidenceBundle({ + result: live.result, + config, + outputDir: path.join(root, evidenceRelative), + experimentId, + evidenceLabel: live.result.publishableHighN + ? 'LIVE CANDIDATE — PUBLICATION GATE PASSED' + : 'LIVE CANDIDATE — CONCLUSIONS SUPPRESSED', + command: 'npm run sweep:live', + recordedAtUtc, + modelProvider: 'Anthropic', + model: snapshot.model, + liveBudget: { + snapshotPath: 'fixtures/live-budget-v1.json', + snapshotSha256: createHash('sha256').update(snapshotBytes).digest('hex'), + authorizationHash: live.authorizationHash, + humanCapMicroUsd: live.capMicroUsd.toString(), + conservativeEstimateMicroUsd: live.estimateMicroUsd.toString(), + worstCasePerCallMicroUsd: live.perCallMicroUsd.toString(), + attemptedCalls: live.budgetState.attemptedCalls, + knownAccruedMicroUsd: live.budgetState.knownAccruedMicroUsd.toString(), + outstandingReservedMicroUsd: live.budgetState.outstandingReservedMicroUsd.toString(), + lock: live.budgetState.lock, + }, + reproduction: `node scripts/verify-bundle.mjs ${evidenceRelative}`, + }); console.log(`live authorization: ${live.authorizationHash}`); console.log(`attempted calls: ${live.budgetState.attemptedCalls}`); console.log(`publishable high-N: ${live.result.publishableHighN}`); console.log(`suppression: ${live.result.suppressionReason}`); + console.log(`verified evidence: ${evidenceRelative}`); } main().catch((error) => { diff --git a/spikes/clone-economics/tests/evidence.test.mjs b/spikes/clone-economics/tests/evidence.test.mjs new file mode 100644 index 0000000..d98af2c --- /dev/null +++ b/spikes/clone-economics/tests/evidence.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; + +import { recomputeSummary, verifyEvidenceBundle, writeEvidenceBundle } from '../src/evidence.mjs'; +import { normalizeSweepSamples, writeSweepEvidenceBundle } from '../src/sweep.mjs'; + +const samples = [ + { sampleId: 'run:target-heldout:a', phase: 'evaluation', profile: 'target', caseId: 'a', success: true, latencyMs: 10, inputTokens: 3, outputTokens: 2, providerCostUsd: 0.01, score: 0.9, criticalGatePass: true }, + { sampleId: 'run:clone-heldout:a', phase: 'evaluation', profile: 'clone', caseId: 'a', success: true, latencyMs: 30, inputTokens: 3, outputTokens: 2, providerCostUsd: 0.02, score: 0.7, criticalGatePass: false }, + { sampleId: 'run:distill:1', phase: 'distillation', profile: 'clone', caseId: null, success: false, latencyMs: 5, inputTokens: null, outputTokens: null, providerCostUsd: null, score: null, criticalGatePass: null, failureClass: 'ProviderError' }, +]; + +test('bundle hashes and summary recompute from normalized samples', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + writeEvidenceBundle({ + outputDir: dir, + manifest: { experimentId: 'fixture-run', evidenceLabel: 'SYNTHETIC', command: 'npm run sweep:mock' }, + samples, + interpretation: 'Synthetic fixture bundle.', + reproduction: 'node scripts/verify-bundle.mjs evidence/fixture-run', + }); + const verified = verifyEvidenceBundle(dir); + assert.equal(verified.valid, true); + assert.equal(verified.summary.attemptedSamples, 3); + assert.equal(verified.summary.failedSamples, 1); + assert.equal(verified.summary.providerCostUsd, null); + assert.equal(verified.summary.latencyMs.p50, 10); + assert.equal(verified.summary.latencyMs.p95, 30); +}); + +test('redaction rejects private payload fields', () => { + assert.throws(() => recomputeSummary([{ ...samples[0], prompt: 'private' }]), /forbidden sample field: prompt/); +}); + +function rewriteManifestHash(dir, name) { + const manifestPath = path.join(dir, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const bytes = fs.readFileSync(path.join(dir, name)); + manifest.files[name] = { + sha256: createHash('sha256').update(bytes).digest('hex'), + bytes: bytes.length, + }; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} + +function bundle(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-strict-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + writeEvidenceBundle({ + outputDir: dir, + manifest: { experimentId: 'strict-run', evidenceLabel: 'SYNTHETIC', command: 'test' }, + samples, + interpretation: 'Strict fixture bundle.', + reproduction: 'verify strict fixture', + }); + return dir; +} + +test('verifier rejects a changed file even when JSON remains parseable', (t) => { + const dir = bundle(t); + fs.appendFileSync(path.join(dir, 'README.md'), '\ntampered\n'); + assert.throws(() => verifyEvidenceBundle(dir), /hash or byte count mismatch: README\.md/); +}); + +test('verifier rejects duplicate IDs and forbidden fields after a manifest rehash', (t) => { + const duplicateDir = bundle(t); + fs.appendFileSync(path.join(duplicateDir, 'samples.jsonl'), `${JSON.stringify(samples[0])}\n`); + rewriteManifestHash(duplicateDir, 'samples.jsonl'); + assert.throws(() => verifyEvidenceBundle(duplicateDir), /duplicate sampleId/); + + const forbiddenDir = bundle(t); + const changed = { ...samples[0], prompt: 'private' }; + const lines = fs.readFileSync(path.join(forbiddenDir, 'samples.jsonl'), 'utf8').trimEnd().split('\n'); + lines[0] = JSON.stringify(changed); + fs.writeFileSync(path.join(forbiddenDir, 'samples.jsonl'), `${lines.join('\n')}\n`); + rewriteManifestHash(forbiddenDir, 'samples.jsonl'); + assert.throws(() => verifyEvidenceBundle(forbiddenDir), /forbidden sample field: prompt/); +}); + +test('verifier rejects summary and report numbers changed behind updated hashes', (t) => { + const summaryDir = bundle(t); + const summaryPath = path.join(summaryDir, 'summary.json'); + const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); + summary.failedSamples = 0; + fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`); + rewriteManifestHash(summaryDir, 'summary.json'); + assert.throws(() => verifyEvidenceBundle(summaryDir), /summary\.json differs from recomputation/); + + const reportDir = bundle(t); + const reportPath = path.join(reportDir, 'report.md'); + fs.writeFileSync(reportPath, fs.readFileSync(reportPath, 'utf8').replace('Latency p95 ms: 30', 'Latency p95 ms: 29')); + rewriteManifestHash(reportDir, 'report.md'); + assert.throws(() => verifyEvidenceBundle(reportDir), /Latency p95 ms differs/); +}); + +test('sweep attempts normalize without request payload or output bytes', () => { + const normalized = normalizeSweepSamples({ + experimentId: 'sweep-fixture', + attempts: [{ + attemptId: 'distill:distill:1', + kind: 'distill', + caseId: null, + n: 100, + replicateId: 'r1', + pairOrderSeed: 1701, + requestedDistillationSeed: 2701, + appliedDistillationSeed: null, + distillationSeedStatus: 'unsupported', + distillationSeedMechanism: 'provider_seed_not_supported_by_adapter', + success: false, + latencyMs: 5, + inputTokens: null, + outputTokens: null, + providerCostMicroUsd: null, + providerCostUsd: null, + failureClass: 'ProviderError', + providerRequestId: null, + payload: { private: true }, + output: 'private', + }], + }); + assert.equal(normalized.length, 1); + assert.equal(Object.hasOwn(normalized[0], 'payload'), false); + assert.equal(Object.hasOwn(normalized[0], 'output'), false); + assert.doesNotThrow(() => recomputeSummary(normalized)); +}); + +test('completed sweep output writes and verifies through the public bundle seam', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-sweep-evidence-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const result = { + samples: [{ + attemptId: 'target-heldout:a:1', + kind: 'target-heldout', + caseId: 'a', + n: null, + replicateId: null, + pairOrderSeed: null, + requestedDistillationSeed: null, + appliedDistillationSeed: null, + distillationSeedStatus: 'not_requested', + distillationSeedMechanism: 'no_seed_requested', + success: true, + latencyMs: 10, + inputTokens: 3, + outputTokens: 2, + providerCostMicroUsd: '5', + providerCostUsd: 0.000005, + score: 1, + criticalGatePass: true, + failureClass: null, + providerRequestId: 'synthetic', + }], + publishableHighN: false, + suppressionReason: 'HIGH_N_NOT_LIVE', + }; + const config = { + schemaVersion: 1, + experimentFamily: 'clone-economics-high-n-v1', + fixtureSet: 'v2', + nValues: [6, 25, 50, 100], + replicates: [], + acquisitionTreatment: 'modeled_unless_x402_receipts_attached', + }; + const written = await writeSweepEvidenceBundle({ + result, + config, + outputDir: dir, + experimentId: 'sweep-bundle-fixture', + evidenceLabel: 'SYNTHETIC', + command: 'npm run sweep:mock', + }); + assert.equal(written.verified.valid, true); + assert.equal(written.verified.samples.length, 1); +}); From f23c3ba8d5aad0623de9f7e45b433fc59c1ef537 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:42:55 -0400 Subject: [PATCH 041/165] feat: add hash-locked legacy evidence importer --- .../scripts/import-legacy-run.mjs | 173 ++++++++++++++++++ .../tests/import-legacy-run.test.mjs | 118 ++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 spikes/clone-economics/scripts/import-legacy-run.mjs create mode 100644 spikes/clone-economics/tests/import-legacy-run.test.mjs diff --git a/spikes/clone-economics/scripts/import-legacy-run.mjs b/spikes/clone-economics/scripts/import-legacy-run.mjs new file mode 100644 index 0000000..6a25ea5 --- /dev/null +++ b/spikes/clone-economics/scripts/import-legacy-run.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { writeEvidenceBundle } from '../src/evidence.mjs'; + +export const LEGACY_SOURCE_SHA256 = + '0554779988164651bfe6b037c8b16054e009ee6bac76e61c90af331ac6e85212'; +export const LEGACY_SOURCE_BYTES = 76_631; + +function assertLegacyFacts(source) { + assert.equal(source.schemaVersion, 1, 'historical source fact mismatch: schemaVersion'); + assert.equal(source.mode, 'live', 'historical source fact mismatch: mode'); + assert.equal(source.dataset?.N, 6, 'historical source fact mismatch: N'); + assert.equal(source.fidelity?.target?.absoluteScore, 0.4, 'historical source fact mismatch: target score'); + assert.equal(source.fidelity?.target?.criticalGatePass, false, 'historical source fact mismatch: target gates'); + assert.equal(source.economics?.acquisitionModeledUsd, 1.5, 'historical source fact mismatch: modeled acquisition'); +} + +function scoreFor(source, record) { + const cases = record.kind === 'target-heldout' + ? source.fidelity.target.cases + : record.kind === 'clone-heldout' + ? source.fidelity.clone.cases + : record.kind === 'bad-clone-heldout' + ? source.fidelity.badClone.cases + : record.kind === 'target-v2-heldout' + ? source.evolution.updatedTarget.cases + : record.kind === 'clone-v2-heldout' + ? source.evolution.frozenClone.cases + : null; + if (!cases) return null; + const score = cases.find((item) => item.id === record.caseId); + if (!score) throw new Error(`Historical fidelity row missing for ${record.kind}:${record.caseId}`); + return score; +} + +function phaseFor(kind) { + if (kind === 'target-train') return 'acquisition'; + if (kind === 'distill') return 'distillation'; + if (kind.endsWith('-heldout')) return 'evaluation'; + throw new Error(`Unsupported historical request kind: ${kind}`); +} + +function profileFor(kind) { + if (kind.startsWith('target-')) return 'target'; + if (kind.startsWith('bad-clone-')) return 'bad-clone'; + return 'clone'; +} + +export function normalizeLegacyReport(source) { + assertLegacyFacts(source); + if (!Array.isArray(source.usage?.raw) || source.usage.raw.length !== 29) { + throw new Error('Historical source must contain exactly 29 retained usage rows'); + } + const acquisitionPerPair = source.economics.acquisitionModeledUsd / source.dataset.N; + return source.usage.raw.map((record, index) => { + const score = scoreFor(source, record); + const inputTokens = record.normalizedUsage?.inputTokens ?? record.rawUsage?.input_tokens ?? null; + const outputTokens = record.normalizedUsage?.outputTokens ?? record.rawUsage?.output_tokens ?? null; + const providerCostUsd = Number.isFinite(record.costUsd) && record.costUsd >= 0 + ? record.costUsd + : null; + return { + sampleId: `legacy:${record.requestId ?? `${record.kind}:${record.caseId ?? 'none'}:${index + 1}`}`, + phase: phaseFor(record.kind), + profile: profileFor(record.kind), + caseId: record.caseId ?? null, + n: 6, + replicateId: null, + pairOrderSeed: null, + requestedDistillationSeed: null, + appliedDistillationSeed: null, + distillationSeedStatus: 'not_recorded', + distillationSeedMechanism: 'historical_source_not_recorded', + success: true, + latencyMs: record.latencyMs, + inputTokens, + outputTokens, + providerCostMicroUsd: providerCostUsd === null + ? null + : String(Math.round(providerCostUsd * 1_000_000)), + providerCostUsd, + acquisitionCostUsd: record.kind === 'target-train' ? acquisitionPerPair : 0, + acquisitionEvidence: record.kind === 'target-train' ? 'MODELED' : null, + score: score?.score ?? null, + criticalGatePass: score?.criticalGatePass ?? null, + failureClass: null, + providerRequestId: record.requestId ?? null, + }; + }); +} + +function parseNamedArgs(argv) { + const allowed = new Set(['--input', '--expected-sha256', '--output']); + if (argv.length !== 6) throw new Error('Usage: import-legacy-run.mjs --input --expected-sha256 --output '); + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!allowed.has(name) || typeof value !== 'string' || value === '' || values[name] !== undefined) { + throw new Error('Importer accepts each of --input, --expected-sha256, and --output exactly once'); + } + values[name] = value; + } + if (Object.keys(values).length !== 3) throw new Error('Importer requires all named arguments'); + return { + input: path.resolve(values['--input']), + expectedSha256: values['--expected-sha256'], + output: path.resolve(values['--output']), + }; +} + +export function importLegacyRun(argv) { + const args = parseNamedArgs(argv); + if (args.expectedSha256 !== LEGACY_SOURCE_SHA256) { + throw new Error('Declared digest must equal the immutable legacy digest'); + } + const sourceBytes = fs.readFileSync(args.input); + if (sourceBytes.length !== LEGACY_SOURCE_BYTES) { + throw new Error(`Legacy source byte count mismatch: ${sourceBytes.length} != ${LEGACY_SOURCE_BYTES}`); + } + const actualDigest = createHash('sha256').update(sourceBytes).digest('hex'); + if (actualDigest !== LEGACY_SOURCE_SHA256) throw new Error('Legacy source digest mismatch'); + if (fs.existsSync(args.output)) throw new Error('Evidence output directory already exists'); + const source = JSON.parse(sourceBytes.toString('utf8')); + const samples = normalizeLegacyReport(source); + writeEvidenceBundle({ + outputDir: args.output, + manifest: { + experimentId: '2026-07-12-n6-invalid', + recordedAtUtc: null, + gitCommit: 'historical-source-not-recorded', + command: 'historical live command not retained exactly', + modelProvider: 'Anthropic', + model: source.usage.raw[0]?.model ?? null, + evidenceLabel: 'HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED', + sourceEvidence: { + kind: 'legacy-report-json', + sha256: LEGACY_SOURCE_SHA256, + bytes: LEGACY_SOURCE_BYTES, + }, + configuration: { + historicalRunDate: '2026-07-12', + sourceTimestamp: 'not-recorded', + nValues: [6], + pairOrderSeeds: ['not-recorded'], + requestedDistillationSeeds: ['not-recorded'], + appliedDistillationSeeds: ['not-recorded'], + acquisitionTreatment: 'modeled', + attemptCoverage: 'successful fifth run only; four setup attempts have no normalized records', + benchmarkVerdict: 'INVALID_BENCHMARK_TARGET_FAILED', + }, + }, + samples, + interpretation: 'INVALID_BENCHMARK_TARGET_FAILED. The target scored 0.400 and failed its critical gates, so clone quality, fidelity defense, moat, retention, break-even, and economics conclusions are suppressed. Provider execution was measured where retained; acquisition was modeled. Four earlier setup attempts have no normalized records.', + reproduction: 'node scripts/verify-bundle.mjs evidence/2026-07-12-n6-invalid', + }); + return { output: args.output, samples: samples.length }; +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + try { + const result = importLegacyRun(process.argv.slice(2)); + console.log(`Imported ${result.samples} normalized historical samples.`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/spikes/clone-economics/tests/import-legacy-run.test.mjs b/spikes/clone-economics/tests/import-legacy-run.test.mjs new file mode 100644 index 0000000..19378bf --- /dev/null +++ b/spikes/clone-economics/tests/import-legacy-run.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + LEGACY_SOURCE_SHA256, + normalizeLegacyReport, +} from '../scripts/import-legacy-run.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const importer = path.join(root, 'scripts/import-legacy-run.mjs'); + +function scoreCases(prefix, count, score, criticalGatePass) { + return Array.from({ length: count }, (_, index) => ({ + id: `${prefix}-${index + 1}`, + score, + criticalGatePass, + })); +} + +function syntheticLegacyReport() { + const heldoutIds = Array.from({ length: 6 }, (_, index) => `heldout-${index + 1}`); + const v2Ids = ['v2-1', 'v2-2']; + const records = []; + const add = (kind, caseId, index) => records.push({ + requestId: `legacy-${String(index).padStart(3, '0')}`, + kind, + caseId, + model: 'synthetic-legacy-model', + normalizedUsage: { inputTokens: 10 + index, outputTokens: 5 + index }, + costUsd: 0.001 * index, + latencyMs: index, + }); + let index = 1; + for (let i = 1; i <= 6; i += 1) add('target-train', `train-${i}`, index++); + add('distill', null, index++); + for (const id of heldoutIds) add('target-heldout', id, index++); + for (const id of heldoutIds) add('clone-heldout', id, index++); + for (const id of heldoutIds) add('bad-clone-heldout', id, index++); + for (const id of v2Ids) add('target-v2-heldout', id, index++); + for (const id of v2Ids) add('clone-v2-heldout', id, index++); + return { + schemaVersion: 1, + mode: 'live', + dataset: { N: 6 }, + fidelity: { + target: { absoluteScore: 0.4, criticalGatePass: false, cases: scoreCases('heldout', 6, 0.4, false) }, + clone: { absoluteScore: 0.3, criticalGatePass: false, cases: scoreCases('heldout', 6, 0.3, false) }, + badClone: { absoluteScore: 0.1, criticalGatePass: false, cases: scoreCases('heldout', 6, 0.1, false) }, + }, + evolution: { + updatedTarget: { cases: scoreCases('v2', 2, 0.5, false) }, + frozenClone: { cases: scoreCases('v2', 2, 0.25, false) }, + }, + economics: { acquisitionModeledUsd: 1.5 }, + usage: { raw: records }, + }; +} + +test('legacy normalization retains 29 allow-listed rows and joins fidelity', () => { + const samples = normalizeLegacyReport(syntheticLegacyReport()); + assert.equal(samples.length, 29); + assert.equal(samples.filter((sample) => sample.phase === 'acquisition').length, 6); + assert.equal(samples.filter((sample) => sample.phase === 'distillation').length, 1); + assert.equal(samples.find((sample) => sample.caseId === 'heldout-1' && sample.profile === 'target').score, 0.4); + assert.equal(samples.reduce((sum, sample) => sum + sample.acquisitionCostUsd, 0), 1.5); + for (const sample of samples) { + for (const forbidden of ['prompt', 'payload', 'output', 'rawResponse', 'targetSkill', 'referenceText']) { + assert.equal(Object.hasOwn(sample, forbidden), false); + } + } +}); + +test('all six immutable historical facts are asserted', () => { + const source = syntheticLegacyReport(); + const mutations = [ + { ...source, schemaVersion: 2 }, + { ...source, mode: 'mock' }, + { ...source, dataset: { ...source.dataset, N: 7 } }, + { ...source, fidelity: { ...source.fidelity, target: { ...source.fidelity.target, absoluteScore: 0.5 } } }, + { ...source, fidelity: { ...source.fidelity, target: { ...source.fidelity.target, criticalGatePass: true } } }, + { ...source, economics: { ...source.economics, acquisitionModeledUsd: 1.6 } }, + ]; + for (const changed of mutations) assert.throws(() => normalizeLegacyReport(changed), /historical source fact mismatch/); +}); + +test('wrong declared digest and changed source bytes create no output', (t) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-legacy-import-test-')); + t.after(() => fs.rmSync(temp, { recursive: true, force: true })); + const input = path.join(temp, 'source.json'); + fs.writeFileSync(input, JSON.stringify(syntheticLegacyReport())); + + const wrongOutput = path.join(temp, 'wrong-output'); + const wrong = spawnSync(process.execPath, [ + importer, + '--input', input, + '--expected-sha256', '0'.repeat(64), + '--output', wrongOutput, + ], { cwd: root, encoding: 'utf8' }); + assert.notEqual(wrong.status, 0); + assert.match(wrong.stderr, /declared digest must equal the immutable legacy digest/i); + assert.equal(fs.existsSync(wrongOutput), false); + + const changedOutput = path.join(temp, 'changed-output'); + const changed = spawnSync(process.execPath, [ + importer, + '--input', input, + '--expected-sha256', LEGACY_SOURCE_SHA256, + '--output', changedOutput, + ], { cwd: root, encoding: 'utf8' }); + assert.notEqual(changed.status, 0); + assert.match(changed.stderr, /byte count|digest mismatch/i); + assert.equal(fs.existsSync(changedOutput), false); +}); From ab49b0d8ed95bbe6eb607e0780dbefe95fcddf67 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:55:27 -0400 Subject: [PATCH 042/165] fix: close live clone sweep authorization bypasses --- .../fixtures/live-economics-v1.json | 9 + spikes/clone-economics/src/authorization.mjs | 5 +- spikes/clone-economics/src/evidence.mjs | 16 ++ spikes/clone-economics/src/live-economics.mjs | 53 ++++ spikes/clone-economics/src/sweep.mjs | 249 +++++++++++++++--- spikes/clone-economics/sweep.mjs | 18 +- .../tests/authorization.test.mjs | 33 ++- spikes/clone-economics/tests/budget.test.mjs | 63 ++++- .../clone-economics/tests/evidence.test.mjs | 56 ++++ .../tests/fixtures/live-contract.mjs | 15 ++ .../tests/live-economics.test.mjs | 40 +++ spikes/clone-economics/tests/sweep.test.mjs | 96 +++++++ 12 files changed, 599 insertions(+), 54 deletions(-) create mode 100644 spikes/clone-economics/fixtures/live-economics-v1.json create mode 100644 spikes/clone-economics/src/live-economics.mjs create mode 100644 spikes/clone-economics/tests/live-economics.test.mjs diff --git a/spikes/clone-economics/fixtures/live-economics-v1.json b/spikes/clone-economics/fixtures/live-economics-v1.json new file mode 100644 index 0000000..27c9da5 --- /dev/null +++ b/spikes/clone-economics/fixtures/live-economics-v1.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "experimentFamily": "clone-economics-high-n-v1", + "approvalStatus": "not_approved", + "invocationPriceUsd": null, + "cloneServingCostUsd": null, + "deployCostUsd": null, + "laborCostUsd": null +} diff --git a/spikes/clone-economics/src/authorization.mjs b/spikes/clone-economics/src/authorization.mjs index 1c34003..6ea9b82 100644 --- a/spikes/clone-economics/src/authorization.mjs +++ b/spikes/clone-economics/src/authorization.mjs @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { parseUsdToMicroUsd } from './budget.mjs'; +import { validateApprovedLiveEconomics } from './live-economics.mjs'; function canonicalize(value) { if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; @@ -14,11 +15,13 @@ function canonicalize(value) { throw new Error(`Unsupported authorization value type: ${typeof value}`); } -export function liveAuthorizationHash({ config, snapshot }) { +export function liveAuthorizationHash({ config, snapshot, economics }) { + validateApprovedLiveEconomics(economics, config); const canonical = JSON.stringify(canonicalize({ authorizationSchemaVersion: 1, sweepConfig: config, liveBudgetSnapshot: snapshot, + liveEconomics: economics, })); return `sha256:${createHash('sha256').update(canonical).digest('hex')}`; } diff --git a/spikes/clone-economics/src/evidence.mjs b/spikes/clone-economics/src/evidence.mjs index d0ea1aa..f69e3c1 100644 --- a/spikes/clone-economics/src/evidence.mjs +++ b/spikes/clone-economics/src/evidence.mjs @@ -24,6 +24,7 @@ const CONFIGURATION_KEYS = new Set([ 'tokenCaps', 'pricingSnapshot', 'evidenceLabels', 'acquisitionTreatment', 'historicalRunDate', 'sourceTimestamp', 'attemptCoverage', 'benchmarkVerdict', 'publicationGate', 'suppressionReason', 'fixtureSet', + 'liveEconomics', ]); const REQUIRED_BUNDLE_FILES = ['samples.jsonl', 'summary.json', 'report.md', 'README.md']; const rounded = (value) => Number(value.toFixed(12)); @@ -287,6 +288,7 @@ function verifyLiveRows(samples, manifest, dir) { 'snapshotPath', 'snapshotSha256', 'authorizationHash', 'humanCapMicroUsd', 'conservativeEstimateMicroUsd', 'worstCasePerCallMicroUsd', 'attemptedCalls', 'knownAccruedMicroUsd', 'outstandingReservedMicroUsd', 'lock', + 'economicsSnapshotPath', 'economicsSnapshotSha256', ]; if (JSON.stringify(Object.keys(manifest.liveBudget).sort()) !== JSON.stringify(allowed.sort())) { throw new Error('liveBudget contains unexpected or missing fields'); @@ -298,9 +300,23 @@ function verifyLiveRows(samples, manifest, dir) { const snapshotBytes = fs.readFileSync(snapshotPath); if (sha256(snapshotBytes) !== manifest.liveBudget.snapshotSha256) throw new Error('Live budget snapshot hash mismatch'); const snapshot = JSON.parse(snapshotBytes); + if (path.isAbsolute(manifest.liveBudget.economicsSnapshotPath) + || manifest.liveBudget.economicsSnapshotPath.includes('..')) { + throw new Error('liveBudget economicsSnapshotPath must be repository-relative'); + } + const economicsPath = path.resolve(dir, '..', '..', manifest.liveBudget.economicsSnapshotPath); + const economicsBytes = fs.readFileSync(economicsPath); + if (sha256(economicsBytes) !== manifest.liveBudget.economicsSnapshotSha256) { + throw new Error('Live economics snapshot hash mismatch'); + } + const economics = JSON.parse(economicsBytes); + if (JSON.stringify(canonicalize(economics)) !== JSON.stringify(canonicalize(manifest.configuration.liveEconomics))) { + throw new Error('Live economics configuration differs from hash-verified snapshot'); + } const expectedAuthorization = liveAuthorizationHash({ config: manifest.configuration.sweepConfig, snapshot, + economics, }); if (expectedAuthorization !== manifest.liveBudget.authorizationHash) throw new Error('Live authorization hash mismatch'); if (manifest.liveBudget.attemptedCalls !== samples.length) throw new Error('Live attempted-call count differs from samples'); diff --git a/spikes/clone-economics/src/live-economics.mjs b/spikes/clone-economics/src/live-economics.mjs new file mode 100644 index 0000000..b67095e --- /dev/null +++ b/spikes/clone-economics/src/live-economics.mjs @@ -0,0 +1,53 @@ +const KEYS = [ + 'schemaVersion', + 'experimentFamily', + 'approvalStatus', + 'invocationPriceUsd', + 'cloneServingCostUsd', + 'deployCostUsd', + 'laborCostUsd', +]; + +function exactObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Live economics must be an object'); + } + if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...KEYS].sort())) { + throw new Error('Live economics has unexpected or missing fields'); + } +} + +export function validateLiveEconomicsShape(economics, config) { + exactObject(economics); + if (economics.schemaVersion !== 1) throw new Error('Live economics schemaVersion must be 1'); + if (economics.experimentFamily !== config.experimentFamily) { + throw new Error('Live economics experiment family mismatch'); + } + if (economics.approvalStatus === 'not_approved') { + for (const field of KEYS.slice(3)) { + if (economics[field] !== null) throw new Error('Unapproved live economics must retain exact null values'); + } + return economics; + } + if (economics.approvalStatus !== 'approved') { + throw new Error('Live economics approvalStatus must be approved or not_approved'); + } + return validateApprovedLiveEconomics(economics, config); +} + +export function validateApprovedLiveEconomics(economics, config) { + exactObject(economics); + if (economics.schemaVersion !== 1) throw new Error('Live economics schemaVersion must be 1'); + if (economics.experimentFamily !== config.experimentFamily) { + throw new Error('Live economics experiment family mismatch'); + } + if (economics.approvalStatus !== 'approved') { + throw new Error('Live economics must be approved; current contract is not approved'); + } + for (const field of KEYS.slice(3)) { + if (!Number.isFinite(economics[field]) || economics[field] < 0) { + throw new Error(`${field} must be a finite non-negative number`); + } + } + return economics; +} diff --git a/spikes/clone-economics/src/sweep.mjs b/spikes/clone-economics/src/sweep.mjs index 88119df..dac2603 100644 --- a/spikes/clone-economics/src/sweep.mjs +++ b/spikes/clone-economics/src/sweep.mjs @@ -1,3 +1,7 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { calculateProviderCostMicroUsd, conservativeSweepRequestCount, @@ -7,6 +11,61 @@ import { validateApprovedBudgetSnapshot, } from './budget.mjs'; import { liveAuthorizationHash, validateLiveApproval } from './authorization.mjs'; +import { loadFixtureSet } from './fixture-set.mjs'; +import { validateApprovedLiveEconomics } from './live-economics.mjs'; + +const sweepRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const LIVE_SWEEP_AUTHORIZATION = Symbol('live-sweep-authorization'); + +function canonicalize(value) { + if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return value; + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); + } + throw new Error(`Unsupported sweep contract value: ${typeof value}`); +} + +const canonicalJson = (value) => JSON.stringify(canonicalize(value)); + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +} + +function committedSweepInputs() { + const config = JSON.parse(fs.readFileSync(path.join(sweepRoot, 'fixtures/sweep-v1.json'), 'utf8')); + const fixtures = loadFixtureSet(sweepRoot, config.fixtureSet); + const v2Fixtures = JSON.parse(fs.readFileSync(path.join(sweepRoot, 'fixtures/v2-heldout.json'), 'utf8')); + return { + config, + fixtures, + v2Fixtures, + counts: { + trainCount: fixtures.train.length, + heldoutCount: fixtures.heldout.length, + v2Count: v2Fixtures.length, + }, + }; +} + +function requireCommittedConfig(config, committed) { + if (canonicalJson(config) !== canonicalJson(committed)) { + throw new Error('Live sweep config must exactly match committed fixtures/sweep-v1.json'); + } +} + +function requireExactCommittedCounts(counts, expected) { + const keys = ['trainCount', 'heldoutCount', 'v2Count']; + if (!counts || canonicalJson(Object.keys(counts).sort()) !== canonicalJson([...keys].sort()) + || keys.some((key) => !Number.isSafeInteger(counts[key]) || counts[key] < 0) + || keys.some((key) => counts[key] !== expected[key])) { + throw new Error('Caller counts must exactly match committed fixtures'); + } +} function mulberry32(seed) { return () => { @@ -83,13 +142,47 @@ export function classifyHighNSeedValidity({ cells, adapterMode, standaloneBenchm const requested = highN.map((cell) => cell.requestedDistillationSeed); const independentlyHonored = new Set(requested).size === 3 && highN.every((cell) => - cell.distillationSeedStatus === 'honored' + cell.seedEvidenceReconciled === true + && cell.distillationSeedStatus === 'honored' && cell.appliedDistillationSeed === cell.requestedDistillationSeed); return independentlyHonored ? { valid: true, reason: null } : { valid: false, reason: 'DISTILLATION_SEEDS_UNCONTROLLED' }; } +export function reconcileCellSeedEvidence({ requestedSeed, reported, attempts }) { + const distillationAttempts = attempts.filter((attempt) => attempt.kind === 'distill'); + if (distillationAttempts.length !== 1) { + return { + requestedDistillationSeed: requestedSeed, + appliedDistillationSeed: null, + distillationSeedStatus: 'unsupported', + distillationSeedMechanism: 'distillation_attempt_evidence_missing_or_ambiguous', + reportMatchesAttempt: false, + }; + } + const attempt = distillationAttempts[0]; + if (attempt.requestedSeed !== requestedSeed) { + return { + requestedDistillationSeed: requestedSeed, + appliedDistillationSeed: null, + distillationSeedStatus: 'unsupported', + distillationSeedMechanism: 'distillation_attempt_requested_seed_mismatch', + reportMatchesAttempt: false, + }; + } + const reportMatchesAttempt = reported.appliedDistillationSeed === attempt.appliedSeed + && reported.distillationSeedStatus === attempt.status + && reported.distillationSeedMechanism === attempt.mechanism; + return { + requestedDistillationSeed: requestedSeed, + appliedDistillationSeed: attempt.appliedSeed ?? null, + distillationSeedStatus: attempt.status, + distillationSeedMechanism: attempt.mechanism, + reportMatchesAttempt, + }; +} + export const compliantHeldoutOutput = (fixture) => [ `Mode: ${fixture.mode}`, fixture.rubric.exactPaths[0].value, @@ -153,6 +246,7 @@ export async function writeSweepEvidenceBundle({ modelProvider = null, model = null, liveBudget = null, + liveEconomics = null, reproduction = null, }) { const { verifyEvidenceBundle, writeEvidenceBundle } = await import('./evidence.mjs'); @@ -185,6 +279,7 @@ export async function writeSweepEvidenceBundle({ publishableHighN: result.publishableHighN ?? false, suppressionReason: result.suppressionReason ?? result.benchmark?.verdict ?? null, }, + ...(liveEconomics === null ? {} : { liveEconomics }), }, }, samples, @@ -225,36 +320,63 @@ export async function startLiveSweep({ config, counts, snapshot, + economics, fetchFactory, adapterFactory, - runSweep: runSweepImplementation = runSweep, sweepOptions = {}, }) { - validateSweepConfig(config, counts); - validateApprovedBudgetSnapshot(snapshot, config); - const authorizationHash = liveAuthorizationHash({ config, snapshot }); - const capMicroUsd = validateLiveApproval(env, { config, snapshot }); - const requestCount = conservativeSweepRequestCount(config, counts); + const committed = committedSweepInputs(); + requireCommittedConfig(config, committed.config); + requireExactCommittedCounts(counts, committed.counts); + const authorizedConfig = deepFreeze(structuredClone(committed.config)); + const authorizedSnapshot = deepFreeze(structuredClone(snapshot)); + const authorizedEconomics = deepFreeze(structuredClone(economics)); + validateSweepConfig(authorizedConfig, committed.counts); + validateApprovedBudgetSnapshot(authorizedSnapshot, authorizedConfig); + validateApprovedLiveEconomics(authorizedEconomics, authorizedConfig); + const authorizationHash = liveAuthorizationHash({ + config: authorizedConfig, + snapshot: authorizedSnapshot, + economics: authorizedEconomics, + }); + const capMicroUsd = validateLiveApproval(env, { + config: authorizedConfig, + snapshot: authorizedSnapshot, + economics: authorizedEconomics, + }); + const requestCount = conservativeSweepRequestCount(authorizedConfig, committed.counts); const perCallMicroUsd = calculateProviderCostMicroUsd({ - inputTokens: snapshot.tokenCaps.maxInputTokens, - outputTokens: snapshot.tokenCaps.maxOutputTokens, - snapshot, + inputTokens: authorizedSnapshot.tokenCaps.maxInputTokens, + outputTokens: authorizedSnapshot.tokenCaps.maxOutputTokens, + snapshot: authorizedSnapshot, + }); + const estimateMicroUsd = estimateLiveSweepMicroUsd({ + config: authorizedConfig, + counts: committed.counts, + snapshot: authorizedSnapshot, }); - const estimateMicroUsd = estimateLiveSweepMicroUsd({ config, counts, snapshot }); if (estimateMicroUsd > capMicroUsd) { throw new Error(`Conservative live estimate $${formatMicroUsd(estimateMicroUsd)} exceeds human cap $${formatMicroUsd(capMicroUsd)}`); } if (env.ALLOW_LIVE_LLM !== '1') throw new Error('ALLOW_LIVE_LLM=1 is required for a live sweep'); + const liveAuthorizationCapability = LIVE_SWEEP_AUTHORIZATION; const budget = createAttemptBudget({ capMicroUsd, worstCaseCallMicroUsd: perCallMicroUsd }); const fetchImpl = fetchFactory(); - const adapter = adapterFactory({ fetchImpl, budget, snapshot }); - const result = await runSweepImplementation({ + const adapter = adapterFactory({ + fetchImpl, + budget, + snapshot: authorizedSnapshot, + economics: authorizedEconomics, + }); + const result = await runSweep({ ...sweepOptions, mode: 'live', - config, + config: authorizedConfig, adapter, budget, - counts, + counts: committed.counts, + economics: authorizedEconomics, + liveAuthorizationCapability, }); return { authorizationHash, @@ -268,6 +390,10 @@ export async function startLiveSweep({ } export async function runSweep(options) { + const requestedMode = options?.mode ?? 'mock'; + if (requestedMode === 'live' && options.liveAuthorizationCapability !== LIVE_SWEEP_AUTHORIZATION) { + throw new Error('Live runSweep requires the module-private startLiveSweep authorization capability'); + } const fs = await import('node:fs'); const path = await import('node:path'); const { fileURLToPath } = await import('node:url'); @@ -288,6 +414,14 @@ export async function runSweep(options) { validateSweepConfig(config, counts); const mode = options.mode ?? 'mock'; if (!['mock', 'live'].includes(mode)) throw new Error('Sweep mode must be mock or live'); + const economicInputs = mode === 'live' + ? validateApprovedLiveEconomics(options.economics, config) + : { + invocationPriceUsd: options.invocationPriceUsd ?? 0.25, + cloneServingCostUsd: options.cloneServingCostUsd ?? 0.05, + deployCostUsd: options.deployCostUsd ?? 0.05, + laborCostUsd: options.laborCostUsd ?? 0, + }; const trainById = new Map(fixtures.train.map((fixture) => [fixture.id, fixture])); const heldoutById = new Map(fixtures.heldout.map((fixture) => [fixture.id, fixture])); @@ -306,20 +440,7 @@ export async function runSweep(options) { outputFor, }); const adapterMode = mode; - const { benchmark, targetScore } = await runTargetBenchmark({ - adapter, - heldoutFixtures: fixtures.heldout, - threshold: config.targetThreshold ?? 0.8, - }); - const standaloneScores = { target: scoreMap(targetScore) }; - const samples = adapter.attempts.map((attempt) => annotateAttempt(attempt, { - n: null, - replicateId: null, - pairOrderSeed: null, - requestedDistillationSeed: null, - invocationPriceUsd: 0, - }, standaloneScores)); - const reconcile = () => { + const reconcileSamples = (samples) => { if (samples.length !== adapter.attempts.length) { throw new Error('Sweep sample count does not reconcile with adapter attempts'); } @@ -328,8 +449,51 @@ export async function runSweep(options) { throw new Error('Sweep sample count does not reconcile with budget attemptedCalls'); } }; + let benchmark; + let targetScore; + try { + ({ benchmark, targetScore } = await runTargetBenchmark({ + adapter, + heldoutFixtures: fixtures.heldout, + threshold: config.targetThreshold ?? 0.8, + })); + } catch (error) { + const samples = adapter.attempts.map((attempt) => annotateAttempt(attempt, { + n: null, + replicateId: null, + pairOrderSeed: null, + requestedDistillationSeed: null, + invocationPriceUsd: 0, + })); + reconcileSamples(samples); + return { + experimentFamily: config.experimentFamily, + benchmark: { + valid: false, + verdict: 'STANDALONE_TARGET_EXECUTION_FAILED', + cloneConclusionAllowed: false, + economicsConclusionAllowed: false, + reason: `Standalone target execution failed (${error instanceof Error ? error.name : 'UnknownError'}).`, + }, + targetScore: null, + cells: [], + samples, + highNComplete: false, + publishableHighN: false, + suppressionReason: 'STANDALONE_TARGET_EXECUTION_FAILED', + budgetState: options.budget?.state() ?? null, + }; + } + const standaloneScores = { target: scoreMap(targetScore) }; + const samples = adapter.attempts.map((attempt) => annotateAttempt(attempt, { + n: null, + replicateId: null, + pairOrderSeed: null, + requestedDistillationSeed: null, + invocationPriceUsd: 0, + }, standaloneScores)); if (!benchmark.valid) { - reconcile(); + reconcileSamples(samples); return { experimentFamily: config.experimentFamily, benchmark, @@ -337,6 +501,9 @@ export async function runSweep(options) { cells: [], samples, highNComplete: false, + publishableHighN: false, + suppressionReason: benchmark.verdict, + budgetState: options.budget?.state() ?? null, }; } @@ -363,20 +530,25 @@ export async function runSweep(options) { pairOrderSeed: replicate.pairOrderSeed, requestedDistillationSeed: replicate.distillationSeed, replicateId: replicate.replicateId, - invocationPriceUsd: options.invocationPriceUsd ?? 0.25, - cloneServingCostUsd: options.cloneServingCostUsd ?? 0.05, - deployCostUsd: options.deployCostUsd ?? 0.05, - laborCostUsd: options.laborCostUsd ?? 0, + invocationPriceUsd: economicInputs.invocationPriceUsd, + cloneServingCostUsd: economicInputs.cloneServingCostUsd, + deployCostUsd: economicInputs.deployCostUsd, + laborCostUsd: economicInputs.laborCostUsd, + }); + const seed = reconcileCellSeedEvidence({ + requestedSeed: replicate.distillationSeed, + reported: result.report.seedContract, + attempts: adapter.attempts.slice(attemptStart), }); - const seed = result.report.seedContract; cells.push({ n, replicateId: replicate.replicateId, pairOrderSeed: replicate.pairOrderSeed, - requestedDistillationSeed: replicate.distillationSeed, + requestedDistillationSeed: seed.requestedDistillationSeed, appliedDistillationSeed: seed.appliedDistillationSeed, distillationSeedStatus: seed.distillationSeedStatus, distillationSeedMechanism: seed.distillationSeedMechanism, + seedEvidenceReconciled: seed.reportMatchesAttempt, status: 'complete', benchmark: result.report.benchmark, targetAbsoluteScore: result.report.fidelity.target.absoluteScore, @@ -410,7 +582,7 @@ export async function runSweep(options) { replicateId: replicate.replicateId, pairOrderSeed: replicate.pairOrderSeed, requestedDistillationSeed: replicate.distillationSeed, - invocationPriceUsd: options.invocationPriceUsd ?? 0.25, + invocationPriceUsd: economicInputs.invocationPriceUsd, }, scores)); } } @@ -418,7 +590,7 @@ export async function runSweep(options) { } if (stop) break; } - reconcile(); + reconcileSamples(samples); const highNComplete = cells.filter((cell) => cell.n === 100 && cell.status === 'complete').length === 3; const highNGate = classifyHighNSeedValidity({ cells, adapterMode, standaloneBenchmark: benchmark }); return { @@ -430,5 +602,6 @@ export async function runSweep(options) { highNComplete, publishableHighN: highNGate.valid, suppressionReason: highNGate.reason, + budgetState: options.budget?.state() ?? null, }; } diff --git a/spikes/clone-economics/sweep.mjs b/spikes/clone-economics/sweep.mjs index 5f1a2cd..1167062 100644 --- a/spikes/clone-economics/sweep.mjs +++ b/spikes/clone-economics/sweep.mjs @@ -12,6 +12,7 @@ import { } from './src/budget.mjs'; import { liveAuthorizationHash } from './src/authorization.mjs'; import { loadFixtureSet } from './src/fixture-set.mjs'; +import { validateLiveEconomicsShape } from './src/live-economics.mjs'; import { runSweep, startLiveSweep, @@ -23,6 +24,7 @@ const root = path.dirname(fileURLToPath(import.meta.url)); const readJson = (name) => JSON.parse(fs.readFileSync(path.join(root, 'fixtures', name), 'utf8')); const config = readJson('sweep-v1.json'); const snapshot = readJson('live-budget-v1.json'); +const economics = readJson('live-economics-v1.json'); const fixtures = loadFixtureSet(root, config.fixtureSet); const v2Count = readJson('v2-heldout.json').length; const counts = { @@ -48,12 +50,14 @@ async function main() { if (mode === '--preflight') { validateBudgetSnapshotShape(snapshot, config); + validateLiveEconomicsShape(economics, config); printDimensions(); - if (snapshot.approvalStatus === 'not_approved') { - console.log('live budget: not approved'); + console.log(`live budget: ${snapshot.approvalStatus === 'approved' ? 'approved' : 'not approved'}`); + console.log(`live economics: ${economics.approvalStatus === 'approved' ? 'approved' : 'not approved'}`); + if (snapshot.approvalStatus !== 'approved' || economics.approvalStatus !== 'approved') { return; } - const authorizationHash = liveAuthorizationHash({ config, snapshot }); + const authorizationHash = liveAuthorizationHash({ config, snapshot, economics }); const estimate = estimateLiveSweepMicroUsd({ config, counts, snapshot }); console.log(`live authorization: ${authorizationHash}`); console.log(`conservative live estimate USD: ${formatMicroUsd(estimate)}`); @@ -96,6 +100,7 @@ async function main() { config, counts, snapshot, + economics, fetchFactory: () => globalThis.fetch, adapterFactory: ({ fetchImpl, budget, snapshot: committedSnapshot }) => new LiveAnthropicAdapter({ mode: 'live', @@ -110,6 +115,7 @@ async function main() { const experimentId = `live-high-n-${recordedAtUtc.replaceAll(/[-:.]/g, '')}`; const evidenceRelative = path.join('evidence', experimentId); const snapshotBytes = fs.readFileSync(path.join(root, 'fixtures/live-budget-v1.json')); + const economicsBytes = fs.readFileSync(path.join(root, 'fixtures/live-economics-v1.json')); await writeSweepEvidenceBundle({ result: live.result, config, @@ -122,9 +128,12 @@ async function main() { recordedAtUtc, modelProvider: 'Anthropic', model: snapshot.model, + liveEconomics: economics, liveBudget: { snapshotPath: 'fixtures/live-budget-v1.json', snapshotSha256: createHash('sha256').update(snapshotBytes).digest('hex'), + economicsSnapshotPath: 'fixtures/live-economics-v1.json', + economicsSnapshotSha256: createHash('sha256').update(economicsBytes).digest('hex'), authorizationHash: live.authorizationHash, humanCapMicroUsd: live.capMicroUsd.toString(), conservativeEstimateMicroUsd: live.estimateMicroUsd.toString(), @@ -141,6 +150,9 @@ async function main() { console.log(`publishable high-N: ${live.result.publishableHighN}`); console.log(`suppression: ${live.result.suppressionReason}`); console.log(`verified evidence: ${evidenceRelative}`); + if (live.result.suppressionReason === 'STANDALONE_TARGET_EXECUTION_FAILED') { + throw new Error(`Standalone target execution failed; sanitized evidence retained at ${evidenceRelative}`); + } } main().catch((error) => { diff --git a/spikes/clone-economics/tests/authorization.test.mjs b/spikes/clone-economics/tests/authorization.test.mjs index 8f787a7..b1d1057 100644 --- a/spikes/clone-economics/tests/authorization.test.mjs +++ b/spikes/clone-economics/tests/authorization.test.mjs @@ -5,35 +5,36 @@ import { liveAuthorizationHash, validateLiveApproval, } from '../src/authorization.mjs'; -import { approved, config } from './fixtures/live-contract.mjs'; +import { approved, config, economics } from './fixtures/live-contract.mjs'; test('live approval binds the exact canonical sweep and budget snapshot', () => { - const authorizationHash = liveAuthorizationHash({ config, snapshot: approved }); + const authorizationHash = liveAuthorizationHash({ config, snapshot: approved, economics }); assert.match(authorizationHash, /^sha256:[0-9a-f]{64}$/); assert.equal(validateLiveApproval({ APPROVE_LIVE_SWEEP_SHA256: authorizationHash, MAX_SWEEP_COST_USD: '50.000001', - }, { config, snapshot: approved }), 50_000_001n); + }, { config, snapshot: approved, economics }), 50_000_001n); }); test('a stale approval fails after any material snapshot or config change', () => { - const stale = liveAuthorizationHash({ config, snapshot: approved }); + const stale = liveAuthorizationHash({ config, snapshot: approved, economics }); const mutations = [ - { config: { ...config, nValues: [6, 25, 50] }, snapshot: approved }, + { config: { ...config, nValues: [6, 25, 50] }, snapshot: approved, economics }, { config: { ...config, replicates: config.replicates.map((x, index) => index === 0 ? { ...x, distillationSeed: 9999 } : x), - }, snapshot: approved }, - { config, snapshot: { ...approved, model: 'changed-model' } }, + }, snapshot: approved, economics }, + { config, snapshot: { ...approved, model: 'changed-model' }, economics }, { config, snapshot: { ...approved, pricing: { ...approved.pricing, inputUsdPerMillionTokens: '3.01' }, - } }, + }, economics }, { config, snapshot: { ...approved, tokenCaps: { ...approved.tokenCaps, maxOutputTokens: 2048 }, - } }, + }, economics }, + { config, snapshot: approved, economics: { ...economics, invocationPriceUsd: 0.30 } }, ]; for (const changed of mutations) { assert.throws(() => validateLiveApproval({ @@ -47,5 +48,17 @@ test('the old experiment-family token is never accepted as authorization', () => assert.throws(() => validateLiveApproval({ APPROVE_LIVE_SWEEP_SHA256: config.experimentFamily, MAX_SWEEP_COST_USD: '50', - }, { config, snapshot: approved }), /sha256/); + }, { config, snapshot: approved, economics }), /sha256/); +}); + +test('authorization rejects a missing or malformed reviewed live-economics contract', () => { + assert.throws(() => liveAuthorizationHash({ config, snapshot: approved }), /live economics/i); + assert.throws( + () => liveAuthorizationHash({ + config, + snapshot: approved, + economics: { ...economics, deployCostUsd: Number.NaN }, + }), + /deployCostUsd.*finite non-negative/i, + ); }); diff --git a/spikes/clone-economics/tests/budget.test.mjs b/spikes/clone-economics/tests/budget.test.mjs index 05fc3f8..db2c24a 100644 --- a/spikes/clone-economics/tests/budget.test.mjs +++ b/spikes/clone-economics/tests/budget.test.mjs @@ -10,7 +10,7 @@ import { } from '../src/budget.mjs'; import { liveAuthorizationHash } from '../src/authorization.mjs'; import { startLiveSweep } from '../src/sweep.mjs'; -import { approved, config } from './fixtures/live-contract.mjs'; +import { approved, config, economics } from './fixtures/live-contract.mjs'; const counts = { trainCount: 100, heldoutCount: 30, v2Count: 2 }; @@ -44,12 +44,13 @@ test('an under-cap live request constructs neither adapter nor fetch', async () let fetchConstructions = 0; await assert.rejects(startLiveSweep({ env: { - APPROVE_LIVE_SWEEP_SHA256: liveAuthorizationHash({ config, snapshot: approved }), + APPROVE_LIVE_SWEEP_SHA256: liveAuthorizationHash({ config, snapshot: approved, economics }), MAX_SWEEP_COST_USD: '47.00', }, config, counts, snapshot: approved, + economics, fetchFactory() { fetchConstructions += 1; throw new Error('fetch must not be constructed'); @@ -63,6 +64,64 @@ test('an under-cap live request constructs neither adapter nor fetch', async () assert.equal(fetchConstructions, 0); }); +test('negative or understated caller counts fail before adapter or fetch construction', async () => { + let constructions = 0; + for (const invalidCounts of [ + { trainCount: -1, heldoutCount: 30, v2Count: 2 }, + { trainCount: 99, heldoutCount: 30, v2Count: 2 }, + { trainCount: 100, heldoutCount: 29, v2Count: 2 }, + { trainCount: 100, heldoutCount: 30, v2Count: 1 }, + ]) { + await assert.rejects(startLiveSweep({ + env: { + APPROVE_LIVE_SWEEP_SHA256: liveAuthorizationHash({ config, snapshot: approved, economics }), + MAX_SWEEP_COST_USD: '100', + }, + config, + counts: invalidCounts, + snapshot: approved, + economics, + fetchFactory() { constructions += 1; }, + adapterFactory() { constructions += 1; }, + }), /counts must exactly match committed fixtures/i); + } + assert.equal(constructions, 0); +}); + +test('missing live economics fails before construction', async () => { + let constructions = 0; + await assert.rejects(startLiveSweep({ + env: { + APPROVE_LIVE_SWEEP_SHA256: 'sha256:'.concat('0'.repeat(64)), + MAX_SWEEP_COST_USD: '100', + }, + config, + counts, + snapshot: approved, + economics: null, + fetchFactory() { constructions += 1; }, + adapterFactory() { constructions += 1; }, + }), /live economics/i); + assert.equal(constructions, 0); +}); + +test('stale economics authorization fails before construction', async () => { + let constructions = 0; + await assert.rejects(startLiveSweep({ + env: { + APPROVE_LIVE_SWEEP_SHA256: liveAuthorizationHash({ config, snapshot: approved, economics }), + MAX_SWEEP_COST_USD: '100', + }, + config, + counts, + snapshot: approved, + economics: { ...economics, laborCostUsd: 1 }, + fetchFactory() { constructions += 1; }, + adapterFactory() { constructions += 1; }, + }), /stale or does not match/i); + assert.equal(constructions, 0); +}); + test('every attempted call is reserved and the next over-cap call is refused', () => { const budget = createAttemptBudget({ capMicroUsd: 300n, worstCaseCallMicroUsd: 100n }); const first = budget.reserveNextAttempt({ kind: 'target-heldout', caseId: 'one' }); diff --git a/spikes/clone-economics/tests/evidence.test.mjs b/spikes/clone-economics/tests/evidence.test.mjs index d98af2c..446e308 100644 --- a/spikes/clone-economics/tests/evidence.test.mjs +++ b/spikes/clone-economics/tests/evidence.test.mjs @@ -7,6 +7,8 @@ import { createHash } from 'node:crypto'; import { recomputeSummary, verifyEvidenceBundle, writeEvidenceBundle } from '../src/evidence.mjs'; import { normalizeSweepSamples, writeSweepEvidenceBundle } from '../src/sweep.mjs'; +import { liveAuthorizationHash } from '../src/authorization.mjs'; +import { approved, config, economics } from './fixtures/live-contract.mjs'; const samples = [ { sampleId: 'run:target-heldout:a', phase: 'evaluation', profile: 'target', caseId: 'a', success: true, latencyMs: 10, inputTokens: 3, outputTokens: 2, providerCostUsd: 0.01, score: 0.9, criticalGatePass: true }, @@ -178,3 +180,57 @@ test('completed sweep output writes and verifies through the public bundle seam' assert.equal(written.verified.valid, true); assert.equal(written.verified.samples.length, 1); }); + +test('live bundle authorization recomputes from hash-verified budget and economics snapshots', (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-live-evidence-')); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const fixturesDir = path.join(tempRoot, 'fixtures'); + const outputDir = path.join(tempRoot, 'evidence', 'live-fixture'); + fs.mkdirSync(fixturesDir, { recursive: true }); + const snapshotBytes = Buffer.from(`${JSON.stringify(approved, null, 2)}\n`); + const economicsBytes = Buffer.from(`${JSON.stringify(economics, null, 2)}\n`); + fs.writeFileSync(path.join(fixturesDir, 'live-budget-v1.json'), snapshotBytes); + fs.writeFileSync(path.join(fixturesDir, 'live-economics-v1.json'), economicsBytes); + const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); + const authorizationHash = liveAuthorizationHash({ config, snapshot: approved, economics }); + writeEvidenceBundle({ + outputDir, + manifest: { + experimentId: 'live-fixture', + evidenceLabel: 'LIVE CANDIDATE', + command: 'synthetic live verifier fixture', + liveBudget: { + snapshotPath: 'fixtures/live-budget-v1.json', + snapshotSha256: digest(snapshotBytes), + economicsSnapshotPath: 'fixtures/live-economics-v1.json', + economicsSnapshotSha256: digest(economicsBytes), + authorizationHash, + humanCapMicroUsd: '1000000', + conservativeEstimateMicroUsd: '1000000', + worstCasePerCallMicroUsd: '1000000', + attemptedCalls: 1, + knownAccruedMicroUsd: '39', + outstandingReservedMicroUsd: '0', + lock: null, + }, + configuration: { sweepConfig: config, liveEconomics: economics }, + }, + samples: [{ + sampleId: 'live:target-heldout:a', + phase: 'evaluation', + profile: 'target', + caseId: 'a', + success: true, + latencyMs: 1, + inputTokens: 3, + outputTokens: 2, + providerCostMicroUsd: '39', + providerCostUsd: 0.000039, + score: 1, + criticalGatePass: true, + }], + interpretation: 'Synthetic live verifier fixture.', + reproduction: 'verify live fixture', + }); + assert.equal(verifyEvidenceBundle(outputDir).valid, true); +}); diff --git a/spikes/clone-economics/tests/fixtures/live-contract.mjs b/spikes/clone-economics/tests/fixtures/live-contract.mjs index 5207f60..c6bcb2b 100644 --- a/spikes/clone-economics/tests/fixtures/live-contract.mjs +++ b/spikes/clone-economics/tests/fixtures/live-contract.mjs @@ -10,6 +10,11 @@ export const config = { { replicateId: 'r3', pairOrderSeed: 1703, distillationSeed: 2703 }, ], highNDefinition: 100, + targetThreshold: 0.8, + requireAllTargetCriticalGates: true, + acquisitionTreatment: 'modeled_unless_x402_receipts_attached', + attemptCostTreatment: 'include_every_attempted_provider_call', + publicationRequiresValidTarget: true, publicationRequiresIndependentDistillationSeeds: true, }; @@ -29,3 +34,13 @@ export const approved = { }, tokenCaps: { maxInputTokens: 4096, maxOutputTokens: 1024 }, }; + +export const economics = { + schemaVersion: 1, + experimentFamily: config.experimentFamily, + approvalStatus: 'approved', + invocationPriceUsd: 0.25, + cloneServingCostUsd: 0.05, + deployCostUsd: 0.05, + laborCostUsd: 0, +}; diff --git a/spikes/clone-economics/tests/live-economics.test.mjs b/spikes/clone-economics/tests/live-economics.test.mjs new file mode 100644 index 0000000..be68ae6 --- /dev/null +++ b/spikes/clone-economics/tests/live-economics.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + validateApprovedLiveEconomics, + validateLiveEconomicsShape, +} from '../src/live-economics.mjs'; +import { config, economics } from './fixtures/live-contract.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('committed live economics is the exact fail-closed unapproved/null contract', () => { + const committed = JSON.parse(fs.readFileSync(path.join(root, 'fixtures/live-economics-v1.json'), 'utf8')); + assert.doesNotThrow(() => validateLiveEconomicsShape(committed, config)); + assert.equal(committed.approvalStatus, 'not_approved'); + assert.deepEqual([ + committed.invocationPriceUsd, + committed.cloneServingCostUsd, + committed.deployCostUsd, + committed.laborCostUsd, + ], [null, null, null, null]); + assert.throws(() => validateApprovedLiveEconomics(committed, config), /must be approved/i); +}); + +test('approved live economics requires exact fields and finite non-negative values', () => { + assert.doesNotThrow(() => validateApprovedLiveEconomics(economics, config)); + assert.throws( + () => validateApprovedLiveEconomics({ ...economics, extra: 1 }, config), + /unexpected or missing fields/, + ); + for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, '0.25']) { + assert.throws( + () => validateApprovedLiveEconomics({ ...economics, invocationPriceUsd: value }, config), + /finite non-negative/, + ); + } +}); diff --git a/spikes/clone-economics/tests/sweep.test.mjs b/spikes/clone-economics/tests/sweep.test.mjs index 6f05bc6..c6671e4 100644 --- a/spikes/clone-economics/tests/sweep.test.mjs +++ b/spikes/clone-economics/tests/sweep.test.mjs @@ -7,9 +7,11 @@ import test from 'node:test'; import { classifyHighNSeedValidity, compliantHeldoutOutput, + reconcileCellSeedEvidence, runSweep, seededOrder, validateSweepConfig, + writeSweepEvidenceBundle, } from '../src/sweep.mjs'; import { scoreEvaluation } from '../src/scoring.mjs'; @@ -55,6 +57,7 @@ test('publishable high-N requires three adapter-confirmed distillation seeds', ( requestedDistillationSeed: replicate.distillationSeed, appliedDistillationSeed: replicate.distillationSeed, distillationSeedStatus: 'honored', + seedEvidenceReconciled: true, status: 'complete', benchmark: validBenchmark, })); @@ -137,3 +140,96 @@ test('offline sweep completes all 12 cells without a publishable live conclusion assert.equal(result.cells.every((cell) => cell.distillationSeedStatus === 'synthetic_honored'), true); assert.equal(networkAttempts, 0); }); + +test('direct exported live sweep rejects without the module-private authorization capability', async () => { + let invocations = 0; + const adapter = { + records: [], + attempts: [], + async invoke() { invocations += 1; throw new Error('must not invoke'); }, + }; + await assert.rejects(runSweep({ mode: 'live', adapter }), /startLiveSweep authorization capability/i); + assert.equal(invocations, 0); +}); + +test('cell seed status is derived from recorded distillation attempt evidence', () => { + const result = reconcileCellSeedEvidence({ + requestedSeed: 2701, + reported: { + appliedDistillationSeed: 2701, + distillationSeedStatus: 'honored', + distillationSeedMechanism: 'forged-report-claim', + }, + attempts: [{ + kind: 'distill', + requestedSeed: 2701, + appliedSeed: null, + status: 'unsupported', + mechanism: 'provider_seed_not_supported_by_adapter', + }], + }); + assert.deepEqual(result, { + requestedDistillationSeed: 2701, + appliedDistillationSeed: null, + distillationSeedStatus: 'unsupported', + distillationSeedMechanism: 'provider_seed_not_supported_by_adapter', + reportMatchesAttempt: false, + }); +}); + +test('standalone target execution failure preserves its attempt and final budget lock', async (t) => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-failed-target-')); + t.after(() => fs.rmSync(outputDir, { recursive: true, force: true })); + const budget = (await import('../src/budget.mjs')).createAttemptBudget({ + capMicroUsd: 100n, + worstCaseCallMicroUsd: 100n, + }); + const adapter = { + records: [], + attempts: [], + async invoke(request) { + const reservation = budget.reserveNextAttempt({ kind: request.kind, caseId: request.caseId }); + this.attempts.push({ + attemptId: `${request.kind}:${request.caseId}:1`, + kind: request.kind, + caseId: request.caseId, + success: false, + providerRequestId: null, + latencyMs: 1, + inputTokens: null, + outputTokens: null, + providerCostMicroUsd: null, + providerCostUsd: null, + failureClass: 'ProviderError', + requestedSeed: null, + appliedSeed: null, + status: 'not_requested', + mechanism: 'no_seed_requested', + }); + budget.settleAttempt(reservation, { knownCostMicroUsd: null, success: false }); + }, + }; + const result = await runSweep({ mode: 'mock', adapter, budget, outputDir }); + assert.equal(result.publishableHighN, false); + assert.equal(result.suppressionReason, 'STANDALONE_TARGET_EXECUTION_FAILED'); + assert.equal(result.samples.length, 1); + assert.deepEqual(result.budgetState, { + attemptedCalls: 1, + knownAccruedMicroUsd: 0n, + outstandingReservedMicroUsd: 100n, + lock: { kind: 'unknown_cost', attemptId: 'attempt-000001' }, + }); + const evidence = await writeSweepEvidenceBundle({ + result, + config: { + ...config, + acquisitionTreatment: 'modeled_unless_x402_receipts_attached', + }, + outputDir: path.join(outputDir, 'evidence'), + experimentId: 'failed-target-fixture', + evidenceLabel: 'SYNTHETIC FAILED TARGET', + command: 'synthetic failed-target test', + }); + assert.equal(evidence.verified.valid, true); + assert.equal(evidence.verified.samples.length, 1); +}); From 5fbe7767d770252791cf33d6777c49db96803350 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 22:58:21 -0400 Subject: [PATCH 043/165] docs: preserve invalid clone run as reproducible evidence --- spikes/clone-economics/.gitignore | 5 ++ spikes/clone-economics/README.md | 56 ++++++++---------- .../evidence/2026-07-12-n6-invalid/README.md | 13 +++++ .../2026-07-12-n6-invalid/manifest.json | 58 +++++++++++++++++++ .../evidence/2026-07-12-n6-invalid/report.md | 10 ++++ .../2026-07-12-n6-invalid/samples.jsonl | 29 ++++++++++ .../2026-07-12-n6-invalid/summary.json | 33 +++++++++++ 7 files changed, 171 insertions(+), 33 deletions(-) create mode 100644 spikes/clone-economics/evidence/2026-07-12-n6-invalid/README.md create mode 100644 spikes/clone-economics/evidence/2026-07-12-n6-invalid/manifest.json create mode 100644 spikes/clone-economics/evidence/2026-07-12-n6-invalid/report.md create mode 100644 spikes/clone-economics/evidence/2026-07-12-n6-invalid/samples.jsonl create mode 100644 spikes/clone-economics/evidence/2026-07-12-n6-invalid/summary.json diff --git a/spikes/clone-economics/.gitignore b/spikes/clone-economics/.gitignore index aede806..db3e7c5 100644 --- a/spikes/clone-economics/.gitignore +++ b/spikes/clone-economics/.gitignore @@ -1,2 +1,7 @@ .env + +# Raw provider artifacts can contain private output and always remain local. runs/ + +# Sanitized evidence bundles under evidence/ are intentionally tracked. +!evidence/**/*.jsonl diff --git a/spikes/clone-economics/README.md b/spikes/clone-economics/README.md index a6b65c1..db11449 100644 --- a/spikes/clone-economics/README.md +++ b/spikes/clone-economics/README.md @@ -86,36 +86,26 @@ auditable—not observations about a live model or market: - This spike does **not** validate the corpus-wide `~30x` statement. That refers to matched-quality serving cost and remains unmeasured here. -## Measured results — first live run (claude-sonnet-4-6, 2026-07-12) - -Full report: `runs/live/report.{md,json}` (gitignored run artifacts; headline -numbers reproduced here). Operator inputs: N=6, invocation price $0.25, -pricing $3/$15 per M (operator-supplied), $5 hard cap. - -**Fidelity: the clone FAILED.** All 6 held-out cases failed critical gates -(clone 0.250 absolute vs target 0.400; retention 0.625; deliberately-bad -control 0.167). A 6-pair distillation did not reproduce the skill's gated -behaviors. - -**Economics: cost is no defense.** Attacker build B=$1.58, of which -distillation itself was $0.034 (D/A=0.023); modeled acquisition dominates. -Break-even after **8 invocations** if a clone ever passes. The protection -observed here is fidelity difficulty, not economics. - -**Staleness overlay (synthetic):** updated target 0.500 vs frozen clone -0.250 — one revision doubled the gap; says nothing about calendar cadence. - -**Limitations (in addition to those above):** -- **Small-N/small-H:** N≤6 training pairs and 6 held-out cases cannot locate - where fidelity saturates with N. Kill-criterion 4 concerns high-volume - skills (hundreds of paid pairs); a live result at this scale must NOT be - read as answering it. Larger fixture sets are required first. -- It took five runs to get one measurement; four failed on output-format - handling, not capability: (1–2) the distillation prompt never specified - the SKILL.md format (model returned plain markdown) — fixed by stating the - public format in the prompt; (3) an over-eager fence extractor added during - fixing replaced a valid document with an embedded code block — fixed to - unwrap only whole-response fences; (4) the validator demanded an H1 heading - the skill format does not require — loosened to any heading level. Raw - distillation output is now dumped to `runs//distilled-raw.txt` BEFORE - validation so failed runs keep their evidence. +## Historical live run — invalid benchmark (2026-07-12) + +The sanitized normalized evidence is committed at +`evidence/2026-07-12-n6-invalid/`. Provider execution and returned usage were +measured; the $1.50 acquisition component was modeled and no x402 acquisition +payment settled. + +The target scored 0.400 and failed its own critical gates. Therefore the run's +verdict is `INVALID_BENCHMARK_TARGET_FAILED`: clone quality, fidelity defense, +moat, retention, and break-even conclusions are suppressed. Four earlier setup +attempts were described historically but did not retain normalized attempt +records, so total attack cost is also incomplete. + +Verify the retained bundle offline: + +```bash +node scripts/verify-bundle.mjs evidence/2026-07-12-n6-invalid +``` + +No high-N conclusion exists. Only a valid target plus the preregistered +N=6/25/50/100 sweep, 30 held-out fixtures, and three live-adapter-confirmed, +independent distillation seeds at N=100 can produce a publishable high-N +result. Pair-order seeds alone do not establish independent model sampling. diff --git a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/README.md b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/README.md new file mode 100644 index 0000000..f21b5d7 --- /dev/null +++ b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/README.md @@ -0,0 +1,13 @@ +# Evidence bundle + +Verify and reproduce: + +```bash +node scripts/verify-bundle.mjs evidence/2026-07-12-n6-invalid +``` + +Samples are normalized and allow-listed. Prompt payloads, output text, API keys, +headers, target Skill bytes, and reference bytes are excluded. + +Unknown usage or cost remains null and makes aggregate provider cost unknown. +This bundle does not by itself authorize publication or a live benchmark claim. diff --git a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/manifest.json b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/manifest.json new file mode 100644 index 0000000..0f98f5e --- /dev/null +++ b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/manifest.json @@ -0,0 +1,58 @@ +{ + "command": "historical live command not retained exactly", + "configuration": { + "acquisitionTreatment": "modeled", + "appliedDistillationSeeds": [ + "not-recorded" + ], + "attemptCoverage": "successful fifth run only; four setup attempts have no normalized records", + "benchmarkVerdict": "INVALID_BENCHMARK_TARGET_FAILED", + "historicalRunDate": "2026-07-12", + "nValues": [ + 6 + ], + "pairOrderSeeds": [ + "not-recorded" + ], + "requestedDistillationSeeds": [ + "not-recorded" + ], + "sourceTimestamp": "not-recorded" + }, + "evidenceLabel": "HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED", + "experimentId": "2026-07-12-n6-invalid", + "files": { + "README.md": { + "bytes": 420, + "sha256": "2537c49266760dc5b1c20fa83f5dd38d1afae7a46a5a07c30bcaf0223685d52a" + }, + "report.md": { + "bytes": 532, + "sha256": "0eb9bbe03d0599a4188c3ef421c36881caf730db11b84cbf17aa79587c81c147" + }, + "samples.jsonl": { + "bytes": 18424, + "sha256": "bba125d70aa089f45389ccfe882b1dc467169d81328dd9a9ccf309900be301d4" + }, + "summary.json": { + "bytes": 629, + "sha256": "7124543f9bf16569308c69e93d6bae73973853ba025b49655e3b444b1ce15f1f" + } + }, + "gitCommit": "historical-source-not-recorded", + "liveBudget": null, + "model": "claude-sonnet-4-6", + "modelProvider": "Anthropic", + "recordedAtUtc": null, + "runtime": { + "arch": "arm64", + "node": "v22.22.0", + "platform": "darwin" + }, + "schemaVersion": 1, + "sourceEvidence": { + "bytes": 76631, + "kind": "legacy-report-json", + "sha256": "0554779988164651bfe6b037c8b16054e009ee6bac76e61c90af331ac6e85212" + } +} diff --git a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/report.md b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/report.md new file mode 100644 index 0000000..4fd3d8b --- /dev/null +++ b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/report.md @@ -0,0 +1,10 @@ +# Clone-economics evidence report + +INVALID_BENCHMARK_TARGET_FAILED. The target scored 0.400 and failed its critical gates, so clone quality, fidelity defense, moat, retention, break-even, and economics conclusions are suppressed. Provider execution was measured where retained; acquisition was modeled. Four earlier setup attempts have no normalized records. + +- Attempted samples: 29 +- Successful samples: 29 +- Failed samples: 0 +- Provider cost USD: 0.555654 +- Latency p50 ms: 13784.64254099998 +- Latency p95 ms: 26304.560541999992 diff --git a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/samples.jsonl b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/samples.jsonl new file mode 100644 index 0000000..7004c21 --- /dev/null +++ b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/samples.jsonl @@ -0,0 +1,29 @@ +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-optimize-checkout","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":16275.222,"n":6,"outputTokens":631,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26829","providerCostUsd":0.026829,"providerRequestId":"msg_011CcxfosYSFSh81ZXQR4EiL","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfosYSFSh81ZXQR4EiL","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-generate-auth","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5783,"latencyMs":13784.713082999999,"n":6,"outputTokens":601,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26364","providerCostUsd":0.026364,"providerRequestId":"msg_011Ccxfq4LMddJTfS6EuHuKP","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxfq4LMddJTfS6EuHuKP","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-diagnose-scope","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":15894.225249999996,"n":6,"outputTokens":521,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"25179","providerCostUsd":0.025179,"providerRequestId":"msg_011Ccxfr5CxsPnCcwDAGukkh","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxfr5CxsPnCcwDAGukkh","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-spec-billing","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5785,"latencyMs":15611.806458,"n":6,"outputTokens":615,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26580","providerCostUsd":0.02658,"providerRequestId":"msg_011CcxfsHatC4uJ4mtsAmw1z","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfsHatC4uJ4mtsAmw1z","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-unresolved-command","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5786,"latencyMs":29010.672083000005,"n":6,"outputTokens":1368,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"37878","providerCostUsd":0.037877999999999995,"providerRequestId":"msg_011CcxftQEGSGXBgFp7Q17JQ","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxftQEGSGXBgFp7Q17JQ","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-preserve-no-deps","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5784,"latencyMs":14978.455082999993,"n":6,"outputTokens":611,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26517","providerCostUsd":0.026517,"providerRequestId":"msg_011CcxfvXvB4AeTBW4Svu6Ut","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfvXvB4AeTBW4Svu6Ut","score":null,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":null,"criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5100,"latencyMs":26304.560541999992,"n":6,"outputTokens":1249,"pairOrderSeed":null,"phase":"distillation","profile":"clone","providerCostMicroUsd":"34035","providerCostUsd":0.034035,"providerRequestId":"msg_011CcxfweAFY2MQ8T2TirNZ3","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfweAFY2MQ8T2TirNZ3","score":null,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5786,"latencyMs":14347.318874999997,"n":6,"outputTokens":572,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"25938","providerCostUsd":0.025938,"providerRequestId":"msg_011CcxfyapdyEFtJNzonCKyQ","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfyapdyEFtJNzonCKyQ","score":0.4,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-generate-export","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5782,"latencyMs":13212.418750000012,"n":6,"outputTokens":523,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"25191","providerCostUsd":0.025190999999999998,"providerRequestId":"msg_011CcxfzdqyeakDhA5Mdzdp2","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfzdqyeakDhA5Mdzdp2","score":0.5,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5790,"latencyMs":16173.565042000002,"n":6,"outputTokens":610,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"26520","providerCostUsd":0.026520000000000002,"providerRequestId":"msg_011Ccxg1cKUjcCpKwZd8rU6L","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg1cKUjcCpKwZd8rU6L","score":0.4,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-spec-audit","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5791,"latencyMs":16394.731042,"n":6,"outputTokens":716,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"28113","providerCostUsd":0.028113,"providerRequestId":"msg_011Ccxg2oR14qX65cni3xmcg","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg2oR14qX65cni3xmcg","score":0.4,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-unresolved-pattern","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":16397.416291,"n":6,"outputTokens":723,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"28209","providerCostUsd":0.028209,"providerRequestId":"msg_011Ccxg41ZXkHRd3QvnHks2U","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg41ZXkHRd3QvnHks2U","score":0.4,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-preserve-json","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":10850.73666700002,"n":6,"outputTokens":436,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"23904","providerCostUsd":0.023904,"providerRequestId":"msg_011Ccxg5DjnjDMFo5h8Mb9QQ","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg5DjnjDMFo5h8Mb9QQ","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1748,"latencyMs":10833.348874999996,"n":6,"outputTokens":546,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"13434","providerCostUsd":0.013434,"providerRequestId":"msg_011Ccxg62Cn96ZS7R4jqaskM","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg62Cn96ZS7R4jqaskM","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-generate-export","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1744,"latencyMs":9036.210250000004,"n":6,"outputTokens":459,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"12117","providerCostUsd":0.012117,"providerRequestId":"msg_011Ccxg6pLBFzm5M8QakTq1y","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg6pLBFzm5M8QakTq1y","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1752,"latencyMs":12297.006874999992,"n":6,"outputTokens":627,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"14661","providerCostUsd":0.014661,"providerRequestId":"msg_011Ccxg7VTu39MQ1Ko2pRDcx","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg7VTu39MQ1Ko2pRDcx","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-spec-audit","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1753,"latencyMs":13784.64254099998,"n":6,"outputTokens":585,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"14034","providerCostUsd":0.014034,"providerRequestId":"msg_011Ccxg8Pnch42zys9ZHvUKt","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg8Pnch42zys9ZHvUKt","score":0.1,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-unresolved-pattern","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1750,"latencyMs":25843.72425000003,"n":6,"outputTokens":1558,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"28620","providerCostUsd":0.02862,"providerRequestId":"msg_011Ccxg9QsNxEzbsPETdFhPT","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg9QsNxEzbsPETdFhPT","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-preserve-json","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1750,"latencyMs":11227.776290999958,"n":6,"outputTokens":536,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"13290","providerCostUsd":0.01329,"providerRequestId":"msg_011CcxgBLaDfbxSX51NqrqBB","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgBLaDfbxSX51NqrqBB","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":428,"latencyMs":3446.199874999991,"n":6,"outputTokens":118,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"3054","providerCostUsd":0.0030540000000000003,"providerRequestId":"msg_011CcxgC8k6x51iHLYFcBcbF","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgC8k6x51iHLYFcBcbF","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-generate-export","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":424,"latencyMs":3095.5347499999916,"n":6,"outputTokens":103,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2817","providerCostUsd":0.002817,"providerRequestId":"msg_011CcxgCPapmAtjS7KWQwBRU","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCPapmAtjS7KWQwBRU","score":0.1,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":432,"latencyMs":2364.7582500000135,"n":6,"outputTokens":64,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2256","providerCostUsd":0.002256,"providerRequestId":"msg_011CcxgCcn4tSmQc9YShWEvu","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCcn4tSmQc9YShWEvu","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-spec-audit","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":433,"latencyMs":2558.8058329999913,"n":6,"outputTokens":89,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2634","providerCostUsd":0.002634,"providerRequestId":"msg_011CcxgCnnpuXD6WDQS16kYr","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCnnpuXD6WDQS16kYr","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-unresolved-pattern","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":430,"latencyMs":2277.9934999999823,"n":6,"outputTokens":70,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2340","providerCostUsd":0.00234,"providerRequestId":"msg_011CcxgCyocRkCF2BRk61h7A","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCyocRkCF2BRk61h7A","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-preserve-json","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":430,"latencyMs":3092.2258750000037,"n":6,"outputTokens":106,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2880","providerCostUsd":0.0028799999999999997,"providerRequestId":"msg_011CcxgD9XG5iv9Jvgp23wSK","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgD9XG5iv9Jvgp23wSK","score":0.1,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"v2-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5848,"latencyMs":16682.59712500003,"n":6,"outputTokens":695,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"27969","providerCostUsd":0.027969,"providerRequestId":"msg_011CcxgDNz8Zu3EWJvu5C8Nq","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgDNz8Zu3EWJvu5C8Nq","score":0.416666666667,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"v2-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5849,"latencyMs":19427.836750000017,"n":6,"outputTokens":780,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"29247","providerCostUsd":0.029247000000000002,"providerRequestId":"msg_011CcxgEcG8CN3sXKEc1g1qf","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgEcG8CN3sXKEc1g1qf","score":0.583333333333,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"v2-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1766,"latencyMs":9699.860417000018,"n":6,"outputTokens":462,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"12228","providerCostUsd":0.012228,"providerRequestId":"msg_011CcxgG3MGLbXyJS4xdKzUH","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgG3MGLbXyJS4xdKzUH","score":0.25,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"v2-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1767,"latencyMs":9541.945332999981,"n":6,"outputTokens":501,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"12816","providerCostUsd":0.012816000000000001,"providerRequestId":"msg_011CcxgGkndooxKxJ988JudK","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgGkndooxKxJ988JudK","score":0.25,"success":true} diff --git a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/summary.json b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/summary.json new file mode 100644 index 0000000..42e6113 --- /dev/null +++ b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/summary.json @@ -0,0 +1,33 @@ +{ + "acquisition": { + "evidence": [ + "MODELED" + ], + "modeledUsd": 1.5 + }, + "attemptedSamples": 29, + "failedSamples": 0, + "fidelity": { + "bad-clone": { + "criticalGatePass": false, + "meanScore": 0.166666666667, + "scoredSamples": 6 + }, + "clone": { + "criticalGatePass": false, + "meanScore": 0.25, + "scoredSamples": 8 + }, + "target": { + "criticalGatePass": false, + "meanScore": 0.425, + "scoredSamples": 8 + } + }, + "latencyMs": { + "p50": 13784.64254099998, + "p95": 26304.560541999992 + }, + "providerCostUsd": 0.555654, + "successfulSamples": 29 +} From a9e54283dd8202f7f30db9c5d09affaa56764cfc Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 23:01:41 -0400 Subject: [PATCH 044/165] docs: gate clone high-N provider spend --- spikes/clone-economics/.env.example | 10 +++- spikes/clone-economics/RUNBOOK.md | 82 +++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/spikes/clone-economics/.env.example b/spikes/clone-economics/.env.example index 6773227..53ab576 100644 --- a/spikes/clone-economics/.env.example +++ b/spikes/clone-economics/.env.example @@ -1,8 +1,7 @@ # Offline mode is the default and requires no key or network. MOCK_LLM=1 -ALLOW_LIVE_LLM=0 -# Live mode requires every field below plus `npm run real`. +# The bounded `npm run real` mode requires every field in this section. ANTHROPIC_API_KEY= MODEL= N= @@ -17,3 +16,10 @@ INVOCATION_PRICE_USD= CLONE_SERVING_COST_USD= DEPLOY_COST_USD= LABOR_COST_USD= + +# High-N live sweep: set only after a human approves current pricing and spend. +# Model, pricing, and token caps come from committed fixtures/live-budget-v1.json; +# economic inputs come from committed fixtures/live-economics-v1.json. +APPROVE_LIVE_SWEEP_SHA256= +MAX_SWEEP_COST_USD= +ALLOW_LIVE_LLM=0 diff --git a/spikes/clone-economics/RUNBOOK.md b/spikes/clone-economics/RUNBOOK.md index 3d9d09b..0072019 100644 --- a/spikes/clone-economics/RUNBOOK.md +++ b/spikes/clone-economics/RUNBOOK.md @@ -10,7 +10,7 @@ No live run was executed while building this spike. The procedure below is for an operator who deliberately chooses to spend model credits and supplies a current pricing snapshot. -## 1. Supply every live input +## 1. Supply every bounded single-run live input Copy `.env.example` to `.env`, fill it locally, then export it into the shell. The CLI does not silently load `.env`. @@ -21,7 +21,7 @@ source .env set +a ``` -Required live fields: +Required bounded single-run live fields: - `ALLOW_LIVE_LLM=1` and `MOCK_LLM=0` — explicit opt-in and non-mock mode. - `ANTHROPIC_API_KEY` — never print it or commit `.env`. @@ -47,7 +47,7 @@ maximum exceeds `MAX_RUN_COST_USD`. During a run it also enforces cumulative measured provider spend. If a provider response omits usage, that request's cost is `null`, never `$0`. -## 2. Run once +## 2. Run one bounded experiment ```bash npm run real @@ -65,25 +65,77 @@ The command writes ignored artifacts to `runs/live/report.json` and - Check raw usage, normalized tokens, pricing snapshot, every request latency, sequential build time, and the parallel-acquisition lower bound. -## 3. N sweep +## 3. High-N sweep: preflight first -Run separate, capped experiments at the fixed supported sizes; keep model, -pricing, max tokens, repository inventory, and heldout set unchanged: +The preregistration is `fixtures/sweep-v1.json`: N=6,25,50,100; 30 held-out +fixtures; pair-order seeds 1701, 1702, and 1703; and distinct requested +distillation seeds 2701, 2702, and 2703. Pair-order seeds control only local +acquisition ordering. A high-N result is publishable only if a live adapter +reports all three requested distillation seeds as independently applied. The +current Anthropic adapter reports seed support as `unsupported`, so it may +produce explicitly uncontrolled evidence but cannot produce clone-fidelity, +defensibility, moat, break-even, or economics conclusions. ```bash -N=2 npm run real -N=4 npm run real -N=6 npm run real +npm run fixtures:check +npm run sweep:preflight +npm run sweep:mock ``` -Move or rename each ignored report between runs if you want to retain it. Plot: +These commands use no key, network, x402 payment, or provider spend. -- clone absolute score and critical-gate pass versus N; -- `D/A` and `B/A` versus N; -- break-even Invocations where `P - cloneServingCost > 0`; -- sequential build time and the parallel-acquisition lower bound. +## 4. Human-authorized live gate -## 4. Interpretation discipline +The committed `fixtures/live-budget-v1.json` and +`fixtures/live-economics-v1.json` intentionally start with +`approvalStatus: "not_approved"` and null values. Before a live run, a human +must: + +1. Verify the provider's current official pricing. In the budget snapshot, + replace every null with the selected model, decimal-string prices, + timestamped HTTPS source, and token caps, then set `approvalStatus` to + `approved`. +2. Review and explicitly supply all four economic inputs in the economics + snapshot: Invocation price, clone serving cost, deployment cost, and labor + cost. Set its `approvalStatus` to `approved`. +3. Review the 1,713-call conservative request count from + `npm run sweep:preflight`, then commit both approved snapshots and the + unchanged `fixtures/sweep-v1.json`. + +The sweep ignores environment-based model, pricing, token-cap, and economic +values. The committed files are its only execution contract. + +Run `npm run sweep:preflight` again from that exact commit. It prints a +`live authorization: sha256:...` digest over the complete sweep config, +approved budget snapshot, and approved economics snapshot, plus the +conservative maximum cost. After reviewing the printed contract, the human +explicitly approves a maximum at or above the conservative estimate and copies +that exact digest: + +```bash +export APPROVE_LIVE_SWEEP_SHA256='sha256:' +export MAX_SWEEP_COST_USD="$HUMAN_APPROVED_MAX_SWEEP_COST_USD" +export ALLOW_LIVE_LLM=1 +``` + +Any change to N values, either seed family, model, prices, token caps, +economic inputs, or either approval snapshot changes the digest and +invalidates the old authorization. + +Then, and only then, the operator may run: + +```bash +npm run sweep:live +``` + +The command writes raw private output only under ignored `runs/` and writes a +sanitized candidate bundle to a new dated directory selected by its generated +experiment identifier under `evidence/`. Never overwrite a historical bundle. +Review and verify the candidate before staging; do not publish automatically. +An `unsupported` distillation-seed result remains useful only as +`stochastic_uncontrolled` evidence and must retain all conclusion suppressions. + +## 5. Interpretation discipline - `A=N×P` is **MODELED** paid-pair acquisition. The target provider's own model cost is reported separately and not added again to A. From be671401d789bb81691eeac6e07aa18dcad68746 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 23:28:54 -0400 Subject: [PATCH 045/165] fix: harden clone evidence bundles --- .../scripts/import-legacy-run.mjs | 7 +- spikes/clone-economics/src/evidence.mjs | 578 +++++++++++++++--- spikes/clone-economics/src/sweep.mjs | 27 +- .../clone-economics/tests/evidence.test.mjs | 333 +++++++++- spikes/clone-economics/tests/sweep.test.mjs | 6 + 5 files changed, 837 insertions(+), 114 deletions(-) diff --git a/spikes/clone-economics/scripts/import-legacy-run.mjs b/spikes/clone-economics/scripts/import-legacy-run.mjs index 6a25ea5..d99566b 100644 --- a/spikes/clone-economics/scripts/import-legacy-run.mjs +++ b/spikes/clone-economics/scripts/import-legacy-run.mjs @@ -155,7 +155,12 @@ export function importLegacyRun(argv) { }, }, samples, - interpretation: 'INVALID_BENCHMARK_TARGET_FAILED. The target scored 0.400 and failed its critical gates, so clone quality, fidelity defense, moat, retention, break-even, and economics conclusions are suppressed. Provider execution was measured where retained; acquisition was modeled. Four earlier setup attempts have no normalized records.', + reportInputs: { + evidenceLabel: 'HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED', + verdict: 'INVALID_BENCHMARK_TARGET_FAILED', + suppressionReason: 'INVALID_BENCHMARK_TARGET_FAILED', + limitations: ['ACQUISITION_MODELED', 'HISTORICAL_ATTEMPTS_INCOMPLETE'], + }, reproduction: 'node scripts/verify-bundle.mjs evidence/2026-07-12-n6-invalid', }); return { output: args.output, samples: samples.length }; diff --git a/spikes/clone-economics/src/evidence.mjs b/spikes/clone-economics/src/evidence.mjs index f69e3c1..da112aa 100644 --- a/spikes/clone-economics/src/evidence.mjs +++ b/spikes/clone-economics/src/evidence.mjs @@ -3,7 +3,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { liveAuthorizationHash } from './authorization.mjs'; -import { calculateProviderCostMicroUsd } from './budget.mjs'; +import { calculateProviderCostMicroUsd, validateBudgetSnapshotShape } from './budget.mjs'; +import { validateLiveEconomicsShape } from './live-economics.mjs'; const SAMPLE_KEYS = new Set([ 'sampleId', 'phase', 'profile', 'caseId', 'n', 'replicateId', @@ -16,7 +17,7 @@ const SAMPLE_KEYS = new Set([ ]); const FORBIDDEN_KEYS = new Set([ 'prompt', 'payload', 'output', 'rawResponse', 'apiKey', 'authorization', - 'headers', 'skillText', 'referenceText', + 'headers', 'skillText', 'targetSkill', 'targetSkillText', 'referenceText', ]); const CONFIGURATION_KEYS = new Set([ 'sweepConfig', 'nValues', 'replicateIds', 'pairOrderSeeds', @@ -27,19 +28,110 @@ const CONFIGURATION_KEYS = new Set([ 'liveEconomics', ]); const REQUIRED_BUNDLE_FILES = ['samples.jsonl', 'summary.json', 'report.md', 'README.md']; +const ALL_BUNDLE_FILES = [...REQUIRED_BUNDLE_FILES, 'manifest.json']; +const MANIFEST_INPUT_KEYS = [ + 'experimentId', 'recordedAtUtc', 'gitCommit', 'command', 'modelProvider', 'model', + 'evidenceLabel', 'sourceEvidence', 'liveBudget', 'configuration', +]; +const MANIFEST_KEYS = [ + 'schemaVersion', 'experimentId', 'recordedAtUtc', 'gitCommit', 'command', 'runtime', + 'modelProvider', 'model', 'evidenceLabel', 'sourceEvidence', 'liveBudget', + 'configuration', 'reportInputs', 'files', +]; +const REPORT_INPUT_KEYS = ['evidenceLabel', 'verdict', 'suppressionReason', 'limitations']; +const EVIDENCE_LABELS = new Set([ + 'SYNTHETIC', + 'SYNTHETIC FAILED TARGET', + 'LIVE CANDIDATE — PUBLICATION GATE PASSED', + 'LIVE CANDIDATE — CONCLUSIONS SUPPRESSED', + 'HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED', +]); +const VERDICTS = new Set([ + 'HIGH_N_PUBLICATION_GATE_PASSED', + 'HIGH_N_NOT_LIVE', + 'STANDALONE_TARGET_INVALID', + 'STANDALONE_TARGET_EXECUTION_FAILED', + 'HIGH_N_INCOMPLETE', + 'HIGH_N_TARGET_INVALID', + 'DISTILLATION_SEEDS_UNCONTROLLED', + 'INVALID_BENCHMARK_TARGET_FAILED', +]); +const LIMITATIONS = new Set([ + 'SYNTHETIC_ONLY', + 'INCOMPLETE_PROVIDER_COST', + 'ACQUISITION_MODELED', + 'HISTORICAL_ATTEMPTS_INCOMPLETE', + 'DIRTY_CHECKOUT', +]); const rounded = (value) => Number(value.toFixed(12)); const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +function assertExactKeys(value, keys, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${label} has unexpected or missing fields`); + } +} + +function assertPortableJson(value, label = 'evidence input', active = new WeakSet()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error(`${label} must contain only finite JSON numbers`); + return; + } + if (typeof value !== 'object') { + throw new Error(`${label} contains unsupported ${typeof value} value`); + } + if (active.has(value)) throw new Error(`${label} must not contain cycles`); + active.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw new Error(`${label} contains a non-plain array`); + } + if (Object.getOwnPropertySymbols(value).length !== 0) { + throw new Error(`${label} must not contain symbol keys`); + } + const keys = Object.getOwnPropertyNames(value); + const expectedKeys = [...Array.from({ length: value.length }, (_, index) => String(index)), 'length']; + if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) { + throw new Error(`${label} must not contain sparse arrays or extra array properties`); + } + for (let index = 0; index < value.length; index += 1) { + assertPortableJson(value[index], `${label}[${index}]`, active); + } + return; + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + throw new Error(`${label} contains a non-plain object`); + } + if (Object.getOwnPropertySymbols(value).length !== 0) { + throw new Error(`${label} must not contain symbol keys`); + } + for (const key of Object.getOwnPropertyNames(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable) throw new Error(`${label}.${key} must not be non-enumerable`); + if (!Object.hasOwn(descriptor, 'value')) { + throw new Error(`${label}.${key} must be a data property`); + } + if (FORBIDDEN_KEYS.has(key)) throw new Error(`forbidden evidence field: ${label}.${key}`); + assertPortableJson(descriptor.value, `${label}.${key}`, active); + } + } finally { + active.delete(value); + } +} + function canonicalize(value) { - if (typeof value === 'bigint') return value.toString(); if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return value; if (Array.isArray(value)) return value.map(canonicalize); - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]), - ); - } - throw new Error(`Unsupported evidence value type: ${typeof value}`); + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]), + ); } const stableJson = (value) => `${JSON.stringify(canonicalize(value), null, 2)}\n`; @@ -47,7 +139,9 @@ const stableLine = (value) => JSON.stringify(canonicalize(value)); function finiteNonNegative(value, label, { nullable = false } = {}) { if (nullable && value === null) return; - if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a finite non-negative number or null`); + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${label} must be a finite non-negative number${nullable ? ' or null' : ''}`); + } } function validateSample(sample) { @@ -56,10 +150,19 @@ function validateSample(sample) { if (FORBIDDEN_KEYS.has(key)) throw new Error(`forbidden sample field: ${key}`); if (!SAMPLE_KEYS.has(key)) throw new Error(`unknown sample field: ${key}`); } + assertPortableJson(sample, 'sample'); + assertExactKeys(sample, SAMPLE_KEYS, 'Sample'); if (typeof sample.sampleId !== 'string' || sample.sampleId.trim() === '') throw new Error('sampleId is required'); if (typeof sample.phase !== 'string' || sample.phase.trim() === '') throw new Error('sample phase is required'); if (typeof sample.profile !== 'string' || sample.profile.trim() === '') throw new Error('sample profile is required'); - if (!(sample.caseId === null || typeof sample.caseId === 'string')) throw new Error('sample caseId must be string or null'); + for (const key of ['caseId', 'replicateId', 'acquisitionEvidence', 'failureClass', 'providerRequestId']) { + if (!(sample[key] === null || (typeof sample[key] === 'string' && sample[key] !== ''))) { + throw new Error(`${key} must be string or null`); + } + } + for (const key of ['distillationSeedStatus', 'distillationSeedMechanism']) { + if (typeof sample[key] !== 'string' || sample[key] === '') throw new Error(`${key} must be a non-empty string`); + } if (typeof sample.success !== 'boolean') throw new Error('sample success must be boolean'); finiteNonNegative(sample.latencyMs, 'sample latencyMs'); for (const key of ['inputTokens', 'outputTokens']) { @@ -69,23 +172,24 @@ function validateSample(sample) { } } finiteNonNegative(sample.providerCostUsd, 'sample providerCostUsd', { nullable: true }); - if (sample.providerCostMicroUsd !== undefined && sample.providerCostMicroUsd !== null + if (sample.providerCostMicroUsd !== null && !/^(?:0|[1-9]\d*)$/.test(sample.providerCostMicroUsd)) { throw new Error('providerCostMicroUsd must be a base-10 non-negative integer string or null'); } - if (sample.acquisitionCostUsd !== undefined) { - finiteNonNegative(sample.acquisitionCostUsd, 'sample acquisitionCostUsd'); - } - if (sample.score !== undefined && sample.score !== null + finiteNonNegative(sample.acquisitionCostUsd, 'sample acquisitionCostUsd'); + if (sample.score !== null && (!Number.isFinite(sample.score) || sample.score < 0 || sample.score > 1)) { throw new Error('sample score must be a finite number from 0 to 1 or null'); } - if (sample.criticalGatePass !== undefined && sample.criticalGatePass !== null + if (sample.criticalGatePass !== null && typeof sample.criticalGatePass !== 'boolean') { throw new Error('sample criticalGatePass must be boolean or null'); } - for (const key of ['n', 'pairOrderSeed', 'requestedDistillationSeed', 'appliedDistillationSeed']) { - if (sample[key] !== undefined && sample[key] !== null && !Number.isSafeInteger(sample[key])) { + if (sample.n !== null && (!Number.isSafeInteger(sample.n) || sample.n <= 0)) { + throw new Error('n must be a positive safe integer or null'); + } + for (const key of ['pairOrderSeed', 'requestedDistillationSeed', 'appliedDistillationSeed']) { + if (sample[key] !== null && !Number.isSafeInteger(sample[key])) { throw new Error(`${key} must be a safe integer or null`); } } @@ -149,33 +253,296 @@ export function recomputeSummary(samples) { }; } -function sanitizeConfiguration(configuration = {}) { +function nonEmptyString(value, label, { nullable = false } = {}) { + if (nullable && value === null) return; + if (typeof value !== 'string' || value === '') { + throw new Error(`${label} must be a non-empty string${nullable ? ' or null' : ''}`); + } +} + +function integerArray(value, label, { positive = false, historical = false } = {}) { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if (historical && item === 'not-recorded') continue; + if (!Number.isSafeInteger(item) || (positive && item <= 0)) { + throw new Error(`${label}[${index}] must be a ${positive ? 'positive ' : ''}safe integer${historical ? ' or not-recorded' : ''}`); + } + } +} + +function stringArray(value, label, allowed = null) { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + for (let index = 0; index < value.length; index += 1) { + nonEmptyString(value[index], `${label}[${index}]`); + if (allowed && !allowed.has(value[index])) throw new Error(`${label}[${index}] is unsupported`); + } +} + +function validateSweepConfiguration(value) { + const keys = [ + 'schemaVersion', 'experimentFamily', 'fixtureSet', 'nValues', 'heldoutMinimum', + 'replicates', 'highNDefinition', 'targetThreshold', 'requireAllTargetCriticalGates', + 'acquisitionTreatment', 'attemptCostTreatment', 'publicationRequiresValidTarget', + 'publicationRequiresIndependentDistillationSeeds', + ]; + assertExactKeys(value, keys, 'configuration.sweepConfig'); + if (value.schemaVersion !== 1) throw new Error('configuration.sweepConfig.schemaVersion must be 1'); + nonEmptyString(value.experimentFamily, 'configuration.sweepConfig.experimentFamily'); + nonEmptyString(value.fixtureSet, 'configuration.sweepConfig.fixtureSet'); + integerArray(value.nValues, 'configuration.sweepConfig.nValues', { positive: true }); + for (const key of ['heldoutMinimum', 'highNDefinition']) { + if (!Number.isSafeInteger(value[key]) || value[key] <= 0) { + throw new Error(`configuration.sweepConfig.${key} must be a positive safe integer`); + } + } + if (!Number.isFinite(value.targetThreshold) || value.targetThreshold < 0 || value.targetThreshold > 1) { + throw new Error('configuration.sweepConfig.targetThreshold must be a finite number from 0 to 1'); + } + for (const key of [ + 'requireAllTargetCriticalGates', 'publicationRequiresValidTarget', + 'publicationRequiresIndependentDistillationSeeds', + ]) { + if (typeof value[key] !== 'boolean') throw new Error(`configuration.sweepConfig.${key} must be boolean`); + } + for (const key of ['acquisitionTreatment', 'attemptCostTreatment']) { + nonEmptyString(value[key], `configuration.sweepConfig.${key}`); + } + if (!Array.isArray(value.replicates)) throw new Error('configuration.sweepConfig.replicates must be an array'); + for (let index = 0; index < value.replicates.length; index += 1) { + const replicate = value.replicates[index]; + assertExactKeys(replicate, ['replicateId', 'pairOrderSeed', 'distillationSeed'], `configuration.sweepConfig.replicates[${index}]`); + nonEmptyString(replicate.replicateId, `configuration.sweepConfig.replicates[${index}].replicateId`); + for (const key of ['pairOrderSeed', 'distillationSeed']) { + if (!Number.isSafeInteger(replicate[key])) { + throw new Error(`configuration.sweepConfig.replicates[${index}].${key} must be a safe integer`); + } + } + } +} + +function validateConfiguration(configuration = {}) { + assertPortableJson(configuration, 'configuration'); if (!configuration || typeof configuration !== 'object' || Array.isArray(configuration)) { throw new Error('Evidence configuration must be an object'); } for (const key of Object.keys(configuration)) { if (!CONFIGURATION_KEYS.has(key)) throw new Error(`Unsupported evidence configuration field: ${key}`); } + if (Object.hasOwn(configuration, 'sweepConfig')) validateSweepConfiguration(configuration.sweepConfig); + if (Object.hasOwn(configuration, 'nValues')) integerArray(configuration.nValues, 'configuration.nValues', { positive: true }); + if (Object.hasOwn(configuration, 'replicateIds')) stringArray(configuration.replicateIds, 'configuration.replicateIds'); + for (const key of ['pairOrderSeeds', 'requestedDistillationSeeds', 'appliedDistillationSeeds']) { + if (Object.hasOwn(configuration, key)) integerArray(configuration[key], `configuration.${key}`, { historical: true }); + } + if (Object.hasOwn(configuration, 'distillationSeedEvidence')) { + if (!Array.isArray(configuration.distillationSeedEvidence)) { + throw new Error('configuration.distillationSeedEvidence must be an array'); + } + configuration.distillationSeedEvidence.forEach((item, index) => { + assertExactKeys(item, ['requested', 'applied', 'status', 'mechanism'], `configuration.distillationSeedEvidence[${index}]`); + for (const key of ['requested', 'applied']) { + if (item[key] !== null && !Number.isSafeInteger(item[key])) { + throw new Error(`configuration.distillationSeedEvidence[${index}].${key} must be a safe integer or null`); + } + } + nonEmptyString(item.status, `configuration.distillationSeedEvidence[${index}].status`); + nonEmptyString(item.mechanism, `configuration.distillationSeedEvidence[${index}].mechanism`); + }); + } + if (Object.hasOwn(configuration, 'tokenCaps')) { + assertExactKeys(configuration.tokenCaps, ['maxInputTokens', 'maxOutputTokens'], 'configuration.tokenCaps'); + for (const key of ['maxInputTokens', 'maxOutputTokens']) { + const value = configuration.tokenCaps[key]; + if (!(value === null || (Number.isSafeInteger(value) && value > 0))) { + throw new Error(`configuration.tokenCaps.${key} must be a positive safe integer or null`); + } + } + } + if (Object.hasOwn(configuration, 'pricingSnapshot')) { + validateBudgetSnapshotShape(configuration.pricingSnapshot, configuration.sweepConfig ?? null); + } + if (Object.hasOwn(configuration, 'evidenceLabels')) { + stringArray(configuration.evidenceLabels, 'configuration.evidenceLabels', EVIDENCE_LABELS); + } + for (const key of ['acquisitionTreatment', 'attemptCoverage', 'fixtureSet']) { + if (Object.hasOwn(configuration, key)) nonEmptyString(configuration[key], `configuration.${key}`); + } + if (Object.hasOwn(configuration, 'historicalRunDate')) { + const value = configuration.historicalRunDate; + const instant = typeof value === 'string' ? `${value}T00:00:00Z` : ''; + if (!(typeof value === 'string' + && /^\d{4}-\d{2}-\d{2}$/.test(value) + && Number.isFinite(Date.parse(instant)) + && new Date(instant).toISOString().slice(0, 10) === value)) { + throw new Error('configuration.historicalRunDate must be a valid date-only string'); + } + } + if (Object.hasOwn(configuration, 'sourceTimestamp') && configuration.sourceTimestamp !== 'not-recorded') { + throw new Error('configuration.sourceTimestamp must be not-recorded'); + } + for (const key of ['benchmarkVerdict', 'suppressionReason']) { + if (Object.hasOwn(configuration, key)) { + const value = configuration[key]; + if (!(value === null || VERDICTS.has(value))) throw new Error(`configuration.${key} is unsupported`); + } + } + if (Object.hasOwn(configuration, 'publicationGate')) { + const gate = configuration.publicationGate; + assertExactKeys(gate, ['publishableHighN', 'suppressionReason'], 'configuration.publicationGate'); + if (typeof gate.publishableHighN !== 'boolean') { + throw new Error('publicationGate.publishableHighN must be boolean'); + } + if (!(gate.suppressionReason === null || VERDICTS.has(gate.suppressionReason))) { + throw new Error('publicationGate.suppressionReason must be string or null'); + } + if (gate.publishableHighN !== (gate.suppressionReason === null)) { + throw new Error('configuration.publicationGate has inconsistent publication state'); + } + } + if (Object.hasOwn(configuration, 'liveEconomics')) { + if (!configuration.sweepConfig) throw new Error('configuration.liveEconomics requires sweepConfig'); + validateLiveEconomicsShape(configuration.liveEconomics, configuration.sweepConfig); + } return canonicalize(configuration); } function validateSourceEvidence(sourceEvidence) { if (sourceEvidence === null || sourceEvidence === undefined) return null; - if (!sourceEvidence || typeof sourceEvidence !== 'object' - || JSON.stringify(Object.keys(sourceEvidence).sort()) !== JSON.stringify(['bytes', 'kind', 'sha256'])) { - throw new Error('sourceEvidence must contain only kind, sha256, and bytes'); - } + assertPortableJson(sourceEvidence, 'manifest.sourceEvidence'); + assertExactKeys(sourceEvidence, ['bytes', 'kind', 'sha256'], 'sourceEvidence'); if (typeof sourceEvidence.kind !== 'string' || sourceEvidence.kind === '') throw new Error('sourceEvidence kind is required'); if (!/^[0-9a-f]{64}$/.test(sourceEvidence.sha256)) throw new Error('sourceEvidence sha256 must be a lowercase digest'); if (!Number.isSafeInteger(sourceEvidence.bytes) || sourceEvidence.bytes <= 0) throw new Error('sourceEvidence bytes must be positive'); return { ...sourceEvidence }; } -function renderReport(summary, interpretation) { +function validateReportInputs(reportInputs, manifestEvidenceLabel, configuration) { + assertPortableJson(reportInputs, 'reportInputs'); + assertExactKeys(reportInputs, REPORT_INPUT_KEYS, 'reportInputs'); + if (!EVIDENCE_LABELS.has(reportInputs.evidenceLabel)) throw new Error('reportInputs.evidenceLabel is unsupported'); + if (reportInputs.evidenceLabel !== manifestEvidenceLabel) { + throw new Error('reportInputs.evidenceLabel must equal manifest evidenceLabel'); + } + if (!VERDICTS.has(reportInputs.verdict)) throw new Error('reportInputs.verdict is unsupported'); + if (!(reportInputs.suppressionReason === null || VERDICTS.has(reportInputs.suppressionReason))) { + throw new Error('reportInputs.suppressionReason is unsupported'); + } + stringArray(reportInputs.limitations, 'reportInputs.limitations', LIMITATIONS); + if (new Set(reportInputs.limitations).size !== reportInputs.limitations.length) { + throw new Error('reportInputs.limitations must not contain duplicates'); + } + const passed = reportInputs.verdict === 'HIGH_N_PUBLICATION_GATE_PASSED'; + const passedLabel = 'LIVE CANDIDATE — PUBLICATION GATE PASSED'; + if (passed && reportInputs.evidenceLabel !== passedLabel) { + throw new Error('Publication gate passed evidence label must identify a live candidate'); + } + if (!passed && reportInputs.evidenceLabel === passedLabel) { + throw new Error('Publication gate passed evidence label requires the passed verdict'); + } + if (passed !== (reportInputs.suppressionReason === null)) { + throw new Error('reportInputs has inconsistent verdict and suppressionReason'); + } + if (!passed && reportInputs.suppressionReason !== reportInputs.verdict) { + throw new Error('suppressed reportInputs verdict must equal suppressionReason'); + } + if (configuration.publicationGate) { + if (configuration.publicationGate.publishableHighN !== passed + || configuration.publicationGate.suppressionReason !== reportInputs.suppressionReason) { + throw new Error('reportInputs differs from configuration publicationGate'); + } + } else if (configuration.benchmarkVerdict) { + if (configuration.benchmarkVerdict !== reportInputs.verdict) { + throw new Error('reportInputs verdict differs from configuration benchmarkVerdict'); + } + } else if (passed) { + throw new Error('passed reportInputs requires a validated publicationGate'); + } + const sorted = [...reportInputs.limitations].sort(); + if (JSON.stringify(sorted) !== JSON.stringify(reportInputs.limitations)) { + throw new Error('reportInputs.limitations must be sorted'); + } + return canonicalize(reportInputs); +} + +function validateLock(lock) { + if (lock === null) return null; + if (!lock || typeof lock !== 'object' || Array.isArray(lock)) throw new Error('liveBudget.lock must be object or null'); + if (lock.kind === 'unknown_cost') { + assertExactKeys(lock, ['kind', 'attemptId'], 'liveBudget.lock'); + } else if (lock.kind === 'budget_overrun') { + assertExactKeys(lock, ['kind', 'attemptId', 'reason'], 'liveBudget.lock'); + if (!['token_cap_exceeded', 'human_cap_exceeded', 'reservation_exceeded'].includes(lock.reason)) { + throw new Error('liveBudget.lock.reason is unsupported'); + } + } else { + throw new Error('liveBudget.lock.kind is unsupported'); + } + nonEmptyString(lock.attemptId, 'liveBudget.lock.attemptId'); + return canonicalize(lock); +} + +function validateLiveBudget(liveBudget) { + if (liveBudget === null || liveBudget === undefined) return null; + assertPortableJson(liveBudget, 'manifest.liveBudget'); + const keys = [ + 'snapshotPath', 'snapshotSha256', 'authorizationHash', 'humanCapMicroUsd', + 'conservativeEstimateMicroUsd', 'worstCasePerCallMicroUsd', 'attemptedCalls', + 'knownAccruedMicroUsd', 'outstandingReservedMicroUsd', 'lock', + 'economicsSnapshotPath', 'economicsSnapshotSha256', + ]; + assertExactKeys(liveBudget, keys, 'liveBudget'); + for (const key of ['snapshotPath', 'economicsSnapshotPath']) { + nonEmptyString(liveBudget[key], `liveBudget.${key}`); + if (path.isAbsolute(liveBudget[key]) || liveBudget[key].split(/[\\/]/).includes('..')) { + throw new Error(`liveBudget.${key} must be repository-relative`); + } + } + for (const key of ['snapshotSha256', 'economicsSnapshotSha256']) { + if (!/^[0-9a-f]{64}$/.test(liveBudget[key])) throw new Error(`liveBudget.${key} must be a lowercase digest`); + } + if (!/^sha256:[0-9a-f]{64}$/.test(liveBudget.authorizationHash)) { + throw new Error('liveBudget.authorizationHash must be a lowercase sha256 digest'); + } + for (const key of [ + 'humanCapMicroUsd', 'conservativeEstimateMicroUsd', 'worstCasePerCallMicroUsd', + 'knownAccruedMicroUsd', 'outstandingReservedMicroUsd', + ]) { + if (!/^(?:0|[1-9]\d*)$/.test(liveBudget[key])) { + throw new Error(`liveBudget.${key} must be a base-10 non-negative integer string`); + } + } + if (!Number.isSafeInteger(liveBudget.attemptedCalls) || liveBudget.attemptedCalls < 0) { + throw new Error('liveBudget.attemptedCalls must be a non-negative safe integer'); + } + validateLock(liveBudget.lock); + return canonicalize(liveBudget); +} + +function validateRecordedAtUtc(value) { + if (value === null) return; + if (typeof value !== 'string' + || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)) { + throw new Error('recordedAtUtc must be an ISO-8601 instant or null'); + } + const canonical = new Date(value).toISOString(); + const expected = value.includes('.') ? value : value.replace(/Z$/, '.000Z'); + if (canonical !== expected) throw new Error('recordedAtUtc must be an ISO-8601 instant or null'); +} + +function renderReport(summary, reportInputs) { const value = (input) => input === null ? 'unknown' : String(input); + const limitations = reportInputs.limitations.length === 0 + ? 'none' + : reportInputs.limitations.join(', '); return `# Clone-economics evidence report -${interpretation} +- Evidence label: ${reportInputs.evidenceLabel} +- Verdict: ${reportInputs.verdict} +- Suppression reason: ${reportInputs.suppressionReason ?? 'none'} +- Limitations: ${limitations} + +## Recomputed metrics - Attempted samples: ${summary.attemptedSamples} - Successful samples: ${summary.successfulSamples} @@ -203,46 +570,90 @@ This bundle does not by itself authorize publication or a live benchmark claim. `; } -export function writeEvidenceBundle({ - outputDir, - manifest, - samples, - interpretation, - reproduction, -}) { +function validateManifest(manifest) { + assertPortableJson(manifest, 'manifest'); + assertExactKeys(manifest, MANIFEST_KEYS, 'Evidence manifest'); + if (manifest.schemaVersion !== 1) throw new Error('Unsupported evidence manifest schemaVersion'); if (!manifest || typeof manifest.experimentId !== 'string' || manifest.experimentId === '') { throw new Error('Evidence manifest experimentId is required'); } - if (typeof manifest.evidenceLabel !== 'string' || manifest.evidenceLabel === '') { - throw new Error('Evidence manifest evidenceLabel is required'); + validateRecordedAtUtc(manifest.recordedAtUtc); + for (const key of ['gitCommit', 'command']) nonEmptyString(manifest[key], `manifest.${key}`); + for (const key of ['modelProvider', 'model']) nonEmptyString(manifest[key], `manifest.${key}`, { nullable: true }); + if (!EVIDENCE_LABELS.has(manifest.evidenceLabel)) throw new Error('Evidence manifest evidenceLabel is unsupported'); + assertExactKeys(manifest.runtime, ['node', 'platform', 'arch'], 'manifest.runtime'); + for (const key of ['node', 'platform', 'arch']) nonEmptyString(manifest.runtime[key], `manifest.runtime.${key}`); + validateSourceEvidence(manifest.sourceEvidence); + validateLiveBudget(manifest.liveBudget); + const configuration = validateConfiguration(manifest.configuration); + validateReportInputs(manifest.reportInputs, manifest.evidenceLabel, configuration); + assertExactKeys(manifest.files, REQUIRED_BUNDLE_FILES, 'manifest.files'); + for (const name of REQUIRED_BUNDLE_FILES) { + const file = manifest.files[name]; + assertExactKeys(file, ['sha256', 'bytes'], `manifest.files.${name}`); + if (!/^[0-9a-f]{64}$/.test(file.sha256)) throw new Error(`manifest.files.${name}.sha256 must be a lowercase digest`); + if (!Number.isSafeInteger(file.bytes) || file.bytes <= 0) { + throw new Error(`manifest.files.${name}.bytes must be a positive safe integer`); + } } - if (typeof manifest.command !== 'string' || manifest.command === '') throw new Error('Evidence manifest command is required'); - if (typeof interpretation !== 'string' || interpretation === '') throw new Error('Evidence interpretation is required'); - if (typeof reproduction !== 'string' || reproduction === '') throw new Error('Evidence reproduction command is required'); - const summary = recomputeSummary(samples); - fs.mkdirSync(outputDir, { recursive: true }); - if (fs.readdirSync(outputDir).length !== 0) throw new Error('Evidence output directory must be empty'); + if (manifest.recordedAtUtc === null + && !(configuration.historicalRunDate && configuration.sourceTimestamp === 'not-recorded')) { + throw new Error('A null recordedAtUtc requires a historical date and sourceTimestamp not-recorded'); + } + return manifest; +} - const contents = { - 'samples.jsonl': `${samples.map(stableLine).join('\n')}\n`, - 'summary.json': stableJson(summary), - 'report.md': renderReport(summary, interpretation), - 'README.md': renderReadme(reproduction), - }; - for (const name of REQUIRED_BUNDLE_FILES) fs.writeFileSync(path.join(outputDir, name), contents[name]); +function validateOutputDirectory(outputDir) { + nonEmptyString(outputDir, 'outputDir'); + if (!fs.existsSync(outputDir)) return; + const stat = fs.lstatSync(outputDir); + if (stat.isSymbolicLink()) throw new Error('Evidence output directory must not be a symlink'); + if (!stat.isDirectory()) throw new Error('Evidence output path must be a directory'); + if (fs.readdirSync(outputDir).length !== 0) throw new Error('Evidence output directory must be empty'); +} - const recordedAtUtc = manifest.recordedAtUtc === undefined ? new Date().toISOString() : manifest.recordedAtUtc; - if (!(recordedAtUtc === null || (typeof recordedAtUtc === 'string' - && Number.isFinite(Date.parse(recordedAtUtc)) - && /^\d{4}-\d{2}-\d{2}T/.test(recordedAtUtc)))) { - throw new Error('recordedAtUtc must be an ISO-8601 instant or null'); +export function writeEvidenceBundle(input) { + assertPortableJson(input, 'writer input'); + assertExactKeys(input, ['outputDir', 'manifest', 'samples', 'reportInputs', 'reproduction'], 'Evidence writer input'); + const { + outputDir, + manifest, + samples, + reportInputs, + reproduction, + } = input; + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + throw new Error('Evidence manifest input must be an object'); + } + for (const key of Object.keys(manifest)) { + if (!MANIFEST_INPUT_KEYS.includes(key)) throw new Error(`Unsupported evidence manifest field: ${key}`); } + nonEmptyString(manifest.experimentId, 'manifest.experimentId'); + nonEmptyString(manifest.command, 'manifest.command'); + if (!EVIDENCE_LABELS.has(manifest.evidenceLabel)) throw new Error('Evidence manifest evidenceLabel is unsupported'); + for (const key of ['gitCommit', 'modelProvider', 'model']) { + if (Object.hasOwn(manifest, key)) nonEmptyString(manifest[key], `manifest.${key}`, { nullable: key !== 'gitCommit' }); + } + nonEmptyString(reproduction, 'Evidence reproduction command'); + if (reproduction.includes('```')) throw new Error('Evidence reproduction command must not contain a code-fence delimiter'); + const recordedAtUtc = manifest.recordedAtUtc === undefined ? new Date().toISOString() : manifest.recordedAtUtc; + validateRecordedAtUtc(recordedAtUtc); const sourceEvidence = validateSourceEvidence(manifest.sourceEvidence); - const configuration = sanitizeConfiguration(manifest.configuration); + const liveBudget = validateLiveBudget(manifest.liveBudget); + const configuration = validateConfiguration(manifest.configuration); if (recordedAtUtc === null && !(configuration.historicalRunDate && configuration.sourceTimestamp === 'not-recorded')) { throw new Error('A null recordedAtUtc requires a historical date and sourceTimestamp not-recorded'); } + const validatedReportInputs = validateReportInputs(reportInputs, manifest.evidenceLabel, configuration); + const summary = recomputeSummary(samples); + + const contents = { + 'samples.jsonl': `${samples.map(stableLine).join('\n')}\n`, + 'summary.json': stableJson(summary), + 'report.md': renderReport(summary, validatedReportInputs), + 'README.md': renderReadme(reproduction), + }; const finalManifest = { schemaVersion: 1, experimentId: manifest.experimentId, @@ -254,34 +665,22 @@ export function writeEvidenceBundle({ model: manifest.model ?? null, evidenceLabel: manifest.evidenceLabel, sourceEvidence, - liveBudget: canonicalize(manifest.liveBudget ?? null), + liveBudget, configuration, + reportInputs: validatedReportInputs, files: Object.fromEntries(REQUIRED_BUNDLE_FILES.map((name) => [name, { sha256: sha256(contents[name]), bytes: Buffer.byteLength(contents[name]), }])), }; + validateManifest(finalManifest); + validateOutputDirectory(outputDir); + if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); + for (const name of REQUIRED_BUNDLE_FILES) fs.writeFileSync(path.join(outputDir, name), contents[name]); fs.writeFileSync(path.join(outputDir, 'manifest.json'), stableJson(finalManifest)); return finalManifest; } -function assertReportMatchesSummary(report, summary) { - const fields = { - 'Attempted samples': summary.attemptedSamples, - 'Successful samples': summary.successfulSamples, - 'Failed samples': summary.failedSamples, - 'Provider cost USD': summary.providerCostUsd, - 'Latency p50 ms': summary.latencyMs.p50, - 'Latency p95 ms': summary.latencyMs.p95, - }; - for (const [label, expected] of Object.entries(fields)) { - const match = report.match(new RegExp(`^- ${label}: (.+)$`, 'm')); - if (!match) throw new Error(`report.md is missing ${label}`); - const actual = match[1] === 'unknown' ? null : Number(match[1]); - if (!Object.is(actual, expected)) throw new Error(`report.md ${label} differs from summary.json`); - } -} - function verifyLiveRows(samples, manifest, dir) { if (manifest.liveBudget === null) return; const allowed = [ @@ -337,16 +736,32 @@ function verifyLiveRows(samples, manifest, dir) { } export function verifyEvidenceBundle(dir) { + nonEmptyString(dir, 'Evidence bundle path'); + let directoryStat; + try { + directoryStat = fs.lstatSync(dir); + } catch { + throw new Error('Evidence bundle path must be a real directory'); + } + if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) { + throw new Error('Evidence bundle path must be a real directory'); + } + const listing = fs.readdirSync(dir).sort(); + if (JSON.stringify(listing) !== JSON.stringify([...ALL_BUNDLE_FILES].sort())) { + throw new Error('Evidence bundle must contain exactly the five required regular files'); + } + for (const name of ALL_BUNDLE_FILES) { + const stat = fs.lstatSync(path.join(dir, name)); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Evidence bundle entry must be a regular file: ${name}`); + } const manifestPath = path.join(dir, 'manifest.json'); - if (!fs.existsSync(manifestPath)) throw new Error('Missing required evidence file: manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - if (manifest.schemaVersion !== 1) throw new Error('Unsupported evidence manifest schemaVersion'); + validateManifest(manifest); for (const name of REQUIRED_BUNDLE_FILES) { const filePath = path.join(dir, name); - if (!fs.existsSync(filePath)) throw new Error(`Missing required evidence file: ${name}`); const bytes = fs.readFileSync(filePath); - const expected = manifest.files?.[name]; - if (!expected || expected.bytes !== bytes.length || expected.sha256 !== sha256(bytes)) { + const expected = manifest.files[name]; + if (expected.bytes !== bytes.length || expected.sha256 !== sha256(bytes)) { throw new Error(`Evidence hash or byte count mismatch: ${name}`); } } @@ -354,11 +769,18 @@ export function verifyEvidenceBundle(dir) { if (!sampleText.endsWith('\n')) throw new Error('samples.jsonl must end with a newline'); const lines = sampleText.slice(0, -1).split('\n'); const samples = lines.length === 1 && lines[0] === '' ? [] : lines.map((line) => JSON.parse(line)); + for (let index = 0; index < samples.length; index += 1) { + if (lines[index] !== stableLine(samples[index])) { + throw new Error(`samples.jsonl row ${index + 1} is not canonical JSON`); + } + } const summary = recomputeSummary(samples); if (fs.readFileSync(path.join(dir, 'summary.json'), 'utf8') !== stableJson(summary)) { throw new Error('summary.json differs from recomputation'); } - assertReportMatchesSummary(fs.readFileSync(path.join(dir, 'report.md'), 'utf8'), summary); + if (fs.readFileSync(path.join(dir, 'report.md'), 'utf8') !== renderReport(summary, manifest.reportInputs)) { + throw new Error('report.md differs from deterministic rendering'); + } verifyLiveRows(samples, manifest, dir); return { valid: true, manifest, summary, samples }; } diff --git a/spikes/clone-economics/src/sweep.mjs b/spikes/clone-economics/src/sweep.mjs index dac2603..555b996 100644 --- a/spikes/clone-economics/src/sweep.mjs +++ b/spikes/clone-economics/src/sweep.mjs @@ -252,20 +252,20 @@ export async function writeSweepEvidenceBundle({ const { verifyEvidenceBundle, writeEvidenceBundle } = await import('./evidence.mjs'); const samples = normalizeSweepSamples({ experimentId, attempts: result.samples }); const incompleteCosts = samples.some((sample) => sample.providerCostUsd === null); - const interpretation = [ - result.publishableHighN - ? 'The preregistered high-N publication gate passed.' - : `Aggregate clone and economics conclusions are suppressed: ${result.suppressionReason ?? result.benchmark?.verdict ?? 'INCOMPLETE'}.`, - incompleteCosts - ? 'Limitations: at least one attempted provider call has unknown cost, so aggregate provider cost is incomplete.' - : 'All normalized attempted-call costs are present.', - ].join(' '); + const verdict = result.publishableHighN + ? 'HIGH_N_PUBLICATION_GATE_PASSED' + : result.suppressionReason ?? result.benchmark?.verdict ?? 'HIGH_N_INCOMPLETE'; + const limitations = [ + ...(samples.some((sample) => sample.acquisitionEvidence === 'MODELED') ? ['ACQUISITION_MODELED'] : []), + ...(incompleteCosts ? ['INCOMPLETE_PROVIDER_COST'] : []), + ...(evidenceLabel.startsWith('SYNTHETIC') ? ['SYNTHETIC_ONLY'] : []), + ].sort(); const manifest = writeEvidenceBundle({ outputDir, manifest: { experimentId, - recordedAtUtc, - gitCommit, + ...(recordedAtUtc === undefined ? {} : { recordedAtUtc }), + ...(gitCommit === undefined ? {} : { gitCommit }), command, modelProvider, model, @@ -283,7 +283,12 @@ export async function writeSweepEvidenceBundle({ }, }, samples, - interpretation, + reportInputs: { + evidenceLabel, + verdict, + suppressionReason: result.publishableHighN ? null : verdict, + limitations, + }, reproduction: reproduction ?? `node scripts/verify-bundle.mjs ${outputDir}`, }); return { manifest, verified: verifyEvidenceBundle(outputDir) }; diff --git a/spikes/clone-economics/tests/evidence.test.mjs b/spikes/clone-economics/tests/evidence.test.mjs index 446e308..3a32884 100644 --- a/spikes/clone-economics/tests/evidence.test.mjs +++ b/spikes/clone-economics/tests/evidence.test.mjs @@ -10,10 +10,83 @@ import { normalizeSweepSamples, writeSweepEvidenceBundle } from '../src/sweep.mj import { liveAuthorizationHash } from '../src/authorization.mjs'; import { approved, config, economics } from './fixtures/live-contract.mjs'; +const normalizedSample = (overrides = {}) => ({ + sampleId: 'run:target-heldout:a', + phase: 'evaluation', + profile: 'target', + caseId: 'a', + n: null, + replicateId: null, + pairOrderSeed: null, + requestedDistillationSeed: null, + appliedDistillationSeed: null, + distillationSeedStatus: 'not_requested', + distillationSeedMechanism: 'no_seed_requested', + success: true, + latencyMs: 10, + inputTokens: 3, + outputTokens: 2, + providerCostMicroUsd: '10000', + providerCostUsd: 0.01, + acquisitionCostUsd: 0, + acquisitionEvidence: null, + score: 0.9, + criticalGatePass: true, + failureClass: null, + providerRequestId: null, + ...overrides, +}); + +const syntheticReportInputs = (overrides = {}) => ({ + evidenceLabel: 'SYNTHETIC', + verdict: 'HIGH_N_NOT_LIVE', + suppressionReason: 'HIGH_N_NOT_LIVE', + limitations: ['SYNTHETIC_ONLY'], + ...overrides, +}); + +function evidenceInput(outputDir, overrides = {}) { + return { + outputDir, + manifest: { + experimentId: 'adversarial-fixture', + evidenceLabel: 'SYNTHETIC', + command: 'test', + configuration: {}, + ...(overrides.manifest ?? {}), + }, + samples, + reportInputs: syntheticReportInputs(overrides.reportInputs), + reproduction: 'verify adversarial fixture', + }; +} + const samples = [ - { sampleId: 'run:target-heldout:a', phase: 'evaluation', profile: 'target', caseId: 'a', success: true, latencyMs: 10, inputTokens: 3, outputTokens: 2, providerCostUsd: 0.01, score: 0.9, criticalGatePass: true }, - { sampleId: 'run:clone-heldout:a', phase: 'evaluation', profile: 'clone', caseId: 'a', success: true, latencyMs: 30, inputTokens: 3, outputTokens: 2, providerCostUsd: 0.02, score: 0.7, criticalGatePass: false }, - { sampleId: 'run:distill:1', phase: 'distillation', profile: 'clone', caseId: null, success: false, latencyMs: 5, inputTokens: null, outputTokens: null, providerCostUsd: null, score: null, criticalGatePass: null, failureClass: 'ProviderError' }, + normalizedSample(), + normalizedSample({ + sampleId: 'run:clone-heldout:a', + profile: 'clone', + latencyMs: 30, + providerCostMicroUsd: '20000', + providerCostUsd: 0.02, + score: 0.7, + criticalGatePass: false, + }), + normalizedSample({ + sampleId: 'run:distill:1', + phase: 'distillation', + profile: 'clone', + caseId: null, + success: false, + latencyMs: 5, + inputTokens: null, + outputTokens: null, + providerCostMicroUsd: null, + providerCostUsd: null, + score: null, + criticalGatePass: null, + failureClass: 'ProviderError', + }), ]; test('bundle hashes and summary recompute from normalized samples', (t) => { @@ -23,7 +96,7 @@ test('bundle hashes and summary recompute from normalized samples', (t) => { outputDir: dir, manifest: { experimentId: 'fixture-run', evidenceLabel: 'SYNTHETIC', command: 'npm run sweep:mock' }, samples, - interpretation: 'Synthetic fixture bundle.', + reportInputs: syntheticReportInputs(), reproduction: 'node scripts/verify-bundle.mjs evidence/fixture-run', }); const verified = verifyEvidenceBundle(dir); @@ -39,6 +112,46 @@ test('redaction rejects private payload fields', () => { assert.throws(() => recomputeSummary([{ ...samples[0], prompt: 'private' }]), /forbidden sample field: prompt/); }); +test('sample and configuration schemas reject nested values and secrets', () => { + assert.throws( + () => recomputeSummary([{ ...samples[0], failureClass: { rawResponse: 'private' } }]), + /forbidden evidence field|failureClass must be string or null/, + ); + assert.throws( + () => recomputeSummary([{ ...samples[0], acquisitionEvidence: { authorization: 'private' } }]), + /forbidden evidence field|acquisitionEvidence must be string or null/, + ); + assert.throws( + () => recomputeSummary([{ ...samples[0], failureClass: { code: 'ProviderError' } }]), + /failureClass must be string or null/, + ); + + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-preflight-')); + const outputDir = path.join(parent, 'bundle'); + try { + assert.throws(() => writeEvidenceBundle({ + outputDir, + manifest: { + experimentId: 'nested-config', + evidenceLabel: 'SYNTHETIC', + command: 'test', + configuration: { + publicationGate: { + publishableHighN: false, + suppressionReason: { rawResponse: 'private' }, + }, + }, + }, + samples, + reportInputs: syntheticReportInputs(), + reproduction: 'verify rejected fixture', + }), /forbidden evidence field|publicationGate\.suppressionReason must be string or null/); + assert.equal(fs.existsSync(outputDir), false, 'invalid input must not create an output directory'); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + function rewriteManifestHash(dir, name) { const manifestPath = path.join(dir, 'manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); @@ -57,7 +170,7 @@ function bundle(t) { outputDir: dir, manifest: { experimentId: 'strict-run', evidenceLabel: 'SYNTHETIC', command: 'test' }, samples, - interpretation: 'Strict fixture bundle.', + reportInputs: syntheticReportInputs(), reproduction: 'verify strict fixture', }); return dir; @@ -69,16 +182,57 @@ test('verifier rejects a changed file even when JSON remains parseable', (t) => assert.throws(() => verifyEvidenceBundle(dir), /hash or byte count mismatch: README\.md/); }); +test('verifier requires exactly five regular files and a real directory', (t) => { + const extraDir = bundle(t); + fs.writeFileSync(path.join(extraDir, 'raw-provider.json'), '{}\n'); + assert.throws(() => verifyEvidenceBundle(extraDir), /exactly the five required regular files/); + + const nestedDir = bundle(t); + fs.mkdirSync(path.join(nestedDir, 'raw')); + assert.throws(() => verifyEvidenceBundle(nestedDir), /exactly the five required regular files/); + + const linkedFileDir = bundle(t); + const reportPath = path.join(linkedFileDir, 'report.md'); + const externalReport = path.join(path.dirname(linkedFileDir), `${path.basename(linkedFileDir)}-report.md`); + fs.renameSync(reportPath, externalReport); + fs.symlinkSync(externalReport, reportPath); + t.after(() => fs.rmSync(externalReport, { force: true })); + assert.throws(() => verifyEvidenceBundle(linkedFileDir), /must be a regular file: report\.md/); + + const targetDir = bundle(t); + const linkedDir = `${targetDir}-link`; + fs.symlinkSync(targetDir, linkedDir, 'dir'); + t.after(() => fs.rmSync(linkedDir, { force: true })); + assert.throws(() => verifyEvidenceBundle(linkedDir), /Evidence bundle path must be a real directory/); +}); + +test('verifier validates the exact manifest shape and nested scalar values', (t) => { + const extraFieldDir = bundle(t); + const extraManifestPath = path.join(extraFieldDir, 'manifest.json'); + const extraManifest = JSON.parse(fs.readFileSync(extraManifestPath, 'utf8')); + extraManifest.rawResponse = { private: true }; + fs.writeFileSync(extraManifestPath, `${JSON.stringify(extraManifest, null, 2)}\n`); + assert.throws(() => verifyEvidenceBundle(extraFieldDir), /forbidden evidence field|Evidence manifest has unexpected or missing fields/); + + const nonFiniteDir = bundle(t); + const nonFiniteManifestPath = path.join(nonFiniteDir, 'manifest.json'); + const nonFiniteText = fs.readFileSync(nonFiniteManifestPath, 'utf8') + .replace('"configuration": {}', '"configuration": {"nValues": [1e400]}'); + fs.writeFileSync(nonFiniteManifestPath, nonFiniteText); + assert.throws(() => verifyEvidenceBundle(nonFiniteDir), /finite JSON numbers|configuration\.nValues\[0\] must be a positive safe integer/); +}); + test('verifier rejects duplicate IDs and forbidden fields after a manifest rehash', (t) => { const duplicateDir = bundle(t); - fs.appendFileSync(path.join(duplicateDir, 'samples.jsonl'), `${JSON.stringify(samples[0])}\n`); + const firstLine = fs.readFileSync(path.join(duplicateDir, 'samples.jsonl'), 'utf8').split('\n')[0]; + fs.appendFileSync(path.join(duplicateDir, 'samples.jsonl'), `${firstLine}\n`); rewriteManifestHash(duplicateDir, 'samples.jsonl'); assert.throws(() => verifyEvidenceBundle(duplicateDir), /duplicate sampleId/); const forbiddenDir = bundle(t); const changed = { ...samples[0], prompt: 'private' }; const lines = fs.readFileSync(path.join(forbiddenDir, 'samples.jsonl'), 'utf8').trimEnd().split('\n'); - lines[0] = JSON.stringify(changed); + lines[0] = JSON.stringify(Object.fromEntries(Object.entries(changed).sort(([left], [right]) => left.localeCompare(right)))); fs.writeFileSync(path.join(forbiddenDir, 'samples.jsonl'), `${lines.join('\n')}\n`); rewriteManifestHash(forbiddenDir, 'samples.jsonl'); assert.throws(() => verifyEvidenceBundle(forbiddenDir), /forbidden sample field: prompt/); @@ -97,7 +251,150 @@ test('verifier rejects summary and report numbers changed behind updated hashes' const reportPath = path.join(reportDir, 'report.md'); fs.writeFileSync(reportPath, fs.readFileSync(reportPath, 'utf8').replace('Latency p95 ms: 30', 'Latency p95 ms: 29')); rewriteManifestHash(reportDir, 'report.md'); - assert.throws(() => verifyEvidenceBundle(reportDir), /Latency p95 ms differs/); + assert.throws(() => verifyEvidenceBundle(reportDir), /report\.md differs from deterministic rendering/); + + const duplicateDir = bundle(t); + const duplicateReportPath = path.join(duplicateDir, 'report.md'); + fs.appendFileSync(duplicateReportPath, '- Provider cost USD: 0\n'); + rewriteManifestHash(duplicateDir, 'report.md'); + assert.throws(() => verifyEvidenceBundle(duplicateDir), /report\.md differs from deterministic rendering/); + + const interpretationDir = bundle(t); + const interpretationPath = path.join(interpretationDir, 'report.md'); + fs.writeFileSync( + interpretationPath, + fs.readFileSync(interpretationPath, 'utf8') + .replace('Verdict: HIGH_N_NOT_LIVE', 'Verdict: HIGH_N_PUBLICATION_GATE_PASSED'), + ); + rewriteManifestHash(interpretationDir, 'report.md'); + assert.throws(() => verifyEvidenceBundle(interpretationDir), /report\.md differs from deterministic rendering/); +}); + +test('writer validates all inputs before creating output', () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-no-partial-')); + const outputDir = path.join(parent, 'bundle'); + try { + assert.throws(() => writeEvidenceBundle({ + outputDir, + manifest: { + experimentId: 'invalid-manifest', + recordedAtUtc: 'not-a-timestamp', + evidenceLabel: 'SYNTHETIC', + command: 'test', + }, + samples, + reportInputs: syntheticReportInputs(), + reproduction: 'verify absent fixture', + }), /recordedAtUtc must be an ISO-8601 instant or null/); + assert.equal(fs.existsSync(outputDir), false); + + const invalidCalendarOutput = path.join(parent, 'invalid-calendar'); + assert.throws(() => writeEvidenceBundle(evidenceInput(invalidCalendarOutput, { + manifest: { recordedAtUtc: '2026-02-30T00:00:00Z' }, + })), /recordedAtUtc must be an ISO-8601 instant or null/); + assert.equal(fs.existsSync(invalidCalendarOutput), false); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test('writer rejects every non-portable JSON value before creating output', () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-portable-')); + try { + const cycle = {}; + cycle.self = cycle; + const sparse = []; + sparse[1] = 'value'; + const customPrototype = Object.create({ inherited: true }); + customPrototype.value = 'value'; + const symbolKey = { value: 'value' }; + symbolKey[Symbol('secret')] = 'private'; + const extraArrayProperty = ['value']; + extraArrayProperty.private = 'hidden'; + const invalidValues = [ + NaN, + Infinity, + -Infinity, + 1n, + undefined, + () => {}, + Symbol('value'), + sparse, + cycle, + new Date('2026-07-17T00:00:00Z'), + new Map([['value', 1]]), + customPrototype, + symbolKey, + extraArrayProperty, + ]; + invalidValues.forEach((value, index) => { + const outputDir = path.join(parent, `bundle-${index}`); + const input = evidenceInput(outputDir, { + manifest: { configuration: { evidenceLabels: [value] } }, + }); + assert.throws(() => writeEvidenceBundle(input), /unsupported|finite JSON|cycles|non-plain|sparse arrays|symbol keys|extra array/); + assert.equal(fs.existsSync(outputDir), false); + }); + + const hiddenConfiguration = {}; + Object.defineProperty(hiddenConfiguration, 'authorization', { value: 'private', enumerable: false }); + const hiddenOutput = path.join(parent, 'hidden-property'); + assert.throws( + () => writeEvidenceBundle(evidenceInput(hiddenOutput, { manifest: { configuration: hiddenConfiguration } })), + /non-enumerable|forbidden evidence field/, + ); + assert.equal(fs.existsSync(hiddenOutput), false); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test('writer rejects a symlink output directory without touching its target', (t) => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-output-link-')); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + const target = path.join(parent, 'target'); + const outputDir = path.join(parent, 'bundle'); + fs.mkdirSync(target); + fs.symlinkSync(target, outputDir, 'dir'); + assert.throws(() => writeEvidenceBundle(evidenceInput(outputDir)), /must not be a symlink/); + assert.deepEqual(fs.readdirSync(target), []); +}); + +test('verifier requires exact runtime and file-entry schemas', (t) => { + const runtimeDir = bundle(t); + const runtimeManifestPath = path.join(runtimeDir, 'manifest.json'); + const runtimeManifest = JSON.parse(fs.readFileSync(runtimeManifestPath, 'utf8')); + runtimeManifest.runtime.node = null; + fs.writeFileSync(runtimeManifestPath, `${JSON.stringify(runtimeManifest, null, 2)}\n`); + assert.throws(() => verifyEvidenceBundle(runtimeDir), /manifest\.runtime\.node must be a non-empty string/); + + const filesDir = bundle(t); + const filesManifestPath = path.join(filesDir, 'manifest.json'); + const filesManifest = JSON.parse(fs.readFileSync(filesManifestPath, 'utf8')); + filesManifest.files['report.md'].contentType = 'text/markdown'; + fs.writeFileSync(filesManifestPath, `${JSON.stringify(filesManifest, null, 2)}\n`); + assert.throws(() => verifyEvidenceBundle(filesDir), /manifest\.files\.report\.md has unexpected or missing fields/); +}); + +test('verifier rejects a coordinated false publication claim even when report hash is updated', (t) => { + const dir = bundle(t); + const manifestPath = path.join(dir, 'manifest.json'); + const reportPath = path.join(dir, 'report.md'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.configuration.publicationGate = { publishableHighN: true, suppressionReason: null }; + manifest.reportInputs.verdict = 'HIGH_N_PUBLICATION_GATE_PASSED'; + manifest.reportInputs.suppressionReason = null; + const report = fs.readFileSync(reportPath, 'utf8') + .replace('Verdict: HIGH_N_NOT_LIVE', 'Verdict: HIGH_N_PUBLICATION_GATE_PASSED') + .replace('Suppression reason: HIGH_N_NOT_LIVE', 'Suppression reason: none'); + fs.writeFileSync(reportPath, report); + const bytes = fs.readFileSync(reportPath); + manifest.files['report.md'] = { + sha256: createHash('sha256').update(bytes).digest('hex'), + bytes: bytes.length, + }; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + assert.throws(() => verifyEvidenceBundle(dir), /publication gate passed evidence label|SYNTHETIC.*publication/i); }); test('sweep attempts normalize without request payload or output bytes', () => { @@ -161,14 +458,6 @@ test('completed sweep output writes and verifies through the public bundle seam' publishableHighN: false, suppressionReason: 'HIGH_N_NOT_LIVE', }; - const config = { - schemaVersion: 1, - experimentFamily: 'clone-economics-high-n-v1', - fixtureSet: 'v2', - nValues: [6, 25, 50, 100], - replicates: [], - acquisitionTreatment: 'modeled_unless_x402_receipts_attached', - }; const written = await writeSweepEvidenceBundle({ result, config, @@ -197,7 +486,7 @@ test('live bundle authorization recomputes from hash-verified budget and economi outputDir, manifest: { experimentId: 'live-fixture', - evidenceLabel: 'LIVE CANDIDATE', + evidenceLabel: 'SYNTHETIC', command: 'synthetic live verifier fixture', liveBudget: { snapshotPath: 'fixtures/live-budget-v1.json', @@ -215,12 +504,8 @@ test('live bundle authorization recomputes from hash-verified budget and economi }, configuration: { sweepConfig: config, liveEconomics: economics }, }, - samples: [{ + samples: [normalizedSample({ sampleId: 'live:target-heldout:a', - phase: 'evaluation', - profile: 'target', - caseId: 'a', - success: true, latencyMs: 1, inputTokens: 3, outputTokens: 2, @@ -228,8 +513,8 @@ test('live bundle authorization recomputes from hash-verified budget and economi providerCostUsd: 0.000039, score: 1, criticalGatePass: true, - }], - interpretation: 'Synthetic live verifier fixture.', + })], + reportInputs: syntheticReportInputs(), reproduction: 'verify live fixture', }); assert.equal(verifyEvidenceBundle(outputDir).valid, true); diff --git a/spikes/clone-economics/tests/sweep.test.mjs b/spikes/clone-economics/tests/sweep.test.mjs index c6671e4..6367fa5 100644 --- a/spikes/clone-economics/tests/sweep.test.mjs +++ b/spikes/clone-economics/tests/sweep.test.mjs @@ -27,6 +27,12 @@ const config = { { replicateId: 'r3', pairOrderSeed: 1703, distillationSeed: 2703 }, ], highNDefinition: 100, + targetThreshold: 0.8, + requireAllTargetCriticalGates: true, + acquisitionTreatment: 'modeled_unless_x402_receipts_attached', + attemptCostTreatment: 'include_every_attempted_provider_call', + publicationRequiresValidTarget: true, + publicationRequiresIndependentDistillationSeeds: true, }; test('sweep contract requires the exact preregistered dimensions', () => { From d139c92fa81526f6a77bfa8aa089fea57e8fefb4 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 17 Jul 2026 23:43:13 -0400 Subject: [PATCH 046/165] fix: close clone evidence text channels --- .../scripts/import-legacy-run.mjs | 2 +- spikes/clone-economics/src/evidence.mjs | 118 +++++++++++---- spikes/clone-economics/src/sweep.mjs | 4 +- spikes/clone-economics/sweep.mjs | 4 +- .../clone-economics/tests/evidence.test.mjs | 135 ++++++++++++++++-- spikes/clone-economics/tests/sweep.test.mjs | 1 + 6 files changed, 220 insertions(+), 44 deletions(-) diff --git a/spikes/clone-economics/scripts/import-legacy-run.mjs b/spikes/clone-economics/scripts/import-legacy-run.mjs index d99566b..b46a998 100644 --- a/spikes/clone-economics/scripts/import-legacy-run.mjs +++ b/spikes/clone-economics/scripts/import-legacy-run.mjs @@ -137,6 +137,7 @@ export function importLegacyRun(argv) { modelProvider: 'Anthropic', model: source.usage.raw[0]?.model ?? null, evidenceLabel: 'HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED', + readmeInputs: { bundlePath: 'evidence/2026-07-12-n6-invalid' }, sourceEvidence: { kind: 'legacy-report-json', sha256: LEGACY_SOURCE_SHA256, @@ -161,7 +162,6 @@ export function importLegacyRun(argv) { suppressionReason: 'INVALID_BENCHMARK_TARGET_FAILED', limitations: ['ACQUISITION_MODELED', 'HISTORICAL_ATTEMPTS_INCOMPLETE'], }, - reproduction: 'node scripts/verify-bundle.mjs evidence/2026-07-12-n6-invalid', }); return { output: args.output, samples: samples.length }; } diff --git a/spikes/clone-economics/src/evidence.mjs b/spikes/clone-economics/src/evidence.mjs index da112aa..eacc19e 100644 --- a/spikes/clone-economics/src/evidence.mjs +++ b/spikes/clone-economics/src/evidence.mjs @@ -15,6 +15,17 @@ const SAMPLE_KEYS = new Set([ 'acquisitionCostUsd', 'acquisitionEvidence', 'score', 'criticalGatePass', 'failureClass', 'providerRequestId', ]); +const SAMPLE_PHASES = new Set(['acquisition', 'distillation', 'evaluation']); +const SAMPLE_PROFILES = new Set(['target', 'clone', 'bad-clone']); +const DISTILLATION_SEED_STATUSES = new Set([ + 'not_requested', 'synthetic_honored', 'honored', 'unsupported', 'not_recorded', +]); +const DISTILLATION_SEED_MECHANISMS = new Set([ + 'no_seed_requested', + 'deterministic_mock_fixture_selection', + 'provider_seed_not_supported_by_adapter', + 'historical_source_not_recorded', +]); const FORBIDDEN_KEYS = new Set([ 'prompt', 'payload', 'output', 'rawResponse', 'apiKey', 'authorization', 'headers', 'skillText', 'targetSkill', 'targetSkillText', 'referenceText', @@ -31,12 +42,12 @@ const REQUIRED_BUNDLE_FILES = ['samples.jsonl', 'summary.json', 'report.md', 'RE const ALL_BUNDLE_FILES = [...REQUIRED_BUNDLE_FILES, 'manifest.json']; const MANIFEST_INPUT_KEYS = [ 'experimentId', 'recordedAtUtc', 'gitCommit', 'command', 'modelProvider', 'model', - 'evidenceLabel', 'sourceEvidence', 'liveBudget', 'configuration', + 'evidenceLabel', 'sourceEvidence', 'liveBudget', 'configuration', 'readmeInputs', ]; const MANIFEST_KEYS = [ 'schemaVersion', 'experimentId', 'recordedAtUtc', 'gitCommit', 'command', 'runtime', 'modelProvider', 'model', 'evidenceLabel', 'sourceEvidence', 'liveBudget', - 'configuration', 'reportInputs', 'files', + 'configuration', 'reportInputs', 'readmeInputs', 'files', ]; const REPORT_INPUT_KEYS = ['evidenceLabel', 'verdict', 'suppressionReason', 'limitations']; const EVIDENCE_LABELS = new Set([ @@ -65,6 +76,21 @@ const LIMITATIONS = new Set([ ]); const rounded = (value) => Number(value.toFixed(12)); const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const SENSITIVE_VALUE_TOKENS = new Set([ + 'apikey', 'authorization', 'authorisation', 'bearer', 'header', 'headers', + 'raw', 'rawresponse', 'rawpayload', 'rawoutput', 'tmp', 'temp', 'temporary', + 'path', 'paths', +]); + +function containsSensitiveValue(value) { + const tokens = value + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((token) => token.toLowerCase()); + if (tokens.some((token) => SENSITIVE_VALUE_TOKENS.has(token))) return true; + return tokens.some((token, index) => token === 'api' && tokens[index + 1] === 'key'); +} function assertExactKeys(value, keys, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -77,8 +103,15 @@ function assertExactKeys(value, keys, label) { } } -function assertPortableJson(value, label = 'evidence input', active = new WeakSet()) { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return; +function assertPortableJson(value, label = 'evidence input', active = new WeakSet(), checkSensitive = true) { + if (value === null || typeof value === 'boolean') return; + if (typeof value === 'string') { + if (value.length > 4096) throw new Error(`${label} exceeds the evidence string length limit`); + if (checkSensitive && containsSensitiveValue(value)) { + throw new Error(`${label} contains a sensitive evidence value`); + } + return; + } if (typeof value === 'number') { if (!Number.isFinite(value)) throw new Error(`${label} must contain only finite JSON numbers`); return; @@ -102,7 +135,7 @@ function assertPortableJson(value, label = 'evidence input', active = new WeakSe throw new Error(`${label} must not contain sparse arrays or extra array properties`); } for (let index = 0; index < value.length; index += 1) { - assertPortableJson(value[index], `${label}[${index}]`, active); + assertPortableJson(value[index], `${label}[${index}]`, active, checkSensitive); } return; } @@ -119,7 +152,7 @@ function assertPortableJson(value, label = 'evidence input', active = new WeakSe throw new Error(`${label}.${key} must be a data property`); } if (FORBIDDEN_KEYS.has(key)) throw new Error(`forbidden evidence field: ${label}.${key}`); - assertPortableJson(descriptor.value, `${label}.${key}`, active); + assertPortableJson(descriptor.value, `${label}.${key}`, active, checkSensitive); } } finally { active.delete(value); @@ -144,6 +177,15 @@ function finiteNonNegative(value, label, { nullable = false } = {}) { } } +function safeIdentifier(value, label, maxLength) { + if (typeof value !== 'string' + || value.length === 0 + || value.length > maxLength + || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)) { + throw new Error(`${label} must be a bounded safe identifier`); + } +} + function validateSample(sample) { if (!sample || typeof sample !== 'object' || Array.isArray(sample)) throw new Error('Sample must be an object'); for (const key of Object.keys(sample)) { @@ -152,16 +194,24 @@ function validateSample(sample) { } assertPortableJson(sample, 'sample'); assertExactKeys(sample, SAMPLE_KEYS, 'Sample'); - if (typeof sample.sampleId !== 'string' || sample.sampleId.trim() === '') throw new Error('sampleId is required'); - if (typeof sample.phase !== 'string' || sample.phase.trim() === '') throw new Error('sample phase is required'); - if (typeof sample.profile !== 'string' || sample.profile.trim() === '') throw new Error('sample profile is required'); - for (const key of ['caseId', 'replicateId', 'acquisitionEvidence', 'failureClass', 'providerRequestId']) { - if (!(sample[key] === null || (typeof sample[key] === 'string' && sample[key] !== ''))) { - throw new Error(`${key} must be string or null`); - } + safeIdentifier(sample.sampleId, 'sampleId', 256); + if (!SAMPLE_PHASES.has(sample.phase)) throw new Error('sample phase is unsupported'); + if (!SAMPLE_PROFILES.has(sample.profile)) throw new Error('sample profile is unsupported'); + if (sample.caseId !== null) safeIdentifier(sample.caseId, 'caseId', 128); + if (sample.replicateId !== null) safeIdentifier(sample.replicateId, 'replicateId', 64); + if (sample.providerRequestId !== null) safeIdentifier(sample.providerRequestId, 'providerRequestId', 128); + if (sample.failureClass !== null + && !(typeof sample.failureClass === 'string' && /^[A-Z][A-Za-z0-9]{0,63}$/.test(sample.failureClass))) { + throw new Error('failureClass must be an error-class token or null'); + } + if (!(sample.acquisitionEvidence === null || sample.acquisitionEvidence === 'MODELED')) { + throw new Error('acquisitionEvidence is unsupported'); } - for (const key of ['distillationSeedStatus', 'distillationSeedMechanism']) { - if (typeof sample[key] !== 'string' || sample[key] === '') throw new Error(`${key} must be a non-empty string`); + if (!DISTILLATION_SEED_STATUSES.has(sample.distillationSeedStatus)) { + throw new Error('distillationSeedStatus is unsupported'); + } + if (!DISTILLATION_SEED_MECHANISMS.has(sample.distillationSeedMechanism)) { + throw new Error('distillationSeedMechanism is unsupported'); } if (typeof sample.success !== 'boolean') throw new Error('sample success must be boolean'); finiteNonNegative(sample.latencyMs, 'sample latencyMs'); @@ -553,13 +603,27 @@ function renderReport(summary, reportInputs) { `; } -function renderReadme(reproduction) { +function validateReadmeInputs(readmeInputs) { + assertPortableJson(readmeInputs, 'readmeInputs'); + assertExactKeys(readmeInputs, ['bundlePath'], 'readmeInputs'); + const { bundlePath } = readmeInputs; + if (typeof bundlePath !== 'string' + || bundlePath.length > 240 + || !/^(?:evidence|runs\/mock-sweep\/evidence)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(bundlePath) + || bundlePath.includes('//') + || bundlePath.split('/').includes('..')) { + throw new Error('readmeInputs.bundlePath must be a safe repository-relative evidence path'); + } + return { bundlePath }; +} + +function renderReadme(readmeInputs) { return `# Evidence bundle Verify and reproduce: \`\`\`bash -${reproduction} +node scripts/verify-bundle.mjs ${readmeInputs.bundlePath} \`\`\` Samples are normalized and allow-listed. Prompt payloads, output text, API keys, @@ -587,6 +651,7 @@ function validateManifest(manifest) { validateLiveBudget(manifest.liveBudget); const configuration = validateConfiguration(manifest.configuration); validateReportInputs(manifest.reportInputs, manifest.evidenceLabel, configuration); + validateReadmeInputs(manifest.readmeInputs); assertExactKeys(manifest.files, REQUIRED_BUNDLE_FILES, 'manifest.files'); for (const name of REQUIRED_BUNDLE_FILES) { const file = manifest.files[name]; @@ -613,14 +678,13 @@ function validateOutputDirectory(outputDir) { } export function writeEvidenceBundle(input) { - assertPortableJson(input, 'writer input'); - assertExactKeys(input, ['outputDir', 'manifest', 'samples', 'reportInputs', 'reproduction'], 'Evidence writer input'); + assertPortableJson(input, 'writer input', new WeakSet(), false); + assertExactKeys(input, ['outputDir', 'manifest', 'samples', 'reportInputs'], 'Evidence writer input'); const { outputDir, manifest, samples, reportInputs, - reproduction, } = input; if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { throw new Error('Evidence manifest input must be an object'); @@ -628,19 +692,19 @@ export function writeEvidenceBundle(input) { for (const key of Object.keys(manifest)) { if (!MANIFEST_INPUT_KEYS.includes(key)) throw new Error(`Unsupported evidence manifest field: ${key}`); } + assertPortableJson(manifest, 'manifest input'); nonEmptyString(manifest.experimentId, 'manifest.experimentId'); nonEmptyString(manifest.command, 'manifest.command'); if (!EVIDENCE_LABELS.has(manifest.evidenceLabel)) throw new Error('Evidence manifest evidenceLabel is unsupported'); for (const key of ['gitCommit', 'modelProvider', 'model']) { if (Object.hasOwn(manifest, key)) nonEmptyString(manifest[key], `manifest.${key}`, { nullable: key !== 'gitCommit' }); } - nonEmptyString(reproduction, 'Evidence reproduction command'); - if (reproduction.includes('```')) throw new Error('Evidence reproduction command must not contain a code-fence delimiter'); const recordedAtUtc = manifest.recordedAtUtc === undefined ? new Date().toISOString() : manifest.recordedAtUtc; validateRecordedAtUtc(recordedAtUtc); const sourceEvidence = validateSourceEvidence(manifest.sourceEvidence); const liveBudget = validateLiveBudget(manifest.liveBudget); const configuration = validateConfiguration(manifest.configuration); + const validatedReadmeInputs = validateReadmeInputs(manifest.readmeInputs); if (recordedAtUtc === null && !(configuration.historicalRunDate && configuration.sourceTimestamp === 'not-recorded')) { throw new Error('A null recordedAtUtc requires a historical date and sourceTimestamp not-recorded'); @@ -652,7 +716,7 @@ export function writeEvidenceBundle(input) { 'samples.jsonl': `${samples.map(stableLine).join('\n')}\n`, 'summary.json': stableJson(summary), 'report.md': renderReport(summary, validatedReportInputs), - 'README.md': renderReadme(reproduction), + 'README.md': renderReadme(validatedReadmeInputs), }; const finalManifest = { schemaVersion: 1, @@ -668,6 +732,7 @@ export function writeEvidenceBundle(input) { liveBudget, configuration, reportInputs: validatedReportInputs, + readmeInputs: validatedReadmeInputs, files: Object.fromEntries(REQUIRED_BUNDLE_FILES.map((name) => [name, { sha256: sha256(contents[name]), bytes: Buffer.byteLength(contents[name]), @@ -755,7 +820,9 @@ export function verifyEvidenceBundle(dir) { if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Evidence bundle entry must be a regular file: ${name}`); } const manifestPath = path.join(dir, 'manifest.json'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const manifestText = fs.readFileSync(manifestPath, 'utf8'); + const manifest = JSON.parse(manifestText); + if (manifestText !== stableJson(manifest)) throw new Error('manifest.json must use canonical JSON'); validateManifest(manifest); for (const name of REQUIRED_BUNDLE_FILES) { const filePath = path.join(dir, name); @@ -781,6 +848,9 @@ export function verifyEvidenceBundle(dir) { if (fs.readFileSync(path.join(dir, 'report.md'), 'utf8') !== renderReport(summary, manifest.reportInputs)) { throw new Error('report.md differs from deterministic rendering'); } + if (fs.readFileSync(path.join(dir, 'README.md'), 'utf8') !== renderReadme(manifest.readmeInputs)) { + throw new Error('README.md differs from deterministic rendering'); + } verifyLiveRows(samples, manifest, dir); return { valid: true, manifest, summary, samples }; } diff --git a/spikes/clone-economics/src/sweep.mjs b/spikes/clone-economics/src/sweep.mjs index 555b996..6cca356 100644 --- a/spikes/clone-economics/src/sweep.mjs +++ b/spikes/clone-economics/src/sweep.mjs @@ -247,7 +247,7 @@ export async function writeSweepEvidenceBundle({ model = null, liveBudget = null, liveEconomics = null, - reproduction = null, + readmeInputs, }) { const { verifyEvidenceBundle, writeEvidenceBundle } = await import('./evidence.mjs'); const samples = normalizeSweepSamples({ experimentId, attempts: result.samples }); @@ -271,6 +271,7 @@ export async function writeSweepEvidenceBundle({ model, evidenceLabel, liveBudget, + readmeInputs, configuration: { sweepConfig: config, fixtureSet: config.fixtureSet, @@ -289,7 +290,6 @@ export async function writeSweepEvidenceBundle({ suppressionReason: result.publishableHighN ? null : verdict, limitations, }, - reproduction: reproduction ?? `node scripts/verify-bundle.mjs ${outputDir}`, }); return { manifest, verified: verifyEvidenceBundle(outputDir) }; } diff --git a/spikes/clone-economics/sweep.mjs b/spikes/clone-economics/sweep.mjs index 1167062..33469db 100644 --- a/spikes/clone-economics/sweep.mjs +++ b/spikes/clone-economics/sweep.mjs @@ -82,7 +82,7 @@ async function main() { experimentId, evidenceLabel: 'SYNTHETIC', command: 'npm run sweep:mock', - reproduction: `node scripts/verify-bundle.mjs ${evidenceRelative}`, + readmeInputs: { bundlePath: evidenceRelative }, }); console.log(`cells complete: ${result.cells.filter((cell) => cell.status === 'complete').length}/${result.cells.length}`); console.log(`publishable high-N: ${result.publishableHighN}`); @@ -143,7 +143,7 @@ async function main() { outstandingReservedMicroUsd: live.budgetState.outstandingReservedMicroUsd.toString(), lock: live.budgetState.lock, }, - reproduction: `node scripts/verify-bundle.mjs ${evidenceRelative}`, + readmeInputs: { bundlePath: evidenceRelative }, }); console.log(`live authorization: ${live.authorizationHash}`); console.log(`attempted calls: ${live.budgetState.attemptedCalls}`); diff --git a/spikes/clone-economics/tests/evidence.test.mjs b/spikes/clone-economics/tests/evidence.test.mjs index 3a32884..0d46a83 100644 --- a/spikes/clone-economics/tests/evidence.test.mjs +++ b/spikes/clone-economics/tests/evidence.test.mjs @@ -45,6 +45,8 @@ const syntheticReportInputs = (overrides = {}) => ({ ...overrides, }); +const readmeInputs = (bundlePath = 'evidence/adversarial-fixture') => ({ bundlePath }); + function evidenceInput(outputDir, overrides = {}) { return { outputDir, @@ -53,11 +55,11 @@ function evidenceInput(outputDir, overrides = {}) { evidenceLabel: 'SYNTHETIC', command: 'test', configuration: {}, + readmeInputs: readmeInputs(), ...(overrides.manifest ?? {}), }, samples, reportInputs: syntheticReportInputs(overrides.reportInputs), - reproduction: 'verify adversarial fixture', }; } @@ -94,10 +96,14 @@ test('bundle hashes and summary recompute from normalized samples', (t) => { t.after(() => fs.rmSync(dir, { recursive: true, force: true })); writeEvidenceBundle({ outputDir: dir, - manifest: { experimentId: 'fixture-run', evidenceLabel: 'SYNTHETIC', command: 'npm run sweep:mock' }, + manifest: { + experimentId: 'fixture-run', + evidenceLabel: 'SYNTHETIC', + command: 'npm run sweep:mock', + readmeInputs: readmeInputs('evidence/fixture-run'), + }, samples, reportInputs: syntheticReportInputs(), - reproduction: 'node scripts/verify-bundle.mjs evidence/fixture-run', }); const verified = verifyEvidenceBundle(dir); assert.equal(verified.valid, true); @@ -123,7 +129,7 @@ test('sample and configuration schemas reject nested values and secrets', () => ); assert.throws( () => recomputeSummary([{ ...samples[0], failureClass: { code: 'ProviderError' } }]), - /failureClass must be string or null/, + /failureClass must be an error-class token or null/, ); const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-preflight-')); @@ -135,6 +141,7 @@ test('sample and configuration schemas reject nested values and secrets', () => experimentId: 'nested-config', evidenceLabel: 'SYNTHETIC', command: 'test', + readmeInputs: readmeInputs('evidence/nested-config'), configuration: { publicationGate: { publishableHighN: false, @@ -144,7 +151,6 @@ test('sample and configuration schemas reject nested values and secrets', () => }, samples, reportInputs: syntheticReportInputs(), - reproduction: 'verify rejected fixture', }), /forbidden evidence field|publicationGate\.suppressionReason must be string or null/); assert.equal(fs.existsSync(outputDir), false, 'invalid input must not create an output directory'); } finally { @@ -152,6 +158,18 @@ test('sample and configuration schemas reject nested values and secrets', () => } }); +function canonicalizeForTest(value) { + if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return value; + if (Array.isArray(value)) return value.map(canonicalizeForTest); + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalizeForTest(value[key])]), + ); +} + +function writeCanonicalJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(canonicalizeForTest(value), null, 2)}\n`); +} + function rewriteManifestHash(dir, name) { const manifestPath = path.join(dir, 'manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); @@ -160,7 +178,7 @@ function rewriteManifestHash(dir, name) { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length, }; - fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + writeCanonicalJson(manifestPath, manifest); } function bundle(t) { @@ -168,10 +186,14 @@ function bundle(t) { t.after(() => fs.rmSync(dir, { recursive: true, force: true })); writeEvidenceBundle({ outputDir: dir, - manifest: { experimentId: 'strict-run', evidenceLabel: 'SYNTHETIC', command: 'test' }, + manifest: { + experimentId: 'strict-run', + evidenceLabel: 'SYNTHETIC', + command: 'test', + readmeInputs: readmeInputs('evidence/strict-run'), + }, samples, reportInputs: syntheticReportInputs(), - reproduction: 'verify strict fixture', }); return dir; } @@ -211,7 +233,7 @@ test('verifier validates the exact manifest shape and nested scalar values', (t) const extraManifestPath = path.join(extraFieldDir, 'manifest.json'); const extraManifest = JSON.parse(fs.readFileSync(extraManifestPath, 'utf8')); extraManifest.rawResponse = { private: true }; - fs.writeFileSync(extraManifestPath, `${JSON.stringify(extraManifest, null, 2)}\n`); + writeCanonicalJson(extraManifestPath, extraManifest); assert.throws(() => verifyEvidenceBundle(extraFieldDir), /forbidden evidence field|Evidence manifest has unexpected or missing fields/); const nonFiniteDir = bundle(t); @@ -219,7 +241,16 @@ test('verifier validates the exact manifest shape and nested scalar values', (t) const nonFiniteText = fs.readFileSync(nonFiniteManifestPath, 'utf8') .replace('"configuration": {}', '"configuration": {"nValues": [1e400]}'); fs.writeFileSync(nonFiniteManifestPath, nonFiniteText); - assert.throws(() => verifyEvidenceBundle(nonFiniteDir), /finite JSON numbers|configuration\.nValues\[0\] must be a positive safe integer/); + assert.throws(() => verifyEvidenceBundle(nonFiniteDir), /canonical JSON|finite JSON numbers|configuration\.nValues\[0\] must be a positive safe integer/); +}); + +test('verifier rejects duplicate manifest keys and noncanonical manifest bytes', (t) => { + const dir = bundle(t); + const manifestPath = path.join(dir, 'manifest.json'); + const manifest = fs.readFileSync(manifestPath, 'utf8') + .replace('{\n', '{\n "command": "authorization: Bearer private",\n'); + fs.writeFileSync(manifestPath, manifest); + assert.throws(() => verifyEvidenceBundle(dir), /manifest\.json must use canonical JSON/); }); test('verifier rejects duplicate IDs and forbidden fields after a manifest rehash', (t) => { @@ -270,6 +301,79 @@ test('verifier rejects summary and report numbers changed behind updated hashes' assert.throws(() => verifyEvidenceBundle(interpretationDir), /report\.md differs from deterministic rendering/); }); +test('verifier rejects a rehashed README publication claim', (t) => { + const dir = bundle(t); + const readmePath = path.join(dir, 'README.md'); + fs.appendFileSync(readmePath, '\nThis bundle is approved for publication.\n'); + rewriteManifestHash(dir, 'README.md'); + assert.throws(() => verifyEvidenceBundle(dir), /README\.md differs from deterministic rendering/); +}); + +test('readmeInputs accepts only the closed repository-relative verifier command', () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-readme-inputs-')); + try { + const cases = [ + { bundlePath: 'docs/not-evidence' }, + { bundlePath: '/tmp/raw-evidence' }, + { bundlePath: '../evidence/run' }, + { bundlePath: 'evidence/run', note: 'publishable' }, + ]; + cases.forEach((value, index) => { + const outputDir = path.join(parent, `bundle-${index}`); + assert.throws( + () => writeEvidenceBundle(evidenceInput(outputDir, { manifest: { readmeInputs: value } })), + /readmeInputs|verifier command|repository-relative|unexpected or missing fields/, + ); + assert.equal(fs.existsSync(outputDir), false); + }); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test('sample scalar channels reject unsupported enums, unsafe IDs, and sensitive values', () => { + const mutations = [ + { phase: 'raw' }, + { profile: 'administrator' }, + { distillationSeedStatus: 'forged' }, + { distillationSeedMechanism: 'custom_header_authorization' }, + { acquisitionEvidence: 'RAW' }, + { sampleId: '../tmp/raw-response' }, + { caseId: '/private/tmp/case' }, + { replicateId: 'replicate with spaces' }, + { failureClass: 'Error: authorization Bearer private' }, + { providerRequestId: 'x-api-key=private' }, + ]; + for (const mutation of mutations) { + assert.throws( + () => recomputeSummary([normalizedSample(mutation)]), + /unsupported|safe identifier|error-class token|sensitive evidence value/, + ); + } +}); + +test('manifest and configuration string values reject secret and raw path markers', () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-sensitive-values-')); + try { + const cases = [ + { model: 'authorization=Bearer private' }, + { modelProvider: 'x-api-key private' }, + { model: 'rawResponse=private' }, + { configuration: { attemptCoverage: 'raw response retained at /tmp/private' } }, + ]; + cases.forEach((manifest, index) => { + const outputDir = path.join(parent, `bundle-${index}`); + assert.throws( + () => writeEvidenceBundle(evidenceInput(outputDir, { manifest })), + /sensitive evidence value/, + ); + assert.equal(fs.existsSync(outputDir), false); + }); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + test('writer validates all inputs before creating output', () => { const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-no-partial-')); const outputDir = path.join(parent, 'bundle'); @@ -281,10 +385,10 @@ test('writer validates all inputs before creating output', () => { recordedAtUtc: 'not-a-timestamp', evidenceLabel: 'SYNTHETIC', command: 'test', + readmeInputs: readmeInputs('evidence/invalid-manifest'), }, samples, reportInputs: syntheticReportInputs(), - reproduction: 'verify absent fixture', }), /recordedAtUtc must be an ISO-8601 instant or null/); assert.equal(fs.existsSync(outputDir), false); @@ -365,14 +469,14 @@ test('verifier requires exact runtime and file-entry schemas', (t) => { const runtimeManifestPath = path.join(runtimeDir, 'manifest.json'); const runtimeManifest = JSON.parse(fs.readFileSync(runtimeManifestPath, 'utf8')); runtimeManifest.runtime.node = null; - fs.writeFileSync(runtimeManifestPath, `${JSON.stringify(runtimeManifest, null, 2)}\n`); + writeCanonicalJson(runtimeManifestPath, runtimeManifest); assert.throws(() => verifyEvidenceBundle(runtimeDir), /manifest\.runtime\.node must be a non-empty string/); const filesDir = bundle(t); const filesManifestPath = path.join(filesDir, 'manifest.json'); const filesManifest = JSON.parse(fs.readFileSync(filesManifestPath, 'utf8')); filesManifest.files['report.md'].contentType = 'text/markdown'; - fs.writeFileSync(filesManifestPath, `${JSON.stringify(filesManifest, null, 2)}\n`); + writeCanonicalJson(filesManifestPath, filesManifest); assert.throws(() => verifyEvidenceBundle(filesDir), /manifest\.files\.report\.md has unexpected or missing fields/); }); @@ -393,7 +497,7 @@ test('verifier rejects a coordinated false publication claim even when report ha sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length, }; - fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + writeCanonicalJson(manifestPath, manifest); assert.throws(() => verifyEvidenceBundle(dir), /publication gate passed evidence label|SYNTHETIC.*publication/i); }); @@ -465,6 +569,7 @@ test('completed sweep output writes and verifies through the public bundle seam' experimentId: 'sweep-bundle-fixture', evidenceLabel: 'SYNTHETIC', command: 'npm run sweep:mock', + readmeInputs: readmeInputs('evidence/sweep-bundle-fixture'), }); assert.equal(written.verified.valid, true); assert.equal(written.verified.samples.length, 1); @@ -488,6 +593,7 @@ test('live bundle authorization recomputes from hash-verified budget and economi experimentId: 'live-fixture', evidenceLabel: 'SYNTHETIC', command: 'synthetic live verifier fixture', + readmeInputs: readmeInputs('evidence/live-fixture'), liveBudget: { snapshotPath: 'fixtures/live-budget-v1.json', snapshotSha256: digest(snapshotBytes), @@ -515,7 +621,6 @@ test('live bundle authorization recomputes from hash-verified budget and economi criticalGatePass: true, })], reportInputs: syntheticReportInputs(), - reproduction: 'verify live fixture', }); assert.equal(verifyEvidenceBundle(outputDir).valid, true); }); diff --git a/spikes/clone-economics/tests/sweep.test.mjs b/spikes/clone-economics/tests/sweep.test.mjs index 6367fa5..038be6a 100644 --- a/spikes/clone-economics/tests/sweep.test.mjs +++ b/spikes/clone-economics/tests/sweep.test.mjs @@ -235,6 +235,7 @@ test('standalone target execution failure preserves its attempt and final budget experimentId: 'failed-target-fixture', evidenceLabel: 'SYNTHETIC FAILED TARGET', command: 'synthetic failed-target test', + readmeInputs: { bundlePath: 'evidence/failed-target-fixture' }, }); assert.equal(evidence.verified.valid, true); assert.equal(evidence.verified.samples.length, 1); From b639ee4f4428ae30c577e2fbde5e366a88172c4c Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 00:02:50 -0400 Subject: [PATCH 047/165] fix: bind live evidence to execution state --- .../scripts/import-legacy-run.mjs | 3 + spikes/clone-economics/src/adapters.mjs | 3 + spikes/clone-economics/src/evidence.mjs | 373 +++++++++++++++--- spikes/clone-economics/src/git-state.mjs | 70 ++++ spikes/clone-economics/src/sweep.mjs | 9 +- spikes/clone-economics/sweep.mjs | 11 + .../tests/adapters-budget.test.mjs | 5 + .../clone-economics/tests/evidence.test.mjs | 187 ++++++--- .../clone-economics/tests/git-state.test.mjs | 75 ++++ .../tests/import-legacy-run.test.mjs | 1 + .../tests/live-evidence-accounting.test.mjs | 238 +++++++++++ .../clone-economics/tests/sweep-cli.test.mjs | 7 +- spikes/clone-economics/tests/sweep.test.mjs | 6 + 13 files changed, 885 insertions(+), 103 deletions(-) create mode 100644 spikes/clone-economics/src/git-state.mjs create mode 100644 spikes/clone-economics/tests/git-state.test.mjs create mode 100644 spikes/clone-economics/tests/live-evidence-accounting.test.mjs diff --git a/spikes/clone-economics/scripts/import-legacy-run.mjs b/spikes/clone-economics/scripts/import-legacy-run.mjs index b46a998..921a220 100644 --- a/spikes/clone-economics/scripts/import-legacy-run.mjs +++ b/spikes/clone-economics/scripts/import-legacy-run.mjs @@ -89,6 +89,7 @@ export function normalizeLegacyReport(source) { criticalGatePass: score?.criticalGatePass ?? null, failureClass: null, providerRequestId: record.requestId ?? null, + budgetAttemptId: null, }; }); } @@ -131,8 +132,10 @@ export function importLegacyRun(argv) { outputDir: args.output, manifest: { experimentId: '2026-07-12-n6-invalid', + executionMode: 'historical', recordedAtUtc: null, gitCommit: 'historical-source-not-recorded', + gitDirty: null, command: 'historical live command not retained exactly', modelProvider: 'Anthropic', model: source.usage.raw[0]?.model ?? null, diff --git a/spikes/clone-economics/src/adapters.mjs b/spikes/clone-economics/src/adapters.mjs index ce97ea4..f8b4874 100644 --- a/spikes/clone-economics/src/adapters.mjs +++ b/spikes/clone-economics/src/adapters.mjs @@ -113,6 +113,7 @@ export class MockLlmAdapter { this.records.push(record); this.attempts.push({ attemptId: `${request.kind}:${request.caseId ?? 'distill'}:${this.attempts.length + 1}`, + budgetAttemptId: null, kind: request.kind, caseId: request.caseId ?? null, success: true, @@ -283,6 +284,7 @@ export class LiveAnthropicAdapter { this.records.push(record); this.attempts.push({ attemptId: `${request.kind}:${request.caseId ?? 'distill'}:${this.attempts.length + 1}`, + budgetAttemptId: reservationId, kind: request.kind, caseId: request.caseId ?? null, success: true, @@ -327,6 +329,7 @@ export class LiveAnthropicAdapter { const latencyMs = performance.now() - started; this.attempts.push({ attemptId: `${request.kind}:${request.caseId ?? 'distill'}:${this.attempts.length + 1}`, + budgetAttemptId: reservationId, kind: request.kind, caseId: request.caseId ?? null, success: false, diff --git a/spikes/clone-economics/src/evidence.mjs b/spikes/clone-economics/src/evidence.mjs index eacc19e..af10ee3 100644 --- a/spikes/clone-economics/src/evidence.mjs +++ b/spikes/clone-economics/src/evidence.mjs @@ -1,10 +1,22 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { liveAuthorizationHash } from './authorization.mjs'; -import { calculateProviderCostMicroUsd, validateBudgetSnapshotShape } from './budget.mjs'; -import { validateLiveEconomicsShape } from './live-economics.mjs'; +import { + calculateProviderCostMicroUsd, + conservativeSweepRequestCount, + estimateLiveSweepMicroUsd, + validateApprovedBudgetSnapshot, + validateBudgetSnapshotShape, +} from './budget.mjs'; +import { loadFixtureSet } from './fixture-set.mjs'; +import { readGitState } from './git-state.mjs'; +import { validateApprovedLiveEconomics, validateLiveEconomicsShape } from './live-economics.mjs'; +import { validateSweepConfig } from './sweep.mjs'; + +const evidenceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const SAMPLE_KEYS = new Set([ 'sampleId', 'phase', 'profile', 'caseId', 'n', 'replicateId', @@ -14,6 +26,7 @@ const SAMPLE_KEYS = new Set([ 'providerCostMicroUsd', 'providerCostUsd', 'acquisitionCostUsd', 'acquisitionEvidence', 'score', 'criticalGatePass', 'failureClass', 'providerRequestId', + 'budgetAttemptId', ]); const SAMPLE_PHASES = new Set(['acquisition', 'distillation', 'evaluation']); const SAMPLE_PROFILES = new Set(['target', 'clone', 'bad-clone']); @@ -41,14 +54,21 @@ const CONFIGURATION_KEYS = new Set([ const REQUIRED_BUNDLE_FILES = ['samples.jsonl', 'summary.json', 'report.md', 'README.md']; const ALL_BUNDLE_FILES = [...REQUIRED_BUNDLE_FILES, 'manifest.json']; const MANIFEST_INPUT_KEYS = [ - 'experimentId', 'recordedAtUtc', 'gitCommit', 'command', 'modelProvider', 'model', + 'experimentId', 'executionMode', 'recordedAtUtc', 'gitCommit', 'gitDirty', 'command', 'modelProvider', 'model', 'evidenceLabel', 'sourceEvidence', 'liveBudget', 'configuration', 'readmeInputs', ]; const MANIFEST_KEYS = [ - 'schemaVersion', 'experimentId', 'recordedAtUtc', 'gitCommit', 'command', 'runtime', + 'schemaVersion', 'experimentId', 'executionMode', 'recordedAtUtc', 'gitCommit', 'gitDirty', 'command', 'runtime', 'modelProvider', 'model', 'evidenceLabel', 'sourceEvidence', 'liveBudget', 'configuration', 'reportInputs', 'readmeInputs', 'files', ]; +const EXECUTION_MODES = new Set(['mock', 'live', 'historical']); +const HISTORICAL_GIT_COMMIT = 'historical-source-not-recorded'; +const HISTORICAL_SOURCE_EVIDENCE = { + kind: 'legacy-report-json', + sha256: '0554779988164651bfe6b037c8b16054e009ee6bac76e61c90af331ac6e85212', + bytes: 76_631, +}; const REPORT_INPUT_KEYS = ['evidenceLabel', 'verdict', 'suppressionReason', 'limitations']; const EVIDENCE_LABELS = new Set([ 'SYNTHETIC', @@ -200,6 +220,10 @@ function validateSample(sample) { if (sample.caseId !== null) safeIdentifier(sample.caseId, 'caseId', 128); if (sample.replicateId !== null) safeIdentifier(sample.replicateId, 'replicateId', 64); if (sample.providerRequestId !== null) safeIdentifier(sample.providerRequestId, 'providerRequestId', 128); + if (sample.budgetAttemptId !== null + && !(typeof sample.budgetAttemptId === 'string' && /^attempt-\d{6}$/.test(sample.budgetAttemptId))) { + throw new Error('budgetAttemptId must be an exact reservation identifier or null'); + } if (sample.failureClass !== null && !(typeof sample.failureClass === 'string' && /^[A-Z][A-Za-z0-9]{0,63}$/.test(sample.failureClass))) { throw new Error('failureClass must be an error-class token or null'); @@ -536,19 +560,20 @@ function validateLiveBudget(liveBudget) { if (liveBudget === null || liveBudget === undefined) return null; assertPortableJson(liveBudget, 'manifest.liveBudget'); const keys = [ - 'snapshotPath', 'snapshotSha256', 'authorizationHash', 'humanCapMicroUsd', + 'configPath', 'configSha256', 'snapshotPath', 'snapshotSha256', + 'authorizationHash', 'humanCapMicroUsd', 'conservativeEstimateMicroUsd', 'worstCasePerCallMicroUsd', 'attemptedCalls', 'knownAccruedMicroUsd', 'outstandingReservedMicroUsd', 'lock', 'economicsSnapshotPath', 'economicsSnapshotSha256', ]; assertExactKeys(liveBudget, keys, 'liveBudget'); - for (const key of ['snapshotPath', 'economicsSnapshotPath']) { + for (const key of ['configPath', 'snapshotPath', 'economicsSnapshotPath']) { nonEmptyString(liveBudget[key], `liveBudget.${key}`); if (path.isAbsolute(liveBudget[key]) || liveBudget[key].split(/[\\/]/).includes('..')) { throw new Error(`liveBudget.${key} must be repository-relative`); } } - for (const key of ['snapshotSha256', 'economicsSnapshotSha256']) { + for (const key of ['configSha256', 'snapshotSha256', 'economicsSnapshotSha256']) { if (!/^[0-9a-f]{64}$/.test(liveBudget[key])) throw new Error(`liveBudget.${key} must be a lowercase digest`); } if (!/^sha256:[0-9a-f]{64}$/.test(liveBudget.authorizationHash)) { @@ -580,6 +605,61 @@ function validateRecordedAtUtc(value) { if (canonical !== expected) throw new Error('recordedAtUtc must be an ISO-8601 instant or null'); } +function validateRecordedAtMode(recordedAtUtc, executionMode) { + if (executionMode === 'historical' && recordedAtUtc !== null) { + throw new Error('historical recordedAtUtc must be null'); + } + if (executionMode !== 'historical' && recordedAtUtc === null) { + throw new Error(`${executionMode} recordedAtUtc must be an exact execution instant`); + } +} + +function validateExecutionModeContract({ executionMode, evidenceLabel, liveBudget }) { + if (!EXECUTION_MODES.has(executionMode)) { + throw new Error('manifest.executionMode is required and must be mock, live, or historical'); + } + if (executionMode === 'live') { + if (liveBudget === null) throw new Error('live executionMode requires liveBudget'); + if (!evidenceLabel.startsWith('LIVE CANDIDATE')) { + throw new Error('live executionMode requires a LIVE CANDIDATE evidence label'); + } + return; + } + if (liveBudget !== null) throw new Error(`${executionMode} executionMode forbids liveBudget`); + if (executionMode === 'mock' && !evidenceLabel.startsWith('SYNTHETIC')) { + throw new Error('mock executionMode requires a SYNTHETIC evidence label'); + } + if (executionMode === 'historical' + && evidenceLabel !== 'HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED') { + throw new Error('historical executionMode requires the historical evidence label'); + } +} + +function validateGitIdentity({ executionMode, gitCommit, gitDirty, sourceEvidence }, { current = false } = {}) { + if (executionMode === 'historical') { + if (gitCommit !== HISTORICAL_GIT_COMMIT || gitDirty !== null) { + throw new Error('historical evidence requires the exact unrecorded git identity sentinel'); + } + if (JSON.stringify(canonicalize(sourceEvidence)) + !== JSON.stringify(canonicalize(HISTORICAL_SOURCE_EVIDENCE))) { + throw new Error('historical evidence requires the exact hash-locked sourceEvidence'); + } + return; + } + if (!/^[0-9a-f]{40}$/.test(gitCommit ?? '')) { + throw new Error('manifest.gitCommit must be an exact lowercase 40-hex commit'); + } + if (typeof gitDirty !== 'boolean') throw new Error('manifest.gitDirty is required and must be boolean'); + if (executionMode === 'live' && gitDirty) throw new Error('Live evidence requires a clean checkout'); + if (sourceEvidence !== null) throw new Error(`${executionMode} evidence forbids sourceEvidence`); + if (current) { + const actual = readGitState(evidenceRoot); + if (actual.gitCommit !== gitCommit || actual.gitDirty !== gitDirty) { + throw new Error('Evidence git identity does not match current repository state'); + } + } +} + function renderReport(summary, reportInputs) { const value = (input) => input === null ? 'unknown' : String(input); const limitations = reportInputs.limitations.length === 0 @@ -642,13 +722,25 @@ function validateManifest(manifest) { throw new Error('Evidence manifest experimentId is required'); } validateRecordedAtUtc(manifest.recordedAtUtc); + validateRecordedAtMode(manifest.recordedAtUtc, manifest.executionMode); for (const key of ['gitCommit', 'command']) nonEmptyString(manifest[key], `manifest.${key}`); for (const key of ['modelProvider', 'model']) nonEmptyString(manifest[key], `manifest.${key}`, { nullable: true }); if (!EVIDENCE_LABELS.has(manifest.evidenceLabel)) throw new Error('Evidence manifest evidenceLabel is unsupported'); assertExactKeys(manifest.runtime, ['node', 'platform', 'arch'], 'manifest.runtime'); for (const key of ['node', 'platform', 'arch']) nonEmptyString(manifest.runtime[key], `manifest.runtime.${key}`); validateSourceEvidence(manifest.sourceEvidence); - validateLiveBudget(manifest.liveBudget); + const liveBudget = validateLiveBudget(manifest.liveBudget); + validateExecutionModeContract({ + executionMode: manifest.executionMode, + evidenceLabel: manifest.evidenceLabel, + liveBudget, + }); + validateGitIdentity({ + executionMode: manifest.executionMode, + gitCommit: manifest.gitCommit, + gitDirty: manifest.gitDirty, + sourceEvidence: manifest.sourceEvidence, + }); const configuration = validateConfiguration(manifest.configuration); validateReportInputs(manifest.reportInputs, manifest.evidenceLabel, configuration); validateReadmeInputs(manifest.readmeInputs); @@ -701,8 +793,20 @@ export function writeEvidenceBundle(input) { } const recordedAtUtc = manifest.recordedAtUtc === undefined ? new Date().toISOString() : manifest.recordedAtUtc; validateRecordedAtUtc(recordedAtUtc); + validateRecordedAtMode(recordedAtUtc, manifest.executionMode); const sourceEvidence = validateSourceEvidence(manifest.sourceEvidence); const liveBudget = validateLiveBudget(manifest.liveBudget); + validateExecutionModeContract({ + executionMode: manifest.executionMode, + evidenceLabel: manifest.evidenceLabel, + liveBudget, + }); + validateGitIdentity({ + executionMode: manifest.executionMode, + gitCommit: manifest.gitCommit, + gitDirty: manifest.gitDirty, + sourceEvidence, + }, { current: true }); const configuration = validateConfiguration(manifest.configuration); const validatedReadmeInputs = validateReadmeInputs(manifest.readmeInputs); if (recordedAtUtc === null @@ -721,8 +825,10 @@ export function writeEvidenceBundle(input) { const finalManifest = { schemaVersion: 1, experimentId: manifest.experimentId, + executionMode: manifest.executionMode, recordedAtUtc, gitCommit: manifest.gitCommit ?? 'not-recorded', + gitDirty: manifest.gitDirty, command: manifest.command, runtime: { node: process.version, platform: process.platform, arch: process.arch }, modelProvider: manifest.modelProvider ?? null, @@ -739,6 +845,7 @@ export function writeEvidenceBundle(input) { }])), }; validateManifest(finalManifest); + verifyModeRows(samples, finalManifest, evidenceRoot); validateOutputDirectory(outputDir); if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); for (const name of REQUIRED_BUNDLE_FILES) fs.writeFileSync(path.join(outputDir, name), contents[name]); @@ -746,57 +853,229 @@ export function writeEvidenceBundle(input) { return finalManifest; } -function verifyLiveRows(samples, manifest, dir) { - if (manifest.liveBudget === null) return; - const allowed = [ - 'snapshotPath', 'snapshotSha256', 'authorizationHash', 'humanCapMicroUsd', - 'conservativeEstimateMicroUsd', 'worstCasePerCallMicroUsd', 'attemptedCalls', - 'knownAccruedMicroUsd', 'outstandingReservedMicroUsd', 'lock', - 'economicsSnapshotPath', 'economicsSnapshotSha256', - ]; - if (JSON.stringify(Object.keys(manifest.liveBudget).sort()) !== JSON.stringify(allowed.sort())) { - throw new Error('liveBudget contains unexpected or missing fields'); - } - if (path.isAbsolute(manifest.liveBudget.snapshotPath) || manifest.liveBudget.snapshotPath.includes('..')) { - throw new Error('liveBudget snapshotPath must be repository-relative'); - } - const snapshotPath = path.resolve(dir, '..', '..', manifest.liveBudget.snapshotPath); - const snapshotBytes = fs.readFileSync(snapshotPath); - if (sha256(snapshotBytes) !== manifest.liveBudget.snapshotSha256) throw new Error('Live budget snapshot hash mismatch'); - const snapshot = JSON.parse(snapshotBytes); - if (path.isAbsolute(manifest.liveBudget.economicsSnapshotPath) - || manifest.liveBudget.economicsSnapshotPath.includes('..')) { - throw new Error('liveBudget economicsSnapshotPath must be repository-relative'); - } - const economicsPath = path.resolve(dir, '..', '..', manifest.liveBudget.economicsSnapshotPath); - const economicsBytes = fs.readFileSync(economicsPath); - if (sha256(economicsBytes) !== manifest.liveBudget.economicsSnapshotSha256) { +function readRegularFixture(packageRoot, relativePath, label) { + const filePath = path.join(packageRoot, relativePath); + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new Error(`${label} must exist as a regular file`); + } + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`${label} must exist as a regular file`); + } + const bytes = fs.readFileSync(filePath); + let parsed; + try { + parsed = JSON.parse(bytes); + } catch { + throw new Error(`${label} must contain valid JSON`); + } + return { bytes, parsed }; +} + +function requireExactLivePath(actual, expected, label) { + if (actual !== expected) throw new Error(`Live ${label} must equal ${expected}`); +} + +function canonicalEqual(left, right) { + return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)); +} + +function deriveBudgetViolation({ sample, exactCost, knownAccrued, snapshot, worstCasePerCall, humanCap }) { + if (sample.inputTokens > snapshot.tokenCaps.maxInputTokens + || sample.outputTokens > snapshot.tokenCaps.maxOutputTokens) { + return 'token_cap_exceeded'; + } + if (knownAccrued > humanCap) return 'human_cap_exceeded'; + if (exactCost > worstCasePerCall) return 'reservation_exceeded'; + return null; +} + +export function verifyLiveEvidenceContract(samples, manifest, packageRoot) { + if (!Array.isArray(samples)) throw new Error('Live samples must be an array'); + if (typeof packageRoot !== 'string' || packageRoot === '') throw new Error('Live package root is required'); + const liveBudget = validateLiveBudget(manifest.liveBudget); + validateExecutionModeContract({ + executionMode: manifest.executionMode, + evidenceLabel: manifest.evidenceLabel, + liveBudget, + }); + if (manifest.gitDirty !== false) throw new Error('Live evidence requires a clean checkout'); + if (manifest.sourceEvidence !== null) throw new Error('Live evidence forbids sourceEvidence'); + const configuration = validateConfiguration(manifest.configuration); + + requireExactLivePath(liveBudget.configPath, 'fixtures/sweep-v1.json', 'configPath'); + requireExactLivePath(liveBudget.snapshotPath, 'fixtures/live-budget-v1.json', 'snapshotPath'); + requireExactLivePath( + liveBudget.economicsSnapshotPath, + 'fixtures/live-economics-v1.json', + 'economicsSnapshotPath', + ); + const configFile = readRegularFixture(packageRoot, liveBudget.configPath, 'Live sweep config'); + const snapshotFile = readRegularFixture(packageRoot, liveBudget.snapshotPath, 'Live budget snapshot'); + const economicsFile = readRegularFixture( + packageRoot, + liveBudget.economicsSnapshotPath, + 'Live economics snapshot', + ); + if (sha256(configFile.bytes) !== liveBudget.configSha256) throw new Error('Live config hash mismatch'); + if (sha256(snapshotFile.bytes) !== liveBudget.snapshotSha256) throw new Error('Live budget snapshot hash mismatch'); + if (sha256(economicsFile.bytes) !== liveBudget.economicsSnapshotSha256) { throw new Error('Live economics snapshot hash mismatch'); } - const economics = JSON.parse(economicsBytes); - if (JSON.stringify(canonicalize(economics)) !== JSON.stringify(canonicalize(manifest.configuration.liveEconomics))) { + if (!canonicalEqual(configFile.parsed, configuration.sweepConfig)) { + throw new Error('Live sweep configuration differs from hash-verified config'); + } + if (!canonicalEqual(economicsFile.parsed, configuration.liveEconomics)) { throw new Error('Live economics configuration differs from hash-verified snapshot'); } - const expectedAuthorization = liveAuthorizationHash({ - config: manifest.configuration.sweepConfig, + const config = configFile.parsed; + validateBudgetSnapshotShape(snapshotFile.parsed, config); + const snapshot = validateApprovedBudgetSnapshot(snapshotFile.parsed, config); + const liveEconomics = validateApprovedLiveEconomics(economicsFile.parsed, config); + if (manifest.model !== snapshot.model) throw new Error('Live manifest model differs from approved snapshot'); + if (typeof manifest.modelProvider !== 'string' + || manifest.modelProvider.toLowerCase() !== snapshot.provider.toLowerCase()) { + throw new Error('Live manifest provider differs from approved snapshot'); + } + + // Validate the fixed preregistration before using fixtureSet in any path. + validateSweepConfig(config, { trainCount: 100, heldoutCount: 30, v2Count: 0 }); + for (const name of [`train-${config.fixtureSet}.json`, `heldout-${config.fixtureSet}.json`, 'v2-heldout.json']) { + readRegularFixture(packageRoot, path.join('fixtures', name), `Live fixture ${name}`); + } + const fixtures = loadFixtureSet(packageRoot, config.fixtureSet); + const v2Fixtures = readRegularFixture(packageRoot, 'fixtures/v2-heldout.json', 'Live v2 fixtures').parsed; + if (!Array.isArray(v2Fixtures)) throw new Error('Live v2 fixtures must be an array'); + const counts = { + trainCount: fixtures.train.length, + heldoutCount: fixtures.heldout.length, + v2Count: v2Fixtures.length, + }; + validateSweepConfig(config, counts); + const requestCount = conservativeSweepRequestCount(config, counts); + if (requestCount !== 1713) throw new Error('Live committed fixture request count must equal 1713'); + const worstCasePerCall = calculateProviderCostMicroUsd({ + inputTokens: snapshot.tokenCaps.maxInputTokens, + outputTokens: snapshot.tokenCaps.maxOutputTokens, snapshot, - economics, }); - if (expectedAuthorization !== manifest.liveBudget.authorizationHash) throw new Error('Live authorization hash mismatch'); - if (manifest.liveBudget.attemptedCalls !== samples.length) throw new Error('Live attempted-call count differs from samples'); - for (const sample of samples) { - if (sample.inputTokens === null || sample.outputTokens === null) { - if (sample.providerCostMicroUsd !== null || sample.providerCostUsd !== null) { - throw new Error('Unknown live usage requires both provider costs to be null'); + const conservativeEstimate = estimateLiveSweepMicroUsd({ config, counts, snapshot }); + if (liveBudget.worstCasePerCallMicroUsd !== worstCasePerCall.toString()) { + throw new Error('Live worst-case per-call amount mismatch'); + } + if (liveBudget.conservativeEstimateMicroUsd !== conservativeEstimate.toString()) { + throw new Error('Live conservative estimate mismatch'); + } + const humanCap = BigInt(liveBudget.humanCapMicroUsd); + if (humanCap < conservativeEstimate) { + throw new Error('Live human cap is below the conservative estimate'); + } + const expectedAuthorization = liveAuthorizationHash({ config, snapshot, economics: liveEconomics }); + if (expectedAuthorization !== liveBudget.authorizationHash) throw new Error('Live authorization hash mismatch'); + if (liveBudget.attemptedCalls !== samples.length || samples.length > requestCount) { + throw new Error('Live attempted-call count differs from samples or approved request count'); + } + + let knownAccrued = 0n; + const unknownIndexes = []; + const violations = []; + for (let index = 0; index < samples.length; index += 1) { + const sample = validateSample(samples[index]); + const expectedAttemptId = `attempt-${String(index + 1).padStart(6, '0')}`; + if (sample.budgetAttemptId !== expectedAttemptId) { + throw new Error(`Live budgetAttemptId sequence mismatch for ${sample.sampleId}`); + } + if (knownAccrued + worstCasePerCall > humanCap) { + throw new Error(`Live sample ${sample.sampleId} could not have reserved within the human cap`); + } + if (sample.phase === 'acquisition') { + if (sample.acquisitionCostUsd !== liveEconomics.invocationPriceUsd + || sample.acquisitionEvidence !== 'MODELED') { + throw new Error(`Live acquisition row price or evidence mismatch for ${sample.sampleId}`); + } + } else if (sample.acquisitionCostUsd !== 0 || sample.acquisitionEvidence !== null) { + throw new Error(`Live non-acquisition row carries acquisition cost for ${sample.sampleId}`); + } + + const unknownUsage = sample.inputTokens === null || sample.outputTokens === null; + if (unknownUsage) { + if (!(sample.inputTokens === null && sample.outputTokens === null + && sample.providerCostMicroUsd === null && sample.providerCostUsd === null)) { + throw new Error('Unknown live usage requires both usage and provider costs to be null'); } + unknownIndexes.push(index); + violations.push(null); continue; } - const expected = calculateProviderCostMicroUsd({ + const exactCost = calculateProviderCostMicroUsd({ inputTokens: sample.inputTokens, outputTokens: sample.outputTokens, snapshot, }); - if (sample.providerCostMicroUsd !== expected.toString()) throw new Error(`Live exact provider cost mismatch for ${sample.sampleId}`); + if (sample.providerCostMicroUsd !== exactCost.toString()) { + throw new Error(`Live exact provider cost mismatch for ${sample.sampleId}`); + } + if (sample.providerCostUsd !== Number(exactCost) / 1_000_000) { + throw new Error(`Live provider USD cost mismatch for ${sample.sampleId}`); + } + knownAccrued += exactCost; + violations.push(deriveBudgetViolation({ + sample, + exactCost, + knownAccrued, + snapshot, + worstCasePerCall, + humanCap, + })); + } + if (liveBudget.knownAccruedMicroUsd !== knownAccrued.toString()) { + throw new Error('Live known accrued amount differs from exact sample costs'); + } + + const outstanding = BigInt(liveBudget.outstandingReservedMicroUsd); + const finalIndex = samples.length - 1; + const finalSample = samples[finalIndex]; + const earlierViolation = violations.findIndex((reason, index) => reason !== null && index !== finalIndex); + if (earlierViolation !== -1) throw new Error('Live samples continue after a budget violation'); + if (liveBudget.lock === null) { + if (unknownIndexes.length !== 0) throw new Error('Unknown live cost requires an unknown_cost lock'); + if (outstanding !== 0n) throw new Error('Unlocked live budget must have zero outstanding reservation'); + if (violations[finalIndex] !== null && samples.length !== 0) { + throw new Error('Live budget violation requires a budget_overrun lock'); + } + return; + } + if (!finalSample || finalSample.success !== false) throw new Error('Live budget lock requires a final failed sample'); + if (liveBudget.lock.attemptId !== finalSample.budgetAttemptId) { + throw new Error('Live lock attemptId must equal final sample budgetAttemptId'); + } + if (liveBudget.lock.kind === 'unknown_cost') { + if (unknownIndexes.length !== 1 || unknownIndexes[0] !== finalIndex) { + throw new Error('unknown_cost lock requires exactly one final unknown-cost sample'); + } + if (outstanding !== worstCasePerCall) { + throw new Error('unknown_cost lock must retain exactly one worst-case reservation'); + } + return; + } + if (unknownIndexes.length !== 0) throw new Error('budget_overrun lock requires a known final cost'); + if (outstanding !== 0n) throw new Error('budget_overrun lock must have zero outstanding reservation'); + const expectedReason = violations[finalIndex]; + if (expectedReason === null) throw new Error('budget_overrun lock has no derived budget violation'); + if (liveBudget.lock.reason !== expectedReason) { + throw new Error(`Live lock reason must equal ${expectedReason}`); + } +} + +function verifyModeRows(samples, manifest, packageRoot) { + if (manifest.executionMode === 'live') { + verifyLiveEvidenceContract(samples, manifest, packageRoot); + return; + } + if (samples.some((sample) => sample.budgetAttemptId !== null)) { + throw new Error(`${manifest.executionMode} evidence requires null budgetAttemptId values`); } } @@ -851,6 +1130,6 @@ export function verifyEvidenceBundle(dir) { if (fs.readFileSync(path.join(dir, 'README.md'), 'utf8') !== renderReadme(manifest.readmeInputs)) { throw new Error('README.md differs from deterministic rendering'); } - verifyLiveRows(samples, manifest, dir); + verifyModeRows(samples, manifest, evidenceRoot); return { valid: true, manifest, summary, samples }; } diff --git a/spikes/clone-economics/src/git-state.mjs b/spikes/clone-economics/src/git-state.mjs new file mode 100644 index 0000000..0450c85 --- /dev/null +++ b/spikes/clone-economics/src/git-state.mjs @@ -0,0 +1,70 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const GIT_CONTEXT_KEYS = [ + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', + 'GIT_COMMON_DIR', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', +]; + +function cleanGitEnvironment() { + const env = { ...process.env }; + for (const key of GIT_CONTEXT_KEYS) delete env[key]; + return env; +} + +function runGit(repoRoot, args) { + const result = spawnSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + env: cleanGitEnvironment(), + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) { + const detail = typeof result.stderr === 'string' ? result.stderr.trim() : ''; + throw new Error(`Unable to read exact committed git HEAD${detail ? `: ${detail}` : ''}`); + } + return result.stdout.replace(/\r?\n$/, ''); +} + +export function readGitState(repoRoot) { + if (typeof repoRoot !== 'string' || repoRoot === '') { + throw new Error('Git repository root is required'); + } + const resolved = path.resolve(repoRoot); + let stat; + try { + stat = fs.lstatSync(resolved); + } catch { + throw new Error('Git repository root must be a real directory'); + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error('Git repository root must be a real directory'); + } + const gitCommit = runGit(resolved, ['rev-parse', '--verify', 'HEAD']); + if (!/^[0-9a-f]{40}$/.test(gitCommit)) { + throw new Error('Unable to read exact committed git HEAD'); + } + const porcelain = runGit(resolved, ['status', '--porcelain=v1', '--untracked-files=all']); + return { + gitCommit, + gitDirty: porcelain !== '', + porcelain, + }; +} + +export function assertLiveCheckoutClean(gitState) { + if (!gitState || !/^[0-9a-f]{40}$/.test(gitState.gitCommit ?? '') + || typeof gitState.gitDirty !== 'boolean') { + throw new Error('Live sweep requires an exact captured git state'); + } + if (gitState.gitDirty) { + throw new Error('Live sweep requires a clean checkout before provider execution'); + } + return gitState; +} diff --git a/spikes/clone-economics/src/sweep.mjs b/spikes/clone-economics/src/sweep.mjs index 6cca356..fc642fc 100644 --- a/spikes/clone-economics/src/sweep.mjs +++ b/spikes/clone-economics/src/sweep.mjs @@ -229,6 +229,7 @@ export function normalizeSweepSamples({ experimentId, attempts }) { criticalGatePass: attempt.criticalGatePass ?? null, failureClass: attempt.failureClass ?? null, providerRequestId: attempt.providerRequestId ?? null, + budgetAttemptId: attempt.budgetAttemptId ?? null, }; return normalized; }); @@ -236,13 +237,14 @@ export function normalizeSweepSamples({ experimentId, attempts }) { export async function writeSweepEvidenceBundle({ result, + executionMode, config, outputDir, experimentId, evidenceLabel, command, recordedAtUtc, - gitCommit, + gitState, modelProvider = null, model = null, liveBudget = null, @@ -259,13 +261,16 @@ export async function writeSweepEvidenceBundle({ ...(samples.some((sample) => sample.acquisitionEvidence === 'MODELED') ? ['ACQUISITION_MODELED'] : []), ...(incompleteCosts ? ['INCOMPLETE_PROVIDER_COST'] : []), ...(evidenceLabel.startsWith('SYNTHETIC') ? ['SYNTHETIC_ONLY'] : []), + ...(gitState?.gitDirty === true ? ['DIRTY_CHECKOUT'] : []), ].sort(); const manifest = writeEvidenceBundle({ outputDir, manifest: { experimentId, + executionMode, ...(recordedAtUtc === undefined ? {} : { recordedAtUtc }), - ...(gitCommit === undefined ? {} : { gitCommit }), + gitCommit: gitState?.gitCommit, + gitDirty: gitState?.gitDirty, command, modelProvider, model, diff --git a/spikes/clone-economics/sweep.mjs b/spikes/clone-economics/sweep.mjs index 33469db..221e177 100644 --- a/spikes/clone-economics/sweep.mjs +++ b/spikes/clone-economics/sweep.mjs @@ -12,6 +12,7 @@ import { } from './src/budget.mjs'; import { liveAuthorizationHash } from './src/authorization.mjs'; import { loadFixtureSet } from './src/fixture-set.mjs'; +import { assertLiveCheckoutClean, readGitState } from './src/git-state.mjs'; import { validateLiveEconomicsShape } from './src/live-economics.mjs'; import { runSweep, @@ -65,6 +66,7 @@ async function main() { } if (mode === '--mock') { + const gitState = Object.freeze(readGitState(root)); let networkAttempts = 0; const priorFetch = globalThis.fetch; globalThis.fetch = async () => { @@ -77,6 +79,8 @@ async function main() { const evidenceRelative = path.join('runs', 'mock-sweep', 'evidence', experimentId); await writeSweepEvidenceBundle({ result, + executionMode: 'mock', + gitState, config, outputDir: path.join(root, evidenceRelative), experimentId, @@ -95,6 +99,8 @@ async function main() { return; } + const gitState = Object.freeze(readGitState(root)); + assertLiveCheckoutClean(gitState); const live = await startLiveSweep({ env: process.env, config, @@ -114,10 +120,13 @@ async function main() { const recordedAtUtc = new Date().toISOString(); const experimentId = `live-high-n-${recordedAtUtc.replaceAll(/[-:.]/g, '')}`; const evidenceRelative = path.join('evidence', experimentId); + const configBytes = fs.readFileSync(path.join(root, 'fixtures/sweep-v1.json')); const snapshotBytes = fs.readFileSync(path.join(root, 'fixtures/live-budget-v1.json')); const economicsBytes = fs.readFileSync(path.join(root, 'fixtures/live-economics-v1.json')); await writeSweepEvidenceBundle({ result: live.result, + executionMode: 'live', + gitState, config, outputDir: path.join(root, evidenceRelative), experimentId, @@ -130,6 +139,8 @@ async function main() { model: snapshot.model, liveEconomics: economics, liveBudget: { + configPath: 'fixtures/sweep-v1.json', + configSha256: createHash('sha256').update(configBytes).digest('hex'), snapshotPath: 'fixtures/live-budget-v1.json', snapshotSha256: createHash('sha256').update(snapshotBytes).digest('hex'), economicsSnapshotPath: 'fixtures/live-economics-v1.json', diff --git a/spikes/clone-economics/tests/adapters-budget.test.mjs b/spikes/clone-economics/tests/adapters-budget.test.mjs index 3a9bfab..0536eb6 100644 --- a/spikes/clone-economics/tests/adapters-budget.test.mjs +++ b/spikes/clone-economics/tests/adapters-budget.test.mjs @@ -67,6 +67,7 @@ test('mock seed evidence is synthetic and output callback receives no payload by status: 'synthetic_honored', mechanism: 'deterministic_mock_fixture_selection', }); + assert.equal(adapter.attempts[0].budgetAttemptId, null); }); test('missing usage retains a reservation, locks unknown_cost, and permits no later fetch', async () => { @@ -90,6 +91,7 @@ test('missing usage retains a reservation, locks unknown_cost, and permits no la lock: { kind: 'unknown_cost', attemptId: 'attempt-000001' }, }); assert.equal(adapter.attempts[0].providerCostMicroUsd, null); + assert.equal(adapter.attempts[0].budgetAttemptId, 'attempt-000001'); await assert.rejects( adapter.invoke({ kind: 'target-heldout', caseId: 'two', payload: { input: 'small' } }), /budget locked/i, @@ -126,6 +128,7 @@ test('above-token usage accrues exact cost, locks budget_overrun, and permits no }); assert.equal(adapter.attempts[0].inputTokens, 501); assert.equal(adapter.attempts[0].providerCostMicroUsd, '511'); + assert.equal(adapter.attempts[0].budgetAttemptId, 'attempt-000001'); await assert.rejects( adapter.invoke({ kind: 'target-heldout', caseId: 'two', payload: { input: 'x' } }), /budget_overrun/, @@ -156,6 +159,7 @@ test('known cost above the human cap is fully accrued and blocks all later calls ); assert.equal(budget.state().knownAccruedMicroUsd, 125n); assert.equal(budget.state().outstandingReservedMicroUsd, 0n); + assert.equal(adapter.attempts[0].budgetAttemptId, 'attempt-000001'); await assert.rejects( adapter.invoke({ kind: 'target-heldout', caseId: 'two', payload: { input: 'x' } }), /budget_overrun/, @@ -190,4 +194,5 @@ test('live distillation records unsupported seed evidence and sends no seed fiel status: 'unsupported', mechanism: 'provider_seed_not_supported_by_adapter', }); + assert.equal(adapter.attempts[0].budgetAttemptId, 'attempt-000001'); }); diff --git a/spikes/clone-economics/tests/evidence.test.mjs b/spikes/clone-economics/tests/evidence.test.mjs index 0d46a83..01f235b 100644 --- a/spikes/clone-economics/tests/evidence.test.mjs +++ b/spikes/clone-economics/tests/evidence.test.mjs @@ -4,11 +4,12 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; import { recomputeSummary, verifyEvidenceBundle, writeEvidenceBundle } from '../src/evidence.mjs'; +import { readGitState } from '../src/git-state.mjs'; import { normalizeSweepSamples, writeSweepEvidenceBundle } from '../src/sweep.mjs'; -import { liveAuthorizationHash } from '../src/authorization.mjs'; -import { approved, config, economics } from './fixtures/live-contract.mjs'; +import { config } from './fixtures/live-contract.mjs'; const normalizedSample = (overrides = {}) => ({ sampleId: 'run:target-heldout:a', @@ -34,6 +35,7 @@ const normalizedSample = (overrides = {}) => ({ criticalGatePass: true, failureClass: null, providerRequestId: null, + budgetAttemptId: null, ...overrides, }); @@ -46,12 +48,21 @@ const syntheticReportInputs = (overrides = {}) => ({ }); const readmeInputs = (bundlePath = 'evidence/adversarial-fixture') => ({ bundlePath }); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const currentGitState = readGitState(packageRoot); +const currentGitIdentity = { + gitCommit: currentGitState.gitCommit, + gitDirty: currentGitState.gitDirty, +}; function evidenceInput(outputDir, overrides = {}) { return { outputDir, manifest: { experimentId: 'adversarial-fixture', + executionMode: 'mock', + gitCommit: currentGitState.gitCommit, + gitDirty: currentGitState.gitDirty, evidenceLabel: 'SYNTHETIC', command: 'test', configuration: {}, @@ -98,6 +109,8 @@ test('bundle hashes and summary recompute from normalized samples', (t) => { outputDir: dir, manifest: { experimentId: 'fixture-run', + executionMode: 'mock', + ...currentGitIdentity, evidenceLabel: 'SYNTHETIC', command: 'npm run sweep:mock', readmeInputs: readmeInputs('evidence/fixture-run'), @@ -114,6 +127,116 @@ test('bundle hashes and summary recompute from normalized samples', (t) => { assert.equal(verified.summary.latencyMs.p95, 30); }); +test('executionMode is required and live mode requires a live budget', (t) => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-mode-')); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + + const missingMode = evidenceInput(path.join(parent, 'missing-mode')); + delete missingMode.manifest.executionMode; + assert.throws( + () => writeEvidenceBundle(missingMode), + /manifest\.executionMode is required/, + ); + assert.equal(fs.existsSync(path.join(parent, 'missing-mode')), false); + + const live = evidenceInput(path.join(parent, 'live-without-budget'), { + manifest: { + executionMode: 'live', + evidenceLabel: 'LIVE CANDIDATE — CONCLUSIONS SUPPRESSED', + }, + reportInputs: { + evidenceLabel: 'LIVE CANDIDATE — CONCLUSIONS SUPPRESSED', + }, + }); + assert.throws( + () => writeEvidenceBundle(live), + /live executionMode requires liveBudget/, + ); + assert.equal(fs.existsSync(path.join(parent, 'live-without-budget')), false); +}); + +test('new-run evidence rejects missing or arbitrary git identity', (t) => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-git-')); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + + const missing = evidenceInput(path.join(parent, 'missing')); + delete missing.manifest.gitDirty; + assert.throws(() => writeEvidenceBundle(missing), /manifest\.gitDirty is required/); + assert.equal(fs.existsSync(path.join(parent, 'missing')), false); + + const arbitrary = evidenceInput(path.join(parent, 'arbitrary'), { + manifest: { gitCommit: '0'.repeat(40) }, + }); + assert.throws(() => writeEvidenceBundle(arbitrary), /git identity does not match current repository state/i); + assert.equal(fs.existsSync(path.join(parent, 'arbitrary')), false); +}); + +test('mode rows and historical provenance use exact non-forgeable sentinels', (t) => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-evidence-modes-')); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + + assert.throws( + () => writeEvidenceBundle({ + ...evidenceInput(path.join(parent, 'mock-budget-id')), + samples: [normalizedSample({ budgetAttemptId: 'attempt-000001' })], + }), + /mock evidence requires null budgetAttemptId values/, + ); + + const historicalLabel = 'HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED'; + const historical = (outputDir) => ({ + outputDir, + manifest: { + experimentId: 'historical-fixture', + executionMode: 'historical', + recordedAtUtc: null, + gitCommit: 'historical-source-not-recorded', + gitDirty: null, + command: 'historical command not retained exactly', + evidenceLabel: historicalLabel, + sourceEvidence: { + kind: 'legacy-report-json', + sha256: '0554779988164651bfe6b037c8b16054e009ee6bac76e61c90af331ac6e85212', + bytes: 76_631, + }, + configuration: { + historicalRunDate: '2026-07-12', + sourceTimestamp: 'not-recorded', + benchmarkVerdict: 'INVALID_BENCHMARK_TARGET_FAILED', + }, + readmeInputs: readmeInputs('evidence/historical-fixture'), + }, + samples: [normalizedSample({ + sampleId: 'legacy:fixture', + distillationSeedStatus: 'not_recorded', + distillationSeedMechanism: 'historical_source_not_recorded', + })], + reportInputs: { + evidenceLabel: historicalLabel, + verdict: 'INVALID_BENCHMARK_TARGET_FAILED', + suppressionReason: 'INVALID_BENCHMARK_TARGET_FAILED', + limitations: ['HISTORICAL_ATTEMPTS_INCOMPLETE'], + }, + }); + + const valid = historical(path.join(parent, 'valid-historical')); + writeEvidenceBundle(valid); + assert.equal(verifyEvidenceBundle(valid.outputDir).valid, true); + + const mutations = [ + ['git sentinel', (input) => { input.manifest.gitCommit = 'a'.repeat(40); }, /unrecorded git identity sentinel/], + ['dirty sentinel', (input) => { input.manifest.gitDirty = false; }, /unrecorded git identity sentinel/], + ['source identity', (input) => { input.manifest.sourceEvidence.bytes -= 1; }, /exact hash-locked sourceEvidence/], + ['invented timestamp', (input) => { input.manifest.recordedAtUtc = '2026-07-12T00:00:00Z'; }, /historical recordedAtUtc must be null/], + ]; + mutations.forEach(([name, mutate, pattern], index) => { + const input = historical(path.join(parent, `invalid-${index}`)); + mutate(input); + assert.throws(() => writeEvidenceBundle(input), pattern, name); + assert.equal(fs.existsSync(input.outputDir), false); + }); +}); + test('redaction rejects private payload fields', () => { assert.throws(() => recomputeSummary([{ ...samples[0], prompt: 'private' }]), /forbidden sample field: prompt/); }); @@ -139,6 +262,8 @@ test('sample and configuration schemas reject nested values and secrets', () => outputDir, manifest: { experimentId: 'nested-config', + executionMode: 'mock', + ...currentGitIdentity, evidenceLabel: 'SYNTHETIC', command: 'test', readmeInputs: readmeInputs('evidence/nested-config'), @@ -188,6 +313,8 @@ function bundle(t) { outputDir: dir, manifest: { experimentId: 'strict-run', + executionMode: 'mock', + ...currentGitIdentity, evidenceLabel: 'SYNTHETIC', command: 'test', readmeInputs: readmeInputs('evidence/strict-run'), @@ -382,6 +509,8 @@ test('writer validates all inputs before creating output', () => { outputDir, manifest: { experimentId: 'invalid-manifest', + executionMode: 'mock', + ...currentGitIdentity, recordedAtUtc: 'not-a-timestamp', evidenceLabel: 'SYNTHETIC', command: 'test', @@ -523,6 +652,7 @@ test('sweep attempts normalize without request payload or output bytes', () => { providerCostUsd: null, failureClass: 'ProviderError', providerRequestId: null, + budgetAttemptId: 'attempt-000001', payload: { private: true }, output: 'private', }], @@ -530,6 +660,7 @@ test('sweep attempts normalize without request payload or output bytes', () => { assert.equal(normalized.length, 1); assert.equal(Object.hasOwn(normalized[0], 'payload'), false); assert.equal(Object.hasOwn(normalized[0], 'output'), false); + assert.equal(normalized[0].budgetAttemptId, 'attempt-000001'); assert.doesNotThrow(() => recomputeSummary(normalized)); }); @@ -564,6 +695,8 @@ test('completed sweep output writes and verifies through the public bundle seam' }; const written = await writeSweepEvidenceBundle({ result, + executionMode: 'mock', + gitState: currentGitState, config, outputDir: dir, experimentId: 'sweep-bundle-fixture', @@ -574,53 +707,3 @@ test('completed sweep output writes and verifies through the public bundle seam' assert.equal(written.verified.valid, true); assert.equal(written.verified.samples.length, 1); }); - -test('live bundle authorization recomputes from hash-verified budget and economics snapshots', (t) => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-live-evidence-')); - t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); - const fixturesDir = path.join(tempRoot, 'fixtures'); - const outputDir = path.join(tempRoot, 'evidence', 'live-fixture'); - fs.mkdirSync(fixturesDir, { recursive: true }); - const snapshotBytes = Buffer.from(`${JSON.stringify(approved, null, 2)}\n`); - const economicsBytes = Buffer.from(`${JSON.stringify(economics, null, 2)}\n`); - fs.writeFileSync(path.join(fixturesDir, 'live-budget-v1.json'), snapshotBytes); - fs.writeFileSync(path.join(fixturesDir, 'live-economics-v1.json'), economicsBytes); - const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); - const authorizationHash = liveAuthorizationHash({ config, snapshot: approved, economics }); - writeEvidenceBundle({ - outputDir, - manifest: { - experimentId: 'live-fixture', - evidenceLabel: 'SYNTHETIC', - command: 'synthetic live verifier fixture', - readmeInputs: readmeInputs('evidence/live-fixture'), - liveBudget: { - snapshotPath: 'fixtures/live-budget-v1.json', - snapshotSha256: digest(snapshotBytes), - economicsSnapshotPath: 'fixtures/live-economics-v1.json', - economicsSnapshotSha256: digest(economicsBytes), - authorizationHash, - humanCapMicroUsd: '1000000', - conservativeEstimateMicroUsd: '1000000', - worstCasePerCallMicroUsd: '1000000', - attemptedCalls: 1, - knownAccruedMicroUsd: '39', - outstandingReservedMicroUsd: '0', - lock: null, - }, - configuration: { sweepConfig: config, liveEconomics: economics }, - }, - samples: [normalizedSample({ - sampleId: 'live:target-heldout:a', - latencyMs: 1, - inputTokens: 3, - outputTokens: 2, - providerCostMicroUsd: '39', - providerCostUsd: 0.000039, - score: 1, - criticalGatePass: true, - })], - reportInputs: syntheticReportInputs(), - }); - assert.equal(verifyEvidenceBundle(outputDir).valid, true); -}); diff --git a/spikes/clone-economics/tests/git-state.test.mjs b/spikes/clone-economics/tests/git-state.test.mjs new file mode 100644 index 0000000..6a45d0f --- /dev/null +++ b/spikes/clone-economics/tests/git-state.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { assertLiveCheckoutClean, readGitState } from '../src/git-state.mjs'; + +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function committedRepo(t, name = 'repo') { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-git-state-')); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + const repo = path.join(parent, name); + fs.mkdirSync(repo); + git(repo, ['init']); + git(repo, ['config', 'user.name', 'Evidence Test']); + git(repo, ['config', 'user.email', 'evidence@example.invalid']); + fs.writeFileSync(path.join(repo, 'tracked.txt'), 'committed\n'); + git(repo, ['add', 'tracked.txt']); + git(repo, ['commit', '-m', 'fixture']); + return { parent, repo }; +} + +test('readGitState captures an exact clean 40-hex commit', (t) => { + const { repo } = committedRepo(t); + const expected = git(repo, ['rev-parse', '--verify', 'HEAD']); + assert.deepEqual(readGitState(repo), { + gitCommit: expected, + gitDirty: false, + porcelain: '', + }); + assert.match(expected, /^[0-9a-f]{40}$/); +}); + +test('readGitState includes tracked changes and untracked files', (t) => { + const { repo } = committedRepo(t); + fs.appendFileSync(path.join(repo, 'tracked.txt'), 'dirty\n'); + fs.writeFileSync(path.join(repo, 'untracked.txt'), 'untracked\n'); + const state = readGitState(repo); + assert.equal(state.gitDirty, true); + assert.equal(state.porcelain, ' M tracked.txt\n?? untracked.txt'); +}); + +test('readGitState passes metacharacter paths as argv without command execution', (t) => { + const { parent, repo } = committedRepo(t, 'repo;touch injected-marker'); + const marker = path.join(parent, 'injected-marker'); + assert.match(readGitState(repo).gitCommit, /^[0-9a-f]{40}$/); + assert.equal(fs.existsSync(marker), false); +}); + +test('readGitState rejects repositories without an exact HEAD identity', (t) => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-git-empty-')); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + git(parent, ['init']); + assert.throws(() => readGitState(parent), /exact committed git HEAD/i); +}); + +test('live checkout gate rejects a captured dirty state', () => { + assert.throws( + () => assertLiveCheckoutClean({ gitCommit: 'a'.repeat(40), gitDirty: true, porcelain: '?? untracked' }), + /clean checkout before provider execution/, + ); + assert.doesNotThrow( + () => assertLiveCheckoutClean({ gitCommit: 'a'.repeat(40), gitDirty: false, porcelain: '' }), + ); +}); diff --git a/spikes/clone-economics/tests/import-legacy-run.test.mjs b/spikes/clone-economics/tests/import-legacy-run.test.mjs index 19378bf..eeb7aea 100644 --- a/spikes/clone-economics/tests/import-legacy-run.test.mjs +++ b/spikes/clone-economics/tests/import-legacy-run.test.mjs @@ -69,6 +69,7 @@ test('legacy normalization retains 29 allow-listed rows and joins fidelity', () assert.equal(samples.find((sample) => sample.caseId === 'heldout-1' && sample.profile === 'target').score, 0.4); assert.equal(samples.reduce((sum, sample) => sum + sample.acquisitionCostUsd, 0), 1.5); for (const sample of samples) { + assert.equal(sample.budgetAttemptId, null); for (const forbidden of ['prompt', 'payload', 'output', 'rawResponse', 'targetSkill', 'referenceText']) { assert.equal(Object.hasOwn(sample, forbidden), false); } diff --git a/spikes/clone-economics/tests/live-evidence-accounting.test.mjs b/spikes/clone-economics/tests/live-evidence-accounting.test.mjs new file mode 100644 index 0000000..504cfde --- /dev/null +++ b/spikes/clone-economics/tests/live-evidence-accounting.test.mjs @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { liveAuthorizationHash } from '../src/authorization.mjs'; +import { calculateProviderCostMicroUsd } from '../src/budget.mjs'; +import { verifyLiveEvidenceContract } from '../src/evidence.mjs'; +import { approved, config, economics } from './fixtures/live-contract.mjs'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function normalizedLiveSample(overrides = {}) { + return { + sampleId: 'live:target-heldout:a', + phase: 'evaluation', + profile: 'target', + caseId: 'a', + n: null, + replicateId: null, + pairOrderSeed: null, + requestedDistillationSeed: null, + appliedDistillationSeed: null, + distillationSeedStatus: 'not_requested', + distillationSeedMechanism: 'no_seed_requested', + success: true, + latencyMs: 1, + inputTokens: 3, + outputTokens: 2, + providerCostMicroUsd: '39', + providerCostUsd: 0.000039, + acquisitionCostUsd: 0, + acquisitionEvidence: null, + score: 1, + criticalGatePass: true, + failureClass: null, + providerRequestId: 'synthetic-request-1', + budgetAttemptId: 'attempt-000001', + ...overrides, + }; +} + +function fixtureContract(t, { + sweepConfig = config, + snapshot = approved, + economicsContract = economics, + samples = [normalizedLiveSample()], +} = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-live-contract-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const fixtures = path.join(root, 'fixtures'); + fs.mkdirSync(fixtures, { recursive: true }); + for (const name of ['train-v2.json', 'heldout-v2.json', 'v2-heldout.json']) { + fs.copyFileSync(path.join(packageRoot, 'fixtures', name), path.join(fixtures, name)); + } + writeJson(path.join(fixtures, 'sweep-v1.json'), sweepConfig); + writeJson(path.join(fixtures, 'live-budget-v1.json'), snapshot); + writeJson(path.join(fixtures, 'live-economics-v1.json'), economicsContract); + const configBytes = fs.readFileSync(path.join(fixtures, 'sweep-v1.json')); + const snapshotBytes = fs.readFileSync(path.join(fixtures, 'live-budget-v1.json')); + const economicsBytes = fs.readFileSync(path.join(fixtures, 'live-economics-v1.json')); + let authorizationHash = `sha256:${'0'.repeat(64)}`; + try { + authorizationHash = liveAuthorizationHash({ + config: sweepConfig, + snapshot, + economics: economicsContract, + }); + } catch { + // Invalid approval fixtures are expected to fail before this placeholder matters. + } + const knownAccrued = samples.reduce( + (sum, sample) => sum + BigInt(sample.providerCostMicroUsd ?? 0), + 0n, + ); + return { + root, + samples, + manifest: { + executionMode: 'live', + gitCommit: 'a'.repeat(40), + gitDirty: false, + evidenceLabel: 'LIVE CANDIDATE — CONCLUSIONS SUPPRESSED', + sourceEvidence: null, + modelProvider: 'Anthropic', + model: snapshot.model, + configuration: { + sweepConfig, + liveEconomics: economicsContract, + }, + liveBudget: { + configPath: 'fixtures/sweep-v1.json', + configSha256: digest(configBytes), + snapshotPath: 'fixtures/live-budget-v1.json', + snapshotSha256: digest(snapshotBytes), + economicsSnapshotPath: 'fixtures/live-economics-v1.json', + economicsSnapshotSha256: digest(economicsBytes), + authorizationHash, + humanCapMicroUsd: '50000000', + conservativeEstimateMicroUsd: '47361024', + worstCasePerCallMicroUsd: '27648', + attemptedCalls: samples.length, + knownAccruedMicroUsd: knownAccrued.toString(), + outstandingReservedMicroUsd: '0', + lock: null, + }, + }, + }; +} + +test('live contract derives exact committed counts, request budget, and per-row accounting', (t) => { + const fixture = fixtureContract(t); + assert.doesNotThrow(() => verifyLiveEvidenceContract( + fixture.samples, + fixture.manifest, + fixture.root, + )); +}); + +test('live contract rejects non-approved budget or economics fixtures', (t) => { + const unapprovedBudget = fixtureContract(t, { + snapshot: { + ...approved, + approvalStatus: 'not_approved', + model: null, + pricing: { + currency: 'USD', + unit: 'per_million_tokens', + inputUsdPerMillionTokens: null, + outputUsdPerMillionTokens: null, + asOf: null, + source: null, + }, + tokenCaps: { maxInputTokens: null, maxOutputTokens: null }, + }, + }); + assert.throws( + () => verifyLiveEvidenceContract(unapprovedBudget.samples, unapprovedBudget.manifest, unapprovedBudget.root), + /budget snapshot must be approved/i, + ); + + const unapprovedEconomics = fixtureContract(t, { + economicsContract: { + ...economics, + approvalStatus: 'not_approved', + invocationPriceUsd: null, + cloneServingCostUsd: null, + deployCostUsd: null, + laborCostUsd: null, + }, + }); + assert.throws( + () => verifyLiveEvidenceContract(unapprovedEconomics.samples, unapprovedEconomics.manifest, unapprovedEconomics.root), + /live economics must be approved/i, + ); +}); + +test('live contract rejects path, hash, config, and aggregate accounting contradictions', (t) => { + const cases = [ + ['config path', (fixture) => { fixture.manifest.liveBudget.configPath = 'fixtures/other.json'; }, /configPath must equal/], + ['config hash', (fixture) => { fixture.manifest.liveBudget.configSha256 = '0'.repeat(64); }, /config hash mismatch/i], + ['config body', (fixture) => { fixture.manifest.configuration.sweepConfig.targetThreshold = 0.9; }, /configuration differs/i], + ['request estimate', (fixture) => { fixture.manifest.liveBudget.conservativeEstimateMicroUsd = '1'; }, /conservative estimate mismatch/i], + ['human cap', (fixture) => { fixture.manifest.liveBudget.humanCapMicroUsd = '47361023'; }, /human cap.*below.*conservative/i], + ['attempt count', (fixture) => { fixture.manifest.liveBudget.attemptedCalls = 2; }, /attempted-call count/i], + ['budget attempt identity', (fixture) => { fixture.samples[0].budgetAttemptId = null; }, /budgetAttemptId sequence/i], + ['known accrued', (fixture) => { fixture.manifest.liveBudget.knownAccruedMicroUsd = '40'; }, /known accrued/i], + ['provider USD', (fixture) => { fixture.samples[0].providerCostUsd = 0.000040; }, /provider USD cost mismatch/i], + ['acquisition price', (fixture) => { + Object.assign(fixture.samples[0], { + phase: 'acquisition', + acquisitionCostUsd: 0.24, + acquisitionEvidence: 'MODELED', + }); + }, /acquisition row price.*mismatch/i], + ['acquisition', (fixture) => { fixture.samples[0].acquisitionCostUsd = 0.25; }, /non-acquisition row/i], + ]; + for (const [name, mutate, pattern] of cases) { + const fixture = fixtureContract(t); + mutate(fixture); + assert.throws( + () => verifyLiveEvidenceContract(fixture.samples, fixture.manifest, fixture.root), + pattern, + name, + ); + } +}); + +test('live locks are linked to the final budget attempt and exact derived reason', (t) => { + const unknown = fixtureContract(t, { + samples: [normalizedLiveSample({ + success: false, + inputTokens: null, + outputTokens: null, + providerCostMicroUsd: null, + providerCostUsd: null, + failureClass: 'ProviderError', + })], + }); + unknown.manifest.liveBudget.outstandingReservedMicroUsd = '27648'; + unknown.manifest.liveBudget.lock = { kind: 'unknown_cost', attemptId: 'attempt-000001' }; + assert.doesNotThrow(() => verifyLiveEvidenceContract(unknown.samples, unknown.manifest, unknown.root)); + unknown.manifest.liveBudget.lock.attemptId = 'attempt-000002'; + assert.throws( + () => verifyLiveEvidenceContract(unknown.samples, unknown.manifest, unknown.root), + /lock attemptId.*budgetAttemptId/i, + ); + + const inputTokens = approved.tokenCaps.maxInputTokens + 1; + const exact = calculateProviderCostMicroUsd({ inputTokens, outputTokens: 2, snapshot: approved }); + const overrun = fixtureContract(t, { + samples: [normalizedLiveSample({ + success: false, + inputTokens, + outputTokens: 2, + providerCostMicroUsd: exact.toString(), + providerCostUsd: Number(exact) / 1_000_000, + failureClass: 'AggregateError', + })], + }); + overrun.manifest.liveBudget.lock = { + kind: 'budget_overrun', + attemptId: 'attempt-000001', + reason: 'human_cap_exceeded', + }; + assert.throws( + () => verifyLiveEvidenceContract(overrun.samples, overrun.manifest, overrun.root), + /lock reason.*token_cap_exceeded/i, + ); +}); diff --git a/spikes/clone-economics/tests/sweep-cli.test.mjs b/spikes/clone-economics/tests/sweep-cli.test.mjs index c0285a5..e521b0b 100644 --- a/spikes/clone-economics/tests/sweep-cli.test.mjs +++ b/spikes/clone-economics/tests/sweep-cli.test.mjs @@ -42,11 +42,14 @@ test('mock CLI completes without network and stays unpublishable', () => { assert.match(result.stdout, /networkAttempts=0/); }); -test('missing mode and default live contract both fail before construction', () => { +test('missing mode and default live path both fail before construction', () => { const missing = run([]); assert.notEqual(missing.status, 0); assert.match(missing.stderr, /Usage:/); const live = run(['--live']); assert.notEqual(live.status, 0); - assert.match(live.stderr, /Live budget snapshot must be approved/); + assert.match( + live.stderr, + /Live sweep requires a clean checkout before provider execution|Live budget snapshot must be approved/, + ); }); diff --git a/spikes/clone-economics/tests/sweep.test.mjs b/spikes/clone-economics/tests/sweep.test.mjs index 038be6a..f88fba9 100644 --- a/spikes/clone-economics/tests/sweep.test.mjs +++ b/spikes/clone-economics/tests/sweep.test.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { classifyHighNSeedValidity, @@ -14,6 +15,9 @@ import { writeSweepEvidenceBundle, } from '../src/sweep.mjs'; import { scoreEvaluation } from '../src/scoring.mjs'; +import { readGitState } from '../src/git-state.mjs'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const config = { schemaVersion: 1, @@ -227,6 +231,8 @@ test('standalone target execution failure preserves its attempt and final budget }); const evidence = await writeSweepEvidenceBundle({ result, + executionMode: 'mock', + gitState: readGitState(packageRoot), config: { ...config, acquisitionTreatment: 'modeled_unless_x402_receipts_attached', From 315824b874e692073ca6c5897714fc1feec39329 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 00:29:08 -0400 Subject: [PATCH 048/165] fix: bind clone evidence to reproducible claims --- spikes/clone-economics/src/evidence.mjs | 506 ++++++++++++++++-- spikes/clone-economics/src/git-state.mjs | 59 +- .../clone-economics/tests/evidence.test.mjs | 104 +++- .../clone-economics/tests/git-state.test.mjs | 28 +- .../tests/live-evidence-accounting.test.mjs | 158 +++++- 5 files changed, 796 insertions(+), 59 deletions(-) diff --git a/spikes/clone-economics/src/evidence.mjs b/spikes/clone-economics/src/evidence.mjs index af10ee3..8f3c530 100644 --- a/spikes/clone-economics/src/evidence.mjs +++ b/spikes/clone-economics/src/evidence.mjs @@ -11,10 +11,15 @@ import { validateApprovedBudgetSnapshot, validateBudgetSnapshotShape, } from './budget.mjs'; -import { loadFixtureSet } from './fixture-set.mjs'; -import { readGitState } from './git-state.mjs'; +import { normalizedInputHash } from './fixture-set.mjs'; +import { + gitRepositoryRoot, + readGitBlobAtCommit, + readGitState, + resolveGitCommit, +} from './git-state.mjs'; import { validateApprovedLiveEconomics, validateLiveEconomicsShape } from './live-economics.mjs'; -import { validateSweepConfig } from './sweep.mjs'; +import { classifyHighNSeedValidity, seededOrder, validateSweepConfig } from './sweep.mjs'; const evidenceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -36,9 +41,17 @@ const DISTILLATION_SEED_STATUSES = new Set([ const DISTILLATION_SEED_MECHANISMS = new Set([ 'no_seed_requested', 'deterministic_mock_fixture_selection', + 'provider_confirmed_distillation_seed', 'provider_seed_not_supported_by_adapter', 'historical_source_not_recorded', ]); +const DISTILLATION_SEED_CONTRACTS = new Map([ + ['not_requested', 'no_seed_requested'], + ['synthetic_honored', 'deterministic_mock_fixture_selection'], + ['honored', 'provider_confirmed_distillation_seed'], + ['unsupported', 'provider_seed_not_supported_by_adapter'], + ['not_recorded', 'historical_source_not_recorded'], +]); const FORBIDDEN_KEYS = new Set([ 'prompt', 'payload', 'output', 'rawResponse', 'apiKey', 'authorization', 'headers', 'skillText', 'targetSkill', 'targetSkillText', 'referenceText', @@ -101,6 +114,23 @@ const SENSITIVE_VALUE_TOKENS = new Set([ 'raw', 'rawresponse', 'rawpayload', 'rawoutput', 'tmp', 'temp', 'temporary', 'path', 'paths', ]); +const CREDENTIAL_VALUE_PATTERNS = [ + /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/i, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, + /\bAIza[0-9A-Za-z_-]{20,}\b/, + /\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b/i, + /\b(?:glpat|npm|hf)_[A-Za-z0-9_-]{20,}\b/i, + /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/i, + /\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}\b/i, + /\b0x[0-9a-f]{64}\b/i, + /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, + /\bbearer\s+[A-Za-z0-9._~+/-]{12,}\b/i, + /\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key)\s*[:=]\s*\S+/i, +]; + +function containsCredentialValue(value) { + return CREDENTIAL_VALUE_PATTERNS.some((pattern) => pattern.test(value)); +} function containsSensitiveValue(value) { const tokens = value @@ -127,6 +157,9 @@ function assertPortableJson(value, label = 'evidence input', active = new WeakSe if (value === null || typeof value === 'boolean') return; if (typeof value === 'string') { if (value.length > 4096) throw new Error(`${label} exceeds the evidence string length limit`); + if (containsCredentialValue(value)) { + throw new Error(`${label} contains a credential-shaped evidence value`); + } if (checkSensitive && containsSensitiveValue(value)) { throw new Error(`${label} contains a sensitive evidence value`); } @@ -201,11 +234,22 @@ function safeIdentifier(value, label, maxLength) { if (typeof value !== 'string' || value.length === 0 || value.length > maxLength - || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)) { + || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) + || containsCredentialValue(value)) { throw new Error(`${label} must be a bounded safe identifier`); } } +function providerRequestIdentifier(value) { + if (typeof value !== 'string' + || value.length === 0 + || value.length > 128 + || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(value) + || containsCredentialValue(value)) { + throw new Error('providerRequestId must be a bounded provider request identifier'); + } +} + function validateSample(sample) { if (!sample || typeof sample !== 'object' || Array.isArray(sample)) throw new Error('Sample must be an object'); for (const key of Object.keys(sample)) { @@ -219,7 +263,7 @@ function validateSample(sample) { if (!SAMPLE_PROFILES.has(sample.profile)) throw new Error('sample profile is unsupported'); if (sample.caseId !== null) safeIdentifier(sample.caseId, 'caseId', 128); if (sample.replicateId !== null) safeIdentifier(sample.replicateId, 'replicateId', 64); - if (sample.providerRequestId !== null) safeIdentifier(sample.providerRequestId, 'providerRequestId', 128); + if (sample.providerRequestId !== null) providerRequestIdentifier(sample.providerRequestId); if (sample.budgetAttemptId !== null && !(typeof sample.budgetAttemptId === 'string' && /^attempt-\d{6}$/.test(sample.budgetAttemptId))) { throw new Error('budgetAttemptId must be an exact reservation identifier or null'); @@ -237,6 +281,10 @@ function validateSample(sample) { if (!DISTILLATION_SEED_MECHANISMS.has(sample.distillationSeedMechanism)) { throw new Error('distillationSeedMechanism is unsupported'); } + if (DISTILLATION_SEED_CONTRACTS.get(sample.distillationSeedStatus) + !== sample.distillationSeedMechanism) { + throw new Error('distillation seed status and mechanism are inconsistent'); + } if (typeof sample.success !== 'boolean') throw new Error('sample success must be boolean'); finiteNonNegative(sample.latencyMs, 'sample latencyMs'); for (const key of ['inputTokens', 'outputTokens']) { @@ -334,6 +382,90 @@ function nonEmptyString(value, label, { nullable = false } = {}) { } } +function boundedPatternString(value, label, { + nullable = false, + maxLength, + pattern, + description, +}) { + if (nullable && value === null) return; + if (typeof value !== 'string' + || value.length === 0 + || value.length > maxLength + || !pattern.test(value) + || containsCredentialValue(value)) { + throw new Error(`${label} must be a bounded ${description}`); + } +} + +function safeCommand(value, label) { + boundedPatternString(value, label, { + maxLength: 512, + pattern: /^[A-Za-z0-9][A-Za-z0-9 ./:_=@-]*$/, + description: 'safe command', + }); +} + +function safeProviderName(value, label, { nullable = false } = {}) { + boundedPatternString(value, label, { + nullable, + maxLength: 64, + pattern: /^[A-Za-z][A-Za-z0-9._-]*$/, + description: 'provider name', + }); +} + +function safeModelIdentifier(value, label, { nullable = false } = {}) { + boundedPatternString(value, label, { + nullable, + maxLength: 160, + pattern: /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/, + description: 'model identifier', + }); +} + +function safeRuntimeToken(value, label) { + boundedPatternString(value, label, { + maxLength: 64, + pattern: /^[A-Za-z0-9][A-Za-z0-9._+-]*$/, + description: 'runtime token', + }); +} + +function boundedEvidenceText(value, label) { + boundedPatternString(value, label, { + maxLength: 512, + pattern: /^[A-Za-z0-9][A-Za-z0-9 .,;:_()'/-]*$/, + description: 'evidence text', + }); +} + +function safeEvidenceHttpsUrl(value, label, { nullable = false } = {}) { + if (nullable && value === null) return; + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${label} must be a credential-free HTTPS URL`); + } + if (typeof value !== 'string' + || value.length > 512 + || parsed.protocol !== 'https:' + || parsed.username !== '' + || parsed.password !== '' + || parsed.hash !== '' + || containsCredentialValue(value)) { + throw new Error(`${label} must be a credential-free HTTPS URL`); + } +} + +function validateBudgetEvidenceStrings(snapshot, label) { + safeIdentifier(snapshot.experimentFamily, `${label}.experimentFamily`, 128); + safeProviderName(snapshot.provider, `${label}.provider`); + safeModelIdentifier(snapshot.model, `${label}.model`, { nullable: true }); + safeEvidenceHttpsUrl(snapshot.pricing.source, `${label}.pricing.source`, { nullable: true }); +} + function integerArray(value, label, { positive = false, historical = false } = {}) { if (!Array.isArray(value)) throw new Error(`${label} must be an array`); for (let index = 0; index < value.length; index += 1) { @@ -362,8 +494,8 @@ function validateSweepConfiguration(value) { ]; assertExactKeys(value, keys, 'configuration.sweepConfig'); if (value.schemaVersion !== 1) throw new Error('configuration.sweepConfig.schemaVersion must be 1'); - nonEmptyString(value.experimentFamily, 'configuration.sweepConfig.experimentFamily'); - nonEmptyString(value.fixtureSet, 'configuration.sweepConfig.fixtureSet'); + safeIdentifier(value.experimentFamily, 'configuration.sweepConfig.experimentFamily', 128); + safeIdentifier(value.fixtureSet, 'configuration.sweepConfig.fixtureSet', 64); integerArray(value.nValues, 'configuration.sweepConfig.nValues', { positive: true }); for (const key of ['heldoutMinimum', 'highNDefinition']) { if (!Number.isSafeInteger(value[key]) || value[key] <= 0) { @@ -379,14 +511,21 @@ function validateSweepConfiguration(value) { ]) { if (typeof value[key] !== 'boolean') throw new Error(`configuration.sweepConfig.${key} must be boolean`); } - for (const key of ['acquisitionTreatment', 'attemptCostTreatment']) { - nonEmptyString(value[key], `configuration.sweepConfig.${key}`); + if (value.acquisitionTreatment !== 'modeled_unless_x402_receipts_attached') { + throw new Error('configuration.sweepConfig.acquisitionTreatment is unsupported'); + } + if (value.attemptCostTreatment !== 'include_every_attempted_provider_call') { + throw new Error('configuration.sweepConfig.attemptCostTreatment is unsupported'); } if (!Array.isArray(value.replicates)) throw new Error('configuration.sweepConfig.replicates must be an array'); for (let index = 0; index < value.replicates.length; index += 1) { const replicate = value.replicates[index]; assertExactKeys(replicate, ['replicateId', 'pairOrderSeed', 'distillationSeed'], `configuration.sweepConfig.replicates[${index}]`); - nonEmptyString(replicate.replicateId, `configuration.sweepConfig.replicates[${index}].replicateId`); + safeIdentifier( + replicate.replicateId, + `configuration.sweepConfig.replicates[${index}].replicateId`, + 64, + ); for (const key of ['pairOrderSeed', 'distillationSeed']) { if (!Number.isSafeInteger(replicate[key])) { throw new Error(`configuration.sweepConfig.replicates[${index}].${key} must be a safe integer`); @@ -405,7 +544,14 @@ function validateConfiguration(configuration = {}) { } if (Object.hasOwn(configuration, 'sweepConfig')) validateSweepConfiguration(configuration.sweepConfig); if (Object.hasOwn(configuration, 'nValues')) integerArray(configuration.nValues, 'configuration.nValues', { positive: true }); - if (Object.hasOwn(configuration, 'replicateIds')) stringArray(configuration.replicateIds, 'configuration.replicateIds'); + if (Object.hasOwn(configuration, 'replicateIds')) { + if (!Array.isArray(configuration.replicateIds)) { + throw new Error('configuration.replicateIds must be an array'); + } + configuration.replicateIds.forEach((value, index) => { + safeIdentifier(value, `configuration.replicateIds[${index}]`, 64); + }); + } for (const key of ['pairOrderSeeds', 'requestedDistillationSeeds', 'appliedDistillationSeeds']) { if (Object.hasOwn(configuration, key)) integerArray(configuration[key], `configuration.${key}`, { historical: true }); } @@ -420,8 +566,12 @@ function validateConfiguration(configuration = {}) { throw new Error(`configuration.distillationSeedEvidence[${index}].${key} must be a safe integer or null`); } } - nonEmptyString(item.status, `configuration.distillationSeedEvidence[${index}].status`); - nonEmptyString(item.mechanism, `configuration.distillationSeedEvidence[${index}].mechanism`); + if (!DISTILLATION_SEED_STATUSES.has(item.status)) { + throw new Error(`configuration.distillationSeedEvidence[${index}].status is unsupported`); + } + if (!DISTILLATION_SEED_MECHANISMS.has(item.mechanism)) { + throw new Error(`configuration.distillationSeedEvidence[${index}].mechanism is unsupported`); + } }); } if (Object.hasOwn(configuration, 'tokenCaps')) { @@ -435,12 +585,20 @@ function validateConfiguration(configuration = {}) { } if (Object.hasOwn(configuration, 'pricingSnapshot')) { validateBudgetSnapshotShape(configuration.pricingSnapshot, configuration.sweepConfig ?? null); + validateBudgetEvidenceStrings(configuration.pricingSnapshot, 'configuration.pricingSnapshot'); } if (Object.hasOwn(configuration, 'evidenceLabels')) { stringArray(configuration.evidenceLabels, 'configuration.evidenceLabels', EVIDENCE_LABELS); } - for (const key of ['acquisitionTreatment', 'attemptCoverage', 'fixtureSet']) { - if (Object.hasOwn(configuration, key)) nonEmptyString(configuration[key], `configuration.${key}`); + if (Object.hasOwn(configuration, 'acquisitionTreatment') + && !['modeled', 'modeled_unless_x402_receipts_attached'].includes(configuration.acquisitionTreatment)) { + throw new Error('configuration.acquisitionTreatment is unsupported'); + } + if (Object.hasOwn(configuration, 'attemptCoverage')) { + boundedEvidenceText(configuration.attemptCoverage, 'configuration.attemptCoverage'); + } + if (Object.hasOwn(configuration, 'fixtureSet')) { + safeIdentifier(configuration.fixtureSet, 'configuration.fixtureSet', 64); } if (Object.hasOwn(configuration, 'historicalRunDate')) { const value = configuration.historicalRunDate; @@ -477,6 +635,11 @@ function validateConfiguration(configuration = {}) { if (Object.hasOwn(configuration, 'liveEconomics')) { if (!configuration.sweepConfig) throw new Error('configuration.liveEconomics requires sweepConfig'); validateLiveEconomicsShape(configuration.liveEconomics, configuration.sweepConfig); + safeIdentifier( + configuration.liveEconomics.experimentFamily, + 'configuration.liveEconomics.experimentFamily', + 128, + ); } return canonicalize(configuration); } @@ -485,7 +648,9 @@ function validateSourceEvidence(sourceEvidence) { if (sourceEvidence === null || sourceEvidence === undefined) return null; assertPortableJson(sourceEvidence, 'manifest.sourceEvidence'); assertExactKeys(sourceEvidence, ['bytes', 'kind', 'sha256'], 'sourceEvidence'); - if (typeof sourceEvidence.kind !== 'string' || sourceEvidence.kind === '') throw new Error('sourceEvidence kind is required'); + if (sourceEvidence.kind !== 'legacy-report-json') { + throw new Error('sourceEvidence kind must be legacy-report-json'); + } if (!/^[0-9a-f]{64}$/.test(sourceEvidence.sha256)) throw new Error('sourceEvidence sha256 must be a lowercase digest'); if (!Number.isSafeInteger(sourceEvidence.bytes) || sourceEvidence.bytes <= 0) throw new Error('sourceEvidence bytes must be positive'); return { ...sourceEvidence }; @@ -552,7 +717,9 @@ function validateLock(lock) { } else { throw new Error('liveBudget.lock.kind is unsupported'); } - nonEmptyString(lock.attemptId, 'liveBudget.lock.attemptId'); + if (typeof lock.attemptId !== 'string' || !/^attempt-\d{6}$/.test(lock.attemptId)) { + throw new Error('liveBudget.lock.attemptId must be an exact reservation identifier'); + } return canonicalize(lock); } @@ -568,8 +735,11 @@ function validateLiveBudget(liveBudget) { ]; assertExactKeys(liveBudget, keys, 'liveBudget'); for (const key of ['configPath', 'snapshotPath', 'economicsSnapshotPath']) { - nonEmptyString(liveBudget[key], `liveBudget.${key}`); - if (path.isAbsolute(liveBudget[key]) || liveBudget[key].split(/[\\/]/).includes('..')) { + if (typeof liveBudget[key] !== 'string' + || liveBudget[key].length > 160 + || !/^fixtures\/[A-Za-z0-9][A-Za-z0-9._/-]*\.json$/.test(liveBudget[key]) + || path.isAbsolute(liveBudget[key]) + || liveBudget[key].split(/[\\/]/).includes('..')) { throw new Error(`liveBudget.${key} must be repository-relative`); } } @@ -635,7 +805,10 @@ function validateExecutionModeContract({ executionMode, evidenceLabel, liveBudge } } -function validateGitIdentity({ executionMode, gitCommit, gitDirty, sourceEvidence }, { current = false } = {}) { +function validateGitIdentity( + { executionMode, gitCommit, gitDirty, sourceEvidence }, + { current = false, repositoryRoot = null } = {}, +) { if (executionMode === 'historical') { if (gitCommit !== HISTORICAL_GIT_COMMIT || gitDirty !== null) { throw new Error('historical evidence requires the exact unrecorded git identity sentinel'); @@ -652,6 +825,7 @@ function validateGitIdentity({ executionMode, gitCommit, gitDirty, sourceEvidenc if (typeof gitDirty !== 'boolean') throw new Error('manifest.gitDirty is required and must be boolean'); if (executionMode === 'live' && gitDirty) throw new Error('Live evidence requires a clean checkout'); if (sourceEvidence !== null) throw new Error(`${executionMode} evidence forbids sourceEvidence`); + if (repositoryRoot !== null) resolveGitCommit(repositoryRoot, gitCommit); if (current) { const actual = readGitState(evidenceRoot); if (actual.gitCommit !== gitCommit || actual.gitDirty !== gitDirty) { @@ -714,20 +888,22 @@ This bundle does not by itself authorize publication or a live benchmark claim. `; } -function validateManifest(manifest) { +function validateManifest(manifest, { repositoryRoot = null } = {}) { assertPortableJson(manifest, 'manifest'); assertExactKeys(manifest, MANIFEST_KEYS, 'Evidence manifest'); if (manifest.schemaVersion !== 1) throw new Error('Unsupported evidence manifest schemaVersion'); - if (!manifest || typeof manifest.experimentId !== 'string' || manifest.experimentId === '') { - throw new Error('Evidence manifest experimentId is required'); - } + safeIdentifier(manifest.experimentId, 'manifest.experimentId', 128); validateRecordedAtUtc(manifest.recordedAtUtc); validateRecordedAtMode(manifest.recordedAtUtc, manifest.executionMode); - for (const key of ['gitCommit', 'command']) nonEmptyString(manifest[key], `manifest.${key}`); - for (const key of ['modelProvider', 'model']) nonEmptyString(manifest[key], `manifest.${key}`, { nullable: true }); + nonEmptyString(manifest.gitCommit, 'manifest.gitCommit'); + safeCommand(manifest.command, 'manifest.command'); + safeProviderName(manifest.modelProvider, 'manifest.modelProvider', { nullable: true }); + safeModelIdentifier(manifest.model, 'manifest.model', { nullable: true }); if (!EVIDENCE_LABELS.has(manifest.evidenceLabel)) throw new Error('Evidence manifest evidenceLabel is unsupported'); assertExactKeys(manifest.runtime, ['node', 'platform', 'arch'], 'manifest.runtime'); - for (const key of ['node', 'platform', 'arch']) nonEmptyString(manifest.runtime[key], `manifest.runtime.${key}`); + for (const key of ['node', 'platform', 'arch']) { + safeRuntimeToken(manifest.runtime[key], `manifest.runtime.${key}`); + } validateSourceEvidence(manifest.sourceEvidence); const liveBudget = validateLiveBudget(manifest.liveBudget); validateExecutionModeContract({ @@ -740,7 +916,7 @@ function validateManifest(manifest) { gitCommit: manifest.gitCommit, gitDirty: manifest.gitDirty, sourceEvidence: manifest.sourceEvidence, - }); + }, { repositoryRoot }); const configuration = validateConfiguration(manifest.configuration); validateReportInputs(manifest.reportInputs, manifest.evidenceLabel, configuration); validateReadmeInputs(manifest.readmeInputs); @@ -785,11 +961,15 @@ export function writeEvidenceBundle(input) { if (!MANIFEST_INPUT_KEYS.includes(key)) throw new Error(`Unsupported evidence manifest field: ${key}`); } assertPortableJson(manifest, 'manifest input'); - nonEmptyString(manifest.experimentId, 'manifest.experimentId'); - nonEmptyString(manifest.command, 'manifest.command'); + safeIdentifier(manifest.experimentId, 'manifest.experimentId', 128); + safeCommand(manifest.command, 'manifest.command'); if (!EVIDENCE_LABELS.has(manifest.evidenceLabel)) throw new Error('Evidence manifest evidenceLabel is unsupported'); - for (const key of ['gitCommit', 'modelProvider', 'model']) { - if (Object.hasOwn(manifest, key)) nonEmptyString(manifest[key], `manifest.${key}`, { nullable: key !== 'gitCommit' }); + if (Object.hasOwn(manifest, 'gitCommit')) nonEmptyString(manifest.gitCommit, 'manifest.gitCommit'); + if (Object.hasOwn(manifest, 'modelProvider')) { + safeProviderName(manifest.modelProvider, 'manifest.modelProvider', { nullable: true }); + } + if (Object.hasOwn(manifest, 'model')) { + safeModelIdentifier(manifest.model, 'manifest.model', { nullable: true }); } const recordedAtUtc = manifest.recordedAtUtc === undefined ? new Date().toISOString() : manifest.recordedAtUtc; validateRecordedAtUtc(recordedAtUtc); @@ -853,18 +1033,36 @@ export function writeEvidenceBundle(input) { return finalManifest; } -function readRegularFixture(packageRoot, relativePath, label) { - const filePath = path.join(packageRoot, relativePath); - let stat; +function committedTreeContext(packageRoot, recordedCommit) { + const packagePath = fs.realpathSync(path.resolve(packageRoot)); + const repositoryRoot = fs.realpathSync(gitRepositoryRoot(packagePath)); + const packageRelative = path.relative(repositoryRoot, packagePath); + if (packageRelative === '..' + || packageRelative.startsWith(`..${path.sep}`) + || path.isAbsolute(packageRelative)) { + throw new Error('Live package root must be inside the recorded git repository'); + } + return { + repositoryRoot, + recordedCommit: resolveGitCommit(repositoryRoot, recordedCommit), + packagePrefix: packageRelative.split(path.sep).filter(Boolean).join('/'), + }; +} + +function readCommittedJsonFixture(context, relativePath, label) { + const repositoryPath = context.packagePrefix + ? `${context.packagePrefix}/${relativePath}` + : relativePath; + let bytes; try { - stat = fs.lstatSync(filePath); + bytes = readGitBlobAtCommit( + context.repositoryRoot, + context.recordedCommit, + repositoryPath, + ); } catch { - throw new Error(`${label} must exist as a regular file`); + throw new Error(`${label} must exist as a regular blob in the recorded commit`); } - if (stat.isSymbolicLink() || !stat.isFile()) { - throw new Error(`${label} must exist as a regular file`); - } - const bytes = fs.readFileSync(filePath); let parsed; try { parsed = JSON.parse(bytes); @@ -874,6 +1072,29 @@ function readRegularFixture(packageRoot, relativePath, label) { return { bytes, parsed }; } +function fixtureSetFromCommittedValues(train, heldout) { + if (!Array.isArray(train) || !Array.isArray(heldout)) { + throw new Error('Live train and heldout fixtures must be arrays'); + } + const decorate = (items, label) => items.map((item, index) => { + if (!item || typeof item !== 'object' || Array.isArray(item) + || typeof item.id !== 'string' || typeof item.input !== 'string') { + throw new Error(`${label}[${index}] must contain string id and input fields`); + } + return { ...item, inputHash: normalizedInputHash(item.input) }; + }); + const decoratedTrain = decorate(train, 'Live train fixture'); + const decoratedHeldout = decorate(heldout, 'Live heldout fixture'); + const trainIds = new Set(decoratedTrain.map((item) => item.id)); + const trainHashes = new Set(decoratedTrain.map((item) => item.inputHash)); + if (trainIds.size !== decoratedTrain.length + || new Set(decoratedHeldout.map((item) => item.id)).size !== decoratedHeldout.length + || decoratedHeldout.some((item) => trainIds.has(item.id) || trainHashes.has(item.inputHash))) { + throw new Error('Train and heldout fixtures must be unique and disjoint'); + } + return { train: decoratedTrain, heldout: decoratedHeldout }; +} + function requireExactLivePath(actual, expected, label) { if (actual !== expected) throw new Error(`Live ${label} must equal ${expected}`); } @@ -882,6 +1103,168 @@ function canonicalEqual(left, right) { return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)); } +function requireExactCaseRows(rows, expectedFixtures, label) { + const expectedIds = expectedFixtures.map((fixture) => fixture.id).sort(); + const actualIds = rows.map((sample) => sample.caseId).sort(); + if (JSON.stringify(actualIds) !== JSON.stringify(expectedIds)) { + throw new Error(`High-N publication gate samples do not contain the exact ${label} cases`); + } + if (rows.some((sample) => sample.success !== true)) { + throw new Error(`High-N publication gate samples contain a failed ${label} attempt`); + } + return rows; +} + +function targetBenchmarkFromRows(rows, fixtures, config, label) { + requireExactCaseRows(rows, fixtures, label); + if (rows.some((sample) => !Number.isFinite(sample.score) + || typeof sample.criticalGatePass !== 'boolean')) { + throw new Error(`High-N publication gate samples lack complete ${label} metrics`); + } + const absoluteScore = rounded(sum(rows.map((sample) => sample.score)) / rows.length); + const criticalGatePass = rows.every((sample) => sample.criticalGatePass === true); + return { + valid: absoluteScore >= config.targetThreshold + && (!config.requireAllTargetCriticalGates || criticalGatePass), + }; +} + +function validateCellMetadata(rows, n, replicate) { + if (rows.some((sample) => ( + sample.n !== n + || sample.replicateId !== replicate.replicateId + || sample.pairOrderSeed !== replicate.pairOrderSeed + || sample.requestedDistillationSeed !== replicate.distillationSeed + ))) { + throw new Error('High-N publication gate samples differ from preregistered cell metadata'); + } +} + +function evaluationRows(rows, profile, fixtures) { + const ids = new Set(fixtures.map((fixture) => fixture.id)); + return rows.filter((sample) => ( + sample.phase === 'evaluation' + && sample.profile === profile + && ids.has(sample.caseId) + )); +} + +function recomputeHighNPublicationGate({ samples, config, fixtures, v2Fixtures, requestCount }) { + if (samples.length !== requestCount) { + throw new Error('High-N publication gate samples do not cover the complete preregistered sweep'); + } + const validated = samples.map((sample) => validateSample(sample)); + const consumed = new Set(); + const consume = (rows) => rows.forEach((sample) => consumed.add(sample.sampleId)); + + const standalone = validated.filter((sample) => sample.n === null); + if (standalone.some((sample) => ( + sample.phase !== 'evaluation' + || sample.profile !== 'target' + || sample.replicateId !== null + || sample.pairOrderSeed !== null + || sample.requestedDistillationSeed !== null + || sample.appliedDistillationSeed !== null + || sample.distillationSeedStatus !== 'not_requested' + || sample.distillationSeedMechanism !== 'no_seed_requested' + ))) { + throw new Error('High-N publication gate standalone samples have invalid metadata'); + } + const standaloneBenchmark = targetBenchmarkFromRows( + standalone, + fixtures.heldout, + config, + 'standalone target', + ); + consume(standalone); + + const cells = []; + for (const n of config.nValues) { + for (const replicate of config.replicates) { + const rows = validated.filter((sample) => ( + sample.n === n && sample.replicateId === replicate.replicateId + )); + validateCellMetadata(rows, n, replicate); + + const acquisition = rows.filter((sample) => ( + sample.phase === 'acquisition' && sample.profile === 'target' + )); + const expectedAcquisition = seededOrder(fixtures.train, replicate.pairOrderSeed).slice(0, n); + requireExactCaseRows(acquisition, expectedAcquisition, `N=${n} acquisition`); + + const distillation = rows.filter((sample) => sample.phase === 'distillation'); + if (distillation.length !== 1 + || distillation[0].profile !== 'clone' + || distillation[0].caseId !== null + || distillation[0].success !== true) { + throw new Error(`High-N publication gate samples lack the exact N=${n} distillation attempt`); + } + + const target = evaluationRows(rows, 'target', fixtures.heldout); + const clone = evaluationRows(rows, 'clone', fixtures.heldout); + const badClone = evaluationRows(rows, 'bad-clone', fixtures.heldout); + const targetV2 = evaluationRows(rows, 'target', v2Fixtures); + const cloneV2 = evaluationRows(rows, 'clone', v2Fixtures); + const benchmark = targetBenchmarkFromRows(target, fixtures.heldout, config, `N=${n} target`); + requireExactCaseRows(clone, fixtures.heldout, `N=${n} clone`); + requireExactCaseRows(badClone, fixtures.heldout, `N=${n} bad clone`); + requireExactCaseRows(targetV2, v2Fixtures, `N=${n} target v2`); + requireExactCaseRows(cloneV2, v2Fixtures, `N=${n} clone v2`); + + const expectedCount = n + 1 + fixtures.heldout.length * 3 + v2Fixtures.length * 2; + if (rows.length !== expectedCount) { + throw new Error(`High-N publication gate samples contain unexpected N=${n} cell rows`); + } + consume(rows); + const seed = distillation[0]; + cells.push({ + n, + replicateId: replicate.replicateId, + status: 'complete', + benchmark, + requestedDistillationSeed: seed.requestedDistillationSeed, + appliedDistillationSeed: seed.appliedDistillationSeed, + distillationSeedStatus: seed.distillationSeedStatus, + seedEvidenceReconciled: true, + }); + } + } + if (consumed.size !== validated.length) { + throw new Error('High-N publication gate samples contain rows outside the preregistered sweep'); + } + return classifyHighNSeedValidity({ + cells, + adapterMode: 'live', + standaloneBenchmark, + }); +} + +function verifyClaimedHighNPublicationGate({ + samples, + manifest, + configuration, + fixtures, + v2Fixtures, + requestCount, +}) { + const passedLabel = manifest.evidenceLabel === 'LIVE CANDIDATE — PUBLICATION GATE PASSED'; + const passedConfiguration = configuration.publicationGate?.publishableHighN === true; + if (passedLabel !== passedConfiguration) { + throw new Error('Live publication gate label and configuration disagree'); + } + if (!passedConfiguration) return; + const recomputed = recomputeHighNPublicationGate({ + samples, + config: configuration.sweepConfig, + fixtures, + v2Fixtures, + requestCount, + }); + if (!recomputed.valid) { + throw new Error(`Claimed high-N publication gate does not match samples: ${recomputed.reason}`); + } +} + function deriveBudgetViolation({ sample, exactCost, knownAccrued, snapshot, worstCasePerCall, humanCap }) { if (sample.inputTokens > snapshot.tokenCaps.maxInputTokens || sample.outputTokens > snapshot.tokenCaps.maxOutputTokens) { @@ -904,6 +1287,7 @@ export function verifyLiveEvidenceContract(samples, manifest, packageRoot) { if (manifest.gitDirty !== false) throw new Error('Live evidence requires a clean checkout'); if (manifest.sourceEvidence !== null) throw new Error('Live evidence forbids sourceEvidence'); const configuration = validateConfiguration(manifest.configuration); + const tree = committedTreeContext(packageRoot, manifest.gitCommit); requireExactLivePath(liveBudget.configPath, 'fixtures/sweep-v1.json', 'configPath'); requireExactLivePath(liveBudget.snapshotPath, 'fixtures/live-budget-v1.json', 'snapshotPath'); @@ -912,10 +1296,10 @@ export function verifyLiveEvidenceContract(samples, manifest, packageRoot) { 'fixtures/live-economics-v1.json', 'economicsSnapshotPath', ); - const configFile = readRegularFixture(packageRoot, liveBudget.configPath, 'Live sweep config'); - const snapshotFile = readRegularFixture(packageRoot, liveBudget.snapshotPath, 'Live budget snapshot'); - const economicsFile = readRegularFixture( - packageRoot, + const configFile = readCommittedJsonFixture(tree, liveBudget.configPath, 'Live sweep config'); + const snapshotFile = readCommittedJsonFixture(tree, liveBudget.snapshotPath, 'Live budget snapshot'); + const economicsFile = readCommittedJsonFixture( + tree, liveBudget.economicsSnapshotPath, 'Live economics snapshot', ); @@ -932,6 +1316,7 @@ export function verifyLiveEvidenceContract(samples, manifest, packageRoot) { } const config = configFile.parsed; validateBudgetSnapshotShape(snapshotFile.parsed, config); + validateBudgetEvidenceStrings(snapshotFile.parsed, 'Live budget snapshot'); const snapshot = validateApprovedBudgetSnapshot(snapshotFile.parsed, config); const liveEconomics = validateApprovedLiveEconomics(economicsFile.parsed, config); if (manifest.model !== snapshot.model) throw new Error('Live manifest model differs from approved snapshot'); @@ -942,11 +1327,22 @@ export function verifyLiveEvidenceContract(samples, manifest, packageRoot) { // Validate the fixed preregistration before using fixtureSet in any path. validateSweepConfig(config, { trainCount: 100, heldoutCount: 30, v2Count: 0 }); - for (const name of [`train-${config.fixtureSet}.json`, `heldout-${config.fixtureSet}.json`, 'v2-heldout.json']) { - readRegularFixture(packageRoot, path.join('fixtures', name), `Live fixture ${name}`); - } - const fixtures = loadFixtureSet(packageRoot, config.fixtureSet); - const v2Fixtures = readRegularFixture(packageRoot, 'fixtures/v2-heldout.json', 'Live v2 fixtures').parsed; + const trainFile = readCommittedJsonFixture( + tree, + `fixtures/train-${config.fixtureSet}.json`, + `Live fixture train-${config.fixtureSet}.json`, + ); + const heldoutFile = readCommittedJsonFixture( + tree, + `fixtures/heldout-${config.fixtureSet}.json`, + `Live fixture heldout-${config.fixtureSet}.json`, + ); + const fixtures = fixtureSetFromCommittedValues(trainFile.parsed, heldoutFile.parsed); + const v2Fixtures = readCommittedJsonFixture( + tree, + 'fixtures/v2-heldout.json', + 'Live v2 fixtures', + ).parsed; if (!Array.isArray(v2Fixtures)) throw new Error('Live v2 fixtures must be an array'); const counts = { trainCount: fixtures.train.length, @@ -956,6 +1352,14 @@ export function verifyLiveEvidenceContract(samples, manifest, packageRoot) { validateSweepConfig(config, counts); const requestCount = conservativeSweepRequestCount(config, counts); if (requestCount !== 1713) throw new Error('Live committed fixture request count must equal 1713'); + verifyClaimedHighNPublicationGate({ + samples, + manifest, + configuration, + fixtures, + v2Fixtures, + requestCount, + }); const worstCasePerCall = calculateProviderCostMicroUsd({ inputTokens: snapshot.tokenCaps.maxInputTokens, outputTokens: snapshot.tokenCaps.maxOutputTokens, @@ -1102,7 +1506,7 @@ export function verifyEvidenceBundle(dir) { const manifestText = fs.readFileSync(manifestPath, 'utf8'); const manifest = JSON.parse(manifestText); if (manifestText !== stableJson(manifest)) throw new Error('manifest.json must use canonical JSON'); - validateManifest(manifest); + validateManifest(manifest, { repositoryRoot: evidenceRoot }); for (const name of REQUIRED_BUNDLE_FILES) { const filePath = path.join(dir, name); const bytes = fs.readFileSync(filePath); diff --git a/spikes/clone-economics/src/git-state.mjs b/spikes/clone-economics/src/git-state.mjs index 0450c85..9c4a55c 100644 --- a/spikes/clone-economics/src/git-state.mjs +++ b/spikes/clone-economics/src/git-state.mjs @@ -17,14 +17,19 @@ function cleanGitEnvironment() { return env; } -function runGit(repoRoot, args) { +function spawnGit(repoRoot, args, encoding = 'utf8') { const result = spawnSync('git', args, { cwd: repoRoot, - encoding: 'utf8', + encoding, env: cleanGitEnvironment(), shell: false, stdio: ['ignore', 'pipe', 'pipe'], }); + return result; +} + +function runGit(repoRoot, args) { + const result = spawnGit(repoRoot, args); if (result.error || result.status !== 0) { const detail = typeof result.stderr === 'string' ? result.stderr.trim() : ''; throw new Error(`Unable to read exact committed git HEAD${detail ? `: ${detail}` : ''}`); @@ -32,6 +37,56 @@ function runGit(repoRoot, args) { return result.stdout.replace(/\r?\n$/, ''); } +function safeRecordedCommit(value) { + if (typeof value !== 'string' || !/^[0-9a-f]{40}$/.test(value)) { + throw new Error('Recorded git commit does not resolve to a commit'); + } + return value; +} + +export function resolveGitCommit(repoRoot, recordedCommit) { + const commit = safeRecordedCommit(recordedCommit); + const result = spawnGit(repoRoot, ['cat-file', '-t', commit]); + if (result.error || result.status !== 0 || result.stdout.trim() !== 'commit') { + throw new Error('Recorded git commit does not resolve to a commit'); + } + return commit; +} + +export function gitRepositoryRoot(repoRoot) { + const result = spawnGit(repoRoot, ['rev-parse', '--show-toplevel']); + if (result.error || result.status !== 0) { + throw new Error('Unable to resolve git repository root'); + } + return path.resolve(result.stdout.replace(/\r?\n$/, '')); +} + +export function readGitBlobAtCommit(repoRoot, recordedCommit, repositoryRelativePath) { + const commit = resolveGitCommit(repoRoot, recordedCommit); + if (typeof repositoryRelativePath !== 'string' + || repositoryRelativePath.length === 0 + || repositoryRelativePath.length > 512 + || path.isAbsolute(repositoryRelativePath) + || repositoryRelativePath.includes('\\') + || repositoryRelativePath.split('/').includes('..') + || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(repositoryRelativePath)) { + throw new Error('Expected a safe repository-relative committed blob path'); + } + const tree = spawnGit(repoRoot, ['ls-tree', commit, '--', repositoryRelativePath]); + if (tree.error || tree.status !== 0) { + throw new Error('Unable to inspect committed blob'); + } + const match = tree.stdout.match(/^100(?:644|755) blob ([0-9a-f]{40})\t(.+)\n?$/); + if (!match || match[2] !== repositoryRelativePath) { + throw new Error('Recorded tree path must resolve to one regular committed blob'); + } + const blob = spawnGit(repoRoot, ['cat-file', 'blob', match[1]], null); + if (blob.error || blob.status !== 0 || !Buffer.isBuffer(blob.stdout)) { + throw new Error('Unable to read committed blob'); + } + return blob.stdout; +} + export function readGitState(repoRoot) { if (typeof repoRoot !== 'string' || repoRoot === '') { throw new Error('Git repository root is required'); diff --git a/spikes/clone-economics/tests/evidence.test.mjs b/spikes/clone-economics/tests/evidence.test.mjs index 01f235b..6fc46f7 100644 --- a/spikes/clone-economics/tests/evidence.test.mjs +++ b/spikes/clone-economics/tests/evidence.test.mjs @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'; import { recomputeSummary, verifyEvidenceBundle, writeEvidenceBundle } from '../src/evidence.mjs'; import { readGitState } from '../src/git-state.mjs'; import { normalizeSweepSamples, writeSweepEvidenceBundle } from '../src/sweep.mjs'; -import { config } from './fixtures/live-contract.mjs'; +import { approved, config } from './fixtures/live-contract.mjs'; const normalizedSample = (overrides = {}) => ({ sampleId: 'run:target-heldout:a', @@ -474,11 +474,109 @@ test('sample scalar channels reject unsupported enums, unsafe IDs, and sensitive for (const mutation of mutations) { assert.throws( () => recomputeSummary([normalizedSample(mutation)]), - /unsupported|safe identifier|error-class token|sensitive evidence value/, + /unsupported|safe identifier|error-class token|sensitive evidence value|credential-shaped evidence value/, ); } }); +test('every free-form evidence string rejects credential-shaped values', () => { + const fakeCredentialShapes = [ + { providerRequestId: `sk-ant-test-${'A'.repeat(32)}` }, + { sampleId: `AKIA${'X'.repeat(16)}` }, + { caseId: `ghp_${'x'.repeat(32)}` }, + { replicateId: `xoxb-${'1'.repeat(12)}-${'x'.repeat(24)}` }, + { providerRequestId: `eyJ${'a'.repeat(20)}.${'b'.repeat(20)}.${'c'.repeat(20)}` }, + ]; + for (const mutation of fakeCredentialShapes) { + assert.throws( + () => recomputeSummary([normalizedSample(mutation)]), + /credential-shaped|sensitive evidence value|safe .*identifier/i, + ); + } + + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-credential-shapes-')); + try { + const manifests = [ + { experimentId: `ghp_${'x'.repeat(32)}` }, + { model: `sk-test-${'x'.repeat(32)}` }, + { command: `node sweep.mjs --token=xoxb-${'1'.repeat(12)}-${'x'.repeat(24)}` }, + { configuration: { attemptCoverage: '-----BEGIN PRIVATE KEY-----' } }, + ]; + manifests.forEach((manifest, index) => { + const outputDir = path.join(parent, `bundle-${index}`); + assert.throws( + () => writeEvidenceBundle(evidenceInput(outputDir, { manifest })), + /credential-shaped|sensitive evidence value|safe .*identifier/i, + ); + assert.equal(fs.existsSync(outputDir), false); + }); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } +}); + +test('provider request IDs use a provider-ID format, not a generic text channel', () => { + assert.doesNotThrow(() => recomputeSummary([ + normalizedSample({ providerRequestId: 'msg_01SyntheticRequestId' }), + ])); + assert.throws( + () => recomputeSummary([normalizedSample({ providerRequestId: 'request.id:with:punctuation' })]), + /providerRequestId.*provider request identifier/i, + ); +}); + +test('distillation seed status is consistent with its allow-listed mechanism', () => { + assert.doesNotThrow(() => recomputeSummary([normalizedSample({ + distillationSeedStatus: 'honored', + distillationSeedMechanism: 'provider_confirmed_distillation_seed', + })])); + assert.throws(() => recomputeSummary([normalizedSample({ + distillationSeedStatus: 'honored', + distillationSeedMechanism: 'provider_seed_not_supported_by_adapter', + })]), /distillation seed status and mechanism are inconsistent/i); +}); + +test('manifest and configuration text channels use field-specific bounded formats', (t) => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-field-formats-')); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + const cases = [ + [{ experimentId: 'run with spaces' }, /experimentId.*safe identifier/i], + [{ command: 'node sweep.mjs\nsecond command' }, /command.*safe command/i], + [{ modelProvider: 'Anthropic\nInjected' }, /modelProvider.*provider name/i], + [{ model: 'model with spaces' }, /model.*model identifier/i], + [{ configuration: { attemptCoverage: 'first line\nsecond line' } }, /attemptCoverage.*bounded evidence text/i], + [{ configuration: { replicateIds: ['r 1'] } }, /replicateIds\[0\].*safe identifier/i], + [{ configuration: { + pricingSnapshot: { ...approved, provider: 'Anthropic Injected' }, + } }, /pricingSnapshot\.provider.*provider name/i], + [{ configuration: { + pricingSnapshot: { + ...approved, + pricing: { ...approved.pricing, source: 'https://user:pass@example.invalid/pricing' }, + }, + } }, /pricingSnapshot\.pricing\.source.*credential-free HTTPS URL/i], + [{ configuration: { + sweepConfig: { + ...config, + replicates: config.replicates.map((replicate, index) => + index === 0 ? { ...replicate, replicateId: 'r 1' } : replicate), + }, + } }, /replicateId.*safe identifier/i], + ]; + cases.forEach(([manifest, pattern], index) => { + const outputDir = path.join(parent, `bundle-${index}`); + assert.throws(() => writeEvidenceBundle(evidenceInput(outputDir, { manifest })), pattern); + assert.equal(fs.existsSync(outputDir), false); + }); + + const runtimeDir = bundle(t); + const manifestPath = path.join(runtimeDir, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.runtime.platform = 'darwin\ninjected'; + writeCanonicalJson(manifestPath, manifest); + assert.throws(() => verifyEvidenceBundle(runtimeDir), /runtime\.platform.*runtime token/i); +}); + test('manifest and configuration string values reject secret and raw path markers', () => { const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-sensitive-values-')); try { @@ -599,7 +697,7 @@ test('verifier requires exact runtime and file-entry schemas', (t) => { const runtimeManifest = JSON.parse(fs.readFileSync(runtimeManifestPath, 'utf8')); runtimeManifest.runtime.node = null; writeCanonicalJson(runtimeManifestPath, runtimeManifest); - assert.throws(() => verifyEvidenceBundle(runtimeDir), /manifest\.runtime\.node must be a non-empty string/); + assert.throws(() => verifyEvidenceBundle(runtimeDir), /manifest\.runtime\.node must be a bounded runtime token/); const filesDir = bundle(t); const filesManifestPath = path.join(filesDir, 'manifest.json'); diff --git a/spikes/clone-economics/tests/git-state.test.mjs b/spikes/clone-economics/tests/git-state.test.mjs index 6a45d0f..dc0ccb0 100644 --- a/spikes/clone-economics/tests/git-state.test.mjs +++ b/spikes/clone-economics/tests/git-state.test.mjs @@ -5,7 +5,12 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { assertLiveCheckoutClean, readGitState } from '../src/git-state.mjs'; +import { + assertLiveCheckoutClean, + readGitBlobAtCommit, + readGitState, + resolveGitCommit, +} from '../src/git-state.mjs'; function git(cwd, args) { return execFileSync('git', args, { @@ -73,3 +78,24 @@ test('live checkout gate rejects a captured dirty state', () => { () => assertLiveCheckoutClean({ gitCommit: 'a'.repeat(40), gitDirty: false, porcelain: '' }), ); }); + +test('recorded commit resolution rejects nonexistent object IDs', (t) => { + const { repo } = committedRepo(t); + const commit = git(repo, ['rev-parse', 'HEAD']); + assert.equal(resolveGitCommit(repo, commit), commit); + assert.throws( + () => resolveGitCommit(repo, '0'.repeat(40)), + /recorded git commit does not resolve to a commit/i, + ); +}); + +test('committed blobs are read from the recorded tree, not the working copy', (t) => { + const { repo } = committedRepo(t); + const commit = git(repo, ['rev-parse', 'HEAD']); + fs.writeFileSync(path.join(repo, 'tracked.txt'), 'working-copy-only\n'); + assert.equal(readGitBlobAtCommit(repo, commit, 'tracked.txt').toString('utf8'), 'committed\n'); + assert.throws( + () => readGitBlobAtCommit(repo, commit, '../outside.txt'), + /repository-relative committed blob path/i, + ); +}); diff --git a/spikes/clone-economics/tests/live-evidence-accounting.test.mjs b/spikes/clone-economics/tests/live-evidence-accounting.test.mjs index 504cfde..e12a7f6 100644 --- a/spikes/clone-economics/tests/live-evidence-accounting.test.mjs +++ b/spikes/clone-economics/tests/live-evidence-accounting.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -9,11 +10,21 @@ import { fileURLToPath } from 'node:url'; import { liveAuthorizationHash } from '../src/authorization.mjs'; import { calculateProviderCostMicroUsd } from '../src/budget.mjs'; import { verifyLiveEvidenceContract } from '../src/evidence.mjs'; +import { seededOrder } from '../src/sweep.mjs'; import { approved, config, economics } from './fixtures/live-contract.mjs'; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + function writeJson(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); } @@ -48,11 +59,82 @@ function normalizedLiveSample(overrides = {}) { }; } +function completePublicationSamples() { + const train = JSON.parse(fs.readFileSync(path.join(packageRoot, 'fixtures/train-v2.json'), 'utf8')); + const heldout = JSON.parse(fs.readFileSync(path.join(packageRoot, 'fixtures/heldout-v2.json'), 'utf8')); + const v2 = JSON.parse(fs.readFileSync(path.join(packageRoot, 'fixtures/v2-heldout.json'), 'utf8')); + let sequence = 0; + const sample = (overrides) => { + sequence += 1; + return normalizedLiveSample({ + sampleId: `live:sample:${String(sequence).padStart(4, '0')}`, + providerRequestId: `req_${String(sequence).padStart(6, '0')}`, + budgetAttemptId: `attempt-${String(sequence).padStart(6, '0')}`, + ...overrides, + }); + }; + const samples = heldout.map((fixture) => sample({ caseId: fixture.id })); + for (const n of config.nValues) { + for (const replicate of config.replicates) { + const metadata = { + n, + replicateId: replicate.replicateId, + pairOrderSeed: replicate.pairOrderSeed, + requestedDistillationSeed: replicate.distillationSeed, + }; + for (const fixture of seededOrder(train, replicate.pairOrderSeed).slice(0, n)) { + samples.push(sample({ + ...metadata, + phase: 'acquisition', + profile: 'target', + caseId: fixture.id, + score: null, + criticalGatePass: null, + acquisitionCostUsd: economics.invocationPriceUsd, + acquisitionEvidence: 'MODELED', + })); + } + samples.push(sample({ + ...metadata, + phase: 'distillation', + profile: 'clone', + caseId: null, + appliedDistillationSeed: replicate.distillationSeed, + distillationSeedStatus: 'honored', + distillationSeedMechanism: 'provider_confirmed_distillation_seed', + score: null, + criticalGatePass: null, + })); + for (const fixture of heldout) { + for (const profile of ['target', 'clone', 'bad-clone']) { + samples.push(sample({ ...metadata, profile, caseId: fixture.id })); + } + } + for (const fixture of v2) { + for (const profile of ['target', 'clone']) { + samples.push(sample({ + ...metadata, + profile, + caseId: fixture.id, + score: null, + criticalGatePass: null, + })); + } + } + } + } + return samples; +} + function fixtureContract(t, { sweepConfig = config, snapshot = approved, economicsContract = economics, samples = [normalizedLiveSample()], + publicationGate = null, + evidenceLabel = publicationGate?.publishableHighN + ? 'LIVE CANDIDATE — PUBLICATION GATE PASSED' + : 'LIVE CANDIDATE — CONCLUSIONS SUPPRESSED', } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-live-contract-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); @@ -64,6 +146,12 @@ function fixtureContract(t, { writeJson(path.join(fixtures, 'sweep-v1.json'), sweepConfig); writeJson(path.join(fixtures, 'live-budget-v1.json'), snapshot); writeJson(path.join(fixtures, 'live-economics-v1.json'), economicsContract); + git(root, ['init']); + git(root, ['config', 'user.name', 'Evidence Test']); + git(root, ['config', 'user.email', 'evidence@example.invalid']); + git(root, ['add', 'fixtures']); + git(root, ['commit', '-m', 'fixture contract']); + const recordedCommit = git(root, ['rev-parse', 'HEAD']); const configBytes = fs.readFileSync(path.join(fixtures, 'sweep-v1.json')); const snapshotBytes = fs.readFileSync(path.join(fixtures, 'live-budget-v1.json')); const economicsBytes = fs.readFileSync(path.join(fixtures, 'live-economics-v1.json')); @@ -86,15 +174,16 @@ function fixtureContract(t, { samples, manifest: { executionMode: 'live', - gitCommit: 'a'.repeat(40), + gitCommit: recordedCommit, gitDirty: false, - evidenceLabel: 'LIVE CANDIDATE — CONCLUSIONS SUPPRESSED', + evidenceLabel, sourceEvidence: null, modelProvider: 'Anthropic', model: snapshot.model, configuration: { sweepConfig, liveEconomics: economicsContract, + ...(publicationGate === null ? {} : { publicationGate }), }, liveBudget: { configPath: 'fixtures/sweep-v1.json', @@ -125,6 +214,71 @@ test('live contract derives exact committed counts, request budget, and per-row )); }); +test('live verification rejects a syntactically valid commit that does not exist', (t) => { + const fixture = fixtureContract(t); + fixture.manifest.gitCommit = '0'.repeat(40); + assert.throws( + () => verifyLiveEvidenceContract(fixture.samples, fixture.manifest, fixture.root), + /recorded git commit does not resolve to a commit/i, + ); +}); + +test('live verification reads contract blobs from the recorded commit despite working-copy changes', (t) => { + const fixture = fixtureContract(t); + for (const name of ['sweep-v1.json', 'live-budget-v1.json', 'live-economics-v1.json']) { + fs.writeFileSync(path.join(fixture.root, 'fixtures', name), '{"workingCopy":"changed"}\n'); + } + assert.doesNotThrow( + () => verifyLiveEvidenceContract(fixture.samples, fixture.manifest, fixture.root), + ); +}); + +test('live verification rejects hashes taken from a later tree than the recorded commit', (t) => { + const fixture = fixtureContract(t); + const configPath = path.join(fixture.root, 'fixtures', 'sweep-v1.json'); + fs.writeFileSync(configPath, `${JSON.stringify(config)}\n`); + git(fixture.root, ['add', 'fixtures/sweep-v1.json']); + git(fixture.root, ['commit', '-m', 'reformat config']); + fixture.manifest.liveBudget.configSha256 = digest(fs.readFileSync(configPath)); + assert.throws( + () => verifyLiveEvidenceContract(fixture.samples, fixture.manifest, fixture.root), + /config hash mismatch/i, + ); +}); + +test('live publication gate is recomputed from complete committed-contract samples', (t) => { + const samples = completePublicationSamples(); + assert.equal(samples.length, 1713); + const fixture = fixtureContract(t, { + samples, + publicationGate: { publishableHighN: true, suppressionReason: null }, + }); + assert.doesNotThrow( + () => verifyLiveEvidenceContract(fixture.samples, fixture.manifest, fixture.root), + ); +}); + +test('live publication gate rejects a coordinated pass when one high-N target fails', (t) => { + const samples = completePublicationSamples(); + const failed = samples.find((sample) => ( + sample.n === 100 + && sample.replicateId === 'r2' + && sample.profile === 'target' + && sample.phase === 'evaluation' + && sample.score !== null + )); + failed.score = 0; + failed.criticalGatePass = false; + const fixture = fixtureContract(t, { + samples, + publicationGate: { publishableHighN: true, suppressionReason: null }, + }); + assert.throws( + () => verifyLiveEvidenceContract(fixture.samples, fixture.manifest, fixture.root), + /publication gate.*HIGH_N_TARGET_INVALID|high-N publication gate.*samples/i, + ); +}); + test('live contract rejects non-approved budget or economics fixtures', (t) => { const unapprovedBudget = fixtureContract(t, { snapshot: { From 9adea454c4ed8976dcc79f1cea334d66fbb2cd29 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 00:42:12 -0400 Subject: [PATCH 049/165] fix: harden clone evidence trust boundaries --- spikes/clone-economics/src/evidence.mjs | 18 +++++++- spikes/clone-economics/src/git-state.mjs | 1 + .../clone-economics/tests/evidence.test.mjs | 7 ++++ .../clone-economics/tests/git-state.test.mjs | 41 +++++++++++++++++++ .../tests/live-evidence-accounting.test.mjs | 24 +++++++++++ 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/spikes/clone-economics/src/evidence.mjs b/spikes/clone-economics/src/evidence.mjs index 8f3c530..b369742 100644 --- a/spikes/clone-economics/src/evidence.mjs +++ b/spikes/clone-economics/src/evidence.mjs @@ -119,9 +119,11 @@ const CREDENTIAL_VALUE_PATTERNS = [ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, /\bAIza[0-9A-Za-z_-]{20,}\b/, /\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b/i, - /\b(?:glpat|npm|hf)_[A-Za-z0-9_-]{20,}\b/i, + /\bglpat[-_][A-Za-z0-9_-]{20,}\b/i, + /\b(?:npm|hf)_[A-Za-z0-9_-]{20,}\b/i, /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/i, /\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}\b/i, + /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/i, /\b0x[0-9a-f]{64}\b/i, /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, /\bbearer\s+[A-Za-z0-9._~+/-]{12,}\b/i, @@ -1115,6 +1117,18 @@ function requireExactCaseRows(rows, expectedFixtures, label) { return rows; } +function requireExactOrderedCaseRows(rows, expectedFixtures, label) { + const expectedIds = expectedFixtures.map((fixture) => fixture.id); + const actualIds = rows.map((sample) => sample.caseId); + if (JSON.stringify(actualIds) !== JSON.stringify(expectedIds)) { + throw new Error(`High-N publication gate samples do not follow the exact preregistered seeded order for ${label}`); + } + if (rows.some((sample) => sample.success !== true)) { + throw new Error(`High-N publication gate samples contain a failed ${label} attempt`); + } + return rows; +} + function targetBenchmarkFromRows(rows, fixtures, config, label) { requireExactCaseRows(rows, fixtures, label); if (rows.some((sample) => !Number.isFinite(sample.score) @@ -1190,7 +1204,7 @@ function recomputeHighNPublicationGate({ samples, config, fixtures, v2Fixtures, sample.phase === 'acquisition' && sample.profile === 'target' )); const expectedAcquisition = seededOrder(fixtures.train, replicate.pairOrderSeed).slice(0, n); - requireExactCaseRows(acquisition, expectedAcquisition, `N=${n} acquisition`); + requireExactOrderedCaseRows(acquisition, expectedAcquisition, `N=${n} acquisition`); const distillation = rows.filter((sample) => sample.phase === 'distillation'); if (distillation.length !== 1 diff --git a/spikes/clone-economics/src/git-state.mjs b/spikes/clone-economics/src/git-state.mjs index 9c4a55c..1a8a5e4 100644 --- a/spikes/clone-economics/src/git-state.mjs +++ b/spikes/clone-economics/src/git-state.mjs @@ -14,6 +14,7 @@ const GIT_CONTEXT_KEYS = [ function cleanGitEnvironment() { const env = { ...process.env }; for (const key of GIT_CONTEXT_KEYS) delete env[key]; + env.GIT_NO_REPLACE_OBJECTS = '1'; return env; } diff --git a/spikes/clone-economics/tests/evidence.test.mjs b/spikes/clone-economics/tests/evidence.test.mjs index 6fc46f7..12145d7 100644 --- a/spikes/clone-economics/tests/evidence.test.mjs +++ b/spikes/clone-economics/tests/evidence.test.mjs @@ -486,6 +486,11 @@ test('every free-form evidence string rejects credential-shaped values', () => { { caseId: `ghp_${'x'.repeat(32)}` }, { replicateId: `xoxb-${'1'.repeat(12)}-${'x'.repeat(24)}` }, { providerRequestId: `eyJ${'a'.repeat(20)}.${'b'.repeat(20)}.${'c'.repeat(20)}` }, + { providerRequestId: `glpat-${'x'.repeat(24)}` }, + { sampleId: `sk_live_${'x'.repeat(24)}` }, + { caseId: `sk_test_${'x'.repeat(24)}` }, + { replicateId: `rk_live_${'x'.repeat(24)}` }, + { sampleId: `rk_test_${'x'.repeat(24)}` }, ]; for (const mutation of fakeCredentialShapes) { assert.throws( @@ -499,6 +504,8 @@ test('every free-form evidence string rejects credential-shaped values', () => { const manifests = [ { experimentId: `ghp_${'x'.repeat(32)}` }, { model: `sk-test-${'x'.repeat(32)}` }, + { model: `sk_live_${'x'.repeat(24)}` }, + { model: `rk_test_${'x'.repeat(24)}` }, { command: `node sweep.mjs --token=xoxb-${'1'.repeat(12)}-${'x'.repeat(24)}` }, { configuration: { attemptCoverage: '-----BEGIN PRIVATE KEY-----' } }, ]; diff --git a/spikes/clone-economics/tests/git-state.test.mjs b/spikes/clone-economics/tests/git-state.test.mjs index dc0ccb0..983b2e6 100644 --- a/spikes/clone-economics/tests/git-state.test.mjs +++ b/spikes/clone-economics/tests/git-state.test.mjs @@ -99,3 +99,44 @@ test('committed blobs are read from the recorded tree, not the working copy', (t /repository-relative committed blob path/i, ); }); + +test('commit replacement refs cannot change recorded-tree bytes', (t) => { + const { repo } = committedRepo(t); + const recordedCommit = git(repo, ['rev-parse', 'HEAD']); + fs.writeFileSync(path.join(repo, 'tracked.txt'), 'replacement-commit\n'); + git(repo, ['add', 'tracked.txt']); + git(repo, ['commit', '-m', 'replacement commit']); + const replacementCommit = git(repo, ['rev-parse', 'HEAD']); + git(repo, ['replace', recordedCommit, replacementCommit]); + + assert.equal( + readGitBlobAtCommit(repo, recordedCommit, 'tracked.txt').toString('utf8'), + 'committed\n', + ); +}); + +test('blob replacement refs cannot change recorded-tree bytes', (t) => { + const { repo } = committedRepo(t); + const recordedCommit = git(repo, ['rev-parse', 'HEAD']); + const recordedBlob = git(repo, ['rev-parse', 'HEAD:tracked.txt']); + const replacementPath = path.join(repo, 'replacement.txt'); + fs.writeFileSync(replacementPath, 'replacement-blob\n'); + const replacementBlob = git(repo, ['hash-object', '-w', 'replacement.txt']); + git(repo, ['replace', recordedBlob, replacementBlob]); + + assert.equal( + readGitBlobAtCommit(repo, recordedCommit, 'tracked.txt').toString('utf8'), + 'committed\n', + ); +}); + +test('blob-to-commit replacement refs cannot forge commit resolution', (t) => { + const { repo } = committedRepo(t); + const commit = git(repo, ['rev-parse', 'HEAD']); + const blob = git(repo, ['rev-parse', 'HEAD:tracked.txt']); + git(repo, ['update-ref', `refs/replace/${blob}`, commit]); + assert.throws( + () => resolveGitCommit(repo, blob), + /recorded git commit does not resolve to a commit/i, + ); +}); diff --git a/spikes/clone-economics/tests/live-evidence-accounting.test.mjs b/spikes/clone-economics/tests/live-evidence-accounting.test.mjs index e12a7f6..dfd05f0 100644 --- a/spikes/clone-economics/tests/live-evidence-accounting.test.mjs +++ b/spikes/clone-economics/tests/live-evidence-accounting.test.mjs @@ -279,6 +279,30 @@ test('live publication gate rejects a coordinated pass when one high-N target fa ); }); +test('live publication gate rejects acquisition rows outside preregistered seeded order', (t) => { + const samples = completePublicationSamples(); + const acquisitionIndexes = samples + .map((sample, index) => ({ sample, index })) + .filter(({ sample }) => ( + sample.n === 100 + && sample.replicateId === 'r1' + && sample.phase === 'acquisition' + )) + .map(({ index }) => index); + [samples[acquisitionIndexes[0]], samples[acquisitionIndexes[1]]] = [ + samples[acquisitionIndexes[1]], + samples[acquisitionIndexes[0]], + ]; + const fixture = fixtureContract(t, { + samples, + publicationGate: { publishableHighN: true, suppressionReason: null }, + }); + assert.throws( + () => verifyLiveEvidenceContract(fixture.samples, fixture.manifest, fixture.root), + /exact preregistered seeded order/i, + ); +}); + test('live contract rejects non-approved budget or economics fixtures', (t) => { const unapprovedBudget = fixtureContract(t, { snapshot: { From 9812dea4b759f2f768681eabbfe4029aba84a306 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 00:48:29 -0400 Subject: [PATCH 050/165] docs: regenerate invalid clone evidence bundle --- .../2026-07-12-n6-invalid/manifest.json | 22 +++++-- .../evidence/2026-07-12-n6-invalid/report.md | 7 ++- .../2026-07-12-n6-invalid/samples.jsonl | 58 +++++++++---------- 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/manifest.json b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/manifest.json index 0f98f5e..49e3f86 100644 --- a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/manifest.json +++ b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/manifest.json @@ -20,6 +20,7 @@ "sourceTimestamp": "not-recorded" }, "evidenceLabel": "HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED", + "executionMode": "historical", "experimentId": "2026-07-12-n6-invalid", "files": { "README.md": { @@ -27,12 +28,12 @@ "sha256": "2537c49266760dc5b1c20fa83f5dd38d1afae7a46a5a07c30bcaf0223685d52a" }, "report.md": { - "bytes": 532, - "sha256": "0eb9bbe03d0599a4188c3ef421c36881caf730db11b84cbf17aa79587c81c147" + "bytes": 473, + "sha256": "c951d64edb45126386353adf0c9053817bbe4fcc58938a320f381a0ba463a44f" }, "samples.jsonl": { - "bytes": 18424, - "sha256": "bba125d70aa089f45389ccfe882b1dc467169d81328dd9a9ccf309900be301d4" + "bytes": 19091, + "sha256": "beda15ac63f136e9ff413662d978ee89b005d4bdf09d4505bdee8e9faf4dffdd" }, "summary.json": { "bytes": 629, @@ -40,10 +41,23 @@ } }, "gitCommit": "historical-source-not-recorded", + "gitDirty": null, "liveBudget": null, "model": "claude-sonnet-4-6", "modelProvider": "Anthropic", + "readmeInputs": { + "bundlePath": "evidence/2026-07-12-n6-invalid" + }, "recordedAtUtc": null, + "reportInputs": { + "evidenceLabel": "HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED", + "limitations": [ + "ACQUISITION_MODELED", + "HISTORICAL_ATTEMPTS_INCOMPLETE" + ], + "suppressionReason": "INVALID_BENCHMARK_TARGET_FAILED", + "verdict": "INVALID_BENCHMARK_TARGET_FAILED" + }, "runtime": { "arch": "arm64", "node": "v22.22.0", diff --git a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/report.md b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/report.md index 4fd3d8b..974b76d 100644 --- a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/report.md +++ b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/report.md @@ -1,6 +1,11 @@ # Clone-economics evidence report -INVALID_BENCHMARK_TARGET_FAILED. The target scored 0.400 and failed its critical gates, so clone quality, fidelity defense, moat, retention, break-even, and economics conclusions are suppressed. Provider execution was measured where retained; acquisition was modeled. Four earlier setup attempts have no normalized records. +- Evidence label: HISTORICAL MIXED — INVALID BENCHMARK; acquisition MODELED +- Verdict: INVALID_BENCHMARK_TARGET_FAILED +- Suppression reason: INVALID_BENCHMARK_TARGET_FAILED +- Limitations: ACQUISITION_MODELED, HISTORICAL_ATTEMPTS_INCOMPLETE + +## Recomputed metrics - Attempted samples: 29 - Successful samples: 29 diff --git a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/samples.jsonl b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/samples.jsonl index 7004c21..1b85e8e 100644 --- a/spikes/clone-economics/evidence/2026-07-12-n6-invalid/samples.jsonl +++ b/spikes/clone-economics/evidence/2026-07-12-n6-invalid/samples.jsonl @@ -1,29 +1,29 @@ -{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-optimize-checkout","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":16275.222,"n":6,"outputTokens":631,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26829","providerCostUsd":0.026829,"providerRequestId":"msg_011CcxfosYSFSh81ZXQR4EiL","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfosYSFSh81ZXQR4EiL","score":null,"success":true} -{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-generate-auth","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5783,"latencyMs":13784.713082999999,"n":6,"outputTokens":601,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26364","providerCostUsd":0.026364,"providerRequestId":"msg_011Ccxfq4LMddJTfS6EuHuKP","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxfq4LMddJTfS6EuHuKP","score":null,"success":true} -{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-diagnose-scope","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":15894.225249999996,"n":6,"outputTokens":521,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"25179","providerCostUsd":0.025179,"providerRequestId":"msg_011Ccxfr5CxsPnCcwDAGukkh","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxfr5CxsPnCcwDAGukkh","score":null,"success":true} -{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-spec-billing","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5785,"latencyMs":15611.806458,"n":6,"outputTokens":615,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26580","providerCostUsd":0.02658,"providerRequestId":"msg_011CcxfsHatC4uJ4mtsAmw1z","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfsHatC4uJ4mtsAmw1z","score":null,"success":true} -{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-unresolved-command","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5786,"latencyMs":29010.672083000005,"n":6,"outputTokens":1368,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"37878","providerCostUsd":0.037877999999999995,"providerRequestId":"msg_011CcxftQEGSGXBgFp7Q17JQ","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxftQEGSGXBgFp7Q17JQ","score":null,"success":true} -{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"caseId":"tr-preserve-no-deps","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5784,"latencyMs":14978.455082999993,"n":6,"outputTokens":611,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26517","providerCostUsd":0.026517,"providerRequestId":"msg_011CcxfvXvB4AeTBW4Svu6Ut","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfvXvB4AeTBW4Svu6Ut","score":null,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":null,"criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5100,"latencyMs":26304.560541999992,"n":6,"outputTokens":1249,"pairOrderSeed":null,"phase":"distillation","profile":"clone","providerCostMicroUsd":"34035","providerCostUsd":0.034035,"providerRequestId":"msg_011CcxfweAFY2MQ8T2TirNZ3","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfweAFY2MQ8T2TirNZ3","score":null,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5786,"latencyMs":14347.318874999997,"n":6,"outputTokens":572,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"25938","providerCostUsd":0.025938,"providerRequestId":"msg_011CcxfyapdyEFtJNzonCKyQ","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfyapdyEFtJNzonCKyQ","score":0.4,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-generate-export","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5782,"latencyMs":13212.418750000012,"n":6,"outputTokens":523,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"25191","providerCostUsd":0.025190999999999998,"providerRequestId":"msg_011CcxfzdqyeakDhA5Mdzdp2","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfzdqyeakDhA5Mdzdp2","score":0.5,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5790,"latencyMs":16173.565042000002,"n":6,"outputTokens":610,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"26520","providerCostUsd":0.026520000000000002,"providerRequestId":"msg_011Ccxg1cKUjcCpKwZd8rU6L","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg1cKUjcCpKwZd8rU6L","score":0.4,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-spec-audit","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5791,"latencyMs":16394.731042,"n":6,"outputTokens":716,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"28113","providerCostUsd":0.028113,"providerRequestId":"msg_011Ccxg2oR14qX65cni3xmcg","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg2oR14qX65cni3xmcg","score":0.4,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-unresolved-pattern","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":16397.416291,"n":6,"outputTokens":723,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"28209","providerCostUsd":0.028209,"providerRequestId":"msg_011Ccxg41ZXkHRd3QvnHks2U","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg41ZXkHRd3QvnHks2U","score":0.4,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-preserve-json","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":10850.73666700002,"n":6,"outputTokens":436,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"23904","providerCostUsd":0.023904,"providerRequestId":"msg_011Ccxg5DjnjDMFo5h8Mb9QQ","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg5DjnjDMFo5h8Mb9QQ","score":0.3,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1748,"latencyMs":10833.348874999996,"n":6,"outputTokens":546,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"13434","providerCostUsd":0.013434,"providerRequestId":"msg_011Ccxg62Cn96ZS7R4jqaskM","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg62Cn96ZS7R4jqaskM","score":0.3,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-generate-export","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1744,"latencyMs":9036.210250000004,"n":6,"outputTokens":459,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"12117","providerCostUsd":0.012117,"providerRequestId":"msg_011Ccxg6pLBFzm5M8QakTq1y","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg6pLBFzm5M8QakTq1y","score":0.3,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1752,"latencyMs":12297.006874999992,"n":6,"outputTokens":627,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"14661","providerCostUsd":0.014661,"providerRequestId":"msg_011Ccxg7VTu39MQ1Ko2pRDcx","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg7VTu39MQ1Ko2pRDcx","score":0.3,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-spec-audit","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1753,"latencyMs":13784.64254099998,"n":6,"outputTokens":585,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"14034","providerCostUsd":0.014034,"providerRequestId":"msg_011Ccxg8Pnch42zys9ZHvUKt","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg8Pnch42zys9ZHvUKt","score":0.1,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-unresolved-pattern","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1750,"latencyMs":25843.72425000003,"n":6,"outputTokens":1558,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"28620","providerCostUsd":0.02862,"providerRequestId":"msg_011Ccxg9QsNxEzbsPETdFhPT","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg9QsNxEzbsPETdFhPT","score":0.2,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-preserve-json","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1750,"latencyMs":11227.776290999958,"n":6,"outputTokens":536,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"13290","providerCostUsd":0.01329,"providerRequestId":"msg_011CcxgBLaDfbxSX51NqrqBB","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgBLaDfbxSX51NqrqBB","score":0.3,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":428,"latencyMs":3446.199874999991,"n":6,"outputTokens":118,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"3054","providerCostUsd":0.0030540000000000003,"providerRequestId":"msg_011CcxgC8k6x51iHLYFcBcbF","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgC8k6x51iHLYFcBcbF","score":0.2,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-generate-export","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":424,"latencyMs":3095.5347499999916,"n":6,"outputTokens":103,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2817","providerCostUsd":0.002817,"providerRequestId":"msg_011CcxgCPapmAtjS7KWQwBRU","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCPapmAtjS7KWQwBRU","score":0.1,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":432,"latencyMs":2364.7582500000135,"n":6,"outputTokens":64,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2256","providerCostUsd":0.002256,"providerRequestId":"msg_011CcxgCcn4tSmQc9YShWEvu","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCcn4tSmQc9YShWEvu","score":0.2,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-spec-audit","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":433,"latencyMs":2558.8058329999913,"n":6,"outputTokens":89,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2634","providerCostUsd":0.002634,"providerRequestId":"msg_011CcxgCnnpuXD6WDQS16kYr","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCnnpuXD6WDQS16kYr","score":0.2,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-unresolved-pattern","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":430,"latencyMs":2277.9934999999823,"n":6,"outputTokens":70,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2340","providerCostUsd":0.00234,"providerRequestId":"msg_011CcxgCyocRkCF2BRk61h7A","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCyocRkCF2BRk61h7A","score":0.2,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"ho-preserve-json","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":430,"latencyMs":3092.2258750000037,"n":6,"outputTokens":106,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2880","providerCostUsd":0.0028799999999999997,"providerRequestId":"msg_011CcxgD9XG5iv9Jvgp23wSK","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgD9XG5iv9Jvgp23wSK","score":0.1,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"v2-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5848,"latencyMs":16682.59712500003,"n":6,"outputTokens":695,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"27969","providerCostUsd":0.027969,"providerRequestId":"msg_011CcxgDNz8Zu3EWJvu5C8Nq","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgDNz8Zu3EWJvu5C8Nq","score":0.416666666667,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"v2-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5849,"latencyMs":19427.836750000017,"n":6,"outputTokens":780,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"29247","providerCostUsd":0.029247000000000002,"providerRequestId":"msg_011CcxgEcG8CN3sXKEc1g1qf","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgEcG8CN3sXKEc1g1qf","score":0.583333333333,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"v2-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1766,"latencyMs":9699.860417000018,"n":6,"outputTokens":462,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"12228","providerCostUsd":0.012228,"providerRequestId":"msg_011CcxgG3MGLbXyJS4xdKzUH","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgG3MGLbXyJS4xdKzUH","score":0.25,"success":true} -{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"caseId":"v2-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1767,"latencyMs":9541.945332999981,"n":6,"outputTokens":501,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"12816","providerCostUsd":0.012816000000000001,"providerRequestId":"msg_011CcxgGkndooxKxJ988JudK","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgGkndooxKxJ988JudK","score":0.25,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"tr-optimize-checkout","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":16275.222,"n":6,"outputTokens":631,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26829","providerCostUsd":0.026829,"providerRequestId":"msg_011CcxfosYSFSh81ZXQR4EiL","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfosYSFSh81ZXQR4EiL","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"tr-generate-auth","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5783,"latencyMs":13784.713082999999,"n":6,"outputTokens":601,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26364","providerCostUsd":0.026364,"providerRequestId":"msg_011Ccxfq4LMddJTfS6EuHuKP","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxfq4LMddJTfS6EuHuKP","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"tr-diagnose-scope","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":15894.225249999996,"n":6,"outputTokens":521,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"25179","providerCostUsd":0.025179,"providerRequestId":"msg_011Ccxfr5CxsPnCcwDAGukkh","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxfr5CxsPnCcwDAGukkh","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"tr-spec-billing","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5785,"latencyMs":15611.806458,"n":6,"outputTokens":615,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26580","providerCostUsd":0.02658,"providerRequestId":"msg_011CcxfsHatC4uJ4mtsAmw1z","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfsHatC4uJ4mtsAmw1z","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"tr-unresolved-command","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5786,"latencyMs":29010.672083000005,"n":6,"outputTokens":1368,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"37878","providerCostUsd":0.037877999999999995,"providerRequestId":"msg_011CcxftQEGSGXBgFp7Q17JQ","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxftQEGSGXBgFp7Q17JQ","score":null,"success":true} +{"acquisitionCostUsd":0.25,"acquisitionEvidence":"MODELED","appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"tr-preserve-no-deps","criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5784,"latencyMs":14978.455082999993,"n":6,"outputTokens":611,"pairOrderSeed":null,"phase":"acquisition","profile":"target","providerCostMicroUsd":"26517","providerCostUsd":0.026517,"providerRequestId":"msg_011CcxfvXvB4AeTBW4Svu6Ut","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfvXvB4AeTBW4Svu6Ut","score":null,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":null,"criticalGatePass":null,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5100,"latencyMs":26304.560541999992,"n":6,"outputTokens":1249,"pairOrderSeed":null,"phase":"distillation","profile":"clone","providerCostMicroUsd":"34035","providerCostUsd":0.034035,"providerRequestId":"msg_011CcxfweAFY2MQ8T2TirNZ3","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfweAFY2MQ8T2TirNZ3","score":null,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5786,"latencyMs":14347.318874999997,"n":6,"outputTokens":572,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"25938","providerCostUsd":0.025938,"providerRequestId":"msg_011CcxfyapdyEFtJNzonCKyQ","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfyapdyEFtJNzonCKyQ","score":0.4,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-generate-export","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5782,"latencyMs":13212.418750000012,"n":6,"outputTokens":523,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"25191","providerCostUsd":0.025190999999999998,"providerRequestId":"msg_011CcxfzdqyeakDhA5Mdzdp2","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxfzdqyeakDhA5Mdzdp2","score":0.5,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5790,"latencyMs":16173.565042000002,"n":6,"outputTokens":610,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"26520","providerCostUsd":0.026520000000000002,"providerRequestId":"msg_011Ccxg1cKUjcCpKwZd8rU6L","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg1cKUjcCpKwZd8rU6L","score":0.4,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-spec-audit","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5791,"latencyMs":16394.731042,"n":6,"outputTokens":716,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"28113","providerCostUsd":0.028113,"providerRequestId":"msg_011Ccxg2oR14qX65cni3xmcg","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg2oR14qX65cni3xmcg","score":0.4,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-unresolved-pattern","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":16397.416291,"n":6,"outputTokens":723,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"28209","providerCostUsd":0.028209,"providerRequestId":"msg_011Ccxg41ZXkHRd3QvnHks2U","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg41ZXkHRd3QvnHks2U","score":0.4,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-preserve-json","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5788,"latencyMs":10850.73666700002,"n":6,"outputTokens":436,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"23904","providerCostUsd":0.023904,"providerRequestId":"msg_011Ccxg5DjnjDMFo5h8Mb9QQ","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg5DjnjDMFo5h8Mb9QQ","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1748,"latencyMs":10833.348874999996,"n":6,"outputTokens":546,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"13434","providerCostUsd":0.013434,"providerRequestId":"msg_011Ccxg62Cn96ZS7R4jqaskM","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg62Cn96ZS7R4jqaskM","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-generate-export","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1744,"latencyMs":9036.210250000004,"n":6,"outputTokens":459,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"12117","providerCostUsd":0.012117,"providerRequestId":"msg_011Ccxg6pLBFzm5M8QakTq1y","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg6pLBFzm5M8QakTq1y","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1752,"latencyMs":12297.006874999992,"n":6,"outputTokens":627,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"14661","providerCostUsd":0.014661,"providerRequestId":"msg_011Ccxg7VTu39MQ1Ko2pRDcx","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg7VTu39MQ1Ko2pRDcx","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-spec-audit","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1753,"latencyMs":13784.64254099998,"n":6,"outputTokens":585,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"14034","providerCostUsd":0.014034,"providerRequestId":"msg_011Ccxg8Pnch42zys9ZHvUKt","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg8Pnch42zys9ZHvUKt","score":0.1,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-unresolved-pattern","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1750,"latencyMs":25843.72425000003,"n":6,"outputTokens":1558,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"28620","providerCostUsd":0.02862,"providerRequestId":"msg_011Ccxg9QsNxEzbsPETdFhPT","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011Ccxg9QsNxEzbsPETdFhPT","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-preserve-json","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1750,"latencyMs":11227.776290999958,"n":6,"outputTokens":536,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"13290","providerCostUsd":0.01329,"providerRequestId":"msg_011CcxgBLaDfbxSX51NqrqBB","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgBLaDfbxSX51NqrqBB","score":0.3,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":428,"latencyMs":3446.199874999991,"n":6,"outputTokens":118,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"3054","providerCostUsd":0.0030540000000000003,"providerRequestId":"msg_011CcxgC8k6x51iHLYFcBcbF","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgC8k6x51iHLYFcBcbF","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-generate-export","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":424,"latencyMs":3095.5347499999916,"n":6,"outputTokens":103,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2817","providerCostUsd":0.002817,"providerRequestId":"msg_011CcxgCPapmAtjS7KWQwBRU","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCPapmAtjS7KWQwBRU","score":0.1,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":432,"latencyMs":2364.7582500000135,"n":6,"outputTokens":64,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2256","providerCostUsd":0.002256,"providerRequestId":"msg_011CcxgCcn4tSmQc9YShWEvu","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCcn4tSmQc9YShWEvu","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-spec-audit","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":433,"latencyMs":2558.8058329999913,"n":6,"outputTokens":89,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2634","providerCostUsd":0.002634,"providerRequestId":"msg_011CcxgCnnpuXD6WDQS16kYr","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCnnpuXD6WDQS16kYr","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-unresolved-pattern","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":430,"latencyMs":2277.9934999999823,"n":6,"outputTokens":70,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2340","providerCostUsd":0.00234,"providerRequestId":"msg_011CcxgCyocRkCF2BRk61h7A","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgCyocRkCF2BRk61h7A","score":0.2,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"ho-preserve-json","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":430,"latencyMs":3092.2258750000037,"n":6,"outputTokens":106,"pairOrderSeed":null,"phase":"evaluation","profile":"bad-clone","providerCostMicroUsd":"2880","providerCostUsd":0.0028799999999999997,"providerRequestId":"msg_011CcxgD9XG5iv9Jvgp23wSK","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgD9XG5iv9Jvgp23wSK","score":0.1,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"v2-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5848,"latencyMs":16682.59712500003,"n":6,"outputTokens":695,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"27969","providerCostUsd":0.027969,"providerRequestId":"msg_011CcxgDNz8Zu3EWJvu5C8Nq","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgDNz8Zu3EWJvu5C8Nq","score":0.416666666667,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"v2-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":5849,"latencyMs":19427.836750000017,"n":6,"outputTokens":780,"pairOrderSeed":null,"phase":"evaluation","profile":"target","providerCostMicroUsd":"29247","providerCostUsd":0.029247000000000002,"providerRequestId":"msg_011CcxgEcG8CN3sXKEc1g1qf","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgEcG8CN3sXKEc1g1qf","score":0.583333333333,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"v2-optimize-cache","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1766,"latencyMs":9699.860417000018,"n":6,"outputTokens":462,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"12228","providerCostUsd":0.012228,"providerRequestId":"msg_011CcxgG3MGLbXyJS4xdKzUH","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgG3MGLbXyJS4xdKzUH","score":0.25,"success":true} +{"acquisitionCostUsd":0,"acquisitionEvidence":null,"appliedDistillationSeed":null,"budgetAttemptId":null,"caseId":"v2-diagnose-session","criticalGatePass":false,"distillationSeedMechanism":"historical_source_not_recorded","distillationSeedStatus":"not_recorded","failureClass":null,"inputTokens":1767,"latencyMs":9541.945332999981,"n":6,"outputTokens":501,"pairOrderSeed":null,"phase":"evaluation","profile":"clone","providerCostMicroUsd":"12816","providerCostUsd":0.012816000000000001,"providerRequestId":"msg_011CcxgGkndooxKxJ988JudK","replicateId":null,"requestedDistillationSeed":null,"sampleId":"legacy:msg_011CcxgGkndooxKxJ988JudK","score":0.25,"success":true} From 54afaa187c80ae485dfde0ba23571d17296ddaaa Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 00:51:37 -0400 Subject: [PATCH 051/165] feat: estimate gas for remaining Phase 0 writes --- phase0/src/demo.ts | 1 + phase0/src/funding.ts | 17 +++++++++++++++++ phase0/src/story.ts | 5 +++++ phase0/tests/demo.test.ts | 5 +++++ phase0/tests/funding.test.ts | 31 +++++++++++++++++++++++++++++++ phase0/tests/story.test.ts | 1 + 6 files changed, 60 insertions(+) create mode 100644 phase0/src/funding.ts create mode 100644 phase0/tests/funding.test.ts diff --git a/phase0/src/demo.ts b/phase0/src/demo.ts index c17ac30..90ff0b3 100644 --- a/phase0/src/demo.ts +++ b/phase0/src/demo.ts @@ -38,6 +38,7 @@ export interface DemoMetadataProvider { export interface DemoChain { getChainId(): Promise; getBalance(address: `0x${string}`): Promise; + getGasPrice(): Promise; createCollection(input: { name: string; symbol: string; diff --git a/phase0/src/funding.ts b/phase0/src/funding.ts new file mode 100644 index 0000000..76a129f --- /dev/null +++ b/phase0/src/funding.ts @@ -0,0 +1,17 @@ +/** + * Conservative native-gas envelope for the complete four-write Phase 0 demo. + * This is an estimate, not a measured or guaranteed final fee. + */ +export const DEMO_GAS_UNITS_ENVELOPE = 6_000_000n; + +export function estimateRemainingDemoGasMinimum(input: { + gasPrice: bigint; + remainingNewWrites: number; +}): bigint { + if (!Number.isSafeInteger(input.remainingNewWrites) || input.remainingNewWrites < 0) { + throw new Error("Remaining new writes must be a non-negative safe integer"); + } + if (input.remainingNewWrites === 0) return 0n; + if (input.gasPrice <= 0n) throw new Error("Gas price must be positive"); + return input.gasPrice * DEMO_GAS_UNITS_ENVELOPE; +} diff --git a/phase0/src/story.ts b/phase0/src/story.ts index b846dfd..abb8181 100644 --- a/phase0/src/story.ts +++ b/phase0/src/story.ts @@ -21,6 +21,7 @@ export type StorySdkBoundary = { export interface StoryPublicClientBoundary { getChainId(): Promise; getBalance(input: { address: Address }): Promise; + getGasPrice(): Promise; } function required(value: T | undefined, label: string): T { @@ -52,6 +53,10 @@ export class StoryChain implements DemoChain { return this.publicClient.getBalance({ address }); } + getGasPrice(): Promise { + return this.publicClient.getGasPrice(); + } + async createCollection(input: { name: string; symbol: string; diff --git a/phase0/tests/demo.test.ts b/phase0/tests/demo.test.ts index 9764689..3857b06 100644 --- a/phase0/tests/demo.test.ts +++ b/phase0/tests/demo.test.ts @@ -70,6 +70,7 @@ class FakeChain implements DemoChain { writes: string[] = []; derivativeInputs: Array<{ parentIpId: string; licenseTermsId: bigint; maxMintingFee: bigint }> = []; failOn: "collection" | "root" | "child" | "grandchild" | null = null; + gasPrice = 2n; constructor( public chainId: number = AENEID_CHAIN_ID, @@ -85,6 +86,10 @@ class FakeChain implements DemoChain { return this.balance; } + async getGasPrice() { + return this.gasPrice; + } + async createCollection() { this.writes.push("collection"); if (this.failOn === "collection") throw new Error("collection failed"); diff --git a/phase0/tests/funding.test.ts b/phase0/tests/funding.test.ts new file mode 100644 index 0000000..529ecec --- /dev/null +++ b/phase0/tests/funding.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEMO_GAS_UNITS_ENVELOPE, + estimateRemainingDemoGasMinimum, +} from "../src/funding"; + +test("no remaining new write needs no native-gas estimate", () => { + assert.equal(estimateRemainingDemoGasMinimum({ gasPrice: 0n, remainingNewWrites: 0 }), 0n); +}); + +test("any remaining new write retains the full conservative gas envelope", () => { + const minimum = estimateRemainingDemoGasMinimum({ gasPrice: 2n, remainingNewWrites: 1 }); + assert.equal(minimum, 2n * DEMO_GAS_UNITS_ENVELOPE); + assert.equal( + estimateRemainingDemoGasMinimum({ gasPrice: 2n, remainingNewWrites: 4 }), + minimum, + ); +}); + +test("invalid gas prices fail closed", () => { + assert.throws( + () => estimateRemainingDemoGasMinimum({ gasPrice: 0n, remainingNewWrites: 1 }), + /gas price must be positive/i, + ); + assert.throws( + () => estimateRemainingDemoGasMinimum({ gasPrice: 1n, remainingNewWrites: -1 }), + /remaining new writes/i, + ); +}); diff --git a/phase0/tests/story.test.ts b/phase0/tests/story.test.ts index 3a8e8f9..167b44f 100644 --- a/phase0/tests/story.test.ts +++ b/phase0/tests/story.test.ts @@ -45,6 +45,7 @@ function chain(boundary = sdk()) { publicClient: { getChainId: async () => 1315, getBalance: async () => 1n, + getGasPrice: async () => 2n, }, }); } From 8f06463e889769f1a1de66d6d342fdd9007cbb47 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 01:04:54 -0400 Subject: [PATCH 052/165] feat: journal pending Aeneid transactions --- phase0/.gitignore | 6 + phase0/src/transactions.ts | 682 ++++++++++++++++++++++++++++++ phase0/tests/transactions.test.ts | 426 +++++++++++++++++++ 3 files changed, 1114 insertions(+) create mode 100644 phase0/src/transactions.ts create mode 100644 phase0/tests/transactions.test.ts diff --git a/phase0/.gitignore b/phase0/.gitignore index d21b1cb..3d621f5 100644 --- a/phase0/.gitignore +++ b/phase0/.gitignore @@ -2,3 +2,9 @@ node_modules/ .env dist/ *.log + +# Contains a signed, testnet-only transaction while crash recovery is in flight. +pending-transactions.json +pending-transactions.json.*.tmp +pending-transactions.json.lock +pending-transactions.json.lock.*.claim diff --git a/phase0/src/transactions.ts b/phase0/src/transactions.ts new file mode 100644 index 0000000..a7f6ef9 --- /dev/null +++ b/phase0/src/transactions.ts @@ -0,0 +1,682 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + link, + mkdir, + open, + rename, + stat, + unlink, + type FileHandle, +} from "node:fs/promises"; +import { hostname as localHostname } from "node:os"; +import { dirname } from "node:path"; +import { keccak256, type Hex } from "viem"; + +export type OperationStage = "collection" | "root" | "child" | "grandchild"; +export type OperationState = "prepared" | "broadcast"; + +export interface CanonicalOperationIntent { + stage: OperationStage; + chainId: 1315; + wallet: `0x${string}`; + registrationName: string | null; + artifactPath: string | null; + spgNftContract: `0x${string}` | null; + parentIpId: `0x${string}` | null; + licenseTermsId: string | null; + licenseTemplate: `0x${string}` | null; + currencyToken: `0x${string}` | null; + defaultMintingFee: string | null; + maxMintingFee: string | null; + metadata: { + ipMetadataURI: string; + ipMetadataHash: `0x${string}`; + nftMetadataURI: string; + nftMetadataHash: `0x${string}`; + artifactMediaHash: `0x${string}`; + artifactMediaType: string; + } | null; + runConfigHash: `0x${string}`; +} + +export interface CanonicalRunConfigStage { + stage: Exclude; + name: string; + description: string; + artifactPath: string; + artifactSha256: `0x${string}`; +} + +export interface CanonicalRunConfig { + chainId: 1315; + wallet: `0x${string}`; + stages: readonly CanonicalRunConfigStage[]; +} + +export interface PendingOperation { + schemaVersion: 1; + operationId: string; + stage: OperationStage; + intent: CanonicalOperationIntent; + intentHash: `0x${string}`; + transactionHash: `0x${string}`; + serializedTransaction: `0x${string}`; + state: OperationState; +} + +export interface JournalSnapshot { + revision: number; + operation: PendingOperation | null; +} + +export interface LeasedOperationJournal { + load(): Promise; + save(operation: PendingOperation, expectedRevision: number): Promise; + clear(operationId: string, expectedRevision: number): Promise; +} + +export interface OperationJournal { + withExclusiveLease(callback: (journal: LeasedOperationJournal) => Promise): Promise; +} + +interface JournalFile { + schemaVersion: 1; + revision: number; + operation: PendingOperation | null; +} + +interface LeaseOwner { + leaseId: string; + hostname: string; + pid: number; + startedAtUtc: string; +} + +interface TransactionJournalTestDependencies { + afterTemporarySync?(): void | Promise; + afterLeaseClaim?(candidatePath: string): void | Promise; + /** Test-only dependency seam; production construction omits this. */ + isProcessAlive?(pid: number): boolean | Promise; +} + +export interface StaleLockRecoveryInput { + expectedLeaseId: string; +} + +const OPERATION_STAGES = new Set(["collection", "root", "child", "grandchild"]); +const OPERATION_STATES = new Set(["prepared", "broadcast"]); +const HASH_32 = /^0x[0-9a-f]{64}$/; +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const SERIALIZED_TRANSACTION = /^0x(?:[0-9a-fA-F]{2})+$/; +const LEASE_ID = /^[0-9a-f]{32}$/; +const DECIMAL_INTEGER = /^(?:0|[1-9][0-9]*)$/; + +const INTENT_KEYS = [ + "stage", + "chainId", + "wallet", + "registrationName", + "artifactPath", + "spgNftContract", + "parentIpId", + "licenseTermsId", + "licenseTemplate", + "currencyToken", + "defaultMintingFee", + "maxMintingFee", + "metadata", + "runConfigHash", +] as const; + +const METADATA_KEYS = [ + "ipMetadataURI", + "ipMetadataHash", + "nftMetadataURI", + "nftMetadataHash", + "artifactMediaHash", + "artifactMediaType", +] as const; + +function asRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function requireExactKeys(value: Record, keys: readonly string[], label: string): void { + const actual = Object.keys(value); + if (actual.length !== keys.length || actual.some((key, index) => key !== keys[index])) { + throw new Error(`${label} has unexpected, missing, or non-canonical fields`); + } +} + +function requireNonemptyString(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${label} must be a nonempty string`); + } +} + +function requireNullableString(value: unknown, label: string): asserts value is string | null { + if (value !== null) requireNonemptyString(value, label); +} + +function requireHash(value: unknown, label: string): asserts value is `0x${string}` { + if (typeof value !== "string" || !HASH_32.test(value)) { + throw new Error(`${label} must be a lowercase 32-byte hash`); + } +} + +function requireAddress(value: unknown, label: string): asserts value is `0x${string}` { + if (typeof value !== "string" || !ADDRESS.test(value)) { + throw new Error(`${label} must be a 20-byte address`); + } +} + +function requireNullableAddress(value: unknown, label: string): asserts value is `0x${string}` | null { + if (value !== null) requireAddress(value, label); +} + +function requireNullableDecimal(value: unknown, label: string): asserts value is string | null { + if (value !== null && (typeof value !== "string" || !DECIMAL_INTEGER.test(value))) { + throw new Error(`${label} must be a non-negative decimal integer string or null`); + } +} + +function validateMetadata(value: unknown): void { + const metadata = asRecord(value, "Pending operation metadata"); + requireExactKeys(metadata, METADATA_KEYS, "Pending operation metadata"); + requireNonemptyString(metadata.ipMetadataURI, "IP metadata URI"); + requireHash(metadata.ipMetadataHash, "IP metadata hash"); + requireNonemptyString(metadata.nftMetadataURI, "NFT metadata URI"); + requireHash(metadata.nftMetadataHash, "NFT metadata hash"); + requireHash(metadata.artifactMediaHash, "Artifact media hash"); + requireNonemptyString(metadata.artifactMediaType, "Artifact media type"); +} + +function validateCanonicalIntent(value: unknown): asserts value is CanonicalOperationIntent { + const intent = asRecord(value, "Pending operation intent"); + requireExactKeys(intent, INTENT_KEYS, "Pending operation intent"); + if (typeof intent.stage !== "string" || !OPERATION_STAGES.has(intent.stage as OperationStage)) { + throw new Error("Pending operation intent stage is invalid"); + } + if (intent.chainId !== 1315) throw new Error("Pending operation intent must target Story Aeneid (1315)"); + requireAddress(intent.wallet, "Pending operation wallet"); + requireNullableString(intent.registrationName, "Pending operation registration name"); + requireNullableString(intent.artifactPath, "Pending operation artifact path"); + requireNullableAddress(intent.spgNftContract, "Pending operation SPG collection"); + requireNullableAddress(intent.parentIpId, "Pending operation parent IP"); + requireNullableDecimal(intent.licenseTermsId, "Pending operation license terms ID"); + requireNullableAddress(intent.licenseTemplate, "Pending operation license template"); + requireNullableAddress(intent.currencyToken, "Pending operation currency token"); + requireNullableDecimal(intent.defaultMintingFee, "Pending operation default minting fee"); + requireNullableDecimal(intent.maxMintingFee, "Pending operation maximum minting fee"); + requireHash(intent.runConfigHash, "Pending operation run-config hash"); + + if (intent.stage === "collection") { + if (intent.registrationName !== null || intent.artifactPath !== null || intent.metadata !== null) { + throw new Error("Collection intent must not contain registration or metadata fields"); + } + } else { + requireNonemptyString(intent.registrationName, "Pending operation registration name"); + requireNonemptyString(intent.artifactPath, "Pending operation artifact path"); + if (intent.metadata === null) throw new Error("Registration intent must contain metadata"); + validateMetadata(intent.metadata); + requireAddress(intent.spgNftContract, "Pending operation SPG collection"); + if (intent.stage === "root") { + if (intent.parentIpId !== null) throw new Error("Root intent must not contain a parent IP"); + } else { + requireAddress(intent.parentIpId, "Derivative parent IP"); + requireNullableDecimal(intent.licenseTermsId, "Derivative license terms ID"); + if (intent.licenseTermsId === null) throw new Error("Derivative intent must contain a license terms ID"); + requireAddress(intent.licenseTemplate, "Derivative license template"); + requireAddress(intent.currencyToken, "Derivative currency token"); + if (intent.maxMintingFee === null) throw new Error("Derivative intent must contain a maximum minting fee"); + } + } +} + +function validatePendingOperation(value: unknown): asserts value is PendingOperation { + const operation = asRecord(value, "Pending operation"); + requireExactKeys( + operation, + [ + "schemaVersion", + "operationId", + "stage", + "intent", + "intentHash", + "transactionHash", + "serializedTransaction", + "state", + ], + "Pending operation", + ); + if (operation.schemaVersion !== 1) throw new Error("Pending operation schema version must be 1"); + requireNonemptyString(operation.operationId, "Pending operation ID"); + if (typeof operation.stage !== "string" || !OPERATION_STAGES.has(operation.stage as OperationStage)) { + throw new Error("Pending operation stage is invalid"); + } + validateCanonicalIntent(operation.intent); + if (operation.intent.stage !== operation.stage) { + throw new Error("Pending operation stage does not match its intent stage"); + } + requireHash(operation.intentHash, "Pending operation intent hash"); + const expectedIntentHash = operationIntentHash(operation.intent); + if (operation.intentHash !== expectedIntentHash) { + throw new Error("Pending operation intent hash does not match its canonical intent"); + } + requireHash(operation.transactionHash, "Pending operation transaction hash"); + if (typeof operation.serializedTransaction !== "string" + || !SERIALIZED_TRANSACTION.test(operation.serializedTransaction)) { + throw new Error("Pending operation serialized transaction must be nonempty, even-length hex bytes"); + } + const expectedTransactionHash = keccak256(operation.serializedTransaction as Hex); + if (operation.transactionHash !== expectedTransactionHash) { + throw new Error("Pending operation transaction hash does not match the serialized transaction"); + } + if (typeof operation.state !== "string" || !OPERATION_STATES.has(operation.state as OperationState)) { + throw new Error("Pending operation state is invalid"); + } +} + +function validateJournalFile(value: unknown): asserts value is JournalFile { + const journal = asRecord(value, "Pending transaction journal"); + requireExactKeys(journal, ["schemaVersion", "revision", "operation"], "Pending transaction journal"); + if (journal.schemaVersion !== 1) throw new Error("Pending transaction journal schema version must be 1"); + if (!Number.isSafeInteger(journal.revision) || (journal.revision as number) < 0) { + throw new Error("Pending transaction journal revision must be a non-negative safe integer"); + } + if (journal.operation !== null) validatePendingOperation(journal.operation); +} + +function validateExpectedRevision(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error("Expected journal revision must be a non-negative safe integer"); + } +} + +function canonicalHash(value: unknown): `0x${string}` { + const canonical = JSON.stringify(value, (_key, item) => + typeof item === "bigint" ? { $bigint: item.toString() } : item, + ); + if (canonical === undefined) throw new Error("Canonical hash input is not serializable"); + return `0x${createHash("sha256").update(canonical).digest("hex")}`; +} + +export function operationIntentHash(value: unknown): `0x${string}` { + return canonicalHash(value); +} + +export function runConfigHash(value: CanonicalRunConfig): `0x${string}` { + const config = asRecord(value, "Run configuration"); + requireExactKeys(config, ["chainId", "wallet", "stages"], "Run configuration"); + if (config.chainId !== 1315) throw new Error("Run configuration must target Story Aeneid (1315)"); + requireAddress(config.wallet, "Run-configuration wallet"); + if (!Array.isArray(config.stages) || config.stages.length !== 3) { + throw new Error("Run configuration must contain root, child, and grandchild stages"); + } + const expectedStages = ["root", "child", "grandchild"] as const; + config.stages.forEach((value, index) => { + const stage = asRecord(value, `Run-configuration ${expectedStages[index]} stage`); + requireExactKeys( + stage, + ["stage", "name", "description", "artifactPath", "artifactSha256"], + `Run-configuration ${expectedStages[index]} stage`, + ); + if (stage.stage !== expectedStages[index]) { + throw new Error(`Run configuration stage ${index + 1} must be ${expectedStages[index]}`); + } + requireNonemptyString(stage.name, "Run-configuration stage name"); + requireNonemptyString(stage.description, "Run-configuration stage description"); + requireNonemptyString(stage.artifactPath, "Run-configuration artifact path"); + requireHash(stage.artifactSha256, "Run-configuration artifact SHA-256"); + }); + return canonicalHash(value); +} + +async function writeAll(handle: FileHandle, bytes: Uint8Array): Promise { + let offset = 0; + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset, offset); + if (!Number.isSafeInteger(bytesWritten) || bytesWritten <= 0 || bytesWritten > bytes.byteLength - offset) { + throw new Error("Journal write made no progress or returned an invalid byte count"); + } + offset += bytesWritten; + } +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +function parseJson(bytes: string, label: string): unknown { + if (!bytes.endsWith("\n")) throw new Error(`${label} must end with one newline`); + try { + return JSON.parse(bytes); + } catch (error) { + throw new Error(`${label} contains malformed JSON`, { cause: error }); + } +} + +function validateLeaseOwner(value: unknown): asserts value is LeaseOwner { + const owner = asRecord(value, "Pending transaction journal lock owner"); + requireExactKeys(owner, ["leaseId", "hostname", "pid", "startedAtUtc"], "Pending transaction journal lock owner"); + if (typeof owner.leaseId !== "string" || !LEASE_ID.test(owner.leaseId)) { + throw new Error("Pending transaction journal lock owner lease ID is malformed"); + } + requireNonemptyString(owner.hostname, "Pending transaction journal lock owner hostname"); + if (!Number.isSafeInteger(owner.pid) || (owner.pid as number) <= 0) { + throw new Error("Pending transaction journal lock owner PID is malformed"); + } + if (typeof owner.startedAtUtc !== "string" + || Number.isNaN(Date.parse(owner.startedAtUtc)) + || new Date(owner.startedAtUtc).toISOString() !== owner.startedAtUtc) { + throw new Error("Pending transaction journal lock owner start time is malformed"); + } +} + +async function readPrivateFile(path: string, label: string): Promise { + const handle = await open(path, "r"); + try { + const fileStat = await handle.stat(); + if ((fileStat.mode & 0o777) !== 0o600) { + throw new Error(`${label} must have mode 0600 before it can be read`); + } + return await handle.readFile("utf8"); + } finally { + await handle.close(); + } +} + +async function processIsAlive(pid: number): Promise { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") return false; + if (code === "EPERM") { + throw new Error(`Cannot prove PID ${pid} is absent: process probe returned EPERM`, { cause: error }); + } + throw new Error(`Cannot prove PID ${pid} is absent: process probe failed`, { cause: error }); + } +} + +class FileLeasedOperationJournal implements LeasedOperationJournal { + constructor( + private readonly owner: FileOperationJournal, + private readonly leaseId: string, + ) {} + + load(): Promise { + return this.owner.loadUnderLease(this.leaseId); + } + + save(operation: PendingOperation, expectedRevision: number): Promise { + return this.owner.saveUnderLease(this.leaseId, operation, expectedRevision); + } + + clear(operationId: string, expectedRevision: number): Promise { + return this.owner.clearUnderLease(this.leaseId, operationId, expectedRevision); + } +} + +export class FileOperationJournal implements OperationJournal { + readonly path: string; + readonly lockPath: string; + + constructor(path: string, private readonly hooks: TransactionJournalTestDependencies = {}) { + if (!path) throw new Error("Pending transaction journal path is required"); + this.path = path; + this.lockPath = `${path}.lock`; + } + + async withExclusiveLease(callback: (journal: LeasedOperationJournal) => Promise): Promise { + await mkdir(dirname(this.path), { recursive: true }); + const owner: LeaseOwner = { + leaseId: randomBytes(16).toString("hex"), + hostname: localHostname(), + pid: process.pid, + startedAtUtc: new Date().toISOString(), + }; + await this.acquireLease(owner); + try { + return await callback(new FileLeasedOperationJournal(this, owner.leaseId)); + } finally { + await this.releaseLease(owner); + } + } + + async recoverStaleLock(input: StaleLockRecoveryInput): Promise { + if (!LEASE_ID.test(input.expectedLeaseId)) { + throw new Error("Expected stale-lock lease ID must be exactly 128 lowercase bits"); + } + const initial = await this.readLeaseOwner(); + if (initial.owner.leaseId !== input.expectedLeaseId) { + throw new Error( + `Recorded lease ID ${initial.owner.leaseId} does not match expected lease ID ${input.expectedLeaseId}`, + ); + } + if (initial.owner.hostname !== localHostname()) { + throw new Error( + `Pending transaction journal lock belongs to different host ${initial.owner.hostname}; current host is ${localHostname()}`, + ); + } + const alive = await (this.hooks.isProcessAlive ?? processIsAlive)(initial.owner.pid); + if (typeof alive !== "boolean") { + throw new Error(`Cannot prove PID ${initial.owner.pid} is absent: process probe returned no boolean proof`); + } + if (alive) { + throw new Error(`Pending transaction journal lock PID ${initial.owner.pid} is still alive`); + } + + await this.claimAndRemoveLease({ + expectedLeaseId: input.expectedLeaseId, + expectedBytes: initial.bytes, + mismatchMessage: "Pending transaction journal lock owner changed during recovery", + }); + } + + async loadUnderLease(leaseId: string): Promise { + await this.assertLeaseOwned(leaseId); + return this.loadJournalFile(); + } + + async saveUnderLease( + leaseId: string, + operation: PendingOperation, + expectedRevision: number, + ): Promise { + await this.assertLeaseOwned(leaseId); + validatePendingOperation(operation); + validateExpectedRevision(expectedRevision); + const current = await this.loadJournalFile(); + if (current.revision !== expectedRevision) { + throw new Error( + `Pending transaction journal has stale CAS revision ${expectedRevision}; current revision is ${current.revision}`, + ); + } + const next = { revision: current.revision + 1, operation } satisfies JournalSnapshot; + await this.writeJournalFile(next); + return next; + } + + async clearUnderLease( + leaseId: string, + operationId: string, + expectedRevision: number, + ): Promise { + await this.assertLeaseOwned(leaseId); + requireNonemptyString(operationId, "Pending operation ID to clear"); + validateExpectedRevision(expectedRevision); + const current = await this.loadJournalFile(); + if (current.revision !== expectedRevision) { + throw new Error( + `Pending transaction journal has stale CAS revision ${expectedRevision}; current revision is ${current.revision}`, + ); + } + if (!current.operation || current.operation.operationId !== operationId) { + throw new Error(`Pending transaction journal operation ID does not match ${operationId}`); + } + const next = { revision: current.revision + 1, operation: null } satisfies JournalSnapshot; + await this.writeJournalFile(next); + return next; + } + + private async acquireLease(owner: LeaseOwner): Promise { + let handle: FileHandle | null = null; + let created = false; + try { + try { + handle = await open(this.lockPath, "wx", 0o600); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const existing = await this.readLeaseOwner(); + throw new Error( + `Pending transaction journal is locked by host ${existing.owner.hostname}, PID ${existing.owner.pid}, lease ${existing.owner.leaseId}`, + { cause: error }, + ); + } + const bytes = Buffer.from(`${JSON.stringify(owner)}\n`, "utf8"); + await writeAll(handle, bytes); + await handle.sync(); + await handle.close(); + handle = null; + await syncDirectory(dirname(this.lockPath)); + } catch (error) { + if (handle) await handle.close().catch(() => undefined); + if (created) await unlink(this.lockPath).catch(() => undefined); + throw error; + } + } + + private async releaseLease(owner: LeaseOwner): Promise { + await this.claimAndRemoveLease({ + expectedLeaseId: owner.leaseId, + expectedBytes: `${JSON.stringify(owner)}\n`, + mismatchMessage: `Pending transaction journal lease CAS failed for ${owner.leaseId}`, + }); + } + + private async assertLeaseOwned(leaseId: string): Promise { + const existing = await this.readLeaseOwner(); + if (existing.owner.leaseId !== leaseId) { + throw new Error("Pending transaction journal lease is no longer owned by this operation"); + } + } + + private async claimAndRemoveLease(input: { + expectedLeaseId: string; + expectedBytes: string; + mismatchMessage: string; + }): Promise { + const candidatePath = `${this.lockPath}.${process.pid}.${randomUUID()}.claim`; + await rename(this.lockPath, candidatePath); + await this.hooks.afterLeaseClaim?.(candidatePath); + + let observed: { owner: LeaseOwner; bytes: string } | null = null; + let validationError: unknown = null; + try { + observed = await this.readLeaseOwner(candidatePath); + } catch (error) { + validationError = error; + } + + if (validationError + || !observed + || observed.owner.leaseId !== input.expectedLeaseId + || observed.bytes !== input.expectedBytes) { + const disposition = await this.restoreOrRetainClaim(candidatePath); + throw new Error(`${input.mismatchMessage}; claimed owner was ${disposition}`, { + cause: validationError ?? undefined, + }); + } + + await unlink(candidatePath); + await syncDirectory(dirname(this.lockPath)); + } + + private async restoreOrRetainClaim(candidatePath: string): Promise<"restored" | `retained at ${string}`> { + try { + await link(candidatePath, this.lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return `retained at ${candidatePath}`; + } + throw new Error(`Unable to restore claimed journal lock; retained at ${candidatePath}`, { cause: error }); + } + await unlink(candidatePath); + await syncDirectory(dirname(this.lockPath)); + return "restored"; + } + + private async readLeaseOwner(path = this.lockPath): Promise<{ owner: LeaseOwner; bytes: string }> { + let bytes: string; + try { + bytes = await readPrivateFile(path, "Pending transaction journal lock"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("Pending transaction journal lock does not exist", { cause: error }); + } + throw error; + } + let parsed: unknown; + try { + parsed = parseJson(bytes, "Pending transaction journal lock owner"); + validateLeaseOwner(parsed); + } catch (error) { + throw new Error("Pending transaction journal lock owner is malformed", { cause: error }); + } + return { owner: parsed, bytes }; + } + + private async loadJournalFile(): Promise { + let bytes: string; + try { + bytes = await readPrivateFile(this.path, "Pending transaction journal"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { revision: 0, operation: null }; + } + throw error; + } + const parsed = parseJson(bytes, "Pending transaction journal"); + validateJournalFile(parsed); + return { revision: parsed.revision, operation: parsed.operation }; + } + + private async writeJournalFile(snapshot: JournalSnapshot): Promise { + const file: JournalFile = { schemaVersion: 1, ...snapshot }; + validateJournalFile(file); + const temporaryPath = `${this.path}.${process.pid}.${randomUUID()}.tmp`; + const bytes = Buffer.from(`${JSON.stringify(file, null, 2)}\n`, "utf8"); + let handle: FileHandle | null = null; + let renamed = false; + try { + handle = await open(temporaryPath, "wx", 0o600); + await writeAll(handle, bytes); + await handle.sync(); + await this.hooks.afterTemporarySync?.(); + await handle.close(); + handle = null; + await rename(temporaryPath, this.path); + renamed = true; + await syncDirectory(dirname(this.path)); + const finalStat = await stat(this.path); + if ((finalStat.mode & 0o777) !== 0o600) { + throw new Error("Pending transaction journal must have mode 0600 after its durable write"); + } + } catch (error) { + if (handle) await handle.close().catch(() => undefined); + if (!renamed) await unlink(temporaryPath).catch(() => undefined); + throw error; + } + } +} diff --git a/phase0/tests/transactions.test.ts b/phase0/tests/transactions.test.ts new file mode 100644 index 0000000..7a83b9f --- /dev/null +++ b/phase0/tests/transactions.test.ts @@ -0,0 +1,426 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + chmod, + mkdtemp, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { hostname, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test, { type TestContext } from "node:test"; +import { keccak256 } from "viem"; + +import { + FileOperationJournal, + operationIntentHash, + type CanonicalOperationIntent, + type JournalSnapshot, + type PendingOperation, +} from "../src/transactions"; + +const execFileAsync = promisify(execFile); +const SERIALIZED_TRANSACTION = `0x${"3".repeat(128)}` as const; + +const INTENT: CanonicalOperationIntent = { + stage: "root", + chainId: 1315, + wallet: "0x00000000000000000000000000000000000000aa", + registrationName: "demo-research-skill", + artifactPath: "fixtures/demo-base/SKILL.md", + spgNftContract: "0x00000000000000000000000000000000000000bb", + parentIpId: null, + licenseTermsId: null, + licenseTemplate: null, + currencyToken: null, + defaultMintingFee: null, + maxMintingFee: null, + metadata: { + ipMetadataURI: "ipfs://root-ip-metadata", + ipMetadataHash: `0x${"4".repeat(64)}`, + nftMetadataURI: "ipfs://root-nft-metadata", + nftMetadataHash: `0x${"5".repeat(64)}`, + artifactMediaHash: `0x${"6".repeat(64)}`, + artifactMediaType: "text/markdown", + }, + runConfigHash: `0x${"7".repeat(64)}`, +}; + +const RECORD: PendingOperation = { + schemaVersion: 1, + operationId: "phase0:0x00000000000000000000000000000000000000aa:root", + stage: "root", + intent: INTENT, + intentHash: operationIntentHash(INTENT), + transactionHash: keccak256(SERIALIZED_TRANSACTION), + serializedTransaction: SERIALIZED_TRANSACTION, + state: "prepared", +}; + +const INTENT_HASH_MISMATCH: PendingOperation = { + ...RECORD, + intentHash: `0x${"0".repeat(64)}`, +}; + +const TRANSACTION_HASH_MISMATCH: PendingOperation = { + ...RECORD, + transactionHash: `0x${"2".repeat(64)}`, +}; + +async function temporaryJournal(t: TestContext) { + const directory = await mkdtemp(join(tmpdir(), "phase0-transaction-journal-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const journalPath = join(directory, "pending-transactions.json"); + return { directory, journalPath, journal: new FileOperationJournal(journalPath) }; +} + +async function writeSnapshot(path: string, snapshot: JournalSnapshot, mode = 0o600) { + await writeFile(path, `${JSON.stringify({ schemaVersion: 1, ...snapshot }, null, 2)}\n`, { mode }); + await chmod(path, mode); +} + +function validLeaseOwner(overrides: Partial<{ + leaseId: string; + hostname: string; + pid: number; + startedAtUtc: string; +}> = {}) { + return { + leaseId: "0123456789abcdef0123456789abcdef", + hostname: hostname(), + pid: 999_999, + startedAtUtc: "2026-07-18T00:00:00.000Z", + ...overrides, + }; +} + +async function writeLock(path: string, owner: unknown) { + await writeFile(`${path}.lock`, `${JSON.stringify(owner)}\n`, { mode: 0o600 }); + await chmod(`${path}.lock`, 0o600); +} + +async function replaceLock(path: string, owner: unknown) { + const replacement = `${path}.replacement`; + await writeFile(replacement, `${JSON.stringify(owner)}\n`, { mode: 0o600 }); + await chmod(replacement, 0o600); + await rename(replacement, `${path}.lock`); +} + +test("journal lifecycle is newline-terminated, mode-0600, and leaves no temporary files", async (t) => { + const { directory, journalPath, journal } = await temporaryJournal(t); + + await journal.withExclusiveLease(async (leased) => { + const empty = await leased.load(); + assert.deepEqual(empty, { revision: 0, operation: null }); + const saved = await leased.save(RECORD, empty.revision); + assert.equal(saved.revision, 1); + assert.deepEqual(saved.operation, RECORD); + const cleared = await leased.clear(RECORD.operationId, saved.revision); + assert.deepEqual(cleared, { revision: 2, operation: null }); + }); + + const journalStat = await stat(journalPath); + assert.equal(journalStat.mode & 0o777, 0o600); + assert.equal((await readFile(journalPath, "utf8")).endsWith("\n"), true); + assert.deepEqual((await readdir(directory)).filter((name) => name.includes(".tmp")), []); + assert.deepEqual((await readdir(directory)).filter((name) => name.endsWith(".lock")), []); +}); + +test("a second journal instance cannot enter a live exclusive lease", async (t) => { + const { journalPath, journal } = await temporaryJournal(t); + let release!: () => void; + const paused = new Promise((resolve) => { release = resolve; }); + let entered!: () => void; + const didEnter = new Promise((resolve) => { entered = resolve; }); + + const first = journal.withExclusiveLease(async () => { + entered(); + await paused; + }); + await didEnter; + + await assert.rejects( + new FileOperationJournal(journalPath).withExclusiveLease(async () => undefined), + /Pending transaction journal is locked.*PID.*lease/i, + ); + + release(); + await first; +}); + +test("normal lease release never unlinks a replacement owner", async (t) => { + const { directory, journalPath, journal } = await temporaryJournal(t); + const replacementOwner = validLeaseOwner({ + leaseId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + pid: process.pid, + startedAtUtc: "2026-07-18T00:00:02.000Z", + }); + + await assert.rejects( + journal.withExclusiveLease(async (leased) => { + await leased.load(); + await replaceLock(journalPath, replacementOwner); + }), + /lease CAS failed.*restored/i, + ); + + assert.deepEqual(JSON.parse(await readFile(`${journalPath}.lock`, "utf8")), replacementOwner); + assert.deepEqual((await readdir(directory)).filter((name) => name.endsWith(".claim")), []); +}); + +test("normal lease release removes only its claim when a new owner acquires the lock path", async (t) => { + const { directory, journalPath } = await temporaryJournal(t); + const newOwner = validLeaseOwner({ + leaseId: "cccccccccccccccccccccccccccccccc", + pid: process.pid, + startedAtUtc: "2026-07-18T00:00:03.000Z", + }); + const journal = new FileOperationJournal(journalPath, { + afterLeaseClaim: async () => writeLock(journalPath, newOwner), + }); + + await journal.withExclusiveLease(async (leased) => { + assert.deepEqual(await leased.load(), { revision: 0, operation: null }); + }); + + assert.deepEqual(JSON.parse(await readFile(`${journalPath}.lock`, "utf8")), newOwner); + assert.deepEqual((await readdir(directory)).filter((name) => name.endsWith(".claim")), []); +}); + +test("save uses compare-and-swap revisions and preserves the winning snapshot", async (t) => { + const { journalPath, journal } = await temporaryJournal(t); + + await journal.withExclusiveLease(async (leased) => { + await leased.save(RECORD, 0); + await assert.rejects(leased.save({ ...RECORD, state: "broadcast" }, 0), /stale.*revision/i); + assert.deepEqual(await leased.load(), { revision: 1, operation: RECORD }); + }); + + assert.equal(JSON.parse(await readFile(journalPath, "utf8")).revision, 1); +}); + +test("clear rejects the wrong operation ID and a stale revision", async (t) => { + const { journal, journalPath } = await temporaryJournal(t); + + await journal.withExclusiveLease(async (leased) => { + const saved = await leased.save(RECORD, 0); + await assert.rejects(leased.clear("phase0:wrong", saved.revision), /operation ID/i); + await assert.rejects(leased.clear(RECORD.operationId, 0), /stale.*revision/i); + assert.deepEqual(await leased.load(), saved); + }); + + assert.equal(JSON.parse(await readFile(journalPath, "utf8")).revision, 1); +}); + +test("load rejects a journal whose permissions expose signed bytes", async (t) => { + const { journal, journalPath } = await temporaryJournal(t); + await writeSnapshot(journalPath, { revision: 1, operation: RECORD }, 0o644); + + await journal.withExclusiveLease(async (leased) => { + await assert.rejects(leased.load(), /mode 0600/i); + }); +}); + +test("a crash after temporary fsync preserves the prior final snapshot and retry advances once", async (t) => { + const { directory, journalPath, journal } = await temporaryJournal(t); + await journal.withExclusiveLease(async (leased) => { + await leased.save(RECORD, 0); + }); + const previousBytes = await readFile(journalPath); + + const crashing = new FileOperationJournal(journalPath, { + afterTemporarySync: () => { throw new Error("simulated crash after temporary fsync"); }, + }); + await assert.rejects( + crashing.withExclusiveLease(async (leased) => { + await leased.save({ ...RECORD, state: "broadcast" }, 1); + }), + /simulated crash/, + ); + assert.deepEqual(await readFile(journalPath), previousBytes); + assert.deepEqual((await readdir(directory)).filter((name) => name.includes(".tmp")), []); + + await new FileOperationJournal(journalPath).withExclusiveLease(async (leased) => { + const saved = await leased.save({ ...RECORD, state: "broadcast" }, 1); + assert.equal(saved.revision, 2); + }); + assert.equal(JSON.parse(await readFile(journalPath, "utf8")).revision, 2); + assert.deepEqual((await readdir(directory)).filter((name) => name.includes(".tmp")), []); +}); + +test("intent corruption fails on save and on load", async (t) => { + const { journal, journalPath } = await temporaryJournal(t); + + await journal.withExclusiveLease(async (leased) => { + await assert.rejects(leased.save(INTENT_HASH_MISMATCH, 0), /intent.*hash/i); + }); + + const changedIntent = { ...INTENT, registrationName: "changed-without-rehash" }; + await writeSnapshot(journalPath, { + revision: 1, + operation: { ...RECORD, intent: changedIntent }, + }); + await journal.withExclusiveLease(async (leased) => { + await assert.rejects(leased.load(), /intent.*hash/i); + }); +}); + +test("serialized transaction bytes are bound to their keccak256 hash on save and load", async (t) => { + const { journal, journalPath } = await temporaryJournal(t); + + await journal.withExclusiveLease(async (leased) => { + await assert.rejects(leased.save(TRANSACTION_HASH_MISMATCH, 0), /transaction hash.*serialized transaction/i); + }); + + await writeSnapshot(journalPath, { revision: 1, operation: TRANSACTION_HASH_MISMATCH }); + await journal.withExclusiveLease(async (leased) => { + await assert.rejects(leased.load(), /transaction hash.*serialized transaction/i); + }); +}); + +test("malformed hashes and signed transaction hex fail closed", async (t) => { + const { journal } = await temporaryJournal(t); + + for (const operation of [ + { ...RECORD, intentHash: "0x12" as `0x${string}` }, + { ...RECORD, transactionHash: "0x12" as `0x${string}` }, + { ...RECORD, serializedTransaction: "0x123" as `0x${string}` }, + { ...RECORD, serializedTransaction: "0x" as `0x${string}` }, + ]) { + await journal.withExclusiveLease(async (leased) => { + await assert.rejects(leased.save(operation, 0), /hash|serialized transaction/i); + }); + } +}); + +test("malformed and live lock owners fail closed without automatic deletion", async (t) => { + const { journal, journalPath } = await temporaryJournal(t); + + await writeLock(journalPath, { pid: "not-a-pid" }); + await assert.rejects(journal.withExclusiveLease(async () => undefined), /lock owner.*malformed/i); + await assert.rejects( + journal.recoverStaleLock({ + expectedLeaseId: "0123456789abcdef0123456789abcdef", + }), + /lock owner.*malformed/i, + ); + assert.equal(await stat(`${journalPath}.lock`).then(() => true), true); + + await writeLock(journalPath, validLeaseOwner({ pid: process.pid })); + await assert.rejects( + journal.recoverStaleLock({ expectedLeaseId: validLeaseOwner().leaseId }), + /PID.*alive/i, + ); + assert.equal(await stat(`${journalPath}.lock`).then(() => true), true); +}); + +test("explicit stale-lock recovery requires exact same-host identity and PID absence", async (t) => { + const { journalPath } = await temporaryJournal(t); + const journal = new FileOperationJournal(journalPath, { isProcessAlive: async () => false }); + const owner = validLeaseOwner(); + await writeLock(journalPath, owner); + + await assert.rejects( + journal.recoverStaleLock({ + expectedLeaseId: "fedcba9876543210fedcba9876543210", + }), + /lease ID.*does not match/i, + ); + await writeLock(journalPath, { ...owner, hostname: "different-host.example" }); + await assert.rejects( + journal.recoverStaleLock({ + expectedLeaseId: owner.leaseId, + }), + /different host/i, + ); + assert.equal(await stat(`${journalPath}.lock`).then(() => true), true); + + await writeLock(journalPath, owner); + const recovering = new FileOperationJournal(journalPath, { + isProcessAlive: async (pid) => { + assert.equal(pid, owner.pid); + return false; + }, + }); + await recovering.recoverStaleLock({ expectedLeaseId: owner.leaseId }); + await assert.rejects(stat(`${journalPath}.lock`), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); +}); + +test("stale-lock recovery rereads immutable lease identity before unlink", async (t) => { + const { journalPath } = await temporaryJournal(t); + const staleOwner = validLeaseOwner(); + const replacementOwner = validLeaseOwner({ + leaseId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + pid: process.pid, + startedAtUtc: "2026-07-18T00:00:01.000Z", + }); + await writeLock(journalPath, staleOwner); + + const journal = new FileOperationJournal(journalPath, { + isProcessAlive: async () => { + await replaceLock(journalPath, replacementOwner); + return false; + }, + }); + + await assert.rejects( + journal.recoverStaleLock({ expectedLeaseId: staleOwner.leaseId }), + /lock owner changed during recovery/i, + ); + + assert.deepEqual(JSON.parse(await readFile(`${journalPath}.lock`, "utf8")), replacementOwner); +}); + +test("stale recovery retains a mismatched claim without deleting a newly acquired lock", async (t) => { + const { directory, journalPath } = await temporaryJournal(t); + const staleOwner = validLeaseOwner(); + const movedReplacement = validLeaseOwner({ + leaseId: "dddddddddddddddddddddddddddddddd", + pid: process.pid, + startedAtUtc: "2026-07-18T00:00:04.000Z", + }); + const newOwner = validLeaseOwner({ + leaseId: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + pid: process.pid, + startedAtUtc: "2026-07-18T00:00:05.000Z", + }); + await writeLock(journalPath, staleOwner); + const journal = new FileOperationJournal(journalPath, { + isProcessAlive: async () => { + await replaceLock(journalPath, movedReplacement); + return false; + }, + afterLeaseClaim: async () => writeLock(journalPath, newOwner), + }); + + await assert.rejects( + journal.recoverStaleLock({ expectedLeaseId: staleOwner.leaseId }), + /owner changed.*retained at/i, + ); + + assert.deepEqual(JSON.parse(await readFile(`${journalPath}.lock`, "utf8")), newOwner); + const claims = (await readdir(directory)).filter((name) => name.endsWith(".claim")); + assert.equal(claims.length, 1); + assert.deepEqual(JSON.parse(await readFile(join(directory, claims[0]), "utf8")), movedReplacement); +}); + +test("git ignores every replayable journal path and tracks none", async () => { + const repository = fileURLToPath(new URL("../../", import.meta.url)); + const paths = [ + "phase0/pending-transactions.json", + "phase0/pending-transactions.json.audit.tmp", + "phase0/pending-transactions.json.lock", + "phase0/pending-transactions.json.lock.audit.claim", + ]; + const ignored = await execFileAsync("git", ["check-ignore", "-v", ...paths], { cwd: repository }); + for (const path of paths) assert.match(ignored.stdout, new RegExp(path.replaceAll(".", "\\."))); + + const tracked = await execFileAsync("git", ["ls-files", ...paths], { cwd: repository }); + assert.equal(tracked.stdout, ""); +}); From 04d2d3634b91f29f0aa1d3d90bb074cece018423 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 01:52:31 -0400 Subject: [PATCH 053/165] fix: reconcile Phase 0 transactions and WIP fees --- phase0/.gitignore | 1 + phase0/package.json | 3 - phase0/src/client.ts | 10 +- phase0/src/demo.ts | 881 +++++++++++++++++++++----- phase0/src/index.ts | 155 +---- phase0/src/registrations.ts | 70 +- phase0/src/story.ts | 298 +++++++-- phase0/tests/demo.test.ts | 981 +++++++++++++++++++++++------ phase0/tests/registrations.test.ts | 67 ++ phase0/tests/story.test.ts | 543 +++++++++++++--- 10 files changed, 2363 insertions(+), 646 deletions(-) diff --git a/phase0/.gitignore b/phase0/.gitignore index 3d621f5..7a478ae 100644 --- a/phase0/.gitignore +++ b/phase0/.gitignore @@ -8,3 +8,4 @@ pending-transactions.json pending-transactions.json.*.tmp pending-transactions.json.lock pending-transactions.json.lock.*.claim +registrations.json.*.tmp diff --git a/phase0/package.json b/phase0/package.json index 064e234..be55e6d 100644 --- a/phase0/package.json +++ b/phase0/package.json @@ -7,9 +7,6 @@ "scripts": { "check": "node --import tsx src/index.ts check", "demo": "node --import tsx src/index.ts demo", - "create-collection": "node --import tsx src/index.ts create-collection", - "register-skill": "node --import tsx src/index.ts register-skill", - "register-derivative": "node --import tsx src/index.ts register-derivative", "test": "node --import tsx --test tests/*.test.ts", "typecheck": "tsc --noEmit" }, diff --git a/phase0/src/client.ts b/phase0/src/client.ts index 017a1bb..d26aa33 100644 --- a/phase0/src/client.ts +++ b/phase0/src/client.ts @@ -1,5 +1,5 @@ import "dotenv/config"; -import { createPublicClient, defineChain, http } from "viem"; +import { createPublicClient, createWalletClient, defineChain, http } from "viem"; import { privateKeyToAccount, type Account } from "viem/accounts"; import { StoryClient, type StoryConfig } from "@story-protocol/core-sdk"; @@ -38,3 +38,11 @@ export function getClient(): StoryClient { export function getPublicClient() { return createPublicClient({ chain: aeneidChain, transport: http(RPC) }); } + +export function getWalletClient() { + return createWalletClient({ + account: getAccount(), + chain: aeneidChain, + transport: http(RPC), + }); +} diff --git a/phase0/src/demo.ts b/phase0/src/demo.ts index 90ff0b3..3bc9cfc 100644 --- a/phase0/src/demo.ts +++ b/phase0/src/demo.ts @@ -1,19 +1,39 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { parseEther } from "viem"; +import { formatEther, keccak256, parseEther } from "viem"; + +import { estimateRemainingDemoGasMinimum } from "./funding"; import { AENEID_NETWORK, + parseRegistrationManifest, type DemoStage, type MetadataProof, type RegistrationManifest, type RegistrationProof, type RegistrationStore, } from "./registrations"; +import { + operationIntentHash, + runConfigHash, + type CanonicalOperationIntent, + type LeasedOperationJournal, + type OperationStage, + type PendingOperation, +} from "./transactions"; export const AENEID_CHAIN_ID = AENEID_NETWORK.chainId; export const AENEID_FAUCET_URL = "https://aeneid.faucet.story.foundation/"; export const DEMO_ROOT_MINTING_FEE = parseEther("0.001"); +const OPERATION_STAGES = ["collection", "root", "child", "grandchild"] as const; +const REGISTRATION_STAGES = ["root", "child", "grandchild"] as const; +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const HASH = /^0x[0-9a-f]{64}$/; +const SERIALIZED_TRANSACTION = /^0x(?:[0-9a-fA-F]{2})+$/; + export interface DemoSkillDefinition { stage: DemoStage; name: string; @@ -35,39 +55,97 @@ export interface DemoMetadataProvider { prepare(input: DemoSkillDefinition & { creatorAddress: `0x${string}` }): Promise; } +export interface PreparedChainTransaction { + transactionHash: `0x${string}`; + serializedTransaction: `0x${string}`; +} + +export interface CollectionInput { + name: string; + symbol: string; + mintFeeRecipient: `0x${string}`; +} + +export interface CollectionResult { + spgNftContract: `0x${string}`; + txHash: `0x${string}`; +} + +export interface SkillInput { + spgNftContract: `0x${string}`; + metadata: PreparedMetadata["onchain"]; + defaultMintingFee: bigint; + revShare?: number; + policy?: "LAP" | "LRP"; +} + +export interface SkillResult { + ipId: `0x${string}`; + tokenId: bigint; + txHash: `0x${string}`; + licenseTermsId: bigint; + licenseTemplate: `0x${string}`; +} + +export interface PredictFeeInput { + licensorIpId: `0x${string}`; + licenseTermsId: bigint; + amount: number; +} + +export interface DerivativeInput { + spgNftContract: `0x${string}`; + parentIpId: `0x${string}`; + licenseTermsId: bigint; + maxMintingFee: bigint; + metadata: PreparedMetadata["onchain"]; +} + +export interface DerivativeResult { + ipId: `0x${string}`; + tokenId: bigint; + txHash: `0x${string}`; + licenseTermsId: bigint; + licenseTemplate: `0x${string}`; +} + +export interface DerivativeFeeReadiness { + currencyToken: `0x${string}`; + spender: `0x${string}`; + requiredAmount: bigint; + balance: bigint; + allowance: bigint; +} + export interface DemoChain { getChainId(): Promise; getBalance(address: `0x${string}`): Promise; getGasPrice(): Promise; - createCollection(input: { - name: string; - symbol: string; - mintFeeRecipient: `0x${string}`; - }): Promise<{ spgNftContract: `0x${string}`; txHash: `0x${string}` }>; - registerSkill(input: { - spgNftContract: `0x${string}`; - metadata: PreparedMetadata["onchain"]; - defaultMintingFee: bigint; - revShare?: number; - policy?: "LAP" | "LRP"; - }): Promise<{ - ipId: `0x${string}`; - tokenId: bigint; - txHash: `0x${string}`; - licenseTermsId: bigint; + prepareCollection(input: CollectionInput): Promise; + prepareSkill(input: SkillInput): Promise; + prepareDerivative(input: DerivativeInput): Promise; + broadcastPrepared(input: PreparedChainTransaction): Promise; + confirmCollection(txHash: `0x${string}`): Promise; + confirmSkill(input: { + transactionHash: `0x${string}`; + expectedCollection: `0x${string}`; + }): Promise; + confirmDerivative(input: { + transactionHash: `0x${string}`; + expectedCollection: `0x${string}`; + expectedParentIpId: `0x${string}`; + expectedLicenseTermsId: bigint; + expectedLicenseTemplate: `0x${string}`; + }): Promise; + predictMintingLicenseFee(input: PredictFeeInput): Promise<{ + currencyToken: `0x${string}`; + tokenAmount: bigint; }>; - predictMintingLicenseFee(input: { - licensorIpId: `0x${string}`; - licenseTermsId: bigint; - amount: number; - }): Promise<{ tokenAmount: bigint }>; - registerDerivative(input: { - spgNftContract: `0x${string}`; - parentIpId: `0x${string}`; - licenseTermsId: bigint; - maxMintingFee: bigint; - metadata: PreparedMetadata["onchain"]; - }): Promise<{ ipId: `0x${string}`; tokenId: bigint; txHash: `0x${string}` }>; + getDerivativeFeeReadiness(input: { + wallet: `0x${string}`; + currencyToken: `0x${string}`; + requiredAmount: bigint; + }): Promise; } export interface RunDemoInput { @@ -75,6 +153,7 @@ export interface RunDemoInput { chain: DemoChain; metadata: DemoMetadataProvider; store: RegistrationStore; + journal: LeasedOperationJournal; skills?: readonly DemoSkillDefinition[]; } @@ -99,202 +178,660 @@ export const DEMO_SKILLS: readonly DemoSkillDefinition[] = [ }, ] as const; -function definitionFor(skills: readonly DemoSkillDefinition[], stage: DemoStage) { - const definition = skills.find((skill) => skill.stage === stage); - if (!definition) throw new Error(`Missing demo Skill definition for ${stage}`); +interface ResolvedRunConfig { + readonly hash: `0x${string}`; + readonly artifactHashes: ReadonlyMap; + readonly definitions: ReadonlyMap>; +} + +interface ExecutedOperation { + result: T; + operation: PendingOperation; + journalRevision: number; +} + +function sameHex(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +function definitionFor(skills: readonly DemoSkillDefinition[], stage: DemoStage): DemoSkillDefinition { + const matches = skills.filter((skill) => skill.stage === stage); + if (matches.length !== 1) { + throw new Error(`Expected exactly one demo Skill definition for ${stage}; received ${matches.length}`); + } + const definition = matches[0]; + if (!definition.name.trim() || !definition.description.trim() || !definition.artifactPath.trim()) { + throw new Error(`${stage} definition must contain name, description, and artifact path`); + } return definition; } -function ensureResumableManifest(manifest: RegistrationManifest, wallet: `0x${string}`) { - if (manifest.schemaVersion !== 1) { - throw new Error(`Unsupported registrations schema version: ${manifest.schemaVersion}`); +function requiredAddress(value: `0x${string}` | null, label: string): `0x${string}` { + if (!value || !ADDRESS.test(value)) throw new Error(`Pending intent is missing ${label}`); + return value; +} + +function requiredString(value: string | null, label: string): string { + if (!value) throw new Error(`Pending intent is missing ${label}`); + return value; +} + +function operationId(wallet: `0x${string}`, stage: OperationStage): string { + return `phase0:${wallet}:${stage}`; +} + +function updateStatus(manifest: RegistrationManifest): void { + if (!manifest.spgNftContract) { + manifest.status = "not-run"; + return; } - if (manifest.network.chainId !== AENEID_CHAIN_ID) { - throw new Error( - `registrations.json targets chain ${manifest.network.chainId}; expected Story Aeneid (${AENEID_CHAIN_ID})`, - ); + manifest.status = REGISTRATION_STAGES.every((stage) => manifest.registrations[stage] !== null) + ? "complete" + : "partial"; +} + +export function missingOperationStages(manifest: RegistrationManifest): OperationStage[] { + const missing: OperationStage[] = []; + if (!manifest.spgNftContract) missing.push("collection"); + for (const stage of REGISTRATION_STAGES) { + if (!manifest.registrations[stage]) missing.push(stage); } - if (manifest.wallet && manifest.wallet.toLowerCase() !== wallet.toLowerCase()) { + return missing; +} + +function ensureCurrentWallet(manifest: RegistrationManifest, wallet: `0x${string}`): void { + if (manifest.wallet && !sameHex(manifest.wallet, wallet)) { throw new Error(`registrations.json belongs to wallet ${manifest.wallet}; current wallet is ${wallet}`); } - if (manifest.registrations.root === null && (manifest.registrations.child || manifest.registrations.grandchild)) { - throw new Error("registrations.json is inconsistent: a Derivative exists without the root Skill"); +} + +async function resolveRunConfig( + wallet: `0x${string}`, + skills: readonly DemoSkillDefinition[], +): Promise { + if (skills.length !== REGISTRATION_STAGES.length) { + throw new Error("Demo run configuration must contain exactly root, child, and grandchild definitions"); } - if (manifest.registrations.child === null && manifest.registrations.grandchild) { - throw new Error("registrations.json is inconsistent: the grandchild exists without its parent Derivative"); + const artifactHashes = new Map(); + const definitions = new Map>(); + const stages = []; + for (const stage of REGISTRATION_STAGES) { + const definition = Object.freeze({ ...definitionFor(skills, stage) }); + const artifactSha256 = `0x${createHash("sha256") + .update(await readFile(definition.artifactPath)) + .digest("hex")}` as const; + artifactHashes.set(stage, artifactSha256); + definitions.set(stage, definition); + stages.push({ + stage, + name: definition.name, + description: definition.description, + artifactPath: definition.artifactPath, + artifactSha256, + }); } + return { + hash: runConfigHash({ chainId: AENEID_CHAIN_ID, wallet, stages }), + artifactHashes, + definitions, + }; } -function metadataProofMatches(stored: MetadataProof, current: MetadataProof): boolean { - return ( - stored.ip.uri === current.ip.uri - && stored.ip.hash === current.ip.hash - && stored.nft.uri === current.nft.uri - && stored.nft.hash === current.nft.hash - && stored.artifact.mediaHash === current.artifact.mediaHash - && stored.artifact.mediaType === current.artifact.mediaType - ); +function resolvedDefinitionFor( + resolved: ResolvedRunConfig, + stage: DemoStage, +): Readonly { + const definition = resolved.definitions.get(stage); + if (!definition) throw new Error(`Resolved run configuration is missing the ${stage} definition`); + return definition; +} + +function verifyConfirmedArtifactHashes( + manifest: RegistrationManifest, + resolved: ResolvedRunConfig, +): void { + for (const stage of REGISTRATION_STAGES) { + const stored = manifest.registrations[stage]; + if (!stored) continue; + const current = resolved.artifactHashes.get(stage); + if (!current || !sameHex(stored.metadata.artifact.mediaHash, current)) { + throw new Error( + `${stage} artifact hash drift detected: restore the recorded local artifact before preparing a new transaction`, + ); + } + } } -function proof(input: { +function metadataForIntent(input: { definition: DemoSkillDefinition; - result: { ipId: `0x${string}`; tokenId: bigint; txHash: `0x${string}` }; - licenseTermsId: bigint; - parentIpIds: `0x${string}`[]; - defaultMintingFee?: bigint; - maxMintingFee?: bigint; - metadata: PreparedMetadata; -}): RegistrationProof { + prepared: PreparedMetadata; + artifactHash: `0x${string}`; +}): NonNullable { + const { definition, prepared, artifactHash } = input; + if (prepared.proof.ip.uri !== prepared.onchain.ipMetadataURI + || !sameHex(prepared.proof.ip.hash, prepared.onchain.ipMetadataHash) + || prepared.proof.nft.uri !== prepared.onchain.nftMetadataURI + || !sameHex(prepared.proof.nft.hash, prepared.onchain.nftMetadataHash)) { + throw new Error(`${definition.stage} metadata proof does not match its on-chain URI/hash fields`); + } + if (resolve(prepared.proof.artifact.path) !== resolve(definition.artifactPath) + || !sameHex(prepared.proof.artifact.mediaHash, artifactHash)) { + throw new Error(`${definition.stage} metadata artifact proof does not match the current local artifact`); + } + if (prepared.proof.artifact.mediaType !== "text/markdown") { + throw new Error(`${definition.stage} artifact media type must be text/markdown`); + } return { - stage: input.definition.stage, - kind: input.definition.stage === "root" ? "Skill" : "Derivative", - name: input.definition.name, - ipId: input.result.ipId, - tokenId: input.result.tokenId.toString(), - txHash: input.result.txHash, - licenseTermsId: input.licenseTermsId.toString(), - parentIpIds: input.parentIpIds, - defaultMintingFee: input.defaultMintingFee?.toString() ?? null, - maxMintingFee: input.maxMintingFee?.toString() ?? null, - metadata: input.metadata.proof, + ipMetadataURI: prepared.onchain.ipMetadataURI, + ipMetadataHash: prepared.onchain.ipMetadataHash, + nftMetadataURI: prepared.onchain.nftMetadataURI, + nftMetadataHash: prepared.onchain.nftMetadataHash, + artifactMediaHash: prepared.proof.artifact.mediaHash, + artifactMediaType: prepared.proof.artifact.mediaType, }; } -export async function runDemo(input: RunDemoInput): Promise { - const chainId = await input.chain.getChainId(); - if (chainId !== AENEID_CHAIN_ID) { - throw new Error(`Wrong network: expected Story Aeneid (${AENEID_CHAIN_ID}), received chain ${chainId}`); +function registrationProof( + operation: PendingOperation, + confirmed: SkillResult | DerivativeResult, +): RegistrationProof { + const intent = operation.intent; + if (operation.stage === "collection" || !intent.metadata) { + throw new Error("A registration proof requires journal-bound metadata"); } + if (!sameHex(confirmed.txHash, operation.transactionHash)) { + throw new Error(`Confirmed ${operation.stage} result does not match the journal transaction hash`); + } + const registrationName = requiredString(intent.registrationName, "registration name"); + const artifactPath = requiredString(intent.artifactPath, "artifact path"); + return { + stage: operation.stage, + kind: operation.stage === "root" ? "Skill" : "Derivative", + name: registrationName, + ipId: confirmed.ipId, + tokenId: confirmed.tokenId.toString(), + txHash: operation.transactionHash, + licenseTermsId: confirmed.licenseTermsId.toString(), + licenseTemplate: confirmed.licenseTemplate, + parentIpIds: operation.stage === "root" + ? [] + : [requiredAddress(intent.parentIpId, "parent IP")], + defaultMintingFee: intent.defaultMintingFee, + maxMintingFee: intent.maxMintingFee, + metadata: { + ip: { uri: intent.metadata.ipMetadataURI, hash: intent.metadata.ipMetadataHash }, + nft: { uri: intent.metadata.nftMetadataURI, hash: intent.metadata.nftMetadataHash }, + artifact: { + path: artifactPath, + mediaHash: intent.metadata.artifactMediaHash, + mediaType: intent.metadata.artifactMediaType, + }, + }, + }; +} - const balance = await input.chain.getBalance(input.wallet); - if (balance === 0n) { +function assertPendingIntegrity(operation: PendingOperation): void { + if (operation.intent.stage !== operation.stage) { + throw new Error("Pending operation stage does not match its intent stage"); + } + const expectedIntentHash = operationIntentHash(operation.intent); + if (operation.intentHash !== expectedIntentHash) { + throw new Error("Pending operation intent hash does not match its canonical intent"); + } + if (!HASH.test(operation.transactionHash) + || !SERIALIZED_TRANSACTION.test(operation.serializedTransaction) + || keccak256(operation.serializedTransaction) !== operation.transactionHash) { + throw new Error("Pending operation transaction hash does not match its serialized transaction"); + } + const expectedOperationId = operationId(operation.intent.wallet, operation.stage); + if (operation.operationId !== expectedOperationId) { + throw new Error(`Pending operation ID ${operation.operationId} does not match ${expectedOperationId}`); + } +} + +function confirmedHash(manifest: RegistrationManifest, pending: PendingOperation): `0x${string}` | null { + return pending.stage === "collection" + ? manifest.collectionTxHash + : manifest.registrations[pending.stage]?.txHash ?? null; +} + +function validatePendingPrerequisites( + manifest: RegistrationManifest, + pending: PendingOperation, +): void { + const intent = pending.intent; + if (intent.chainId !== AENEID_CHAIN_ID) { + throw new Error(`Pending transaction targets chain ${intent.chainId}; expected Story Aeneid (${AENEID_CHAIN_ID})`); + } + if (manifest.wallet && !sameHex(manifest.wallet, intent.wallet)) { + throw new Error("Pending transaction wallet does not match the persisted manifest wallet"); + } + if (pending.stage === "collection") { + if (manifest.spgNftContract || manifest.registrations.root + || manifest.registrations.child || manifest.registrations.grandchild) { + throw new Error("Pending collection transaction conflicts with later persisted proof"); + } + return; + } + const collection = requiredAddress(intent.spgNftContract, "SPG collection"); + if (!manifest.spgNftContract || !sameHex(manifest.spgNftContract, collection)) { + throw new Error(`Pending ${pending.stage} transaction does not match the persisted SPG collection`); + } + if (pending.stage === "root") { + if (manifest.registrations.child || manifest.registrations.grandchild) { + throw new Error("Pending root transaction conflicts with persisted Derivative proof"); + } + return; + } + const parent = pending.stage === "child" + ? manifest.registrations.root + : manifest.registrations.child; + if (!parent) throw new Error(`Pending ${pending.stage} transaction is missing its persisted parent proof`); + if (!sameHex(parent.ipId, requiredAddress(intent.parentIpId, "parent IP"))) { + throw new Error(`Pending ${pending.stage} parent IP does not match the persisted parent proof`); + } + if (parent.licenseTermsId !== requiredString(intent.licenseTermsId, "license terms ID")) { + throw new Error(`Pending ${pending.stage} license terms do not match the persisted parent proof`); + } + if (!sameHex(parent.licenseTemplate, requiredAddress(intent.licenseTemplate, "license template"))) { + throw new Error(`Pending ${pending.stage} license template does not match the persisted parent proof`); + } + if (pending.stage === "child" && manifest.registrations.grandchild) { + throw new Error("Pending child transaction conflicts with a persisted grandchild proof"); + } +} + +async function executeOrResume(input: { + journal: LeasedOperationJournal; + operationId: string; + stage: OperationStage; + intent: CanonicalOperationIntent; + prepare(): Promise; + broadcast(tx: PreparedChainTransaction): Promise; + confirm(operation: PendingOperation): Promise; +}): Promise> { + const intentHash = operationIntentHash(input.intent); + let snapshot = await input.journal.load(); + let pending = snapshot.operation; + if (pending) { + assertPendingIntegrity(pending); + if (pending.operationId !== input.operationId + || pending.stage !== input.stage + || pending.intentHash !== intentHash) { + throw new Error(`Pending ${pending.stage} transaction does not match the current ${input.stage} intent`); + } + } else { + const prepared = await input.prepare(); + if (!HASH.test(prepared.transactionHash) + || !SERIALIZED_TRANSACTION.test(prepared.serializedTransaction) + || keccak256(prepared.serializedTransaction) !== prepared.transactionHash) { + throw new Error("Prepared transaction hash does not match its serialized transaction"); + } + pending = { + schemaVersion: 1, + operationId: input.operationId, + stage: input.stage, + intent: input.intent, + intentHash, + transactionHash: prepared.transactionHash, + serializedTransaction: prepared.serializedTransaction, + state: "prepared", + }; + snapshot = await input.journal.save(pending, snapshot.revision); + } + await input.broadcast({ + transactionHash: pending.transactionHash, + serializedTransaction: pending.serializedTransaction, + }); + if (pending.state !== "broadcast") { + pending = { ...pending, state: "broadcast" }; + snapshot = await input.journal.save(pending, snapshot.revision); + } + const result = await input.confirm(pending); + return { result, operation: pending, journalRevision: snapshot.revision }; +} + +async function requireDerivativeFeeReadiness(input: { + chain: DemoChain; + wallet: `0x${string}`; + predicted: { currencyToken: `0x${string}`; tokenAmount: bigint }; +}): Promise { + if (input.predicted.tokenAmount < 0n) throw new Error("Predicted Derivative fee cannot be negative"); + const readiness = await input.chain.getDerivativeFeeReadiness({ + wallet: input.wallet, + currencyToken: input.predicted.currencyToken, + requiredAmount: input.predicted.tokenAmount, + }); + if (!sameHex(readiness.currencyToken, input.predicted.currencyToken) + || readiness.requiredAmount !== input.predicted.tokenAmount) { + throw new Error("Derivative WIP readiness response does not match the predicted fee"); + } + if (!ADDRESS.test(readiness.spender)) { + throw new Error("Derivative WIP readiness response has an invalid spender"); + } + if (readiness.balance < input.predicted.tokenAmount) { + throw new Error( + `WIP balance ${readiness.balance} is below required ${input.predicted.tokenAmount} ` + + `for Derivative fee token ${readiness.currencyToken}`, + ); + } + if (readiness.allowance < input.predicted.tokenAmount) { throw new Error( - `Wallet ${input.wallet} on Story Aeneid (${AENEID_CHAIN_ID}) has exactly 0 IP. Fund it manually at ${AENEID_FAUCET_URL}`, + `WIP allowance ${readiness.allowance} is below required ${input.predicted.tokenAmount} ` + + `for DerivativeWorkflows spender ${readiness.spender}`, ); } + return readiness; +} - const manifest = await input.store.load(); - ensureResumableManifest(manifest, input.wallet); - const skills = input.skills ?? DEMO_SKILLS; - const metadata = new Map(); +async function persistExecuted(input: { + executed: ExecutedOperation; + manifest: RegistrationManifest; + store: RegistrationStore; + journal: LeasedOperationJournal; + apply(result: T, operation: PendingOperation): void; +}): Promise { + input.apply(input.executed.result, input.executed.operation); + updateStatus(input.manifest); + await input.store.save(input.manifest); + await input.journal.clear( + input.executed.operation.operationId, + input.executed.journalRevision, + ); +} - for (const stage of ["root", "child", "grandchild"] as const) { - const definition = definitionFor(skills, stage); - metadata.set(stage, await input.metadata.prepare({ ...definition, creatorAddress: input.wallet })); +async function recoverPending(input: { + pending: PendingOperation; + manifest: RegistrationManifest; + chain: DemoChain; + store: RegistrationStore; + journal: LeasedOperationJournal; +}): Promise { + const pending = input.pending; + const executed = await executeOrResume({ + journal: input.journal, + operationId: pending.operationId, + stage: pending.stage, + intent: pending.intent, + prepare: async () => { + throw new Error("Recovery must never prepare a replacement transaction"); + }, + broadcast: (transaction) => input.chain.broadcastPrepared(transaction), + confirm: async (operation) => { + if (operation.stage === "collection") { + return input.chain.confirmCollection(operation.transactionHash); + } + if (operation.stage === "root") { + return input.chain.confirmSkill({ + transactionHash: operation.transactionHash, + expectedCollection: requiredAddress(operation.intent.spgNftContract, "SPG collection"), + }); + } + return input.chain.confirmDerivative({ + transactionHash: operation.transactionHash, + expectedCollection: requiredAddress(operation.intent.spgNftContract, "SPG collection"), + expectedParentIpId: requiredAddress(operation.intent.parentIpId, "parent IP"), + expectedLicenseTermsId: BigInt(requiredString(operation.intent.licenseTermsId, "license terms ID")), + expectedLicenseTemplate: requiredAddress(operation.intent.licenseTemplate, "license template"), + }); + }, + }); + await persistExecuted({ + executed, + manifest: input.manifest, + store: input.store, + journal: input.journal, + apply: (result, operation) => { + if (operation.stage === "collection") { + const collection = result as CollectionResult; + if (!sameHex(collection.txHash, operation.transactionHash)) { + throw new Error("Confirmed collection result does not match the journal transaction hash"); + } + input.manifest.wallet = operation.intent.wallet; + input.manifest.spgNftContract = collection.spgNftContract; + input.manifest.collectionTxHash = operation.transactionHash; + } else { + input.manifest.registrations[operation.stage] = registrationProof( + operation, + result as SkillResult | DerivativeResult, + ); + } + }, + }); +} + +function collectionIntent(input: { + wallet: `0x${string}`; + runConfigHash: `0x${string}`; +}): CanonicalOperationIntent { + return { + stage: "collection", + chainId: AENEID_CHAIN_ID, + wallet: input.wallet, + registrationName: null, + artifactPath: null, + spgNftContract: null, + parentIpId: null, + licenseTermsId: null, + licenseTemplate: null, + currencyToken: null, + defaultMintingFee: null, + maxMintingFee: null, + metadata: null, + runConfigHash: input.runConfigHash, + }; +} + +export async function runDemo(input: RunDemoInput): Promise { + const chainId = await input.chain.getChainId(); + if (chainId !== AENEID_CHAIN_ID) { + throw new Error(`Wrong network: expected Story Aeneid (${AENEID_CHAIN_ID}), received chain ${chainId}`); } - for (const stage of ["root", "child", "grandchild"] as const) { - const stored = manifest.registrations[stage]; - if (stored) { - const current = metadata.get(stage); - if (!current) throw new Error(`${stage} metadata was not prepared`); - if (!metadataProofMatches(stored.metadata, current.proof)) { - throw new Error( - `${stage} metadata proof drift detected: the current artifact or metadata no longer matches registrations.json. Restore the recorded inputs or start a separate proof artifact before resuming.`, - ); + const manifest = parseRegistrationManifest(await input.store.load()); + const skills = input.skills ?? DEMO_SKILLS; + const initialSnapshot = await input.journal.load(); + const initialPending = initialSnapshot.operation; + let recoveredRunConfigHash: `0x${string}` | null = null; + let resolved: ResolvedRunConfig | null = null; + + if (initialPending) { + assertPendingIntegrity(initialPending); + recoveredRunConfigHash = initialPending.intent.runConfigHash; + const persistedHash = confirmedHash(manifest, initialPending); + if (persistedHash) { + if (!sameHex(persistedHash, initialPending.transactionHash)) { + throw new Error(`Confirmed ${initialPending.stage} proof does not match the pending transaction hash`); } + await input.journal.clear(initialPending.operationId, initialSnapshot.revision); + } else { + validatePendingPrerequisites(manifest, initialPending); + await recoverPending({ + pending: initialPending, + manifest, + chain: input.chain, + store: input.store, + journal: input.journal, + }); } + + resolved = await resolveRunConfig(input.wallet, skills); + if (resolved.hash !== recoveredRunConfigHash) { + throw new Error("Recovered pending transaction, but current run configuration differs"); + } + } + + ensureCurrentWallet(manifest, input.wallet); + const remainingStages = missingOperationStages(manifest); + if (remainingStages.length === 0) return manifest; + + const [balance, gasPrice] = await Promise.all([ + input.chain.getBalance(input.wallet), + input.chain.getGasPrice(), + ]); + const requiredMinimum = estimateRemainingDemoGasMinimum({ + gasPrice, + remainingNewWrites: remainingStages.length, + }); + if (balance < requiredMinimum) { + throw new Error( + `Wallet ${input.wallet} has ${formatEther(balance)} IP; ` + + `estimated native-gas minimum for ${remainingStages.join(",")} is ` + + `${formatEther(requiredMinimum)} IP. Fund it manually at ${AENEID_FAUCET_URL}`, + ); } + resolved ??= await resolveRunConfig(input.wallet, skills); + verifyConfirmedArtifactHashes(manifest, resolved); + if (!manifest.spgNftContract) { - const collection = await input.chain.createCollection({ - name: "Skill Asset Protocol Demo", - symbol: "SKILL", - mintFeeRecipient: input.wallet, + const intent = collectionIntent({ wallet: input.wallet, runConfigHash: resolved.hash }); + const executed = await executeOrResume({ + journal: input.journal, + operationId: operationId(input.wallet, "collection"), + stage: "collection", + intent, + prepare: () => input.chain.prepareCollection({ + name: "Skill Asset Protocol Demo", + symbol: "SKILL", + mintFeeRecipient: input.wallet, + }), + broadcast: (transaction) => input.chain.broadcastPrepared(transaction), + confirm: (operation) => input.chain.confirmCollection(operation.transactionHash), + }); + await persistExecuted({ + executed, + manifest, + store: input.store, + journal: input.journal, + apply: (collection, operation) => { + if (!sameHex(collection.txHash, operation.transactionHash)) { + throw new Error("Confirmed collection result does not match the journal transaction hash"); + } + manifest.wallet = operation.intent.wallet; + manifest.spgNftContract = collection.spgNftContract; + manifest.collectionTxHash = operation.transactionHash; + }, }); - manifest.wallet = input.wallet; - manifest.spgNftContract = collection.spgNftContract; - manifest.collectionTxHash = collection.txHash; - manifest.status = "partial"; - await input.store.save(manifest); } const spgNftContract = manifest.spgNftContract; if (!spgNftContract) throw new Error("Collection transaction confirmed without an SPG NFT contract"); if (!manifest.registrations.root) { - const definition = definitionFor(skills, "root"); - const prepared = metadata.get("root"); - if (!prepared) throw new Error("Root Skill metadata was not prepared"); - const result = await input.chain.registerSkill({ + const definition = resolvedDefinitionFor(resolved, "root"); + const prepared = await input.metadata.prepare({ ...definition, creatorAddress: input.wallet }); + const artifactHash = resolved.artifactHashes.get("root"); + if (!artifactHash) throw new Error("Root artifact hash was not resolved"); + const intent: CanonicalOperationIntent = { + stage: "root", + chainId: AENEID_CHAIN_ID, + wallet: input.wallet, + registrationName: definition.name, + artifactPath: definition.artifactPath, spgNftContract, - metadata: prepared.onchain, - defaultMintingFee: DEMO_ROOT_MINTING_FEE, + parentIpId: null, + licenseTermsId: null, + licenseTemplate: null, + currencyToken: null, + defaultMintingFee: DEMO_ROOT_MINTING_FEE.toString(), + maxMintingFee: null, + metadata: metadataForIntent({ definition, prepared, artifactHash }), + runConfigHash: resolved.hash, + }; + const executed = await executeOrResume({ + journal: input.journal, + operationId: operationId(input.wallet, "root"), + stage: "root", + intent, + prepare: () => input.chain.prepareSkill({ + spgNftContract, + metadata: prepared.onchain, + defaultMintingFee: DEMO_ROOT_MINTING_FEE, + }), + broadcast: (transaction) => input.chain.broadcastPrepared(transaction), + confirm: (operation) => input.chain.confirmSkill({ + transactionHash: operation.transactionHash, + expectedCollection: requiredAddress(operation.intent.spgNftContract, "SPG collection"), + }), }); - manifest.registrations.root = proof({ - definition, - result, - licenseTermsId: result.licenseTermsId, - parentIpIds: [], - defaultMintingFee: DEMO_ROOT_MINTING_FEE, - metadata: prepared, + await persistExecuted({ + executed, + manifest, + store: input.store, + journal: input.journal, + apply: (result, operation) => { + manifest.registrations.root = registrationProof(operation, result); + }, }); - manifest.status = "partial"; - await input.store.save(manifest); } - const root = manifest.registrations.root; - if (!root) throw new Error("Root Skill transaction confirmed without a persisted proof"); - - if (!manifest.registrations.child) { - const definition = definitionFor(skills, "child"); - const prepared = metadata.get("child"); - if (!prepared) throw new Error("Child Derivative metadata was not prepared"); - const licenseTermsId = BigInt(root.licenseTermsId); + for (const stage of ["child", "grandchild"] as const) { + if (manifest.registrations[stage]) continue; + const parent = stage === "child" ? manifest.registrations.root : manifest.registrations.child; + if (!parent) throw new Error(`${stage} Derivative is missing its persisted parent proof`); + const definition = resolvedDefinitionFor(resolved, stage); const predicted = await input.chain.predictMintingLicenseFee({ - licensorIpId: root.ipId, - licenseTermsId, + licensorIpId: parent.ipId, + licenseTermsId: BigInt(parent.licenseTermsId), amount: 1, }); - const result = await input.chain.registerDerivative({ + if (!ADDRESS.test(predicted.currencyToken)) { + throw new Error("Predicted Derivative fee currency is not an address"); + } + const prepared = await input.metadata.prepare({ ...definition, creatorAddress: input.wallet }); + const artifactHash = resolved.artifactHashes.get(stage); + if (!artifactHash) throw new Error(`${stage} artifact hash was not resolved`); + const intent: CanonicalOperationIntent = { + stage, + chainId: AENEID_CHAIN_ID, + wallet: input.wallet, + registrationName: definition.name, + artifactPath: definition.artifactPath, spgNftContract, - parentIpId: root.ipId, - licenseTermsId, - maxMintingFee: predicted.tokenAmount, - metadata: prepared.onchain, - }); - manifest.registrations.child = proof({ - definition, - result, - licenseTermsId, - parentIpIds: [root.ipId], - maxMintingFee: predicted.tokenAmount, - metadata: prepared, - }); - manifest.status = "partial"; - await input.store.save(manifest); - } - - const child = manifest.registrations.child; - if (!child) throw new Error("Child Derivative transaction confirmed without a persisted proof"); - - if (!manifest.registrations.grandchild) { - const definition = definitionFor(skills, "grandchild"); - const prepared = metadata.get("grandchild"); - if (!prepared) throw new Error("Grandchild Derivative metadata was not prepared"); - const licenseTermsId = BigInt(child.licenseTermsId); - const predicted = await input.chain.predictMintingLicenseFee({ - licensorIpId: child.ipId, - licenseTermsId, - amount: 1, - }); - const result = await input.chain.registerDerivative({ + parentIpId: parent.ipId, + licenseTermsId: parent.licenseTermsId, + licenseTemplate: parent.licenseTemplate, + currencyToken: predicted.currencyToken, + defaultMintingFee: null, + maxMintingFee: predicted.tokenAmount.toString(), + metadata: metadataForIntent({ definition, prepared, artifactHash }), + runConfigHash: resolved.hash, + }; + const derivativeInput: DerivativeInput = { spgNftContract, - parentIpId: child.ipId, - licenseTermsId, + parentIpId: parent.ipId, + licenseTermsId: BigInt(parent.licenseTermsId), maxMintingFee: predicted.tokenAmount, metadata: prepared.onchain, + }; + const executed = await executeOrResume({ + journal: input.journal, + operationId: operationId(input.wallet, stage), + stage, + intent, + prepare: async () => { + await requireDerivativeFeeReadiness({ chain: input.chain, wallet: input.wallet, predicted }); + return input.chain.prepareDerivative(derivativeInput); + }, + broadcast: (transaction) => input.chain.broadcastPrepared(transaction), + confirm: (operation) => input.chain.confirmDerivative({ + transactionHash: operation.transactionHash, + expectedCollection: requiredAddress(operation.intent.spgNftContract, "SPG collection"), + expectedParentIpId: requiredAddress(operation.intent.parentIpId, "parent IP"), + expectedLicenseTermsId: BigInt(requiredString(operation.intent.licenseTermsId, "license terms ID")), + expectedLicenseTemplate: requiredAddress(operation.intent.licenseTemplate, "license template"), + }), }); - manifest.registrations.grandchild = proof({ - definition, - result, - licenseTermsId, - parentIpIds: [child.ipId], - maxMintingFee: predicted.tokenAmount, - metadata: prepared, + await persistExecuted({ + executed, + manifest, + store: input.store, + journal: input.journal, + apply: (result, operation) => { + manifest.registrations[stage] = registrationProof(operation, result); + }, }); - manifest.status = "complete"; - await input.store.save(manifest); } return manifest; diff --git a/phase0/src/index.ts b/phase0/src/index.ts index 95f3bb8..db2a9b3 100644 --- a/phase0/src/index.ts +++ b/phase0/src/index.ts @@ -1,64 +1,35 @@ import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; -import { formatEther, type Address } from "viem"; +import { formatEther } from "viem"; -import { EXPLORER, getAccount, getClient, getPublicClient } from "./client"; +import { + getAccount, + getClient, + getPublicClient, + getWalletClient, +} from "./client"; import { runDemo } from "./demo"; import { HttpMetadataProvider } from "./metadata"; import { FileRegistrationStore } from "./registrations"; -import { - StoryChain, -} from "./story"; - -const { values: options, positionals } = parseArgs({ - allowPositionals: true, - options: { - name: { type: "string" }, - description: { type: "string" }, - "skill-file": { type: "string" }, - "rev-share": { type: "string" }, - policy: { type: "string" }, - "minting-fee": { type: "string" }, - spg: { type: "string" }, - symbol: { type: "string" }, - parent: { type: "string" }, - "license-terms-id": { type: "string" }, - }, -}); +import { StoryChain } from "./story"; +import { FileOperationJournal } from "./transactions"; +const { positionals } = parseArgs({ allowPositionals: true }); const command = positionals[0]; const registrationsPath = fileURLToPath(new URL("../registrations.json", import.meta.url)); +const pendingTransactionsPath = fileURLToPath( + new URL("../pending-transactions.json", import.meta.url), +); function storyChain(): StoryChain { return new StoryChain({ sdk: getClient(), + wallet: getWalletClient(), publicClient: getPublicClient(), }); } -function requiredOption(name: keyof typeof options): string { - const value = options[name]; - if (typeof value !== "string" || value.length === 0) throw new Error(`--${name} is required`); - return value; -} - -function spgAddress(): Address { - const value = options.spg ?? process.env.SPG_NFT_CONTRACT; - if (!value) { - throw new Error( - "No SPG collection. Run `npm run demo`, or run `npm run create-collection` and pass --spg/set SPG_NFT_CONTRACT.", - ); - } - return value as Address; -} - -function royaltyPolicy(): "LAP" | "LRP" { - const value = (options.policy ?? "LAP").toUpperCase(); - if (value !== "LAP" && value !== "LRP") throw new Error("--policy must be LAP or LRP"); - return value; -} - async function check() { const account = getAccount(); const chain = storyChain(); @@ -74,95 +45,16 @@ async function check() { return { wallet: account.address, chainId, balance }; } -async function createCollection() { - const account = getAccount(); - const result = await storyChain().createCollection({ - name: options.name ?? "Skills", - symbol: options.symbol ?? "SKILL", - mintFeeRecipient: account.address, - }); - console.log("✓ SPG NFT collection created"); - console.log("spgNftContract:", result.spgNftContract); - console.log("txHash :", result.txHash); - console.log("\n→ pass to advanced commands: --spg " + result.spgNftContract); - return result; -} - -async function registerSkill() { - const account = getAccount(); - const name = requiredOption("name"); - const artifactPath = requiredOption("skill-file"); - const metadata = await new HttpMetadataProvider().prepare({ - stage: "root", - name, - description: options.description ?? "", - creatorAddress: account.address, - artifactPath, - }); - const revShare = Number(options["rev-share"] ?? "25"); - const result = await storyChain().registerSkill({ - spgNftContract: spgAddress(), - metadata: metadata.onchain, - defaultMintingFee: BigInt(options["minting-fee"] ?? "0"), - revShare, - policy: royaltyPolicy(), - }); - console.log("✓ Skill registered as a Story IP Asset"); - console.log("ipId :", result.ipId); - console.log("tokenId :", result.tokenId.toString()); - console.log("licenseTermsId:", result.licenseTermsId.toString()); - console.log("artifact hash :", metadata.proof.artifact.mediaHash, "(SHA-256)"); - console.log("txHash :", result.txHash); - console.log("explorer :", `${EXPLORER}/ipa/${result.ipId}`); - return { ...result, metadata }; -} - -async function registerDerivative() { - const account = getAccount(); - const name = requiredOption("name"); - const artifactPath = requiredOption("skill-file"); - const parentIpId = requiredOption("parent") as Address; - const licenseTermsId = BigInt(requiredOption("license-terms-id")); - const metadata = await new HttpMetadataProvider().prepare({ - stage: "child", - name, - description: options.description ?? "", - creatorAddress: account.address, - artifactPath, - }); - const chain = storyChain(); - const predicted = await chain.predictMintingLicenseFee({ - licensorIpId: parentIpId, - licenseTermsId, - amount: 1, - }); - const result = await chain.registerDerivative({ - spgNftContract: spgAddress(), - parentIpId, - licenseTermsId, - maxMintingFee: predicted.tokenAmount, - metadata: metadata.onchain, - }); - console.log("✓ Derivative registered (declared parent on-chain)"); - console.log("ipId :", result.ipId); - console.log("tokenId :", result.tokenId.toString()); - console.log("parentIpId :", parentIpId); - console.log("licenseTermsId:", licenseTermsId.toString()); - console.log("maxMintingFee :", predicted.tokenAmount.toString(), "(predicted explicit cap)"); - console.log("artifact hash :", metadata.proof.artifact.mediaHash, "(SHA-256)"); - console.log("txHash :", result.txHash); - console.log("explorer :", `${EXPLORER}/ipa/${result.ipId}`); - return { ...result, licenseTermsId, maxMintingFee: predicted.tokenAmount, metadata }; -} - async function demo() { const account = getAccount(); - const manifest = await runDemo({ + const journal = new FileOperationJournal(pendingTransactionsPath); + const manifest = await journal.withExclusiveLease((leasedJournal) => runDemo({ wallet: account.address, chain: storyChain(), metadata: new HttpMetadataProvider(), store: new FileRegistrationStore(registrationsPath), - }); + journal: leasedJournal, + })); console.log("✓ Phase 0 provenance demo status:", manifest.status); console.log("wallet :", manifest.wallet); console.log("spgNftContract:", manifest.spgNftContract); @@ -174,13 +66,7 @@ async function demo() { return manifest; } -const commands: Record Promise> = { - check, - demo, - "create-collection": createCollection, - "register-skill": registerSkill, - "register-derivative": registerDerivative, -}; +const commands: Record Promise> = { check, demo }; async function main() { const run = command ? commands[command] : undefined; @@ -189,9 +75,6 @@ async function main() { console.log("commands:"); console.log(" npm run demo"); console.log(" npm run check"); - console.log(" npm run create-collection [-- --name Skills --symbol SKILL]"); - console.log(" npm run register-skill -- --spg
--name --skill-file [--rev-share 25] [--policy LAP|LRP]"); - console.log(" npm run register-derivative -- --spg
--parent --license-terms-id --name --skill-file "); process.exit(command ? 1 : 0); } await run(); diff --git a/phase0/src/registrations.ts b/phase0/src/registrations.ts index 764e919..fd81283 100644 --- a/phase0/src/registrations.ts +++ b/phase0/src/registrations.ts @@ -1,5 +1,12 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { + mkdir, + open, + readFile, + rename, + unlink, + type FileHandle, +} from "node:fs/promises"; import { dirname } from "node:path"; export const REGISTRATION_SCHEMA_VERSION = 1 as const; @@ -34,6 +41,7 @@ export interface RegistrationProof { tokenId: string; txHash: `0x${string}`; licenseTermsId: string; + licenseTemplate: `0x${string}`; parentIpIds: `0x${string}`[]; defaultMintingFee: string | null; maxMintingFee: string | null; @@ -55,6 +63,12 @@ export interface RegistrationStore { save(manifest: RegistrationManifest): Promise; } +export interface ManifestWriteHooks { + afterTempSync?(): void | Promise; + afterRename?(): void | Promise; + afterDirectorySync?(): void | Promise; +} + export function createEmptyRegistrationManifest(): RegistrationManifest { return { schemaVersion: REGISTRATION_SCHEMA_VERSION, @@ -133,6 +147,9 @@ function validateProof(value: unknown, stage: DemoStage): RegistrationProof | nu if (!isDecimal(value.licenseTermsId)) { throw new Error(`${stage}.licenseTermsId must be a decimal string`); } + if (!isAddress(value.licenseTemplate)) { + throw new Error(`${stage}.licenseTemplate must be a 20-byte address`); + } if (!Array.isArray(value.parentIpIds) || !value.parentIpIds.every(isAddress)) { throw new Error(`${stage}.parentIpIds must contain addresses`); } @@ -203,6 +220,13 @@ export function parseRegistrationManifest(value: unknown): RegistrationManifest if (grandchild && child && grandchild.licenseTermsId !== child.licenseTermsId) { throw new Error("grandchild.licenseTermsId must inherit the child license terms"); } + if (child && root && child.licenseTemplate.toLowerCase() !== root.licenseTemplate.toLowerCase()) { + throw new Error("child.licenseTemplate must inherit the root license template"); + } + if (grandchild && child + && grandchild.licenseTemplate.toLowerCase() !== child.licenseTemplate.toLowerCase()) { + throw new Error("grandchild.licenseTemplate must inherit the child license template"); + } if (value.status === "not-run") { if (value.wallet || value.spgNftContract || value.collectionTxHash || root || child || grandchild) { @@ -223,7 +247,10 @@ export function parseRegistrationManifest(value: unknown): RegistrationManifest } export class FileRegistrationStore implements RegistrationStore { - constructor(private readonly path: string) {} + constructor( + private readonly path: string, + private readonly hooks: ManifestWriteHooks = {}, + ) {} async load(): Promise { try { @@ -239,15 +266,46 @@ export class FileRegistrationStore implements RegistrationStore { async save(manifest: RegistrationManifest): Promise { parseRegistrationManifest(manifest); - await mkdir(dirname(this.path), { recursive: true }); + const directory = dirname(this.path); + await mkdir(directory, { recursive: true }); const temporaryPath = `${this.path}.${process.pid}.${randomUUID()}.tmp`; - const contents = `${JSON.stringify(manifest, null, 2)}\n`; + const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + let temporaryHandle: FileHandle | null = null; + let renamed = false; try { - await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx" }); + temporaryHandle = await open(temporaryPath, "wx", 0o600); + await writeAll(temporaryHandle, bytes); + await temporaryHandle.sync(); + await this.hooks.afterTempSync?.(); + await temporaryHandle.close(); + temporaryHandle = null; await rename(temporaryPath, this.path); + renamed = true; + await this.hooks.afterRename?.(); + const directoryHandle = await open(directory, "r"); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + await this.hooks.afterDirectorySync?.(); } catch (error) { - await unlink(temporaryPath).catch(() => undefined); + if (temporaryHandle) await temporaryHandle.close().catch(() => undefined); + if (!renamed) await unlink(temporaryPath).catch(() => undefined); throw error; } } } + +async function writeAll(handle: FileHandle, bytes: Uint8Array): Promise { + let offset = 0; + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset, offset); + if (!Number.isSafeInteger(bytesWritten) + || bytesWritten <= 0 + || bytesWritten > bytes.byteLength - offset) { + throw new Error("Manifest temporary write made no progress or returned an invalid byte count"); + } + offset += bytesWritten; + } +} diff --git a/phase0/src/story.ts b/phase0/src/story.ts index abb8181..2c10334 100644 --- a/phase0/src/story.ts +++ b/phase0/src/story.ts @@ -4,8 +4,29 @@ import { type StoryClient, WIP_TOKEN_ADDRESS, } from "@story-protocol/core-sdk"; +import { + keccak256, + parseAbiItem, + parseEventLogs, + TransactionNotFoundError, + TransactionReceiptNotFoundError, + type Hash, + type Hex, + type Log, +} from "viem"; -import type { DemoChain, PreparedMetadata } from "./demo"; +import { + AENEID_CHAIN_ID, + type CollectionInput, + type CollectionResult, + type DemoChain, + type DerivativeInput, + type DerivativeResult, + type PreparedChainTransaction, + type PredictFeeInput, + type SkillInput, + type SkillResult, +} from "./demo"; type Address = `0x${string}`; @@ -14,16 +35,50 @@ export type StorySdkBoundary = { ipAsset: Pick< StoryClient["ipAsset"], "mintAndRegisterIpAssetWithPilTerms" | "mintAndRegisterIpAndMakeDerivative" - >; + > & { + wipClient: { + address: Address; + balanceOf(input: { owner: Address }): Promise<{ result: bigint }>; + allowance(input: { owner: Address; spender: Address }): Promise<{ result: bigint }>; + }; + derivativeWorkflowsClient: { address: Address }; + }; license: Pick; }; +export interface StoryWalletBoundary { + account: unknown; + chain: unknown; + prepareTransactionRequest(input: { + account: unknown; + chain: unknown; + to: Address; + data: Hex; + }): Promise; + signTransaction(request: unknown): Promise; +} + +export interface StoryReceiptBoundary { + status: "success" | "reverted"; + transactionHash: Hash; + logs: Log[]; +} + export interface StoryPublicClientBoundary { getChainId(): Promise; getBalance(input: { address: Address }): Promise; getGasPrice(): Promise; + sendRawTransaction(input: { serializedTransaction: Hex }): Promise; + getTransaction(input: { hash: Hash }): Promise<{ hash: Hash }>; + getTransactionReceipt(input: { hash: Hash }): Promise<{ transactionHash: Hash }>; + waitForTransactionReceipt(input: { hash: Hash }): Promise; } +const COLLECTION_CREATED = parseAbiItem("event CollectionCreated(address indexed spgNftContract)"); +const IP_REGISTERED = parseAbiItem("event IPRegistered(address ipId, uint256 indexed chainId, address indexed tokenContract, uint256 indexed tokenId, string name, string uri, uint256 registrationDate)"); +const LICENSE_TERMS_ATTACHED = parseAbiItem("event LicenseTermsAttached(address indexed caller, address indexed ipId, address licenseTemplate, uint256 licenseTermsId)"); +const DERIVATIVE_REGISTERED = parseAbiItem("event DerivativeRegistered(address indexed caller, address indexed childIpId, uint256[] licenseTokenIds, address[] parentIpIds, uint256[] licenseTermsIds, address licenseTemplate)"); + function required(value: T | undefined, label: string): T { if (value === undefined) throw new Error(`Story SDK response is missing ${label}`); return value; @@ -36,51 +91,46 @@ function validateRevShare(value: number): number { return value; } -export class StoryChain implements DemoChain { - private readonly sdk: StorySdkBoundary; - private readonly publicClient: StoryPublicClientBoundary; +function sameHex(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} - constructor(input: { sdk: StorySdkBoundary; publicClient: StoryPublicClientBoundary }) { - this.sdk = input.sdk; - this.publicClient = input.publicClient; - } +function exactlyOne(items: readonly T[], label: string): T { + if (items.length !== 1) throw new Error(`Expected exactly one ${label} event; received ${items.length}`); + return items[0]; +} + +export class StoryChain implements DemoChain { + constructor(private readonly input: { + sdk: StorySdkBoundary; + wallet: StoryWalletBoundary; + publicClient: StoryPublicClientBoundary; + }) {} getChainId(): Promise { - return this.publicClient.getChainId(); + return this.input.publicClient.getChainId(); } getBalance(address: Address): Promise { - return this.publicClient.getBalance({ address }); + return this.input.publicClient.getBalance({ address }); } getGasPrice(): Promise { - return this.publicClient.getGasPrice(); + return this.input.publicClient.getGasPrice(); } - async createCollection(input: { - name: string; - symbol: string; - mintFeeRecipient: Address; - }) { - const response = await this.sdk.nftClient.createNFTCollection({ + async prepareCollection(input: CollectionInput): Promise { + const response = await this.input.sdk.nftClient.createNFTCollection({ ...input, isPublicMinting: true, mintOpen: true, contractURI: "", + txOptions: { encodedTxDataOnly: true }, }); - return { - spgNftContract: required(response.spgNftContract, "spgNftContract"), - txHash: required(response.txHash, "collection txHash"), - }; + return this.signEncoded(required(response.encodedTxData, "encoded collection transaction")); } - async registerSkill(input: { - spgNftContract: Address; - metadata: PreparedMetadata["onchain"]; - defaultMintingFee: bigint; - revShare?: number; - policy?: "LAP" | "LRP"; - }) { + async prepareSkill(input: SkillInput): Promise { const revShare = validateRevShare(input.revShare ?? 25); const policy = input.policy ?? "LAP"; const terms = PILFlavor.commercialRemix({ @@ -89,36 +139,17 @@ export class StoryChain implements DemoChain { currency: WIP_TOKEN_ADDRESS, royaltyPolicy: policy === "LRP" ? NativeRoyaltyPolicy.LRP : NativeRoyaltyPolicy.LAP, }); - const response = await this.sdk.ipAsset.mintAndRegisterIpAssetWithPilTerms({ + const response = await this.input.sdk.ipAsset.mintAndRegisterIpAssetWithPilTerms({ spgNftContract: input.spgNftContract, licenseTermsData: [{ terms }], ipMetadata: input.metadata, + txOptions: { encodedTxDataOnly: true }, }); - return { - ipId: required(response.ipId, "ipId"), - tokenId: required(response.tokenId, "tokenId"), - txHash: required(response.txHash, "registration txHash"), - licenseTermsId: required(response.licenseTermsIds?.[0], "licenseTermsId"), - }; - } - - async predictMintingLicenseFee(input: { - licensorIpId: Address; - licenseTermsId: bigint; - amount: number; - }) { - const response = await this.sdk.license.predictMintingLicenseFee(input); - return { tokenAmount: required(response.tokenAmount, "predicted tokenAmount") }; + return this.signEncoded(required(response.encodedTxData, "encoded Skill transaction")); } - async registerDerivative(input: { - spgNftContract: Address; - parentIpId: Address; - licenseTermsId: bigint; - maxMintingFee: bigint; - metadata: PreparedMetadata["onchain"]; - }) { - const response = await this.sdk.ipAsset.mintAndRegisterIpAndMakeDerivative({ + async prepareDerivative(input: DerivativeInput): Promise { + const response = await this.input.sdk.ipAsset.mintAndRegisterIpAndMakeDerivative({ spgNftContract: input.spgNftContract, derivData: { parentIpIds: [input.parentIpId], @@ -128,11 +159,166 @@ export class StoryChain implements DemoChain { maxRevenueShare: 100, }, ipMetadata: input.metadata, + txOptions: { encodedTxDataOnly: true }, }); + return this.signEncoded(required(response.encodedTxData, "encoded Derivative transaction")); + } + + async broadcastPrepared(input: PreparedChainTransaction): Promise { + try { + const observed = await this.input.publicClient.sendRawTransaction({ + serializedTransaction: input.serializedTransaction, + }); + if (!sameHex(observed, input.transactionHash)) { + throw new Error(`RPC returned ${observed}; expected ${input.transactionHash}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/already known|known transaction|nonce too low/i.test(message)) throw error; + if (!await this.findExactTransaction(input.transactionHash)) { + throw new Error( + `Prepared transaction ${input.transactionHash} is unresolved; its nonce may have been replaced`, + { cause: error }, + ); + } + } + } + + async confirmCollection(transactionHash: Hash): Promise { + const receipt = await this.successfulReceipt(transactionHash); + const events = parseEventLogs({ abi: [COLLECTION_CREATED], logs: receipt.logs, strict: true }); + const event = exactlyOne(events, "CollectionCreated"); + return { spgNftContract: event.args.spgNftContract, txHash: transactionHash }; + } + + async confirmSkill(input: { + transactionHash: Hash; + expectedCollection: Address; + }): Promise { + const receipt = await this.successfulReceipt(input.transactionHash); + const registrations = parseEventLogs({ abi: [IP_REGISTERED], logs: receipt.logs, strict: true }) + .filter((event) => event.args.chainId === BigInt(AENEID_CHAIN_ID) + && sameHex(event.args.tokenContract, input.expectedCollection)); + const registration = exactlyOne(registrations, "matching IPRegistered"); + const licenses = parseEventLogs({ abi: [LICENSE_TERMS_ATTACHED], logs: receipt.logs, strict: true }) + .filter((event) => sameHex(event.args.ipId, registration.args.ipId)); + const license = exactlyOne(licenses, "matching LicenseTermsAttached"); + return { + ipId: registration.args.ipId, + tokenId: registration.args.tokenId, + txHash: input.transactionHash, + licenseTermsId: license.args.licenseTermsId, + licenseTemplate: license.args.licenseTemplate, + }; + } + + async confirmDerivative(input: { + transactionHash: Hash; + expectedCollection: Address; + expectedParentIpId: Address; + expectedLicenseTermsId: bigint; + expectedLicenseTemplate: Address; + }): Promise { + const receipt = await this.successfulReceipt(input.transactionHash); + const registrations = parseEventLogs({ abi: [IP_REGISTERED], logs: receipt.logs, strict: true }) + .filter((event) => event.args.chainId === BigInt(AENEID_CHAIN_ID) + && sameHex(event.args.tokenContract, input.expectedCollection)); + const registration = exactlyOne(registrations, "matching IPRegistered"); + const derivatives = parseEventLogs({ abi: [DERIVATIVE_REGISTERED], logs: receipt.logs, strict: true }); + const derivative = exactlyOne(derivatives, "DerivativeRegistered"); + if (!sameHex(derivative.args.childIpId, registration.args.ipId)) { + throw new Error("DerivativeRegistered child IP does not match the registered IP"); + } + if (derivative.args.parentIpIds.length !== 1 + || !sameHex(derivative.args.parentIpIds[0], input.expectedParentIpId)) { + throw new Error("DerivativeRegistered parent IP does not match the journal-bound parent"); + } + if (derivative.args.licenseTermsIds.length !== 1 + || derivative.args.licenseTermsIds[0] !== input.expectedLicenseTermsId) { + throw new Error("DerivativeRegistered license terms do not match the journal-bound terms"); + } + if (!sameHex(derivative.args.licenseTemplate, input.expectedLicenseTemplate)) { + throw new Error("DerivativeRegistered license template does not match the journal-bound template"); + } + return { + ipId: registration.args.ipId, + tokenId: registration.args.tokenId, + txHash: input.transactionHash, + licenseTermsId: derivative.args.licenseTermsIds[0], + licenseTemplate: derivative.args.licenseTemplate, + }; + } + + async predictMintingLicenseFee(input: PredictFeeInput) { + const response = await this.input.sdk.license.predictMintingLicenseFee(input); return { - ipId: required(response.ipId, "ipId"), - tokenId: required(response.tokenId, "tokenId"), - txHash: required(response.txHash, "registration txHash"), + currencyToken: required(response.currencyToken, "predicted currencyToken"), + tokenAmount: required(response.tokenAmount, "predicted tokenAmount"), }; } + + async getDerivativeFeeReadiness(input: { + wallet: Address; + currencyToken: Address; + requiredAmount: bigint; + }) { + const wip = this.input.sdk.ipAsset.wipClient; + const spender = this.input.sdk.ipAsset.derivativeWorkflowsClient.address; + if (!sameHex(input.currencyToken, WIP_TOKEN_ADDRESS) + || !sameHex(wip.address, WIP_TOKEN_ADDRESS)) { + throw new Error( + `Derivative fee currency ${input.currencyToken} is not supported WIP ${WIP_TOKEN_ADDRESS}`, + ); + } + const [balanceResult, allowanceResult] = await Promise.all([ + wip.balanceOf({ owner: input.wallet }), + wip.allowance({ owner: input.wallet, spender }), + ]); + return { + currencyToken: wip.address, + spender, + requiredAmount: input.requiredAmount, + balance: balanceResult.result, + allowance: allowanceResult.result, + }; + } + + private async signEncoded(encoded: { to: Address; data: Hex }): Promise { + const request = await this.input.wallet.prepareTransactionRequest({ + account: this.input.wallet.account, + chain: this.input.wallet.chain, + to: encoded.to, + data: encoded.data, + }); + const serializedTransaction = await this.input.wallet.signTransaction(request); + return { serializedTransaction, transactionHash: keccak256(serializedTransaction) }; + } + + private async findExactTransaction(expected: Hash): Promise { + const [transaction, receipt] = await Promise.all([ + this.input.publicClient.getTransaction({ hash: expected }).catch((error: unknown) => { + if (error instanceof TransactionNotFoundError) return null; + throw error; + }), + this.input.publicClient.getTransactionReceipt({ hash: expected }).catch((error: unknown) => { + if (error instanceof TransactionReceiptNotFoundError) return null; + throw error; + }), + ]); + return Boolean( + (transaction && sameHex(transaction.hash, expected)) + || (receipt && sameHex(receipt.transactionHash, expected)), + ); + } + + private async successfulReceipt(expected: Hash): Promise { + const receipt = await this.input.publicClient.waitForTransactionReceipt({ hash: expected }); + if (!sameHex(receipt.transactionHash, expected)) { + throw new Error(`Transaction receipt ${receipt.transactionHash} does not match expected hash ${expected}`); + } + if (receipt.status !== "success") { + throw new Error(`Transaction ${expected} did not produce a successful receipt (status: ${receipt.status})`); + } + return receipt; + } } diff --git a/phase0/tests/demo.test.ts b/phase0/tests/demo.test.ts index 3857b06..17002b5 100644 --- a/phase0/tests/demo.test.ts +++ b/phase0/tests/demo.test.ts @@ -1,38 +1,117 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; +import { WIP_TOKEN_ADDRESS } from "@story-protocol/core-sdk"; +import { keccak256 } from "viem"; + import { AENEID_CHAIN_ID, AENEID_FAUCET_URL, + DEMO_ROOT_MINTING_FEE, + DEMO_SKILLS, runDemo, + type CollectionInput, type DemoChain, type DemoMetadataProvider, + type DemoSkillDefinition, + type DerivativeInput, + type PreparedChainTransaction, + type SkillInput, } from "../src/demo"; +import { HttpMetadataProvider } from "../src/metadata"; import { + FileRegistrationStore, createEmptyRegistrationManifest, type RegistrationManifest, + type RegistrationProof, type RegistrationStore, } from "../src/registrations"; +import { + operationIntentHash, + runConfigHash, + type CanonicalOperationIntent, + type JournalSnapshot, + type LeasedOperationJournal, + type OperationState, + type OperationStage, + type PendingOperation, +} from "../src/transactions"; const WALLET = "0x00000000000000000000000000000000000000aa" as const; +const OTHER_WALLET = "0x00000000000000000000000000000000000000ff" as const; const COLLECTION = "0x00000000000000000000000000000000000000bb" as const; +const LICENSE_TEMPLATE = "0x00000000000000000000000000000000000000cc" as const; +const DERIVATIVE_FEE_SPENDER = "0x00000000000000000000000000000000000000dd" as const; const ROOT = "0x0000000000000000000000000000000000000001" as const; const CHILD = "0x0000000000000000000000000000000000000002" as const; const GRANDCHILD = "0x0000000000000000000000000000000000000003" as const; +const GAS_PRICE = 2n; +const FUNDED_BALANCE = 20_000_000n; + +const SERIALIZED: Record = { + collection: `0x${"11".repeat(64)}`, + root: `0x${"22".repeat(64)}`, + child: `0x${"33".repeat(64)}`, + grandchild: `0x${"44".repeat(64)}`, +}; + +const TRANSACTION_HASH: Record = { + collection: keccak256(SERIALIZED.collection), + root: keccak256(SERIALIZED.root), + child: keccak256(SERIALIZED.child), + grandchild: keccak256(SERIALIZED.grandchild), +}; function clone(value: T): T { return structuredClone(value); } +async function fetchInlineMetadata(input: string | URL | Request): Promise { + const url = new URL(String(input)); + assert.equal(url.origin, "https://httpbin.org"); + assert.match(url.pathname, /^\/base64\//); + return new Response(Buffer.from(url.pathname.slice("/base64/".length), "base64url")); +} + +async function sha256(path: string): Promise<`0x${string}`> { + return `0x${createHash("sha256").update(await readFile(path)).digest("hex")}`; +} + +function definition(stage: "root" | "child" | "grandchild", skills = DEMO_SKILLS) { + const found = skills.find((value) => value.stage === stage); + if (!found) throw new Error(`missing ${stage} definition`); + return found; +} + +async function configHash(wallet = WALLET, skills = DEMO_SKILLS) { + return runConfigHash({ + chainId: AENEID_CHAIN_ID, + wallet, + stages: await Promise.all((["root", "child", "grandchild"] as const).map(async (stage) => { + const value = definition(stage, skills); + return { + stage, + name: value.name, + description: value.description, + artifactPath: value.artifactPath, + artifactSha256: await sha256(value.artifactPath), + }; + })), + }); +} + class MemoryStore implements RegistrationStore { - loadCalls = 0; saveCalls = 0; snapshots: RegistrationManifest[] = []; + throwAfterSavingStage: "root" | "child" | "grandchild" | null = null; constructor(public manifest = createEmptyRegistrationManifest()) {} async load(): Promise { - this.loadCalls += 1; return clone(this.manifest); } @@ -40,297 +119,801 @@ class MemoryStore implements RegistrationStore { this.saveCalls += 1; this.manifest = clone(manifest); this.snapshots.push(clone(manifest)); + const stage = this.throwAfterSavingStage; + if (stage && manifest.registrations[stage]) { + this.throwAfterSavingStage = null; + throw new Error(`simulated crash after ${stage} manifest rename`); + } + } +} + +class MemoryJournal implements LeasedOperationJournal { + revision = 0; + operation: PendingOperation | null = null; + saveCalls = 0; + clearCalls = 0; + crashAfterNextSave = false; + + constructor(snapshot?: JournalSnapshot) { + if (snapshot) { + this.revision = snapshot.revision; + this.operation = clone(snapshot.operation); + } + } + + async load(): Promise { + return { revision: this.revision, operation: clone(this.operation) }; + } + + async save(operation: PendingOperation, expectedRevision: number): Promise { + assert.equal(expectedRevision, this.revision, "test journal CAS revision"); + this.revision += 1; + this.operation = clone(operation); + this.saveCalls += 1; + if (this.crashAfterNextSave) { + this.crashAfterNextSave = false; + throw new Error("simulated crash after journal save"); + } + return this.load(); + } + + async clear(operationId: string, expectedRevision: number): Promise { + assert.equal(expectedRevision, this.revision, "test journal clear CAS revision"); + assert.equal(this.operation?.operationId, operationId, "test journal clear operation"); + this.revision += 1; + this.operation = null; + this.clearCalls += 1; + return this.load(); } } class FakeMetadata implements DemoMetadataProvider { + calls = 0; stages: string[] = []; + failWith: string | null = null; - async prepare(input: { stage: string; artifactPath: string }) { + async prepare(input: DemoSkillDefinition) { + this.calls += 1; this.stages.push(input.stage); + if (this.failWith) throw new Error(this.failWith); const digit = input.stage === "root" ? "1" : input.stage === "child" ? "2" : "3"; - const hash = `0x${digit.repeat(64)}` as const; + const metadataHash = `0x${digit.repeat(64)}` as const; + const artifactHash = await sha256(input.artifactPath); return { onchain: { ipMetadataURI: `https://example.test/${input.stage}/ip`, - ipMetadataHash: hash, + ipMetadataHash: metadataHash, nftMetadataURI: `https://example.test/${input.stage}/nft`, - nftMetadataHash: hash, + nftMetadataHash: metadataHash, }, proof: { - ip: { uri: `https://example.test/${input.stage}/ip`, hash }, - nft: { uri: `https://example.test/${input.stage}/nft`, hash }, - artifact: { path: input.artifactPath, mediaHash: hash, mediaType: "text/markdown" }, + ip: { uri: `https://example.test/${input.stage}/ip`, hash: metadataHash }, + nft: { uri: `https://example.test/${input.stage}/nft`, hash: metadataHash }, + artifact: { + path: input.artifactPath, + mediaHash: artifactHash, + mediaType: "text/markdown", + }, }, }; } } -class FakeChain implements DemoChain { - writes: string[] = []; - derivativeInputs: Array<{ parentIpId: string; licenseTermsId: bigint; maxMintingFee: bigint }> = []; - failOn: "collection" | "root" | "child" | "grandchild" | null = null; - gasPrice = 2n; +type CrashPoint = "broadcast" | "confirm" | null; + +class CrashableChain implements DemoChain { + chainId: number = AENEID_CHAIN_ID; + balance = FUNDED_BALANCE; + gasPrice = GAS_PRICE; + predictedCurrencyToken = WIP_TOKEN_ADDRESS; + predictedFee = 123n; + wipBalance = 1_000_000_000_000_000_000n; + wipAllowance = 1_000_000_000_000_000_000n; + derivativeFeeSpender = DERIVATIVE_FEE_SPENDER; + balanceReads = 0; + gasPriceReads = 0; + predictionCalls = 0; + feeReadinessCalls = 0; + prepareCounts: Record = { + collection: 0, + root: 0, + child: 0, + grandchild: 0, + }; + preparedHashes: `0x${string}`[] = []; + broadcastTransactions: PreparedChainTransaction[] = []; + confirmHashes: `0x${string}`[] = []; + stopAfterRoot = false; + consumeBalancesOnBroadcast = false; + private crashed = false; constructor( - public chainId: number = AENEID_CHAIN_ID, - public balance: bigint = 1n, - public predictedFee: bigint = 123n, + public crashAfter: CrashPoint = null, + public crashStage: OperationStage = "root", ) {} + resumedWithoutCrash(): CrashableChain { + const next = new CrashableChain(null, this.crashStage); + next.chainId = this.chainId; + next.balance = this.balance; + next.gasPrice = this.gasPrice; + next.predictedCurrencyToken = this.predictedCurrencyToken; + next.predictedFee = this.predictedFee; + next.wipBalance = this.wipBalance; + next.wipAllowance = this.wipAllowance; + next.derivativeFeeSpender = this.derivativeFeeSpender; + return next; + } + async getChainId() { return this.chainId; } async getBalance() { + this.balanceReads += 1; return this.balance; } async getGasPrice() { + this.gasPriceReads += 1; return this.gasPrice; } - async createCollection() { - this.writes.push("collection"); - if (this.failOn === "collection") throw new Error("collection failed"); - return { spgNftContract: COLLECTION, txHash: "0xcollection" as const }; + async prepareCollection(_input: CollectionInput) { + return this.prepare("collection"); + } + + async prepareSkill(_input: SkillInput) { + return this.prepare("root"); + } + + async prepareDerivative(input: DerivativeInput) { + return this.prepare(input.parentIpId.toLowerCase() === ROOT.toLowerCase() ? "child" : "grandchild"); + } + + async broadcastPrepared(input: PreparedChainTransaction) { + assert.equal(keccak256(input.serializedTransaction), input.transactionHash); + this.broadcastTransactions.push(clone(input)); + const stage = this.stageForHash(input.transactionHash); + if (this.consumeBalancesOnBroadcast && (stage === "child" || stage === "grandchild")) { + this.balance = 0n; + this.wipBalance = 0n; + this.wipAllowance = 0n; + } + this.maybeCrash("broadcast", stage); + } + + async confirmCollection(transactionHash: `0x${string}`) { + this.confirm("collection", transactionHash); + return { spgNftContract: COLLECTION, txHash: transactionHash }; + } + + async confirmSkill(input: { transactionHash: `0x${string}`; expectedCollection: `0x${string}` }) { + assert.equal(input.expectedCollection.toLowerCase(), COLLECTION.toLowerCase()); + this.confirm("root", input.transactionHash); + return { + ipId: ROOT, + tokenId: 1n, + txHash: input.transactionHash, + licenseTermsId: 7n, + licenseTemplate: LICENSE_TEMPLATE, + }; } - async registerSkill() { - this.writes.push("root"); - if (this.failOn === "root") throw new Error("root failed"); - return { ipId: ROOT, tokenId: 1n, txHash: "0xroot" as const, licenseTermsId: 7n }; + async confirmDerivative(input: { + transactionHash: `0x${string}`; + expectedCollection: `0x${string}`; + expectedParentIpId: `0x${string}`; + expectedLicenseTermsId: bigint; + expectedLicenseTemplate: `0x${string}`; + }) { + assert.equal(input.expectedCollection.toLowerCase(), COLLECTION.toLowerCase()); + assert.equal(input.expectedLicenseTermsId, 7n); + assert.equal(input.expectedLicenseTemplate.toLowerCase(), LICENSE_TEMPLATE.toLowerCase()); + const stage = input.expectedParentIpId.toLowerCase() === ROOT.toLowerCase() ? "child" : "grandchild"; + this.confirm(stage, input.transactionHash); + return { + ipId: stage === "child" ? CHILD : GRANDCHILD, + tokenId: stage === "child" ? 2n : 3n, + txHash: input.transactionHash, + licenseTermsId: 7n, + licenseTemplate: LICENSE_TEMPLATE, + }; } async predictMintingLicenseFee() { - return { tokenAmount: this.predictedFee }; + this.predictionCalls += 1; + if (this.stopAfterRoot) throw new Error("stop after recovered root"); + return { currencyToken: this.predictedCurrencyToken, tokenAmount: this.predictedFee }; } - async registerDerivative(input: { - parentIpId: string; - licenseTermsId: bigint; - maxMintingFee: bigint; + async getDerivativeFeeReadiness(input: { + currencyToken: `0x${string}`; + requiredAmount: bigint; }) { - const stage = input.parentIpId === ROOT ? "child" : "grandchild"; - this.writes.push(stage); - this.derivativeInputs.push(input); - if (this.failOn === stage) throw new Error(`${stage} failed`); - return stage === "child" - ? { ipId: CHILD, tokenId: 2n, txHash: "0xchild" as const } - : { ipId: GRANDCHILD, tokenId: 3n, txHash: "0xgrandchild" as const }; + this.feeReadinessCalls += 1; + return { + currencyToken: input.currencyToken, + spender: this.derivativeFeeSpender, + requiredAmount: input.requiredAmount, + balance: this.wipBalance, + allowance: this.wipAllowance, + }; } -} -test("zero balance exits with faucet details before metadata, store, or writes", async () => { - const chain = new FakeChain(AENEID_CHAIN_ID, 0n); - const metadata = new FakeMetadata(); - const store = new MemoryStore(); + private prepare(stage: OperationStage): PreparedChainTransaction { + this.prepareCounts[stage] += 1; + this.preparedHashes.push(TRANSACTION_HASH[stage]); + return { serializedTransaction: SERIALIZED[stage], transactionHash: TRANSACTION_HASH[stage] }; + } - await assert.rejects( - runDemo({ wallet: WALLET, chain, metadata, store }), - (error: Error) => { - assert.match(error.message, new RegExp(WALLET, "i")); - assert.match(error.message, /Story Aeneid \(1315\)/); - assert.match(error.message, new RegExp(AENEID_FAUCET_URL.replaceAll(".", "\\."))); - return true; - }, - ); + private confirm(stage: OperationStage, transactionHash: `0x${string}`) { + assert.equal(transactionHash, TRANSACTION_HASH[stage]); + this.confirmHashes.push(transactionHash); + this.maybeCrash("confirm", stage); + } - assert.deepEqual(chain.writes, []); - assert.deepEqual(metadata.stages, []); - assert.equal(store.loadCalls, 0); - assert.equal(store.saveCalls, 0); -}); + private maybeCrash(point: Exclude, stage: OperationStage) { + if (!this.crashed && this.crashAfter === point && this.crashStage === stage) { + this.crashed = true; + throw new Error(`simulated crash after ${stage} ${point}`); + } + } -test("wrong chain exits before balance, metadata, store, or writes", async () => { - const chain = new FakeChain(1514, 1n); - let balanceCalls = 0; - chain.getBalance = async () => { - balanceCalls += 1; - return 1n; + private stageForHash(hash: `0x${string}`): OperationStage { + const found = (Object.entries(TRANSACTION_HASH) as Array<[OperationStage, `0x${string}`]>) + .find(([, candidate]) => candidate === hash)?.[0]; + if (!found) throw new Error(`unknown transaction ${hash}`); + return found; + } +} + +async function proof( + stage: "root" | "child" | "grandchild", + input: { ipId: `0x${string}`; parentIpIds: `0x${string}`[] }, +): Promise { + const value = definition(stage); + const digit = stage === "root" ? "1" : stage === "child" ? "2" : "3"; + const metadataHash = `0x${digit.repeat(64)}` as const; + return { + stage, + kind: stage === "root" ? "Skill" : "Derivative", + name: value.name, + ipId: input.ipId, + tokenId: stage === "root" ? "1" : stage === "child" ? "2" : "3", + txHash: TRANSACTION_HASH[stage], + licenseTermsId: "7", + licenseTemplate: LICENSE_TEMPLATE, + parentIpIds: input.parentIpIds, + defaultMintingFee: stage === "root" ? DEMO_ROOT_MINTING_FEE.toString() : null, + maxMintingFee: stage === "root" ? null : "123", + metadata: { + ip: { uri: `https://example.test/${stage}/ip`, hash: metadataHash }, + nft: { uri: `https://example.test/${stage}/nft`, hash: metadataHash }, + artifact: { + path: value.artifactPath, + mediaHash: await sha256(value.artifactPath), + mediaType: "text/markdown", + }, + }, }; - const metadata = new FakeMetadata(); - const store = new MemoryStore(); +} - await assert.rejects(runDemo({ wallet: WALLET, chain, metadata, store }), /expected Story Aeneid \(1315\).*1514/i); +function collectionOnlyStore() { + const manifest = createEmptyRegistrationManifest(); + manifest.status = "partial"; + manifest.wallet = WALLET; + manifest.spgNftContract = COLLECTION; + manifest.collectionTxHash = TRANSACTION_HASH.collection; + return new MemoryStore(manifest); +} - assert.equal(balanceCalls, 0); - assert.deepEqual(chain.writes, []); - assert.deepEqual(metadata.stages, []); - assert.equal(store.loadCalls, 0); - assert.equal(store.saveCalls, 0); -}); +async function rootOnlyStore() { + const store = collectionOnlyStore(); + store.manifest.registrations.root = await proof("root", { ipId: ROOT, parentIpIds: [] }); + return store; +} -test("metadata verification failure rejects before any chain write or manifest save", async () => { - const chain = new FakeChain(); - const store = new MemoryStore(); - const metadata: DemoMetadataProvider = { - prepare: async () => { - throw new Error("fetched metadata bytes do not match"); - }, +async function throughChildStore() { + const store = await rootOnlyStore(); + store.manifest.registrations.child = await proof("child", { ipId: CHILD, parentIpIds: [ROOT] }); + return store; +} + +async function completeStore() { + const store = await throughChildStore(); + store.manifest.registrations.grandchild = await proof("grandchild", { + ipId: GRANDCHILD, + parentIpIds: [CHILD], + }); + store.manifest.status = "complete"; + return store; +} + +function intentMetadata(prepared: Awaited>) { + return { + ipMetadataURI: prepared.onchain.ipMetadataURI, + ipMetadataHash: prepared.onchain.ipMetadataHash, + nftMetadataURI: prepared.onchain.nftMetadataURI, + nftMetadataHash: prepared.onchain.nftMetadataHash, + artifactMediaHash: prepared.proof.artifact.mediaHash, + artifactMediaType: prepared.proof.artifact.mediaType, }; +} - await assert.rejects(runDemo({ wallet: WALLET, chain, metadata, store }), /bytes do not match/); +function pendingOperation(input: { + stage: "child" | "grandchild"; + intent: CanonicalOperationIntent; + state?: OperationState; +}): PendingOperation { + return { + schemaVersion: 1, + operationId: `phase0:${input.intent.wallet}:${input.stage}`, + stage: input.stage, + intent: input.intent, + intentHash: operationIntentHash(input.intent), + transactionHash: TRANSACTION_HASH[input.stage], + serializedTransaction: SERIALIZED[input.stage], + state: input.state ?? "prepared", + }; +} - assert.deepEqual(chain.writes, []); - assert.equal(store.saveCalls, 0); -}); +async function derivativeIntent(input: { + stage: "child" | "grandchild"; + parentIpId: `0x${string}`; + registrationName?: string; + artifactPath?: string; + persistedRunConfigHash?: `0x${string}`; +}) { + const value = definition(input.stage); + const prepared = await new FakeMetadata().prepare({ + ...value, + artifactPath: input.artifactPath ?? value.artifactPath, + }); + return { + stage: input.stage, + chainId: AENEID_CHAIN_ID, + wallet: WALLET, + registrationName: input.registrationName ?? value.name, + artifactPath: input.artifactPath ?? value.artifactPath, + spgNftContract: COLLECTION, + parentIpId: input.parentIpId, + licenseTermsId: "7", + licenseTemplate: LICENSE_TEMPLATE, + currencyToken: WIP_TOKEN_ADDRESS, + defaultMintingFee: null, + maxMintingFee: "123", + metadata: intentMetadata(prepared), + runConfigHash: input.persistedRunConfigHash ?? await configHash(), + } satisfies CanonicalOperationIntent; +} -test("funded demo persists a root Skill and two-level Derivative chain", async () => { - const chain = new FakeChain(); +test("a funded demo journals and confirms one collection, Skill, child, and grandchild", async () => { + const chain = new CrashableChain(); const metadata = new FakeMetadata(); const store = new MemoryStore(); + const journal = new MemoryJournal(); - const result = await runDemo({ wallet: WALLET, chain, metadata, store }); + const result = await runDemo({ wallet: WALLET, chain, metadata, store, journal }); - assert.deepEqual(chain.writes, ["collection", "root", "child", "grandchild"]); - assert.deepEqual(metadata.stages, ["root", "child", "grandchild"]); assert.equal(result.status, "complete"); - assert.equal(result.wallet, WALLET); - assert.equal(result.spgNftContract, COLLECTION); - assert.equal(result.collectionTxHash, "0xcollection"); - assert.deepEqual(result.registrations.root?.parentIpIds, []); - assert.deepEqual(result.registrations.child?.parentIpIds, [ROOT]); - assert.deepEqual(result.registrations.grandchild?.parentIpIds, [CHILD]); - assert.equal(result.registrations.root?.ipId, ROOT); - assert.equal(result.registrations.child?.ipId, CHILD); - assert.equal(result.registrations.grandchild?.ipId, GRANDCHILD); - assert.equal(result.registrations.root?.tokenId, "1"); - assert.equal(result.registrations.child?.tokenId, "2"); - assert.equal(result.registrations.grandchild?.tokenId, "3"); - assert.equal(result.registrations.root?.licenseTermsId, "7"); - assert.equal(result.registrations.child?.licenseTermsId, "7"); - assert.equal(result.registrations.grandchild?.licenseTermsId, "7"); - assert.equal(result.registrations.child?.maxMintingFee, "123"); - assert.equal(result.registrations.grandchild?.maxMintingFee, "123"); + assert.deepEqual(chain.prepareCounts, { collection: 1, root: 1, child: 1, grandchild: 1 }); + assert.equal(chain.broadcastTransactions.length, 4); + assert.equal(chain.confirmHashes.length, 4); + assert.deepEqual(metadata.stages, ["root", "child", "grandchild"]); + assert.equal(chain.balanceReads, 1); + assert.equal(chain.gasPriceReads, 1); + assert.equal(chain.feeReadinessCalls, 2); + assert.equal(journal.operation, null); + assert.equal(journal.revision, 12); assert.equal(store.saveCalls, 4); + assert.equal(result.registrations.root?.licenseTemplate, LICENSE_TEMPLATE); + assert.equal(result.registrations.child?.licenseTemplate, LICENSE_TEMPLATE); + assert.equal(result.registrations.grandchild?.licenseTemplate, LICENSE_TEMPLATE); }); -test("each Derivative receives the fee predicted immediately before it", async () => { - const chain = new FakeChain(); +test("the real metadata provider accepts an absolute demo artifact path at the journal boundary", async () => { + const chain = new CrashableChain(); + chain.stopAfterRoot = true; + const store = collectionOnlyStore(); + const metadata = new HttpMetadataProvider({ fetcher: fetchInlineMetadata }); - await runDemo({ wallet: WALLET, chain, metadata: new FakeMetadata(), store: new MemoryStore() }); + await assert.rejects( + runDemo({ wallet: WALLET, chain, metadata, store, journal: new MemoryJournal() }), + /stop after recovered root/, + ); - assert.deepEqual(chain.derivativeInputs.map(({ parentIpId, licenseTermsId, maxMintingFee }) => ({ - parentIpId, - licenseTermsId, - maxMintingFee, - })), [ - { parentIpId: ROOT, licenseTermsId: 7n, maxMintingFee: 123n }, - { parentIpId: CHILD, licenseTermsId: 7n, maxMintingFee: 123n }, - ]); + assert.equal(store.manifest.registrations.root?.txHash, TRANSACTION_HASH.root); + assert.equal( + store.manifest.registrations.root?.metadata.artifact.path, + definition("root").artifactPath, + ); }); -test("a confirmed partial proof survives failure and rerun resumes only missing stages", async () => { - const store = new MemoryStore(); - const firstChain = new FakeChain(); - firstChain.failOn = "child"; +test("a nonzero but insufficient native balance stops before metadata or prepare", async () => { + const chain = new CrashableChain(); + chain.balance = 1n; + const metadata = new FakeMetadata(); + const journal = new MemoryJournal(); await assert.rejects( - runDemo({ wallet: WALLET, chain: firstChain, metadata: new FakeMetadata(), store }), - /child failed/, + runDemo({ wallet: WALLET, chain, metadata, store: new MemoryStore(), journal }), + /estimated native-gas minimum.*Fund it manually/i, ); - assert.equal(store.manifest.status, "partial"); - assert.equal(store.manifest.spgNftContract, COLLECTION); - assert.equal(store.manifest.registrations.root?.ipId, ROOT); - assert.equal(store.manifest.registrations.child, null); - assert.equal(store.saveCalls, 2); - - const resumedChain = new FakeChain(); - const resumedMetadata = new FakeMetadata(); - const result = await runDemo({ wallet: WALLET, chain: resumedChain, metadata: resumedMetadata, store }); + assert.equal(metadata.calls, 0); + assert.deepEqual(chain.prepareCounts, { collection: 0, root: 0, child: 0, grandchild: 0 }); + assert.equal(chain.broadcastTransactions.length, 0); + assert.equal(journal.operation, null); +}); - assert.deepEqual(resumedChain.writes, ["child", "grandchild"]); - assert.deepEqual(resumedMetadata.stages, ["root", "child", "grandchild"]); - assert.equal(result.status, "complete"); - assert.equal(result.registrations.root?.txHash, "0xroot"); - assert.equal(result.registrations.child?.txHash, "0xchild"); - assert.equal(result.registrations.grandchild?.txHash, "0xgrandchild"); +test("zero native balance reports the Aeneid faucet before metadata or prepare", async () => { + const chain = new CrashableChain(); + chain.balance = 0n; + const metadata = new FakeMetadata(); + await assert.rejects( + runDemo({ wallet: WALLET, chain, metadata, store: new MemoryStore(), journal: new MemoryJournal() }), + new RegExp(AENEID_FAUCET_URL.replaceAll(".", "\\.")), + ); + assert.equal(metadata.calls, 0); + assert.equal(chain.prepareCounts.collection, 0); }); -test("resume rejects drift in a persisted root metadata proof before writes or saves", async () => { +test("wrong chain exits before manifest, journal, balances, or metadata", async () => { + const chain = new CrashableChain(); + chain.chainId = 1514; + const metadata = new FakeMetadata(); const store = new MemoryStore(); - const firstChain = new FakeChain(); - firstChain.failOn = "child"; + let journalLoads = 0; + const journal = new MemoryJournal(); + const originalLoad = journal.load.bind(journal); + journal.load = async () => { journalLoads += 1; return originalLoad(); }; await assert.rejects( - runDemo({ wallet: WALLET, chain: firstChain, metadata: new FakeMetadata(), store }), - /child failed/, + runDemo({ wallet: WALLET, chain, metadata, store, journal }), + /expected Story Aeneid \(1315\).*1514/i, ); + assert.equal(journalLoads, 0); + assert.equal(chain.balanceReads, 0); + assert.equal(metadata.calls, 0); +}); - const savesBeforeResume = store.saveCalls; - const persistedRootTxHash = store.manifest.registrations.root?.txHash; - const currentMetadata = new FakeMetadata(); - const driftedRootHash = `0x${"f".repeat(64)}` as const; - const metadata: DemoMetadataProvider = { - prepare: async (input) => { - const prepared = await currentMetadata.prepare(input); - if (input.stage !== "root") return prepared; - return { - ...prepared, - proof: { - ...prepared.proof, - artifact: { - ...prepared.proof.artifact, - mediaHash: driftedRootHash, - }, - }, - }; - }, - }; - const resumedChain = new FakeChain(); +for (const crashAfter of ["journal", "broadcast", "confirm", "manifest-save"] as const) { + test(`resume after ${crashAfter} reuses the exact root transaction bytes and hash`, async () => { + const store = collectionOnlyStore(); + const journal = new MemoryJournal(); + const first = new CrashableChain( + crashAfter === "broadcast" || crashAfter === "confirm" ? crashAfter : null, + "root", + ); + if (crashAfter === "journal") journal.crashAfterNextSave = true; + if (crashAfter === "manifest-save") store.throwAfterSavingStage = "root"; + await assert.rejects( + runDemo({ wallet: WALLET, chain: first, metadata: new FakeMetadata(), store, journal }), + /simulated crash/, + ); + const pending = clone(journal.operation); + assert.equal(pending?.stage, "root"); + assert.equal(pending?.transactionHash, TRANSACTION_HASH.root); + assert.equal(pending?.serializedTransaction, SERIALIZED.root); + + const resumed = first.resumedWithoutCrash(); + resumed.stopAfterRoot = true; + await assert.rejects( + runDemo({ wallet: WALLET, chain: resumed, metadata: new FakeMetadata(), store, journal }), + /stop after recovered root/, + ); + + assert.equal(resumed.prepareCounts.root, 0); + assert.equal( + resumed.broadcastTransactions.every((value) => + value.transactionHash === pending?.transactionHash + && value.serializedTransaction === pending.serializedTransaction), + true, + ); + assert.equal(store.manifest.registrations.root?.txHash, pending?.transactionHash); + assert.equal(journal.operation, null); + }); +} + +test("manifest rename crash retains the exact root journal until durable recovery", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-manifest-resume-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, "registrations.json"); + const stableStore = new FileRegistrationStore(path); + await stableStore.save(collectionOnlyStore().manifest); + + const journal = new MemoryJournal(); + const first = new CrashableChain(); + const crashingStore = new FileRegistrationStore(path, { + afterRename: () => { throw new Error("simulated crash after root manifest rename"); }, + }); await assert.rejects( - runDemo({ wallet: WALLET, chain: resumedChain, metadata, store }), - /root.*metadata.*drift/i, + runDemo({ + wallet: WALLET, + chain: first, + metadata: new FakeMetadata(), + store: crashingStore, + journal, + }), + /simulated crash after root manifest rename/, ); - assert.deepEqual(currentMetadata.stages, ["root", "child", "grandchild"]); - assert.deepEqual(resumedChain.writes, []); - assert.equal(store.saveCalls, savesBeforeResume); - assert.equal(store.manifest.registrations.root?.txHash, persistedRootTxHash); - assert.equal(store.manifest.registrations.child, null); - assert.equal(store.manifest.registrations.grandchild, null); + const pending = clone(journal.operation); + const pendingRevision = journal.revision; + assert.equal(journal.clearCalls, 0); + assert.equal(pendingRevision, 2); + assert.equal(pending?.stage, "root"); + assert.equal(pending?.state, "broadcast"); + assert.equal(pending?.transactionHash, TRANSACTION_HASH.root); + assert.equal(pending?.serializedTransaction, SERIALIZED.root); + assert.equal((await stableStore.load()).registrations.root?.txHash, TRANSACTION_HASH.root); + + const resumed = first.resumedWithoutCrash(); + resumed.stopAfterRoot = true; + await assert.rejects( + runDemo({ + wallet: WALLET, + chain: resumed, + metadata: new FakeMetadata(), + store: stableStore, + journal, + }), + /stop after recovered root/, + ); + + assert.equal(resumed.prepareCounts.root, 0); + assert.equal(resumed.broadcastTransactions.length, 0); + assert.equal(resumed.confirmHashes.length, 0); + assert.equal(journal.clearCalls, 1); + assert.equal(journal.operation, null); + assert.equal(journal.revision, pendingRevision + 1); }); -for (const scenario of [ - { failOn: "root" as const, saves: 1, lastProof: "collection" }, - { failOn: "grandchild" as const, saves: 3, lastProof: "child" }, -]) { - test(`failure at ${scenario.failOn} keeps every earlier confirmed proof`, async () => { - const store = new MemoryStore(); - const chain = new FakeChain(); - chain.failOn = scenario.failOn; +test("a corrupted persisted intent hash aborts before exact-byte broadcast", async () => { + const store = await rootOnlyStore(); + const intent = await derivativeIntent({ stage: "child", parentIpId: ROOT }); + const pending = pendingOperation({ stage: "child", intent }); + pending.intent = { ...pending.intent, registrationName: "tampered-without-rehash" }; + const journal = new MemoryJournal({ revision: 1, operation: pending }); + const chain = new CrashableChain(); + await assert.rejects( + runDemo({ wallet: WALLET, chain, metadata: new FakeMetadata(), store, journal }), + /intent.*hash|does not match/i, + ); + assert.equal(chain.broadcastTransactions.length, 0); + assert.equal(chain.balanceReads, 0); +}); + +for (const crashAfter of ["broadcast", "confirm"] as const) { + test(`depleted WIP cannot block exact child recovery after ${crashAfter}`, async () => { + const store = await rootOnlyStore(); + const journal = new MemoryJournal(); + const first = new CrashableChain(crashAfter, "child"); + first.consumeBalancesOnBroadcast = true; await assert.rejects( - runDemo({ wallet: WALLET, chain, metadata: new FakeMetadata(), store }), - new RegExp(`${scenario.failOn} failed`), + runDemo({ wallet: WALLET, chain: first, metadata: new FakeMetadata(), store, journal }), + /simulated crash/, ); + const pending = clone(journal.operation); + assert.equal(pending?.stage, "child"); - assert.equal(store.saveCalls, scenario.saves); - assert.equal(store.manifest.spgNftContract, COLLECTION); - assert.equal(store.manifest.collectionTxHash, "0xcollection"); - if (scenario.lastProof === "child") { - assert.equal(store.manifest.registrations.root?.txHash, "0xroot"); - assert.equal(store.manifest.registrations.child?.txHash, "0xchild"); - assert.equal(store.manifest.registrations.grandchild, null); - } else { - assert.equal(store.manifest.registrations.root, null); - } + const resumed = first.resumedWithoutCrash(); + const resumeMetadata = new FakeMetadata(); + resumeMetadata.failWith = "metadata must not run during child recovery"; + store.throwAfterSavingStage = "child"; + await assert.rejects( + runDemo({ wallet: WALLET, chain: resumed, metadata: resumeMetadata, store, journal }), + /simulated crash after child manifest rename/, + ); + + assert.equal(resumed.balanceReads, 0); + assert.equal(resumed.gasPriceReads, 0); + assert.equal(resumed.feeReadinessCalls, 0); + assert.equal(resumed.prepareCounts.child, 0); + assert.deepEqual(resumed.broadcastTransactions[0], { + transactionHash: pending?.transactionHash, + serializedTransaction: pending?.serializedTransaction, + }); + assert.equal(resumed.confirmHashes[0], pending?.transactionHash); + assert.equal(store.manifest.registrations.child?.txHash, pending?.transactionHash); + assert.equal(resumeMetadata.calls, 0); }); } -test("a run refuses to resume another wallet's proof", async () => { - const manifest = createEmptyRegistrationManifest(); - manifest.status = "partial"; - manifest.wallet = "0x00000000000000000000000000000000000000ff"; - manifest.spgNftContract = COLLECTION; - manifest.collectionTxHash = "0xcollection"; - const store = new MemoryStore(manifest); +for (const crashAfter of ["broadcast", "confirm"] as const) { + test(`pending grandchild after ${crashAfter} completes with zero readiness or metadata reads`, async () => { + const store = await throughChildStore(); + const journal = new MemoryJournal(); + const first = new CrashableChain(crashAfter, "grandchild"); + first.consumeBalancesOnBroadcast = true; + await assert.rejects( + runDemo({ wallet: WALLET, chain: first, metadata: new FakeMetadata(), store, journal }), + /simulated crash/, + ); + const pending = clone(journal.operation); + assert.equal(pending?.stage, "grandchild"); + assert.equal(pending?.state, crashAfter === "broadcast" ? "prepared" : "broadcast"); + + const chain = first.resumedWithoutCrash(); + const metadata = new FakeMetadata(); + metadata.failWith = "Pinata unavailable on resume"; + + const result = await runDemo({ wallet: WALLET, chain, metadata, store, journal }); + + assert.equal(result.status, "complete"); + assert.equal(chain.balanceReads, 0); + assert.equal(chain.gasPriceReads, 0); + assert.equal(chain.predictionCalls, 0); + assert.equal(chain.feeReadinessCalls, 0); + assert.equal(metadata.calls, 0); + assert.equal(chain.prepareCounts.grandchild, 0); + assert.deepEqual(chain.broadcastTransactions[0], { + transactionHash: pending?.transactionHash, + serializedTransaction: pending?.serializedTransaction, + }); + assert.equal(chain.confirmHashes[0], pending?.transactionHash); + assert.equal(result.registrations.grandchild?.txHash, pending?.transactionHash); + assert.equal(journal.operation, null); + }); +} + +test("recovered proof uses journal-bound fields, then run-config drift blocks new work", async () => { + const store = await rootOnlyStore(); + const journalName = "journal-child-name"; + const journalPath = "fixtures/journal-child/SKILL.md"; + const baseIntent = await derivativeIntent({ stage: "child", parentIpId: ROOT }); + const intent: CanonicalOperationIntent = { + ...baseIntent, + registrationName: journalName, + artifactPath: journalPath, + metadata: baseIntent.metadata ? { + ...baseIntent.metadata, + artifactMediaHash: `0x${"9".repeat(64)}`, + } : null, + runConfigHash: `0x${"8".repeat(64)}`, + }; + const journal = new MemoryJournal({ + revision: 1, + operation: pendingOperation({ stage: "child", intent }), + }); + const metadata = new FakeMetadata(); + metadata.failWith = "metadata must not run after drift"; + const chain = new CrashableChain(); + const changedDefinition: DemoSkillDefinition = { + ...definition("child"), + name: "changed-current-child-name", + description: "Changed only to prove recovery does not consult current proof fields.", + artifactPath: definition("grandchild").artifactPath, + }; + const changedSkills = DEMO_SKILLS.map((value) => + value.stage === "child" ? changedDefinition : value, + ); + + await assert.rejects( + runDemo({ wallet: WALLET, chain, metadata, store, journal, skills: changedSkills }), + /Recovered pending transaction, but current run configuration differs/, + ); + + assert.equal(store.manifest.registrations.child?.name, journalName); + assert.equal(store.manifest.registrations.child?.metadata.artifact.path, journalPath); + assert.notEqual(store.manifest.registrations.child?.name, changedDefinition.name); + assert.notEqual( + store.manifest.registrations.child?.metadata.artifact.path, + changedDefinition.artifactPath, + ); + assert.equal(metadata.calls, 0); + assert.equal(chain.balanceReads, 0); + assert.equal(chain.feeReadinessCalls, 0); + assert.equal(chain.prepareCounts.child, 0); + assert.equal(journal.operation, null); +}); + +test("post-recovery work stays bound to the validated configuration snapshot", async () => { + const store = await rootOnlyStore(); + const intent = await derivativeIntent({ stage: "child", parentIpId: ROOT }); + const journal = new MemoryJournal({ + revision: 1, + operation: pendingOperation({ stage: "child", intent }), + }); + const mutableSkills: DemoSkillDefinition[] = DEMO_SKILLS.map((value) => ({ ...value })); + const originalGrandchild = { ...definition("grandchild", mutableSkills) }; + const changedGrandchild: DemoSkillDefinition = { + ...originalGrandchild, + name: "mutated-during-readiness", + description: "This definition appeared only after pending recovery was validated.", + artifactPath: definition("root", mutableSkills).artifactPath, + }; + const chain = new CrashableChain(); + const getBalance = chain.getBalance.bind(chain); + chain.getBalance = async () => { + mutableSkills.splice(2, 1, changedGrandchild); + return getBalance(); + }; + + const result = await runDemo({ + wallet: WALLET, + chain, + metadata: new FakeMetadata(), + store, + journal, + skills: mutableSkills, + }); + + assert.equal(result.status, "complete"); + assert.equal(result.registrations.grandchild?.name, originalGrandchild.name); + assert.equal( + result.registrations.grandchild?.metadata.artifact.path, + originalGrandchild.artifactPath, + ); + assert.notEqual(result.registrations.grandchild?.name, changedGrandchild.name); +}); + +test("insufficient WIP balance stops before Derivative prepare", async () => { + const chain = new CrashableChain(); + chain.predictedFee = DEMO_ROOT_MINTING_FEE; + chain.wipBalance = DEMO_ROOT_MINTING_FEE - 1n; + chain.wipAllowance = DEMO_ROOT_MINTING_FEE; + const metadata = new FakeMetadata(); + const journal = new MemoryJournal(); + await assert.rejects( + runDemo({ wallet: WALLET, chain, metadata, store: await rootOnlyStore(), journal }), + /WIP balance.*required/i, + ); + assert.equal(chain.feeReadinessCalls, 1); + assert.equal(chain.prepareCounts.child, 0); + assert.equal(chain.broadcastTransactions.length, 0); + assert.equal(journal.operation, null); +}); + +test("insufficient WIP allowance stops before Derivative prepare", async () => { + const chain = new CrashableChain(); + chain.predictedFee = DEMO_ROOT_MINTING_FEE; + chain.wipBalance = DEMO_ROOT_MINTING_FEE; + chain.wipAllowance = DEMO_ROOT_MINTING_FEE - 1n; + const journal = new MemoryJournal(); + await assert.rejects( + runDemo({ + wallet: WALLET, + chain, + metadata: new FakeMetadata(), + store: await rootOnlyStore(), + journal, + }), + /WIP allowance.*required/i, + ); + assert.equal(chain.feeReadinessCalls, 1); + assert.equal(chain.prepareCounts.child, 0); + assert.equal(chain.broadcastTransactions.length, 0); + assert.equal(journal.operation, null); +}); + +test("a complete manifest performs zero native, WIP, metadata, or prepare reads", async () => { + const chain = new CrashableChain(); + const metadata = new FakeMetadata(); + const result = await runDemo({ + wallet: WALLET, + chain, + metadata, + store: await completeStore(), + journal: new MemoryJournal(), + }); + assert.equal(result.status, "complete"); + assert.equal(chain.balanceReads, 0); + assert.equal(chain.gasPriceReads, 0); + assert.equal(chain.feeReadinessCalls, 0); + assert.equal(metadata.calls, 0); + assert.deepEqual(chain.prepareCounts, { collection: 0, root: 0, child: 0, grandchild: 0 }); +}); +test("without pending recovery, another wallet's manifest fails before readiness", async () => { + const store = collectionOnlyStore(); + const chain = new CrashableChain(); await assert.rejects( - runDemo({ wallet: WALLET, chain: new FakeChain(), metadata: new FakeMetadata(), store }), - /belongs to wallet.*00ff/i, + runDemo({ wallet: OTHER_WALLET, chain, metadata: new FakeMetadata(), store, journal: new MemoryJournal() }), + /belongs to wallet/i, ); + assert.equal(chain.balanceReads, 0); }); diff --git a/phase0/tests/registrations.test.ts b/phase0/tests/registrations.test.ts index d3f6604..9149500 100644 --- a/phase0/tests/registrations.test.ts +++ b/phase0/tests/registrations.test.ts @@ -13,6 +13,7 @@ import { const WALLET = "0x00000000000000000000000000000000000000aa" as const; const SPG = "0x00000000000000000000000000000000000000bb" as const; +const LICENSE_TEMPLATE = "0x00000000000000000000000000000000000000cc" as const; const TX_HASH = `0x${"1".repeat(64)}` as const; const METADATA_HASH = `0x${"2".repeat(64)}` as const; @@ -25,6 +26,7 @@ function proof(stage: "root" | "child" | "grandchild", ipId: `0x${string}`): Reg tokenId: stage === "root" ? "1" : stage === "child" ? "2" : "3", txHash: TX_HASH, licenseTermsId: "7", + licenseTemplate: LICENSE_TEMPLATE, parentIpIds: [], defaultMintingFee: stage === "root" ? "1000000000000000" : null, maxMintingFee: stage === "root" ? null : "123", @@ -65,6 +67,58 @@ test("filesystem store returns the honest not-run schema when the artifact is ab assert.deepEqual(manifest, createEmptyRegistrationManifest()); }); +test("manifest save resolves only after file and directory fsync", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-durable-manifest-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const events: string[] = []; + const store = new FileRegistrationStore(join(directory, "registrations.json"), { + afterTempSync: () => { events.push("temp-fsync"); }, + afterRename: () => { events.push("rename"); }, + afterDirectorySync: () => { events.push("directory-fsync"); }, + }); + await store.save(createEmptyRegistrationManifest()); + events.push("resolved"); + assert.deepEqual(events, ["temp-fsync", "rename", "directory-fsync", "resolved"]); + assert.deepEqual(await readdir(directory), ["registrations.json"]); +}); + +test("crash after temp fsync preserves the previous complete manifest", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-durable-manifest-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, "registrations.json"); + const previous = createEmptyRegistrationManifest(); + await new FileRegistrationStore(path).save(previous); + const previousBytes = await readFile(path); + const next = createEmptyRegistrationManifest(); + next.wallet = WALLET; + next.spgNftContract = SPG; + next.collectionTxHash = TX_HASH; + next.status = "partial"; + const crashing = new FileRegistrationStore(path, { + afterTempSync: () => { throw new Error("simulated crash after temp fsync"); }, + }); + await assert.rejects(crashing.save(next), /simulated crash/); + assert.deepEqual(await readFile(path), previousBytes); + assert.deepEqual(await readdir(directory), ["registrations.json"]); +}); + +test("rename interruption exposes only a parseable complete manifest", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-durable-manifest-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, "registrations.json"); + const next = createEmptyRegistrationManifest(); + next.wallet = WALLET; + next.spgNftContract = SPG; + next.collectionTxHash = TX_HASH; + next.status = "partial"; + const crashing = new FileRegistrationStore(path, { + afterRename: () => { throw new Error("simulated crash before directory fsync"); }, + }); + await assert.rejects(crashing.save(next), /simulated crash/); + assert.deepEqual(await new FileRegistrationStore(path).load(), next); + assert.deepEqual(await readdir(directory), ["registrations.json"]); +}); + test("manifest parser rejects truthy malformed proofs instead of treating them as resumable", () => { const manifest = createEmptyRegistrationManifest(); manifest.status = "partial"; @@ -98,3 +152,16 @@ test("manifest parser enforces status and exact Derivative parent edges", () => manifest.registrations.grandchild = null; assert.throws(() => parseRegistrationManifest(manifest), /complete.*grandchild/i); }); + +test("manifest parser requires an event-derived license template on every proof", () => { + const manifest = createEmptyRegistrationManifest(); + manifest.status = "partial"; + manifest.wallet = WALLET; + manifest.spgNftContract = SPG; + manifest.collectionTxHash = TX_HASH; + const root = proof("root", "0x0000000000000000000000000000000000000001"); + delete (root as Partial).licenseTemplate; + manifest.registrations.root = root; + + assert.throws(() => parseRegistrationManifest(manifest), /licenseTemplate/i); +}); diff --git a/phase0/tests/story.test.ts b/phase0/tests/story.test.ts index 167b44f..b7292d5 100644 --- a/phase0/tests/story.test.ts +++ b/phase0/tests/story.test.ts @@ -1,12 +1,37 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { StoryChain, type StorySdkBoundary } from "../src/story"; +import { WIP_TOKEN_ADDRESS } from "@story-protocol/core-sdk"; +import { + TransactionNotFoundError, + TransactionReceiptNotFoundError, +} from "viem"; +import { + encodeAbiParameters, + encodeEventTopics, + keccak256, + parseAbiItem, + type Log, +} from "viem"; + +import { + StoryChain, + type StoryPublicClientBoundary, + type StorySdkBoundary, + type StoryWalletBoundary, +} from "../src/story"; const WALLET = "0x00000000000000000000000000000000000000aa" as const; const SPG = "0x00000000000000000000000000000000000000bb" as const; +const LICENSE_TEMPLATE = "0x00000000000000000000000000000000000000cc" as const; +const DERIVATIVE_WORKFLOWS = "0x00000000000000000000000000000000000000dd" as const; const PARENT = "0x0000000000000000000000000000000000000001" as const; +const CHILD = "0x0000000000000000000000000000000000000002" as const; +const CALLER = "0x00000000000000000000000000000000000000ee" as const; const HASH = `0x${"1".repeat(64)}` as const; +const ENCODED = { to: SPG, data: "0x1234" as const }; +const SERIALIZED = `0x${"ab".repeat(64)}` as const; +const TX_HASH = keccak256(SERIALIZED); const METADATA = { ipMetadataURI: "https://example.test/ip", ipMetadataHash: HASH, @@ -14,108 +39,480 @@ const METADATA = { nftMetadataHash: HASH, }; -function sdk(overrides: Partial = {}): StorySdkBoundary { +const COLLECTION_CREATED = parseAbiItem("event CollectionCreated(address indexed spgNftContract)"); +const IP_REGISTERED = parseAbiItem("event IPRegistered(address ipId, uint256 indexed chainId, address indexed tokenContract, uint256 indexed tokenId, string name, string uri, uint256 registrationDate)"); +const LICENSE_TERMS_ATTACHED = parseAbiItem("event LicenseTermsAttached(address indexed caller, address indexed ipId, address licenseTemplate, uint256 licenseTermsId)"); +const DERIVATIVE_REGISTERED = parseAbiItem("event DerivativeRegistered(address indexed caller, address indexed childIpId, uint256[] licenseTokenIds, address[] parentIpIds, uint256[] licenseTermsIds, address licenseTemplate)"); + +function collectionLog(spgNftContract = SPG): Log { + return { + topics: encodeEventTopics({ + abi: [COLLECTION_CREATED], + eventName: "CollectionCreated", + args: { spgNftContract }, + }), + data: "0x", + } as unknown as Log; +} + +function ipRegisteredLog(input: { + ipId?: `0x${string}`; + chainId?: bigint; + tokenContract?: `0x${string}`; + tokenId?: bigint; +} = {}): Log { + const ipId = input.ipId ?? CHILD; + const chainId = input.chainId ?? 1315n; + const tokenContract = input.tokenContract ?? SPG; + const tokenId = input.tokenId ?? 2n; + return { + topics: encodeEventTopics({ + abi: [IP_REGISTERED], + eventName: "IPRegistered", + args: { chainId, tokenContract, tokenId }, + }), + data: encodeAbiParameters( + [ + { type: "address" }, + { type: "string" }, + { type: "string" }, + { type: "uint256" }, + ], + [ipId, "registered", "ipfs://registered", 1n], + ), + } as Log; +} + +function licenseTermsAttachedLog(input: { + ipId?: `0x${string}`; + licenseTemplate?: `0x${string}`; + licenseTermsId?: bigint; +} = {}): Log { + const ipId = input.ipId ?? PARENT; + return { + topics: encodeEventTopics({ + abi: [LICENSE_TERMS_ATTACHED], + eventName: "LicenseTermsAttached", + args: { caller: CALLER, ipId }, + }), + data: encodeAbiParameters( + [{ type: "address" }, { type: "uint256" }], + [input.licenseTemplate ?? LICENSE_TEMPLATE, input.licenseTermsId ?? 7n], + ), + } as Log; +} + +function derivativeRegisteredLog(input: { + childIpId?: `0x${string}`; + parentIpIds?: readonly `0x${string}`[]; + licenseTermsIds?: readonly bigint[]; + licenseTemplate?: `0x${string}`; +} = {}): Log { + const childIpId = input.childIpId ?? CHILD; return { + topics: encodeEventTopics({ + abi: [DERIVATIVE_REGISTERED], + eventName: "DerivativeRegistered", + args: { caller: CALLER, childIpId }, + }), + data: encodeAbiParameters( + [ + { type: "uint256[]" }, + { type: "address[]" }, + { type: "uint256[]" }, + { type: "address" }, + ], + [[], input.parentIpIds ?? [PARENT], input.licenseTermsIds ?? [7n], input.licenseTemplate ?? LICENSE_TEMPLATE], + ), + } as Log; +} + +function receipt(logs: Log[], status: "success" | "reverted" = "success") { + return { status, transactionHash: TX_HASH, logs } as const; +} + +function sdk(overrides: Partial = {}): StorySdkBoundary { + const base: StorySdkBoundary = { nftClient: { - createNFTCollection: async () => ({ spgNftContract: SPG, txHash: "0xcollection" }), + createNFTCollection: async () => ({ encodedTxData: ENCODED }), }, ipAsset: { - mintAndRegisterIpAssetWithPilTerms: async () => ({ - ipId: PARENT, - tokenId: 1n, - txHash: "0xroot", - licenseTermsIds: [7n], - }), - mintAndRegisterIpAndMakeDerivative: async () => ({ - ipId: "0x0000000000000000000000000000000000000002", - tokenId: 2n, - txHash: "0xchild", - }), + mintAndRegisterIpAssetWithPilTerms: async () => ({ encodedTxData: ENCODED }), + mintAndRegisterIpAndMakeDerivative: async () => ({ encodedTxData: ENCODED }), + wipClient: { + address: WIP_TOKEN_ADDRESS, + balanceOf: async () => ({ result: 123n }), + allowance: async () => ({ result: 123n }), + }, + derivativeWorkflowsClient: { address: DERIVATIVE_WORKFLOWS }, }, license: { - predictMintingLicenseFee: async () => ({ currencyToken: SPG, tokenAmount: 123n }), + predictMintingLicenseFee: async () => ({ currencyToken: WIP_TOKEN_ADDRESS, tokenAmount: 123n }), }, + }; + return { + ...base, ...overrides, + nftClient: { ...base.nftClient, ...overrides.nftClient }, + ipAsset: { ...base.ipAsset, ...overrides.ipAsset }, + license: { ...base.license, ...overrides.license }, }; } -function chain(boundary = sdk()) { - return new StoryChain({ - sdk: boundary, - publicClient: { - getChainId: async () => 1315, - getBalance: async () => 1n, - getGasPrice: async () => 2n, +function boundaries(input: { + sdk?: StorySdkBoundary; + receipt?: ReturnType; + sendRawTransaction?: StoryPublicClientBoundary["sendRawTransaction"]; + getTransaction?: StoryPublicClientBoundary["getTransaction"]; + getTransactionReceipt?: StoryPublicClientBoundary["getTransactionReceipt"]; +} = {}) { + const sent: `0x${string}`[] = []; + const preparedRequests: unknown[] = []; + const signedRequests: unknown[] = []; + const wallet: StoryWalletBoundary = { + account: { address: WALLET }, + chain: { id: 1315 }, + prepareTransactionRequest: async (request) => { + preparedRequests.push(request); + return { ...request, nonce: 1 }; }, - }); + signTransaction: async (request) => { + signedRequests.push(request); + return SERIALIZED; + }, + }; + const publicClient: StoryPublicClientBoundary = { + getChainId: async () => 1315, + getBalance: async () => 1n, + getGasPrice: async () => 2n, + sendRawTransaction: input.sendRawTransaction ?? (async ({ serializedTransaction }) => { + sent.push(serializedTransaction); + return TX_HASH; + }), + getTransaction: input.getTransaction ?? (async () => ({ hash: TX_HASH })), + getTransactionReceipt: input.getTransactionReceipt ?? (async () => ({ transactionHash: TX_HASH })), + waitForTransactionReceipt: async () => input.receipt ?? receipt([]), + }; + return { + story: new StoryChain({ sdk: input.sdk ?? sdk(), wallet, publicClient }), + sent, + preparedRequests, + signedRequests, + }; } -test("SDK optional proof fields are guarded before the workflow can advance", async () => { - const missingCollection = sdk({ - nftClient: { createNFTCollection: async () => ({ txHash: "0xcollection" }) }, +test("prepareCollection encodes, signs, and hashes without broadcasting", async () => { + let observedInput: Record | undefined; + const chain = boundaries({ + sdk: sdk({ + nftClient: { + createNFTCollection: async (input) => { + observedInput = input as unknown as Record; + return { encodedTxData: ENCODED }; + }, + }, + }), + }); + + const prepared = await chain.story.prepareCollection({ + name: "Skills", + symbol: "SKILL", + mintFeeRecipient: WALLET, + }); + + assert.deepEqual(observedInput?.txOptions, { encodedTxDataOnly: true }); + assert.equal(observedInput?.isPublicMinting, true); + assert.equal(observedInput?.mintOpen, true); + assert.deepEqual(chain.preparedRequests[0], { + account: { address: WALLET }, + chain: { id: 1315 }, + to: ENCODED.to, + data: ENCODED.data, + }); + assert.equal(chain.signedRequests.length, 1); + assert.deepEqual(prepared, { serializedTransaction: SERIALIZED, transactionHash: TX_HASH }); + assert.deepEqual(chain.sent, []); +}); + +test("Skill and Derivative prepare calls preserve terms and fee caps at the encoded boundary", async () => { + let rootInput: Record | undefined; + let derivativeInput: Record | undefined; + const chain = boundaries({ + sdk: sdk({ + ipAsset: { + ...sdk().ipAsset, + mintAndRegisterIpAssetWithPilTerms: async (input) => { + rootInput = input as unknown as Record; + return { encodedTxData: ENCODED }; + }, + mintAndRegisterIpAndMakeDerivative: async (input) => { + derivativeInput = input as unknown as Record; + return { encodedTxData: ENCODED }; + }, + }, + }), + }); + + await chain.story.prepareSkill({ + spgNftContract: SPG, + metadata: METADATA, + defaultMintingFee: 1n, + }); + await chain.story.prepareDerivative({ + spgNftContract: SPG, + parentIpId: PARENT, + licenseTermsId: 7n, + maxMintingFee: 123n, + metadata: METADATA, + }); + + assert.deepEqual(rootInput?.txOptions, { encodedTxDataOnly: true }); + assert.equal(Array.isArray(rootInput?.licenseTermsData), true); + assert.deepEqual(derivativeInput?.txOptions, { encodedTxDataOnly: true }); + assert.deepEqual((derivativeInput?.derivData as Record).parentIpIds, [PARENT]); + assert.equal((derivativeInput?.derivData as Record).maxMintingFee, 123n); + assert.deepEqual(chain.sent, []); +}); + +test("missing encoded SDK data fails before signing", async () => { + const chain = boundaries({ + sdk: sdk({ nftClient: { createNFTCollection: async () => ({}) } }), }); await assert.rejects( - chain(missingCollection).createCollection({ name: "Skills", symbol: "SKILL", mintFeeRecipient: WALLET }), - /missing spgNftContract/i, + chain.story.prepareCollection({ name: "Skills", symbol: "SKILL", mintFeeRecipient: WALLET }), + /missing encoded collection transaction/i, ); + assert.equal(chain.signedRequests.length, 0); +}); - const missingRootTerms = sdk({ - ipAsset: { - mintAndRegisterIpAssetWithPilTerms: async () => ({ ipId: PARENT, tokenId: 1n, txHash: "0xroot" }), - mintAndRegisterIpAndMakeDerivative: async () => ({ - ipId: "0x0000000000000000000000000000000000000002", - tokenId: 2n, - txHash: "0xchild", - }), +test("broadcastPrepared sends exact bytes and requires the exact local hash", async () => { + const exact = boundaries(); + await exact.story.broadcastPrepared({ serializedTransaction: SERIALIZED, transactionHash: TX_HASH }); + assert.deepEqual(exact.sent, [SERIALIZED]); + + const mismatch = boundaries({ sendRawTransaction: async () => `0x${"9".repeat(64)}` }); + await assert.rejects( + mismatch.story.broadcastPrepared({ serializedTransaction: SERIALIZED, transactionHash: TX_HASH }), + /RPC returned.*expected/i, + ); +}); + +test("known or consumed nonce reconciles only when the exact hash is queryable", async () => { + let transactionQueries = 0; + let receiptQueries = 0; + const reconciled = boundaries({ + sendRawTransaction: async () => { throw new Error("nonce too low"); }, + getTransaction: async ({ hash }) => { + transactionQueries += 1; + return { hash }; + }, + getTransactionReceipt: async ({ hash }) => { + receiptQueries += 1; + throw new TransactionReceiptNotFoundError({ hash }); }, }); + await reconciled.story.broadcastPrepared({ serializedTransaction: SERIALIZED, transactionHash: TX_HASH }); + assert.equal(transactionQueries, 1); + assert.equal(receiptQueries, 1); + + const absent = boundaries({ + sendRawTransaction: async () => { throw new Error("already known transaction"); }, + getTransaction: async ({ hash }) => { throw new TransactionNotFoundError({ hash }); }, + getTransactionReceipt: async ({ hash }) => { throw new TransactionReceiptNotFoundError({ hash }); }, + }); await assert.rejects( - chain(missingRootTerms).registerSkill({ - spgNftContract: SPG, - metadata: METADATA, - defaultMintingFee: 1n, - }), - /missing licenseTermsId/i, + absent.story.broadcastPrepared({ serializedTransaction: SERIALIZED, transactionHash: TX_HASH }), + /unresolved.*replaced/i, ); }); -test("predicted fee is passed as the standalone Derivative maxMintingFee cap", async () => { - let observedCap: bigint | undefined; - const boundary = sdk({ - ipAsset: { - mintAndRegisterIpAssetWithPilTerms: async () => ({ - ipId: PARENT, - tokenId: 1n, - txHash: "0xroot", - licenseTermsIds: [7n], - }), - mintAndRegisterIpAndMakeDerivative: async (input) => { - const cap = input.derivData.maxMintingFee; - if (typeof cap !== "bigint") throw new Error("expected a bigint cap"); - observedCap = cap; - return { - ipId: "0x0000000000000000000000000000000000000002", - tokenId: 2n, - txHash: "0xchild", - }; - }, +test("known or consumed nonce reconciles from an exact receipt when the transaction lookup is absent", async () => { + let transactionQueries = 0; + let receiptQueries = 0; + const reconciled = boundaries({ + sendRawTransaction: async () => { throw new Error("already known transaction"); }, + getTransaction: async ({ hash }) => { + transactionQueries += 1; + throw new TransactionNotFoundError({ hash }); + }, + getTransactionReceipt: async ({ hash }) => { + receiptQueries += 1; + return { transactionHash: hash }; }, }); - const story = chain(boundary); - const prediction = await story.predictMintingLicenseFee({ - licensorIpId: PARENT, - licenseTermsId: 7n, - amount: 1, + + await reconciled.story.broadcastPrepared({ + serializedTransaction: SERIALIZED, + transactionHash: TX_HASH, }); + assert.equal(transactionQueries, 1); + assert.equal(receiptQueries, 1); +}); + +test("reconciliation propagates unrelated RPC failures", async () => { + const reconciled = boundaries({ + sendRawTransaction: async () => { throw new Error("nonce too low"); }, + getTransaction: async () => { throw new Error("RPC authorization failed"); }, + getTransactionReceipt: async ({ hash }) => ({ transactionHash: hash }), + }); + + await assert.rejects( + reconciled.story.broadcastPrepared({ + serializedTransaction: SERIALIZED, + transactionHash: TX_HASH, + }), + /RPC authorization failed/, + ); +}); - await story.registerDerivative({ +test("confirmCollection decodes one successful CollectionCreated event", async () => { + const chain = boundaries({ receipt: receipt([collectionLog()]) }); + assert.deepEqual(await chain.story.confirmCollection(TX_HASH), { spgNftContract: SPG, - parentIpId: PARENT, + txHash: TX_HASH, + }); +}); + +test("confirmSkill binds IP registration and attached license terms to the expected collection", async () => { + const chain = boundaries({ + receipt: receipt([ + ipRegisteredLog({ ipId: PARENT, tokenId: 1n }), + licenseTermsAttachedLog({ ipId: PARENT }), + ]), + }); + assert.deepEqual(await chain.story.confirmSkill({ + transactionHash: TX_HASH, + expectedCollection: SPG, + }), { + ipId: PARENT, + tokenId: 1n, + txHash: TX_HASH, licenseTermsId: 7n, - maxMintingFee: prediction.tokenAmount, - metadata: METADATA, + licenseTemplate: LICENSE_TEMPLATE, + }); +}); + +test("confirmDerivative returns only event-derived matching ancestry", async () => { + const chain = boundaries({ + receipt: receipt([ipRegisteredLog(), derivativeRegisteredLog()]), + }); + assert.deepEqual(await chain.story.confirmDerivative({ + transactionHash: TX_HASH, + expectedCollection: SPG, + expectedParentIpId: PARENT, + expectedLicenseTermsId: 7n, + expectedLicenseTemplate: LICENSE_TEMPLATE, + }), { + ipId: CHILD, + tokenId: 2n, + txHash: TX_HASH, + licenseTermsId: 7n, + licenseTemplate: LICENSE_TEMPLATE, + }); +}); + +for (const scenario of [ + { name: "child", logs: [ipRegisteredLog(), derivativeRegisteredLog({ childIpId: PARENT })] }, + { name: "parent", logs: [ipRegisteredLog(), derivativeRegisteredLog({ parentIpIds: [CHILD] })] }, + { name: "terms", logs: [ipRegisteredLog(), derivativeRegisteredLog({ licenseTermsIds: [8n] })] }, + { name: "template", logs: [ipRegisteredLog(), derivativeRegisteredLog({ licenseTemplate: SPG })] }, + { name: "duplicate", logs: [ipRegisteredLog(), ipRegisteredLog(), derivativeRegisteredLog()] }, + { + name: "mixed matching and foreign DerivativeRegistered", + logs: [ + ipRegisteredLog(), + derivativeRegisteredLog(), + derivativeRegisteredLog({ childIpId: PARENT }), + ], + }, + { name: "missing", logs: [ipRegisteredLog()] }, +] as const) { + test(`confirmDerivative rejects ${scenario.name} event evidence`, async () => { + const chain = boundaries({ receipt: receipt([...scenario.logs]) }); + await assert.rejects( + chain.story.confirmDerivative({ + transactionHash: TX_HASH, + expectedCollection: SPG, + expectedParentIpId: PARENT, + expectedLicenseTermsId: 7n, + expectedLicenseTemplate: LICENSE_TEMPLATE, + }), + /Derivative|event|parent|terms|template|exactly one/i, + ); + }); +} + +test("confirmation rejects a reverted receipt or a receipt for another hash", async () => { + const reverted = boundaries({ receipt: receipt([collectionLog()], "reverted") }); + await assert.rejects(reverted.story.confirmCollection(TX_HASH), /reverted|successful/i); + + const wrongHashReceipt = { + ...receipt([collectionLog()]), + transactionHash: `0x${"8".repeat(64)}` as const, + }; + const wrongHash = boundaries({ receipt: wrongHashReceipt }); + await assert.rejects(wrongHash.story.confirmCollection(TX_HASH), /receipt.*expected hash/i); +}); + +test("Derivative fee readiness reads WIP balance and allowance for the SDK spender", async () => { + const calls: unknown[] = []; + const chain = boundaries({ + sdk: sdk({ + ipAsset: { + ...sdk().ipAsset, + wipClient: { + address: WIP_TOKEN_ADDRESS, + balanceOf: async (input) => { + calls.push(["balance", input]); + return { result: 11n }; + }, + allowance: async (input) => { + calls.push(["allowance", input]); + return { result: 12n }; + }, + }, + derivativeWorkflowsClient: { address: DERIVATIVE_WORKFLOWS }, + }, + }), + }); + assert.deepEqual(await chain.story.getDerivativeFeeReadiness({ + wallet: WALLET, + currencyToken: WIP_TOKEN_ADDRESS, + requiredAmount: 10n, + }), { + currencyToken: WIP_TOKEN_ADDRESS, + spender: DERIVATIVE_WORKFLOWS, + requiredAmount: 10n, + balance: 11n, + allowance: 12n, }); + assert.deepEqual(calls, [ + ["balance", { owner: WALLET }], + ["allowance", { owner: WALLET, spender: DERIVATIVE_WORKFLOWS }], + ]); +}); - assert.equal(prediction.tokenAmount, 123n); - assert.equal(observedCap, 123n); +test("unsupported predicted currency rejects before any WIP read", async () => { + let reads = 0; + const chain = boundaries({ + sdk: sdk({ + ipAsset: { + ...sdk().ipAsset, + wipClient: { + address: WIP_TOKEN_ADDRESS, + balanceOf: async () => { reads += 1; return { result: 1n }; }, + allowance: async () => { reads += 1; return { result: 1n }; }, + }, + }, + }), + }); + await assert.rejects( + chain.story.getDerivativeFeeReadiness({ + wallet: WALLET, + currencyToken: SPG, + requiredAmount: 1n, + }), + /not supported WIP/i, + ); + assert.equal(reads, 0); }); From c92f8355f0c4c7309a219a9d6aec601a3f4c0fbd Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 02:17:21 -0400 Subject: [PATCH 054/165] fix: pin stage-specific Phase 0 metadata --- phase0/.env.example | 22 ++- phase0/src/metadata.ts | 179 +++++++++++++++++---- phase0/tests/demo.test.ts | 29 +++- phase0/tests/metadata.test.ts | 284 +++++++++++++++++++++++++++++----- 4 files changed, 437 insertions(+), 77 deletions(-) diff --git a/phase0/.env.example b/phase0/.env.example index 336fdbe..d9aba98 100644 --- a/phase0/.env.example +++ b/phase0/.env.example @@ -11,9 +11,19 @@ RPC_PROVIDER_URL=https://aeneid.storyrpc.io # persists its own collection in registrations.json. SPG_NFT_CONTRACT= -# Optional advanced overrides. Each must be a retrievable HTTPS URL whose -# response bytes exactly match the metadata JSON generated for this command. -# If unset, the CLI uses an https://httpbin.org/base64/ URL containing -# the exact JSON bytes, fetches it back, and verifies its SHA-256 before writing. -IP_METADATA_URI= -NFT_METADATA_URI= +# Default durable metadata path. Never commit the JWT. The JWT-bearing upload +# endpoint is fixed at https://uploads.pinata.cloud/v3/files and is not +# configurable. The gateway may be the default below or an HTTPS +# *.mypinata.cloud host with the exact /ipfs/ path. +PINATA_JWT= +IPFS_PUBLIC_GATEWAY_BASE_URL=https://gateway.pinata.cloud/ipfs/ + +# Optional paired, stage-specific public-IPFS overrides. Both values in a pair +# must use the exact configured Pinata gateway origin, /ipfs/, and return +# the exact serialized bytes the CLI generates. +ROOT_IP_METADATA_URI= +ROOT_NFT_METADATA_URI= +CHILD_IP_METADATA_URI= +CHILD_NFT_METADATA_URI= +GRANDCHILD_IP_METADATA_URI= +GRANDCHILD_NFT_METADATA_URI= diff --git a/phase0/src/metadata.ts b/phase0/src/metadata.ts index f49ee8e..df4c5f3 100644 --- a/phase0/src/metadata.ts +++ b/phase0/src/metadata.ts @@ -7,34 +7,108 @@ import type { DemoSkillDefinition, PreparedMetadata, } from "./demo"; +import type { DemoStage } from "./registrations"; + +export type StageMetadataUris = Partial>; export interface HttpMetadataProviderOptions { fetcher?: typeof fetch; - ipMetadataURI?: string; - nftMetadataURI?: string; + stageUris?: StageMetadataUris; + pinataJwt?: string; + publicGatewayBaseUrl?: string; } +const PINATA_PUBLIC_UPLOAD_URL = "https://uploads.pinata.cloud/v3/files"; +const DEFAULT_PUBLIC_GATEWAY_BASE_URL = "https://gateway.pinata.cloud/ipfs/"; +const STAGES: readonly DemoStage[] = ["root", "child", "grandchild"]; + function sha256Hex(bytes: Uint8Array): `0x${string}` { return `0x${createHash("sha256").update(bytes).digest("hex")}`; } -function inlineHttpsUri(bytes: Uint8Array): string { - const unpadded = Buffer.from(bytes).toString("base64url"); - const padding = (4 - (unpadded.length % 4)) % 4; - return `https://httpbin.org/base64/${unpadded}${"=".repeat(padding)}`; -} - -function requireHttps(uri: string, label: string): string { - let parsed: URL; +function strictHttpsUrl(value: string, label: string): URL { + let url: URL; try { - parsed = new URL(uri); + url = new URL(value); } catch { throw new Error(`${label} must be a valid HTTPS URL`); } - if (parsed.protocol !== "https:") { - throw new Error(`${label} must use HTTPS`); + if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS`); + if (url.username || url.password) throw new Error(`${label} must not contain credentials`); + if (url.search) throw new Error(`${label} must not contain a query`); + if (url.hash) throw new Error(`${label} must not contain a fragment`); + if (!/^https:\/\/[^/:?#]+(?:\/|$)/.test(value)) { + throw new Error(`${label} must not contain an explicit port or malformed authority`); + } + return url; +} + +function isAllowedPinataGatewayHost(hostname: string): boolean { + return hostname === "gateway.pinata.cloud" + || /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.mypinata\.cloud$/.test(hostname); +} + +export function validatePublicGatewayBaseUrl(value: string): string { + const url = strictHttpsUrl(value, "IPFS public gateway base URL"); + if (!isAllowedPinataGatewayHost(url.hostname)) { + throw new Error("IPFS public gateway must use gateway.pinata.cloud or a mypinata.cloud subdomain"); + } + if (url.pathname !== "/ipfs/") { + throw new Error("IPFS public gateway path must be exactly /ipfs/"); + } + return url.toString(); +} + +export function validateStageMetadataUri( + value: string, + gatewayBaseUrl: string, + label: string, +): string { + const base = new URL(validatePublicGatewayBaseUrl(gatewayBaseUrl)); + const url = strictHttpsUrl(value, label); + if (url.origin !== base.origin) { + throw new Error(`${label} origin must exactly match the configured public gateway`); + } + if (!url.pathname.startsWith(base.pathname)) { + throw new Error(`${label} path must start with ${base.pathname}`); + } + const cid = url.pathname.slice(base.pathname.length); + if (!/^b[a-z0-9]+$/.test(cid)) { + throw new Error(`${label} must end in exactly one lowercase CID and no extra path`); + } + return url.toString(); +} + +async function pinPublicJson(input: { + fetcher: typeof fetch; + jwt: string; + gatewayBaseUrl: string; + name: string; + bytes: Uint8Array; +}): Promise { + const form = new FormData(); + form.set("network", "public"); + form.set("name", input.name); + form.set("file", new Blob([Buffer.from(input.bytes)], { type: "application/json" }), input.name); + const response = await input.fetcher(PINATA_PUBLIC_UPLOAD_URL, { + method: "POST", + headers: { Authorization: `Bearer ${input.jwt}` }, + body: form, + redirect: "error", + }); + if (!response.ok) throw new Error(`Metadata pin failed (${response.status})`); + const body = await response.json() as { data?: { cid?: string } }; + const cid = body.data?.cid; + if (!cid || !/^b[a-z0-9]+$/.test(cid)) { + throw new Error("Pinata response is missing a public CID"); } - return parsed.toString(); + return new URL( + cid, + input.gatewayBaseUrl.endsWith("/") ? input.gatewayBaseUrl : `${input.gatewayBaseUrl}/`, + ).toString(); } async function verifyExactBytes( @@ -43,7 +117,7 @@ async function verifyExactBytes( expectedBytes: Uint8Array, expectedHash: `0x${string}`, ): Promise { - const response = await fetcher(uri); + const response = await fetcher(uri, { redirect: "error" }); if (!response.ok) { throw new Error(`Metadata fetch failed (${response.status}) for ${uri}`); } @@ -56,20 +130,58 @@ async function verifyExactBytes( } } +function envStageUris(): StageMetadataUris { + const configured: StageMetadataUris = {}; + for (const stage of STAGES) { + const prefix = stage.toUpperCase(); + const ip = process.env[`${prefix}_IP_METADATA_URI`]?.trim(); + const nft = process.env[`${prefix}_NFT_METADATA_URI`]?.trim(); + if (ip || nft) configured[stage] = { ip: ip ?? "", nft: nft ?? "" }; + } + return configured; +} + export class HttpMetadataProvider implements DemoMetadataProvider { private readonly fetcher: typeof fetch; - private readonly ipMetadataURI?: string; - private readonly nftMetadataURI?: string; + private readonly rawStageUris: StageMetadataUris; + private readonly pinataJwt?: string; + private readonly rawPublicGatewayBaseUrl: string; + private validated?: { gatewayBaseUrl: string; stageUris: StageMetadataUris }; constructor(options: HttpMetadataProviderOptions = {}) { this.fetcher = options.fetcher ?? fetch; - this.ipMetadataURI = options.ipMetadataURI ?? (process.env.IP_METADATA_URI?.trim() || undefined); - this.nftMetadataURI = options.nftMetadataURI ?? (process.env.NFT_METADATA_URI?.trim() || undefined); + this.rawStageUris = options.stageUris ?? envStageUris(); + this.pinataJwt = options.pinataJwt !== undefined + ? options.pinataJwt.trim() || undefined + : process.env.PINATA_JWT?.trim() || undefined; + this.rawPublicGatewayBaseUrl = options.publicGatewayBaseUrl + ?? process.env.IPFS_PUBLIC_GATEWAY_BASE_URL?.trim() + ?? DEFAULT_PUBLIC_GATEWAY_BASE_URL; + } + + private configuration(): { gatewayBaseUrl: string; stageUris: StageMetadataUris } { + if (this.validated) return this.validated; + const gatewayBaseUrl = validatePublicGatewayBaseUrl(this.rawPublicGatewayBaseUrl); + const stageUris: StageMetadataUris = {}; + for (const stage of STAGES) { + const pair = this.rawStageUris[stage]; + if (!pair) continue; + if (!pair.ip || !pair.nft) { + throw new Error(`${stage.toUpperCase()} metadata overrides must provide both IP and NFT URIs`); + } + stageUris[stage] = { + ip: validateStageMetadataUri(pair.ip, gatewayBaseUrl, `${stage.toUpperCase()}_IP_METADATA_URI`), + nft: validateStageMetadataUri(pair.nft, gatewayBaseUrl, `${stage.toUpperCase()}_NFT_METADATA_URI`), + }; + } + this.validated = { gatewayBaseUrl, stageUris }; + return this.validated; } async prepare( input: DemoSkillDefinition & { creatorAddress: `0x${string}` }, ): Promise { + const configuration = this.configuration(); const artifactBytes = await readFile(input.artifactPath); const mediaHash = sha256Hex(artifactBytes); const ipMetadata = { @@ -85,20 +197,29 @@ export class HttpMetadataProvider implements DemoMetadataProvider { }; const nftMetadata = { name: input.name, description: input.description }; - // Serialize each document exactly once. These exact bytes are encoded into the - // default URI, hashed, fetched back, and compared before a Story write. const ipBytes = Buffer.from(JSON.stringify(ipMetadata), "utf8"); const nftBytes = Buffer.from(JSON.stringify(nftMetadata), "utf8"); const ipMetadataHash = sha256Hex(ipBytes); const nftMetadataHash = sha256Hex(nftBytes); - const ipMetadataURI = requireHttps( - this.ipMetadataURI ?? inlineHttpsUri(ipBytes), - "IP_METADATA_URI", - ); - const nftMetadataURI = requireHttps( - this.nftMetadataURI ?? inlineHttpsUri(nftBytes), - "NFT_METADATA_URI", - ); + const override = configuration.stageUris[input.stage]; + if (!override && !this.pinataJwt) { + throw new Error(`PINATA_JWT is required to publish durable metadata for ${input.stage}`); + } + + const ipMetadataURI = override?.ip ?? await pinPublicJson({ + fetcher: this.fetcher, + jwt: this.pinataJwt!, + gatewayBaseUrl: configuration.gatewayBaseUrl, + name: `${input.stage}-ip-metadata.json`, + bytes: ipBytes, + }); + const nftMetadataURI = override?.nft ?? await pinPublicJson({ + fetcher: this.fetcher, + jwt: this.pinataJwt!, + gatewayBaseUrl: configuration.gatewayBaseUrl, + name: `${input.stage}-nft-metadata.json`, + bytes: nftBytes, + }); await verifyExactBytes(this.fetcher, ipMetadataURI, ipBytes, ipMetadataHash); await verifyExactBytes(this.fetcher, nftMetadataURI, nftBytes, nftMetadataHash); diff --git a/phase0/tests/demo.test.ts b/phase0/tests/demo.test.ts index 17002b5..192b6ad 100644 --- a/phase0/tests/demo.test.ts +++ b/phase0/tests/demo.test.ts @@ -70,11 +70,25 @@ function clone(value: T): T { return structuredClone(value); } -async function fetchInlineMetadata(input: string | URL | Request): Promise { - const url = new URL(String(input)); - assert.equal(url.origin, "https://httpbin.org"); - assert.match(url.pathname, /^\/base64\//); - return new Response(Buffer.from(url.pathname.slice("/base64/".length), "base64url")); +function fakePinataFetcher(): typeof fetch { + const pinned = new Map(); + let uploadCount = 0; + return async (input, init) => { + const url = String(input); + if (init?.method === "POST") { + assert.equal(url, "https://uploads.pinata.cloud/v3/files"); + assert.ok(init.body instanceof FormData); + const file = init.body.get("file"); + assert.ok(file instanceof Blob); + uploadCount += 1; + const cid = `bafyabsolute${uploadCount}`; + pinned.set(`https://gateway.pinata.cloud/ipfs/${cid}`, Buffer.from(await file.arrayBuffer())); + return Response.json({ data: { cid } }); + } + const bytes = pinned.get(url); + assert.ok(bytes, `unexpected metadata fetch ${url}`); + return new Response(bytes.toString("utf8")); + }; } async function sha256(path: string): Promise<`0x${string}`> { @@ -516,7 +530,10 @@ test("the real metadata provider accepts an absolute demo artifact path at the j const chain = new CrashableChain(); chain.stopAfterRoot = true; const store = collectionOnlyStore(); - const metadata = new HttpMetadataProvider({ fetcher: fetchInlineMetadata }); + const metadata = new HttpMetadataProvider({ + fetcher: fakePinataFetcher(), + pinataJwt: "fixture-token", + }); await assert.rejects( runDemo({ wallet: WALLET, chain, metadata, store, journal: new MemoryJournal() }), diff --git a/phase0/tests/metadata.test.ts b/phase0/tests/metadata.test.ts index aec6f55..a5453af 100644 --- a/phase0/tests/metadata.test.ts +++ b/phase0/tests/metadata.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import type { DemoStage } from "../src/registrations"; import { HttpMetadataProvider } from "../src/metadata"; const WALLET = "0x00000000000000000000000000000000000000aa" as const; @@ -11,8 +12,31 @@ const ARTIFACT = "# Fixture Skill\n\nReturn one concise answer.\n"; const ARTIFACT_HASH = "0x316163c97d7669db8b33fc52d05d458318acec1aad98fc5515b2f0f508957912"; const IP_HASH = "0x6c42a18b50e58fbe307da28995b381d6c5690f7815070733946c20344b58bae9"; const NFT_HASH = "0xdb24ac9487196af7c830b213c8127f08b3b2c12eb2baeb1fcc6625281ab16098"; -const IP_JSON = `{"title":"Fixture Skill","description":"fixture","createdAt":"0","ipType":"skill","creators":[{"name":"creator","address":"${WALLET}","contributionPercent":100}],"mediaHash":"${ARTIFACT_HASH}","mediaType":"text/markdown"}`; -const NFT_JSON = '{"name":"Fixture Skill","description":"fixture"}'; +const UPLOAD_URL = "https://uploads.pinata.cloud/v3/files"; +const DEFAULT_GATEWAY = "https://gateway.pinata.cloud/ipfs/"; + +const INVALID_STAGE_URIS = [ + "http://gateway.pinata.cloud/ipfs/bafyvalidcid123", + "https://user:pass@gateway.pinata.cloud/ipfs/bafyvalidcid123", + "https://gateway.pinata.cloud/ipfs/bafyvalidcid123?download=1", + "https://gateway.pinata.cloud/ipfs/bafyvalidcid123#fragment", + "https://gateway.pinata.cloud.evil/ipfs/bafyvalidcid123", + "https://gateway.pinata.cloud/not-ipfs/bafyvalidcid123", + "https://gateway.pinata.cloud/ipfs/bafyvalidcid123/extra", + "https://gateway.pinata.cloud/ipfs/%2e%2e/bafyvalidcid123", +] as const; + +const INVALID_GATEWAY_BASES = [ + "http://gateway.pinata.cloud/ipfs/", + "https://user:pass@gateway.pinata.cloud/ipfs/", + "https://gateway.pinata.cloud:444/ipfs/", + "https://gateway.pinata.cloud/ipfs/?query=1", + "https://gateway.pinata.cloud/ipfs/#fragment", + "https://gateway.pinata.cloud.evil/ipfs/", + "https://evil-mypinata.cloud/ipfs/", + "https://gateway.pinata.cloud/not-ipfs/", + "https://gateway.pinata.cloud/ipfs/extra/", +] as const; async function withArtifact(t: test.TestContext) { const directory = await mkdtemp(join(tmpdir(), "phase0-metadata-")); @@ -22,57 +46,245 @@ async function withArtifact(t: test.TestContext) { return path; } -function decodeHttpbin(input: string | URL | Request): Response { - const url = new URL(String(input)); - assert.equal(url.origin, "https://httpbin.org"); - assert.match(url.pathname, /^\/base64\//); - const encoded = url.pathname.slice("/base64/".length); - if (encoded.length % 4 !== 0) return new Response("Incorrect Base64 data"); - return new Response(Buffer.from(encoded, "base64url")); +function input(artifactPath: string, stage: DemoStage = "root") { + return { + stage, + name: "Fixture Skill", + description: "fixture", + creatorAddress: WALLET, + artifactPath, + }; +} + +function headerValue(headers: HeadersInit | undefined, name: string): string | null { + return new Headers(headers).get(name); } -test("metadata HTTPS URIs decode to exact serialized bytes with Story SHA-256 hashes", async (t) => { +test("default publication pins two exact byte documents and verifies them without credentials", async (t) => { const artifactPath = await withArtifact(t); - const fetched: string[] = []; + const calls: Array<{ url: string; init?: RequestInit }> = []; + const pinned = new Map(); + const cids = ["bafyipfixture123", "bafynftfixture456"]; const provider = new HttpMetadataProvider({ - fetcher: async (input) => { - fetched.push(String(input)); - return decodeHttpbin(input); + pinataJwt: "fixture-token", + fetcher: async (request, init) => { + const url = String(request); + calls.push({ url, init }); + if (init?.method === "POST") { + assert.equal(url, UPLOAD_URL); + assert.equal(init.redirect, "error"); + assert.equal(headerValue(init.headers, "authorization"), "Bearer fixture-token"); + assert.ok(init.body instanceof FormData); + assert.equal(init.body.get("network"), "public"); + const file = init.body.get("file"); + assert.ok(file instanceof Blob); + const cid = cids[pinned.size]; + assert.ok(cid); + pinned.set(`${DEFAULT_GATEWAY}${cid}`, new Uint8Array(await file.arrayBuffer())); + return Response.json({ data: { cid } }); + } + assert.equal(headerValue(init?.headers, "authorization"), null); + assert.equal(init?.redirect, "error"); + const bytes = pinned.get(url); + assert.ok(bytes, `unexpected gateway fetch ${url}`); + return new Response(Buffer.from(bytes)); }, }); - const prepared = await provider.prepare({ - stage: "root", - name: "Fixture Skill", - description: "fixture", - creatorAddress: WALLET, - artifactPath, - }); + const prepared = await provider.prepare(input(artifactPath)); assert.equal(prepared.proof.artifact.mediaHash, ARTIFACT_HASH); assert.equal(prepared.onchain.ipMetadataHash, IP_HASH); assert.equal(prepared.onchain.nftMetadataHash, NFT_HASH); - assert.equal(prepared.proof.ip.hash, IP_HASH); - assert.equal(prepared.proof.nft.hash, NFT_HASH); - assert.equal(fetched.length, 2); - assert.equal(Buffer.from(new URL(fetched[0]).pathname.slice("/base64/".length), "base64url").toString(), IP_JSON); - assert.equal(Buffer.from(new URL(fetched[1]).pathname.slice("/base64/".length), "base64url").toString(), NFT_JSON); + assert.equal(prepared.onchain.ipMetadataURI, `${DEFAULT_GATEWAY}${cids[0]}`); + assert.equal(prepared.onchain.nftMetadataURI, `${DEFAULT_GATEWAY}${cids[1]}`); + assert.equal(calls.filter((call) => call.init?.method === "POST").length, 2); + assert.equal(calls.filter((call) => call.init?.method !== "POST").length, 2); +}); + +test("a root override cannot leak into child or grandchild", async (t) => { + const artifactPath = await withArtifact(t); + const rootIp = `${DEFAULT_GATEWAY}bafyrootip123`; + const rootNft = `${DEFAULT_GATEWAY}bafyrootnft456`; + let fetchCalls = 0; + const provider = new HttpMetadataProvider({ + stageUris: { root: { ip: rootIp, nft: rootNft } }, + pinataJwt: "", + fetcher: async (request) => { + fetchCalls += 1; + if (String(request) === rootIp) { + return new Response(Buffer.from(JSON.stringify({ + title: "Fixture Skill", + description: "fixture", + createdAt: "0", + ipType: "skill", + creators: [{ name: "creator", address: WALLET, contributionPercent: 100 }], + mediaHash: ARTIFACT_HASH, + mediaType: "text/markdown", + }))); + } + if (String(request) === rootNft) { + return new Response(Buffer.from(JSON.stringify({ name: "Fixture Skill", description: "fixture" }))); + } + throw new Error(`unexpected fetch ${String(request)}`); + }, + }); + + await provider.prepare(input(artifactPath, "root")); + assert.equal(fetchCalls, 2); + await assert.rejects(provider.prepare(input(artifactPath, "child")), /PINATA_JWT.*child/i); + await assert.rejects(provider.prepare(input(artifactPath, "grandchild")), /PINATA_JWT.*grandchild/i); + assert.equal(fetchCalls, 2); +}); + +test("an incomplete stage override pair fails before any fetch", async (t) => { + const artifactPath = await withArtifact(t); + let fetchCalls = 0; + const provider = new HttpMetadataProvider({ + stageUris: { root: { ip: `${DEFAULT_GATEWAY}bafyrootip123` } } as never, + fetcher: async () => { + fetchCalls += 1; + throw new Error("must not fetch"); + }, + }); + + await assert.rejects(provider.prepare(input(artifactPath)), /ROOT metadata overrides must provide both IP and NFT URIs/); + assert.equal(fetchCalls, 0); +}); + +test("every configured stage pair is validated before the selected stage can fetch", async (t) => { + const artifactPath = await withArtifact(t); + let fetchCalls = 0; + const provider = new HttpMetadataProvider({ + stageUris: { + root: { + ip: `${DEFAULT_GATEWAY}bafyrootip123`, + nft: `${DEFAULT_GATEWAY}bafyrootnft456`, + }, + child: { + ip: "https://attacker.invalid/ipfs/bafychildip123", + nft: "https://attacker.invalid/ipfs/bafychildnft456", + }, + }, + fetcher: async () => { + fetchCalls += 1; + throw new Error("must not fetch"); + }, + }); + + await assert.rejects(provider.prepare(input(artifactPath, "root")), /origin must exactly match/i); + assert.equal(fetchCalls, 0); +}); + +for (const uri of INVALID_STAGE_URIS) { + test(`invalid stage URI fails before fetch: ${uri}`, async (t) => { + const artifactPath = await withArtifact(t); + let fetchCalls = 0; + const provider = new HttpMetadataProvider({ + stageUris: { root: { ip: uri, nft: uri } }, + fetcher: async () => { + fetchCalls += 1; + throw new Error("must not fetch"); + }, + }); + + await assert.rejects(provider.prepare(input(artifactPath))); + assert.equal(fetchCalls, 0); + }); +} + +for (const gateway of INVALID_GATEWAY_BASES) { + test(`invalid public gateway fails before upload: ${gateway}`, async (t) => { + const artifactPath = await withArtifact(t); + let fetchCalls = 0; + const provider = new HttpMetadataProvider({ + publicGatewayBaseUrl: gateway, + pinataJwt: "fixture-token", + fetcher: async () => { + fetchCalls += 1; + throw new Error("must not fetch"); + }, + }); + + await assert.rejects(provider.prepare(input(artifactPath))); + assert.equal(fetchCalls, 0); + }); +} + +test("a dedicated mypinata gateway accepts same-origin stage objects", async (t) => { + const artifactPath = await withArtifact(t); + const gateway = "https://team.mypinata.cloud/ipfs/"; + const ip = `${gateway}bafyrootip123`; + const nft = `${gateway}bafyrootnft456`; + const expected = new Map([ + [ip, JSON.stringify({ + title: "Fixture Skill", + description: "fixture", + createdAt: "0", + ipType: "skill", + creators: [{ name: "creator", address: WALLET, contributionPercent: 100 }], + mediaHash: ARTIFACT_HASH, + mediaType: "text/markdown", + })], + [nft, JSON.stringify({ name: "Fixture Skill", description: "fixture" })], + ]); + const provider = new HttpMetadataProvider({ + publicGatewayBaseUrl: gateway, + stageUris: { root: { ip, nft } }, + fetcher: async (request, init) => { + assert.equal(headerValue(init?.headers, "authorization"), null); + const bytes = expected.get(String(request)); + assert.ok(bytes); + return new Response(Buffer.from(bytes)); + }, + }); + + const prepared = await provider.prepare(input(artifactPath)); + assert.equal(prepared.onchain.ipMetadataURI, ip); + assert.equal(prepared.onchain.nftMetadataURI, nft); +}); + +test("PINATA_UPLOAD_URL cannot redirect a JWT-bearing request", async (t) => { + const artifactPath = await withArtifact(t); + const original = process.env.PINATA_UPLOAD_URL; + process.env.PINATA_UPLOAD_URL = "https://attacker.invalid/collect"; + t.after(() => { + if (original === undefined) delete process.env.PINATA_UPLOAD_URL; + else process.env.PINATA_UPLOAD_URL = original; + }); + const posted: string[] = []; + const pinned = new Map(); + const provider = new HttpMetadataProvider({ + pinataJwt: "fixture-token", + fetcher: async (request, init) => { + const url = String(request); + if (init?.method === "POST") { + posted.push(url); + assert.ok(init.body instanceof FormData); + const file = init.body.get("file"); + assert.ok(file instanceof Blob); + const cid = `bafyignored${posted.length}`; + pinned.set(`${DEFAULT_GATEWAY}${cid}`, new Uint8Array(await file.arrayBuffer())); + return Response.json({ data: { cid } }); + } + const bytes = pinned.get(url); + assert.ok(bytes); + return new Response(Buffer.from(bytes)); + }, + }); + + await provider.prepare(input(artifactPath)); + assert.deepEqual(posted, [UPLOAD_URL, UPLOAD_URL]); }); test("altered fetched metadata bytes are rejected", async (t) => { const artifactPath = await withArtifact(t); + const ip = `${DEFAULT_GATEWAY}bafyrootip123`; + const nft = `${DEFAULT_GATEWAY}bafyrootnft456`; const provider = new HttpMetadataProvider({ + stageUris: { root: { ip, nft } }, fetcher: async () => new Response("altered"), }); - await assert.rejects( - provider.prepare({ - stage: "root", - name: "Fixture Skill", - description: "fixture", - creatorAddress: WALLET, - artifactPath, - }), - /fetched metadata bytes do not match/i, - ); + await assert.rejects(provider.prepare(input(artifactPath)), /fetched metadata bytes do not match/i); }); From 3afa41f375e4dc9d6f64f874285a0d8e62f1979d Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 02:31:30 -0400 Subject: [PATCH 055/165] docs: define Phase 0 wallet-attested proof boundary --- phase0/.env.example | 4 - phase0/README.md | 182 ++++++++++++++---------- phase0/package.json | 3 +- phase0/src/check.ts | 182 ++++++++++++++++++++++++ phase0/src/demo.ts | 2 +- phase0/src/index.ts | 171 ++++++++++++++-------- phase0/tests/check.test.ts | 280 +++++++++++++++++++++++++++++++++++++ phase0/tests/index.test.ts | 57 ++++++++ 8 files changed, 743 insertions(+), 138 deletions(-) create mode 100644 phase0/src/check.ts create mode 100644 phase0/tests/check.test.ts create mode 100644 phase0/tests/index.test.ts diff --git a/phase0/.env.example b/phase0/.env.example index d9aba98..781445a 100644 --- a/phase0/.env.example +++ b/phase0/.env.example @@ -7,10 +7,6 @@ WALLET_PRIVATE_KEY= # Story Aeneid testnet RPC (default is fine) RPC_PROVIDER_URL=https://aeneid.storyrpc.io -# Optional for the advanced one-step commands. `npm run demo` creates and -# persists its own collection in registrations.json. -SPG_NFT_CONTRACT= - # Default durable metadata path. Never commit the JWT. The JWT-bearing upload # endpoint is fixed at https://uploads.pinata.cloud/v3/files and is not # configurable. The gateway may be the default below or an HTTPS diff --git a/phase0/README.md b/phase0/README.md index fcb84dc..ed84552 100644 --- a/phase0/README.md +++ b/phase0/README.md @@ -1,97 +1,125 @@ -# Phase 0 — Story provenance demo +# Phase 0 — wallet-attested Story registration -From a funded Aeneid wallet, one command creates an SPG NFT collection and -registers a real three-level provenance graph: +This testnet demo proves `wallet_asserted` registration and declared Derivative +ancestry. It does not prove authorship, originality, repository control, or +safety. + +From a prepared Story Aeneid wallet, one command creates an SPG NFT collection, +registers a base **Skill**, and registers two declared Derivatives: ```bash cd phase0 npm install -cp .env.example .env # add a throwaway testnet key +cp .env.example .env # add a throwaway testnet key and Pinata JWT +npm run check npm run demo ``` -The demo checks the RPC's chain ID and the wallet's native-IP balance before it -does anything else. An exactly-zero balance exits nonzero before metadata is -fetched, `registrations.json` is changed, or a Story transaction is submitted, -and prints the wallet, network, and Aeneid faucet URL. Funding is always a human -step: . +The committed `registrations.json` remains honestly `status: "not-run"` until a +human completes the real testnet prerequisites and runs the demo. Automated +tests use injected fakes and do not read a wallet key, contact Pinata or Story, +wrap IP, approve WIP, or submit a transaction. -## What the command writes +## Registration evidence The confirmed sequence is: 1. create an SPG NFT collection; -2. register the base **Skill** with commercial-remix PIL terms and a small, - positive, testnet-only minting fee (`0.001 IP`); -3. register a declared **Derivative** of that Skill; +2. register the base Skill with commercial-remix PIL terms and a configured + `0.001 WIP` minting-fee estimate; +3. register a declared Derivative of that Skill; 4. register a second-level Derivative whose parent is the first Derivative. -The three artifacts are committed under `fixtures/`; every registration hashes -the actual `SKILL.md` bytes with SHA-256. IP and NFT metadata JSON are each -serialized exactly once, hashed with SHA-256, embedded by default in a -retrievable `https://httpbin.org/base64/` URI, fetched back, and -byte-compared before any Story write. `IP_METADATA_URI` and `NFT_METADATA_URI` -may override those defaults only when their fetched bytes match exactly. - -Immediately after every confirmed transaction, the demo atomically replaces -`registrations.json`. The artifact records the network and wallet, SPG contract, -collection transaction, and each Skill/Derivative's `ipId`, `tokenId`, -transaction hash, inherited license-terms ID, parent IP IDs, minting-fee values, -and metadata URI/hash pairs. Native `bigint` values are persisted as decimal -strings. A rerun with the same chain and wallet skips confirmed stages and -resumes only the missing suffix; a different wallet is rejected rather than -overwriting the proof. - -The committed artifact is deliberately `status: "not-run"` with null IDs. The -write path remains **unexecuted** until `registrations.json` contains confirmed -IDs and transaction hashes from a funded-wallet run. - -Before each Derivative transaction, the CLI calls -`predictMintingLicenseFee(..., amount: 1)` and passes the returned `tokenAmount` -as an explicit `maxMintingFee` cap. In Story SDK 1.4.4, `0` means unlimited; the -explicit predicted cap is spend protection and exercises the paid-parent path, -not a workaround for a claimed SDK incompatibility. - -## Network boundary and PRD criterion - -This code targets **Story Aeneid testnet, chain ID 1315**, and never sends -mainnet transactions or real funds. The PRD's Phase-0 success criterion targets -**Story mainnet, chain ID 1514**, and requires broader proof than this testnet -write path. Aeneid results are useful engineering evidence; they **do not -satisfy the PRD Phase-0 success criterion**. - -## Advanced commands - -The individual commands remain available for targeted runs. They return only -after validating the SDK's optional proof fields, and registrations verify -metadata bytes before submitting a transaction. +Every registration hashes the committed `SKILL.md` artifact bytes. The evidence +artifact records the Aeneid network, connected wallet, SPG collection, confirmed +transaction hashes, event-derived IP IDs and license fields, declared parent +edges, fee caps, and exact metadata URI/hash pairs. These are wallet and chain +facts, not proof that the wallet authored the artifacts. + +Before broadcast, the demo signs the transaction locally and atomically saves +its hash, serialized testnet transaction, and canonical operation intent to the +mode-0600, ignored `pending-transactions.json`. The whole demo holds an exclusive +same-host journal lease and updates it with compare-and-swap revisions. A rerun +validates the journal-bound intent and prerequisite proofs, reconciles or +rebroadcasts the exact persisted bytes and hash without a Pinata or funding read, +waits for that same hash, saves confirmed evidence from persisted metadata, and +only then clears the matching journal revision. Only after recovery does it +compare the current local run configuration and permit another prepare. + +Never delete `pending-transactions.json`, its `.lock`, or a `.claim` file merely +to force progress. An intent/config mismatch or unresolved/replaced nonce +requires operator investigation. A stale lock may be recovered only on the same +host, for a PID proven absent, while the recorded lease remains byte-for-byte +unchanged. Copy the exact lease ID printed by the lock error: ```bash -npm run check - -npm run create-collection -- --name Skills --symbol SKILL - -npm run register-skill -- \ - --spg \ - --name "research-skill" \ - --description "base research Skill" \ - --skill-file fixtures/demo-base/SKILL.md \ - --rev-share 25 \ - --policy LAP \ - --minting-fee 1000000000000000 - -npm run register-derivative -- \ - --spg \ - --parent \ - --license-terms-id \ - --name "research-derivative" \ - --description "declared Derivative" \ - --skill-file fixtures/demo-child/SKILL.md +npm run recover-stale-lock -- ``` -`register-derivative` predicts the parent's current minting fee immediately -before its write and uses that value as the cap. Explorer links use -. +There is no force flag, timeout deletion, or automatic lease-ID discovery. + +## Durable metadata boundary + +Metadata defaults to public IPFS pinning. Pinata credentials and any wallet key +remain local. The JWT-bearing upload URL is fixed to +`https://uploads.pinata.cloud/v3/files`, redirects are disabled, and public +gateway verification requests never carry the JWT or follow redirects. + +Stage-specific URI overrides must be supplied in complete IP/NFT pairs. They +must use the exact configured, allow-listed Pinata gateway origin, contain the +exact `/ipfs/` path, and return byte-identical content. The only allowed +gateway hosts are `gateway.pinata.cloud` and HTTPS subdomains of +`mypinata.cloud`, with no port, credentials, query, or fragment. Every metadata +document is serialized once, hashed, fetched, and byte-compared before a new +transaction is prepared. + +## Native gas and WIP are separate prerequisites + +Run `npm run check` before each human testnet step. It reports pending recovery, +remaining new writes, native-IP gas readiness, and—only when the next missing +write is a Derivative—the WIP balance, allowance, and exact spender. It never +uploads metadata, wraps IP, approves WIP, signs, broadcasts, reconciles, or +clears a transaction. + +The native-IP minimum is a conservative preflight estimate, not a guarantee of +final gas use. There is no validated per-stage gas allocation, so any positive +number of remaining writes retains the full four-write envelope; zero remaining +writes requires zero and performs no balance or gas-price read. + +Derivative minting fees use WIP, not native IP. The displayed `0.001 WIP` is a +configured estimate, not a substitute for the per-parent on-chain prediction +performed immediately before each new Derivative prepare. Because the crash-safe +path requests `encodedTxDataOnly`, the Story SDK does not perform its normal +automatic IP-to-WIP wrapping or WIP approval. Before a real testnet demo, a +human must use supported Story testnet tooling to wrap sufficient IP to WIP and +approve the exact `DerivativeWorkflows` spender. + +On a clean `not-run` manifest, collection is next, so `npm run check` correctly +performs no WIP read and prints that the WIP domain is not yet applicable. The +staged operator flow is: + +1. verify native gas with `npm run check`; +2. run `npm run demo`; it may confirm and persist the collection and root, then + fails closed before preparing the child when WIP is not ready; +3. run `npm run check` again, now with child next, to print the exact spender, + WIP balance, and allowance; +4. complete the human testnet wrap and approval, rerun `npm run check`, and only + then resume `npm run demo`. + +The demo predicts the current fee and rechecks WIP balance and allowance before +each new Derivative prepare. A matching signed Derivative already in the journal +is recovered without those reads because it may already have consumed the funds. +The prerequisite wrap and approval transactions are not journaled by this demo. + +Funding remains a human action. Obtain test IP only from the Story Aeneid faucet: +. + +## Network boundary + +This code rejects any chain other than **Story Aeneid testnet, chain ID 1315** +and never targets mainnet or real funds. The PRD Phase-0 success criterion names +Story mainnet, chain ID 1514, and requires broader evidence. Aeneid registration +evidence does not satisfy that criterion. ## Local verification @@ -100,5 +128,5 @@ npm test npm run typecheck ``` -The tests use injected fakes only at filesystem, HTTP, RPC, and Story SDK -boundaries. They make no network calls and use no wallet key. +The suite uses injected filesystem, HTTP, RPC, and SDK boundaries and requires +no network, wallet key, Pinata credential, WIP operation, or transaction. diff --git a/phase0/package.json b/phase0/package.json index be55e6d..ca92ea0 100644 --- a/phase0/package.json +++ b/phase0/package.json @@ -3,10 +3,11 @@ "private": true, "type": "module", "version": "0.0.0", - "description": "Phase 0 (ADR-0006): register a Skill as a Story IP Asset with commercial-remix PIL terms, and declare a Derivative (fork). Provenance + fork-graph on Story Aeneid testnet.", + "description": "Phase 0 testnet spike: wallet-attested Skill registration and declared Derivative ancestry on Story Aeneid.", "scripts": { "check": "node --import tsx src/index.ts check", "demo": "node --import tsx src/index.ts demo", + "recover-stale-lock": "node --import tsx src/index.ts recover-stale-lock", "test": "node --import tsx --test tests/*.test.ts", "typecheck": "tsc --noEmit" }, diff --git a/phase0/src/check.ts b/phase0/src/check.ts new file mode 100644 index 0000000..a58da45 --- /dev/null +++ b/phase0/src/check.ts @@ -0,0 +1,182 @@ +import { WIP_TOKEN_ADDRESS } from "@story-protocol/core-sdk"; +import { formatEther } from "viem"; + +import { + AENEID_CHAIN_ID, + DEMO_ROOT_MINTING_FEE, + missingOperationStages, + type DemoChain, +} from "./demo"; +import { estimateRemainingDemoGasMinimum } from "./funding"; +import { + parseRegistrationManifest, + type RegistrationStore, +} from "./registrations"; +import type { OperationJournal } from "./transactions"; + +export type CheckChain = Pick< + DemoChain, + "getChainId" | "getBalance" | "getGasPrice" | "getDerivativeFeeReadiness" +>; + +export interface CheckReport { + wallet: `0x${string}`; + chain: string; + pendingRecovery: string; + remainingNewWrites: number; + nativeIpBalance: string; + gasPrice: string; + estimatedGasMinimum: string; + nativeGasReady: "yes" | "no" | "deferred until exact-hash recovery"; + derivativeFeeToken: string; + configuredFeeEstimate: string; + wipBalance: string; + wipAllowance: string; + derivativeWorkflowsSpender: string; + nextDerivativeWipReady: string; +} + +export interface BuildCheckReportInput { + wallet: `0x${string}`; + chain: CheckChain; + store: RegistrationStore; + journal: OperationJournal; +} + +const DEFERRED = "deferred until exact-hash recovery"; +const NOT_APPLICABLE = "not applicable until the next new Derivative"; +const NOT_READ = "not read (no remaining new writes)"; +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; + +function sameAddress(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +function requireReadinessShape(value: Awaited>) { + if (!sameAddress(value.currencyToken, WIP_TOKEN_ADDRESS)) { + throw new Error(`Derivative readiness returned unsupported token ${value.currencyToken}`); + } + if (value.requiredAmount !== DEMO_ROOT_MINTING_FEE) { + throw new Error("Derivative readiness returned a different configured fee amount"); + } + if (!ADDRESS.test(value.spender) || /^0x0{40}$/i.test(value.spender)) { + throw new Error("Derivative readiness returned a malformed workflows spender"); + } + if (value.balance < 0n || value.allowance < 0n) { + throw new Error("Derivative readiness returned a negative WIP balance or allowance"); + } +} + +export async function buildCheckReport(input: BuildCheckReportInput): Promise { + return input.journal.withExclusiveLease(async (journal) => { + const snapshot = await journal.load(); + const manifest = parseRegistrationManifest(await input.store.load()); + const remainingStages = missingOperationStages(manifest); + const remainingNewWrites = remainingStages.length; + + if (snapshot.operation) { + return { + wallet: snapshot.operation.intent.wallet, + chain: `Story Aeneid (${AENEID_CHAIN_ID}) — deferred until exact-hash recovery`, + pendingRecovery: `required (${snapshot.operation.stage}, ${snapshot.operation.transactionHash})`, + remainingNewWrites, + nativeIpBalance: DEFERRED, + gasPrice: DEFERRED, + estimatedGasMinimum: DEFERRED, + nativeGasReady: DEFERRED, + derivativeFeeToken: DEFERRED, + configuredFeeEstimate: "0.001 WIP per Derivative", + wipBalance: DEFERRED, + wipAllowance: DEFERRED, + derivativeWorkflowsSpender: DEFERRED, + nextDerivativeWipReady: DEFERRED, + }; + } + + if (manifest.wallet && !sameAddress(manifest.wallet, input.wallet)) { + throw new Error(`Existing registration evidence belongs to another wallet: ${manifest.wallet}`); + } + const chainId = await input.chain.getChainId(); + if (chainId !== AENEID_CHAIN_ID) { + throw new Error(`Phase 0 requires Story Aeneid chain ${AENEID_CHAIN_ID}; received ${chainId}`); + } + + let nativeIpBalance = NOT_READ; + let gasPrice = NOT_READ; + let estimatedGasMinimum = "0 IP"; + let nativeGasReady: CheckReport["nativeGasReady"] = "yes"; + if (remainingNewWrites > 0) { + const [balance, currentGasPrice] = await Promise.all([ + input.chain.getBalance(input.wallet), + input.chain.getGasPrice(), + ]); + const minimum = estimateRemainingDemoGasMinimum({ + gasPrice: currentGasPrice, + remainingNewWrites, + }); + nativeIpBalance = `${formatEther(balance)} IP`; + gasPrice = `${currentGasPrice} wei/gas`; + estimatedGasMinimum = `${formatEther(minimum)} IP`; + nativeGasReady = balance >= minimum ? "yes" : "no"; + } + + let derivativeFeeToken = NOT_APPLICABLE; + let wipBalance = NOT_APPLICABLE; + let wipAllowance = NOT_APPLICABLE; + let derivativeWorkflowsSpender = NOT_APPLICABLE; + let nextDerivativeWipReady = NOT_APPLICABLE; + const nextStage = remainingStages[0]; + if (nextStage === "child" || nextStage === "grandchild") { + const readiness = await input.chain.getDerivativeFeeReadiness({ + wallet: input.wallet, + currencyToken: WIP_TOKEN_ADDRESS, + requiredAmount: DEMO_ROOT_MINTING_FEE, + }); + requireReadinessShape(readiness); + derivativeFeeToken = readiness.currencyToken; + wipBalance = `${formatEther(readiness.balance)} WIP`; + wipAllowance = `${formatEther(readiness.allowance)} WIP`; + derivativeWorkflowsSpender = readiness.spender; + nextDerivativeWipReady = readiness.balance >= readiness.requiredAmount + && readiness.allowance >= readiness.requiredAmount + ? "yes" + : "no"; + } + + return { + wallet: input.wallet, + chain: `Story Aeneid (${chainId})`, + pendingRecovery: "none", + remainingNewWrites, + nativeIpBalance, + gasPrice, + estimatedGasMinimum, + nativeGasReady, + derivativeFeeToken, + configuredFeeEstimate: "0.001 WIP per Derivative", + wipBalance, + wipAllowance, + derivativeWorkflowsSpender, + nextDerivativeWipReady, + }; + }); +} + +export function renderCheckReport(report: CheckReport): string[] { + return [ + `wallet : ${report.wallet}`, + `chain : ${report.chain}`, + `pending recovery : ${report.pendingRecovery}`, + `remaining new writes : ${report.remainingNewWrites}`, + `native IP balance : ${report.nativeIpBalance}`, + `gas price : ${report.gasPrice}`, + `estimated gas minimum : ${report.estimatedGasMinimum}`, + `native gas ready : ${report.nativeGasReady}`, + `Derivative fee token : ${report.derivativeFeeToken}`, + `configured fee estimate : ${report.configuredFeeEstimate}`, + `WIP balance : ${report.wipBalance}`, + `WIP allowance : ${report.wipAllowance}`, + `DerivativeWorkflows spender : ${report.derivativeWorkflowsSpender}`, + `next Derivative WIP ready : ${report.nextDerivativeWipReady}`, + ]; +} diff --git a/phase0/src/demo.ts b/phase0/src/demo.ts index 3bc9cfc..3a513a9 100644 --- a/phase0/src/demo.ts +++ b/phase0/src/demo.ts @@ -161,7 +161,7 @@ export const DEMO_SKILLS: readonly DemoSkillDefinition[] = [ { stage: "root", name: "demo-research-skill", - description: "A tiny research Skill used to prove Story provenance on Aeneid.", + description: "A tiny research Skill used for wallet-attested registration on Aeneid.", artifactPath: fileURLToPath(new URL("../fixtures/demo-base/SKILL.md", import.meta.url)), }, { diff --git a/phase0/src/index.ts b/phase0/src/index.ts index db2a9b3..341b3f4 100644 --- a/phase0/src/index.ts +++ b/phase0/src/index.ts @@ -1,86 +1,147 @@ -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; -import { formatEther } from "viem"; - -import { - getAccount, - getClient, - getPublicClient, - getWalletClient, -} from "./client"; -import { runDemo } from "./demo"; -import { HttpMetadataProvider } from "./metadata"; -import { FileRegistrationStore } from "./registrations"; -import { StoryChain } from "./story"; import { FileOperationJournal } from "./transactions"; -const { positionals } = parseArgs({ allowPositionals: true }); -const command = positionals[0]; const registrationsPath = fileURLToPath(new URL("../registrations.json", import.meta.url)); const pendingTransactionsPath = fileURLToPath( new URL("../pending-transactions.json", import.meta.url), ); -function storyChain(): StoryChain { - return new StoryChain({ - sdk: getClient(), - wallet: getWalletClient(), - publicClient: getPublicClient(), - }); +async function storyBoundary() { + const [client, { StoryChain }] = await Promise.all([ + import("./client"), + import("./story"), + ]); + return { + account: client.getAccount(), + chain: new StoryChain({ + sdk: client.getClient(), + wallet: client.getWalletClient(), + publicClient: client.getPublicClient(), + }), + }; } -async function check() { - const account = getAccount(); - const chain = storyChain(); - const chainId = await chain.getChainId(); - const balance = await chain.getBalance(account.address); - console.log("wallet :", account.address); - console.log("chain :", `Story Aeneid (${chainId})`); - console.log("balance :", formatEther(balance), "IP"); - console.log("SPG contract:", process.env.SPG_NFT_CONTRACT || "(none — npm run demo creates one)"); - if (balance === 0n) { - console.log("\n⚠ Wallet has 0 IP. Fund it manually: https://aeneid.faucet.story.foundation/"); - } - return { wallet: account.address, chainId, balance }; +async function check(): Promise { + const [{ buildCheckReport, renderCheckReport }, { FileRegistrationStore }, boundary] = await Promise.all([ + import("./check"), + import("./registrations"), + storyBoundary(), + ]); + const report = await buildCheckReport({ + wallet: boundary.account.address, + chain: boundary.chain, + store: new FileRegistrationStore(registrationsPath), + journal: new FileOperationJournal(pendingTransactionsPath), + }); + for (const line of renderCheckReport(report)) console.log(line); } -async function demo() { - const account = getAccount(); +async function demo(): Promise { + const [ + { runDemo }, + { HttpMetadataProvider }, + { FileRegistrationStore }, + boundary, + ] = await Promise.all([ + import("./demo"), + import("./metadata"), + import("./registrations"), + storyBoundary(), + ]); const journal = new FileOperationJournal(pendingTransactionsPath); const manifest = await journal.withExclusiveLease((leasedJournal) => runDemo({ - wallet: account.address, - chain: storyChain(), + wallet: boundary.account.address, + chain: boundary.chain, metadata: new HttpMetadataProvider(), store: new FileRegistrationStore(registrationsPath), journal: leasedJournal, })); - console.log("✓ Phase 0 provenance demo status:", manifest.status); + console.log("✓ Phase 0 wallet-attested registration status:", manifest.status); + console.log("evidence level : wallet_asserted"); + console.log("scope : wallet registration + declared Derivative ancestry; not authorship, originality, or safety"); console.log("wallet :", manifest.wallet); console.log("spgNftContract:", manifest.spgNftContract); for (const stage of ["root", "child", "grandchild"] as const) { const registration = manifest.registrations[stage]; console.log(`${stage.padEnd(10)}:`, registration?.ipId ?? "not registered"); } - console.log("proof artifact:", registrationsPath); - return manifest; + console.log("registration evidence artifact:", registrationsPath); +} + +async function recoverStaleLock(expectedLeaseId: string): Promise { + const journal = new FileOperationJournal(pendingTransactionsPath); + await journal.recoverStaleLock({ expectedLeaseId }); + console.log(`Recovered stale journal lock ${pendingTransactionsPath}.lock for lease ${expectedLeaseId}`); +} + +export interface CommandDependencies { + check(): Promise; + demo(): Promise; + recoverStaleLock(expectedLeaseId: string): Promise; + log(line?: string): void; } -const commands: Record Promise> = { check, demo }; +const REAL_DEPENDENCIES: CommandDependencies = { + check, + demo, + recoverStaleLock, + log: (line = "") => console.log(line), +}; -async function main() { - const run = command ? commands[command] : undefined; - if (!run) { - console.log("Phase 0 — Story provenance CLI\n"); - console.log("commands:"); - console.log(" npm run demo"); - console.log(" npm run check"); - process.exit(command ? 1 : 0); +function printHelp(log: CommandDependencies["log"]): void { + log("Phase 0 — wallet-attested Story Aeneid registration CLI"); + log(); + log("commands:"); + log(" npm run demo"); + log(" npm run check"); + log(" npm run recover-stale-lock -- "); +} + +export async function runCommand( + positionals: readonly string[], + dependencies: CommandDependencies = REAL_DEPENDENCIES, +): Promise { + const [command, ...args] = positionals; + if (!command) { + printHelp(dependencies.log); + return 0; + } + if (command === "check") { + if (args.length !== 0) throw new Error("Usage: npm run check"); + await dependencies.check(); + return 0; + } + if (command === "demo") { + if (args.length !== 0) throw new Error("Usage: npm run demo"); + await dependencies.demo(); + return 0; + } + if (command === "recover-stale-lock") { + if (args.length !== 1) { + throw new Error("Usage: npm run recover-stale-lock -- "); + } + if (!/^[0-9a-f]{32}$/.test(args[0])) { + throw new Error("Usage: npm run recover-stale-lock -- "); + } + await dependencies.recoverStaleLock(args[0]); + dependencies.log(`Stale-lock recovery complete for lease ${args[0]}`); + return 0; } - await run(); + printHelp(dependencies.log); + return 1; } -main().catch((error) => { - console.error("\n✗ " + (error instanceof Error ? error.message : String(error))); - process.exit(1); -}); +async function main(): Promise { + const { positionals } = parseArgs({ allowPositionals: true, strict: true }); + process.exitCode = await runCommand(positionals); +} + +const invokedPath = process.argv[1]; +if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { + main().catch((error) => { + console.error("\n✗ " + (error instanceof Error ? error.message : String(error))); + process.exitCode = 1; + }); +} diff --git a/phase0/tests/check.test.ts b/phase0/tests/check.test.ts new file mode 100644 index 0000000..4800737 --- /dev/null +++ b/phase0/tests/check.test.ts @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { WIP_TOKEN_ADDRESS } from "@story-protocol/core-sdk"; + +import { buildCheckReport, type CheckChain } from "../src/check"; +import { + AENEID_CHAIN_ID, + DEMO_ROOT_MINTING_FEE, + type DerivativeFeeReadiness, +} from "../src/demo"; +import { + createEmptyRegistrationManifest, + type DemoStage, + type RegistrationManifest, + type RegistrationProof, + type RegistrationStore, +} from "../src/registrations"; +import type { + LeasedOperationJournal, + OperationJournal, + PendingOperation, +} from "../src/transactions"; + +const WALLET = "0x00000000000000000000000000000000000000aa" as const; +const OTHER_WALLET = "0x00000000000000000000000000000000000000bb" as const; +const COLLECTION = "0x00000000000000000000000000000000000000cc" as const; +const WIP_SPENDER = "0x00000000000000000000000000000000000000dd" as const; +const HASH = `0x${"11".repeat(32)}` as const; +const IP_IDS = { + root: "0x0000000000000000000000000000000000000011", + child: "0x0000000000000000000000000000000000000022", + grandchild: "0x0000000000000000000000000000000000000033", +} as const; + +function proof(stage: DemoStage): RegistrationProof { + const parentIpIds = stage === "root" + ? [] + : [stage === "child" ? IP_IDS.root : IP_IDS.child]; + return { + stage, + kind: stage === "root" ? "Skill" : "Derivative", + name: `${stage}-fixture`, + ipId: IP_IDS[stage], + tokenId: "1", + txHash: HASH, + licenseTermsId: "7", + licenseTemplate: COLLECTION, + parentIpIds, + defaultMintingFee: stage === "root" ? "1000000000000000" : null, + maxMintingFee: stage === "root" ? null : "1000000000000000", + metadata: { + ip: { uri: "https://gateway.pinata.cloud/ipfs/bafyipfixture123", hash: HASH }, + nft: { uri: "https://gateway.pinata.cloud/ipfs/bafynftfixture123", hash: HASH }, + artifact: { path: `fixtures/${stage}/SKILL.md`, mediaHash: HASH, mediaType: "text/markdown" }, + }, + }; +} + +function manifestThrough(stage?: DemoStage): RegistrationManifest { + const manifest = createEmptyRegistrationManifest(); + const stages: DemoStage[] = stage === "grandchild" + ? ["root", "child", "grandchild"] + : stage === "child" + ? ["root", "child"] + : stage === "root" + ? ["root"] + : []; + if (stage || stages.length > 0) { + manifest.wallet = WALLET; + manifest.spgNftContract = COLLECTION; + manifest.collectionTxHash = HASH; + } + for (const value of stages) manifest.registrations[value] = proof(value); + manifest.status = stages.length === 3 ? "complete" : stages.length > 0 || stage ? "partial" : "not-run"; + return manifest; +} + +function collectionOnlyManifest(): RegistrationManifest { + const manifest = createEmptyRegistrationManifest(); + manifest.status = "partial"; + manifest.wallet = WALLET; + manifest.spgNftContract = COLLECTION; + manifest.collectionTxHash = HASH; + return manifest; +} + +class MemoryStore implements RegistrationStore { + saveCalls = 0; + constructor(private readonly manifest: RegistrationManifest) {} + async load() { return structuredClone(this.manifest); } + async save() { this.saveCalls += 1; throw new Error("check must not save"); } +} + +class MemoryJournal implements OperationJournal { + saveCalls = 0; + clearCalls = 0; + leaseCalls = 0; + constructor(private readonly operation: PendingOperation | null = null) {} + async withExclusiveLease(callback: (journal: LeasedOperationJournal) => Promise): Promise { + this.leaseCalls += 1; + return callback({ + load: async () => ({ revision: this.operation ? 1 : 0, operation: structuredClone(this.operation) }), + save: async () => { this.saveCalls += 1; throw new Error("check must not save"); }, + clear: async () => { this.clearCalls += 1; throw new Error("check must not clear"); }, + }); + } +} + +class FakeChain implements CheckChain { + chainId: number = AENEID_CHAIN_ID; + balance = 10n ** 20n; + gasPrice = 1_000_000_000n; + wipBalance = DEMO_ROOT_MINTING_FEE; + wipAllowance = DEMO_ROOT_MINTING_FEE; + chainReads = 0; + balanceReads = 0; + gasPriceReads = 0; + wipReads = 0; + async getChainId() { this.chainReads += 1; return this.chainId; } + async getBalance() { this.balanceReads += 1; return this.balance; } + async getGasPrice() { this.gasPriceReads += 1; return this.gasPrice; } + async getDerivativeFeeReadiness(input: { + wallet: `0x${string}`; + currencyToken: `0x${string}`; + requiredAmount: bigint; + }): Promise { + this.wipReads += 1; + assert.equal(input.wallet, WALLET); + assert.equal(input.currencyToken, WIP_TOKEN_ADDRESS); + assert.equal(input.requiredAmount, DEMO_ROOT_MINTING_FEE); + return { + currencyToken: WIP_TOKEN_ADDRESS, + spender: WIP_SPENDER, + requiredAmount: DEMO_ROOT_MINTING_FEE, + balance: this.wipBalance, + allowance: this.wipAllowance, + }; + } +} + +function pending(stage: PendingOperation["stage"] = "child"): PendingOperation { + return { + schemaVersion: 1, + operationId: "fixture-operation", + stage, + intent: { + stage, + chainId: AENEID_CHAIN_ID, + wallet: WALLET, + registrationName: null, + artifactPath: null, + spgNftContract: COLLECTION, + parentIpId: IP_IDS.root, + licenseTermsId: "7", + licenseTemplate: COLLECTION, + currencyToken: WIP_TOKEN_ADDRESS, + defaultMintingFee: null, + maxMintingFee: DEMO_ROOT_MINTING_FEE.toString(), + metadata: null, + runConfigHash: HASH, + }, + intentHash: HASH, + transactionHash: HASH, + serializedTransaction: "0x11", + state: "broadcast", + }; +} + +async function report(manifest: RegistrationManifest, chain = new FakeChain(), journal = new MemoryJournal()) { + const store = new MemoryStore(manifest); + return { + value: await buildCheckReport({ wallet: WALLET, chain, store, journal }), + chain, + journal, + store, + }; +} + +test("four missing writes read native gas once and defer WIP until a Derivative", async () => { + const result = await report(manifestThrough()); + assert.equal(result.value.remainingNewWrites, 4); + assert.equal(result.value.nativeGasReady, "yes"); + assert.equal(result.value.nextDerivativeWipReady, "not applicable until the next new Derivative"); + assert.equal(result.chain.balanceReads, 1); + assert.equal(result.chain.gasPriceReads, 1); + assert.equal(result.chain.wipReads, 0); +}); + +test("a confirmed collection leaves root next and performs no WIP read", async () => { + const result = await report(collectionOnlyManifest()); + assert.equal(result.value.remainingNewWrites, 3); + assert.equal(result.chain.wipReads, 0); +}); + +for (const [stage, remaining] of [["root", 2], ["child", 1]] as const) { + test(`${stage}-confirmed state checks the next Derivative WIP domain exactly once`, async () => { + const result = await report(manifestThrough(stage)); + assert.equal(result.value.remainingNewWrites, remaining); + assert.equal(result.value.derivativeFeeToken, WIP_TOKEN_ADDRESS); + assert.equal(result.value.configuredFeeEstimate, "0.001 WIP per Derivative"); + assert.equal(result.value.nextDerivativeWipReady, "yes"); + assert.equal(result.chain.wipReads, 1); + }); +} + +test("native and WIP insufficiency remain independent diagnostics", async () => { + const chain = new FakeChain(); + chain.balance = 1n; + chain.wipBalance = 0n; + const result = await report(manifestThrough("root"), chain); + assert.equal(result.value.nativeGasReady, "no"); + assert.equal(result.value.nextDerivativeWipReady, "no"); + assert.equal(result.chain.balanceReads, 1); + assert.equal(result.chain.wipReads, 1); +}); + +test("malformed WIP readiness fails closed", async () => { + const chain = new FakeChain(); + chain.getDerivativeFeeReadiness = async () => ({ + currencyToken: WIP_TOKEN_ADDRESS, + spender: "0x0000000000000000000000000000000000000000", + requiredAmount: DEMO_ROOT_MINTING_FEE, + balance: DEMO_ROOT_MINTING_FEE, + allowance: DEMO_ROOT_MINTING_FEE, + }); + await assert.rejects(report(manifestThrough("root"), chain), /malformed workflows spender/i); +}); + +test("a complete manifest performs zero native, gas, or WIP reads", async () => { + const result = await report(manifestThrough("grandchild")); + assert.equal(result.value.remainingNewWrites, 0); + assert.equal(result.value.nativeIpBalance, "not read (no remaining new writes)"); + assert.equal(result.value.estimatedGasMinimum, "0 IP"); + assert.equal(result.value.nativeGasReady, "yes"); + assert.equal(result.chain.balanceReads, 0); + assert.equal(result.chain.gasPriceReads, 0); + assert.equal(result.chain.wipReads, 0); +}); + +test("pending exact-hash recovery defers every readiness read and never mutates state", async () => { + const chain = new FakeChain(); + const journal = new MemoryJournal(pending()); + const result = await report(manifestThrough("root"), chain, journal); + assert.equal(result.value.pendingRecovery, `required (child, ${HASH})`); + assert.equal(result.value.nativeIpBalance, "deferred until exact-hash recovery"); + assert.equal(result.value.nextDerivativeWipReady, "deferred until exact-hash recovery"); + assert.equal(chain.chainReads, 0); + assert.equal(chain.balanceReads, 0); + assert.equal(chain.gasPriceReads, 0); + assert.equal(chain.wipReads, 0); + assert.equal(journal.saveCalls, 0); + assert.equal(journal.clearCalls, 0); + assert.equal(result.store.saveCalls, 0); +}); + +test("wrong chain and wallet mismatch reject before readiness", async () => { + const wrongChain = new FakeChain(); + wrongChain.chainId = 1514; + await assert.rejects(report(manifestThrough(), wrongChain), /Aeneid.*1315/i); + assert.equal(wrongChain.balanceReads, 0); + assert.equal(wrongChain.gasPriceReads, 0); + + const mismatched = manifestThrough("root"); + mismatched.wallet = OTHER_WALLET; + const chain = new FakeChain(); + await assert.rejects(report(mismatched, chain), /another wallet/i); + assert.equal(chain.balanceReads, 0); + assert.equal(chain.gasPriceReads, 0); + assert.equal(chain.wipReads, 0); +}); + +test("malformed manifests fail before chain readiness", async () => { + const malformed = manifestThrough("root"); + malformed.registrations.root!.parentIpIds = [IP_IDS.child]; + const chain = new FakeChain(); + await assert.rejects(report(malformed, chain), /root.*parent/i); + assert.equal(chain.chainReads, 0); + assert.equal(chain.balanceReads, 0); +}); diff --git a/phase0/tests/index.test.ts b/phase0/tests/index.test.ts new file mode 100644 index 0000000..3656476 --- /dev/null +++ b/phase0/tests/index.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runCommand, type CommandDependencies } from "../src/index"; + +const LEASE_ID = "0123456789abcdef0123456789abcdef"; + +function dependencies() { + const calls = { check: 0, demo: 0, recover: [] as string[] }; + const lines: string[] = []; + const deps: CommandDependencies = { + check: async () => { calls.check += 1; }, + demo: async () => { calls.demo += 1; }, + recoverStaleLock: async (expectedLeaseId) => { calls.recover.push(expectedLeaseId); }, + log: (line = "") => { lines.push(line); }, + }; + return { calls, lines, deps }; +} + +test("stale-lock recovery routes exactly one unchanged lease ID and nothing else", async () => { + const fixture = dependencies(); + const exitCode = await runCommand(["recover-stale-lock", LEASE_ID], fixture.deps); + assert.equal(exitCode, 0); + assert.deepEqual(fixture.calls, { check: 0, demo: 0, recover: [LEASE_ID] }); + assert.match(fixture.lines.join("\n"), new RegExp(LEASE_ID)); +}); + +for (const args of [ + ["recover-stale-lock"], + ["recover-stale-lock", "not-a-lease-id"], + ["recover-stale-lock", LEASE_ID.toUpperCase()], + ["recover-stale-lock", LEASE_ID, "extra"], +] as const) { + test(`recovery rejects invalid arity: ${args.join(" ")}`, async () => { + const fixture = dependencies(); + await assert.rejects(runCommand([...args], fixture.deps), /npm run recover-stale-lock -- /); + assert.deepEqual(fixture.calls, { check: 0, demo: 0, recover: [] }); + }); +} + +test("help exposes only demo, check, and explicit stale-lock recovery", async () => { + const fixture = dependencies(); + assert.equal(await runCommand([], fixture.deps), 0); + const output = fixture.lines.join("\n"); + assert.match(output, /npm run demo/); + assert.match(output, /npm run check/); + assert.match(output, /npm run recover-stale-lock/); + assert.doesNotMatch(output, /create-collection|register-skill|register-derivative/); +}); + +for (const retired of ["create-collection", "register-skill", "register-derivative"] as const) { + test(`retired ${retired} route stays rejected`, async () => { + const fixture = dependencies(); + assert.equal(await runCommand([retired], fixture.deps), 1); + assert.deepEqual(fixture.calls, { check: 0, demo: 0, recover: [] }); + }); +} From 0f46860326ec98e94bc90086db5ad5cb6400613c Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 02:33:54 -0400 Subject: [PATCH 056/165] feat: add atomic USDC boundary --- prototype/atomic-money.mjs | 55 +++++++++++++++++++++++++++ prototype/package.json | 5 ++- prototype/tests/atomic-money.test.mjs | 54 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 prototype/atomic-money.mjs create mode 100644 prototype/tests/atomic-money.test.mjs diff --git a/prototype/atomic-money.mjs b/prototype/atomic-money.mjs new file mode 100644 index 0000000..3800004 --- /dev/null +++ b/prototype/atomic-money.mjs @@ -0,0 +1,55 @@ +export const USDC_DECIMALS = 6; +export const ATOMIC_PER_USDC = 10n ** BigInt(USDC_DECIMALS); +export const BPS_DENOMINATOR = 10_000n; + +export class MoneyInputError extends RangeError { + constructor(code, message) { + super(message); + this.name = 'MoneyInputError'; + this.code = code; + } +} + +function fail(code, message) { + throw new MoneyInputError(code, message); +} + +export function assertAtomic(value, label = 'amountAtomic') { + if (typeof value !== 'bigint') fail('ATOMIC_TYPE', `${label} must be a bigint`); + if (value < 0n) fail('ATOMIC_NEGATIVE', `${label} must be non-negative`); + return value; +} + +export function assertBps(value, label = 'bps') { + if (!Number.isSafeInteger(value)) fail('BPS_INTEGER', `${label} must be a safe integer`); + if (value < 0 || value > 10_000) { + fail('BPS_RANGE', `${label} must be between 0 and 10000`); + } + return BigInt(value); +} + +export function parseUsdc(value, label = 'USDC amount') { + if (typeof value !== 'string') fail('DISPLAY_TYPE', `${label} must be a decimal string`); + const text = value.trim(); + const match = /^(0|[1-9]\d*)(?:\.(\d{1,6}))?$/.exec(text); + if (!match) { + fail( + 'DISPLAY_FORMAT', + `${label} must be a non-negative decimal with at most six fractional digits`, + ); + } + const whole = BigInt(match[1]); + const fraction = BigInt((match[2] ?? '').padEnd(USDC_DECIMALS, '0') || '0'); + return whole * ATOMIC_PER_USDC + fraction; +} + +export function formatUsdc(value) { + const atomic = assertAtomic(value); + const whole = atomic / ATOMIC_PER_USDC; + const fraction = String(atomic % ATOMIC_PER_USDC).padStart(USDC_DECIMALS, '0'); + return `${whole}.${fraction}`; +} + +export function floorBps(amountAtomic, bps) { + return (assertAtomic(amountAtomic) * assertBps(bps)) / BPS_DENOMINATOR; +} diff --git a/prototype/package.json b/prototype/package.json index fd8a88a..b5538a5 100644 --- a/prototype/package.json +++ b/prototype/package.json @@ -1,10 +1,11 @@ { "name": "prototype", "version": "1.0.0", - "description": "> **Throwaway.** This exists to answer one question, then be deleted or absorbed. > `settlement-engine.mjs` is the keeper (pure logic); `settlement-tui.mjs` is the disposable shell.", + "description": "Offline settlement-economics spikes and exact atomic-money allocation kernel.", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test tests/*.test.mjs", + "test:fork-economics": "node spike-fork-economics.mjs" }, "keywords": [], "author": "", diff --git a/prototype/tests/atomic-money.test.mjs b/prototype/tests/atomic-money.test.mjs new file mode 100644 index 0000000..3a80754 --- /dev/null +++ b/prototype/tests/atomic-money.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ATOMIC_PER_USDC, + BPS_DENOMINATOR, + assertAtomic, + floorBps, + formatUsdc, + parseUsdc, +} from '../atomic-money.mjs'; + +test('parseUsdc converts exact display values to six-decimal atomic units', () => { + assert.equal(ATOMIC_PER_USDC, 1_000_000n); + assert.equal(BPS_DENOMINATOR, 10_000n); + assert.equal(parseUsdc('0'), 0n); + assert.equal(parseUsdc('0.000001'), 1n); + assert.equal(parseUsdc('0.25'), 250_000n); + assert.equal(parseUsdc('9007199254740991.123456'), 9_007_199_254_740_991_123_456n); +}); + +test('parseUsdc rejects every non-string, negative, exponent, and over-precision input', () => { + for (const value of [ + '-1', '0.0000001', '1e-6', '', 0.25, 0, Number.NaN, Number.POSITIVE_INFINITY, + -0.01, 9_007_199_254.740992, 1n, null, + ]) { + assert.throws(() => parseUsdc(value), (error) => error?.name === 'MoneyInputError'); + } +}); + +test('assertAtomic accepts only non-negative bigint values', () => { + assert.equal(assertAtomic(0n), 0n); + assert.equal(assertAtomic(7n), 7n); + assert.throws(() => assertAtomic(-1n), /must be non-negative/); + for (const value of [1, '1', null, undefined]) { + assert.throws(() => assertAtomic(value), /must be a bigint/); + } +}); + +test('formatUsdc is a canonical six-decimal serialization boundary', () => { + assert.equal(formatUsdc(0n), '0.000000'); + assert.equal(formatUsdc(1n), '0.000001'); + assert.equal(formatUsdc(250_000n), '0.250000'); + assert.equal(formatUsdc(1_000_001n), '1.000001'); +}); + +test('floorBps floors deterministically without using floating point', () => { + assert.equal(floorBps(250_000n, 250), 6_250n); + assert.equal(floorBps(1n, 5_000), 0n); + assert.equal(floorBps(3n, 5_000), 1n); + assert.throws(() => floorBps(1n, -1), /between 0 and 10000/); + assert.throws(() => floorBps(1n, 10_001), /between 0 and 10000/); + assert.throws(() => floorBps(1n, 1.5), /safe integer/); +}); From 03d54511cd31c2ab73e27170088e569d653dbc0c Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 02:34:49 -0400 Subject: [PATCH 057/165] feat: conserve weighted atomic allocations --- prototype/atomic-money.mjs | 73 +++++++++++++++++++++++++++ prototype/tests/atomic-money.test.mjs | 52 +++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/prototype/atomic-money.mjs b/prototype/atomic-money.mjs index 3800004..e75ea6c 100644 --- a/prototype/atomic-money.mjs +++ b/prototype/atomic-money.mjs @@ -53,3 +53,76 @@ export function formatUsdc(value) { export function floorBps(amountAtomic, bps) { return (assertAtomic(amountAtomic) * assertBps(bps)) / BPS_DENOMINATOR; } + +function weightToBigInt(value, label) { + if (typeof value === 'bigint') { + if (value < 0n) fail('WEIGHT_NEGATIVE', `${label} must be non-negative`); + return value; + } + if (!Number.isSafeInteger(value) || value < 0) { + fail('WEIGHT_INTEGER', `${label} must be a non-negative safe integer or bigint`); + } + return BigInt(value); +} + +function compareKeys(left, right) { + if (left.key < right.key) return -1; + if (left.key > right.key) return 1; + return 0; +} + +export function allocateByWeights(amountAtomic, shares) { + const amount = assertAtomic(amountAtomic); + if (!Array.isArray(shares) || shares.length === 0) { + fail('ALLOCATIONS_EMPTY', 'shares must contain at least one allocation'); + } + + const seen = new Set(); + const rows = shares.map((share, index) => { + const key = String(share?.key ?? ''); + if (!key) fail('ALLOCATION_KEY', `shares[${index}].key must be non-empty`); + if (seen.has(key)) fail('ALLOCATION_DUPLICATE', `duplicate allocation key '${key}'`); + seen.add(key); + return { key, weight: weightToBigInt(share.weight, `shares[${index}].weight`) }; + }).sort(compareKeys); + + const totalWeight = rows.reduce((sum, row) => sum + row.weight, 0n); + if (totalWeight === 0n) { + fail('WEIGHTS_ZERO', 'at least one allocation weight must be positive'); + } + + const allocations = rows.map((row) => ({ + key: row.key, + weight: row.weight, + amountAtomic: (amount * row.weight) / totalWeight, + })); + let remainder = amount - allocations.reduce((sum, row) => sum + row.amountAtomic, 0n); + for (const row of allocations) { + if (remainder === 0n) break; + if (row.weight === 0n) continue; + row.amountAtomic += 1n; + remainder -= 1n; + } + if (remainder !== 0n) { + throw new Error('internal invariant: weighted remainder was not exhausted'); + } + return allocations.map(({ key, amountAtomic: allocated }) => ({ + key, + amountAtomic: allocated, + })); +} + +export function allocateByBps(amountAtomic, shares) { + if (!Array.isArray(shares) || shares.length === 0) { + fail('ALLOCATIONS_EMPTY', 'shares must contain at least one allocation'); + } + const normalized = shares.map((share, index) => ({ + key: share?.key, + weight: assertBps(share?.bps, `shares[${index}].bps`), + })); + const total = normalized.reduce((sum, row) => sum + row.weight, 0n); + if (total !== BPS_DENOMINATOR) { + fail('BPS_TOTAL', `basis-point allocations must sum to 10000 (got ${total})`); + } + return allocateByWeights(amountAtomic, normalized); +} diff --git a/prototype/tests/atomic-money.test.mjs b/prototype/tests/atomic-money.test.mjs index 3a80754..0656ac3 100644 --- a/prototype/tests/atomic-money.test.mjs +++ b/prototype/tests/atomic-money.test.mjs @@ -4,6 +4,8 @@ import test from 'node:test'; import { ATOMIC_PER_USDC, BPS_DENOMINATOR, + allocateByBps, + allocateByWeights, assertAtomic, floorBps, formatUsdc, @@ -52,3 +54,53 @@ test('floorBps floors deterministically without using floating point', () => { assert.throws(() => floorBps(1n, 10_001), /between 0 and 10000/); assert.throws(() => floorBps(1n, 1.5), /safe integer/); }); + +test('allocateByWeights conserves atomic units and assigns remainder by stable key', () => { + const shares = [ + { key: 'zoe', weight: 1 }, + { key: 'alice', weight: 1 }, + { key: 'mika', weight: 1 }, + ]; + assert.deepEqual(allocateByWeights(10_000n, shares), [ + { key: 'alice', amountAtomic: 3_334n }, + { key: 'mika', amountAtomic: 3_333n }, + { key: 'zoe', amountAtomic: 3_333n }, + ]); + assert.deepEqual(allocateByWeights(1n, [...shares].reverse()), [ + { key: 'alice', amountAtomic: 1n }, + { key: 'mika', amountAtomic: 0n }, + { key: 'zoe', amountAtomic: 0n }, + ]); +}); + +test('allocateByBps requires one complete, unique 10000-bps claim table', () => { + assert.deepEqual(allocateByBps(1n, [ + { key: 'creator', bps: 5_000 }, + { key: 'employer', bps: 5_000 }, + ]), [ + { key: 'creator', amountAtomic: 1n }, + { key: 'employer', amountAtomic: 0n }, + ]); + assert.throws( + () => allocateByBps(100n, [{ key: 'creator', bps: 9_999 }]), + /sum to 10000/, + ); + assert.throws(() => allocateByBps(100n, [ + { key: 'creator', bps: 5_000 }, + { key: 'creator', bps: 5_000 }, + ]), /duplicate allocation key/); +}); + +test('allocators reject unsafe values and do not mutate caller-owned frozen inputs', () => { + const shares = Object.freeze([ + Object.freeze({ key: 'b', weight: 1 }), + Object.freeze({ key: 'a', weight: 2 }), + ]); + allocateByWeights(7n, shares); + assert.deepEqual(shares, [{ key: 'b', weight: 1 }, { key: 'a', weight: 2 }]); + + assert.throws(() => allocateByWeights(-1n, shares), /must be non-negative/); + assert.throws(() => allocateByWeights(1, shares), /must be a bigint/); + assert.throws(() => allocateByWeights(1n, [{ key: 'a', weight: -1 }]), /non-negative/); + assert.throws(() => allocateByWeights(1n, [{ key: 'a', weight: 0 }]), /must be positive/); +}); From e7a778f9db3be9832142dc2cb55830b842205a6d Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 02:37:11 -0400 Subject: [PATCH 058/165] feat: allocate atomic royalties through ancestry --- prototype/atomic-money.mjs | 143 +++++++++++++++++++ prototype/tests/atomic-money.test.mjs | 190 ++++++++++++++++++++++++++ 2 files changed, 333 insertions(+) diff --git a/prototype/atomic-money.mjs b/prototype/atomic-money.mjs index e75ea6c..b5db724 100644 --- a/prototype/atomic-money.mjs +++ b/prototype/atomic-money.mjs @@ -1,6 +1,11 @@ export const USDC_DECIMALS = 6; export const ATOMIC_PER_USDC = 10n ** BigInt(USDC_DECIMALS); export const BPS_DENOMINATOR = 10_000n; +export const ROYALTY_ALLOCATION_POLICY = 'lrp-per-hop-v1'; + +const MAX_ANCESTRY_DEPTH = 32; +const MAX_REACHABLE_SKILLS = 128; +const MAX_DISTRIBUTION_VISITS = 1_024; export class MoneyInputError extends RangeError { constructor(code, message) { @@ -126,3 +131,141 @@ export function allocateByBps(amountAtomic, shares) { } return allocateByWeights(amountAtomic, normalized); } + +export function allocateRoyaltyGraph({ + royaltyPoolAtomic, + leafSkillId, + skills, + allocationPolicy = ROYALTY_ALLOCATION_POLICY, +}) { + const pool = assertAtomic(royaltyPoolAtomic, 'royaltyPoolAtomic'); + if (allocationPolicy !== ROYALTY_ALLOCATION_POLICY) { + fail( + 'ALLOCATION_POLICY_UNSUPPORTED', + `unsupported royalty allocation policy '${String(allocationPolicy)}'; ` + + `only '${ROYALTY_ALLOCATION_POLICY}' is implemented`, + ); + } + if (!skills || typeof skills !== 'object' || Array.isArray(skills)) { + fail('SKILLS_TYPE', 'skills must be an object keyed by Skill identifier'); + } + + function requireSkill(skillId) { + if (!Object.hasOwn(skills, skillId) || !skills[skillId] || typeof skills[skillId] !== 'object') { + fail('SKILL_UNKNOWN', `unknown Skill '${skillId}'`); + } + return skills[skillId]; + } + + function normalizedNode(skillId) { + const skill = requireSkill(skillId); + if (!Array.isArray(skill.parentIds)) { + fail('PARENTS_TYPE', `Skill '${skillId}' parentIds must be an array`); + } + if (!Array.isArray(skill.holders)) { + fail('HOLDERS_TYPE', `Skill '${skillId}' holders must be an array`); + } + const parentIds = skill.parentIds.map(String).sort(); + if (new Set(parentIds).size !== parentIds.length) { + fail('PARENT_DUPLICATE', `Skill '${skillId}' has a duplicate parent`); + } + assertBps(skill.inheritBps, `${skillId}.inheritBps`); + const inheritBps = skill.inheritBps; + const holders = skill.holders.map((holder) => ({ + key: holder?.recipientId, + bps: holder?.bps, + })); + // Validate the complete claim table even if this traversal carries zero atomic units. + allocateByBps(0n, holders); + return { parentIds, inheritBps, holders }; + } + + const deepestValidatedDepth = new Map(); + const validating = new Set(); + const reachableNodes = new Set(); + function validateReachable(skillId, depth) { + if (depth > MAX_ANCESTRY_DEPTH) { + fail('ANCESTRY_DEPTH', `Derivative ancestry exceeds maximum depth ${MAX_ANCESTRY_DEPTH}`); + } + if (validating.has(skillId)) { + fail('ANCESTRY_CYCLE', `ancestry cycle contains Skill '${skillId}'`); + } + const priorDepth = deepestValidatedDepth.get(skillId); + // A prior visit at an equal or greater depth had no more remaining depth budget. + if (priorDepth != null && priorDepth >= depth) return; + + const node = normalizedNode(skillId); + reachableNodes.add(skillId); + if (reachableNodes.size > MAX_REACHABLE_SKILLS) { + fail( + 'ANCESTRY_NODES', + `Derivative ancestry exceeds maximum ${MAX_REACHABLE_SKILLS} reachable Skills`, + ); + } + validating.add(skillId); + for (const parentId of node.parentIds) validateReachable(parentId, depth + 1); + validating.delete(skillId); + deepestValidatedDepth.set(skillId, Math.max(priorDepth ?? -1, depth)); + } + + const leaf = String(leafSkillId ?? ''); + validateReachable(leaf, 0); + + const credits = []; + const visiting = new Set(); + let distributionVisits = 0; + function distribute(skillId, amountAtomic, depth) { + distributionVisits += 1; + if (distributionVisits > MAX_DISTRIBUTION_VISITS) { + fail( + 'ANCESTRY_VISITS', + `Derivative ancestry distribution exceeds maximum ${MAX_DISTRIBUTION_VISITS} visits`, + ); + } + if (depth > MAX_ANCESTRY_DEPTH) { + fail('ANCESTRY_DEPTH', `Derivative ancestry exceeds maximum depth ${MAX_ANCESTRY_DEPTH}`); + } + if (visiting.has(skillId)) { + fail('ANCESTRY_CYCLE', `ancestry cycle contains Skill '${skillId}'`); + } + visiting.add(skillId); + + const node = normalizedNode(skillId); + const ancestorPool = node.parentIds.length + ? floorBps(amountAtomic, node.inheritBps) + : 0n; + const ownPool = amountAtomic - ancestorPool; + const holderRows = allocateByBps(ownPool, node.holders); + for (const row of holderRows) { + credits.push({ + recipientId: row.key, + viaSkillId: skillId, + depth, + kind: depth === 0 ? 'holder' : 'ancestor', + amountAtomic: row.amountAtomic, + }); + } + + if (node.parentIds.length && ancestorPool > 0n) { + const parentRows = allocateByWeights( + ancestorPool, + node.parentIds.map((parentId) => ({ key: parentId, weight: 1 })), + ); + for (const row of parentRows) distribute(row.key, row.amountAtomic, depth + 1); + } + visiting.delete(skillId); + } + + distribute(leaf, pool, 0); + const credited = credits.reduce((sum, credit) => sum + credit.amountAtomic, 0n); + if (credited !== pool) { + throw new Error(`internal invariant: credits ${credited} do not equal Royalty pool ${pool}`); + } + return { + allocationPolicy, + royaltyPoolAtomic: pool, + credits, + holderCredits: credits.filter((credit) => credit.kind === 'holder'), + ancestorCredits: credits.filter((credit) => credit.kind === 'ancestor'), + }; +} diff --git a/prototype/tests/atomic-money.test.mjs b/prototype/tests/atomic-money.test.mjs index 0656ac3..79168b5 100644 --- a/prototype/tests/atomic-money.test.mjs +++ b/prototype/tests/atomic-money.test.mjs @@ -4,8 +4,10 @@ import test from 'node:test'; import { ATOMIC_PER_USDC, BPS_DENOMINATOR, + ROYALTY_ALLOCATION_POLICY, allocateByBps, allocateByWeights, + allocateRoyaltyGraph, assertAtomic, floorBps, formatUsdc, @@ -104,3 +106,191 @@ test('allocators reject unsafe values and do not mutate caller-owned frozen inpu assert.throws(() => allocateByWeights(1n, [{ key: 'a', weight: -1 }]), /non-negative/); assert.throws(() => allocateByWeights(1n, [{ key: 'a', weight: 0 }]), /must be positive/); }); + +const chain = (depth, inheritBps = 3_000) => Object.fromEntries( + Array.from({ length: depth + 1 }, (_, index) => [`skill-${index}`, { + parentIds: index === 0 ? [] : [`skill-${index - 1}`], + inheritBps, + holders: [{ recipientId: `creator-${index}`, bps: 10_000 }], + }]), +); + +test('allocateRoyaltyGraph conserves one-atomic remainders at every ancestry depth', () => { + for (let depth = 0; depth <= 4; depth += 1) { + for (const royaltyPoolAtomic of [0n, 1n, 2n, 9_999n, 250_001n]) { + const result = allocateRoyaltyGraph({ + royaltyPoolAtomic, + leafSkillId: `skill-${depth}`, + skills: chain(depth), + }); + assert.equal(result.allocationPolicy, ROYALTY_ALLOCATION_POLICY); + assert.equal( + result.credits.reduce((sum, credit) => sum + credit.amountAtomic, 0n), + royaltyPoolAtomic, + ); + assert.equal( + result.holderCredits.reduce((sum, credit) => sum + credit.amountAtomic, 0n) + + result.ancestorCredits.reduce((sum, credit) => sum + credit.amountAtomic, 0n), + royaltyPoolAtomic, + ); + } + } +}); + +test('allocateRoyaltyGraph names the LRP-like policy and rejects unimplemented LAP allocation', () => { + const result = allocateRoyaltyGraph({ + royaltyPoolAtomic: 1n, + leafSkillId: 'skill-0', + skills: chain(0), + allocationPolicy: 'lrp-per-hop-v1', + }); + assert.equal(result.allocationPolicy, 'lrp-per-hop-v1'); + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic: 1n, + leafSkillId: 'skill-0', + skills: chain(0), + allocationPolicy: 'lap-whole-ancestry-v1', + }), /unsupported royalty allocation policy/); +}); + +test('allocateRoyaltyGraph splits co-held claims in stable recipient order', () => { + const result = allocateRoyaltyGraph({ + royaltyPoolAtomic: 1n, + leafSkillId: 'root', + skills: { + root: { + parentIds: [], + inheritBps: 0, + holders: [ + { recipientId: 'employee', bps: 5_000 }, + { recipientId: 'employer', bps: 5_000 }, + ], + }, + }, + }); + assert.deepEqual(result.credits, [ + { + recipientId: 'employee', viaSkillId: 'root', depth: 0, + kind: 'holder', amountAtomic: 1n, + }, + { + recipientId: 'employer', viaSkillId: 'root', depth: 0, + kind: 'holder', amountAtomic: 0n, + }, + ]); +}); + +test('allocateRoyaltyGraph rejects missing nodes, duplicate parents, and cycles at zero pools', () => { + for (const royaltyPoolAtomic of [0n, 1n]) { + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic, + leafSkillId: 'missing', + skills: {}, + }), /unknown Skill/); + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic, + leafSkillId: 'a', + skills: { + a: { + parentIds: ['b'], inheritBps: 1_000, + holders: [{ recipientId: 'a', bps: 10_000 }], + }, + b: { + parentIds: ['a'], inheritBps: 1_000, + holders: [{ recipientId: 'b', bps: 10_000 }], + }, + }, + }), /ancestry cycle/); + } + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic: 1n, + leafSkillId: 'leaf', + skills: { + leaf: { + parentIds: ['root', 'root'], inheritBps: 1_000, + holders: [{ recipientId: 'leaf', bps: 10_000 }], + }, + root: { + parentIds: [], inheritBps: 0, + holders: [{ recipientId: 'root', bps: 10_000 }], + }, + }, + }), /duplicate parent/); + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic: 1n, + leafSkillId: 'skill-33', + skills: chain(33), + }), /maximum depth 32/); +}); + +test('shared ancestry cannot hide a path deeper than 32 behind validation memoization', () => { + const holder = (recipientId) => [{ recipientId, bps: 10_000 }]; + const skills = { + leaf: { parentIds: ['a-short', 'b-0'], inheritBps: 5_000, holders: holder('leaf') }, + 'a-short': { parentIds: ['shared'], inheritBps: 10_000, holders: holder('a') }, + shared: { parentIds: ['suffix'], inheritBps: 10_000, holders: holder('shared') }, + suffix: { parentIds: [], inheritBps: 0, holders: holder('suffix') }, + }; + for (let index = 0; index <= 30; index += 1) { + skills[`b-${index}`] = { + parentIds: index === 30 ? ['shared'] : [`b-${index + 1}`], + inheritBps: 10_000, + holders: holder(`b-holder-${index}`), + }; + } + for (const royaltyPoolAtomic of [0n, 1n]) { + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic, + leafSkillId: 'leaf', + skills, + }), /maximum depth 32/); + } +}); + +function expandingSharedDag(stages) { + const holders = (recipientId) => [{ recipientId, bps: 10_000 }]; + const skills = { + leaf: { parentIds: ['left-0', 'right-0'], inheritBps: 10_000, holders: holders('leaf') }, + }; + for (let index = 0; index < stages; index += 1) { + const next = index === stages - 1 ? 'root' : `shared-${index}`; + skills[`left-${index}`] = { + parentIds: [next], inheritBps: 10_000, holders: holders(`left-holder-${index}`), + }; + skills[`right-${index}`] = { + parentIds: [next], inheritBps: 10_000, holders: holders(`right-holder-${index}`), + }; + if (index < stages - 1) { + skills[`shared-${index}`] = { + parentIds: [`left-${index + 1}`, `right-${index + 1}`], + inheritBps: 10_000, + holders: holders(`shared-holder-${index}`), + }; + } + } + skills.root = { parentIds: [], inheritBps: 0, holders: holders('root-holder') }; + return skills; +} + +test('allocateRoyaltyGraph bounds repeated distribution visits in shared DAGs', () => { + const skills = expandingSharedDag(11); + assert.throws(() => allocateRoyaltyGraph({ + royaltyPoolAtomic: 1n << 20n, + leafSkillId: 'leaf', + skills, + }), /distribution exceeds maximum 1024 visits/); +}); + +test('allocateRoyaltyGraph leaves a deeply frozen claim graph unchanged', () => { + const skills = Object.freeze({ + root: Object.freeze({ + parentIds: Object.freeze([]), + inheritBps: 0, + holders: Object.freeze([Object.freeze({ recipientId: 'creator', bps: 10_000 })]), + }), + }); + allocateRoyaltyGraph({ royaltyPoolAtomic: 0n, leafSkillId: 'root', skills }); + assert.deepEqual(skills, { + root: { parentIds: [], inheritBps: 0, holders: [{ recipientId: 'creator', bps: 10_000 }] }, + }); +}); From dcab2cb91ded67469a65f6bec0fd65b9eda27661 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 02:39:12 -0400 Subject: [PATCH 059/165] feat: partition external and internal gross amounts --- prototype/atomic-money.mjs | 112 ++++++++++ prototype/tests/atomic-money.test.mjs | 286 ++++++++++++++++++++++++++ 2 files changed, 398 insertions(+) diff --git a/prototype/atomic-money.mjs b/prototype/atomic-money.mjs index b5db724..34690c6 100644 --- a/prototype/atomic-money.mjs +++ b/prototype/atomic-money.mjs @@ -269,3 +269,115 @@ export function allocateRoyaltyGraph({ ancestorCredits: credits.filter((credit) => credit.kind === 'ancestor'), }; } + +function requireCoveredGross(grossAtomic, components) { + const required = components.reduce((sum, component) => sum + component, 0n); + if (required > grossAtomic) { + fail( + 'GROSS_INSUFFICIENT', + `gross ${grossAtomic} cannot cover costs and reserves ${required}`, + ); + } + return grossAtomic - required; +} + +function journalEntry(category, debitAccountId, creditAccountId, amountAtomic) { + return { + category, + debitAccountId, + creditAccountId, + amountAtomic: assertAtomic(amountAtomic), + }; +} + +export function allocateExternalGross({ + grossAtomic, + executionCostAtomic, + settlementCostAtomic, + protocolFeeBps, + refundReserveAtomic, + leafSkillId, + skills, + allocationPolicy = ROYALTY_ALLOCATION_POLICY, +}) { + const gross = assertAtomic(grossAtomic, 'grossAtomic'); + const executionCost = assertAtomic(executionCostAtomic, 'executionCostAtomic'); + const settlementCost = assertAtomic(settlementCostAtomic, 'settlementCostAtomic'); + const refundReserve = assertAtomic(refundReserveAtomic, 'refundReserveAtomic'); + const protocolFee = floorBps(gross, protocolFeeBps); + const royaltyPool = requireCoveredGross( + gross, + [executionCost, settlementCost, protocolFee, refundReserve], + ); + const royalty = allocateRoyaltyGraph({ + royaltyPoolAtomic: royaltyPool, + leafSkillId, + skills, + allocationPolicy, + }); + const debitAccountId = 'wielder:external-gross'; + const journalEntries = [ + journalEntry('execution-cogs', debitAccountId, 'provider:execution', executionCost), + journalEntry('settlement-cogs', debitAccountId, 'provider:settlement', settlementCost), + journalEntry('protocol-fee', debitAccountId, 'protocol:treasury', protocolFee), + journalEntry('refund-reserve', debitAccountId, 'reserve:refund', refundReserve), + ...royalty.credits.map((credit) => journalEntry( + credit.kind === 'holder' ? 'royalty-holder' : 'royalty-ancestor', + debitAccountId, + `royalty:${credit.recipientId}`, + credit.amountAtomic, + )), + ]; + return { + allocationPolicy: royalty.allocationPolicy, + grossAtomic: gross, + executionCostAtomic: executionCost, + settlementCostAtomic: settlementCost, + protocolFeeAtomic: protocolFee, + royaltyPoolAtomic: royaltyPool, + refundReserveAtomic: refundReserve, + credits: royalty.credits, + holderCredits: royalty.holderCredits, + ancestorCredits: royalty.ancestorCredits, + journalEntries, + }; +} + +export function allocateInternalGross({ + grossAtomic, + executionCostAtomic, + protocolFeeAtomic, + refundReserveAtomic, + recipientId, +}) { + const gross = assertAtomic(grossAtomic, 'grossAtomic'); + const executionCost = assertAtomic(executionCostAtomic, 'executionCostAtomic'); + const protocolFee = assertAtomic(protocolFeeAtomic, 'protocolFeeAtomic'); + const refundReserve = assertAtomic(refundReserveAtomic, 'refundReserveAtomic'); + const invocationAward = requireCoveredGross( + gross, + [executionCost, protocolFee, refundReserve], + ); + const recipient = String(recipientId ?? ''); + if (!recipient) fail('RECIPIENT_REQUIRED', 'recipientId must be non-empty'); + const debitAccountId = 'employer:invocation-gross'; + return { + grossAtomic: gross, + executionCostAtomic: executionCost, + protocolFeeAtomic: protocolFee, + refundReserveAtomic: refundReserve, + invocationAwardAtomic: invocationAward, + awardCredit: { recipientId: recipient, amountAtomic: invocationAward }, + journalEntries: [ + journalEntry('execution-cogs', debitAccountId, 'provider:execution', executionCost), + journalEntry('protocol-fee', debitAccountId, 'protocol:treasury', protocolFee), + journalEntry('refund-reserve', debitAccountId, 'reserve:refund', refundReserve), + journalEntry( + 'invocation-award', + debitAccountId, + `employee:${recipient}`, + invocationAward, + ), + ], + }; +} diff --git a/prototype/tests/atomic-money.test.mjs b/prototype/tests/atomic-money.test.mjs index 79168b5..685ef54 100644 --- a/prototype/tests/atomic-money.test.mjs +++ b/prototype/tests/atomic-money.test.mjs @@ -7,6 +7,8 @@ import { ROYALTY_ALLOCATION_POLICY, allocateByBps, allocateByWeights, + allocateExternalGross, + allocateInternalGross, allocateRoyaltyGraph, assertAtomic, floorBps, @@ -294,3 +296,287 @@ test('allocateRoyaltyGraph leaves a deeply frozen claim graph unchanged', () => root: { parentIds: [], inheritBps: 0, holders: [{ recipientId: 'creator', bps: 10_000 }] }, }); }); + +test('allocateExternalGross subtracts costs, fee, and reserve before the Royalty pool', () => { + const result = allocateExternalGross({ + grossAtomic: 250_000n, + executionCostAtomic: 60_000n, + settlementCostAtomic: 1_000n, + protocolFeeBps: 250, + refundReserveAtomic: 2_000n, + leafSkillId: 'skill', + skills: { + skill: { + parentIds: [], + inheritBps: 0, + holders: [{ recipientId: 'creator', bps: 10_000 }], + }, + }, + }); + assert.equal(result.allocationPolicy, 'lrp-per-hop-v1'); + assert.equal(result.protocolFeeAtomic, 6_250n); + assert.equal(result.royaltyPoolAtomic, 180_750n); + assert.equal(result.holderCredits[0].amountAtomic, 180_750n); + assert.equal( + result.executionCostAtomic + result.settlementCostAtomic + result.protocolFeeAtomic + + result.royaltyPoolAtomic + result.refundReserveAtomic, + result.grossAtomic, + ); + assert.deepEqual( + result.journalEntries.map(({ debitAccountId, creditAccountId, amountAtomic }) => ({ + debitAccountId, creditAccountId, amountAtomic, + })), + [ + { + debitAccountId: 'wielder:external-gross', + creditAccountId: 'provider:execution', + amountAtomic: 60_000n, + }, + { + debitAccountId: 'wielder:external-gross', + creditAccountId: 'provider:settlement', + amountAtomic: 1_000n, + }, + { + debitAccountId: 'wielder:external-gross', + creditAccountId: 'protocol:treasury', + amountAtomic: 6_250n, + }, + { + debitAccountId: 'wielder:external-gross', + creditAccountId: 'reserve:refund', + amountAtomic: 2_000n, + }, + { + debitAccountId: 'wielder:external-gross', + creditAccountId: 'royalty:creator', + amountAtomic: 180_750n, + }, + ], + ); +}); + +test('allocateInternalGross leaves one exact employee Invocation award', () => { + const result = allocateInternalGross({ + grossAtomic: 200_000n, + executionCostAtomic: 50_000n, + protocolFeeAtomic: 5_000n, + refundReserveAtomic: 5_000n, + recipientId: 'employee-1', + }); + assert.deepEqual(result.awardCredit, { recipientId: 'employee-1', amountAtomic: 140_000n }); + assert.equal( + result.executionCostAtomic + result.protocolFeeAtomic + + result.refundReserveAtomic + result.invocationAwardAtomic, + result.grossAtomic, + ); + assert.deepEqual(result.journalEntries, [ + { + category: 'execution-cogs', debitAccountId: 'employer:invocation-gross', + creditAccountId: 'provider:execution', amountAtomic: 50_000n, + }, + { + category: 'protocol-fee', debitAccountId: 'employer:invocation-gross', + creditAccountId: 'protocol:treasury', amountAtomic: 5_000n, + }, + { + category: 'refund-reserve', debitAccountId: 'employer:invocation-gross', + creditAccountId: 'reserve:refund', amountAtomic: 5_000n, + }, + { + category: 'invocation-award', debitAccountId: 'employer:invocation-gross', + creditAccountId: 'employee:employee-1', amountAtomic: 140_000n, + }, + ]); +}); + +test('gross partitions reject insufficient, negative, and non-bigint monetary inputs', () => { + const external = { + grossAtomic: 100n, + executionCostAtomic: 0n, + settlementCostAtomic: 0n, + protocolFeeBps: 0, + refundReserveAtomic: 0n, + leafSkillId: 'skill', + skills: chain(0), + }; + const internal = { + grossAtomic: 100n, + executionCostAtomic: 0n, + protocolFeeAtomic: 0n, + refundReserveAtomic: 0n, + recipientId: 'employee', + }; + + assert.throws(() => allocateExternalGross({ + ...external, executionCostAtomic: 99n, protocolFeeBps: 250, + }), /cannot cover costs/); + assert.throws(() => allocateInternalGross({ + ...internal, executionCostAtomic: 101n, + }), /cannot cover costs/); + + for (const field of [ + 'grossAtomic', 'executionCostAtomic', 'settlementCostAtomic', 'refundReserveAtomic', + ]) { + assert.throws(() => allocateExternalGross({ ...external, [field]: -1n }), /non-negative/); + assert.throws(() => allocateExternalGross({ ...external, [field]: 1 }), /must be a bigint/); + } + for (const field of [ + 'grossAtomic', 'executionCostAtomic', 'protocolFeeAtomic', 'refundReserveAtomic', + ]) { + assert.throws(() => allocateInternalGross({ ...internal, [field]: -1n }), /non-negative/); + assert.throws(() => allocateInternalGross({ ...internal, [field]: 1 }), /must be a bigint/); + } +}); + +test('gross partitions accept frozen inputs without mutation and reject unsupported policy', () => { + const external = Object.freeze({ + grossAtomic: 100n, + executionCostAtomic: 0n, + settlementCostAtomic: 0n, + protocolFeeBps: 0, + refundReserveAtomic: 0n, + leafSkillId: 'skill-0', + skills: Object.freeze({ + 'skill-0': Object.freeze({ + parentIds: Object.freeze([]), + inheritBps: 0, + holders: Object.freeze([ + Object.freeze({ recipientId: 'creator', bps: 10_000 }), + ]), + }), + }), + }); + const internal = Object.freeze({ + grossAtomic: 100n, + executionCostAtomic: 1n, + protocolFeeAtomic: 2n, + refundReserveAtomic: 3n, + recipientId: 'employee', + }); + + allocateExternalGross(external); + allocateInternalGross(internal); + assert.equal(external.grossAtomic, 100n); + assert.equal(internal.grossAtomic, 100n); + assert.throws(() => allocateExternalGross({ + ...external, + allocationPolicy: 'lap-whole-ancestry-v1', + }), /unsupported royalty allocation policy/); +}); + +const branchingClaims = () => ({ + leaf: { + parentIds: ['root-b', 'root-a'], + inheritBps: 3_333, + holders: [ + { recipientId: 'employee', bps: 3_333 }, + { recipientId: 'employer', bps: 6_667 }, + ], + }, + 'root-a': { + parentIds: [], + inheritBps: 0, + holders: [{ recipientId: 'alice', bps: 5_001 }, { recipientId: 'acme', bps: 4_999 }], + }, + 'root-b': { + parentIds: [], + inheritBps: 0, + holders: [{ recipientId: 'bob', bps: 7_777 }, { recipientId: 'beta', bps: 2_223 }], + }, +}); + +function assertBalanced(result, expectedSourceAccount, grossAtomic) { + const debitTotal = result.journalEntries.reduce((sum, entry) => sum + entry.amountAtomic, 0n); + const creditTotal = result.journalEntries.reduce((sum, entry) => sum + entry.amountAtomic, 0n); + assert.equal(debitTotal, grossAtomic); + assert.equal(creditTotal, grossAtomic); + assert.ok(result.journalEntries.every((entry) => entry.debitAccountId === expectedSourceAccount)); + assert.ok(result.journalEntries.every((entry) => entry.creditAccountId && entry.amountAtomic >= 0n)); +} + +test('152-case external matrix conserves gross across costs, claims, ancestry, and rounding', () => { + let cases = 0; + const claimGraphs = [ + { leafSkillId: 'skill-2', skills: chain(2) }, + { leafSkillId: 'leaf', skills: branchingClaims() }, + ]; + for (const grossAtomic of [250_001n, 1_000_003n]) { + for (const executionCostAtomic of [0n, 17n]) { + for (const settlementCostAtomic of [0n, 13n]) { + for (const refundReserveAtomic of [0n, 11n]) { + for (const protocolFeeBps of [0, 1, 250, 3_333]) { + for (const graph of claimGraphs) { + const result = allocateExternalGross({ + grossAtomic, + executionCostAtomic, + settlementCostAtomic, + protocolFeeBps, + refundReserveAtomic, + ...graph, + }); + assert.equal( + result.executionCostAtomic + result.settlementCostAtomic + + result.protocolFeeAtomic + result.royaltyPoolAtomic + + result.refundReserveAtomic, + grossAtomic, + ); + assert.equal( + result.credits.reduce((sum, credit) => sum + credit.amountAtomic, 0n), + result.royaltyPoolAtomic, + ); + assertBalanced(result, 'wielder:external-gross', grossAtomic); + cases += 1; + } + } + } + } + } + } + for (const grossAtomic of [0n, 1n, 2n]) { + for (const protocolFeeBps of [0, 1, 250, 3_333]) { + for (const graph of claimGraphs) { + const result = allocateExternalGross({ + grossAtomic, + executionCostAtomic: 0n, + settlementCostAtomic: 0n, + protocolFeeBps, + refundReserveAtomic: 0n, + ...graph, + }); + assertBalanced(result, 'wielder:external-gross', grossAtomic); + assert.ok(result.credits.every((credit) => credit.amountAtomic >= 0n)); + cases += 1; + } + } + } + assert.equal(cases, 152); +}); + +test('20-case internal matrix conserves employer gross at zero, dust, and large amounts', () => { + let cases = 0; + for (const grossAtomic of [0n, 1n, 2n, 250_001n, 1_000_003n]) { + const third = grossAtomic / 3n; + const partitions = [ + { executionCostAtomic: 0n, protocolFeeAtomic: 0n, refundReserveAtomic: 0n }, + { executionCostAtomic: grossAtomic, protocolFeeAtomic: 0n, refundReserveAtomic: 0n }, + { executionCostAtomic: 0n, protocolFeeAtomic: grossAtomic, refundReserveAtomic: 0n }, + { executionCostAtomic: third, protocolFeeAtomic: third, refundReserveAtomic: third }, + ]; + for (const partition of partitions) { + const result = allocateInternalGross({ + grossAtomic, + ...partition, + recipientId: 'employee', + }); + assert.equal( + result.executionCostAtomic + result.protocolFeeAtomic + + result.refundReserveAtomic + result.invocationAwardAtomic, + grossAtomic, + ); + assertBalanced(result, 'employer:invocation-gross', grossAtomic); + cases += 1; + } + } + assert.equal(cases, 20); +}); From c514cb97e05b7b6fd47d4ce55224b032dd7109df Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 02:40:47 -0400 Subject: [PATCH 060/165] test: freeze internal allocation matrix --- prototype/tests/atomic-money.test.mjs | 69 +++++++++++++++++---------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/prototype/tests/atomic-money.test.mjs b/prototype/tests/atomic-money.test.mjs index 685ef54..6580427 100644 --- a/prototype/tests/atomic-money.test.mjs +++ b/prototype/tests/atomic-money.test.mjs @@ -487,12 +487,20 @@ const branchingClaims = () => ({ }); function assertBalanced(result, expectedSourceAccount, grossAtomic) { - const debitTotal = result.journalEntries.reduce((sum, entry) => sum + entry.amountAtomic, 0n); - const creditTotal = result.journalEntries.reduce((sum, entry) => sum + entry.amountAtomic, 0n); + const debits = result.journalEntries.map((entry) => ({ + accountId: entry.debitAccountId, + amountAtomic: entry.amountAtomic, + })); + const credits = result.journalEntries.map((entry) => ({ + accountId: entry.creditAccountId, + amountAtomic: entry.amountAtomic, + })); + const debitTotal = debits.reduce((sum, entry) => sum + entry.amountAtomic, 0n); + const creditTotal = credits.reduce((sum, entry) => sum + entry.amountAtomic, 0n); assert.equal(debitTotal, grossAtomic); assert.equal(creditTotal, grossAtomic); - assert.ok(result.journalEntries.every((entry) => entry.debitAccountId === expectedSourceAccount)); - assert.ok(result.journalEntries.every((entry) => entry.creditAccountId && entry.amountAtomic >= 0n)); + assert.ok(debits.every((entry) => entry.accountId === expectedSourceAccount)); + assert.ok(credits.every((entry) => entry.accountId && entry.amountAtomic >= 0n)); } test('152-case external matrix conserves gross across costs, claims, ancestry, and rounding', () => { @@ -555,27 +563,38 @@ test('152-case external matrix conserves gross across costs, claims, ancestry, a test('20-case internal matrix conserves employer gross at zero, dust, and large amounts', () => { let cases = 0; - for (const grossAtomic of [0n, 1n, 2n, 250_001n, 1_000_003n]) { - const third = grossAtomic / 3n; - const partitions = [ - { executionCostAtomic: 0n, protocolFeeAtomic: 0n, refundReserveAtomic: 0n }, - { executionCostAtomic: grossAtomic, protocolFeeAtomic: 0n, refundReserveAtomic: 0n }, - { executionCostAtomic: 0n, protocolFeeAtomic: grossAtomic, refundReserveAtomic: 0n }, - { executionCostAtomic: third, protocolFeeAtomic: third, refundReserveAtomic: third }, - ]; - for (const partition of partitions) { - const result = allocateInternalGross({ - grossAtomic, - ...partition, - recipientId: 'employee', - }); - assert.equal( - result.executionCostAtomic + result.protocolFeeAtomic - + result.refundReserveAtomic + result.invocationAwardAtomic, - grossAtomic, - ); - assertBalanced(result, 'employer:invocation-gross', grossAtomic); - cases += 1; + for (const grossAtomic of [0n, 1n, 2n, 250_001n]) { + for (const executionCostAtomic of [0n, 1n]) { + for (const protocolFeeAtomic of [0n, 1n]) { + for (const refundReserveAtomic of [0n, 1n]) { + if (executionCostAtomic + protocolFeeAtomic + refundReserveAtomic > grossAtomic) { + continue; + } + const result = allocateInternalGross({ + grossAtomic, + executionCostAtomic, + protocolFeeAtomic, + refundReserveAtomic, + recipientId: 'employee', + }); + assert.equal( + result.executionCostAtomic + result.protocolFeeAtomic + + result.refundReserveAtomic + result.invocationAwardAtomic, + grossAtomic, + ); + assertBalanced(result, 'employer:invocation-gross', grossAtomic); + const awardEntries = result.journalEntries.filter( + (entry) => entry.category === 'invocation-award', + ); + assert.deepEqual(awardEntries, [{ + category: 'invocation-award', + debitAccountId: 'employer:invocation-gross', + creditAccountId: 'employee:employee', + amountAtomic: result.invocationAwardAtomic, + }]); + cases += 1; + } + } } } assert.equal(cases, 20); From 34eb7077530128f720c8f2e416ae49ddd582e994 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 02:41:14 -0400 Subject: [PATCH 061/165] refactor: keep atomic kernel arithmetic explicit --- prototype/atomic-money.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/prototype/atomic-money.mjs b/prototype/atomic-money.mjs index 34690c6..bd2ff51 100644 --- a/prototype/atomic-money.mjs +++ b/prototype/atomic-money.mjs @@ -205,7 +205,10 @@ export function allocateRoyaltyGraph({ validating.add(skillId); for (const parentId of node.parentIds) validateReachable(parentId, depth + 1); validating.delete(skillId); - deepestValidatedDepth.set(skillId, Math.max(priorDepth ?? -1, depth)); + deepestValidatedDepth.set( + skillId, + priorDepth == null || depth > priorDepth ? depth : priorDepth, + ); } const leaf = String(leafSkillId ?? ''); From f5457465fb65aa5ae01205e52e55f2c96025b717 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 02:41:21 -0400 Subject: [PATCH 062/165] docs: mark atomic accounting migration boundary --- prototype/README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/prototype/README.md b/prototype/README.md index ca4e7d0..8a216aa 100644 --- a/prototype/README.md +++ b/prototype/README.md @@ -1,7 +1,24 @@ # Prototype — settlement loop economics > **Throwaway.** This exists to answer one question, then be deleted or absorbed. -> `settlement-engine.mjs` is the keeper (pure logic); `settlement-tui.mjs` is the disposable shell. +> `settlement-engine.mjs` and `settlement-tui.mjs` are historical prototype consumers. + +## Accounting kernel status + +`atomic-money.mjs` is the tested accounting source for new work. It accepts USDC at +the display boundary, converts it to six-decimal atomic `bigint` values, and proves +exact gross and Royalty-pool conservation under deterministic remainder allocation. + +The only implemented ancestry allocation policy is the explicitly named +`lrp-per-hop-v1`, an LRP-like relative split applied once per Derivative hop. LAP +(whole-ancestry absolute allocation) remains unimplemented and deferred; callers +must not infer LAP semantics from this spike. + +`settlement-engine.mjs` and its TUI are historical prototype consumers that still use +display-number state. Do not use them for new receipts or public allocation figures. +The Collar and employer-budget plans migrate their runtime consumers to +`atomic-money.mjs`; historical results remain labeled as historical rather than being +silently recomputed. ## The question From de1e4b13ecf0ffc00ab09c50d3fc23cfd9848dcb Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:04:36 -0400 Subject: [PATCH 063/165] feat: add authoritative Invocation journal --- .gitignore | 4 + spikes/pi-wielder/package.json | 4 +- spikes/pi-wielder/src/invocation-journal.mjs | 1028 +++++++++++++++++ .../tests/invocation-journal.test.mjs | 471 ++++++++ .../tests/journal-reader-fixture.mjs | 9 + .../tests/journal-writer-fixture.mjs | 18 + 6 files changed, 1533 insertions(+), 1 deletion(-) create mode 100644 spikes/pi-wielder/src/invocation-journal.mjs create mode 100644 spikes/pi-wielder/tests/invocation-journal.test.mjs create mode 100644 spikes/pi-wielder/tests/journal-reader-fixture.mjs create mode 100644 spikes/pi-wielder/tests/journal-writer-fixture.mjs diff --git a/.gitignore b/.gitignore index a05f42d..4989f5f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ out/ # Run artifacts (belt-and-braces; spikes also ignore locally) runs/ *.jsonl +spikes/pi-wielder/**/*.lock +spikes/pi-wielder/**/*.claim +spikes/pi-wielder/**/*.pem +spikes/pi-wielder/**/*.key # Vendor doc snapshots — do not track (redistribution-unsafe) .archive/ diff --git a/spikes/pi-wielder/package.json b/spikes/pi-wielder/package.json index 4694a28..2b1572a 100644 --- a/spikes/pi-wielder/package.json +++ b/spikes/pi-wielder/package.json @@ -2,9 +2,11 @@ "name": "pi-wielder-spike", "version": "0.1.0", "private": true, - "description": "Pi-Wielder spike: one wallet, two asset classes. A coding harness pays per-call x402 for model inference AND a hosted Skill behind a mock collar, with a unified attributed session ledger. Testnet-only (Base Sepolia); fully offline in mock mode.", + "description": "Pi-Wielder spike: one wallet pays per-call x402 for model inference and hosted Skill Invocations; the Collar journal is authoritative and the Wielder keeps a signed-receipt view. Testnet-only (Base Sepolia); fully offline in mock mode.", "type": "module", "scripts": { + "test": "node --test tests/*.test.mjs", + "test:journal": "node --test tests/invocation-journal.test.mjs", "e2e": "MOCK_FACILITATOR=1 MOCK_LLM=1 node e2e.mjs", "collar": "node src/collar.mjs", "gateway": "node src/gateway.mjs", diff --git a/spikes/pi-wielder/src/invocation-journal.mjs b/spikes/pi-wielder/src/invocation-journal.mjs new file mode 100644 index 0000000..96679f6 --- /dev/null +++ b/spikes/pi-wielder/src/invocation-journal.mjs @@ -0,0 +1,1028 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const TERMINAL_EXECUTION = new Set(['succeeded', 'failed', 'cancelled']); +const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../', import.meta.url))); +const LEASE_ID = /^[0-9a-f]{32}$/; +const waitCell = new Int32Array(new SharedArrayBuffer(4)); + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); + } + return value; +} + +export const canonicalJson = (value) => JSON.stringify(canonicalize(value)); +const same = (left, right) => canonicalJson(left) === canonicalJson(right); +const copy = (value) => structuredClone(value); + +function requireText(value, label) { + const text = String(value ?? '').trim(); + if (!text) throw new Error(`${label} must be non-empty`); + return text; +} + +function requireAtomicString(value, label) { + const text = requireText(value, label); + if (!/^(0|[1-9]\d*)$/.test(text)) { + throw new Error(`${label} must be a canonical non-negative atomic string`); + } + return text; +} + +function canonicalHex(value, bytes, label) { + const text = requireText(value, label); + if (!new RegExp(`^0x[0-9a-fA-F]{${bytes * 2}}$`).test(text)) { + throw new Error(`${label} must be a ${bytes}-byte hex identifier`); + } + return text.toLowerCase(); +} + +const canonicalAddress = (value, label) => canonicalHex(value, 20, label); +const canonicalBytes32 = (value, label) => canonicalHex(value, 32, label); + +function exactKeys(value, expected, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + if (!same(Object.keys(value).sort(), [...expected].sort())) { + throw new Error(`${label} has unexpected fields`); + } +} + +function safePersistentPath(input, label) { + if (!path.isAbsolute(input ?? '')) throw new Error(`${label} must be an explicit absolute path`); + const lexical = path.resolve(input); + const lexicalParent = path.dirname(lexical); + const realParent = fs.realpathSync(lexicalParent); + if (realParent !== lexicalParent) throw new Error(`${label} must not traverse a symlinked directory`); + const candidate = path.join(realParent, path.basename(lexical)); + const relative = path.relative(CHECKOUT_ROOT, candidate); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + throw new Error(`${label} must be outside the repository checkout`); + } + if (fs.existsSync(candidate)) { + const stat = fs.lstatSync(candidate); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`${label} must be a regular non-symlink file`); + } + if ((stat.mode & 0o777) !== 0o600) throw new Error(`${label} permissions must be exactly 0600`); + if (fs.realpathSync(candidate) !== candidate) throw new Error(`${label} must be canonical`); + } + return candidate; +} + +function fsyncDirectory(directory) { + const descriptor = fs.openSync(directory, 'r'); + try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } +} + +function writeAll(descriptor, bytes) { + let offset = 0; + while (offset < bytes.byteLength) { + const written = fs.writeSync(descriptor, bytes, offset, bytes.byteLength - offset, null); + if (!Number.isSafeInteger(written) || written <= 0) throw new Error('journal write made no progress'); + offset += written; + } +} + +function readPrivateFile(filePath, label) { + const descriptor = fs.openSync(filePath, 'r'); + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) { + throw new Error(`${label} must be a regular file with mode exactly 0600`); + } + return fs.readFileSync(descriptor, 'utf8'); + } finally { + fs.closeSync(descriptor); + } +} + +function validateLeaseOwner(value) { + exactKeys(value, ['leaseId', 'hostname', 'pid', 'startedAtUtc'], 'journal lock owner'); + if (!LEASE_ID.test(value.leaseId)) throw new Error('journal lock owner lease ID is malformed'); + requireText(value.hostname, 'journal lock owner hostname'); + if (!Number.isSafeInteger(value.pid) || value.pid <= 0) throw new Error('journal lock owner PID is malformed'); + if (!Number.isFinite(Date.parse(value.startedAtUtc)) + || new Date(value.startedAtUtc).toISOString() !== value.startedAtUtc) { + throw new Error('journal lock owner start time is malformed'); + } +} + +function readLeaseOwner(lockPath) { + let bytes; + try { + bytes = readPrivateFile(lockPath, 'journal lock'); + } catch (error) { + if (error.code === 'ENOENT') throw new Error('journal lock does not exist', { cause: error }); + throw error; + } + if (!bytes.endsWith('\n')) throw new Error('journal lock owner must end with one newline'); + let owner; + try { owner = JSON.parse(bytes); } catch (error) { + throw new Error('journal lock owner is malformed', { cause: error }); + } + validateLeaseOwner(owner); + return { owner, bytes }; +} + +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + if (error.code === 'EPERM') { + throw new Error(`cannot prove PID ${pid} is absent: process probe returned EPERM`, { cause: error }); + } + throw new Error(`cannot prove PID ${pid} is absent`, { cause: error }); + } +} + +function restoreOrRetainClaim(lockPath, claimPath) { + try { + fs.linkSync(claimPath, lockPath); + } catch (error) { + if (error.code === 'EEXIST') return `retained at ${claimPath}`; + throw new Error(`unable to restore claimed journal lock; retained at ${claimPath}`, { cause: error }); + } + fs.unlinkSync(claimPath); + fsyncDirectory(path.dirname(lockPath)); + return 'restored'; +} + +function claimAndRemoveLease(lockPath, { + expectedLeaseId, + expectedBytes, + mismatchMessage, + hooks = {}, +}) { + const claimPath = `${lockPath}.${process.pid}.${crypto.randomUUID()}.claim`; + fs.renameSync(lockPath, claimPath); + hooks.afterLeaseClaim?.(claimPath); + let observed = null; + let validationError = null; + try { observed = readLeaseOwner(claimPath); } catch (error) { validationError = error; } + if (validationError || !observed + || observed.owner.leaseId !== expectedLeaseId + || observed.bytes !== expectedBytes) { + const disposition = restoreOrRetainClaim(lockPath, claimPath); + throw new Error(`${mismatchMessage}; claimed owner was ${disposition}`, { + cause: validationError ?? undefined, + }); + } + fs.unlinkSync(claimPath); + fsyncDirectory(path.dirname(lockPath)); +} + +function acquireLease(lockPath, hooks = {}, timeoutMs = 5_000) { + const owner = { + leaseId: crypto.randomBytes(16).toString('hex'), + hostname: os.hostname(), + pid: process.pid, + startedAtUtc: new Date().toISOString(), + }; + const bytes = `${JSON.stringify(owner)}\n`; + const deadline = Date.now() + timeoutMs; + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + while (true) { + let descriptor = null; + try { + descriptor = fs.openSync(lockPath, 'wx', 0o600); + writeAll(descriptor, Buffer.from(bytes)); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + fsyncDirectory(path.dirname(lockPath)); + return { owner, bytes }; + } catch (error) { + if (descriptor != null) { + try { fs.closeSync(descriptor); } catch { /* already closed */ } + } + if (error.code !== 'EEXIST') { + if (fs.existsSync(lockPath)) { + try { + const current = readLeaseOwner(lockPath); + if (current.owner.leaseId === owner.leaseId) fs.unlinkSync(lockPath); + } catch { /* fail closed around unrelated owner */ } + } + throw error; + } + const existing = readLeaseOwner(lockPath); + const alive = existing.owner.hostname === os.hostname() + ? (hooks.isProcessAlive ?? processIsAlive)(existing.owner.pid) + : true; + if (typeof alive !== 'boolean') throw new Error('journal lock process probe returned no boolean proof'); + if (!alive) { + throw new Error( + `journal lock is stale for PID ${existing.owner.pid}, lease ${existing.owner.leaseId}; explicit recovery is required`, + ); + } + if (Date.now() >= deadline) { + throw new Error( + `timed out acquiring journal lock held by host ${existing.owner.hostname}, PID ${existing.owner.pid}, lease ${existing.owner.leaseId}`, + ); + } + Atomics.wait(waitCell, 0, 0, 10); + } + } +} + +function withLease(lockPath, operation, hooks = {}) { + const lease = acquireLease(lockPath, hooks); + try { + return operation(lease.owner); + } finally { + claimAndRemoveLease(lockPath, { + expectedLeaseId: lease.owner.leaseId, + expectedBytes: lease.bytes, + mismatchMessage: `journal lease CAS failed for ${lease.owner.leaseId}`, + hooks, + }); + } +} + +export function createReceiptSigner(keys = {}, { persistent = false } = {}) { + const pair = keys.privateKey && keys.publicKey + ? { privateKey: keys.privateKey, publicKey: keys.publicKey } + : crypto.generateKeyPairSync('ed25519'); + const publicKeyPem = pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const keyId = `sha256:${crypto.createHash('sha256') + .update(pair.publicKey.export({ type: 'spki', format: 'der' })) + .digest('hex')}`; + return Object.freeze({ + algorithm: 'Ed25519', + publicKeyPem, + keyId, + persistent, + signHash(hashHex) { + return crypto.sign(null, Buffer.from(hashHex, 'hex'), pair.privateKey).toString('base64'); + }, + }); +} + +export function loadOrCreateReceiptSigner(keyPath) { + const canonicalKeyPath = safePersistentPath(keyPath, 'persistent receipt key'); + return withLease(`${canonicalKeyPath}.lock`, () => { + let privateKey; + if (fs.existsSync(canonicalKeyPath)) { + privateKey = crypto.createPrivateKey(readPrivateFile(canonicalKeyPath, 'persistent receipt key')); + } else { + const pair = crypto.generateKeyPairSync('ed25519'); + privateKey = pair.privateKey; + const temporary = `${canonicalKeyPath}.${process.pid}.${crypto.randomUUID()}.tmp`; + const descriptor = fs.openSync(temporary, 'wx', 0o600); + try { + writeAll(descriptor, Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' }))); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + fs.renameSync(temporary, canonicalKeyPath); + fsyncDirectory(path.dirname(canonicalKeyPath)); + } + return createReceiptSigner( + { privateKey, publicKey: crypto.createPublicKey(privateKey) }, + { persistent: true }, + ); + }); +} + +export function verifySignedReceipt(bundle, { publicKeyPem, keyId }) { + try { + if (bundle?.algorithm !== 'Ed25519' || bundle.keyId !== keyId) return false; + const expectedHash = crypto.createHash('sha256').update(canonicalJson(bundle.receipt)).digest('hex'); + if (bundle.receiptHash !== expectedHash) return false; + return crypto.verify( + null, + Buffer.from(expectedHash, 'hex'), + crypto.createPublicKey(publicKeyPem), + Buffer.from(bundle.signature, 'base64'), + ); + } catch { + return false; + } +} + +const EVENT_DATA_KEYS = Object.freeze({ + 'invocation.requested': ['invocationId', 'mode', 'skill', 'requestHash', 'creatorId', 'beneficiaryId'], + 'payment.offered': ['quote'], + 'payment.signed': ['settlementReference', 'payer'], + 'payment.settled': ['settlementReference', 'txHash', 'payer'], + 'payment.unresolved': ['reason'], + 'payment.rejected': ['reason'], + 'payment.refunded': ['reason', 'refundReference', 'refundAmountAtomic', 'reversalEntries'], + 'execution.started': ['executionAttemptId'], + 'execution.finished': ['executionAttemptId', 'outcome', 'outcomeHash', 'failureClass', 'message', 'httpStatus', 'accounting'], + 'receipt.issued': ['bundle'], +}); + +function deriveFullGrossRefundReversal(record) { + if (record.payment.state !== 'settled' || record.execution.state !== 'failed' + || record.accounting?.allocationState !== 'pending_cogs_reconciliation') { + throw new Error('refund requires a settled terminal failed full-gross reconciliation hold'); + } + if ((record.accounting.holderCredits?.length ?? 0) !== 0 + || (record.accounting.ancestorCredits?.length ?? 0) !== 0) { + throw new Error('refund refuses accounting with finalized Royalty claims'); + } + const [hold, ...extra] = record.accounting.journalEntries ?? []; + if (extra.length || !hold + || hold.category !== 'unresolved-execution-accounting' + || hold.debitAccountId !== 'wielder:external-gross' + || hold.creditAccountId !== 'hold:execution-accounting-reconciliation' + || hold.amountAtomic !== record.quote.amountAtomic + || record.accounting.grossAtomic !== record.quote.amountAtomic) { + throw new Error('refund requires one exact full-gross reconciliation hold'); + } + return [{ + category: 'refund-reverse-reconciliation-hold', + debitAccountId: hold.creditAccountId, + creditAccountId: hold.debitAccountId, + amountAtomic: hold.amountAtomic, + }, { + category: 'refund-disbursement', + debitAccountId: 'wielder:external-gross', + creditAccountId: `refund:${record.payment.payer}`, + amountAtomic: record.quote.amountAtomic, + }]; +} + +function receiptPayload(record) { + return { + schemaVersion: 1, + revision: record.receiptHistory.length + 1, + supersedesReceiptHash: record.receiptHistory.at(-1)?.receiptHash ?? null, + sequence: record.lastSequence, + invocationId: record.invocationId, + idempotencyKey: record.idempotencyKey, + mode: record.mode, + skill: record.skill, + requestHash: record.requestHash, + creatorId: record.creatorId, + wielderId: record.wielderId, + beneficiaryId: record.beneficiaryId, + quote: record.quote, + payment: record.payment, + execution: record.execution, + accounting: record.accounting, + createdAt: record.createdAt, + completedAt: record.updatedAt, + }; +} + +function requireRecord(records, key) { + const record = records.get(key); + if (!record) throw new Error(`unknown idempotency key '${key}'`); + return record; +} + +function assertState(record, allowed, action) { + if (!allowed.includes(record.execution.state)) { + throw new Error(`${action} cannot run from execution state '${record.execution.state}'`); + } +} + +function assertUnique(index, value, key, label) { + const existing = index.get(value); + if (existing && existing !== key) throw new Error(`${label} already binds idempotency key '${existing}'`); +} + +export function createInvocationJournal({ + filePath = null, + signingKeyPath = null, + now = () => new Date().toISOString(), + createId = () => `inv-${crypto.randomUUID()}`, + signer = null, + lockTestHooks = {}, +} = {}) { + if (Boolean(filePath) !== Boolean(signingKeyPath)) { + throw new Error('persistent journal and receipt signing key paths must be set together'); + } + const journalPath = filePath ? safePersistentPath(filePath, 'persistent journal') : null; + const lockPath = journalPath ? `${journalPath}.lock` : null; + const canonicalSigningKeyPath = signingKeyPath + ? safePersistentPath(signingKeyPath, 'persistent receipt key') + : null; + if (journalPath && journalPath === canonicalSigningKeyPath) { + throw new Error('journal and signing key paths must differ'); + } + if (journalPath && signer && signer.persistent !== true) { + throw new Error('persistent journal refuses an ephemeral receipt signer'); + } + const diskSigner = journalPath ? loadOrCreateReceiptSigner(canonicalSigningKeyPath) : null; + if (signer && diskSigner && signer.keyId !== diskSigner.keyId) { + throw new Error('injected receipt signer does not match the persistent signing key'); + } + const receiptSigner = signer ?? diskSigner ?? createReceiptSigner(); + const records = new Map(); + const settlementReferences = new Map(); + const transactionHashes = new Map(); + const eventLog = []; + let nextSequence = 1; + let headHash = null; + + function validateQuote(quote) { + exactKeys(quote, [ + 'quoteId', 'amountAtomic', 'currency', 'network', 'asset', 'payTo', 'resource', + 'requestHash', 'requirementsHash', 'expiresAt', 'requirements', + ], 'payment quote'); + requireText(quote.quoteId, 'quoteId'); + requireAtomicString(quote.amountAtomic, 'amountAtomic'); + if (quote.currency !== 'USDC') throw new Error("currency must be 'USDC'"); + for (const field of ['network', 'resource', 'requestHash', 'requirementsHash', 'expiresAt']) { + requireText(quote[field], field); + } + if (canonicalAddress(quote.asset, 'asset') !== quote.asset + || canonicalAddress(quote.payTo, 'payTo') !== quote.payTo) { + throw new Error('indexed quote addresses must use canonical lowercase hex'); + } + exactKeys(quote.requirements, [ + 'scheme', 'network', 'maxAmountRequired', 'resource', 'description', 'mimeType', + 'payTo', 'maxTimeoutSeconds', 'asset', 'extra', + ], 'frozen PaymentRequirements'); + exactKeys(quote.requirements.extra, [ + 'name', 'version', 'requestHash', 'quoteId', 'issuedAt', 'expiresAt', + ], 'PaymentRequirements.extra'); + if (quote.requirements.maxAmountRequired !== quote.amountAtomic + || quote.requirements.network !== quote.network + || canonicalAddress(quote.requirements.asset, 'requirements.asset') !== quote.asset + || canonicalAddress(quote.requirements.payTo, 'requirements.payTo') !== quote.payTo + || quote.requirements.resource !== quote.resource + || quote.requirements.extra.requestHash !== quote.requestHash + || quote.requirements.extra.quoteId !== quote.quoteId + || quote.requirements.extra.expiresAt !== quote.expiresAt) { + throw new Error('frozen x402 requirements do not match indexed quote fields'); + } + } + + function validateEventForApply(event) { + exactKeys(event, [ + 'schemaVersion', 'eventId', 'sequence', 'previousHash', 'type', 'idempotencyKey', + 'at', 'data', 'keyId', 'eventHash', 'eventSignature', + ], 'journal event'); + if (event.schemaVersion !== 1 || event.eventId !== `event-${String(event.sequence).padStart(8, '0')}`) { + throw new Error('journal event schema or identifier is invalid'); + } + if (!Number.isSafeInteger(event.sequence) || event.sequence < 1 + || !Number.isFinite(Date.parse(event.at))) { + throw new Error('journal event sequence or timestamp is invalid'); + } + const dataKeys = EVENT_DATA_KEYS[event.type]; + if (!dataKeys) throw new Error(`unknown journal event '${event.type}'`); + exactKeys(event.data, dataKeys, `${event.type}.data`); + const record = records.get(event.idempotencyKey); + switch (event.type) { + case 'invocation.requested': + if (record) throw new Error(`duplicate request event for '${event.idempotencyKey}'`); + exactKeys(event.data.skill, ['id', 'versionHash'], 'skill'); + if (event.data.mode !== 'external') throw new Error("journal supports mode 'external' only"); + break; + case 'payment.offered': + if (!record || record.execution.state !== 'requested' || record.payment.state !== null) { + throw new Error('payment.offered requires one unquoted requested Invocation'); + } + validateQuote(event.data.quote); + break; + case 'payment.signed': + if (!record || record.payment.state !== 'offered') throw new Error('payment.signed requires offered payment'); + assertUnique(settlementReferences, event.data.settlementReference, event.idempotencyKey, 'settlement reference'); + break; + case 'payment.settled': + if (!record || !['signed', 'unresolved'].includes(record.payment.state)) { + throw new Error('payment.settled requires signed or unresolved payment'); + } + if (record.payment.settlementReference !== event.data.settlementReference + || record.payment.payer !== event.data.payer) { + throw new Error('settlement does not match signed payment'); + } + assertUnique(transactionHashes, event.data.txHash, event.idempotencyKey, 'transaction hash'); + break; + case 'payment.unresolved': + if (!record || record.payment.state !== 'signed') throw new Error('payment.unresolved requires signed payment'); + break; + case 'payment.rejected': + if (!record || !['offered', 'signed', 'unresolved'].includes(record.payment.state)) { + throw new Error('payment.rejected has invalid predecessor'); + } + break; + case 'payment.refunded': + if (!record || event.data.refundAmountAtomic !== record.quote.amountAtomic + || !same(event.data.reversalEntries, deriveFullGrossRefundReversal(record))) { + throw new Error('refund must exactly reverse the full settled gross hold'); + } + break; + case 'execution.started': + if (!record || record.payment.state !== 'settled' || record.execution.state !== 'authorized') { + throw new Error('execution.started requires authorized settled payment'); + } + break; + case 'execution.finished': + if (!record || record.execution.state !== 'executing' + || event.data.executionAttemptId !== record.execution.executionAttemptId) { + throw new Error('execution.finished requires the claimed executing attempt'); + } + if (!TERMINAL_EXECUTION.has(event.data.outcome)) throw new Error('execution outcome is not terminal'); + if (!Number.isSafeInteger(event.data.httpStatus) + || event.data.httpStatus < 100 || event.data.httpStatus > 599) { + throw new Error('execution HTTP status is invalid'); + } + break; + case 'receipt.issued': + if (!record || !TERMINAL_EXECUTION.has(record.execution.state) || record.receipt) { + throw new Error('receipt.issued requires one unreceipted terminal Invocation'); + } + if (!same(event.data.bundle.receipt, receiptPayload(record))) { + throw new Error('receipt does not byte-bind the derived Invocation record'); + } + if (!verifySignedReceipt(event.data.bundle, { + publicKeyPem: receiptSigner.publicKeyPem, + keyId: receiptSigner.keyId, + })) throw new Error('receipt signature does not match the pinned Collar key'); + break; + default: + throw new Error(`unknown journal event '${event.type}'`); + } + } + + function apply(event) { + validateEventForApply(event); + let record = records.get(event.idempotencyKey); + switch (event.type) { + case 'invocation.requested': + record = { + schemaVersion: 1, + invocationId: event.data.invocationId, + idempotencyKey: event.idempotencyKey, + mode: event.data.mode, + skill: event.data.skill, + requestHash: event.data.requestHash, + creatorId: event.data.creatorId, + requestedBeneficiaryId: event.data.beneficiaryId, + wielderId: null, + beneficiaryId: event.data.beneficiaryId, + quote: null, + payment: { + state: null, settlementReference: null, txHash: null, payer: null, reason: null, + refundReference: null, refundAmountAtomic: null, refundAccounting: null, + }, + execution: { + state: 'requested', executionAttemptId: null, outcomeHash: null, + failureClass: null, message: null, httpStatus: null, + }, + accounting: null, + receipt: null, + receiptHistory: [], + createdAt: event.at, + updatedAt: event.at, + lastSequence: event.sequence, + }; + records.set(event.idempotencyKey, record); + break; + case 'payment.offered': + record.quote = event.data.quote; + record.payment.state = 'offered'; + record.execution.state = 'quoted'; + break; + case 'payment.signed': + record.payment = { ...record.payment, state: 'signed', ...event.data, reason: null }; + record.wielderId = event.data.payer; + record.beneficiaryId ??= event.data.payer; + settlementReferences.set(event.data.settlementReference, event.idempotencyKey); + break; + case 'payment.settled': + record.payment = { ...record.payment, state: 'settled', ...event.data, reason: null }; + record.wielderId = event.data.payer; + record.beneficiaryId ??= event.data.payer; + record.execution.state = 'authorized'; + settlementReferences.set(event.data.settlementReference, event.idempotencyKey); + transactionHashes.set(event.data.txHash, event.idempotencyKey); + break; + case 'payment.unresolved': + record.payment.state = 'unresolved'; + record.payment.reason = event.data.reason; + break; + case 'payment.rejected': + record.payment.state = 'rejected'; + record.payment.reason = event.data.reason; + record.execution.state = 'cancelled'; + record.execution.httpStatus = 402; + break; + case 'payment.refunded': + record.payment.state = 'refunded'; + record.payment.reason = event.data.reason; + record.payment.refundReference = event.data.refundReference; + record.payment.refundAmountAtomic = event.data.refundAmountAtomic; + record.payment.refundAccounting = { + priorAllocationState: 'pending_cogs_reconciliation', + reversalEntries: event.data.reversalEntries, + }; + record.receipt = null; + break; + case 'execution.started': + record.execution.state = 'executing'; + record.execution.executionAttemptId = event.data.executionAttemptId; + break; + case 'execution.finished': + record.execution = { + state: event.data.outcome, + executionAttemptId: event.data.executionAttemptId, + outcomeHash: event.data.outcomeHash, + failureClass: event.data.failureClass, + message: event.data.message, + httpStatus: event.data.httpStatus, + }; + record.accounting = event.data.accounting; + break; + case 'receipt.issued': + record.receipt = event.data.bundle; + record.receiptHistory.push(event.data.bundle); + break; + default: + throw new Error(`unknown journal event '${event.type}'`); + } + record.updatedAt = event.at; + record.lastSequence = event.sequence; + } + + const calculateEventHash = (unsigned) => crypto.createHash('sha256') + .update(canonicalJson(unsigned)).digest('hex'); + + function readVerifiedDiskEvents() { + if (!journalPath || !fs.existsSync(journalPath)) return []; + const text = readPrivateFile(journalPath, 'persistent journal'); + if (!text) return []; + if (!text.endsWith('\n')) throw new Error('journal has a torn or unterminated final event'); + const lines = text.slice(0, -1).split('\n'); + let previousHash = null; + return lines.map((line, index) => { + if (!line) throw new Error(`journal contains a blank event at sequence ${index + 1}`); + let event; + try { event = JSON.parse(line); } catch (error) { + throw new Error(`journal event ${index + 1} is malformed JSON`, { cause: error }); + } + if (event.sequence !== index + 1) throw new Error(`journal sequence gap at ${index + 1}`); + if (event.previousHash !== previousHash) throw new Error(`journal hash-chain predecessor mismatch at ${index + 1}`); + const { eventHash, eventSignature, ...unsigned } = event; + const expectedHash = calculateEventHash(unsigned); + if (eventHash !== expectedHash) throw new Error(`journal event hash mismatch at ${index + 1}`); + if (event.keyId !== receiptSigner.keyId || !crypto.verify( + null, + Buffer.from(eventHash, 'hex'), + crypto.createPublicKey(receiptSigner.publicKeyPem), + Buffer.from(eventSignature, 'base64'), + )) throw new Error(`journal event signature mismatch at ${index + 1}`); + previousHash = eventHash; + return event; + }); + } + + function syncFromDisk() { + const diskEvents = readVerifiedDiskEvents(); + for (let index = 0; index < eventLog.length; index += 1) { + if (!same(eventLog[index], diskEvents[index])) { + throw new Error(`journal history changed at sequence ${index + 1}`); + } + } + for (const event of diskEvents.slice(eventLog.length)) { + apply(event); + eventLog.push(event); + } + nextSequence = diskEvents.length + 1; + headHash = diskEvents.at(-1)?.eventHash ?? null; + } + + const refreshFromAuthority = () => { + if (journalPath) withLease(lockPath, syncFromDisk, lockTestHooks); + }; + + function append(type, idempotencyKey, data) { + const expectedRecordSequence = records.get(idempotencyKey)?.lastSequence ?? 0; + const write = () => { + if (journalPath) syncFromDisk(); + const currentRecordSequence = records.get(idempotencyKey)?.lastSequence ?? 0; + if (currentRecordSequence !== expectedRecordSequence) { + const error = new Error(`journal compare-and-swap conflict for '${idempotencyKey}'`); + error.name = 'JournalConflictError'; + error.code = 'JOURNAL_CONFLICT'; + throw error; + } + const unsigned = { + schemaVersion: 1, + eventId: `event-${String(nextSequence).padStart(8, '0')}`, + sequence: nextSequence, + previousHash: headHash, + type, + idempotencyKey, + at: now(), + data, + keyId: receiptSigner.keyId, + }; + const eventHash = calculateEventHash(unsigned); + const event = { + ...unsigned, + eventHash, + eventSignature: receiptSigner.signHash(eventHash), + }; + if (journalPath) { + const existed = fs.existsSync(journalPath); + const descriptor = fs.openSync(journalPath, 'a', 0o600); + try { + writeAll(descriptor, Buffer.from(`${JSON.stringify(event)}\n`)); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + if (!existed) fsyncDirectory(path.dirname(journalPath)); + } + apply(event); + eventLog.push(event); + nextSequence += 1; + headHash = event.eventHash; + return event; + }; + return journalPath ? withLease(lockPath, write, lockTestHooks) : write(); + } + + function requestInvocation(input) { + refreshFromAuthority(); + const key = requireText(input.idempotencyKey, 'idempotencyKey'); + const declaration = { + mode: input.mode === 'external' ? 'external' : (() => { throw new Error("journal supports mode 'external' only"); })(), + skill: { + id: requireText(input.skillId, 'skillId'), + versionHash: requireText(input.skillVersionHash, 'skillVersionHash'), + }, + requestHash: requireText(input.requestHash, 'requestHash'), + creatorId: requireText(input.creatorId, 'creatorId'), + beneficiaryId: input.beneficiaryId == null ? null : requireText(input.beneficiaryId, 'beneficiaryId'), + }; + const existing = records.get(key); + if (existing) { + const bound = { + mode: existing.mode, + skill: existing.skill, + requestHash: existing.requestHash, + creatorId: existing.creatorId, + beneficiaryId: existing.requestedBeneficiaryId, + }; + if (!same(bound, declaration)) throw new Error('idempotency key already binds a different Invocation declaration'); + return copy(existing); + } + append('invocation.requested', key, { invocationId: createId(), ...declaration }); + return copy(records.get(key)); + } + + function offerExternalPayment(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const requirements = copy(input.requirements); + const frozenQuote = { + quoteId: requireText(input.quoteId, 'quoteId'), + amountAtomic: requireAtomicString(input.amountAtomic, 'amountAtomic'), + currency: input.currency === 'USDC' ? 'USDC' : (() => { throw new Error("currency must be 'USDC'"); })(), + network: requireText(input.network, 'network'), + asset: canonicalAddress(input.asset, 'asset'), + payTo: canonicalAddress(input.payTo, 'payTo'), + resource: requireText(input.resource, 'resource'), + requestHash: requireText(input.requestHash, 'requestHash'), + requirementsHash: requireText(input.requirementsHash, 'requirementsHash'), + expiresAt: requireText(input.expiresAt, 'expiresAt'), + requirements, + }; + validateQuote(frozenQuote); + if (record.quote) { + if (!same(record.quote, frozenQuote)) throw new Error('idempotency key already binds a different quote'); + return copy(record); + } + assertState(record, ['requested'], 'offerExternalPayment'); + append('payment.offered', key, { quote: frozenQuote }); + return copy(records.get(key)); + } + + function markExternalPaymentSigned(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const reference = canonicalBytes32(input.settlementReference, 'settlementReference'); + const normalizedPayer = canonicalAddress(input.payer, 'payer'); + if (record.payment.settlementReference) { + if (record.payment.settlementReference !== reference || record.payment.payer !== normalizedPayer) { + throw new Error('idempotency key already binds a different signed payment'); + } + return copy(record); + } + if (record.payment.state !== 'offered') { + throw new Error(`markExternalPaymentSigned cannot run from payment state '${record.payment.state}'`); + } + assertUnique(settlementReferences, reference, key, 'settlement reference'); + append('payment.signed', key, { settlementReference: reference, payer: normalizedPayer }); + return copy(records.get(key)); + } + + function markExternalPaymentSettled(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const reference = canonicalBytes32(input.settlementReference, 'settlementReference'); + const normalizedTxHash = canonicalBytes32(input.txHash, 'txHash'); + const normalizedPayer = canonicalAddress(input.payer, 'payer'); + if (['settled', 'refunded'].includes(record.payment.state)) { + if (record.payment.settlementReference !== reference || record.payment.txHash !== normalizedTxHash + || record.payment.payer !== normalizedPayer) { + throw new Error('idempotency key already binds a different settlement'); + } + return copy(record); + } + if (!['signed', 'unresolved'].includes(record.payment.state)) { + throw new Error(`markExternalPaymentSettled cannot run from payment state '${record.payment.state}'`); + } + if (record.payment.settlementReference !== reference || record.payment.payer !== normalizedPayer) { + throw new Error('settlement does not match signed payment'); + } + assertUnique(transactionHashes, normalizedTxHash, key, 'transaction hash'); + append('payment.settled', key, { + settlementReference: reference, + txHash: normalizedTxHash, + payer: normalizedPayer, + }); + return copy(records.get(key)); + } + + function markExternalPaymentUnresolved(key, { reason }) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const normalizedReason = requireText(reason, 'reason'); + if (record.payment.state === 'unresolved' && record.payment.reason === normalizedReason) return copy(record); + if (record.payment.state !== 'signed') { + throw new Error(`markExternalPaymentUnresolved cannot run from payment state '${record.payment.state}'`); + } + append('payment.unresolved', key, { reason: normalizedReason }); + return copy(records.get(key)); + } + + function reconcileExternalSettlement(input) { + refreshFromAuthority(); + const reference = canonicalBytes32(input.settlementReference, 'settlementReference'); + const key = settlementReferences.get(reference); + if (!key) throw new Error(`unknown settlement reference '${reference}'`); + return markExternalPaymentSettled(key, { ...input, settlementReference: reference }); + } + + function rejectExternalPayment(key, { reason }) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const normalizedReason = requireText(reason, 'reason'); + if (record.payment.state === 'rejected' && record.payment.reason === normalizedReason) return copy(record); + if (!['offered', 'signed', 'unresolved'].includes(record.payment.state)) { + throw new Error(`rejectExternalPayment cannot run from payment state '${record.payment.state}'`); + } + append('payment.rejected', key, { reason: normalizedReason }); + return copy(records.get(key)); + } + + function refundExternalPayment(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const refund = { + reason: requireText(input.reason, 'reason'), + refundReference: requireText(input.refundReference, 'refundReference'), + refundAmountAtomic: requireAtomicString(input.refundAmountAtomic, 'refundAmountAtomic'), + }; + if (record.payment.state === 'refunded') { + if (record.payment.reason !== refund.reason + || record.payment.refundReference !== refund.refundReference + || record.payment.refundAmountAtomic !== refund.refundAmountAtomic) { + throw new Error('Invocation already binds a different refund'); + } + return copy(record); + } + if (refund.refundAmountAtomic !== record.quote.amountAtomic) { + throw new Error('refund must return the full settled gross'); + } + append('payment.refunded', key, { + ...refund, + reversalEntries: deriveFullGrossRefundReversal(record), + }); + return copy(records.get(key)); + } + + function startExecution(key, { executionAttemptId = null } = {}) { + refreshFromAuthority(); + const record = requireRecord(records, key); + if (record.execution.state === 'executing') return { started: false, record: copy(record) }; + if (record.payment.state !== 'settled') throw new Error('external execution requires a settled payment'); + assertState(record, ['authorized'], 'startExecution'); + const attempt = executionAttemptId ?? `attempt:${crypto.createHash('sha256') + .update(`${record.invocationId}\n${record.requestHash}`).digest('hex')}`; + append('execution.started', key, { executionAttemptId: requireText(attempt, 'executionAttemptId') }); + return { started: true, record: copy(records.get(key)) }; + } + + function finishExecution(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const outcome = requireText(input.outcome, 'outcome'); + if (!TERMINAL_EXECUTION.has(outcome)) throw new Error(`unsupported execution outcome '${outcome}'`); + const data = { + executionAttemptId: requireText(input.executionAttemptId ?? record.execution.executionAttemptId, 'executionAttemptId'), + outcome, + outcomeHash: input.outcomeHash ?? null, + failureClass: input.failureClass ?? null, + message: input.message ?? null, + httpStatus: input.httpStatus, + accounting: input.accounting ?? null, + }; + if (TERMINAL_EXECUTION.has(record.execution.state)) { + const terminal = { ...record.execution, accounting: record.accounting }; + const expected = { state: data.outcome, ...data }; + delete expected.outcome; + if (!same(terminal, expected)) throw new Error('idempotency key already binds a different execution outcome'); + return copy(record); + } + assertState(record, ['executing'], 'finishExecution'); + append('execution.finished', key, data); + return copy(records.get(key)); + } + + function issueReceipt(key) { + refreshFromAuthority(); + const record = requireRecord(records, key); + if (record.receipt) return copy(record.receipt); + if (!TERMINAL_EXECUTION.has(record.execution.state)) { + throw new Error('receipt requires a terminal execution outcome'); + } + const receipt = receiptPayload(record); + const receiptHash = crypto.createHash('sha256').update(canonicalJson(receipt)).digest('hex'); + const bundle = { + receipt, + receiptHash, + signature: receiptSigner.signHash(receiptHash), + algorithm: receiptSigner.algorithm, + keyId: receiptSigner.keyId, + }; + append('receipt.issued', key, { bundle }); + return copy(bundle); + } + + function recoverStaleLock({ expectedLeaseId }) { + if (!journalPath) throw new Error('ephemeral journal has no persistent lock to recover'); + if (!LEASE_ID.test(expectedLeaseId ?? '')) { + throw new Error('expected stale-lock lease ID must be exactly 128 lowercase bits'); + } + const initial = readLeaseOwner(lockPath); + if (initial.owner.leaseId !== expectedLeaseId) { + throw new Error(`recorded lease ID ${initial.owner.leaseId} does not match expected lease ID ${expectedLeaseId}`); + } + if (initial.owner.hostname !== os.hostname()) { + throw new Error(`journal lock belongs to different host ${initial.owner.hostname}`); + } + const alive = (lockTestHooks.isProcessAlive ?? processIsAlive)(initial.owner.pid); + if (typeof alive !== 'boolean') throw new Error('journal lock process probe returned no boolean proof'); + if (alive) throw new Error(`journal lock PID ${initial.owner.pid} is still alive`); + claimAndRemoveLease(lockPath, { + expectedLeaseId, + expectedBytes: initial.bytes, + mismatchMessage: 'journal lock owner changed during recovery', + hooks: lockTestHooks, + }); + } + + if (journalPath) withLease(lockPath, syncFromDisk, lockTestHooks); + return Object.freeze({ + requestInvocation, + offerExternalPayment, + markExternalPaymentSigned, + markExternalPaymentSettled, + markExternalPaymentUnresolved, + reconcileExternalSettlement, + rejectExternalPayment, + refundExternalPayment, + startExecution, + finishExecution, + issueReceipt, + recoverStaleLock, + getByIdempotencyKey: (key) => { + refreshFromAuthority(); + return records.has(key) ? copy(records.get(key)) : null; + }, + getBySettlementReference: (reference) => { + refreshFromAuthority(); + const key = settlementReferences.get(canonicalBytes32(reference, 'settlementReference')); + return key ? copy(records.get(key)) : null; + }, + getByTxHash: (hash) => { + refreshFromAuthority(); + const key = transactionHashes.get(canonicalBytes32(hash, 'txHash')); + return key ? copy(records.get(key)) : null; + }, + get events() { refreshFromAuthority(); return copy(eventLog); }, + signingPublicKeyPem: receiptSigner.publicKeyPem, + signingKeyId: receiptSigner.keyId, + isPersistent: Boolean(journalPath), + lockPath, + }); +} diff --git a/spikes/pi-wielder/tests/invocation-journal.test.mjs b/spikes/pi-wielder/tests/invocation-journal.test.mjs new file mode 100644 index 0000000..ad07f80 --- /dev/null +++ b/spikes/pi-wielder/tests/invocation-journal.test.mjs @@ -0,0 +1,471 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +import { + canonicalJson, + createInvocationJournal, + createReceiptSigner, + loadOrCreateReceiptSigner, + verifySignedReceipt, +} from '../src/invocation-journal.mjs'; + +const payer = `0x${'1'.repeat(40)}`; +const payTo = `0x${'d'.repeat(40)}`; +const settlementReference = `0x${'2'.repeat(64)}`; +const txHash = `0x${'3'.repeat(64)}`; + +const declaration = Object.freeze({ + idempotencyKey: 'idem-0001', + mode: 'external', + skillId: 'skill-a', + skillVersionHash: `sha256:${'a'.repeat(64)}`, + requestHash: `sha256:${'b'.repeat(64)}`, + creatorId: 'creator-a', + beneficiaryId: null, +}); + +const requirements = Object.freeze({ + scheme: 'exact', + network: 'base-sepolia', + maxAmountRequired: '250000', + resource: 'http://seller.test/invoke/skill-a', + description: 'Invoke skill-a', + mimeType: 'application/json', + payTo, + maxTimeoutSeconds: 60, + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + extra: { + name: 'USDC', + version: '2', + requestHash: declaration.requestHash, + quoteId: `sha256:${'c'.repeat(64)}`, + issuedAt: '2026-07-17T12:00:00.000Z', + expiresAt: '2026-07-17T12:01:00.000Z', + }, +}); + +const quote = Object.freeze({ + quoteId: requirements.extra.quoteId, + amountAtomic: requirements.maxAmountRequired, + currency: 'USDC', + network: requirements.network, + asset: requirements.asset, + payTo: requirements.payTo, + resource: requirements.resource, + requestHash: requirements.extra.requestHash, + requirementsHash: `sha256:${'e'.repeat(64)}`, + expiresAt: requirements.extra.expiresAt, + requirements, +}); + +function fixture(overrides = {}) { + let tick = 0; + return createInvocationJournal({ + now: () => new Date(Date.UTC(2026, 6, 17, 12, 0, tick++)).toISOString(), + createId: () => 'inv-0001', + signer: createReceiptSigner(), + ...overrides, + }); +} + +const trustFor = (journal) => ({ + publicKeyPem: journal.signingPublicKeyPem, + keyId: journal.signingKeyId, +}); + +function offer(journal, input = declaration) { + journal.requestInvocation(input); + journal.offerExternalPayment(input.idempotencyKey, quote); +} + +function settle(journal, input = declaration) { + offer(journal, input); + journal.markExternalPaymentSigned(input.idempotencyKey, { settlementReference, payer }); + journal.markExternalPaymentSettled(input.idempotencyKey, { + settlementReference, + txHash, + payer, + }); +} + +function pendingFailureAccounting() { + return { + grossAtomic: '250000', + allocationState: 'pending_cogs_reconciliation', + holderCredits: [], + ancestorCredits: [], + journalEntries: [{ + category: 'unresolved-execution-accounting', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'hold:execution-accounting-reconciliation', + amountAtomic: '250000', + }], + }; +} + +test('exact retries are no-ops and conflicting idempotency reuse fails closed', () => { + const journal = fixture(); + const first = journal.requestInvocation(declaration); + assert.deepEqual(journal.requestInvocation(declaration), first); + assert.equal(journal.events.length, 1); + assert.throws(() => journal.requestInvocation({ + ...declaration, + skillVersionHash: `sha256:${'f'.repeat(64)}`, + }), /idempotency key already binds/); + assert.equal(journal.events.length, 1); +}); + +test('a settled execution failure keeps its transaction, full-gross hold, and HTTP status', () => { + const journal = fixture(); + settle(journal); + const claim = journal.startExecution(declaration.idempotencyKey); + journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: claim.record.execution.executionAttemptId, + outcome: 'failed', + failureClass: 'UPSTREAM_500', + message: 'provider returned HTTP 500', + outcomeHash: null, + httpStatus: 500, + accounting: pendingFailureAccounting(), + }); + const bundle = journal.issueReceipt(declaration.idempotencyKey); + assert.equal(bundle.receipt.payment.state, 'settled'); + assert.equal(bundle.receipt.payment.txHash, txHash); + assert.equal(bundle.receipt.execution.state, 'failed'); + assert.equal(bundle.receipt.execution.httpStatus, 500); + assert.deepEqual(bundle.receipt.accounting, pendingFailureAccounting()); + assert.equal(verifySignedReceipt(bundle, trustFor(journal)), true); +}); + +test('an unresolved settlement reconciles once by its payment reference', () => { + const journal = fixture(); + offer(journal); + journal.markExternalPaymentSigned(declaration.idempotencyKey, { settlementReference, payer }); + journal.markExternalPaymentUnresolved(declaration.idempotencyKey, { reason: 'facilitator response lost' }); + const before = journal.events.length; + journal.reconcileExternalSettlement({ settlementReference, txHash, payer }); + journal.reconcileExternalSettlement({ settlementReference, txHash, payer }); + assert.equal(journal.events.length, before + 1); + assert.equal(journal.getBySettlementReference(settlementReference).payment.state, 'settled'); + assert.equal(journal.getByTxHash(txHash).idempotencyKey, declaration.idempotencyKey); +}); + +test('settlement and transaction indexes canonicalize and reject collisions', () => { + const journal = fixture(); + const second = { ...declaration, idempotencyKey: 'idem-0002', requestHash: `sha256:${'9'.repeat(64)}` }; + offer(journal); + journal.markExternalPaymentSigned(declaration.idempotencyKey, { + settlementReference: settlementReference.toUpperCase().replace('0X', '0x'), + payer: payer.toUpperCase().replace('0X', '0x'), + }); + assert.equal(journal.getByIdempotencyKey(declaration.idempotencyKey).payment.payer, payer); + offer(journal, second); + assert.throws(() => journal.markExternalPaymentSigned(second.idempotencyKey, { + settlementReference, + payer, + }), /settlement reference already binds/); +}); + +function temporaryAuthority(prefix = 'collar-journal-') { + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + return { + directory, + filePath: path.join(directory, 'events.jsonl'), + signingKeyPath: path.join(directory, 'receipt-key.pem'), + }; +} + +test('JSONL replay reconstructs a terminal record and refuses rewritten signed history', () => { + const { filePath, signingKeyPath } = temporaryAuthority(); + const journal = createInvocationJournal({ filePath, signingKeyPath, createId: () => 'inv-persistent' }); + settle(journal); + const claim = journal.startExecution(declaration.idempotencyKey); + journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: claim.record.execution.executionAttemptId, + outcome: 'succeeded', + failureClass: null, + message: null, + outcomeHash: `sha256:${'7'.repeat(64)}`, + httpStatus: 200, + accounting: { grossAtomic: '250000', allocationState: 'finalized', holderCredits: [], ancestorCredits: [], journalEntries: [] }, + }); + const original = journal.issueReceipt(declaration.idempotencyKey); + const reopened = createInvocationJournal({ filePath, signingKeyPath }); + assert.deepEqual(reopened.getByIdempotencyKey(declaration.idempotencyKey), journal.getByIdempotencyKey(declaration.idempotencyKey)); + assert.deepEqual(reopened.issueReceipt(declaration.idempotencyKey), original); + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600); + assert.equal(fs.statSync(signingKeyPath).mode & 0o777, 0o600); + + const lines = fs.readFileSync(filePath, 'utf8').trimEnd().split('\n'); + const event = JSON.parse(lines[0]); + event.data.creatorId = 'attacker'; + const { eventHash: ignoredHash, eventSignature: preservedSignature, ...unsigned } = event; + event.eventHash = crypto.createHash('sha256').update(canonicalJson(unsigned)).digest('hex'); + event.eventSignature = preservedSignature; + lines[0] = JSON.stringify(event); + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, { mode: 0o600 }); + assert.throws(() => createInvocationJournal({ filePath, signingKeyPath }), /event signature mismatch/); +}); + +test('persistent authority rejects checkout, symlink, relative, non-file, and broad-permission paths', () => { + const { directory, filePath, signingKeyPath } = temporaryAuthority('collar-paths-'); + const journal = createInvocationJournal({ filePath, signingKeyPath }); + journal.requestInvocation(declaration); + fs.chmodSync(filePath, 0o644); + assert.throws(() => createInvocationJournal({ filePath, signingKeyPath }), /exactly 0600/); + fs.chmodSync(filePath, 0o600); + const keyLink = path.join(directory, 'key-link.pem'); + fs.symlinkSync(signingKeyPath, keyLink); + assert.throws(() => createInvocationJournal({ filePath: path.join(directory, 'other.jsonl'), signingKeyPath: keyLink }), /non-symlink/); + const directoryTarget = path.join(directory, 'not-a-file'); + fs.mkdirSync(directoryTarget); + assert.throws(() => createInvocationJournal({ filePath: directoryTarget, signingKeyPath }), /regular non-symlink file/); + assert.throws(() => createInvocationJournal({ filePath: 'relative.jsonl', signingKeyPath }), /explicit absolute/); + assert.throws(() => createInvocationJournal({ + filePath: fileURLToPath(new URL('../unsafe-journal.jsonl', import.meta.url)), + signingKeyPath, + }), /outside the repository checkout/); +}); + +test('a receipt cannot authenticate itself and tampering invalidates it', () => { + const journal = fixture(); + settle(journal); + const claim = journal.startExecution(declaration.idempotencyKey); + journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: claim.record.execution.executionAttemptId, + outcome: 'failed', failureClass: 'FAULT', message: 'fault', outcomeHash: null, + httpStatus: 500, accounting: pendingFailureAccounting(), + }); + const bundle = journal.issueReceipt(declaration.idempotencyKey); + const tampered = structuredClone(bundle); + tampered.receipt.payment.txHash = `0x${'9'.repeat(64)}`; + assert.equal(verifySignedReceipt(tampered, trustFor(journal)), false); + const attacker = fixture({ createId: () => 'inv-attacker' }); + settle(attacker); + const attackerClaim = attacker.startExecution(declaration.idempotencyKey); + attacker.finishExecution(declaration.idempotencyKey, { + executionAttemptId: attackerClaim.record.execution.executionAttemptId, + outcome: 'failed', failureClass: 'FAULT', message: 'fault', outcomeHash: null, + httpStatus: 500, accounting: pendingFailureAccounting(), + }); + assert.equal(verifySignedReceipt(attacker.issueReceipt(declaration.idempotencyKey), trustFor(journal)), false); +}); + +test('refund reverses only a terminal failed full-gross hold and issues a signed revision', () => { + const journal = fixture(); + settle(journal); + const claim = journal.startExecution(declaration.idempotencyKey); + journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: claim.record.execution.executionAttemptId, + outcome: 'failed', failureClass: 'COGS_UNKNOWN', message: 'fault', outcomeHash: null, + httpStatus: 500, accounting: pendingFailureAccounting(), + }); + const original = journal.issueReceipt(declaration.idempotencyKey); + const request = { + reason: 'settled execution failure', + refundReference: `refund:${'5'.repeat(64)}`, + refundAmountAtomic: '250000', + }; + assert.throws(() => journal.refundExternalPayment(declaration.idempotencyKey, { ...request, refundAmountAtomic: '249999' }), /full settled gross/); + journal.refundExternalPayment(declaration.idempotencyKey, request); + const revised = journal.issueReceipt(declaration.idempotencyKey); + assert.equal(revised.receipt.revision, 2); + assert.equal(revised.receipt.supersedesReceiptHash, original.receiptHash); + assert.equal(revised.receipt.payment.state, 'refunded'); + assert.equal(revised.receipt.payment.refundAmountAtomic, '250000'); + assert.equal(revised.receipt.payment.refundAccounting.reversalEntries.length, 2); + assert.equal(verifySignedReceipt(original, trustFor(journal)), true); + assert.equal(verifySignedReceipt(revised, trustFor(journal)), true); +}); + +const waitForExit = (child) => new Promise((resolve, reject) => { + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('exit', (code, signal) => code === 0 + ? resolve() + : reject(new Error(`fixture exited ${code ?? signal}: ${stderr}`))); +}); + +async function waitForFiles(paths) { + for (let attempt = 0; attempt < 300; attempt += 1) { + if (paths.every((candidate) => fs.existsSync(candidate))) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`fixtures did not become ready: ${paths.join(', ')}`); +} + +test('two same-host processes serialize signed hash-chained appends', async () => { + const { directory, filePath, signingKeyPath } = temporaryAuthority('collar-writers-'); + const barrierPath = path.join(directory, 'start'); + loadOrCreateReceiptSigner(signingKeyPath); + const worker = fileURLToPath(new URL('./journal-writer-fixture.mjs', import.meta.url)); + const keys = ['idem-process-a', 'idem-process-b']; + const children = keys.map((key) => spawn(process.execPath, [ + worker, filePath, signingKeyPath, barrierPath, key, path.join(directory, `${key}.ready`), + ], { stdio: ['ignore', 'ignore', 'pipe'] })); + await waitForFiles(keys.map((key) => path.join(directory, `${key}.ready`))); + fs.writeFileSync(barrierPath, 'go', { flag: 'wx' }); + await Promise.all(children.map(waitForExit)); + const reopened = createInvocationJournal({ filePath, signingKeyPath }); + assert.ok(reopened.getByIdempotencyKey(keys[0])); + assert.ok(reopened.getByIdempotencyKey(keys[1])); + assert.deepEqual(reopened.events.map(({ sequence }) => sequence), [1, 2]); + assert.equal(reopened.events[1].previousHash, reopened.events[0].eventHash); +}); + +test('a second process sees the complete frozen offer from the first', async () => { + const { directory, filePath, signingKeyPath } = temporaryAuthority('collar-reader-'); + const outputPath = path.join(directory, 'quote.json'); + const writer = createInvocationJournal({ filePath, signingKeyPath }); + offer(writer); + const child = spawn(process.execPath, [ + fileURLToPath(new URL('./journal-reader-fixture.mjs', import.meta.url)), + filePath, signingKeyPath, declaration.idempotencyKey, outputPath, + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + await waitForExit(child); + assert.deepEqual(JSON.parse(fs.readFileSync(outputPath, 'utf8')), requirements); +}); + +test('explicit stale-lock recovery uses an immutable lease claim and never deletes a replacement owner', () => { + const { filePath, signingKeyPath } = temporaryAuthority('collar-lock-'); + let armed = false; + const stale = { + leaseId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + hostname: os.hostname(), + pid: 999_999_999, + startedAtUtc: '2026-07-17T12:00:00.000Z', + }; + const movedReplacement = { + ...stale, + leaseId: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + pid: process.pid, + startedAtUtc: '2026-07-17T12:00:01.000Z', + }; + const newOwner = { + ...stale, + leaseId: 'cccccccccccccccccccccccccccccccc', + pid: process.pid, + startedAtUtc: '2026-07-17T12:00:02.000Z', + }; + const hooks = { + isProcessAlive: () => { + if (armed) { + const replacementPath = `${filePath}.lock.replacement`; + fs.writeFileSync(replacementPath, `${JSON.stringify(movedReplacement)}\n`, { flag: 'wx', mode: 0o600 }); + fs.renameSync(replacementPath, `${filePath}.lock`); + } + return false; + }, + afterLeaseClaim: () => { + if (armed) fs.writeFileSync(`${filePath}.lock`, `${JSON.stringify(newOwner)}\n`, { flag: 'wx', mode: 0o600 }); + }, + }; + const journal = createInvocationJournal({ filePath, signingKeyPath, lockTestHooks: hooks }); + fs.writeFileSync(`${filePath}.lock`, `${JSON.stringify(stale)}\n`, { flag: 'wx', mode: 0o600 }); + armed = true; + assert.throws(() => journal.recoverStaleLock({ expectedLeaseId: stale.leaseId }), /retained at|owner changed/); + assert.deepEqual(JSON.parse(fs.readFileSync(`${filePath}.lock`, 'utf8')), newOwner); + assert.equal(fs.readdirSync(path.dirname(filePath)).filter((name) => name.endsWith('.claim')).length, 1); +}); + +test('malformed, unknown-liveness, and different-host locks fail closed', () => { + for (const scenario of ['malformed', 'unknown', 'different-host']) { + const { filePath, signingKeyPath } = temporaryAuthority(`collar-lock-${scenario}-`); + const journal = createInvocationJournal({ + filePath, + signingKeyPath, + lockTestHooks: scenario === 'unknown' ? { isProcessAlive: () => undefined } : {}, + }); + const lockPath = `${filePath}.lock`; + const owner = { + leaseId: 'dddddddddddddddddddddddddddddddd', + hostname: scenario === 'different-host' ? 'other-host.example' : os.hostname(), + pid: 999_999_998, + startedAtUtc: '2026-07-17T12:00:00.000Z', + }; + fs.writeFileSync( + lockPath, + scenario === 'malformed' ? '{not-json}\n' : `${JSON.stringify(owner)}\n`, + { flag: 'wx', mode: 0o600 }, + ); + if (scenario === 'malformed') { + assert.throws(() => journal.recoverStaleLock({ expectedLeaseId: owner.leaseId }), /malformed/); + } else if (scenario === 'unknown') { + assert.throws(() => journal.recoverStaleLock({ expectedLeaseId: owner.leaseId }), /no boolean proof/); + } else { + assert.throws(() => journal.recoverStaleLock({ expectedLeaseId: owner.leaseId }), /different host/); + } + assert.equal(fs.existsSync(lockPath), true); + } +}); + +test('same-host process-probe errors fail closed and exact stale recovery succeeds only with absence proof', () => { + const denied = temporaryAuthority('collar-lock-eperm-'); + const deniedJournal = createInvocationJournal({ + filePath: denied.filePath, + signingKeyPath: denied.signingKeyPath, + lockTestHooks: { isProcessAlive: () => { + const error = new Error('EPERM'); + error.code = 'EPERM'; + throw error; + } }, + }); + const deniedOwner = { + leaseId: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + hostname: os.hostname(), + pid: 999_999_997, + startedAtUtc: '2026-07-17T12:00:00.000Z', + }; + fs.writeFileSync(`${denied.filePath}.lock`, `${JSON.stringify(deniedOwner)}\n`, { flag: 'wx', mode: 0o600 }); + assert.throws(() => deniedJournal.recoverStaleLock({ expectedLeaseId: deniedOwner.leaseId }), /EPERM/); + assert.equal(fs.existsSync(`${denied.filePath}.lock`), true); + + const recoverable = temporaryAuthority('collar-lock-recover-'); + const recoverableJournal = createInvocationJournal({ + filePath: recoverable.filePath, + signingKeyPath: recoverable.signingKeyPath, + lockTestHooks: { isProcessAlive: () => false }, + }); + const recoverableOwner = { ...deniedOwner, leaseId: 'ffffffffffffffffffffffffffffffff' }; + fs.writeFileSync(`${recoverable.filePath}.lock`, `${JSON.stringify(recoverableOwner)}\n`, { flag: 'wx', mode: 0o600 }); + assert.throws(() => recoverableJournal.recoverStaleLock({ + expectedLeaseId: '00000000000000000000000000000000', + }), /does not match/); + recoverableJournal.recoverStaleLock({ expectedLeaseId: recoverableOwner.leaseId }); + assert.equal(fs.existsSync(`${recoverable.filePath}.lock`), false); + assert.deepEqual(recoverableJournal.events, []); +}); + +test('normal lease release never unlinks a replacement owner and uses private lock/claim paths', () => { + const { directory, filePath, signingKeyPath } = temporaryAuthority('collar-lock-release-'); + const replacementOwner = { + leaseId: 'abababababababababababababababab', + hostname: os.hostname(), + pid: process.pid, + startedAtUtc: '2026-07-17T12:00:03.000Z', + }; + let observedMode = null; + const journal = createInvocationJournal({ + filePath, + signingKeyPath, + now: () => { + observedMode = fs.statSync(`${filePath}.lock`).mode & 0o777; + const replacementPath = `${filePath}.lock.replacement`; + fs.writeFileSync(replacementPath, `${JSON.stringify(replacementOwner)}\n`, { flag: 'wx', mode: 0o600 }); + fs.renameSync(replacementPath, `${filePath}.lock`); + return '2026-07-17T12:00:04.000Z'; + }, + }); + assert.throws(() => journal.requestInvocation(declaration), /lease CAS failed.*restored/); + assert.equal(observedMode, 0o600); + assert.deepEqual(JSON.parse(fs.readFileSync(`${filePath}.lock`, 'utf8')), replacementOwner); + assert.equal(fs.readdirSync(directory).filter((name) => name.endsWith('.claim')).length, 0); + assert.equal(journal.lockPath, `${filePath}.lock`); +}); diff --git a/spikes/pi-wielder/tests/journal-reader-fixture.mjs b/spikes/pi-wielder/tests/journal-reader-fixture.mjs new file mode 100644 index 0000000..71bb729 --- /dev/null +++ b/spikes/pi-wielder/tests/journal-reader-fixture.mjs @@ -0,0 +1,9 @@ +import fs from 'node:fs'; + +import { createInvocationJournal } from '../src/invocation-journal.mjs'; + +const [filePath, signingKeyPath, idempotencyKey, outputPath] = process.argv.slice(2); +const journal = createInvocationJournal({ filePath, signingKeyPath }); +const persisted = journal.getByIdempotencyKey(idempotencyKey)?.quote?.requirements; +if (!persisted) throw new Error('persisted frozen offer is not visible'); +fs.writeFileSync(outputPath, JSON.stringify(persisted), { flag: 'wx' }); diff --git a/spikes/pi-wielder/tests/journal-writer-fixture.mjs b/spikes/pi-wielder/tests/journal-writer-fixture.mjs new file mode 100644 index 0000000..0e0fcee --- /dev/null +++ b/spikes/pi-wielder/tests/journal-writer-fixture.mjs @@ -0,0 +1,18 @@ +import fs from 'node:fs'; + +import { createInvocationJournal } from '../src/invocation-journal.mjs'; + +const [filePath, signingKeyPath, barrierPath, idempotencyKey, readyPath] = process.argv.slice(2); +const journal = createInvocationJournal({ filePath, signingKeyPath }); +fs.writeFileSync(readyPath, 'ready', { flag: 'wx' }); +while (!fs.existsSync(barrierPath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); +const digit = idempotencyKey.endsWith('-a') ? '1' : '2'; +journal.requestInvocation({ + idempotencyKey, + mode: 'external', + skillId: 'skill-a', + skillVersionHash: `sha256:${'a'.repeat(64)}`, + requestHash: `sha256:${digit.repeat(64)}`, + creatorId: 'creator-a', + beneficiaryId: null, +}); From 97428f907abbc75e2f9c1fef5b0013dede8875b7 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:06:33 -0400 Subject: [PATCH 064/165] spike: define internal Invocation award schemas --- .../internal-invocation-awards/package.json | 11 + .../internal-invocation-awards/src/schema.mjs | 272 ++++++++++++++++++ .../test/budget.test.mjs | 142 +++++++++ 3 files changed, 425 insertions(+) create mode 100644 spikes/internal-invocation-awards/package.json create mode 100644 spikes/internal-invocation-awards/src/schema.mjs create mode 100644 spikes/internal-invocation-awards/test/budget.test.mjs diff --git a/spikes/internal-invocation-awards/package.json b/spikes/internal-invocation-awards/package.json new file mode 100644 index 0000000..23852dd --- /dev/null +++ b/spikes/internal-invocation-awards/package.json @@ -0,0 +1,11 @@ +{ + "name": "internal-invocation-awards-spike", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Offline accounting spike for employer-funded internal Invocation awards; no real funds.", + "scripts": { + "test": "node --test test/*.test.mjs", + "demo": "node demo.mjs" + } +} diff --git a/spikes/internal-invocation-awards/src/schema.mjs b/spikes/internal-invocation-awards/src/schema.mjs new file mode 100644 index 0000000..1ae863d --- /dev/null +++ b/spikes/internal-invocation-awards/src/schema.mjs @@ -0,0 +1,272 @@ +export const AWARD_STATES = deepFreeze([ + 'measured', + 'vesting_pending', + 'earned', + 'payable', + 'paid', +]); + +export const INVOCATION_STATES = deepFreeze([ + 'requested', + 'quoted', + 'authorized', + 'executing', + 'succeeded', + 'failed', + 'unresolved', + 'cancelled', +]); + +const POLICY_KEYS = [ + 'schemaVersion', 'policyId', 'version', 'status', 'currency', 'atomicScale', + 'employerId', 'effectiveAt', 'expiresAt', 'permittedSkillIds', + 'permittedCreatorIds', 'permittedWielderIds', 'permittedCostCenters', + 'maxQuoteAtomic', 'awardRule', 'maxAwardPerInvocationAtomic', + 'maxAwardPerPeriodAtomic', 'selfInvocation', 'permittedManagerSignerIds', + 'permittedCredentialAuthorizerIds', 'permittedFinanceSignerIds', 'vestingRule', + 'paymentSchedule', 'terminationTreatment', 'paymentRail', +]; + +const AWARD_RULE_KEYS = ['type', 'awardRateBps', 'rateBase', 'rounding']; + +const QUOTE_KEYS = [ + 'schemaVersion', 'quoteId', 'invocationId', 'idempotencyKey', 'skillId', + 'skillVersionHash', 'creatorId', 'wielderId', 'beneficiaryId', 'costCenter', + 'policyId', 'policyVersion', 'maxExecutionCostAtomic', 'protocolFeeAtomic', + 'refundReserveAtomic', 'maxInvocationAwardAtomic', 'maxGrossAtomic', 'expiresAt', +]; + +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; + +export function deepFreeze(value) { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +export function cloneFrozen(value) { + return deepFreeze(structuredClone(value)); +} + +export function toAtomic(value) { + if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value)) { + throw new Error('atomic amount must be a non-negative decimal string'); + } + return BigInt(value); +} + +export function fromAtomic(value) { + if (typeof value !== 'bigint' || value < 0n) { + throw new Error('atomic amount must be a non-negative bigint'); + } + return value.toString(); +} + +export function sumAtomic(values) { + if (!Array.isArray(values)) throw new Error('atomic values must be an array'); + return values.reduce((sum, value) => sum + toAtomic(value), 0n); +} + +function requirePlainObject(value, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) { + throw new Error(`${label} must be a plain object`); + } +} + +export function requireExactKeys(value, keys, label) { + requirePlainObject(value, label); + const allowed = new Set(keys); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${label} has unknown key ${key}`); + } + for (const key of keys) { + if (!Object.hasOwn(value, key)) throw new Error(`${label} is missing key ${key}`); + } +} + +export function parseUtc(value, label) { + if (typeof value !== 'string') throw new Error(`${label} must be a UTC timestamp`); + const millis = Date.parse(value); + if (!Number.isFinite(millis) || new Date(millis).toISOString() !== value) { + throw new Error(`${label} must be a canonical UTC timestamp`); + } + return millis; +} + +function nowMillis(now) { + if (now instanceof Date) return now.getTime(); + return parseUtc(now, 'now'); +} + +function requireString(value, label) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function requireStringSet(value, label) { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${label} must be a non-empty array`); + } + const seen = new Set(); + for (const item of value) { + requireString(item, `${label} entry`); + if (seen.has(item)) throw new Error(`${label} contains duplicate ${item}`); + seen.add(item); + } +} + +function assertPermitted(value, permitted, label) { + if (!permitted.includes(value)) throw new Error(`${label} is not permitted`); +} + +export function validatePolicy(input, now) { + requireExactKeys(input, POLICY_KEYS, 'policy'); + if (input.schemaVersion !== 1) throw new Error('policy schemaVersion must equal 1'); + requireString(input.policyId, 'policyId'); + if (!Number.isSafeInteger(input.version) || input.version < 1) { + throw new Error('policy version must be a positive integer'); + } + if (input.status !== 'active') throw new Error('policy must be active'); + if (typeof input.currency !== 'string' || !/^[A-Z]{3,12}$/.test(input.currency)) { + throw new Error('policy currency must be an uppercase denomination identifier'); + } + if (!Number.isSafeInteger(input.atomicScale) || input.atomicScale < 0 || input.atomicScale > 18) { + throw new Error('policy atomicScale must be an integer from 0 through 18'); + } + requireString(input.employerId, 'employerId'); + const effectiveAt = parseUtc(input.effectiveAt, 'policy effectiveAt'); + const expiresAt = parseUtc(input.expiresAt, 'policy expiresAt'); + if (expiresAt <= effectiveAt) throw new Error('policy expiresAt must follow effectiveAt'); + const at = nowMillis(now); + if (at < effectiveAt) throw new Error('policy is not yet effective'); + if (at >= expiresAt) throw new Error('policy expired'); + + for (const key of [ + 'permittedSkillIds', 'permittedCreatorIds', 'permittedWielderIds', + 'permittedCostCenters', 'permittedManagerSignerIds', + 'permittedCredentialAuthorizerIds', 'permittedFinanceSignerIds', + ]) requireStringSet(input[key], key); + + toAtomic(input.maxQuoteAtomic); + toAtomic(input.maxAwardPerInvocationAtomic); + toAtomic(input.maxAwardPerPeriodAtomic); + if (toAtomic(input.maxAwardPerInvocationAtomic) > toAtomic(input.maxAwardPerPeriodAtomic)) { + throw new Error('maxAwardPerInvocationAtomic exceeds maxAwardPerPeriodAtomic'); + } + + requireExactKeys(input.awardRule, AWARD_RULE_KEYS, 'awardRule'); + const ruleProblems = []; + if (input.awardRule.type !== 'residual_after_execution_fee_and_reserve') { + ruleProblems.push('type must equal residual_after_execution_fee_and_reserve'); + } + if (input.awardRule.awardRateBps !== 10000) { + ruleProblems.push('awardRateBps must equal 10000'); + } + if (input.awardRule.rateBase !== 'post_cost_residual') { + ruleProblems.push('rateBase must equal post_cost_residual'); + } + if (input.awardRule.rounding !== 'floor_atomic') { + ruleProblems.push('rounding must equal floor_atomic'); + } + if (ruleProblems.length > 0) { + throw new Error(`unsupported award rule: ${ruleProblems.join('; ')}`); + } + if (!['excluded', 'manager_approval_required'].includes(input.selfInvocation)) { + throw new Error('unsupported selfInvocation policy'); + } + if (!['none', 'future_policy_controlled'].includes(input.vestingRule)) { + throw new Error('unsupported vestingRule'); + } + requireString(input.paymentSchedule, 'paymentSchedule'); + requireString(input.terminationTreatment, 'terminationTreatment'); + requireString(input.paymentRail, 'paymentRail'); + return cloneFrozen(input); +} + +export function validateQuote(input, policyInput, now) { + requireExactKeys(input, QUOTE_KEYS, 'quote'); + const policy = validatePolicy(policyInput, now); + if (input.schemaVersion !== 1) throw new Error('quote schemaVersion must equal 1'); + for (const key of ['quoteId', 'invocationId', 'idempotencyKey', 'skillId', 'creatorId', + 'wielderId', 'beneficiaryId', 'costCenter', 'policyId']) { + requireString(input[key], key); + } + if (!SHA256_PATTERN.test(input.skillVersionHash)) { + throw new Error('skillVersionHash must be a lowercase SHA-256 hash'); + } + if (input.policyId !== policy.policyId || input.policyVersion !== policy.version) { + throw new Error('quote policy binding does not match effective policy'); + } + assertPermitted(input.skillId, policy.permittedSkillIds, 'Skill'); + assertPermitted(input.creatorId, policy.permittedCreatorIds, 'Creator'); + assertPermitted(input.wielderId, policy.permittedWielderIds, 'Wielder'); + assertPermitted(input.costCenter, policy.permittedCostCenters, 'cost center'); + if (input.beneficiaryId !== policy.employerId) { + throw new Error('Beneficiary must equal the policy employer'); + } + + const componentNames = [ + 'maxExecutionCostAtomic', 'protocolFeeAtomic', 'refundReserveAtomic', + 'maxInvocationAwardAtomic', + ]; + for (const key of [...componentNames, 'maxGrossAtomic']) toAtomic(input[key]); + const expectedGross = sumAtomic(componentNames.map((key) => input[key])); + if (toAtomic(input.maxGrossAtomic) !== expectedGross) { + throw new Error(`maxGrossAtomic must equal ${expectedGross}`); + } + if (toAtomic(input.maxGrossAtomic) > toAtomic(policy.maxQuoteAtomic)) { + throw new Error('maxGrossAtomic exceeds policy maxQuoteAtomic'); + } + if (toAtomic(input.maxInvocationAwardAtomic) > toAtomic(policy.maxAwardPerInvocationAtomic)) { + throw new Error('maxInvocationAwardAtomic exceeds policy cap'); + } + const expiry = parseUtc(input.expiresAt, 'quote expiresAt'); + const at = nowMillis(now); + if (at >= expiry) throw new Error('quote expired'); + if (expiry > parseUtc(policy.expiresAt, 'policy expiresAt')) { + throw new Error('quote expiry exceeds policy expiry'); + } + return cloneFrozen(input); +} + +const UNRESOLVED_SENTINEL = deepFreeze({ + kind: 'unresolved_after_start', + reason: 'malformed_outcome', +}); + +export function parseExecutorOutcome(value, quote) { + try { + requirePlainObject(value, 'executor outcome'); + if (value.kind === 'succeeded') { + requireExactKeys(value, ['kind', 'executionCostAtomic', 'outputHash'], 'executor outcome'); + if (!SHA256_PATTERN.test(value.outputHash)) throw new Error('invalid outputHash'); + if (toAtomic(value.executionCostAtomic) > toAtomic(quote.maxExecutionCostAtomic)) { + throw new Error('execution cost exceeds quote'); + } + return cloneFrozen(value); + } + if (value.kind === 'failed_after_start') { + requireExactKeys(value, ['kind', 'executionCostAtomic', 'failureClass'], 'executor outcome'); + if (!['provider_error', 'skill_error', 'invalid_output'].includes(value.failureClass)) { + throw new Error('invalid failureClass'); + } + if (toAtomic(value.executionCostAtomic) > toAtomic(quote.maxExecutionCostAtomic)) { + throw new Error('execution cost exceeds quote'); + } + return cloneFrozen(value); + } + if (value.kind === 'unresolved_after_start') { + requireExactKeys(value, ['kind', 'reason'], 'executor outcome'); + if (!['executor_threw', 'malformed_outcome', 'cost_unknown'].includes(value.reason)) { + throw new Error('invalid unresolved reason'); + } + return cloneFrozen(value); + } + throw new Error('unknown outcome kind'); + } catch { + return UNRESOLVED_SENTINEL; + } +} diff --git a/spikes/internal-invocation-awards/test/budget.test.mjs b/spikes/internal-invocation-awards/test/budget.test.mjs new file mode 100644 index 0000000..0ac1299 --- /dev/null +++ b/spikes/internal-invocation-awards/test/budget.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + parseExecutorOutcome, + sumAtomic, + toAtomic, + validatePolicy, + validateQuote, +} from '../src/schema.mjs'; + +const NOW = '2026-07-17T00:01:00.000Z'; +const ACTIVE_POLICY = { + schemaVersion: 1, + policyId: 'policy-megacorp-ledger-recon', + version: 1, + status: 'active', + currency: 'USD', + atomicScale: 6, + employerId: 'megacorp', + effectiveAt: '2026-07-17T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + permittedSkillIds: ['ledger-recon'], + permittedCreatorIds: ['sam'], + permittedWielderIds: ['megacorp-internal-agent'], + permittedCostCenters: ['platform-engineering'], + maxQuoteAtomic: '4000000', + awardRule: { + type: 'residual_after_execution_fee_and_reserve', + awardRateBps: 10000, + rateBase: 'post_cost_residual', + rounding: 'floor_atomic', + }, + maxAwardPerInvocationAtomic: '2000000', + maxAwardPerPeriodAtomic: '100000000', + selfInvocation: 'manager_approval_required', + permittedManagerSignerIds: ['manager-alex'], + permittedCredentialAuthorizerIds: ['megacorp-collar-authorizer'], + permittedFinanceSignerIds: ['megacorp-finance'], + vestingRule: 'none', + paymentSchedule: 'monthly_in_arrears', + terminationTreatment: 'earned_remains_payable_unearned_cancelled', + paymentRail: 'employer_payroll_or_ap', +}; + +const QUOTE = { + schemaVersion: 1, + quoteId: 'quote-inv-001', + invocationId: 'inv-001', + idempotencyKey: 'run-ledger-recon-001', + skillId: 'ledger-recon', + skillVersionHash: `sha256:${'1'.repeat(64)}`, + creatorId: 'sam', + wielderId: 'megacorp-internal-agent', + beneficiaryId: 'megacorp', + costCenter: 'platform-engineering', + policyId: ACTIVE_POLICY.policyId, + policyVersion: 1, + maxExecutionCostAtomic: '1000000', + protocolFeeAtomic: '25000', + refundReserveAtomic: '25000', + maxInvocationAwardAtomic: '2000000', + maxGrossAtomic: '3050000', + expiresAt: '2026-07-17T00:05:00.000Z', +}; + +test('atomic boundary accepts canonical decimal strings only', () => { + assert.equal(toAtomic('3050000'), 3_050_000n); + assert.equal(sumAtomic(['1000000', '25000', '25000', '2000000']), 3_050_000n); + for (const value of [-1, 1n, '-1', '01', '1.5', '', ' 1']) { + assert.throws(() => toAtomic(value), /non-negative decimal string/); + } +}); + +test('policy validation is effective-dated, exact, recursively frozen, and denomination neutral', () => { + const policy = validatePolicy(ACTIVE_POLICY, NOW); + assert.ok(Object.isFrozen(policy)); + assert.ok(Object.isFrozen(policy.awardRule)); + assert.ok(Object.isFrozen(policy.permittedSkillIds)); + assert.throws(() => validatePolicy({ ...ACTIVE_POLICY, status: 'draft' }, NOW), /must be active/); + assert.throws( + () => validatePolicy({ ...ACTIVE_POLICY, effectiveAt: '2026-07-18T00:00:00.000Z' }, NOW), + /not yet effective/, + ); + assert.throws( + () => validatePolicy({ ...ACTIVE_POLICY, expiresAt: NOW }, NOW), + /expired/, + ); + assert.throws( + () => validatePolicy({ + ...ACTIVE_POLICY, + awardRule: { ...ACTIVE_POLICY.awardRule, awardRateBps: 9000 }, + }, NOW), + /unsupported award rule.*awardRateBps must equal 10000/, + ); + assert.throws(() => validatePolicy({ ...ACTIVE_POLICY, surprise: true }, NOW), /unknown key surprise/); + assert.equal(validatePolicy({ ...ACTIVE_POLICY, currency: 'EUR', atomicScale: 2 }, NOW).currency, 'EUR'); +}); + +test('quote validation binds exact maximums and keeps manager approval separate', () => { + const quote = validateQuote(QUOTE, ACTIVE_POLICY, NOW); + assert.ok(Object.isFrozen(quote)); + assert.throws( + () => validateQuote({ ...QUOTE, maxGrossAtomic: '3049999' }, ACTIVE_POLICY, NOW), + /maxGrossAtomic.*3050000/, + ); + assert.throws( + () => validateQuote({ ...QUOTE, wielderId: 'unknown-agent' }, ACTIVE_POLICY, NOW), + /Wielder is not permitted/, + ); + assert.throws( + () => validateQuote({ ...QUOTE, selfInvocationApproval: {} }, ACTIVE_POLICY, NOW), + /unknown key selfInvocationApproval/, + ); + assert.throws( + () => validateQuote({ ...QUOTE, expiresAt: NOW }, ACTIVE_POLICY, NOW), + /quote expired/, + ); +}); + +test('executor outcomes fail closed without inventing zero COGS', () => { + assert.deepEqual(parseExecutorOutcome({ + kind: 'succeeded', + executionCostAtomic: '700000', + outputHash: `sha256:${'a'.repeat(64)}`, + }, QUOTE), { + kind: 'succeeded', + executionCostAtomic: '700000', + outputHash: `sha256:${'a'.repeat(64)}`, + }); + assert.deepEqual(parseExecutorOutcome({ kind: 'succeeded' }, QUOTE), { + kind: 'unresolved_after_start', + reason: 'malformed_outcome', + }); + assert.deepEqual(parseExecutorOutcome({ + kind: 'failed_after_start', executionCostAtomic: '1000001', failureClass: 'provider_error', + }, QUOTE), { + kind: 'unresolved_after_start', reason: 'malformed_outcome', + }); +}); + +export { ACTIVE_POLICY, NOW, QUOTE }; From 461c16cc91270472a3a3de749cb89acc0f9cc1ed Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:08:04 -0400 Subject: [PATCH 065/165] fix: harden Invocation journal durability --- spikes/pi-wielder/src/invocation-journal.mjs | 39 +++++++++---- .../tests/invocation-journal.test.mjs | 58 +++++++++++++++++++ 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/spikes/pi-wielder/src/invocation-journal.mjs b/spikes/pi-wielder/src/invocation-journal.mjs index 96679f6..c372614 100644 --- a/spikes/pi-wielder/src/invocation-journal.mjs +++ b/spikes/pi-wielder/src/invocation-journal.mjs @@ -8,6 +8,7 @@ const TERMINAL_EXECUTION = new Set(['succeeded', 'failed', 'cancelled']); const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../', import.meta.url))); const LEASE_ID = /^[0-9a-f]{32}$/; const waitCell = new Int32Array(new SharedArrayBuffer(4)); +const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0; function canonicalize(value) { if (Array.isArray(value)) return value.map(canonicalize); @@ -28,6 +29,7 @@ function requireText(value, label) { } function requireAtomicString(value, label) { + if (typeof value !== 'string') throw new Error(`${label} must be a string`); const text = requireText(value, label); if (!/^(0|[1-9]\d*)$/.test(text)) { throw new Error(`${label} must be a canonical non-negative atomic string`); @@ -92,7 +94,7 @@ function writeAll(descriptor, bytes) { } function readPrivateFile(filePath, label) { - const descriptor = fs.openSync(filePath, 'r'); + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | NOFOLLOW); try { const stat = fs.fstatSync(descriptor); if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) { @@ -194,23 +196,25 @@ function acquireLease(lockPath, hooks = {}, timeoutMs = 5_000) { while (true) { let descriptor = null; try { - descriptor = fs.openSync(lockPath, 'wx', 0o600); + descriptor = fs.openSync( + lockPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, + 0o600, + ); writeAll(descriptor, Buffer.from(bytes)); fs.fsyncSync(descriptor); fs.closeSync(descriptor); fsyncDirectory(path.dirname(lockPath)); + hooks.afterLeaseCreated?.(lockPath, owner); return { owner, bytes }; } catch (error) { if (descriptor != null) { try { fs.closeSync(descriptor); } catch { /* already closed */ } } if (error.code !== 'EEXIST') { - if (fs.existsSync(lockPath)) { - try { - const current = readLeaseOwner(lockPath); - if (current.owner.leaseId === owner.leaseId) fs.unlinkSync(lockPath); - } catch { /* fail closed around unrelated owner */ } - } + // Never read-then-unlink here: the pathname may already belong to a + // replacement owner. A partially acquired lease remains fail-closed + // and can be removed only through exact-ID stale recovery. throw error; } const existing = readLeaseOwner(lockPath); @@ -276,7 +280,11 @@ export function loadOrCreateReceiptSigner(keyPath) { const pair = crypto.generateKeyPairSync('ed25519'); privateKey = pair.privateKey; const temporary = `${canonicalKeyPath}.${process.pid}.${crypto.randomUUID()}.tmp`; - const descriptor = fs.openSync(temporary, 'wx', 0o600); + const descriptor = fs.openSync( + temporary, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, + 0o600, + ); try { writeAll(descriptor, Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' }))); fs.fsyncSync(descriptor); @@ -729,10 +737,21 @@ export function createInvocationJournal({ eventHash, eventSignature: receiptSigner.signHash(eventHash), }; + // Validation must happen before the first durable byte. Replay must + // never encounter an event that this process already knew was invalid. + validateEventForApply(event); if (journalPath) { const existed = fs.existsSync(journalPath); - const descriptor = fs.openSync(journalPath, 'a', 0o600); + const descriptor = fs.openSync( + journalPath, + fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | NOFOLLOW, + 0o600, + ); try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) { + throw new Error('persistent journal must remain a regular file with mode exactly 0600'); + } writeAll(descriptor, Buffer.from(`${JSON.stringify(event)}\n`)); fs.fsyncSync(descriptor); } finally { diff --git a/spikes/pi-wielder/tests/invocation-journal.test.mjs b/spikes/pi-wielder/tests/invocation-journal.test.mjs index ad07f80..69b4263 100644 --- a/spikes/pi-wielder/tests/invocation-journal.test.mjs +++ b/spikes/pi-wielder/tests/invocation-journal.test.mjs @@ -121,6 +121,17 @@ test('exact retries are no-ops and conflicting idempotency reuse fails closed', assert.equal(journal.events.length, 1); }); +test('atomic journal fields reject numeric coercion before state changes', () => { + const journal = fixture(); + journal.requestInvocation(declaration); + assert.throws(() => journal.offerExternalPayment(declaration.idempotencyKey, { + ...quote, + amountAtomic: 250000, + }), /must be a string/); + assert.equal(journal.events.length, 1); + assert.equal(journal.getByIdempotencyKey(declaration.idempotencyKey).quote, null); +}); + test('a settled execution failure keeps its transaction, full-gross hold, and HTTP status', () => { const journal = fixture(); settle(journal); @@ -213,6 +224,26 @@ test('JSONL replay reconstructs a terminal record and refuses rewritten signed h assert.throws(() => createInvocationJournal({ filePath, signingKeyPath }), /event signature mismatch/); }); +test('a rejected transition is validated before append and leaves durable bytes replayable', () => { + const { filePath, signingKeyPath } = temporaryAuthority('collar-prevalidate-'); + const journal = createInvocationJournal({ filePath, signingKeyPath }); + settle(journal); + const claim = journal.startExecution(declaration.idempotencyKey); + const before = fs.readFileSync(filePath); + assert.throws(() => journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: claim.record.execution.executionAttemptId, + outcome: 'failed', + failureClass: 'FAULT', + message: 'invalid status must not append', + outcomeHash: null, + httpStatus: 99, + accounting: pendingFailureAccounting(), + }), /HTTP status/); + assert.deepEqual(fs.readFileSync(filePath), before); + const reopened = createInvocationJournal({ filePath, signingKeyPath }); + assert.equal(reopened.getByIdempotencyKey(declaration.idempotencyKey).execution.state, 'executing'); +}); + test('persistent authority rejects checkout, symlink, relative, non-file, and broad-permission paths', () => { const { directory, filePath, signingKeyPath } = temporaryAuthority('collar-paths-'); const journal = createInvocationJournal({ filePath, signingKeyPath }); @@ -469,3 +500,30 @@ test('normal lease release never unlinks a replacement owner and uses private lo assert.equal(fs.readdirSync(directory).filter((name) => name.endsWith('.claim')).length, 0); assert.equal(journal.lockPath, `${filePath}.lock`); }); + +test('lease-acquisition failure never read-then-unlinks a replacement owner', () => { + const { filePath, signingKeyPath } = temporaryAuthority('collar-lock-acquire-'); + let armed = false; + const replacementOwner = { + leaseId: 'cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd', + hostname: os.hostname(), + pid: process.pid, + startedAtUtc: '2026-07-17T12:00:05.000Z', + }; + const journal = createInvocationJournal({ + filePath, + signingKeyPath, + lockTestHooks: { + afterLeaseCreated(lockPath) { + if (!armed) return; + const replacementPath = `${lockPath}.replacement`; + fs.writeFileSync(replacementPath, `${JSON.stringify(replacementOwner)}\n`, { flag: 'wx', mode: 0o600 }); + fs.renameSync(replacementPath, lockPath); + throw new Error('injected post-create failure'); + }, + }, + }); + armed = true; + assert.throws(() => journal.requestInvocation(declaration), /injected post-create failure/); + assert.deepEqual(JSON.parse(fs.readFileSync(`${filePath}.lock`, 'utf8')), replacementOwner); +}); From 573d9fdd40d51c215b74592cc94d88492a8e4fa5 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:10:32 -0400 Subject: [PATCH 066/165] spike: reserve employer Invocation budgets atomically --- .../internal-invocation-awards/src/budget.mjs | 445 ++++++++++++++++++ .../test/budget.test.mjs | 215 ++++++++- 2 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 spikes/internal-invocation-awards/src/budget.mjs diff --git a/spikes/internal-invocation-awards/src/budget.mjs b/spikes/internal-invocation-awards/src/budget.mjs new file mode 100644 index 0000000..932d71a --- /dev/null +++ b/spikes/internal-invocation-awards/src/budget.mjs @@ -0,0 +1,445 @@ +import { sign as cryptoSign, verify as cryptoVerify } from 'node:crypto'; + +import { allocateInternalGross } from '../../../prototype/atomic-money.mjs'; +import { + cloneFrozen, + deepFreeze, + fromAtomic, + parseUtc, + requireExactKeys, + sumAtomic, + toAtomic, + validatePolicy, + validateQuote, +} from './schema.mjs'; + +const BUDGET_AUTHORIZATION_KEYS = [ + 'schemaVersion', 'budgetId', 'policyId', 'policyVersion', 'period', 'currency', + 'atomicScale', 'allocatedAtomic', 'effectiveAt', 'expiresAt', 'signerId', +]; +const SIGNED_BUDGET_AUTHORIZATION_KEYS = [...BUDGET_AUTHORIZATION_KEYS, 'signature']; +const BUDGET_STATE_KEYS = [ + 'schemaVersion', 'budgetId', 'policyId', 'policyVersion', 'period', 'currency', + 'atomicScale', 'authorization', 'policy', 'allocatedAtomic', 'reservedAtomic', + 'consumedAtomic', 'releasedAtomic', 'revision', +]; +const RESERVATION_KEYS = [ + 'schemaVersion', 'reservationId', 'quote', 'state', 'reservedAtomic', 'revision', + 'executionAttemptId', 'authorizedAt', 'startedAt', 'finalizedAt', +]; + +function ordered(source, keys) { + return Object.fromEntries(keys.map((key) => [key, source[key]])); +} + +function canonicalBytes(source, keys) { + return new TextEncoder().encode(JSON.stringify(ordered(source, keys))); +} + +function requireNonEmpty(value, label) { + if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be non-empty`); +} + +function decodeSignature(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + throw new Error('budget signature must be canonical base64'); + } + const bytes = Buffer.from(value, 'base64'); + if (bytes.length !== 64 || bytes.toString('base64') !== value) { + throw new Error('budget signature must be a 64-byte canonical base64 Ed25519 signature'); + } + return bytes; +} + +function validateUnsignedAuthorization(input) { + requireExactKeys(input, BUDGET_AUTHORIZATION_KEYS, 'budget authorization'); + if (input.schemaVersion !== 1) throw new Error('budget authorization schemaVersion must equal 1'); + for (const key of ['budgetId', 'policyId', 'currency', 'signerId']) { + requireNonEmpty(input[key], key); + } + if (!Number.isSafeInteger(input.policyVersion) || input.policyVersion < 1) { + throw new Error('budget policyVersion must be a positive integer'); + } + if (typeof input.period !== 'string' || !/^\d{4}-(0[1-9]|1[0-2])$/.test(input.period)) { + throw new Error('budget period must be YYYY-MM'); + } + if (!Number.isSafeInteger(input.atomicScale) || input.atomicScale < 0 || input.atomicScale > 18) { + throw new Error('budget atomicScale must be an integer from 0 through 18'); + } + toAtomic(input.allocatedAtomic); + const effectiveAt = parseUtc(input.effectiveAt, 'budget effectiveAt'); + const expiresAt = parseUtc(input.expiresAt, 'budget expiresAt'); + if (expiresAt <= effectiveAt) throw new Error('budget expiresAt must follow effectiveAt'); + return cloneFrozen(input); +} + +export function canonicalBudgetBytes(unsignedBudget) { + const validated = validateUnsignedAuthorization(unsignedBudget); + return canonicalBytes(validated, BUDGET_AUTHORIZATION_KEYS); +} + +export function signBudget(unsignedBudget, privateKey) { + const validated = validateUnsignedAuthorization(unsignedBudget); + const signature = cryptoSign(null, canonicalBudgetBytes(validated), privateKey).toString('base64'); + return cloneFrozen({ ...validated, signature }); +} + +function validateTrustedSignerMap(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('trustedFinanceSigners must be an object'); + } + for (const [signerId, key] of Object.entries(value)) { + requireNonEmpty(signerId, 'finance signer ID'); + requireNonEmpty(key, `trusted key for ${signerId}`); + } +} + +export function createBudget(signedBudget, { trustedFinanceSigners, policy: policyInput, now }) { + requireExactKeys(signedBudget, SIGNED_BUDGET_AUTHORIZATION_KEYS, 'signed budget authorization'); + const unsigned = validateUnsignedAuthorization(ordered(signedBudget, BUDGET_AUTHORIZATION_KEYS)); + const policy = validatePolicy(policyInput, now); + validateTrustedSignerMap(trustedFinanceSigners); + if (unsigned.policyId !== policy.policyId || unsigned.policyVersion !== policy.version) { + throw new Error('budget authorization policy binding does not match effective policy'); + } + if (unsigned.currency !== policy.currency || unsigned.atomicScale !== policy.atomicScale) { + throw new Error('budget authorization denomination does not match policy'); + } + if (!policy.permittedFinanceSignerIds.includes(unsigned.signerId)) { + throw new Error('finance signer is not permitted by policy'); + } + const trustedKey = trustedFinanceSigners[unsigned.signerId]; + if (typeof trustedKey !== 'string' || trustedKey.length === 0) { + throw new Error('trusted finance signer is not provisioned'); + } + const at = parseUtc(now, 'now'); + const effectiveAt = parseUtc(unsigned.effectiveAt, 'budget effectiveAt'); + const expiresAt = parseUtc(unsigned.expiresAt, 'budget expiresAt'); + if (at < effectiveAt) throw new Error('budget authorization is not yet effective'); + if (at >= expiresAt) throw new Error('budget authorization expired'); + if (effectiveAt < parseUtc(policy.effectiveAt, 'policy effectiveAt') + || expiresAt > parseUtc(policy.expiresAt, 'policy expiresAt')) { + throw new Error('budget authorization window exceeds policy window'); + } + const signature = decodeSignature(signedBudget.signature); + if (!cryptoVerify(null, canonicalBudgetBytes(unsigned), trustedKey, signature)) { + throw new Error('budget authorization signature verification failed'); + } + return deepFreeze({ + schemaVersion: 1, + budgetId: unsigned.budgetId, + policyId: unsigned.policyId, + policyVersion: unsigned.policyVersion, + period: unsigned.period, + currency: unsigned.currency, + atomicScale: unsigned.atomicScale, + authorization: cloneFrozen(signedBudget), + policy, + allocatedAtomic: unsigned.allocatedAtomic, + reservedAtomic: '0', + consumedAtomic: '0', + releasedAtomic: '0', + revision: 0, + }); +} + +function validateBudgetState(budget) { + requireExactKeys(budget, BUDGET_STATE_KEYS, 'budget state'); + if (budget.schemaVersion !== 1 || !Number.isSafeInteger(budget.revision) || budget.revision < 0) { + throw new Error('invalid budget state revision'); + } + for (const key of ['allocatedAtomic', 'reservedAtomic', 'consumedAtomic', 'releasedAtomic']) { + toAtomic(budget[key]); + } + const allocated = toAtomic(budget.allocatedAtomic); + const committed = toAtomic(budget.reservedAtomic) + toAtomic(budget.consumedAtomic); + if (committed > allocated) throw new Error('budget state exceeds allocated amount'); + return budget; +} + +function validateReservation(reservation) { + requireExactKeys(reservation, RESERVATION_KEYS, 'reservation'); + if (reservation.schemaVersion !== 1 + || !Number.isSafeInteger(reservation.revision) || reservation.revision < 0) { + throw new Error('invalid reservation revision'); + } + toAtomic(reservation.reservedAtomic); + return reservation; +} + +function requireRevision(actual, expected, label) { + if (!Number.isSafeInteger(expected) || actual !== expected) { + throw new Error(`stale ${label} revision: expected ${expected}, received ${actual}`); + } +} + +function requireCurrentAuthorization(budget, now) { + validatePolicy(budget.policy, now); + const at = parseUtc(now, 'now'); + if (at < parseUtc(budget.authorization.effectiveAt, 'budget effectiveAt')) { + throw new Error('budget authorization is not yet effective'); + } + if (at >= parseUtc(budget.authorization.expiresAt, 'budget expiresAt')) { + throw new Error('budget authorization expired'); + } + if (String(now).slice(0, 7) !== budget.period) throw new Error('budget period is not active'); +} + +function replaceBudget(budget, changes) { + const next = deepFreeze({ ...budget, ...changes }); + validateBudgetState(next); + return next; +} + +function replaceReservation(reservation, changes) { + const next = deepFreeze({ ...reservation, ...changes }); + validateReservation(next); + return next; +} + +function event(type, reservation, budget, now, details = {}) { + return deepFreeze({ + schemaVersion: 1, + eventId: `${reservation.reservationId}:${type}:${budget.revision}`, + type, + occurredAt: now, + budgetId: budget.budgetId, + budgetRevision: budget.revision, + reservationId: reservation.reservationId, + reservationRevision: reservation.revision, + invocationId: reservation.quote.invocationId, + ...details, + }); +} + +export function remainingAtomic(budget) { + validateBudgetState(budget); + return toAtomic(budget.allocatedAtomic) + - toAtomic(budget.reservedAtomic) + - toAtomic(budget.consumedAtomic); +} + +export function reserveBudget(budgetInput, quoteInput, { expectedRevision, reservationId, now }) { + const budget = validateBudgetState(budgetInput); + requireRevision(budget.revision, expectedRevision, 'budget'); + requireCurrentAuthorization(budget, now); + requireNonEmpty(reservationId, 'reservationId'); + const quote = validateQuote(quoteInput, budget.policy, now); + if (quote.policyId !== budget.policyId || quote.policyVersion !== budget.policyVersion) { + throw new Error('quote does not match budget policy'); + } + const amount = toAtomic(quote.maxGrossAtomic); + if (amount > remainingAtomic(budget)) throw new Error('insufficient remaining budget'); + const nextBudget = replaceBudget(budget, { + reservedAtomic: fromAtomic(toAtomic(budget.reservedAtomic) + amount), + revision: budget.revision + 1, + }); + const reservation = deepFreeze({ + schemaVersion: 1, + reservationId, + quote, + state: 'reserved', + reservedAtomic: quote.maxGrossAtomic, + revision: 0, + executionAttemptId: null, + authorizedAt: now, + startedAt: null, + finalizedAt: null, + }); + return deepFreeze({ + budget: nextBudget, + reservation, + event: event('budget_reserved', reservation, nextBudget, now, { + reservedAtomic: reservation.reservedAtomic, + }), + }); +} + +function requireTransitionRevisions(budget, reservation, options) { + requireRevision(budget.revision, options.expectedBudgetRevision, 'budget'); + requireRevision(reservation.revision, options.expectedReservationRevision, 'reservation'); +} + +export function startReservationExecution(budgetInput, reservationInput, options) { + const budget = validateBudgetState(budgetInput); + const reservation = validateReservation(reservationInput); + requireTransitionRevisions(budget, reservation, options); + if (reservation.state !== 'reserved') throw new Error('reservation must be reserved'); + requireNonEmpty(options.executionAttemptId, 'executionAttemptId'); + parseUtc(options.now, 'now'); + const nextBudget = replaceBudget(budget, { revision: budget.revision + 1 }); + const nextReservation = replaceReservation(reservation, { + state: 'executing', + revision: reservation.revision + 1, + executionAttemptId: options.executionAttemptId, + startedAt: options.now, + }); + return deepFreeze({ + budget: nextBudget, + reservation: nextReservation, + event: event('execution_started', nextReservation, nextBudget, options.now, { + executionAttemptId: options.executionAttemptId, + }), + }); +} + +function requireExecuting(budget, reservation, options) { + requireTransitionRevisions(budget, reservation, options); + if (reservation.state !== 'executing') throw new Error('reservation must be executing'); + if (reservation.executionAttemptId !== options.executionAttemptId) { + throw new Error('execution attempt does not match reservation'); + } +} + +function serializeJournalEntries(entries) { + return entries.map((entry) => deepFreeze({ + category: entry.category, + debitAccountId: entry.debitAccountId, + creditAccountId: entry.creditAccountId, + amountAtomic: fromAtomic(entry.amountAtomic), + })); +} + +export function finalizeReservation(budgetInput, reservationInput, actual) { + const budget = validateBudgetState(budgetInput); + const reservation = validateReservation(reservationInput); + requireExecuting(budget, reservation, actual); + parseUtc(actual.now, 'now'); + if (actual.recipientId !== reservation.quote.creatorId) { + throw new Error('award recipient must equal quote Creator'); + } + const executionCost = toAtomic(actual.executionCostAtomic); + const fee = toAtomic(actual.protocolFeeAtomic); + const reserve = toAtomic(actual.refundReserveAtomic); + const gross = toAtomic(actual.grossAtomic); + const maximumAward = toAtomic(reservation.quote.maxInvocationAwardAtomic); + if (executionCost > toAtomic(reservation.quote.maxExecutionCostAtomic)) { + throw new Error('execution cost exceeds quote maximum'); + } + if (actual.protocolFeeAtomic !== reservation.quote.protocolFeeAtomic) { + throw new Error('protocol fee must equal the quote-final amount'); + } + if (actual.refundReserveAtomic !== reservation.quote.refundReserveAtomic) { + throw new Error('refund reserve must equal the quote-final amount'); + } + const derivedGross = executionCost + fee + reserve + maximumAward; + if (gross !== derivedGross) { + throw new Error(`grossAtomic must equal cost, fee, reserve, and maximum award (${derivedGross})`); + } + const reservedAmount = toAtomic(reservation.reservedAtomic); + if (gross > reservedAmount) throw new Error('actual gross exceeds reserved amount'); + const allocation = deepFreeze(allocateInternalGross({ + grossAtomic: gross, + executionCostAtomic: executionCost, + protocolFeeAtomic: fee, + refundReserveAtomic: reserve, + recipientId: actual.recipientId, + })); + if (allocation.invocationAwardAtomic !== maximumAward) { + throw new Error('kernel Invocation award does not equal the authorized maximum award'); + } + const released = reservedAmount - gross; + const nextBudget = replaceBudget(budget, { + reservedAtomic: fromAtomic(toAtomic(budget.reservedAtomic) - reservedAmount), + consumedAtomic: fromAtomic(toAtomic(budget.consumedAtomic) + gross), + releasedAtomic: fromAtomic(toAtomic(budget.releasedAtomic) + released), + revision: budget.revision + 1, + }); + const nextReservation = replaceReservation(reservation, { + state: 'consumed', + revision: reservation.revision + 1, + finalizedAt: actual.now, + }); + const journalEntries = serializeJournalEntries(allocation.journalEntries); + return deepFreeze({ + budget: nextBudget, + reservation: nextReservation, + allocation, + event: event('budget_consumed', nextReservation, nextBudget, actual.now, { + grossAtomic: actual.grossAtomic, + releasedUnusedAtomic: fromAtomic(released), + executionCostAtomic: actual.executionCostAtomic, + protocolFeeAtomic: actual.protocolFeeAtomic, + refundReserveAtomic: actual.refundReserveAtomic, + invocationAwardAtomic: fromAtomic(allocation.invocationAwardAtomic), + journalEntries, + }), + }); +} + +export function releaseReservation(budgetInput, reservationInput, options) { + const budget = validateBudgetState(budgetInput); + const reservation = validateReservation(reservationInput); + requireTransitionRevisions(budget, reservation, options); + parseUtc(options.now, 'now'); + const cost = toAtomic(options.executionCostAtomic); + if (options.reason === 'cancelled_before_start') { + if (reservation.state !== 'reserved') throw new Error('reservation must be reserved'); + if (options.executionAttemptId !== null || cost !== 0n) { + throw new Error('pre-execution cancellation must have no attempt and zero execution cost'); + } + } else if (options.reason === 'failed_after_start') { + if (reservation.state !== 'executing') throw new Error('reservation must be executing'); + if (reservation.executionAttemptId !== options.executionAttemptId) { + throw new Error('execution attempt does not match reservation'); + } + if (cost > toAtomic(reservation.quote.maxExecutionCostAtomic)) { + throw new Error('execution cost exceeds quote maximum'); + } + } else { + throw new Error('unsupported reservation release reason'); + } + const reservedAmount = toAtomic(reservation.reservedAtomic); + const released = reservedAmount - cost; + const nextBudget = replaceBudget(budget, { + reservedAtomic: fromAtomic(toAtomic(budget.reservedAtomic) - reservedAmount), + consumedAtomic: fromAtomic(toAtomic(budget.consumedAtomic) + cost), + releasedAtomic: fromAtomic(toAtomic(budget.releasedAtomic) + released), + revision: budget.revision + 1, + }); + const nextReservation = replaceReservation(reservation, { + state: 'released', + revision: reservation.revision + 1, + finalizedAt: options.now, + }); + return deepFreeze({ + budget: nextBudget, + reservation: nextReservation, + event: event('budget_released', nextReservation, nextBudget, options.now, { + reason: options.reason, + executionCostAtomic: options.executionCostAtomic, + releasedAtomic: fromAtomic(released), + }), + }); +} + +export function holdUnresolvedReservation(budgetInput, reservationInput, options) { + const budget = validateBudgetState(budgetInput); + const reservation = validateReservation(reservationInput); + requireExecuting(budget, reservation, options); + parseUtc(options.now, 'now'); + if (!['executor_threw', 'malformed_outcome', 'cost_unknown'].includes(options.reason)) { + throw new Error('unsupported unresolved reason'); + } + const nextBudget = replaceBudget(budget, { revision: budget.revision + 1 }); + const nextReservation = replaceReservation(reservation, { + state: 'held_unresolved', + revision: reservation.revision + 1, + finalizedAt: options.now, + }); + return deepFreeze({ + budget: nextBudget, + reservation: nextReservation, + event: event('execution_cost_unresolved', nextReservation, nextBudget, options.now, { + reason: options.reason, + heldAtomic: reservation.reservedAtomic, + executionCostStatus: 'unresolved', + }), + }); +} + +export const BUDGET_SCHEMAS = deepFreeze({ + EmployerBudgetAuthorizationV1: BUDGET_AUTHORIZATION_KEYS, + BudgetStateV1: BUDGET_STATE_KEYS, + ReservationV1: RESERVATION_KEYS, +}); diff --git a/spikes/internal-invocation-awards/test/budget.test.mjs b/spikes/internal-invocation-awards/test/budget.test.mjs index 0ac1299..00f71fa 100644 --- a/spikes/internal-invocation-awards/test/budget.test.mjs +++ b/spikes/internal-invocation-awards/test/budget.test.mjs @@ -1,6 +1,18 @@ import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; import test from 'node:test'; +import { allocateInternalGross } from '../../../prototype/atomic-money.mjs'; +import { + createBudget, + finalizeReservation, + holdUnresolvedReservation, + releaseReservation, + remainingAtomic, + reserveBudget, + signBudget, + startReservationExecution, +} from '../src/budget.mjs'; import { parseExecutorOutcome, sumAtomic, @@ -139,4 +151,205 @@ test('executor outcomes fail closed without inventing zero COGS', () => { }); }); -export { ACTIVE_POLICY, NOW, QUOTE }; +const UNSIGNED_BUDGET_AUTHORIZATION = { + schemaVersion: 1, + budgetId: 'budget-megacorp-2026-07', + policyId: ACTIVE_POLICY.policyId, + policyVersion: 1, + period: '2026-07', + currency: 'USD', + atomicScale: 6, + allocatedAtomic: '1000000000', + effectiveAt: '2026-07-17T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + signerId: 'megacorp-finance', +}; + +function financeFixture(overrides = {}) { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const unsigned = { ...UNSIGNED_BUDGET_AUTHORIZATION, ...overrides }; + const signedBudget = signBudget(unsigned, privateKey); + const trustedFinanceSigners = { + [unsigned.signerId]: publicKey.export({ type: 'spki', format: 'pem' }), + }; + return { privateKey, publicKey, signedBudget, trustedFinanceSigners }; +} + +function verifiedBudget(fixture = financeFixture()) { + return createBudget(fixture.signedBudget, { + trustedFinanceSigners: fixture.trustedFinanceSigners, + policy: ACTIVE_POLICY, + now: NOW, + }); +} + +test('signed budget authorization is immutable and separate from mutable counters', () => { + const fixture = financeFixture(); + const budget = verifiedBudget(fixture); + assert.equal(budget.authorization.allocatedAtomic, '1000000000'); + assert.equal(budget.reservedAtomic, '0'); + assert.equal(budget.consumedAtomic, '0'); + assert.equal(budget.releasedAtomic, '0'); + assert.equal(remainingAtomic(budget), 1_000_000_000n); + assert.ok(Object.isFrozen(budget.authorization)); + + assert.throws( + () => createBudget({ ...fixture.signedBudget, allocatedAtomic: '999999999' }, { + trustedFinanceSigners: fixture.trustedFinanceSigners, policy: ACTIVE_POLICY, now: NOW, + }), + /signature/, + ); + assert.throws( + () => createBudget({ ...fixture.signedBudget, signerPublicKeyPem: 'attacker' }, { + trustedFinanceSigners: fixture.trustedFinanceSigners, policy: ACTIVE_POLICY, now: NOW, + }), + /unknown key signerPublicKeyPem/, + ); + assert.throws( + () => createBudget(fixture.signedBudget, { + trustedFinanceSigners: {}, policy: ACTIVE_POLICY, now: NOW, + }), + /trusted finance signer/, + ); + const attacker = financeFixture(); + assert.throws( + () => createBudget(fixture.signedBudget, { + trustedFinanceSigners: attacker.trustedFinanceSigners, policy: ACTIVE_POLICY, now: NOW, + }), + /signature/, + ); +}); + +test('budget authorization validates its own effective window and policy signer allow-list', () => { + const future = financeFixture({ effectiveAt: '2026-07-18T00:00:00.000Z' }); + assert.throws(() => verifiedBudget(future), /budget authorization is not yet effective/); + const expired = financeFixture({ expiresAt: NOW }); + assert.throws(() => verifiedBudget(expired), /budget authorization expired/); + const signer = financeFixture({ signerId: 'rogue-finance' }); + assert.throws(() => verifiedBudget(signer), /finance signer is not permitted/); +}); + +test('reservation uses exact budget and reservation revisions then kernel finalizes', () => { + const budget = verifiedBudget(); + const reserved = reserveBudget(budget, QUOTE, { + expectedRevision: 0, + reservationId: 'res-001', + now: NOW, + }); + assert.equal(reserved.budget.reservedAtomic, '3050000'); + assert.equal(reserved.budget.revision, 1); + assert.equal(reserved.reservation.state, 'reserved'); + assert.equal(reserved.reservation.revision, 0); + assert.throws( + () => reserveBudget(reserved.budget, { ...QUOTE, quoteId: 'quote-2', invocationId: 'inv-2', idempotencyKey: 'run-2' }, { + expectedRevision: 0, reservationId: 'res-2', now: NOW, + }), + /stale budget revision/, + ); + + const started = startReservationExecution(reserved.budget, reserved.reservation, { + expectedBudgetRevision: 1, + expectedReservationRevision: 0, + executionAttemptId: 'attempt-inv-001-1', + now: NOW, + }); + assert.equal(started.reservation.state, 'executing'); + assert.equal(started.reservation.revision, 1); + assert.equal(started.budget.revision, 2); + + const finalized = finalizeReservation(started.budget, started.reservation, { + expectedBudgetRevision: 2, + expectedReservationRevision: 1, + executionAttemptId: 'attempt-inv-001-1', + grossAtomic: '2750000', + executionCostAtomic: '700000', + protocolFeeAtomic: '25000', + refundReserveAtomic: '25000', + recipientId: 'sam', + now: NOW, + }); + assert.equal(finalized.budget.consumedAtomic, '2750000'); + assert.equal(finalized.budget.releasedAtomic, '300000'); + assert.equal(finalized.budget.reservedAtomic, '0'); + assert.equal(finalized.allocation.invocationAwardAtomic, 2_000_000n); + assert.deepEqual(finalized.allocation.awardCredit, { recipientId: 'sam', amountAtomic: 2_000_000n }); + assert.equal(finalized.event.journalEntries.length, 4); + assert.equal(finalized.event.journalEntries.reduce( + (sum, entry) => sum + BigInt(entry.amountAtomic), 0n, + ), 2_750_000n); + assert.ok(finalized.event.journalEntries.every( + (entry) => entry.debitAccountId === 'employer:invocation-gross', + )); + assert.deepEqual(finalized.allocation, allocateInternalGross({ + grossAtomic: 2_750_000n, + executionCostAtomic: 700_000n, + protocolFeeAtomic: 25_000n, + refundReserveAtomic: 25_000n, + recipientId: 'sam', + })); + assert.equal(remainingAtomic(finalized.budget), 997_250_000n); + assert.throws(() => finalizeReservation(finalized.budget, finalized.reservation, { + expectedBudgetRevision: 3, + expectedReservationRevision: 2, + executionAttemptId: 'attempt-inv-001-1', + grossAtomic: '2750000', executionCostAtomic: '700000', protocolFeeAtomic: '25000', + refundReserveAtomic: '25000', recipientId: 'sam', now: NOW, + }), /reservation must be executing/); +}); + +test('insufficient budget, exact failed COGS, cancellation, and unresolved holds conserve funds', () => { + const smallFixture = financeFixture({ allocatedAtomic: '1000000' }); + assert.throws(() => reserveBudget(verifiedBudget(smallFixture), QUOTE, { + expectedRevision: 0, reservationId: 'res-small', now: NOW, + }), /insufficient remaining budget/); + + const first = reserveBudget(verifiedBudget(), QUOTE, { + expectedRevision: 0, reservationId: 'res-failed', now: NOW, + }); + const executing = startReservationExecution(first.budget, first.reservation, { + expectedBudgetRevision: 1, expectedReservationRevision: 0, + executionAttemptId: 'attempt-failed-1', now: NOW, + }); + const failed = releaseReservation(executing.budget, executing.reservation, { + expectedBudgetRevision: 2, expectedReservationRevision: 1, + executionAttemptId: 'attempt-failed-1', executionCostAtomic: '700000', + reason: 'failed_after_start', now: NOW, + }); + assert.equal(failed.budget.consumedAtomic, '700000'); + assert.equal(failed.budget.releasedAtomic, '2350000'); + assert.equal(failed.budget.reservedAtomic, '0'); + + const cancelReserved = reserveBudget(verifiedBudget(), QUOTE, { + expectedRevision: 0, reservationId: 'res-cancel', now: NOW, + }); + const cancelled = releaseReservation(cancelReserved.budget, cancelReserved.reservation, { + expectedBudgetRevision: 1, expectedReservationRevision: 0, + executionAttemptId: null, executionCostAtomic: '0', reason: 'cancelled_before_start', now: NOW, + }); + assert.equal(cancelled.budget.releasedAtomic, '3050000'); + assert.equal(cancelled.budget.consumedAtomic, '0'); + + const heldReserved = reserveBudget(verifiedBudget(), QUOTE, { + expectedRevision: 0, reservationId: 'res-held', now: NOW, + }); + const heldExecuting = startReservationExecution(heldReserved.budget, heldReserved.reservation, { + expectedBudgetRevision: 1, expectedReservationRevision: 0, + executionAttemptId: 'attempt-held-1', now: NOW, + }); + const held = holdUnresolvedReservation(heldExecuting.budget, heldExecuting.reservation, { + expectedBudgetRevision: 2, expectedReservationRevision: 1, + executionAttemptId: 'attempt-held-1', reason: 'cost_unknown', now: NOW, + }); + assert.equal(held.reservation.state, 'held_unresolved'); + assert.equal(held.budget.reservedAtomic, '3050000'); + assert.equal(held.budget.consumedAtomic, '0'); + assert.equal(held.budget.releasedAtomic, '0'); + assert.equal(held.event.type, 'execution_cost_unresolved'); + assert.throws(() => releaseReservation(held.budget, held.reservation, { + expectedBudgetRevision: 3, expectedReservationRevision: 2, + executionAttemptId: 'attempt-held-1', executionCostAtomic: '0', + reason: 'failed_after_start', now: NOW, + }), /reservation must be executing/); +}); + +export { ACTIVE_POLICY, NOW, QUOTE, UNSIGNED_BUDGET_AUTHORIZATION, financeFixture }; From 6f56b30404f9d5d4cbfb3ec5716b6607d1ed3231 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:10:50 -0400 Subject: [PATCH 067/165] feat: model explicit registration attestation levels --- phase0/src/attestations.ts | 824 ++++++++++++++++++++++++++++++ phase0/tests/attestations.test.ts | 266 ++++++++++ 2 files changed, 1090 insertions(+) create mode 100644 phase0/src/attestations.ts create mode 100644 phase0/tests/attestations.test.ts diff --git a/phase0/src/attestations.ts b/phase0/src/attestations.ts new file mode 100644 index 0000000..78c593b --- /dev/null +++ b/phase0/src/attestations.ts @@ -0,0 +1,824 @@ +import { createHash } from "node:crypto"; + +import { keccak256, stringToHex, verifyMessage } from "viem"; + +import type { RegistrationManifest } from "./registrations"; + +export type AttestationLevel = + | "wallet_asserted" + | "repository_control_verified" + | "organization_approved"; +export type AttestationStatus = "active" | "challenged"; +export type SafetyReviewStatus = "not_reviewed" | "pending" | "approved" | "rejected"; + +export interface RegistrationSubject { + registrationId: `eip155:1315:${string}`; + ipId: `0x${string}`; + wallet: `0x${string}`; + artifactHash: `0x${string}`; + declaredParentIpIds: `0x${string}`[]; +} + +export interface RepositoryControlChallengeV1 { + schemaVersion: 1; + subject: RegistrationSubject; + repositoryUrl: string; + artifactCommitSha: string; + artifactPath: string; + challengePath: string; + nonce: `0x${string}`; + issuedAt: string; + expiresAt: string; +} + +export interface OrganizationApprovalV1 { + schemaVersion: 1; + subject: RegistrationSubject; + organizationId: string; + approverWallet: `0x${string}`; + role: "ip_admin" | "engineering_executive"; + approvedAt: string; + statementHash: `0x${string}`; + signature: `0x${string}`; +} + +export interface ForgeObservationV1 { + schemaVersion: 1; + repositoryId: string; + repositoryUrl: string; + trustedRef: `refs/heads/${string}` | `refs/remotes/${string}`; + proofCommitSha: string; + challengeNonce: `0x${string}`; + observedAt: string; + forgeSignerId: string; + signature: string; +} + +export type RepositoryControlEvent = { + type: "repository_control_verified"; + eventId: string; + sequence: number; + occurredAt: string; + subject: RegistrationSubject; + challenge: RepositoryControlChallengeV1; + forgeObservation: ForgeObservationV1; + statementHash: `0x${string}`; + signature: `0x${string}`; +}; + +export type OrganizationApprovedEvent = { + type: "organization_approved"; + eventId: string; + sequence: number; + occurredAt: string; + subject: RegistrationSubject; + approval: OrganizationApprovalV1; +}; + +export type ChallengeOpenedEvent = { + type: "challenge_opened"; + eventId: string; + sequence: number; + occurredAt: string; + conflictId: string; + challengedRegistrationId: string; + challengerRegistrationId: string; + challengerWallet: `0x${string}`; + evidenceUris: string[]; + reason: "duplicate_bytes" | "misattributed_creator" | "unauthorized_registration"; + statementHash: `0x${string}`; + signature: `0x${string}`; +}; + +export type ChallengeResolvedEvent = { + type: "challenge_resolved"; + eventId: string; + sequence: number; + occurredAt: string; + conflictId: string; + outcome: "upheld" | "rejected" | "inconclusive"; + rationale: string; + adminSignerId: string; + statementHash: `0x${string}`; + signature: `0x${string}`; +}; + +export type AttestationRevokedEvent = { + type: "attestation_revoked"; + eventId: string; + sequence: number; + occurredAt: string; + registrationId: string; + level: Exclude; + reason: string; + adminSignerId: string; + statementHash: `0x${string}`; + signature: `0x${string}`; +}; + +export type AttestationEvent = + | RepositoryControlEvent + | OrganizationApprovedEvent + | ChallengeOpenedEvent + | ChallengeResolvedEvent + | AttestationRevokedEvent; + +export interface AttestationRegistration { + subject: RegistrationSubject; + level: AttestationLevel; + status: AttestationStatus; + claim: string; + safetyReviewStatus: SafetyReviewStatus; + evidenceEventIds: readonly string[]; + revocations: readonly { + level: Exclude; + eventId: string; + occurredAt: string; + reason: string; + }[]; +} + +export interface AttestationConflict { + conflictId: string; + artifactHash: `0x${string}` | null; + registrationIds: readonly string[]; + status: "open" | "resolved"; + reason: ChallengeOpenedEvent["reason"]; + outcome: ChallengeResolvedEvent["outcome"] | null; + eventIds: readonly string[]; +} + +export interface AttestationIndex { + registrations: Readonly>; + conflicts: readonly AttestationConflict[]; + events: readonly AttestationEvent[]; +} + +const ADDRESS = /^0x[0-9a-f]{40}$/; +const HASH = /^0x[0-9a-f]{64}$/; +const COMMIT = /^[0-9a-f]{40,64}$/; +const NONCE = /^0x[0-9a-f]{64}$/; +const HEX_SIGNATURE = /^0x(?:[0-9a-fA-F]{2})+$/; +const REGISTRATION_ID = /^eip155:1315:0x[0-9a-f]{40}$/; +const IDENTIFIER = /^[a-z0-9][a-z0-9._-]{0,127}$/; +const TRUSTED_REF = /^refs\/(?:heads|remotes)\/[A-Za-z0-9._\/-]+$/; + +const SUBJECT_KEYS = [ + "registrationId", + "ipId", + "wallet", + "artifactHash", + "declaredParentIpIds", +] as const; +const CHALLENGE_KEYS = [ + "schemaVersion", + "subject", + "repositoryUrl", + "artifactCommitSha", + "artifactPath", + "challengePath", + "nonce", + "issuedAt", + "expiresAt", +] as const; +const FORGE_KEYS = [ + "schemaVersion", + "repositoryId", + "repositoryUrl", + "trustedRef", + "proofCommitSha", + "challengeNonce", + "observedAt", + "forgeSignerId", + "signature", +] as const; +const APPROVAL_KEYS = [ + "schemaVersion", + "subject", + "organizationId", + "approverWallet", + "role", + "approvedAt", + "statementHash", + "signature", +] as const; + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${label} has unexpected or missing fields`); + } +} + +function nonempty(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value.trim() !== value || value.length === 0) { + throw new Error(`${label} must be a nonempty canonical string`); + } +} + +function iso(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || Number.isNaN(Date.parse(value)) || new Date(value).toISOString() !== value) { + throw new Error(`${label} must be a canonical UTC ISO-8601 timestamp`); + } +} + +function hash(value: unknown, label: string): asserts value is `0x${string}` { + if (typeof value !== "string" || !HASH.test(value)) throw new Error(`${label} must be a lowercase 32-byte hash`); +} + +function address(value: unknown, label: string): asserts value is `0x${string}` { + if (typeof value !== "string" || !ADDRESS.test(value)) throw new Error(`${label} must be a lowercase address`); +} + +function signature(value: unknown, label: string): asserts value is `0x${string}` { + if (typeof value !== "string" || !HEX_SIGNATURE.test(value)) throw new Error(`${label} must be a hex signature`); +} + +function relativePath(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value.length === 0 || value.includes("\\") || value.includes("\0") || value.startsWith("/")) { + throw new Error(`${label} must be a normalized relative POSIX path`); + } + const segments = value.split("/"); + if (segments.some((part) => part === "" || part === "." || part === "..")) { + throw new Error(`${label} must be a normalized relative POSIX path`); + } +} + +export function normalizeRepositoryUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error("repository URL must be normalized HTTPS"); + } + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port || parsed.search || parsed.hash) { + throw new Error("repository URL must be normalized HTTPS"); + } + if (!parsed.hostname || parsed.pathname === "/" || parsed.pathname.includes("//") || parsed.pathname.includes("/../")) { + throw new Error("repository URL must be normalized HTTPS"); + } + const path = parsed.pathname.replace(/\/$/, ""); + const normalized = `https://${parsed.hostname.toLowerCase()}${path}`; + if (value !== normalized) throw new Error("repository URL must already be normalized HTTPS without a trailing slash"); + return normalized; +} + +function parseSubject(value: unknown, label = "registration subject"): RegistrationSubject { + const subject = record(value, label); + exactKeys(subject, SUBJECT_KEYS, label); + if (typeof subject.registrationId !== "string" || !REGISTRATION_ID.test(subject.registrationId)) { + throw new Error(`${label}.registrationId must be an Aeneid CAIP registration ID`); + } + address(subject.ipId, `${label}.ipId`); + if (subject.registrationId !== `eip155:1315:${subject.ipId}`) { + throw new Error(`${label}.registrationId must identify its ipId`); + } + address(subject.wallet, `${label}.wallet`); + hash(subject.artifactHash, `${label}.artifactHash`); + if (!Array.isArray(subject.declaredParentIpIds)) throw new Error(`${label}.declaredParentIpIds must be an array`); + const parents = subject.declaredParentIpIds.map((parent, index) => { + address(parent, `${label}.declaredParentIpIds[${index}]`); + return parent; + }); + if (new Set(parents).size !== parents.length) throw new Error(`${label}.declaredParentIpIds must be unique`); + return { + registrationId: subject.registrationId as `eip155:1315:${string}`, + ipId: subject.ipId, + wallet: subject.wallet, + artifactHash: subject.artifactHash, + declaredParentIpIds: parents, + }; +} + +export function parseRepositoryChallenge(value: unknown): RepositoryControlChallengeV1 { + const challenge = record(value, "repository challenge"); + exactKeys(challenge, CHALLENGE_KEYS, "repository challenge"); + if (challenge.schemaVersion !== 1) throw new Error("repository challenge schemaVersion must be 1"); + const subject = parseSubject(challenge.subject, "repository challenge subject"); + nonempty(challenge.repositoryUrl, "repository challenge repositoryUrl"); + normalizeRepositoryUrl(challenge.repositoryUrl); + if (typeof challenge.artifactCommitSha !== "string" || !COMMIT.test(challenge.artifactCommitSha)) { + throw new Error("repository challenge artifactCommitSha must be a lowercase full commit SHA"); + } + relativePath(challenge.artifactPath, "repository challenge artifactPath"); + relativePath(challenge.challengePath, "repository challenge challengePath"); + if (challenge.artifactPath === challenge.challengePath) throw new Error("artifact and challenge paths must differ"); + if (typeof challenge.nonce !== "string" || !NONCE.test(challenge.nonce)) { + throw new Error("repository challenge nonce must be 32 lowercase bytes"); + } + iso(challenge.issuedAt, "repository challenge issuedAt"); + iso(challenge.expiresAt, "repository challenge expiresAt"); + if (Date.parse(challenge.expiresAt) <= Date.parse(challenge.issuedAt)) { + throw new Error("repository challenge must expire after it is issued"); + } + return { + schemaVersion: 1, + subject, + repositoryUrl: challenge.repositoryUrl, + artifactCommitSha: challenge.artifactCommitSha, + artifactPath: challenge.artifactPath, + challengePath: challenge.challengePath, + nonce: challenge.nonce as `0x${string}`, + issuedAt: challenge.issuedAt, + expiresAt: challenge.expiresAt, + }; +} + +export function parseForgeObservation(value: unknown): ForgeObservationV1 { + const observation = record(value, "forge observation"); + exactKeys(observation, FORGE_KEYS, "forge observation"); + if (observation.schemaVersion !== 1) throw new Error("forge observation schemaVersion must be 1"); + if (typeof observation.repositoryId !== "string" || !IDENTIFIER.test(observation.repositoryId)) { + throw new Error("forge observation repositoryId is invalid"); + } + nonempty(observation.repositoryUrl, "forge observation repositoryUrl"); + normalizeRepositoryUrl(observation.repositoryUrl); + if (typeof observation.trustedRef !== "string" || !TRUSTED_REF.test(observation.trustedRef) || observation.trustedRef.includes("..")) { + throw new Error("forge observation trustedRef must be a full trusted ref"); + } + if (typeof observation.proofCommitSha !== "string" || !COMMIT.test(observation.proofCommitSha)) { + throw new Error("forge observation proofCommitSha must be a lowercase full commit SHA"); + } + if (typeof observation.challengeNonce !== "string" || !NONCE.test(observation.challengeNonce)) { + throw new Error("forge observation challengeNonce must be 32 lowercase bytes"); + } + iso(observation.observedAt, "forge observation observedAt"); + if (typeof observation.forgeSignerId !== "string" || !IDENTIFIER.test(observation.forgeSignerId)) { + throw new Error("forge observation forgeSignerId is invalid"); + } + nonempty(observation.signature, "forge observation signature"); + return observation as unknown as ForgeObservationV1; +} + +function parseApproval(value: unknown): OrganizationApprovalV1 { + const approval = record(value, "organization approval"); + exactKeys(approval, APPROVAL_KEYS, "organization approval"); + if (approval.schemaVersion !== 1) throw new Error("organization approval schemaVersion must be 1"); + const subject = parseSubject(approval.subject, "organization approval subject"); + if (typeof approval.organizationId !== "string" || !IDENTIFIER.test(approval.organizationId)) { + throw new Error("organization approval organizationId must be normalized lowercase"); + } + address(approval.approverWallet, "organization approval approverWallet"); + if (approval.role !== "ip_admin" && approval.role !== "engineering_executive") { + throw new Error("organization approval role is invalid"); + } + iso(approval.approvedAt, "organization approval approvedAt"); + hash(approval.statementHash, "organization approval statementHash"); + signature(approval.signature, "organization approval signature"); + return { ...approval, subject } as unknown as OrganizationApprovalV1; +} + +function parseEventBase(event: Record): void { + nonempty(event.eventId, "attestation eventId"); + if (!Number.isSafeInteger(event.sequence) || (event.sequence as number) <= 0) { + throw new Error("attestation sequence must be a positive integer"); + } + iso(event.occurredAt, "attestation occurredAt"); +} + +export function parseAttestationEvent(value: unknown): AttestationEvent { + const event = record(value, "attestation event"); + if (event.type === "wallet_asserted") throw new Error("wallet_asserted cannot be a sidecar event"); + if (event.type === "repository_control_verified") { + exactKeys(event, ["type", "eventId", "sequence", "occurredAt", "subject", "challenge", "forgeObservation", "statementHash", "signature"], "repository event"); + parseEventBase(event); + const subject = parseSubject(event.subject); + const challenge = parseRepositoryChallenge(event.challenge); + const forgeObservation = parseForgeObservation(event.forgeObservation); + hash(event.statementHash, "repository event statementHash"); + signature(event.signature, "repository event signature"); + return { ...event, subject, challenge, forgeObservation } as unknown as RepositoryControlEvent; + } + if (event.type === "organization_approved") { + exactKeys(event, ["type", "eventId", "sequence", "occurredAt", "subject", "approval"], "organization event"); + parseEventBase(event); + return { ...event, subject: parseSubject(event.subject), approval: parseApproval(event.approval) } as unknown as OrganizationApprovedEvent; + } + if (event.type === "challenge_opened") { + exactKeys(event, ["type", "eventId", "sequence", "occurredAt", "conflictId", "challengedRegistrationId", "challengerRegistrationId", "challengerWallet", "evidenceUris", "reason", "statementHash", "signature"], "challenge event"); + parseEventBase(event); + nonempty(event.conflictId, "challenge conflictId"); + if (typeof event.challengedRegistrationId !== "string" || !REGISTRATION_ID.test(event.challengedRegistrationId)) throw new Error("challenged registration ID is invalid"); + if (typeof event.challengerRegistrationId !== "string" || !REGISTRATION_ID.test(event.challengerRegistrationId)) throw new Error("challenger registration ID is invalid"); + if (event.challengedRegistrationId === event.challengerRegistrationId) throw new Error("a registration cannot challenge itself"); + address(event.challengerWallet, "challenger wallet"); + if (!Array.isArray(event.evidenceUris)) throw new Error("challenge evidenceUris must be an array"); + const evidenceUris = event.evidenceUris.map((uri, index) => { + nonempty(uri, `challenge evidenceUris[${index}]`); + let parsed: URL; + try { parsed = new URL(uri); } catch { throw new Error("challenge evidence URI is invalid"); } + if (!new Set(["https:", "ipfs:"]).has(parsed.protocol)) throw new Error("challenge evidence URI must use HTTPS or IPFS"); + return uri; + }); + if (new Set(evidenceUris).size !== evidenceUris.length) throw new Error("challenge evidenceUris must be unique"); + if (!(event.reason === "duplicate_bytes" || event.reason === "misattributed_creator" || event.reason === "unauthorized_registration")) throw new Error("challenge reason is invalid"); + hash(event.statementHash, "challenge statementHash"); + signature(event.signature, "challenge signature"); + return { ...event, evidenceUris } as unknown as ChallengeOpenedEvent; + } + if (event.type === "challenge_resolved") { + exactKeys(event, ["type", "eventId", "sequence", "occurredAt", "conflictId", "outcome", "rationale", "adminSignerId", "statementHash", "signature"], "resolution event"); + parseEventBase(event); + nonempty(event.conflictId, "resolution conflictId"); + if (!(event.outcome === "upheld" || event.outcome === "rejected" || event.outcome === "inconclusive")) throw new Error("resolution outcome is invalid"); + nonempty(event.rationale, "resolution rationale"); + if (typeof event.adminSignerId !== "string" || !IDENTIFIER.test(event.adminSignerId)) throw new Error("resolution adminSignerId is invalid"); + hash(event.statementHash, "resolution statementHash"); + signature(event.signature, "resolution signature"); + return event as unknown as ChallengeResolvedEvent; + } + if (event.type === "attestation_revoked") { + exactKeys(event, ["type", "eventId", "sequence", "occurredAt", "registrationId", "level", "reason", "adminSignerId", "statementHash", "signature"], "revocation event"); + parseEventBase(event); + if (typeof event.registrationId !== "string" || !REGISTRATION_ID.test(event.registrationId)) throw new Error("revocation registrationId is invalid"); + if (event.level === "wallet_asserted") throw new Error("wallet_asserted cannot be revoked"); + if (event.level !== "repository_control_verified" && event.level !== "organization_approved") throw new Error("revocation level is invalid"); + nonempty(event.reason, "revocation reason"); + if (typeof event.adminSignerId !== "string" || !IDENTIFIER.test(event.adminSignerId)) throw new Error("revocation adminSignerId is invalid"); + hash(event.statementHash, "revocation statementHash"); + signature(event.signature, "revocation signature"); + return event as unknown as AttestationRevokedEvent; + } + throw new Error("attestation event type is invalid"); +} + +function subjectEquals(a: RegistrationSubject, b: RegistrationSubject): boolean { + return a.registrationId === b.registrationId + && a.ipId === b.ipId + && a.wallet === b.wallet + && a.artifactHash === b.artifactHash + && a.declaredParentIpIds.length === b.declaredParentIpIds.length + && a.declaredParentIpIds.every((value, index) => value === b.declaredParentIpIds[index]); +} + +export function canonicalRepositoryStatement(challengeValue: RepositoryControlChallengeV1): string { + const challenge = parseRepositoryChallenge(challengeValue); + return [ + "skill-asset-protocol/repository-control/v1", + `registration=${challenge.subject.registrationId}`, + `ipId=${challenge.subject.ipId}`, + `wallet=${challenge.subject.wallet}`, + `artifactSha256=${challenge.subject.artifactHash}`, + `repository=${challenge.repositoryUrl}`, + `artifactCommit=${challenge.artifactCommitSha}`, + `artifactPath=${challenge.artifactPath}`, + `challengePath=${challenge.challengePath}`, + `nonce=${challenge.nonce}`, + `issuedAt=${challenge.issuedAt}`, + `expiresAt=${challenge.expiresAt}`, + "", + ].join("\n"); +} + +export function repositoryStatementHash(challenge: RepositoryControlChallengeV1): `0x${string}` { + return keccak256(stringToHex(canonicalRepositoryStatement(challenge))); +} + +export async function verifyRepositoryEventSignature(eventValue: RepositoryControlEvent): Promise { + const event = parseAttestationEvent(eventValue); + if (event.type !== "repository_control_verified") throw new Error("repository event required"); + if (!subjectEquals(event.subject, event.challenge.subject)) throw new Error("repository event subject drift"); + const expectedHash = repositoryStatementHash(event.challenge); + if (event.statementHash !== expectedHash) throw new Error("repository statement hash mismatch"); + const valid = await verifyMessage({ + address: event.subject.wallet, + message: canonicalRepositoryStatement(event.challenge), + signature: event.signature, + }); + if (!valid) throw new Error("repository signature does not recover the subject wallet"); +} + +type UnsignedApproval = Omit; + +export function canonicalOrganizationStatement(approvalValue: UnsignedApproval): string { + const approval = parseApproval({ + ...approvalValue, + statementHash: `0x${"0".repeat(64)}`, + signature: "0x00", + }); + return [ + "skill-asset-protocol/organization-approval/v1", + `registration=${approval.subject.registrationId}`, + `ipId=${approval.subject.ipId}`, + `wallet=${approval.subject.wallet}`, + `artifactSha256=${approval.subject.artifactHash}`, + `declaredParentIpIds=${[...approval.subject.declaredParentIpIds].sort().join(",")}`, + `organizationId=${approval.organizationId}`, + `approverWallet=${approval.approverWallet}`, + `role=${approval.role}`, + `approvedAt=${approval.approvedAt}`, + "", + ].join("\n"); +} + +export function organizationStatementHash(approval: UnsignedApproval): `0x${string}` { + return keccak256(stringToHex(canonicalOrganizationStatement(approval))); +} + +export async function verifyOrganizationApproval( + approvalValue: OrganizationApprovalV1, + organizationSigners: Readonly>, +): Promise { + const approval = parseApproval(approvalValue); + const unsigned: UnsignedApproval = { + schemaVersion: approval.schemaVersion, + subject: approval.subject, + organizationId: approval.organizationId, + approverWallet: approval.approverWallet, + role: approval.role, + approvedAt: approval.approvedAt, + }; + if (approval.statementHash !== organizationStatementHash(unsigned)) throw new Error("organization statement hash mismatch"); + const trusted = organizationSigners[approval.organizationId] ?? []; + if (!trusted.includes(approval.approverWallet)) throw new Error("organization approver is not allow-listed"); + if (!await verifyMessage({ address: approval.approverWallet, message: canonicalOrganizationStatement(unsigned), signature: approval.signature })) { + throw new Error("organization signature does not recover the approver wallet"); + } +} + +function eventStatement(header: string, rows: readonly [string, string][]): string { + return [header, ...rows.map(([key, value]) => `${key}=${value}`), ""].join("\n"); +} + +export function canonicalChallengeEventStatement(eventValue: ChallengeOpenedEvent): string { + const event = parseAttestationEvent(eventValue); + if (event.type !== "challenge_opened") throw new Error("challenge event required"); + return eventStatement("skill-asset-protocol/challenge-opened/v1", [ + ["eventId", event.eventId], ["sequence", String(event.sequence)], ["occurredAt", event.occurredAt], + ["conflictId", event.conflictId], ["challengedRegistrationId", event.challengedRegistrationId], + ["challengerRegistrationId", event.challengerRegistrationId], ["challengerWallet", event.challengerWallet], + ["evidenceUris", [...event.evidenceUris].sort().join(",")], ["reason", event.reason], + ]); +} + +export function canonicalAdminEventStatement(eventValue: ChallengeResolvedEvent | AttestationRevokedEvent): string { + const event = parseAttestationEvent(eventValue); + if (event.type === "challenge_resolved") { + return eventStatement("skill-asset-protocol/challenge-resolved/v1", [ + ["eventId", event.eventId], ["sequence", String(event.sequence)], ["occurredAt", event.occurredAt], + ["conflictId", event.conflictId], ["outcome", event.outcome], ["rationale", event.rationale], + ["adminSignerId", event.adminSignerId], + ]); + } + if (event.type === "attestation_revoked") { + return eventStatement("skill-asset-protocol/attestation-revoked/v1", [ + ["eventId", event.eventId], ["sequence", String(event.sequence)], ["occurredAt", event.occurredAt], + ["registrationId", event.registrationId], ["level", event.level], ["reason", event.reason], + ["adminSignerId", event.adminSignerId], + ]); + } + throw new Error("admin event required"); +} + +export function challengeEventStatementHash(event: ChallengeOpenedEvent): `0x${string}` { + return keccak256(stringToHex(canonicalChallengeEventStatement(event))); +} + +export function adminEventStatementHash(event: ChallengeResolvedEvent | AttestationRevokedEvent): `0x${string}` { + return keccak256(stringToHex(canonicalAdminEventStatement(event))); +} + +export async function verifyChallengeEventSignature( + eventValue: ChallengeOpenedEvent, + subjects: Readonly>, +): Promise { + const event = parseAttestationEvent(eventValue); + if (event.type !== "challenge_opened") throw new Error("challenge event required"); + const challenger = subjects[event.challengerRegistrationId]; + if (!challenger) throw new Error("challenger registration is unknown"); + if (!subjects[event.challengedRegistrationId]) throw new Error("challenged registration is unknown"); + if (challenger.wallet !== event.challengerWallet) throw new Error("challenger wallet does not match its registration"); + if (event.statementHash !== challengeEventStatementHash(event)) throw new Error("challenge statement hash mismatch"); + if (!await verifyMessage({ address: challenger.wallet, message: canonicalChallengeEventStatement(event), signature: event.signature })) { + throw new Error("challenge signature does not recover the challenger wallet"); + } +} + +export async function verifyAdminEventSignature( + eventValue: ChallengeResolvedEvent | AttestationRevokedEvent, + adminSigners: Readonly>, +): Promise { + const event = parseAttestationEvent(eventValue); + if (event.type !== "challenge_resolved" && event.type !== "attestation_revoked") throw new Error("admin event required"); + const signer = adminSigners[event.adminSignerId]; + if (!signer) throw new Error("admin signer is not provisioned"); + address(signer, "admin signer address"); + if (event.statementHash !== adminEventStatementHash(event)) throw new Error("admin statement hash mismatch"); + if (!await verifyMessage({ address: signer, message: canonicalAdminEventStatement(event), signature: event.signature })) { + throw new Error("admin signature does not recover the provisioned wallet"); + } +} + +export function registrationSubjectsFromManifest(manifest: RegistrationManifest): RegistrationSubject[] { + if (manifest.status === "not-run") return []; + if (!manifest.wallet) throw new Error("confirmed registration manifest wallet is required"); + return (Object.values(manifest.registrations).filter((proof) => proof !== null)).map((proof) => ({ + registrationId: `eip155:1315:${proof.ipId.toLowerCase()}` as `eip155:1315:${string}`, + ipId: proof.ipId.toLowerCase() as `0x${string}`, + wallet: manifest.wallet!.toLowerCase() as `0x${string}`, + artifactHash: proof.metadata.artifact.mediaHash.toLowerCase() as `0x${string}`, + declaredParentIpIds: proof.parentIpIds.map((parent) => parent.toLowerCase() as `0x${string}`), + })); +} + +export function deterministicConflictId(a: RegistrationSubject, b: RegistrationSubject): string { + const [first, second] = [a.registrationId, b.registrationId].sort(); + return `sha256:${createHash("sha256").update(`${first}\n${second}`).digest("hex")}`; +} + +const CLAIMS: Record = { + wallet_asserted: "wallet registered these bytes and declared this ancestry", + repository_control_verified: "wallet signature and matching bytes were verified against a trusted forge observation and verifier-provisioned Git snapshot", + organization_approved: "named organization signer approved the Skill and Creator relationship", +}; + +export async function reduceAttestationEvents( + eventValues: readonly AttestationEvent[], + trust: { + organizationSigners?: Readonly>; + adminSigners?: Readonly>; + baseSubjects?: readonly RegistrationSubject[]; + repositoryVerifier?: (event: RepositoryControlEvent) => Promise; + } = {}, +): Promise { + const subjects: Record = {}; + for (const raw of trust.baseSubjects ?? []) { + const subject = parseSubject(raw, "base registration subject"); + if (subjects[subject.registrationId]) throw new Error(`duplicate base registration ${subject.registrationId}`); + subjects[subject.registrationId] = subject; + } + + const registrations: Record = {}; + for (const subject of Object.values(subjects)) { + registrations[subject.registrationId] = { + subject, + level: "wallet_asserted", + status: "active", + claim: CLAIMS.wallet_asserted, + safetyReviewStatus: "not_reviewed", + evidenceEventIds: [], + revocations: [], + repositoryActive: false, + organizationActive: false, + }; + } + + const conflicts = new Map(); + const byHash = new Map(); + for (const subject of Object.values(subjects)) { + const group = byHash.get(subject.artifactHash) ?? []; + for (const prior of group) { + if (prior.wallet === subject.wallet) continue; + const conflictId = deterministicConflictId(prior, subject); + conflicts.set(conflictId, { + conflictId, + artifactHash: subject.artifactHash, + registrationIds: [prior.registrationId, subject.registrationId].sort(), + status: "open", + reason: "duplicate_bytes", + outcome: null, + eventIds: [], + }); + } + group.push(subject); + byHash.set(subject.artifactHash, group); + } + + const parsedEvents: AttestationEvent[] = []; + const eventIds = new Set(); + for (let index = 0; index < eventValues.length; index += 1) { + const event = parseAttestationEvent(eventValues[index]); + if (event.sequence !== index + 1) throw new Error(`attestation sequence must be contiguous at ${index + 1}`); + if (eventIds.has(event.eventId)) throw new Error(`duplicate attestation event ID ${event.eventId}`); + eventIds.add(event.eventId); + parsedEvents.push(event); + + if (event.type === "repository_control_verified") { + const registration = registrations[event.subject.registrationId]; + if (!registration || !subjectEquals(registration.subject, event.subject)) throw new Error("repository event subject drift or unknown registration"); + await verifyRepositoryEventSignature(event); + if (!trust.repositoryVerifier) throw new Error("repository verifier context required"); + await trust.repositoryVerifier(event); + registration.repositoryActive = true; + registration.evidenceEventIds = Object.freeze([...registration.evidenceEventIds, event.eventId]); + } else if (event.type === "organization_approved") { + const registration = registrations[event.subject.registrationId]; + if (!registration || !subjectEquals(registration.subject, event.subject) || !subjectEquals(event.subject, event.approval.subject)) throw new Error("organization event subject drift or unknown registration"); + if (!registration.repositoryActive) throw new Error("organization approval requires active repository evidence"); + await verifyOrganizationApproval(event.approval, trust.organizationSigners ?? {}); + registration.organizationActive = true; + registration.evidenceEventIds = Object.freeze([...registration.evidenceEventIds, event.eventId]); + } else if (event.type === "challenge_opened") { + await verifyChallengeEventSignature(event, subjects); + const existing = conflicts.get(event.conflictId); + if (existing) { + const ids = new Set(existing.registrationIds); + if (!ids.has(event.challengedRegistrationId) || !ids.has(event.challengerRegistrationId)) { + throw new Error("challenge registrations do not match the existing conflict"); + } + if (existing.status === "resolved") throw new Error("resolved conflict cannot be reopened"); + conflicts.set(event.conflictId, { ...existing, eventIds: [...existing.eventIds, event.eventId] }); + } else { + const challenged = subjects[event.challengedRegistrationId]; + const challenger = subjects[event.challengerRegistrationId]; + conflicts.set(event.conflictId, { + conflictId: event.conflictId, + artifactHash: challenged.artifactHash === challenger.artifactHash ? challenged.artifactHash : null, + registrationIds: [event.challengedRegistrationId, event.challengerRegistrationId].sort(), + status: "open", + reason: event.reason, + outcome: null, + eventIds: [event.eventId], + }); + } + } else if (event.type === "challenge_resolved") { + await verifyAdminEventSignature(event, trust.adminSigners ?? {}); + const conflict = conflicts.get(event.conflictId); + if (!conflict) throw new Error("resolution targets an unknown conflict"); + if (conflict.status === "resolved") throw new Error("conflict is already resolved"); + conflicts.set(event.conflictId, { ...conflict, status: "resolved", outcome: event.outcome, eventIds: [...conflict.eventIds, event.eventId] }); + } else { + await verifyAdminEventSignature(event, trust.adminSigners ?? {}); + const registration = registrations[event.registrationId]; + if (!registration) throw new Error("revocation targets an unknown registration"); + if (event.level === "repository_control_verified") { + if (!registration.repositoryActive) throw new Error("repository evidence is not active"); + registration.repositoryActive = false; + registration.organizationActive = false; + } else { + if (!registration.organizationActive) throw new Error("organization evidence is not active"); + registration.organizationActive = false; + } + registration.revocations = Object.freeze([...registration.revocations, { + level: event.level, + eventId: event.eventId, + occurredAt: event.occurredAt, + reason: event.reason, + }]); + } + } + + for (const registration of Object.values(registrations)) { + registration.level = registration.organizationActive + ? "organization_approved" + : registration.repositoryActive ? "repository_control_verified" : "wallet_asserted"; + registration.claim = CLAIMS[registration.level]; + } + for (const conflict of conflicts.values()) { + if (conflict.status === "open") { + for (const registrationId of conflict.registrationIds) { + if (registrations[registrationId]) registrations[registrationId].status = "challenged"; + } + } + } + + const publicRegistrations = Object.fromEntries(Object.entries(registrations).map(([id, value]) => { + const { repositoryActive: _repositoryActive, organizationActive: _organizationActive, ...publicValue } = value; + return [id, deepFreeze(publicValue)]; + })); + return deepFreeze({ + registrations: publicRegistrations, + conflicts: [...conflicts.values()].sort((a, b) => a.conflictId.localeCompare(b.conflictId)), + events: parsedEvents, + }); +} + +export function displayAttestation(index: AttestationIndex, registrationId: string): { + level: AttestationLevel; + status: AttestationStatus; + claim: string; + safetyReviewStatus: SafetyReviewStatus; + warnings: string[]; +} { + const registration = index.registrations[registrationId]; + if (!registration) throw new Error(`unknown registration ${registrationId}`); + const warnings = [ + "registration does not prove authorship, originality, legal ownership, or safety", + `Safety review: ${registration.safetyReviewStatus}; authorship attestation does not prove safety.`, + ]; + if (registration.level === "repository_control_verified" || registration.level === "organization_approved") { + warnings.push("Repository evidence relies on the named forge observer and snapshot; it does not prove current remote account ownership or continuing hosting."); + } + return deepFreeze({ + level: registration.level, + status: registration.status, + claim: registration.claim, + safetyReviewStatus: registration.safetyReviewStatus, + warnings, + }); +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const item of Object.values(value as Record)) deepFreeze(item); + return value; +} diff --git a/phase0/tests/attestations.test.ts b/phase0/tests/attestations.test.ts new file mode 100644 index 0000000..21cb84d --- /dev/null +++ b/phase0/tests/attestations.test.ts @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +import { + adminEventStatementHash, + canonicalAdminEventStatement, + canonicalChallengeEventStatement, + canonicalOrganizationStatement, + canonicalRepositoryStatement, + challengeEventStatementHash, + deterministicConflictId, + displayAttestation, + organizationStatementHash, + parseAttestationEvent, + reduceAttestationEvents, + repositoryStatementHash, + type AttestationRevokedEvent, + type ChallengeOpenedEvent, + type ChallengeResolvedEvent, + type OrganizationApprovedEvent, + type RegistrationSubject, + type RepositoryControlChallengeV1, + type RepositoryControlEvent, +} from "../src/attestations"; + +const HASH_A = `0x${"1".repeat(64)}` as const; +const HASH_B = `0x${"2".repeat(64)}` as const; +const IP_A = `0x${"a".repeat(40)}` as const; +const IP_B = `0x${"b".repeat(40)}` as const; +const NOW = "2026-07-18T12:00:00.000Z"; + +function subject(ipId: `0x${string}`, wallet: `0x${string}`, artifactHash = HASH_A): RegistrationSubject { + return { + registrationId: `eip155:1315:${ipId}`, + ipId, + wallet: wallet.toLowerCase() as `0x${string}`, + artifactHash, + declaredParentIpIds: [], + }; +} + +function challengeFor(value: RegistrationSubject): RepositoryControlChallengeV1 { + return { + schemaVersion: 1, + subject: value, + repositoryUrl: "https://github.com/example/skill", + artifactCommitSha: "1".repeat(40), + artifactPath: "skills/demo/SKILL.md", + challengePath: "attestations/demo.json", + nonce: `0x${"3".repeat(64)}`, + issuedAt: "2026-07-18T10:00:00.000Z", + expiresAt: "2026-07-18T14:00:00.000Z", + }; +} + +async function repositoryEvent(value: RegistrationSubject, account: ReturnType): Promise { + const challenge = challengeFor(value); + return { + type: "repository_control_verified", + eventId: "repo-1", + sequence: 1, + occurredAt: NOW, + subject: value, + challenge, + forgeObservation: { + schemaVersion: 1, + repositoryId: "demo", + repositoryUrl: challenge.repositoryUrl, + trustedRef: "refs/heads/main", + proofCommitSha: "2".repeat(40), + challengeNonce: challenge.nonce, + observedAt: "2026-07-18T11:00:00.000Z", + forgeSignerId: "forge-1", + signature: "test-signature", + }, + statementHash: repositoryStatementHash(challenge), + signature: await account.signMessage({ message: canonicalRepositoryStatement(challenge) }), + }; +} + +test("canonical repository statement has fixed order and exactly one trailing newline", () => { + const account = privateKeyToAccount(generatePrivateKey()); + const statement = canonicalRepositoryStatement(challengeFor(subject(IP_A, account.address))); + assert.equal(statement.split("\n")[0], "skill-asset-protocol/repository-control/v1"); + assert.match(statement, /\nregistration=eip155:1315:/); + assert.ok(statement.endsWith("\n")); + assert.ok(!statement.endsWith("\n\n")); +}); + +test("wallet assertion is seeded only by base subjects", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const state = await reduceAttestationEvents([], { baseSubjects: [base] }); + assert.equal(state.registrations[base.registrationId].level, "wallet_asserted"); + assert.equal(state.registrations[base.registrationId].claim, "wallet registered these bytes and declared this ancestry"); + assert.equal(state.registrations[base.registrationId].safetyReviewStatus, "not_reviewed"); + assert.throws(() => parseAttestationEvent({ type: "wallet_asserted" }), /cannot be a sidecar/); + assert.throws( + () => parseAttestationEvent({ type: "attestation_revoked", level: "wallet_asserted" }), + /unexpected or missing|cannot be revoked/, + ); +}); + +test("wallet-signed repository evidence requires the injected repository verifier", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const event = await repositoryEvent(base, account); + await assert.rejects(reduceAttestationEvents([event], { baseSubjects: [base] }), /repository verifier context required/); + const state = await reduceAttestationEvents([event], { baseSubjects: [base], repositoryVerifier: async () => undefined }); + assert.equal(state.registrations[base.registrationId].level, "repository_control_verified"); + assert.match(displayAttestation(state, base.registrationId).warnings.join("\n"), /does not prove current remote account ownership/); +}); + +test("tampered repository subject, statement, and signature fail closed", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const other = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const event = await repositoryEvent(base, account); + const trust = { baseSubjects: [base], repositoryVerifier: async () => undefined }; + await assert.rejects(reduceAttestationEvents([{ ...event, statementHash: HASH_B }], trust), /statement hash mismatch/); + await assert.rejects(reduceAttestationEvents([{ ...event, signature: await other.signMessage({ message: canonicalRepositoryStatement(event.challenge) }) }], trust), /does not recover/); + await assert.rejects(reduceAttestationEvents([{ ...event, subject: { ...base, artifactHash: HASH_B } }], trust), /subject drift/); +}); + +test("organization approval requires repository evidence and an allow-listed signer", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const approver = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const repo = await repositoryEvent(base, account); + const unsigned = { + schemaVersion: 1 as const, + subject: base, + organizationId: "example-org", + approverWallet: approver.address.toLowerCase() as `0x${string}`, + role: "ip_admin" as const, + approvedAt: NOW, + }; + const approval = { + ...unsigned, + statementHash: organizationStatementHash(unsigned), + signature: await approver.signMessage({ message: canonicalOrganizationStatement(unsigned) }), + }; + const event: OrganizationApprovedEvent = { + type: "organization_approved", + eventId: "org-1", + sequence: 2, + occurredAt: NOW, + subject: base, + approval, + }; + await assert.rejects(reduceAttestationEvents([{ ...event, sequence: 1 }], { + baseSubjects: [base], organizationSigners: { "example-org": [unsigned.approverWallet] }, + }), /requires active repository evidence/); + await assert.rejects(reduceAttestationEvents([repo, event], { + baseSubjects: [base], repositoryVerifier: async () => undefined, + }), /not allow-listed/); + const state = await reduceAttestationEvents([repo, event], { + baseSubjects: [base], repositoryVerifier: async () => undefined, + organizationSigners: { "example-org": [unsigned.approverWallet] }, + }); + assert.equal(state.registrations[base.registrationId].level, "organization_approved"); +}); + +test("duplicate bytes under different wallets create a deterministic visible conflict", async () => { + const first = privateKeyToAccount(generatePrivateKey()); + const second = privateKeyToAccount(generatePrivateKey()); + const a = subject(IP_A, first.address); + const b = subject(IP_B, second.address); + const forward = await reduceAttestationEvents([], { baseSubjects: [a, b] }); + const reverse = await reduceAttestationEvents([], { baseSubjects: [b, a] }); + assert.equal(forward.conflicts.length, 1); + assert.equal(forward.conflicts[0].conflictId, deterministicConflictId(a, b)); + assert.deepEqual(forward.conflicts, reverse.conflicts); + assert.equal(forward.registrations[a.registrationId].status, "challenged"); + assert.equal(forward.registrations[b.registrationId].status, "challenged"); +}); + +test("signed challenges, resolutions, and revocations preserve history", async () => { + const first = privateKeyToAccount(generatePrivateKey()); + const second = privateKeyToAccount(generatePrivateKey()); + const admin = privateKeyToAccount(generatePrivateKey()); + const a = subject(IP_A, first.address); + const b = subject(IP_B, second.address); + const repo = await repositoryEvent(a, first); + const conflictId = deterministicConflictId(a, b); + const challengeBase = { + type: "challenge_opened" as const, + eventId: "challenge-1", + sequence: 2, + occurredAt: NOW, + conflictId, + challengedRegistrationId: a.registrationId, + challengerRegistrationId: b.registrationId, + challengerWallet: b.wallet, + evidenceUris: ["https://example.com/evidence"], + reason: "duplicate_bytes" as const, + statementHash: HASH_A, + signature: "0x00" as `0x${string}`, + }; + const challenge: ChallengeOpenedEvent = { + ...challengeBase, + statementHash: challengeEventStatementHash(challengeBase), + signature: await second.signMessage({ message: canonicalChallengeEventStatement({ ...challengeBase, statementHash: challengeEventStatementHash(challengeBase) }) }), + }; + const resolutionBase = { + type: "challenge_resolved" as const, + eventId: "resolution-1", + sequence: 3, + occurredAt: NOW, + conflictId, + outcome: "inconclusive" as const, + rationale: "Evidence remains incomplete.", + adminSignerId: "admin-1", + statementHash: HASH_A, + signature: "0x00" as `0x${string}`, + }; + const resolution: ChallengeResolvedEvent = { + ...resolutionBase, + statementHash: adminEventStatementHash(resolutionBase), + signature: await admin.signMessage({ message: canonicalAdminEventStatement({ ...resolutionBase, statementHash: adminEventStatementHash(resolutionBase) }) }), + }; + const revocationBase = { + type: "attestation_revoked" as const, + eventId: "revoke-1", + sequence: 4, + occurredAt: NOW, + registrationId: a.registrationId, + level: "repository_control_verified" as const, + reason: "Snapshot trust was withdrawn.", + adminSignerId: "admin-1", + statementHash: HASH_A, + signature: "0x00" as `0x${string}`, + }; + const revoked: AttestationRevokedEvent = { + ...revocationBase, + statementHash: adminEventStatementHash(revocationBase), + signature: await admin.signMessage({ message: canonicalAdminEventStatement({ ...revocationBase, statementHash: adminEventStatementHash(revocationBase) }) }), + }; + const state = await reduceAttestationEvents([repo, challenge, resolution, revoked], { + baseSubjects: [a, b], repositoryVerifier: async () => undefined, + adminSigners: { "admin-1": admin.address.toLowerCase() as `0x${string}` }, + }); + assert.equal(state.registrations[a.registrationId].level, "wallet_asserted"); + assert.equal(state.registrations[a.registrationId].status, "active"); + assert.equal(state.registrations[a.registrationId].revocations[0].level, "repository_control_verified"); + assert.equal(state.conflicts[0].outcome, "inconclusive"); + assert.equal(state.events.length, 4); +}); + +test("sequence gaps, duplicate IDs, malformed normalized inputs, and overclaim text fail", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const repo = await repositoryEvent(base, account); + await assert.rejects(reduceAttestationEvents([{ ...repo, sequence: 2 }], { + baseSubjects: [base], repositoryVerifier: async () => undefined, + }), /contiguous/); + await assert.rejects(reduceAttestationEvents([repo, { ...repo, sequence: 2 }], { + baseSubjects: [base], repositoryVerifier: async () => undefined, + }), /duplicate attestation event ID/); + assert.throws(() => canonicalRepositoryStatement({ ...repo.challenge, repositoryUrl: "https://github.com/example/skill/" }), /normalized HTTPS/); + const state = await reduceAttestationEvents([], { baseSubjects: [base] }); + const rendered = JSON.stringify(displayAttestation(state, base.registrationId)).toLowerCase(); + assert.doesNotMatch(rendered, /authored by|safe skill|proves originality|proves safety/); +}); From 6d5aa21e2a2a6e419d86cd9a1797c2c3f2517275 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:11:13 -0400 Subject: [PATCH 068/165] feat: bind x402 payment lifecycle --- spikes/pi-wielder/src/facilitator-mock.mjs | 2 +- spikes/pi-wielder/src/gateway.mjs | 34 +- spikes/pi-wielder/src/proxy.mjs | 33 +- spikes/pi-wielder/src/x402-seller.mjs | 432 +++++++++++++----- .../pi-wielder/tests/atomic-boundary.test.mjs | 12 + .../tests/gateway-transport.test.mjs | 34 ++ .../pi-wielder/tests/x402-lifecycle.test.mjs | 287 ++++++++++++ 7 files changed, 701 insertions(+), 133 deletions(-) create mode 100644 spikes/pi-wielder/tests/atomic-boundary.test.mjs create mode 100644 spikes/pi-wielder/tests/gateway-transport.test.mjs create mode 100644 spikes/pi-wielder/tests/x402-lifecycle.test.mjs diff --git a/spikes/pi-wielder/src/facilitator-mock.mjs b/spikes/pi-wielder/src/facilitator-mock.mjs index 0dfc926..82d5e1f 100644 --- a/spikes/pi-wielder/src/facilitator-mock.mjs +++ b/spikes/pi-wielder/src/facilitator-mock.mjs @@ -45,7 +45,7 @@ export function createMockFacilitator() { if (paymentPayload?.scheme !== 'exact' || paymentPayload?.network !== NETWORK) return fail('scheme/network mismatch'); if (!auth || !signature) return fail('missing authorization or signature'); if (auth.to?.toLowerCase() !== req?.payTo?.toLowerCase()) return fail('authorization pays the wrong address'); - if (BigInt(auth.value) < BigInt(req.maxAmountRequired)) return fail('authorized amount below price'); + if (String(auth.value) !== String(req.maxAmountRequired)) return fail('authorization amount must equal price'); const now = Math.floor(Date.now() / 1000); if (now <= Number(auth.validAfter) || now >= Number(auth.validBefore)) return fail('authorization outside validity window'); diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index 4ad9164..5285f57 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -14,18 +14,22 @@ import { pathToFileURL } from 'node:url'; import { Hono } from 'hono'; import { serve } from '@hono/node-server'; -import { x402Paywall } from './x402-seller.mjs'; +import { + createLiveFacilitatorTransport, + createMockFacilitatorTransport, + x402Paywall, +} from './x402-seller.mjs'; // Flat per-call testnet prices by model family (real resellers price per // token; per-call keeps the 402 requirements computable before inference). -export const MODEL_PRICES_USDC = { claude: 0.041, gpt: 0.087, default: 0.05 }; +export const MODEL_PRICES_USDC = Object.freeze({ claude: '0.041', gpt: '0.087', default: '0.05' }); const priceFor = (model = '') => model.startsWith('claude') ? MODEL_PRICES_USDC.claude : model.startsWith('gpt') ? MODEL_PRICES_USDC.gpt : MODEL_PRICES_USDC.default; export function createGateway({ - facilitatorUrl, + facilitatorTransport, payTo = process.env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dEaD', mockLlm = process.env.MOCK_LLM === '1', } = {}) { @@ -39,7 +43,7 @@ export function createGateway({ // and again in the handler is safe. price: async (c) => priceFor((await c.req.json().catch(() => ({}))).model), payTo, - facilitatorUrl, + facilitatorTransport, description: 'per-call model inference (x402 reseller, testnet)', }), async (c) => { @@ -196,12 +200,22 @@ export function startGateway({ port = 0, ...opts } = {}) { // Standalone: `npm run gateway` if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const { startMockFacilitator } = await import('./facilitator-mock.mjs'); - const facilitatorUrl = process.env.MOCK_FACILITATOR === '1' - ? (await startMockFacilitator()).url - : (process.env.FACILITATOR_URL || 'https://x402.org/facilitator'); - const { url } = await startGateway({ port: Number(process.env.GATEWAY_PORT || 8403), facilitatorUrl }); - console.log(`[gateway] x402-gated /v1/chat/completions at ${url} (facilitator: ${facilitatorUrl})`); + let facilitatorTransport; + let facilitatorMode; + if (process.env.ALLOW_LIVE_X402 === '1') { + facilitatorTransport = createLiveFacilitatorTransport(process.env.FACILITATOR_URL); + facilitatorMode = 'approved-base-sepolia'; + } else { + const { createMockFacilitator } = await import('./facilitator-mock.mjs'); + const facilitator = createMockFacilitator(); + facilitatorTransport = createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); + facilitatorMode = 'in-process-mock'; + } + const { url } = await startGateway({ + port: Number(process.env.GATEWAY_PORT || 8403), + facilitatorTransport, + }); + console.log(`[gateway] x402-gated /v1/chat/completions at ${url} (facilitator: ${facilitatorMode})`); } // Wrap a completed chat.completion as OpenAI SSE chunks. Not true streaming — diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index a501d98..c7e79f5 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -17,6 +17,7 @@ import crypto from 'node:crypto'; import { pathToFileURL } from 'node:url'; import { Hono } from 'hono'; import { serve } from '@hono/node-server'; +import { formatUsdc } from '../../../prototype/atomic-money.mjs'; import { loadAccount } from './wallet.mjs'; import { createLedger, renderLedger } from './ledger.mjs'; @@ -35,12 +36,16 @@ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64'); const unb64 = (s) => JSON.parse(Buffer.from(s, 'base64').toString('utf8')); // The whole buyer protocol: request -> 402 -> sign EIP-3009 -> retry once. -// Returns { res, paid, xPayment, timings } — timings are the spike's +// Returns the accepted quote/authorization identity with the response. Timings are the spike's // payment-overhead measurement (402 roundtrip + sign + facilitator). -async function payingFetch(account, url, init) { +export async function payingFetch(account, url, init, { + fetchImpl = fetch, + idempotencyKey = crypto.randomUUID(), +} = {}) { + const requestHeaders = { ...init.headers, 'Idempotency-Key': idempotencyKey }; const t0 = performance.now(); - const first = await fetch(url, init); - if (first.status !== 402) return { res: first, paid: false }; + const first = await fetchImpl(url, { ...init, headers: requestHeaders }); + if (first.status !== 402) return { res: first, paid: false, idempotencyKey }; const ms402 = performance.now() - t0; // The 402 body carries PaymentRequirements; we accept the first offer. @@ -71,12 +76,26 @@ async function payingFetch(account, url, init) { // Retry with X-PAYMENT. The seller verifies + settles via its facilitator. const xPayment = b64({ x402Version: 1, scheme: 'exact', network: req.network, payload: { signature, authorization } }); const tRetry = performance.now(); - const res = await fetch(url, { ...init, headers: { ...init.headers, 'X-PAYMENT': xPayment } }); + const res = await fetchImpl(url, { + ...init, + headers: { ...requestHeaders, 'X-PAYMENT': xPayment }, + }); const msPaidRoundtrip = performance.now() - tRetry; const msFacilitator = Number(res.headers.get('X-402-FACILITATOR-MS') ?? NaN); // seller-reported verify+settle + const paymentResponse = res.headers.get('X-PAYMENT-RESPONSE'); + const settlement = paymentResponse ? unb64(paymentResponse) : null; return { - res, paid: true, xPayment, - amountUSDC: Number(req.maxAmountRequired) / 1e6, + res, + paid: true, + xPayment, + idempotencyKey, + settlementReference: authorization.nonce.toLowerCase(), + txHash: settlement?.transaction ?? null, + payer: account.address.toLowerCase(), + requestHash: req.extra.requestHash, + quoteId: req.extra.quoteId, + amountAtomic: String(req.maxAmountRequired), + amountDisplay: formatUsdc(BigInt(req.maxAmountRequired)), timings: { ms402, msSign, msFacilitator, msPaidRoundtrip, msOverhead: ms402 + msSign + (msFacilitator || 0) }, }; } diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index 542441e..cdaf179 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -1,152 +1,354 @@ -// x402-seller.mjs — the SELLER half of the x402 protocol, written out by hand. +// Seller-side x402 v1 boundary for the offline/testnet spike. // -// Both paid services in this spike (collar.mjs, gateway.mjs) gate their routes -// with the `x402Paywall` Hono middleware below. We deliberately implement the -// x402 v1 "exact" scheme manually instead of pulling in `@x402/hono`: -// the published 2.x packages implement protocol v2 (class-based scheme -// registries, facilitator sync-on-start) while the free no-auth testnet -// facilitator at https://x402.org/facilitator speaks v1 — and, for a spike, -// spelling the handshake out is the argument. The whole protocol is ~100 -// commented lines. -// -// The seller flow (this file), per the x402 v1 spec: -// 1. Request arrives without an X-PAYMENT header -// -> respond 402 with { x402Version: 1, accepts: [PaymentRequirements] }. -// 2. Client retries with X-PAYMENT: base64(JSON payment payload) -// -> POST facilitator /verify (checks the EIP-3009 signature + funds) -// -> POST facilitator /settle (broadcasts transferWithAuthorization; -// the settled txHash is the receipt) -// 3. The settled txHash is treated as a SINGLE-USE EXECUTION CREDENTIAL: -// it goes into an in-memory consumed-set and any replay of the same -// payment is rejected. (On a real chain the EIP-3009 nonce makes the -// replayed /settle fail anyway; the consumed-set makes the same property -// hold under the mock facilitator, and models the protocol's -// "no credential, no run" rule explicitly.) -// 4. Only then does the resource handler run. Success responses carry an -// X-PAYMENT-RESPONSE header (base64 settlement receipt) so the buyer -// learns the txHash. -// -// NOTE we settle BEFORE executing the resource. Production middleware usually -// executes first and settles after (so a crashed handler doesn't charge the -// buyer); the collar wants the opposite order because the txHash *is* the -// execution credential — pay -> mint -> consume -> execute, exactly the -// sequence in prototype/settlement-engine.mjs. +// A client-generated Idempotency-Key binds method, resource URL, and exact body +// bytes to one frozen PaymentRequirements envelope. The Collar persists that +// envelope and owns payment/execution lifecycle state; this middleware never +// rebuilds an offer after restart and never treats a quote as authorization. + +import crypto from 'node:crypto'; + +import { formatUsdc, parseUsdc } from '../../../prototype/atomic-money.mjs'; -// --- x402 v1 / Base Sepolia constants ------------------------------------- export const X402_VERSION = 1; export const NETWORK = 'base-sepolia'; export const CHAIN_ID = 84532; -// Circle's canonical USDC deployment on Base Sepolia (6 decimals). -export const USDC_ADDRESS = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; -// EIP-712 domain values USDC uses for EIP-3009 signatures. -export const USDC_EIP712 = { name: 'USDC', version: '2' }; -export const USDC_DECIMALS = 6; - -export const usdcToAtomic = (usdc) => String(Math.round(Number(usdc) * 10 ** USDC_DECIMALS)); -export const atomicToUsdc = (atomic) => Number(atomic) / 10 ** USDC_DECIMALS; - -const b64ToJson = (s) => JSON.parse(Buffer.from(s, 'base64').toString('utf8')); -const jsonToB64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64'); - -/** - * Hono middleware that 402-gates a route. - * - * @param {object} opts - * @param {number|function} opts.price price in USDC (e.g. 0.25), or an async - * (honoContext) => number for per-request pricing - * @param {string} opts.payTo address the USDC authorization must pay - * @param {string} opts.facilitatorUrl x402 facilitator base URL (/verify, /settle) - * @param {string} opts.description human-readable description in the 402 offer - * - * On success, the settlement receipt is exposed to the downstream handler as - * c.get('x402') = { txHash, payer, amountUsdc, requirements }. - */ -export function x402Paywall({ price, payTo, facilitatorUrl, description = '' }) { - const consumed = new Set(); // settled txHash -> already-used execution credentials +export const USDC_ADDRESS = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +export const USDC_EIP712 = Object.freeze({ name: 'USDC', version: '2' }); +export const APPROVED_LIVE_FACILITATOR_BASE = 'https://x402.org/facilitator'; - return async (c, next) => { - const priceUsdc = typeof price === 'function' ? await price(c) : price; - const requirements = { - scheme: 'exact', - network: NETWORK, - maxAmountRequired: usdcToAtomic(priceUsdc), // atomic USDC (6 decimals) - resource: c.req.url, - description, - mimeType: 'application/json', - payTo, - maxTimeoutSeconds: 60, - asset: USDC_ADDRESS, - // The buyer needs these to build the EIP-712 domain it signs against. - extra: { name: USDC_EIP712.name, version: USDC_EIP712.version }, - }; +export const usdcToAtomic = (display) => parseUsdc(display).toString(); +export const atomicToUsdc = (atomic) => formatUsdc(BigInt(atomic)); + +const b64ToJson = (value) => JSON.parse(Buffer.from(value, 'base64').toString('utf8')); +const jsonToB64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64'); +const authorizedTransports = new WeakSet(); + +function authorizeTransport(transport) { + const frozen = Object.freeze(transport); + authorizedTransports.add(frozen); + return frozen; +} + +export function createMockFacilitatorTransport(fetchImpl) { + if (typeof fetchImpl !== 'function') throw new TypeError('mock facilitator requires an injected fetch/app'); + return authorizeTransport({ + mode: 'mock', + baseUrl: 'http://facilitator.invalid', + fetchImpl, + }); +} + +export function createLiveFacilitatorTransport(rawBaseUrl, fetchImpl = fetch) { + if (rawBaseUrl !== APPROVED_LIVE_FACILITATOR_BASE) { + const error = new Error('live facilitator is not the pinned approved endpoint'); + error.code = 'FACILITATOR_NOT_APPROVED'; + throw error; + } + const parsed = new URL(rawBaseUrl); + if (parsed.protocol !== 'https:' || parsed.username || parsed.password + || parsed.port || parsed.search || parsed.hash || parsed.pathname !== '/facilitator') { + const error = new Error('live facilitator endpoint violates the approved HTTPS contract'); + error.code = 'FACILITATOR_NOT_APPROVED'; + throw error; + } + if (typeof fetchImpl !== 'function') throw new TypeError('live facilitator requires fetch'); + return authorizeTransport({ mode: 'live', baseUrl: rawBaseUrl, fetchImpl }); +} + +function requireFacilitatorTransport(transport) { + if (!transport || !authorizedTransports.has(transport)) { + throw new Error('facilitatorTransport must come from an approved live or injected-mock constructor'); + } + return transport; +} - // -- step 1: no payment attached -> challenge with 402 ------------------ +function canonicalAddress(value) { + const text = String(value ?? ''); + if (!/^0x[0-9a-fA-F]{40}$/.test(text)) throw new Error('payTo must be a 20-byte hex address'); + return text.toLowerCase(); +} + +function validTxHash(value) { + return /^0x[0-9a-fA-F]{64}$/.test(String(value ?? '')); +} + +function terminalReplayIsTrusted(decision, payer) { + return decision?.kind === 'terminal' + && ['settled', 'refunded'].includes(decision.paymentState) + && validTxHash(decision.txHash) + && String(decision.payer ?? '').toLowerCase() === String(payer).toLowerCase() + && decision.receipt + && Number.isSafeInteger(decision.httpStatus) + && decision.httpStatus >= 100 + && decision.httpStatus <= 599; +} + +function validateAuthorizationEnvelope(paymentPayload, requirements) { + if (paymentPayload?.x402Version !== X402_VERSION + || paymentPayload?.scheme !== requirements.scheme + || paymentPayload?.network !== requirements.network) { + throw new Error('payment envelope does not exactly match the frozen x402 offer'); + } + const authorization = paymentPayload?.payload?.authorization; + if (!authorization || !paymentPayload?.payload?.signature) { + throw new Error('payment authorization lacks authorization or signature'); + } + if (String(authorization.to ?? '').toLowerCase() !== requirements.payTo.toLowerCase() + || String(authorization.value ?? '') !== requirements.maxAmountRequired) { + throw new Error('payment authorization must exactly match payee and quoted amount'); + } + if (!/^0x[0-9a-fA-F]{64}$/.test(String(authorization.nonce ?? '')) + || !/^0x[0-9a-fA-F]{40}$/.test(String(authorization.from ?? ''))) { + throw new Error('payment authorization lacks a valid nonce or payer'); + } + return authorization; +} + +export function x402Paywall({ + price, + payTo, + facilitatorTransport, + description = '', + lifecycle = {}, +}) { + const transport = requireFacilitatorTransport(facilitatorTransport); + const canonicalPayTo = canonicalAddress(payTo); + const frozenOffers = new Map(); + + return async (c, next) => { + const idempotencyKey = c.req.header('Idempotency-Key')?.trim(); + if (!idempotencyKey) return c.json({ error: 'Idempotency-Key header is required' }, 400); const paymentHeader = c.req.header('X-PAYMENT'); + const requestBody = await c.req.text(); + const requestHash = `sha256:${crypto.createHash('sha256') + .update(`${c.req.method}\n${c.req.url}\n${requestBody}`) + .digest('hex')}`; + + let requirements = frozenOffers.get(idempotencyKey) ?? null; + if (!requirements) { + const recovered = await lifecycle.loadFrozenOffer?.({ idempotencyKey }); + if (recovered) { + requirements = structuredClone(recovered); + frozenOffers.set(idempotencyKey, requirements); + } + } + if (requirements) { + if (requirements.extra?.requestHash !== requestHash) { + return c.json({ error: 'Idempotency-Key already binds a different request' }, 409); + } + } else { + if (paymentHeader) return c.json({ error: 'paid retry has no prior frozen x402 offer' }, 409); + const priceUsdc = typeof price === 'function' ? await price(c) : price; + const issuedAt = new Date().toISOString(); + const expiresAt = new Date(Date.now() + 60_000).toISOString(); + const base = { + scheme: 'exact', + network: NETWORK, + maxAmountRequired: usdcToAtomic(priceUsdc), + resource: c.req.url, + description, + mimeType: 'application/json', + payTo: canonicalPayTo, + maxTimeoutSeconds: 60, + asset: USDC_ADDRESS, + }; + const quoteId = `sha256:${crypto.createHash('sha256') + .update(JSON.stringify({ ...base, requestHash, issuedAt, expiresAt })) + .digest('hex')}`; + requirements = { + ...base, + extra: { + name: USDC_EIP712.name, + version: USDC_EIP712.version, + requestHash, + quoteId, + issuedAt, + expiresAt, + }, + }; + frozenOffers.set(idempotencyKey, requirements); + } + if (!paymentHeader) { - return c.json( - { x402Version: X402_VERSION, error: 'X-PAYMENT header is required', accepts: [requirements] }, - 402, - ); + try { + await lifecycle.onOffered?.({ + idempotencyKey, + requirements: structuredClone(requirements), + expiresAt: requirements.extra.expiresAt, + }); + } catch (error) { + return c.json({ error: error.message }, error.code === 'JOURNAL_CONFLICT' ? 409 : 409); + } + return c.json({ + x402Version: X402_VERSION, + error: 'X-PAYMENT header is required', + accepts: [requirements], + }, 402); } - // -- step 2: decode + verify + settle through the facilitator ----------- let paymentPayload; try { paymentPayload = b64ToJson(paymentHeader); } catch { - return c.json({ x402Version: X402_VERSION, error: 'malformed X-PAYMENT header', accepts: [requirements] }, 402); + return c.json({ + x402Version: X402_VERSION, + error: 'malformed X-PAYMENT header', + accepts: [requirements], + }, 402); } - const facilitatorBody = { x402Version: X402_VERSION, paymentPayload, paymentRequirements: requirements }; - const tFacilitator = performance.now(); // measured so the buyer can report verify+settle overhead - const verify = await postJson(`${facilitatorUrl}/verify`, facilitatorBody); - if (!verify?.isValid) { - return c.json( - { x402Version: X402_VERSION, error: `payment verification failed: ${verify?.invalidReason ?? 'unknown'}`, accepts: [requirements] }, - 402, - ); + let authorization; + try { + authorization = validateAuthorizationEnvelope(paymentPayload, requirements); + } catch (error) { + await lifecycle.onRejected?.({ idempotencyKey, reason: error.message }); + return c.json({ + x402Version: X402_VERSION, + error: error.message, + accepts: [requirements], + }, 402); + } + const settlementReference = authorization.nonce.toLowerCase(); + const payer = authorization.from.toLowerCase(); + let priorDecision = null; + try { + priorDecision = await lifecycle.onSigned?.({ + idempotencyKey, + settlementReference, + payer, + requirements: structuredClone(requirements), + }); + } catch (error) { + return c.json({ error: error.message }, 409); } - const settle = await postJson(`${facilitatorUrl}/settle`, facilitatorBody); - const facilitatorMs = performance.now() - tFacilitator; - if (!settle?.success) { - return c.json( - { x402Version: X402_VERSION, error: `payment settlement failed: ${settle?.errorReason ?? 'unknown'}`, accepts: [requirements] }, - 402, - ); + if (priorDecision?.kind === 'terminal') { + if (!terminalReplayIsTrusted(priorDecision, payer)) { + return c.json({ error: 'terminal replay lacks a settled or refunded transaction' }, 503); + } + const body = { + replayed: true, + receipt: priorDecision.receipt, + ...(priorDecision.httpStatus >= 400 + ? { error: priorDecision.receipt?.receipt?.execution?.message ?? 'terminal execution failed' } + : {}), + }; + const replay = c.json(body, priorDecision.httpStatus); + replay.headers.set('X-PAYMENT-RESPONSE', jsonToB64({ + success: true, + transaction: priorDecision.txHash, + network: NETWORK, + payer: priorDecision.payer, + settlementReference, + })); + return replay; + } + if (priorDecision?.kind === 'payment_unresolved') { + return c.json({ + error: 'payment settlement unresolved; trusted reconciliation is required', + settlementReference, + }, 503); + } + if (priorDecision?.kind === 'execution_unresolved') { + return c.json({ + error: 'execution outcome unresolved; trusted executor reconciliation is required', + executionAttemptId: priorDecision.executionAttemptId, + }, 503); } - // -- step 3: the settled txHash is a single-use credential -------------- - if (consumed.has(settle.transaction)) { - // "NO CREDENTIAL, NO RUN" — a credential spends exactly once. - return c.json({ error: 'replayed payment: credential already consumed', txHash: settle.transaction }, 409); + const facilitatorBody = { + x402Version: X402_VERSION, + paymentPayload, + paymentRequirements: requirements, + }; + let settle; + let facilitatorMs = 0; + if (priorDecision?.kind === 'settled') { + if (!validTxHash(priorDecision.txHash) + || String(priorDecision.payer ?? '').toLowerCase() !== payer) { + return c.json({ error: 'persisted settlement proof does not match the signed payer' }, 503); + } + settle = { + success: true, + transaction: priorDecision.txHash, + payer: priorDecision.payer, + network: NETWORK, + }; + } else { + const started = performance.now(); + try { + const verify = await postJson(transport, 'verify', facilitatorBody); + if (!verify?.isValid) { + const reason = `payment verification failed: ${verify?.invalidReason ?? 'unknown'}`; + await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); + return c.json({ + x402Version: X402_VERSION, + error: reason, + accepts: [requirements], + }, 402); + } + settle = await postJson(transport, 'settle', facilitatorBody); + } catch (error) { + await lifecycle.onUnresolved?.({ + idempotencyKey, + settlementReference, + payer, + reason: `facilitator response unresolved: ${error.message}`, + }); + return c.json({ error: 'payment settlement unresolved', settlementReference }, 503); + } + facilitatorMs = performance.now() - started; + } + if (!settle?.success) { + const reason = `payment settlement failed: ${settle?.errorReason ?? 'unknown'}`; + await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); + return c.json({ + x402Version: X402_VERSION, + error: reason, + accepts: [requirements], + }, 402); + } + if (priorDecision?.kind !== 'settled') { + await lifecycle.onSettled?.({ + idempotencyKey, + settlementReference, + txHash: settle.transaction, + payer: String(settle.payer ?? payer).toLowerCase(), + amountAtomic: requirements.maxAmountRequired, + requirements: structuredClone(requirements), + }); } - consumed.add(settle.transaction); - // -- step 4: run the resource with the receipt in scope ----------------- c.set('x402', { + idempotencyKey, + settlementReference, txHash: settle.transaction, - payer: settle.payer ?? paymentPayload?.payload?.authorization?.from, - amountUsdc: atomicToUsdc(requirements.maxAmountRequired), + payer: String(settle.payer ?? payer).toLowerCase(), + amountAtomic: requirements.maxAmountRequired, requirements, }); await next(); - - // Buyer-visible settlement receipt (standard x402 response header) plus a - // spike-only timing header so the buyer can attribute verify+settle cost. - c.res.headers.set( - 'X-PAYMENT-RESPONSE', - jsonToB64({ success: true, transaction: settle.transaction, network: NETWORK, payer: settle.payer }), - ); + c.res.headers.set('X-PAYMENT-RESPONSE', jsonToB64({ + success: true, + transaction: settle.transaction, + network: NETWORK, + payer: settle.payer ?? payer, + settlementReference, + })); c.res.headers.set('X-402-FACILITATOR-MS', facilitatorMs.toFixed(1)); }; } -async function postJson(url, body) { - const res = await fetch(url, { +async function postJson(transport, operation, body) { + if (!['verify', 'settle'].includes(operation)) throw new Error('invalid facilitator operation'); + const response = await transport.fetchImpl(`${transport.baseUrl}/${operation}`, { method: 'POST', + redirect: 'error', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); - return res.json().catch(() => null); + if (!response.ok) throw new Error(`facilitator HTTP ${response.status}`); + const json = await response.json().catch(() => null); + if (!json) throw new Error('facilitator returned no JSON result'); + return json; } diff --git a/spikes/pi-wielder/tests/atomic-boundary.test.mjs b/spikes/pi-wielder/tests/atomic-boundary.test.mjs new file mode 100644 index 0000000..a8d76e9 --- /dev/null +++ b/spikes/pi-wielder/tests/atomic-boundary.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { atomicToUsdc, usdcToAtomic } from '../src/x402-seller.mjs'; + +test('x402 monetary conversion accepts decimal strings and returns canonical strings only', () => { + assert.equal(usdcToAtomic('0.25'), '250000'); + assert.equal(usdcToAtomic('0.000001'), '1'); + assert.equal(atomicToUsdc('250000'), '0.250000'); + assert.throws(() => usdcToAtomic(0.25), /decimal string|must be a string/); + assert.throws(() => usdcToAtomic('0.0000001'), /six fractional digits/); +}); diff --git a/spikes/pi-wielder/tests/gateway-transport.test.mjs b/spikes/pi-wielder/tests/gateway-transport.test.mjs new file mode 100644 index 0000000..c13ddfd --- /dev/null +++ b/spikes/pi-wielder/tests/gateway-transport.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createMockFacilitator } from '../src/facilitator-mock.mjs'; +import { createGateway, MODEL_PRICES_USDC } from '../src/gateway.mjs'; +import { payingFetch } from '../src/proxy.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; +import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; + +test('gateway prices are decimal strings and the injected transport stays in process', async () => { + assert.ok(Object.values(MODEL_PRICES_USDC).every((price) => typeof price === 'string')); + const facilitator = createMockFacilitator(); + const facilitatorTransport = createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); + const gateway = createGateway({ facilitatorTransport, mockLlm: true }); + const paid = await payingFetch(throwawayAccount(), 'http://gateway.test/v1/chat/completions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] }), + }, { + fetchImpl: (url, init) => gateway.request(url, init), + idempotencyKey: 'idem-gateway', + }); + assert.equal(paid.res.status, 200); + assert.equal(paid.amountAtomic, '41000'); + assert.equal(paid.amountDisplay, '0.041000'); +}); + +test('gateway rejects an unapproved structural transport or legacy facilitator URL', () => { + assert.throws(() => createGateway({ + facilitatorTransport: { mode: 'mock', baseUrl: 'http://facilitator.invalid', fetchImpl: fetch }, + mockLlm: true, + }), /approved live or injected-mock/); + assert.throws(() => createGateway({ facilitatorUrl: 'https://evil.test', mockLlm: true }), /facilitatorTransport/); +}); diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs new file mode 100644 index 0000000..c24b2df --- /dev/null +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -0,0 +1,287 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { Hono } from 'hono'; +import { createMockFacilitator } from '../src/facilitator-mock.mjs'; +import { payingFetch } from '../src/proxy.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; +import { + APPROVED_LIVE_FACILITATOR_BASE, + createLiveFacilitatorTransport, + createMockFacilitatorTransport, + x402Paywall, +} from '../src/x402-seller.mjs'; + +const payTo = `0x${'d'.repeat(40)}`; + +function resourceApp({ facilitatorTransport, lifecycle = {}, price = '0.25', handler } = {}) { + const app = new Hono(); + app.post('/resource', x402Paywall({ + price, + payTo, + facilitatorTransport, + lifecycle, + }), handler ?? ((c) => c.json({ ok: true }))); + return app; +} + +test('challenge and retry emit one ordered lifecycle under one idempotency key', async () => { + const facilitator = createMockFacilitator(); + const transport = createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); + const calls = []; + const lifecycle = Object.fromEntries([ + 'onOffered', 'onSigned', 'onSettled', 'onUnresolved', 'onRejected', + ].map((name) => [name, async (payload) => calls.push([name, payload])])); + const app = resourceApp({ facilitatorTransport: transport, lifecycle }); + const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + fetchImpl: (url, init) => app.request(url, init), + idempotencyKey: 'idem-lifecycle', + }); + assert.equal(result.res.status, 200); + assert.deepEqual(calls.map(([name]) => name), ['onOffered', 'onSigned', 'onSettled']); + assert.ok(calls.every(([, payload]) => payload.idempotencyKey === 'idem-lifecycle')); + assert.deepEqual(calls[0][1].requirements, calls[1][1].requirements); + assert.match(calls[0][1].requirements.extra.requestHash, /^sha256:[0-9a-f]{64}$/); + assert.equal(calls[2][1].settlementReference, result.settlementReference); + assert.equal(calls[2][1].txHash, result.txHash); + assert.equal(result.amountAtomic, '250000'); + assert.equal(result.amountDisplay, '0.250000'); +}); + +test('a restarted paywall accepts only the complete persisted frozen offer', async () => { + const facilitator = createMockFacilitator(); + const transport = createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); + let persistedRequirements = null; + let fetchCount = 0; + const beforeRestart = resourceApp({ + facilitatorTransport: transport, + lifecycle: { + async onOffered({ requirements }) { persistedRequirements = structuredClone(requirements); }, + }, + handler: (c) => c.json({ shouldNotExecute: true }), + }); + const afterRestart = resourceApp({ + facilitatorTransport: transport, + price: '9.99', + lifecycle: { + async loadFrozenOffer() { return structuredClone(persistedRequirements); }, + }, + }); + const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{"input":"same bytes"}', + }, { + idempotencyKey: 'idem-restart', + fetchImpl: (url, init) => (++fetchCount === 1 ? beforeRestart : afterRestart).request(url, init), + }); + assert.equal(result.res.status, 200); + assert.equal(fetchCount, 2); + assert.equal(persistedRequirements.maxAmountRequired, '250000'); + assert.equal(persistedRequirements.payTo, payTo); +}); + +test('restart rejects different request bytes under the frozen idempotency key before facilitator or execution', async () => { + let persistedRequirements = null; + let paidHeaders = null; + let facilitatorCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async () => { + facilitatorCalls += 1; + throw new Error('must not run'); + }); + const beforeRestart = resourceApp({ + facilitatorTransport: transport, + lifecycle: { + async onOffered({ requirements }) { persistedRequirements = structuredClone(requirements); }, + }, + }); + let fetchCount = 0; + const first = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{"input":"original bytes"}', + }, { + idempotencyKey: 'idem-restart-conflict', + fetchImpl: async (url, init) => { + fetchCount += 1; + if (fetchCount === 1) return beforeRestart.request(url, init); + paidHeaders = init.headers; + return new Response(JSON.stringify({ injected: 'process stopped before retry' }), { + status: 503, headers: { 'content-type': 'application/json' }, + }); + }, + }); + assert.equal(first.res.status, 503); + const afterRestart = resourceApp({ + facilitatorTransport: transport, + lifecycle: { + async loadFrozenOffer() { return structuredClone(persistedRequirements); }, + }, + handler: (c) => { executions += 1; return c.json({ ok: true }); }, + }); + const conflict = await afterRestart.request('http://seller.test/resource', { + method: 'POST', + headers: paidHeaders, + body: '{"input":"different bytes"}', + }); + assert.equal(conflict.status, 409); + assert.match((await conflict.json()).error, /different request/); + assert.equal(facilitatorCalls, 0); + assert.equal(executions, 0); +}); + +test('missing idempotency and paid retry without a frozen offer fail before facilitator calls', async () => { + let facilitatorCalls = 0; + const transport = createMockFacilitatorTransport(async () => { + facilitatorCalls += 1; + throw new Error('must not run'); + }); + const app = resourceApp({ facilitatorTransport: transport }); + const missing = await app.request('http://seller.test/resource', { method: 'POST', body: '{}' }); + assert.equal(missing.status, 400); + assert.match((await missing.json()).error, /Idempotency-Key/); + const orphan = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': 'idem-orphan', 'X-PAYMENT': Buffer.from('{}').toString('base64') }, + body: '{}', + }); + assert.equal(orphan.status, 409); + assert.equal(facilitatorCalls, 0); +}); + +test('authorization amount must equal the frozen quote exactly before facilitator submission', async () => { + let facilitatorCalls = 0; + const facilitator = createMockFacilitator(); + const transport = createMockFacilitatorTransport(async (url, init) => { + facilitatorCalls += 1; + return facilitator.request(url, init); + }); + const app = resourceApp({ facilitatorTransport: transport }); + let requestCount = 0; + const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'idem-overpay', + fetchImpl: (url, init) => { + requestCount += 1; + if (requestCount === 2) { + const payment = JSON.parse(Buffer.from(init.headers['X-PAYMENT'], 'base64').toString('utf8')); + payment.payload.authorization.value = '250001'; + return app.request(url, { + ...init, + headers: { ...init.headers, 'X-PAYMENT': Buffer.from(JSON.stringify(payment)).toString('base64') }, + }); + } + return app.request(url, init); + }, + }); + assert.equal(result.res.status, 402); + assert.match((await result.res.json()).error, /exactly match/); + assert.equal(facilitatorCalls, 0); +}); + +test('unresolved payment retries return 503 without re-verification or settlement', async () => { + let facilitatorCalls = 0; + const transport = createMockFacilitatorTransport(async () => { + facilitatorCalls += 1; + throw new Error('must not run'); + }); + const app = resourceApp({ + facilitatorTransport: transport, + lifecycle: { + async onSigned() { return { kind: 'payment_unresolved', settlementReference: `0x${'1'.repeat(64)}` }; }, + }, + }); + const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'idem-unresolved', + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(result.res.status, 503); + assert.match((await result.res.json()).error, /settlement unresolved/); + assert.equal(facilitatorCalls, 0); +}); + +test('terminal replay requires settled or refunded payment with a transaction and preserves HTTP status', async () => { + let calls = 0; + const transport = createMockFacilitatorTransport(async () => { + calls += 1; + throw new Error('must not run'); + }); + const account = throwawayAccount(); + for (const [decision, expectedStatus] of [[{ + kind: 'terminal', paymentState: 'rejected', txHash: null, payer: account.address, + httpStatus: 500, receipt: { receipt: { execution: { message: 'failed' } } }, + }, 503], [{ + kind: 'terminal', paymentState: 'settled', txHash: `0x${'4'.repeat(64)}`, + payer: account.address.toLowerCase(), httpStatus: 500, + receipt: { receipt: { execution: { message: 'provider failed' } } }, + }, 500]]) { + const app = resourceApp({ + facilitatorTransport: transport, + lifecycle: { async onSigned() { return decision; } }, + }); + const result = await payingFetch(account, 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: `idem-terminal-${expectedStatus}`, + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(result.res.status, expectedStatus); + const body = await result.res.json(); + if (expectedStatus === 500) { + assert.equal(body.replayed, true); + assert.equal(body.error, 'provider failed'); + assert.equal(result.txHash, decision.txHash); + } + } + assert.equal(calls, 0); +}); + +test('live facilitator configuration pins one exact HTTPS base before authorization exists', () => { + let networkCalls = 0; + for (const malicious of [ + 'http://x402.org/facilitator', + 'https://user:pass@x402.org/facilitator', + 'https://x402.org:8443/facilitator', + 'https://x402.org/facilitator/', + 'https://x402.org/facilitator/verify', + 'https://x402.org/facilitator?next=https://evil.test', + 'https://x402.org/facilitator#evil', + ]) { + assert.throws(() => createLiveFacilitatorTransport(malicious, async () => { networkCalls += 1; }), + (error) => error.code === 'FACILITATOR_NOT_APPROVED'); + } + assert.equal(networkCalls, 0); + assert.doesNotThrow(() => createLiveFacilitatorTransport( + APPROVED_LIVE_FACILITATOR_BASE, + async () => { networkCalls += 1; }, + )); +}); + +test('verify and settle disable redirects and never follow a signed authorization', async () => { + for (const redirectOperation of ['verify', 'settle']) { + const destinations = []; + const transport = createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname.slice(1); + destinations.push([url, init.redirect]); + if (operation === redirectOperation) { + return new Response(null, { status: 302, headers: { location: 'https://evil.test/collect' } }); + } + return new Response(JSON.stringify({ isValid: true }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + }); + const app = resourceApp({ facilitatorTransport: transport }); + const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + fetchImpl: (url, init) => app.request(url, init), + idempotencyKey: `idem-redirect-${redirectOperation}`, + }); + assert.equal(result.res.status, 503); + assert.equal(destinations.at(-1)[0], `http://facilitator.invalid/${redirectOperation}`); + assert.ok(destinations.every(([, redirect]) => redirect === 'error')); + assert.ok(destinations.every(([url]) => !url.startsWith('https://evil.test'))); + } +}); From c1247a22646c0aacd6d89ae90abacce0c1653425 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:14:36 -0400 Subject: [PATCH 069/165] fix: preserve unresolved x402 settlements --- spikes/pi-wielder/src/x402-seller.mjs | 61 +++++++++++++--- .../pi-wielder/tests/x402-lifecycle.test.mjs | 73 +++++++++++++++++++ 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index cdaf179..3246518 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -114,6 +114,7 @@ export function x402Paywall({ const transport = requireFacilitatorTransport(facilitatorTransport); const canonicalPayTo = canonicalAddress(payTo); const frozenOffers = new Map(); + const locallyUnresolvedSettlements = new Set(); return async (c, next) => { const idempotencyKey = c.req.header('Idempotency-Key')?.trim(); @@ -222,6 +223,14 @@ export function x402Paywall({ return c.json({ error: error.message }, 409); } + if (locallyUnresolvedSettlements.has(idempotencyKey) + && !['terminal', 'settled'].includes(priorDecision?.kind)) { + return c.json({ + error: 'payment settlement unresolved; trusted reconciliation is required', + settlementReference, + }, 503); + } + if (priorDecision?.kind === 'terminal') { if (!terminalReplayIsTrusted(priorDecision, payer)) { return c.json({ error: 'terminal replay lacks a settled or refunded transaction' }, 503); @@ -289,6 +298,7 @@ export function x402Paywall({ } settle = await postJson(transport, 'settle', facilitatorBody); } catch (error) { + locallyUnresolvedSettlements.add(idempotencyKey); await lifecycle.onUnresolved?.({ idempotencyKey, settlementReference, @@ -308,31 +318,62 @@ export function x402Paywall({ accepts: [requirements], }, 402); } - if (priorDecision?.kind !== 'settled') { - await lifecycle.onSettled?.({ + const settledPayer = String(settle.payer ?? '').toLowerCase(); + const settledTxHash = String(settle.transaction ?? '').toLowerCase(); + if (!validTxHash(settledTxHash) + || settledPayer !== payer + || settle.network !== NETWORK) { + locallyUnresolvedSettlements.add(idempotencyKey); + await lifecycle.onUnresolved?.({ idempotencyKey, settlementReference, - txHash: settle.transaction, - payer: String(settle.payer ?? payer).toLowerCase(), - amountAtomic: requirements.maxAmountRequired, - requirements: structuredClone(requirements), + payer, + reason: 'facilitator returned malformed settlement evidence', }); + return c.json({ + error: 'payment settlement unresolved: facilitator evidence is invalid', + settlementReference, + }, 503); + } + if (priorDecision?.kind !== 'settled') { + try { + await lifecycle.onSettled?.({ + idempotencyKey, + settlementReference, + txHash: settledTxHash, + payer: settledPayer, + amountAtomic: requirements.maxAmountRequired, + requirements: structuredClone(requirements), + }); + } catch (error) { + locallyUnresolvedSettlements.add(idempotencyKey); + await lifecycle.onUnresolved?.({ + idempotencyKey, + settlementReference, + payer, + reason: `settlement confirmed but journal persistence unresolved: ${error.message}`, + }); + return c.json({ + error: 'payment settlement unresolved: authoritative persistence requires reconciliation', + settlementReference, + }, 503); + } } c.set('x402', { idempotencyKey, settlementReference, - txHash: settle.transaction, - payer: String(settle.payer ?? payer).toLowerCase(), + txHash: settledTxHash, + payer: settledPayer, amountAtomic: requirements.maxAmountRequired, requirements, }); await next(); c.res.headers.set('X-PAYMENT-RESPONSE', jsonToB64({ success: true, - transaction: settle.transaction, + transaction: settledTxHash, network: NETWORK, - payer: settle.payer ?? payer, + payer: settledPayer, settlementReference, })); c.res.headers.set('X-402-FACILITATOR-MS', facilitatorMs.toFixed(1)); diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs index c24b2df..2fd124b 100644 --- a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -285,3 +285,76 @@ test('verify and settle disable redirects and never follow a signed authorizatio assert.ok(destinations.every(([url]) => !url.startsWith('https://evil.test'))); } }); + +test('post-settle journal failure becomes durable unresolved and exact retry never settles twice', async () => { + const facilitator = createMockFacilitator(); + let verifyCalls = 0; + let settleCalls = 0; + let unresolved = false; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname.slice(1); + if (operation === 'verify') verifyCalls += 1; + if (operation === 'settle') settleCalls += 1; + return facilitator.request(url, init); + }); + const app = resourceApp({ + facilitatorTransport: transport, + lifecycle: { + async onSigned() { return unresolved ? { kind: 'payment_unresolved' } : null; }, + async onSettled() { throw new Error('injected journal append failure after settlement'); }, + async onUnresolved() { unresolved = true; }, + }, + handler: (c) => { executions += 1; return c.json({ ok: true }); }, + }); + const first = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'idem-post-settle-gap', + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(first.res.status, 503); + assert.equal(unresolved, true); + const retry = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': first.idempotencyKey, 'X-PAYMENT': first.xPayment }, + body: '{}', + }); + assert.equal(retry.status, 503); + assert.equal(verifyCalls, 1); + assert.equal(settleCalls, 1); + assert.equal(executions, 0); +}); + +test('malformed facilitator success evidence is unresolved and never authorizes execution', async () => { + let unresolvedCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url) => { + const operation = new URL(url).pathname.slice(1); + if (operation === 'verify') { + return new Response(JSON.stringify({ isValid: true }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ + success: true, + transaction: 'not-a-transaction', + payer: `0x${'9'.repeat(40)}`, + network: 'base-mainnet', + }), { status: 200, headers: { 'content-type': 'application/json' } }); + }); + const app = resourceApp({ + facilitatorTransport: transport, + lifecycle: { async onUnresolved() { unresolvedCalls += 1; } }, + handler: (c) => { executions += 1; return c.json({ ok: true }); }, + }); + const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'idem-malformed-settlement', + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(result.res.status, 503); + assert.equal(unresolvedCalls, 1); + assert.equal(executions, 0); +}); From 9d920e03566141667fbfdb0584d3498fc51c97af Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:15:52 -0400 Subject: [PATCH 070/165] feat: verify repository-control attestations offline --- .gitignore | 2 + phase0/src/attestation-config.ts | 218 ++++++++++++++++++ phase0/src/attestation-git.ts | 316 +++++++++++++++++++++++++++ phase0/tests/attestation-git.test.ts | 199 +++++++++++++++++ 4 files changed, 735 insertions(+) create mode 100644 phase0/src/attestation-config.ts create mode 100644 phase0/src/attestation-git.ts create mode 100644 phase0/tests/attestation-git.test.ts diff --git a/.gitignore b/.gitignore index 4989f5f..3f50c13 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ out/ # Run artifacts (belt-and-braces; spikes also ignore locally) runs/ *.jsonl +!phase0/attestations.jsonl +phase0/.attestation-checkouts.local.json spikes/pi-wielder/**/*.lock spikes/pi-wielder/**/*.claim spikes/pi-wielder/**/*.pem diff --git a/phase0/src/attestation-config.ts b/phase0/src/attestation-config.ts new file mode 100644 index 0000000..d8ab478 --- /dev/null +++ b/phase0/src/attestation-config.ts @@ -0,0 +1,218 @@ +import { lstat, readFile, realpath, stat } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; + +import { normalizeRepositoryUrl } from "./attestations"; +import type { TrustedRepository, TrustedRepositoryResolver } from "./attestation-git"; + +export interface LocalCheckoutMapV1 { + schemaVersion: 1; + checkouts: Record; +} + +interface FileMetadata { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + mode: number; + uid: number; +} + +export interface AttestationConfigFileSystem { + lstat(path: string): Promise; + stat(path: string): Promise; + realpath(path: string): Promise; + readFile(path: string): Promise; + currentUid(): number; +} + +const NODE_FS: AttestationConfigFileSystem = { + lstat, + stat, + realpath, + readFile: (path) => readFile(path, "utf8"), + currentUid: () => { + const uid = process.getuid?.(); + if (uid === undefined) throw new Error("repository snapshot mapping requires an operating-system owner identity"); + return uid; + }, +}; + +const KEY = /^[a-z0-9][a-z0-9._-]{0,127}$/; +const REF = /^refs\/(?:heads|remotes)\/[A-Za-z0-9._\/-]+$/; + +function object(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${label} has unexpected or missing fields`); + } +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const item of Object.values(value as Record)) deepFreeze(item); + return value; +} + +function inside(path: string, root: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +export async function loadLocalCheckoutMap(input: { + env: Readonly>; + phase0Root: string; + referencedCheckoutKeys: readonly string[]; + fs?: AttestationConfigFileSystem; +}): Promise>> { + const fs = input.fs ?? NODE_FS; + if (!isAbsolute(input.phase0Root)) throw new Error("phase0Root must be an absolute canonical path"); + let canonicalRoot: string; + try { canonicalRoot = await fs.realpath(input.phase0Root); } catch (error) { + throw new Error("phase0Root must exist before repository verification", { cause: error }); + } + if (resolve(input.phase0Root) !== canonicalRoot) throw new Error("phase0Root must be an absolute canonical path"); + const defaultPath = resolve(canonicalRoot, ".attestation-checkouts.local.json"); + const override = input.env.PHASE0_ATTESTATION_CHECKOUTS_FILE; + if (override !== undefined && override.length === 0) throw new Error("PHASE0_ATTESTATION_CHECKOUTS_FILE must not be blank"); + if (override !== undefined && !isAbsolute(override)) throw new Error("PHASE0_ATTESTATION_CHECKOUTS_FILE must be an absolute path"); + const configPath = resolve(override ?? defaultPath); + if (inside(configPath, canonicalRoot) && configPath !== defaultPath) { + throw new Error("an in-repository checkout mapping override must equal the exact ignored default path"); + } + + let metadata: FileMetadata; + try { metadata = await fs.lstat(configPath); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`repository snapshot mapping unavailable: ${configPath}`, { cause: error }); + } + throw error; + } + if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error("repository snapshot mapping must be a non-symlink regular file"); + if ((metadata.mode & 0o777) !== 0o600) throw new Error("repository snapshot mapping must have mode 0600"); + if (metadata.uid !== fs.currentUid()) throw new Error("repository snapshot mapping must be owned by the current user"); + + let parsed: unknown; + try { parsed = JSON.parse(await fs.readFile(configPath)); } catch (error) { + throw new Error("repository snapshot mapping contains malformed JSON", { cause: error }); + } + const config = object(parsed, "repository snapshot mapping"); + exactKeys(config, ["schemaVersion", "checkouts"], "repository snapshot mapping"); + if (config.schemaVersion !== 1) throw new Error("repository snapshot mapping schemaVersion must be 1"); + const checkouts = object(config.checkouts, "repository snapshot mapping checkouts"); + const expected = [...input.referencedCheckoutKeys]; + if (new Set(expected).size !== expected.length || expected.some((key) => !KEY.test(key))) throw new Error("tracked repository trust contains invalid checkout keys"); + const actual = Object.keys(checkouts); + if (actual.some((key) => !KEY.test(key))) throw new Error("repository snapshot mapping contains an invalid checkout key"); + if (actual.length !== expected.length || [...actual].sort().some((key, index) => key !== [...expected].sort()[index])) { + throw new Error("repository snapshot mapping keys must exactly match tracked repository trust"); + } + + const result: Record = {}; + for (const key of actual) { + const checkoutPath = checkouts[key]; + if (typeof checkoutPath !== "string" || !isAbsolute(checkoutPath) || resolve(checkoutPath) !== checkoutPath) { + throw new Error(`checkout ${key} must use a canonical absolute path`); + } + let canonical: string; + try { canonical = await fs.realpath(checkoutPath); } catch (error) { + throw new Error(`checkout ${key} does not exist`, { cause: error }); + } + if (canonical !== checkoutPath) throw new Error(`checkout ${key} must use its real canonical path`); + const checkoutMetadata = await fs.stat(checkoutPath); + if (!checkoutMetadata.isDirectory()) throw new Error(`checkout ${key} must be a directory`); + if (checkoutMetadata.uid !== fs.currentUid()) throw new Error(`checkout ${key} must be owned by the current user`); + if ((checkoutMetadata.mode & 0o022) !== 0) throw new Error(`checkout ${key} must not be group- or world-writable`); + result[key] = checkoutPath; + } + return deepFreeze(result); +} + +export interface RepositoryTrustEntryV1 { + repositoryId: string; + repositoryUrl: string; + checkoutKey: string; + trustedRef: `refs/heads/${string}` | `refs/remotes/${string}`; + permittedForgeSignerIds: string[]; +} + +export interface RepositoryTrustConfigV1 { + schemaVersion: 1; + repositories: RepositoryTrustEntryV1[]; +} + +export function referencedCheckoutKeys(trustConfig: unknown): string[] { + return parseRepositoryTrustConfig(trustConfig).repositories.map((entry) => entry.checkoutKey).sort(); +} + +export function parseRepositoryTrustConfig(value: unknown): RepositoryTrustConfigV1 { + const config = object(value, "repository trust config"); + exactKeys(config, ["schemaVersion", "repositories"], "repository trust config"); + if (config.schemaVersion !== 1 || !Array.isArray(config.repositories)) throw new Error("repository trust config schemaVersion/repositories are invalid"); + const seenRepositories = new Set(); + const seenCheckoutKeys = new Set(); + const repositories = config.repositories.map((entryValue, index) => { + const entry = object(entryValue, `repository trust entry ${index}`); + exactKeys(entry, ["repositoryId", "repositoryUrl", "checkoutKey", "trustedRef", "permittedForgeSignerIds"], `repository trust entry ${index}`); + if (typeof entry.repositoryId !== "string" || !KEY.test(entry.repositoryId)) throw new Error("repositoryId is invalid"); + if (seenRepositories.has(entry.repositoryId)) throw new Error("repositoryId must be unique"); + seenRepositories.add(entry.repositoryId); + if (typeof entry.repositoryUrl !== "string") throw new Error("repositoryUrl must be a normalized HTTPS URL"); + normalizeRepositoryUrl(entry.repositoryUrl); + if (typeof entry.checkoutKey !== "string" || !KEY.test(entry.checkoutKey)) throw new Error("checkoutKey is invalid"); + if (seenCheckoutKeys.has(entry.checkoutKey)) throw new Error("checkoutKey must be unique"); + seenCheckoutKeys.add(entry.checkoutKey); + if (typeof entry.trustedRef !== "string" || !REF.test(entry.trustedRef) || entry.trustedRef.includes("..")) throw new Error("trustedRef is invalid"); + if (!Array.isArray(entry.permittedForgeSignerIds) || entry.permittedForgeSignerIds.length === 0) throw new Error("permittedForgeSignerIds must be a nonempty array"); + const permitted = entry.permittedForgeSignerIds.map((id) => { + if (typeof id !== "string" || !KEY.test(id)) throw new Error("forge signer ID is invalid"); + return id; + }); + if (new Set(permitted).size !== permitted.length) throw new Error("forge signer IDs must be unique"); + return { + repositoryId: entry.repositoryId, + repositoryUrl: entry.repositoryUrl, + checkoutKey: entry.checkoutKey, + trustedRef: entry.trustedRef as RepositoryTrustEntryV1["trustedRef"], + permittedForgeSignerIds: permitted, + }; + }); + return deepFreeze({ schemaVersion: 1, repositories }); +} + +export function createTrustedRepositoryResolver(input: { + trustConfig: unknown; + checkoutPaths: Readonly>; +}): TrustedRepositoryResolver { + const config = parseRepositoryTrustConfig(input.trustConfig); + const configuredKeys = config.repositories.map((entry) => entry.checkoutKey).sort(); + const suppliedKeys = Object.keys(input.checkoutPaths).sort(); + if (configuredKeys.length !== suppliedKeys.length || configuredKeys.some((key, index) => key !== suppliedKeys[index])) { + throw new Error("trusted repository checkout paths do not exactly match tracked trust configuration"); + } + const repositories = new Map(config.repositories.map((entry) => { + const repository: TrustedRepository = deepFreeze({ + repositoryId: entry.repositoryId, + repositoryUrl: entry.repositoryUrl, + repositoryPath: input.checkoutPaths[entry.checkoutKey], + trustedRef: entry.trustedRef, + permittedForgeSignerIds: [...entry.permittedForgeSignerIds], + }); + return [entry.repositoryId, repository]; + })); + return Object.freeze({ + resolve(repositoryId: string, normalizedRepositoryUrl: string): TrustedRepository { + const repository = repositories.get(repositoryId); + if (!repository || repository.repositoryUrl !== normalizedRepositoryUrl) { + throw new Error("repository is not present in verifier-provisioned trust configuration"); + } + return repository; + }, + }); +} diff --git a/phase0/src/attestation-git.ts b/phase0/src/attestation-git.ts new file mode 100644 index 0000000..d26201c --- /dev/null +++ b/phase0/src/attestation-git.ts @@ -0,0 +1,316 @@ +import { createHash, verify as verifySignature } from "node:crypto"; +import { execFile } from "node:child_process"; + +import { + canonicalRepositoryStatement, + normalizeRepositoryUrl, + parseForgeObservation, + parseRepositoryChallenge, + repositoryStatementHash, + verifyRepositoryEventSignature, + type ForgeObservationV1, + type RepositoryControlChallengeV1, + type RepositoryControlEvent, +} from "./attestations"; + +export interface GitReader { + commitExists(repositoryPath: string, commitSha: string): Promise; + readBlob(repositoryPath: string, commitSha: string, relativePath: string): Promise; + isAncestor(repositoryPath: string, ancestor: string, descendant: string): Promise; + remoteUrl(repositoryPath: string, remoteName: string): Promise; +} + +export interface TrustedRepository { + repositoryId: string; + repositoryUrl: string; + repositoryPath: string; + trustedRef: `refs/heads/${string}` | `refs/remotes/${string}`; + permittedForgeSignerIds: readonly string[]; +} + +export interface TrustedRepositoryResolver { + resolve(repositoryId: string, normalizedRepositoryUrl: string): TrustedRepository; +} + +export interface SignedRepositoryChallengeFileV1 { + challenge: RepositoryControlChallengeV1; + statementHash: `0x${string}`; + signature: `0x${string}`; +} + +function runGit(args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + execFile("git", [...args], { + encoding: "buffer", + maxBuffer: 16 * 1024 * 1024, + windowsHide: true, + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }, + }, (error, stdout, stderr) => { + if (error) { + const detail = Buffer.from(stderr).toString("utf8").trim(); + reject(new Error(`offline Git verification failed${detail ? `: ${detail}` : ""}`, { cause: error })); + return; + } + resolve(new Uint8Array(stdout)); + }); + }); +} + +function validRepositoryPath(value: string): void { + if (!value || !value.startsWith("/") || value.includes("\0")) { + throw new Error("trusted repository path must be an absolute path"); + } +} + +function validObjectName(value: string, label: string): void { + if (!/^(?:[0-9a-f]{40,64}|refs\/(?:heads|remotes)\/[A-Za-z0-9._\/-]+)$/.test(value) || value.includes("..")) { + throw new Error(`${label} is not a full commit OID or configured trusted ref`); + } +} + +function validRelativePath(value: string): void { + if (!value || value.startsWith("/") || value.includes("\\") || value.includes("\0")) { + throw new Error("Git blob path must be a normalized relative POSIX path"); + } + if (value.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) { + throw new Error("Git blob path must be a normalized relative POSIX path"); + } +} + +export class ExecGitReader implements GitReader { + async commitExists(repositoryPath: string, commitSha: string): Promise { + validRepositoryPath(repositoryPath); + validObjectName(commitSha, "Git commit"); + try { + await runGit(["-C", repositoryPath, "cat-file", "-e", `${commitSha}^{commit}`]); + return true; + } catch (error) { + const cause = (error as Error & { cause?: { code?: string | number } }).cause; + if (cause && typeof cause.code === "number") return false; + throw error; + } + } + + async readBlob(repositoryPath: string, commitSha: string, relativePath: string): Promise { + validRepositoryPath(repositoryPath); + validObjectName(commitSha, "Git commit"); + validRelativePath(relativePath); + return runGit(["-C", repositoryPath, "show", `${commitSha}:${relativePath}`]); + } + + async isAncestor(repositoryPath: string, ancestor: string, descendant: string): Promise { + validRepositoryPath(repositoryPath); + validObjectName(ancestor, "Git ancestor"); + validObjectName(descendant, "Git descendant"); + try { + await runGit(["-C", repositoryPath, "merge-base", "--is-ancestor", ancestor, descendant]); + return true; + } catch (error) { + const cause = (error as Error & { cause?: { code?: string | number } }).cause; + if (cause?.code === 1) return false; + throw error; + } + } + + async remoteUrl(repositoryPath: string, remoteName: string): Promise { + validRepositoryPath(repositoryPath); + if (remoteName !== "origin") throw new Error("only the configured origin remote may be verified"); + const bytes = await runGit(["-C", repositoryPath, "remote", "get-url", remoteName]); + return Buffer.from(bytes).toString("utf8").trim(); + } +} + +export function canonicalChallengeFileBytes(input: SignedRepositoryChallengeFileV1): Uint8Array { + const challenge = parseRepositoryChallenge(input.challenge); + if (!/^0x[0-9a-f]{64}$/.test(input.statementHash)) throw new Error("challenge file statementHash must be lowercase"); + if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(input.signature)) throw new Error("challenge file signature is malformed"); + const canonical = { + challenge: { + schemaVersion: challenge.schemaVersion, + subject: { + registrationId: challenge.subject.registrationId, + ipId: challenge.subject.ipId, + wallet: challenge.subject.wallet, + artifactHash: challenge.subject.artifactHash, + declaredParentIpIds: [...challenge.subject.declaredParentIpIds], + }, + repositoryUrl: challenge.repositoryUrl, + artifactCommitSha: challenge.artifactCommitSha, + artifactPath: challenge.artifactPath, + challengePath: challenge.challengePath, + nonce: challenge.nonce, + issuedAt: challenge.issuedAt, + expiresAt: challenge.expiresAt, + }, + statementHash: input.statementHash, + signature: input.signature, + }; + return Buffer.from(`${JSON.stringify(canonical)}\n`, "utf8"); +} + +export function parseSignedRepositoryChallengeFile(bytes: Uint8Array): SignedRepositoryChallengeFileV1 { + const text = Buffer.from(bytes).toString("utf8"); + if (!text.endsWith("\n") || text.endsWith("\n\n") || text.slice(0, -1).includes("\n")) { + throw new Error("repository challenge file must be canonical single-line JSON with exactly one trailing newline"); + } + let value: unknown; + try { value = JSON.parse(text); } catch (error) { throw new Error("repository challenge file contains malformed JSON", { cause: error }); } + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("repository challenge file must be an object"); + const object = value as Record; + const keys = Object.keys(object); + if (keys.length !== 3 || keys[0] !== "challenge" || keys[1] !== "statementHash" || keys[2] !== "signature") { + throw new Error("repository challenge file fields are missing, extra, or out of canonical order"); + } + if (typeof object.statementHash !== "string" || typeof object.signature !== "string") throw new Error("repository challenge file signature fields are malformed"); + const parsed: SignedRepositoryChallengeFileV1 = { + challenge: parseRepositoryChallenge(object.challenge), + statementHash: object.statementHash as `0x${string}`, + signature: object.signature as `0x${string}`, + }; + const canonical = canonicalChallengeFileBytes(parsed); + if (!Buffer.from(canonical).equals(Buffer.from(bytes))) throw new Error("repository challenge file is not canonical byte-for-byte"); + if (parsed.statementHash !== repositoryStatementHash(parsed.challenge)) throw new Error("repository challenge statement hash mismatch"); + return parsed; +} + +export function canonicalForgeObservationBytes( + observationValue: Omit, +): Uint8Array { + const observation = parseForgeObservation({ ...observationValue, signature: "placeholder" }); + const canonical = { + schemaVersion: observation.schemaVersion, + repositoryId: observation.repositoryId, + repositoryUrl: observation.repositoryUrl, + trustedRef: observation.trustedRef, + proofCommitSha: observation.proofCommitSha, + challengeNonce: observation.challengeNonce, + observedAt: observation.observedAt, + forgeSignerId: observation.forgeSignerId, + }; + return Buffer.from(`${JSON.stringify(canonical)}\n`, "utf8"); +} + +export function verifyForgeObservation( + observationValue: ForgeObservationV1, + trusted: TrustedRepository, + forgeSigners: Readonly>, +): void { + const observation = parseForgeObservation(observationValue); + if (observation.repositoryId !== trusted.repositoryId + || observation.repositoryUrl !== trusted.repositoryUrl + || observation.trustedRef !== trusted.trustedRef) { + throw new Error("forge observation does not match verifier-provisioned repository trust"); + } + if (!trusted.permittedForgeSignerIds.includes(observation.forgeSignerId)) { + throw new Error("forge signer is not permitted for this repository"); + } + const publicKey = forgeSigners[observation.forgeSignerId]; + if (!publicKey) throw new Error("forge signer is unknown"); + if (/PRIVATE KEY/.test(publicKey)) throw new Error("forge signer configuration must contain only a public key"); + let signatureBytes: Buffer; + try { + signatureBytes = Buffer.from(observation.signature, "base64"); + } catch (error) { + throw new Error("forge observation signature must be base64", { cause: error }); + } + if (signatureBytes.length === 0 || signatureBytes.toString("base64") !== observation.signature) { + throw new Error("forge observation signature must be canonical base64"); + } + const { signature: _signature, ...unsigned } = observation; + if (!verifySignature(null, canonicalForgeObservationBytes(unsigned), publicKey, signatureBytes)) { + throw new Error("forge observation signature is invalid"); + } +} + +function sha256(bytes: Uint8Array): `0x${string}` { + return `0x${createHash("sha256").update(bytes).digest("hex")}`; +} + +async function verifyRepositorySnapshot(input: { + event: RepositoryControlEvent; + challengeFile: Uint8Array; + now: Date; + git: GitReader; + repositories: TrustedRepositoryResolver; + forgeSigners: Readonly>; +}): Promise { + const { event, now, git, repositories, forgeSigners } = input; + await verifyRepositoryEventSignature(event); + const signed = parseSignedRepositoryChallengeFile(input.challengeFile); + if (signed.statementHash !== event.statementHash || signed.signature !== event.signature + || JSON.stringify(signed.challenge) !== JSON.stringify(event.challenge)) { + throw new Error("repository event does not match the exact signed challenge file"); + } + const trusted = repositories.resolve(event.forgeObservation.repositoryId, event.challenge.repositoryUrl); + verifyForgeObservation(event.forgeObservation, trusted, forgeSigners); + if (event.forgeObservation.challengeNonce !== event.challenge.nonce) throw new Error("forge observation nonce mismatch"); + const issuedAt = Date.parse(event.challenge.issuedAt); + const expiresAt = Date.parse(event.challenge.expiresAt); + const observedAt = Date.parse(event.forgeObservation.observedAt); + const occurredAt = Date.parse(event.occurredAt); + if (observedAt < issuedAt || observedAt > expiresAt) throw new Error("repository challenge was not valid at observation time"); + if (occurredAt < observedAt) throw new Error("repository event occurred before the forge observation"); + if (occurredAt > now.getTime() || observedAt > now.getTime()) throw new Error("repository evidence is dated in the future"); + + const origin = normalizeRepositoryUrl(await git.remoteUrl(trusted.repositoryPath, "origin")); + if (origin !== trusted.repositoryUrl || origin !== event.challenge.repositoryUrl || origin !== event.forgeObservation.repositoryUrl) { + throw new Error("trusted checkout origin does not match signed repository URL"); + } + if (!await git.commitExists(trusted.repositoryPath, event.challenge.artifactCommitSha)) throw new Error("artifact commit is absent"); + if (!await git.commitExists(trusted.repositoryPath, event.forgeObservation.proofCommitSha)) throw new Error("proof commit is absent"); + if (!await git.commitExists(trusted.repositoryPath, trusted.trustedRef)) throw new Error("configured trusted ref is absent"); + if (!await git.isAncestor(trusted.repositoryPath, event.challenge.artifactCommitSha, event.forgeObservation.proofCommitSha)) { + throw new Error("proof commit does not descend from artifact commit"); + } + if (!await git.isAncestor(trusted.repositoryPath, event.forgeObservation.proofCommitSha, trusted.trustedRef)) { + throw new Error("proof commit is not reachable from the configured trusted ref"); + } + const artifact = await git.readBlob(trusted.repositoryPath, event.challenge.artifactCommitSha, event.challenge.artifactPath); + if (sha256(artifact) !== event.subject.artifactHash) throw new Error("registered artifact hash does not match exact Git bytes"); + const challengeBlob = await git.readBlob(trusted.repositoryPath, event.forgeObservation.proofCommitSha, event.challenge.challengePath); + if (!Buffer.from(challengeBlob).equals(Buffer.from(input.challengeFile))) throw new Error("committed challenge bytes do not match the signed challenge file"); +} + +export async function verifyRepositoryControl(input: { + challengeFile: Uint8Array; + forgeObservation: ForgeObservationV1; + eventId: string; + sequence: number; + occurredAt: string; + now: Date; + git: GitReader; + repositories: TrustedRepositoryResolver; + forgeSigners: Readonly>; +}): Promise { + const signed = parseSignedRepositoryChallengeFile(input.challengeFile); + const event: RepositoryControlEvent = { + type: "repository_control_verified", + eventId: input.eventId, + sequence: input.sequence, + occurredAt: input.occurredAt, + subject: signed.challenge.subject, + challenge: signed.challenge, + forgeObservation: parseForgeObservation(input.forgeObservation), + statementHash: signed.statementHash, + signature: signed.signature, + }; + await verifyRepositorySnapshot({ ...input, event }); + return event; +} + +export async function reverifyRepositoryEvent( + event: RepositoryControlEvent, + context: { + git: GitReader; + repositories: TrustedRepositoryResolver; + forgeSigners: Readonly>; + now?: Date; + }, +): Promise { + const challengeFile = canonicalChallengeFileBytes({ + challenge: event.challenge, + statementHash: event.statementHash, + signature: event.signature, + }); + await verifyRepositorySnapshot({ event, challengeFile, now: context.now ?? new Date(), ...context }); +} diff --git a/phase0/tests/attestation-git.test.ts b/phase0/tests/attestation-git.test.ts new file mode 100644 index 0000000..228e689 --- /dev/null +++ b/phase0/tests/attestation-git.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { createHash, generateKeyPairSync, sign as signBytes } from "node:crypto"; +import { chmod, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; + +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +import { + canonicalRepositoryStatement, + repositoryStatementHash, + type ForgeObservationV1, + type RegistrationSubject, +} from "../src/attestations"; +import { + createTrustedRepositoryResolver, + loadLocalCheckoutMap, +} from "../src/attestation-config"; +import { + canonicalChallengeFileBytes, + canonicalForgeObservationBytes, + ExecGitReader, + verifyRepositoryControl, + type SignedRepositoryChallengeFileV1, +} from "../src/attestation-git"; + +const REPOSITORY_URL = "https://github.com/example/offline-skill"; + +function git(repo: string, ...args: string[]): string { + return execFileSync("git", ["-C", repo, ...args], { + encoding: "utf8", + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" }, + }).trim(); +} + +async function fixture(t: test.TestContext) { + const root = await realpath(await mkdtemp(join(tmpdir(), "phase0-attestation-git-"))); + t.after(() => rm(root, { recursive: true, force: true })); + git(root, "init", "-b", "main"); + git(root, "config", "user.name", "Offline Test"); + git(root, "config", "user.email", "offline@example.invalid"); + git(root, "remote", "add", "origin", REPOSITORY_URL); + const artifact = Buffer.from("# Demo Skill\n\nOffline evidence.\n", "utf8"); + await mkdir(join(root, "skills/demo"), { recursive: true }); + await writeFile(join(root, "skills/demo/SKILL.md"), artifact); + git(root, "add", "skills/demo/SKILL.md"); + git(root, "commit", "-m", "add Skill bytes"); + const artifactCommit = git(root, "rev-parse", "HEAD"); + + const wallet = privateKeyToAccount(generatePrivateKey()); + const ipId = `0x${"a".repeat(40)}` as const; + const subject: RegistrationSubject = { + registrationId: `eip155:1315:${ipId}`, + ipId, + wallet: wallet.address.toLowerCase() as `0x${string}`, + artifactHash: `0x${createHash("sha256").update(artifact).digest("hex")}`, + declaredParentIpIds: [], + }; + const challenge = { + schemaVersion: 1 as const, + subject, + repositoryUrl: REPOSITORY_URL, + artifactCommitSha: artifactCommit, + artifactPath: "skills/demo/SKILL.md", + challengePath: "attestations/repository-control.json", + nonce: `0x${"4".repeat(64)}` as `0x${string}`, + issuedAt: "2026-07-18T10:00:00.000Z", + expiresAt: "2026-07-18T14:00:00.000Z", + }; + const signed: SignedRepositoryChallengeFileV1 = { + challenge, + statementHash: repositoryStatementHash(challenge), + signature: await wallet.signMessage({ message: canonicalRepositoryStatement(challenge) }), + }; + const challengeFile = canonicalChallengeFileBytes(signed); + await mkdir(join(root, "attestations")); + await writeFile(join(root, challenge.challengePath), challengeFile); + git(root, "add", challenge.challengePath); + git(root, "commit", "-m", "add signed repository challenge"); + const proofCommit = git(root, "rev-parse", "HEAD"); + + const forge = generateKeyPairSync("ed25519"); + const unsignedObservation = { + schemaVersion: 1 as const, + repositoryId: "demo", + repositoryUrl: REPOSITORY_URL, + trustedRef: "refs/heads/main" as const, + proofCommitSha: proofCommit, + challengeNonce: challenge.nonce, + observedAt: "2026-07-18T11:00:00.000Z", + forgeSignerId: "forge-1", + }; + const forgeObservation: ForgeObservationV1 = { + ...unsignedObservation, + signature: signBytes(null, canonicalForgeObservationBytes(unsignedObservation), forge.privateKey).toString("base64"), + }; + const trustConfig = { + schemaVersion: 1, + repositories: [{ + repositoryId: "demo", + repositoryUrl: REPOSITORY_URL, + checkoutKey: "demo-checkout", + trustedRef: "refs/heads/main", + permittedForgeSignerIds: ["forge-1"], + }], + }; + const repositories = createTrustedRepositoryResolver({ trustConfig, checkoutPaths: { "demo-checkout": root } }); + const forgeSigners = { "forge-1": forge.publicKey.export({ type: "spki", format: "pem" }).toString() }; + return { root, artifactCommit, proofCommit, subject, challenge, challengeFile, forgeObservation, repositories, forgeSigners, trustConfig }; +} + +test("repository control verifies offline against exact artifact and challenge bytes", async (t) => { + const f = await fixture(t); + const event = await verifyRepositoryControl({ + challengeFile: f.challengeFile, + forgeObservation: f.forgeObservation, + eventId: "repository-1", + sequence: 1, + occurredAt: "2026-07-18T12:00:00.000Z", + now: new Date("2026-07-18T12:30:00.000Z"), + git: new ExecGitReader(), + repositories: f.repositories, + forgeSigners: f.forgeSigners, + }); + assert.equal(event.type, "repository_control_verified"); + assert.equal(event.subject.artifactHash, f.subject.artifactHash); +}); + +test("repository verification rejects signed-binding and snapshot tampering", async (t) => { + const f = await fixture(t); + const base = { + challengeFile: f.challengeFile, + forgeObservation: f.forgeObservation, + eventId: "repository-1", + sequence: 1, + occurredAt: "2026-07-18T12:00:00.000Z", + now: new Date("2026-07-18T12:30:00.000Z"), + git: new ExecGitReader(), + repositories: f.repositories, + forgeSigners: f.forgeSigners, + }; + await assert.rejects(verifyRepositoryControl({ ...base, forgeObservation: { ...f.forgeObservation, challengeNonce: `0x${"5".repeat(64)}` } }), /signature is invalid|nonce mismatch/); + await assert.rejects(verifyRepositoryControl({ ...base, forgeObservation: { ...f.forgeObservation, proofCommitSha: f.artifactCommit } }), /signature is invalid|does not match/); + await assert.rejects(verifyRepositoryControl({ ...base, forgeSigners: {} }), /unknown/); + const altered = Buffer.from(f.challengeFile); + altered[altered.length - 2] ^= 1; + await assert.rejects(verifyRepositoryControl({ ...base, challengeFile: altered }), /malformed|canonical|statement|signature/); +}); + +test("resolver rejects claimant-selected repositories before Git runs", async (t) => { + const f = await fixture(t); + let calls = 0; + const gitReader = { + commitExists: async () => { calls += 1; return true; }, + readBlob: async () => { calls += 1; return new Uint8Array(); }, + isAncestor: async () => { calls += 1; return true; }, + remoteUrl: async () => { calls += 1; return REPOSITORY_URL; }, + }; + await assert.rejects(verifyRepositoryControl({ + challengeFile: f.challengeFile, + forgeObservation: { ...f.forgeObservation, repositoryId: "claimant-copy" }, + eventId: "repository-1", + sequence: 1, + occurredAt: "2026-07-18T12:00:00.000Z", + now: new Date("2026-07-18T12:30:00.000Z"), + git: gitReader, + repositories: f.repositories, + forgeSigners: f.forgeSigners, + }), /not present|signature/); + assert.equal(calls, 0); +}); + +test("local checkout mapping requires exact owner-only canonical configuration", async (t) => { + const f = await fixture(t); + const phase0Root = await mkdtemp(join(tmpdir(), "phase0-attestation-config-")); + t.after(() => rm(phase0Root, { recursive: true, force: true })); + const canonicalPhase0Root = await realpath(phase0Root); + const mappingPath = join(canonicalPhase0Root, ".attestation-checkouts.local.json"); + await writeFile(mappingPath, `${JSON.stringify({ schemaVersion: 1, checkouts: { "demo-checkout": f.root } })}\n`, { mode: 0o600 }); + await chmod(mappingPath, 0o600); + const loaded = await loadLocalCheckoutMap({ env: {}, phase0Root: canonicalPhase0Root, referencedCheckoutKeys: ["demo-checkout"] }); + assert.deepEqual(loaded, { "demo-checkout": f.root }); + assert.ok(Object.isFrozen(loaded)); + + await chmod(mappingPath, 0o644); + await assert.rejects(loadLocalCheckoutMap({ env: {}, phase0Root: canonicalPhase0Root, referencedCheckoutKeys: ["demo-checkout"] }), /mode 0600/); + await chmod(mappingPath, 0o600); + await assert.rejects(loadLocalCheckoutMap({ env: {}, phase0Root: canonicalPhase0Root, referencedCheckoutKeys: [] }), /exactly match/); + await assert.rejects(loadLocalCheckoutMap({ env: { PHASE0_ATTESTATION_CHECKOUTS_FILE: "relative.json" }, phase0Root: canonicalPhase0Root, referencedCheckoutKeys: ["demo-checkout"] }), /absolute path/); + await assert.rejects(loadLocalCheckoutMap({ env: { PHASE0_ATTESTATION_CHECKOUTS_FILE: join(canonicalPhase0Root, "tracked.json") }, phase0Root: canonicalPhase0Root, referencedCheckoutKeys: ["demo-checkout"] }), /exact ignored default/); +}); + +test("missing checkout mapping fails with an explicit unavailable error", async (t) => { + const phase0Root = await mkdtemp(join(tmpdir(), "phase0-attestation-missing-")); + t.after(() => rm(phase0Root, { recursive: true, force: true })); + await assert.rejects(loadLocalCheckoutMap({ env: {}, phase0Root: await realpath(phase0Root), referencedCheckoutKeys: [] }), /repository snapshot mapping unavailable/); +}); From 6eacc73bee7a7ff6267718c0d24dabe12ac043c1 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:17:03 -0400 Subject: [PATCH 071/165] fix: fail closed on ambiguous settlement evidence --- spikes/pi-wielder/src/x402-seller.mjs | 27 +++++-- .../pi-wielder/tests/x402-lifecycle.test.mjs | 72 +++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index 3246518..c836f7f 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -117,6 +117,15 @@ export function x402Paywall({ const locallyUnresolvedSettlements = new Set(); return async (c, next) => { + const notifyUnresolved = async (payload) => { + try { + await lifecycle.onUnresolved?.(payload); + } catch { + // A settlement append can win and then report a lease-release error. + // The next retry re-reads authority through onSigned; never turn this + // ambiguity into a second settlement attempt or an unstructured 500. + } + }; const idempotencyKey = c.req.header('Idempotency-Key')?.trim(); if (!idempotencyKey) return c.json({ error: 'Idempotency-Key header is required' }, 400); const paymentHeader = c.req.header('X-PAYMENT'); @@ -299,7 +308,7 @@ export function x402Paywall({ settle = await postJson(transport, 'settle', facilitatorBody); } catch (error) { locallyUnresolvedSettlements.add(idempotencyKey); - await lifecycle.onUnresolved?.({ + await notifyUnresolved({ idempotencyKey, settlementReference, payer, @@ -309,7 +318,17 @@ export function x402Paywall({ } facilitatorMs = performance.now() - started; } - if (!settle?.success) { + if (settle?.success !== true) { + if (settle?.success !== false) { + locallyUnresolvedSettlements.add(idempotencyKey); + await notifyUnresolved({ + idempotencyKey, + settlementReference, + payer, + reason: 'facilitator returned an ambiguous settlement result', + }); + return c.json({ error: 'payment settlement unresolved', settlementReference }, 503); + } const reason = `payment settlement failed: ${settle?.errorReason ?? 'unknown'}`; await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); return c.json({ @@ -324,7 +343,7 @@ export function x402Paywall({ || settledPayer !== payer || settle.network !== NETWORK) { locallyUnresolvedSettlements.add(idempotencyKey); - await lifecycle.onUnresolved?.({ + await notifyUnresolved({ idempotencyKey, settlementReference, payer, @@ -347,7 +366,7 @@ export function x402Paywall({ }); } catch (error) { locallyUnresolvedSettlements.add(idempotencyKey); - await lifecycle.onUnresolved?.({ + await notifyUnresolved({ idempotencyKey, settlementReference, payer, diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs index 2fd124b..e400a19 100644 --- a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -358,3 +358,75 @@ test('malformed facilitator success evidence is unresolved and never authorizes assert.equal(unresolvedCalls, 1); assert.equal(executions, 0); }); + +test('missing or malformed settle result is ambiguous unresolved, while explicit failure is rejected', async () => { + for (const [settleBody, expectedStatus] of [[{}, 503], [{ success: 'false' }, 503], [{ success: false, errorReason: 'declined' }, 402]]) { + let unresolvedCalls = 0; + let rejectedCalls = 0; + const transport = createMockFacilitatorTransport(async (url) => { + const operation = new URL(url).pathname.slice(1); + const body = operation === 'verify' ? { isValid: true } : settleBody; + return new Response(JSON.stringify(body), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + }); + const app = resourceApp({ + facilitatorTransport: transport, + lifecycle: { + async onUnresolved() { unresolvedCalls += 1; }, + async onRejected() { rejectedCalls += 1; }, + }, + }); + const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: `idem-settle-shape-${expectedStatus}-${JSON.stringify(settleBody)}`, + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(result.res.status, expectedStatus); + assert.equal(unresolvedCalls, expectedStatus === 503 ? 1 : 0); + assert.equal(rejectedCalls, expectedStatus === 402 ? 1 : 0); + } +}); + +test('onUnresolved may observe an already-settled append without turning the response into 500', async () => { + const facilitator = createMockFacilitator(); + let settled = false; + let settleCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + if (new URL(url).pathname === '/settle') settleCalls += 1; + return facilitator.request(url, init); + }); + const app = resourceApp({ + facilitatorTransport: transport, + lifecycle: { + async onSigned({ payer }) { + return settled ? { + kind: 'settled', txHash: `0x${'8'.repeat(64)}`, payer, + } : null; + }, + async onSettled() { + settled = true; + throw new Error('lease release failed after append'); + }, + async onUnresolved() { throw new Error('journal already settled'); }, + }, + handler: (c) => { executions += 1; return c.json({ ok: true }); }, + }); + const first = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'idem-settled-append-then-error', + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(first.res.status, 503); + const retry = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': first.idempotencyKey, 'X-PAYMENT': first.xPayment }, + body: '{}', + }); + assert.equal(retry.status, 200); + assert.equal(settleCalls, 1); + assert.equal(executions, 1); +}); From 5eb169902ed3287ae21a9ca5cefef416c8c5aa42 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:19:18 -0400 Subject: [PATCH 072/165] spike: execute budget-backed internal Invocations --- .../src/credentials.mjs | 168 ++++ .../internal-invocation-awards/src/engine.mjs | 728 ++++++++++++++++++ .../internal-invocation-awards/src/store.mjs | 90 +++ .../test/engine.test.mjs | 444 +++++++++++ 4 files changed, 1430 insertions(+) create mode 100644 spikes/internal-invocation-awards/src/credentials.mjs create mode 100644 spikes/internal-invocation-awards/src/engine.mjs create mode 100644 spikes/internal-invocation-awards/src/store.mjs create mode 100644 spikes/internal-invocation-awards/test/engine.test.mjs diff --git a/spikes/internal-invocation-awards/src/credentials.mjs b/spikes/internal-invocation-awards/src/credentials.mjs new file mode 100644 index 0000000..7837cb3 --- /dev/null +++ b/spikes/internal-invocation-awards/src/credentials.mjs @@ -0,0 +1,168 @@ +import { sign as cryptoSign, verify as cryptoVerify } from 'node:crypto'; + +import { + cloneFrozen, + parseUtc, + requireExactKeys, +} from './schema.mjs'; + +const CREDENTIAL_KEYS = [ + 'schemaVersion', 'credentialAuthorizerId', 'invocationId', 'reservationId', + 'idempotencyKey', 'skillId', 'skillVersionHash', 'policyId', 'policyVersion', + 'nonce', 'issuedAt', 'expiresAt', +]; +const SIGNED_CREDENTIAL_KEYS = [...CREDENTIAL_KEYS, 'signature']; +const MANAGER_APPROVAL_KEYS = [ + 'schemaVersion', 'approvalId', 'managerSignerId', 'invocationId', 'creatorId', + 'policyId', 'policyVersion', 'issuedAt', 'expiresAt', +]; +const SIGNED_MANAGER_APPROVAL_KEYS = [...MANAGER_APPROVAL_KEYS, 'signature']; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; + +function ordered(source, keys) { + return Object.fromEntries(keys.map((key) => [key, source[key]])); +} + +function bytes(source, keys) { + return new TextEncoder().encode(JSON.stringify(ordered(source, keys))); +} + +function requireString(value, label) { + if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be non-empty`); +} + +function decodeSignature(value, label) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + throw new Error(`${label} signature must be canonical base64`); + } + const decoded = Buffer.from(value, 'base64'); + if (decoded.length !== 64 || decoded.toString('base64') !== value) { + throw new Error(`${label} signature must be a 64-byte Ed25519 signature`); + } + return decoded; +} + +function validateCredentialPayload(input) { + requireExactKeys(input, CREDENTIAL_KEYS, 'credential'); + if (input.schemaVersion !== 1) throw new Error('credential schemaVersion must equal 1'); + for (const key of [ + 'credentialAuthorizerId', 'invocationId', 'reservationId', 'idempotencyKey', + 'skillId', 'policyId', + ]) requireString(input[key], key); + if (!SHA256_PATTERN.test(input.skillVersionHash)) { + throw new Error('credential skillVersionHash must be a lowercase SHA-256 hash'); + } + if (!Number.isSafeInteger(input.policyVersion) || input.policyVersion < 1) { + throw new Error('credential policyVersion must be a positive integer'); + } + if (typeof input.nonce !== 'string' || !/^[0-9a-f]{64}$/.test(input.nonce)) { + throw new Error('credential nonce must be lowercase 64-character hex without 0x'); + } + const issuedAt = parseUtc(input.issuedAt, 'credential issuedAt'); + const expiresAt = parseUtc(input.expiresAt, 'credential expiresAt'); + if (expiresAt <= issuedAt) throw new Error('credential expiresAt must follow issuedAt'); + return cloneFrozen(input); +} + +export function canonicalCredentialBytes(payload) { + const validated = validateCredentialPayload(payload); + return bytes(validated, CREDENTIAL_KEYS); +} + +export function signCredential(payload, privateKey) { + const validated = validateCredentialPayload(payload); + return cloneFrozen({ + ...validated, + signature: cryptoSign(null, canonicalCredentialBytes(validated), privateKey).toString('base64'), + }); +} + +export function verifyCredential(signed, trustedPublicKey, now) { + requireExactKeys(signed, SIGNED_CREDENTIAL_KEYS, 'signed credential'); + const payload = validateCredentialPayload(ordered(signed, CREDENTIAL_KEYS)); + const signature = decodeSignature(signed.signature, 'credential'); + if (!cryptoVerify(null, canonicalCredentialBytes(payload), trustedPublicKey, signature)) { + throw new Error('credential signature verification failed'); + } + const at = parseUtc(now, 'now'); + if (at < parseUtc(payload.issuedAt, 'credential issuedAt')) { + throw new Error('credential is not yet valid'); + } + if (at >= parseUtc(payload.expiresAt, 'credential expiresAt')) { + throw new Error('credential expired'); + } + return payload; +} + +function validateManagerApprovalPayload(input) { + requireExactKeys(input, MANAGER_APPROVAL_KEYS, 'manager approval'); + if (input.schemaVersion !== 1) throw new Error('manager approval schemaVersion must equal 1'); + for (const key of [ + 'approvalId', 'managerSignerId', 'invocationId', 'creatorId', 'policyId', + ]) requireString(input[key], key); + if (!Number.isSafeInteger(input.policyVersion) || input.policyVersion < 1) { + throw new Error('manager approval policyVersion must be a positive integer'); + } + const issuedAt = parseUtc(input.issuedAt, 'manager approval issuedAt'); + const expiresAt = parseUtc(input.expiresAt, 'manager approval expiresAt'); + if (expiresAt <= issuedAt) throw new Error('manager approval expiresAt must follow issuedAt'); + return cloneFrozen(input); +} + +export function canonicalManagerApprovalBytes(approval) { + const validated = validateManagerApprovalPayload(approval); + return bytes(validated, MANAGER_APPROVAL_KEYS); +} + +export function signManagerApproval(approval, privateKey) { + const validated = validateManagerApprovalPayload(approval); + return cloneFrozen({ + ...validated, + signature: cryptoSign(null, canonicalManagerApprovalBytes(validated), privateKey).toString('base64'), + }); +} + +export function verifyManagerApproval(approval, { + policy, + quote, + managerSigners, + now, +}) { + requireExactKeys(approval, SIGNED_MANAGER_APPROVAL_KEYS, 'signed manager approval'); + const payload = validateManagerApprovalPayload(ordered(approval, MANAGER_APPROVAL_KEYS)); + if (payload.managerSignerId === quote.creatorId) { + throw new Error('Creator cannot self-approve an internal Invocation'); + } + if (!policy.permittedManagerSignerIds.includes(payload.managerSignerId)) { + throw new Error('manager signer is not permitted by policy'); + } + const trustedKey = managerSigners[payload.managerSignerId]; + if (typeof trustedKey !== 'string' || trustedKey.length === 0) { + throw new Error('manager signer is not provisioned'); + } + if (payload.invocationId !== quote.invocationId + || payload.creatorId !== quote.creatorId + || payload.policyId !== policy.policyId + || payload.policyVersion !== policy.version) { + throw new Error('manager approval binding does not match Invocation'); + } + const at = parseUtc(now, 'now'); + const issuedAt = parseUtc(payload.issuedAt, 'manager approval issuedAt'); + const expiresAt = parseUtc(payload.expiresAt, 'manager approval expiresAt'); + if (at < issuedAt) throw new Error('manager approval is not yet valid'); + if (at >= expiresAt) throw new Error('manager approval expired'); + if (expiresAt > parseUtc(quote.expiresAt, 'quote expiresAt') + || expiresAt > parseUtc(policy.expiresAt, 'policy expiresAt')) { + throw new Error('manager approval expiry exceeds Invocation bounds'); + } + const signature = decodeSignature(approval.signature, 'manager approval'); + if (!cryptoVerify(null, canonicalManagerApprovalBytes(payload), trustedKey, signature)) { + throw new Error('manager approval signature verification failed'); + } + return payload; +} + +export const CREDENTIAL_SCHEMAS = cloneFrozen({ + InternalExecutionCredentialV1: CREDENTIAL_KEYS, + ManagerApprovalV1: MANAGER_APPROVAL_KEYS, +}); diff --git a/spikes/internal-invocation-awards/src/engine.mjs b/spikes/internal-invocation-awards/src/engine.mjs new file mode 100644 index 0000000..c7c4c6a --- /dev/null +++ b/spikes/internal-invocation-awards/src/engine.mjs @@ -0,0 +1,728 @@ +import { createPublicKey } from 'node:crypto'; + +import { + createBudget, + finalizeReservation, + holdUnresolvedReservation, + releaseReservation, + remainingAtomic, + reserveBudget, + startReservationExecution, +} from './budget.mjs'; +import { + canonicalCredentialBytes, + verifyCredential, + verifyManagerApproval, +} from './credentials.mjs'; +import { + cloneFrozen, + deepFreeze, + fromAtomic, + parseExecutorOutcome, + parseUtc, + requireExactKeys, + toAtomic, + validatePolicy, + validateQuote, +} from './schema.mjs'; + +const TRUSTED_ENGINE_STATES = new WeakSet(); + +const CREATE_STATE_KEYS = [ + 'signedBudget', 'policies', 'financeSigners', 'managerSigners', + 'credentialAuthorizers', 'now', +]; +const AUTHORIZE_KEYS = [ + 'store', 'quote', 'expectedRevision', 'expectedBudgetRevision', 'reservationId', + 'credentialNonce', 'credentialIssuedAt', 'credentialExpiresAt', + 'credentialAuthorizerId', 'managerApproval', 'now', +]; +const CANCEL_KEYS = ['store', 'expectedRevision', 'reservationId', 'reason', 'now']; +const EXECUTE_KEYS = ['store', 'quote', 'credential', 'executor', 'now']; +const ENGINE_STATE_KEYS = [ + 'revision', 'budget', 'policies', 'financeSigners', 'managerSigners', + 'credentialAuthorizers', 'invocations', 'reservations', 'awards', + 'consumedNonces', 'issuedNonces', 'idempotency', 'events', 'nextReceiptSequence', +]; + +function requirePlainMap(value, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) { + throw new Error(`${label} must be a plain object`); + } + return value; +} + +function validateTrustMap(mapInput, allowedIds, label) { + const map = requirePlainMap(mapInput, label); + const allowed = new Set(allowedIds); + for (const id of allowed) { + if (typeof map[id] !== 'string' || map[id].length === 0) { + throw new Error(`missing trusted ${label.slice(0, -1)} ${id}`); + } + } + for (const [id, key] of Object.entries(map)) { + if (!allowed.has(id)) throw new Error(`unexpected ${label.slice(0, -1)} ${id}`); + try { + createPublicKey(key); + } catch { + throw new Error(`invalid public key for ${label.slice(0, -1)} ${id}`); + } + } + return cloneFrozen(map); +} + +function markTrusted(state) { + const frozen = deepFreeze(state); + TRUSTED_ENGINE_STATES.add(frozen); + return frozen; +} + +function assertTrustedState(state, now) { + if (!TRUSTED_ENGINE_STATES.has(state)) { + throw new Error('engine state was not created by the trusted engine boundary'); + } + requireExactKeys(state, ENGINE_STATE_KEYS, 'engine state'); + const policyKey = `${state.budget.policyId}@${state.budget.policyVersion}`; + const policy = validatePolicy(state.policies[policyKey], now); + const verified = createBudget(state.budget.authorization, { + trustedFinanceSigners: state.financeSigners, + policy, + now, + }); + for (const key of [ + 'budgetId', 'policyId', 'policyVersion', 'period', 'currency', 'atomicScale', + 'allocatedAtomic', + ]) { + if (state.budget[key] !== verified[key]) throw new Error(`budget state changed signed ${key}`); + } + remainingAtomic(state.budget); + return policy; +} + +function nextState(state, changes) { + return markTrusted({ ...state, ...changes, revision: state.revision + 1 }); +} + +function mapWith(map, key, value) { + return deepFreeze({ ...map, [key]: value }); +} + +function invocationEvent(type, invocationId, occurredAt, details = {}) { + return deepFreeze({ + schemaVersion: 1, + eventId: `${invocationId}:${type}:${occurredAt}`, + type, + invocationId, + occurredAt, + ...details, + }); +} + +function effectiveCredentialExpiry(requested, quote, policy, budget) { + const candidates = [ + requested, + quote.expiresAt, + policy.expiresAt, + budget.authorization.expiresAt, + ]; + for (const [index, candidate] of candidates.entries()) parseUtc(candidate, `credential bound ${index}`); + return candidates.reduce((earliest, candidate) => ( + parseUtc(candidate, 'credential bound') < parseUtc(earliest, 'credential bound') + ? candidate + : earliest + )); +} + +function assertCredentialNonce(value) { + if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) { + throw new Error('credential nonce must be lowercase 64-character hex without 0x'); + } +} + +function compareQuote(left, right) { + if (JSON.stringify(left) !== JSON.stringify(right)) { + throw new Error('quote does not match persisted authorization'); + } +} + +function awardExposureAtomic(state, policy, period) { + let exposure = 0n; + // V1 has no automated reversal API, so every recorded award remains in exposure. + for (const award of Object.values(state.awards)) { + if (award.policyId === policy.policyId + && award.policyVersion === policy.version + && award.period === period) { + exposure += toAtomic(award.amountAtomic); + } + } + for (const invocation of Object.values(state.invocations)) { + if (invocation.policyId === policy.policyId + && invocation.policyVersion === policy.version + && invocation.period === period + && ['authorized', 'executing', 'unresolved'].includes(invocation.state)) { + exposure += toAtomic(invocation.maxInvocationAwardAtomic); + } + } + return exposure; +} + +function jsonAllocation(allocation) { + if (!allocation) return null; + return deepFreeze({ + grossAtomic: fromAtomic(allocation.grossAtomic), + executionCostAtomic: fromAtomic(allocation.executionCostAtomic), + protocolFeeAtomic: fromAtomic(allocation.protocolFeeAtomic), + refundReserveAtomic: fromAtomic(allocation.refundReserveAtomic), + invocationAwardAtomic: fromAtomic(allocation.invocationAwardAtomic), + awardCredit: { + recipientId: allocation.awardCredit.recipientId, + amountAtomic: fromAtomic(allocation.awardCredit.amountAtomic), + }, + journalEntries: allocation.journalEntries.map((entry) => ({ + category: entry.category, + debitAccountId: entry.debitAccountId, + creditAccountId: entry.creditAccountId, + amountAtomic: fromAtomic(entry.amountAtomic), + })), + }); +} + +export function createEngineState(input) { + requireExactKeys(input, CREATE_STATE_KEYS, 'engine configuration'); + const rawPolicies = requirePlainMap(input.policies, 'policies'); + if (Object.keys(rawPolicies).length === 0) throw new Error('at least one policy is required'); + const policies = {}; + const financeIds = new Set(); + const managerIds = new Set(); + const authorizerIds = new Set(); + for (const [key, rawPolicy] of Object.entries(rawPolicies)) { + const validated = validatePolicy(rawPolicy, input.now); + if (key !== `${validated.policyId}@${validated.version}`) { + throw new Error(`policy map key ${key} does not match policy identity`); + } + policies[key] = validated; + for (const id of validated.permittedFinanceSignerIds) financeIds.add(id); + for (const id of validated.permittedManagerSignerIds) managerIds.add(id); + for (const id of validated.permittedCredentialAuthorizerIds) authorizerIds.add(id); + } + const frozenPolicies = cloneFrozen(policies); + const financeSigners = validateTrustMap(input.financeSigners, financeIds, 'finance signers'); + const managerSigners = validateTrustMap(input.managerSigners, managerIds, 'manager signers'); + const credentialAuthorizers = validateTrustMap( + input.credentialAuthorizers, + authorizerIds, + 'credential authorizers', + ); + const policy = frozenPolicies[`${input.signedBudget.policyId}@${input.signedBudget.policyVersion}`]; + if (!policy) throw new Error('signed budget policy is not provisioned'); + const budget = createBudget(input.signedBudget, { + trustedFinanceSigners: financeSigners, + policy, + now: input.now, + }); + return markTrusted({ + revision: 0, + budget, + policies: frozenPolicies, + financeSigners, + managerSigners, + credentialAuthorizers, + invocations: deepFreeze({}), + reservations: deepFreeze({}), + awards: deepFreeze({}), + consumedNonces: deepFreeze({}), + issuedNonces: deepFreeze({}), + idempotency: deepFreeze({}), + events: deepFreeze([]), + nextReceiptSequence: 1, + }); +} + +export async function authorizeInternalInvocation(input) { + requireExactKeys(input, AUTHORIZE_KEYS, 'authorization input'); + const beforeCount = input.store.snapshot().events.length; + const state = await input.store.transact(input.expectedRevision, (current) => { + const policyKey = `${input.quote.policyId}@${input.quote.policyVersion}`; + const trustedPolicy = assertTrustedState(current, input.now); + const policy = current.policies[policyKey]; + if (!policy || policy !== trustedPolicy) { + // Object identity is stable because createEngineState freezes the same policy instance. + if (!policy) throw new Error('quote policy is not provisioned'); + } + const validatedPolicy = validatePolicy(policy, input.now); + const quote = validateQuote(input.quote, validatedPolicy, input.now); + if (current.budget.policyId !== quote.policyId + || current.budget.policyVersion !== quote.policyVersion + || current.budget.period !== String(input.now).slice(0, 7)) { + throw new Error('quote is outside the active employer budget'); + } + if (current.budget.revision !== input.expectedBudgetRevision) { + throw new Error( + `stale budget revision: expected ${input.expectedBudgetRevision}, received ${current.budget.revision}`, + ); + } + if (Object.hasOwn(current.idempotency, quote.idempotencyKey)) { + throw new Error('idempotency key already bound'); + } + if (Object.hasOwn(current.invocations, quote.invocationId)) { + throw new Error('Invocation identifier already exists'); + } + if (Object.hasOwn(current.reservations, input.reservationId)) { + throw new Error('reservation identifier already exists'); + } + assertCredentialNonce(input.credentialNonce); + if (Object.hasOwn(current.issuedNonces, input.credentialNonce)) { + throw new Error('credential nonce already issued'); + } + if (!validatedPolicy.permittedCredentialAuthorizerIds.includes(input.credentialAuthorizerId)) { + throw new Error('credential authorizer is not permitted by policy'); + } + if (!current.credentialAuthorizers[input.credentialAuthorizerId]) { + throw new Error('credential authorizer is not provisioned'); + } + + const isSelfInvocation = quote.creatorId === quote.wielderId; + if (isSelfInvocation) { + if (validatedPolicy.selfInvocation === 'excluded') { + throw new Error('self Invocation is excluded by policy'); + } + if (input.managerApproval === null) throw new Error('manager approval is required for self Invocation'); + verifyManagerApproval(input.managerApproval, { + policy: validatedPolicy, + quote, + managerSigners: current.managerSigners, + now: input.now, + }); + } else if (input.managerApproval !== null) { + throw new Error('manager approval must be separate and null for non-self Invocation'); + } + + const requestedExpiry = parseUtc(input.credentialExpiresAt, 'credential expiresAt'); + const issuedAt = parseUtc(input.credentialIssuedAt, 'credential issuedAt'); + const at = parseUtc(input.now, 'now'); + if (issuedAt > at) throw new Error('credential issuedAt cannot be in the future'); + if (requestedExpiry <= issuedAt || requestedExpiry <= at) { + throw new Error('credential expiry must follow issuance and authorization'); + } + const expiresAt = effectiveCredentialExpiry( + input.credentialExpiresAt, + quote, + validatedPolicy, + current.budget, + ); + if (parseUtc(expiresAt, 'credential expiresAt') <= issuedAt) { + throw new Error('effective credential expiry does not follow issuance'); + } + + const prospectiveExposure = awardExposureAtomic(current, validatedPolicy, current.budget.period) + + toAtomic(quote.maxInvocationAwardAtomic); + if (prospectiveExposure > toAtomic(validatedPolicy.maxAwardPerPeriodAtomic)) { + throw new Error('period award cap would be exceeded'); + } + const reserved = reserveBudget(current.budget, quote, { + expectedRevision: input.expectedBudgetRevision, + reservationId: input.reservationId, + now: input.now, + }); + const credentialPayload = deepFreeze({ + schemaVersion: 1, + credentialAuthorizerId: input.credentialAuthorizerId, + invocationId: quote.invocationId, + reservationId: input.reservationId, + idempotencyKey: quote.idempotencyKey, + skillId: quote.skillId, + skillVersionHash: quote.skillVersionHash, + policyId: quote.policyId, + policyVersion: quote.policyVersion, + nonce: input.credentialNonce, + issuedAt: input.credentialIssuedAt, + expiresAt, + }); + const invocation = deepFreeze({ + schemaVersion: 1, + invocationId: quote.invocationId, + idempotencyKey: quote.idempotencyKey, + quoteId: quote.quoteId, + reservationId: input.reservationId, + skillId: quote.skillId, + skillVersionHash: quote.skillVersionHash, + creatorId: quote.creatorId, + wielderId: quote.wielderId, + beneficiaryId: quote.beneficiaryId, + costCenter: quote.costCenter, + policyId: quote.policyId, + policyVersion: quote.policyVersion, + period: current.budget.period, + currency: current.budget.currency, + atomicScale: current.budget.atomicScale, + state: 'authorized', + revision: 0, + credentialNonce: input.credentialNonce, + credentialIssuedAt: input.credentialIssuedAt, + credentialExpiresAt: expiresAt, + executionAttemptId: null, + authorizedAt: input.now, + startedAt: null, + finalizedAt: null, + executionCostStatus: null, + executionCostAtomic: null, + protocolFeeAtomic: '0', + refundReserveAtomic: '0', + maxInvocationAwardAtomic: quote.maxInvocationAwardAtomic, + invocationAwardAtomic: '0', + releasedAtomic: '0', + heldReservationAtomic: '0', + awardId: null, + outputHash: null, + failureClass: null, + unresolvedReason: null, + externalRoyaltyCreditsAtomic: '0', + employerSelfCreditAtomic: '0', + journalEntries: deepFreeze([]), + receiptSequence: null, + }); + const lifecycleEvents = [ + invocationEvent('invocation_requested', quote.invocationId, input.now), + invocationEvent('invocation_quoted', quote.invocationId, input.now, { quoteId: quote.quoteId }), + reserved.event, + invocationEvent('invocation_authorized', quote.invocationId, input.now, { + reservationId: input.reservationId, + credentialAuthorizerId: input.credentialAuthorizerId, + }), + ]; + return nextState(current, { + budget: reserved.budget, + invocations: mapWith(current.invocations, quote.invocationId, invocation), + reservations: mapWith(current.reservations, input.reservationId, reserved.reservation), + issuedNonces: mapWith(current.issuedNonces, input.credentialNonce, { + invocationId: quote.invocationId, + reservationId: input.reservationId, + }), + idempotency: mapWith(current.idempotency, quote.idempotencyKey, { + invocationId: quote.invocationId, + reservationId: input.reservationId, + quoteId: quote.quoteId, + }), + events: deepFreeze([...current.events, ...lifecycleEvents]), + }); + }); + const invocation = state.invocations[input.quote.invocationId]; + const reservation = state.reservations[input.reservationId]; + return deepFreeze({ + state, + budget: state.budget, + invocation, + reservation, + credentialPayload: state.invocations[input.quote.invocationId] + ? deepFreeze({ + schemaVersion: 1, + credentialAuthorizerId: input.credentialAuthorizerId, + invocationId: invocation.invocationId, + reservationId: reservation.reservationId, + idempotencyKey: invocation.idempotencyKey, + skillId: invocation.skillId, + skillVersionHash: invocation.skillVersionHash, + policyId: invocation.policyId, + policyVersion: invocation.policyVersion, + nonce: invocation.credentialNonce, + issuedAt: invocation.credentialIssuedAt, + expiresAt: invocation.credentialExpiresAt, + }) + : null, + events: deepFreeze(state.events.slice(beforeCount)), + }); +} + +export async function cancelInternalAuthorization(input) { + requireExactKeys(input, CANCEL_KEYS, 'cancellation input'); + if (typeof input.reason !== 'string' || input.reason.length === 0) { + throw new Error('cancellation reason must be non-empty'); + } + const beforeCount = input.store.snapshot().events.length; + const state = await input.store.transact(input.expectedRevision, (current) => { + assertTrustedState(current, input.now); + const reservation = current.reservations[input.reservationId]; + if (!reservation) throw new Error('reservation does not exist'); + const invocation = current.invocations[reservation.quote.invocationId]; + if (!invocation || invocation.state !== 'authorized') throw new Error('Invocation is not authorized'); + const released = releaseReservation(current.budget, reservation, { + expectedBudgetRevision: current.budget.revision, + expectedReservationRevision: reservation.revision, + executionAttemptId: null, + executionCostAtomic: '0', + reason: 'cancelled_before_start', + now: input.now, + }); + const cancelled = deepFreeze({ + ...invocation, + state: 'cancelled', + revision: invocation.revision + 1, + finalizedAt: input.now, + releasedAtomic: reservation.reservedAtomic, + }); + return nextState(current, { + budget: released.budget, + reservations: mapWith(current.reservations, input.reservationId, released.reservation), + invocations: mapWith(current.invocations, invocation.invocationId, cancelled), + events: deepFreeze([ + ...current.events, + released.event, + invocationEvent('invocation_cancelled', invocation.invocationId, input.now, { + reason: input.reason, + }), + ]), + }); + }); + const reservation = state.reservations[input.reservationId]; + return deepFreeze({ + state, + budget: state.budget, + reservation, + invocation: state.invocations[reservation.quote.invocationId], + events: deepFreeze(state.events.slice(beforeCount)), + }); +} + +export async function executeAuthorizedInvocation(input) { + requireExactKeys(input, EXECUTE_KEYS, 'execution input'); + if (typeof input.executor !== 'function') throw new Error('executor must be an injected function'); + const initial = input.store.snapshot(); + const beforeCount = initial.events.length; + const started = await input.store.transact(initial.revision, (current) => { + const policy = assertTrustedState(current, input.now); + const quote = validateQuote(input.quote, policy, input.now); + const invocation = current.invocations[quote.invocationId]; + if (!invocation) throw new Error('Invocation has no persisted authorization'); + const reservation = current.reservations[invocation.reservationId]; + if (!reservation) throw new Error('Invocation has no persisted reservation'); + if (Object.hasOwn(current.consumedNonces, invocation.credentialNonce)) { + throw new Error('credential already consumed'); + } + if (invocation.state !== 'authorized') throw new Error('Invocation is not authorized'); + if (reservation.state !== 'reserved') throw new Error('reservation must be reserved'); + compareQuote(quote, reservation.quote); + const authorizerId = input.credential?.credentialAuthorizerId; + if (typeof authorizerId !== 'string' + || !policy.permittedCredentialAuthorizerIds.includes(authorizerId)) { + throw new Error('credential authorizer is not permitted by policy'); + } + const trustedKey = current.credentialAuthorizers[authorizerId]; + if (!trustedKey) throw new Error('credential authorizer is not provisioned'); + const credentialPayload = verifyCredential(input.credential, trustedKey, input.now); + const expectedPayload = { + schemaVersion: 1, + credentialAuthorizerId: authorizerId, + invocationId: invocation.invocationId, + reservationId: reservation.reservationId, + idempotencyKey: invocation.idempotencyKey, + skillId: invocation.skillId, + skillVersionHash: invocation.skillVersionHash, + policyId: invocation.policyId, + policyVersion: invocation.policyVersion, + nonce: invocation.credentialNonce, + issuedAt: invocation.credentialIssuedAt, + expiresAt: invocation.credentialExpiresAt, + }; + if (!Buffer.from(canonicalCredentialBytes(credentialPayload)) + .equals(Buffer.from(canonicalCredentialBytes(expectedPayload)))) { + throw new Error('credential does not match persisted authorization'); + } + const executionAttemptId = `attempt-${invocation.invocationId}-${invocation.credentialNonce}`; + const execution = startReservationExecution(current.budget, reservation, { + expectedBudgetRevision: current.budget.revision, + expectedReservationRevision: reservation.revision, + executionAttemptId, + now: input.now, + }); + const executingInvocation = deepFreeze({ + ...invocation, + state: 'executing', + revision: invocation.revision + 1, + executionAttemptId, + startedAt: input.now, + }); + return nextState(current, { + budget: execution.budget, + reservations: mapWith(current.reservations, reservation.reservationId, execution.reservation), + invocations: mapWith(current.invocations, invocation.invocationId, executingInvocation), + consumedNonces: mapWith(current.consumedNonces, invocation.credentialNonce, { + invocationId: invocation.invocationId, + reservationId: reservation.reservationId, + executionAttemptId, + consumedAt: input.now, + }), + events: deepFreeze([ + ...current.events, + execution.event, + invocationEvent('invocation_executing', invocation.invocationId, input.now, { + executionAttemptId, + }), + ]), + }); + }); + + const startedInvocation = started.invocations[input.quote.invocationId]; + const startedReservation = started.reservations[startedInvocation.reservationId]; + let rawOutcome; + try { + rawOutcome = await input.executor(cloneFrozen({ + invocationId: startedInvocation.invocationId, + reservationId: startedReservation.reservationId, + executionAttemptId: startedInvocation.executionAttemptId, + skillId: startedInvocation.skillId, + skillVersionHash: startedInvocation.skillVersionHash, + })); + } catch { + rawOutcome = { kind: 'unresolved_after_start', reason: 'executor_threw' }; + } + const outcome = parseExecutorOutcome(rawOutcome, startedReservation.quote); + let resultAllocation = null; + const finalized = await input.store.transactRecord({ + invocationId: startedInvocation.invocationId, + expectedInvocationRevision: startedInvocation.revision, + reservationId: startedReservation.reservationId, + expectedReservationRevision: startedReservation.revision, + executionAttemptId: startedInvocation.executionAttemptId, + }, (current, { invocation, reservation }) => { + if (!TRUSTED_ENGINE_STATES.has(current)) { + throw new Error('engine state was not created by the trusted engine boundary'); + } + let money; + let terminalInvocation; + let award = null; + const receiptSequence = current.nextReceiptSequence; + if (outcome.kind === 'succeeded') { + const gross = toAtomic(outcome.executionCostAtomic) + + toAtomic(reservation.quote.protocolFeeAtomic) + + toAtomic(reservation.quote.refundReserveAtomic) + + toAtomic(reservation.quote.maxInvocationAwardAtomic); + money = finalizeReservation(current.budget, reservation, { + expectedBudgetRevision: current.budget.revision, + expectedReservationRevision: reservation.revision, + executionAttemptId: invocation.executionAttemptId, + grossAtomic: fromAtomic(gross), + executionCostAtomic: outcome.executionCostAtomic, + protocolFeeAtomic: reservation.quote.protocolFeeAtomic, + refundReserveAtomic: reservation.quote.refundReserveAtomic, + recipientId: invocation.creatorId, + now: input.now, + }); + resultAllocation = jsonAllocation(money.allocation); + const awardState = current.policies[`${invocation.policyId}@${invocation.policyVersion}`].vestingRule === 'none' + ? 'earned' + : 'vesting_pending'; + award = deepFreeze({ + schemaVersion: 1, + awardId: `award-${invocation.invocationId}`, + invocationId: invocation.invocationId, + recipientId: invocation.creatorId, + policyId: invocation.policyId, + policyVersion: invocation.policyVersion, + period: invocation.period, + currency: invocation.currency, + atomicScale: invocation.atomicScale, + amountAtomic: resultAllocation.invocationAwardAtomic, + state: awardState, + measuredAt: input.now, + earnedAt: awardState === 'earned' ? input.now : null, + payableAt: null, + paidAt: null, + }); + terminalInvocation = deepFreeze({ + ...invocation, + state: 'succeeded', + revision: invocation.revision + 1, + finalizedAt: input.now, + executionCostStatus: 'known', + executionCostAtomic: outcome.executionCostAtomic, + protocolFeeAtomic: reservation.quote.protocolFeeAtomic, + refundReserveAtomic: reservation.quote.refundReserveAtomic, + invocationAwardAtomic: resultAllocation.invocationAwardAtomic, + releasedAtomic: money.event.releasedUnusedAtomic, + awardId: award.awardId, + outputHash: outcome.outputHash, + journalEntries: resultAllocation.journalEntries, + receiptSequence, + }); + } else if (outcome.kind === 'failed_after_start') { + money = releaseReservation(current.budget, reservation, { + expectedBudgetRevision: current.budget.revision, + expectedReservationRevision: reservation.revision, + executionAttemptId: invocation.executionAttemptId, + executionCostAtomic: outcome.executionCostAtomic, + reason: 'failed_after_start', + now: input.now, + }); + terminalInvocation = deepFreeze({ + ...invocation, + state: 'failed', + revision: invocation.revision + 1, + finalizedAt: input.now, + executionCostStatus: 'known', + executionCostAtomic: outcome.executionCostAtomic, + releasedAtomic: money.event.releasedAtomic, + failureClass: outcome.failureClass, + receiptSequence, + }); + } else { + money = holdUnresolvedReservation(current.budget, reservation, { + expectedBudgetRevision: current.budget.revision, + expectedReservationRevision: reservation.revision, + executionAttemptId: invocation.executionAttemptId, + reason: outcome.reason, + now: input.now, + }); + terminalInvocation = deepFreeze({ + ...invocation, + state: 'unresolved', + revision: invocation.revision + 1, + finalizedAt: input.now, + executionCostStatus: 'unresolved', + executionCostAtomic: null, + heldReservationAtomic: reservation.reservedAtomic, + unresolvedReason: outcome.reason, + receiptSequence, + }); + } + const terminalEvents = [ + money.event, + invocationEvent(`invocation_${terminalInvocation.state}`, invocation.invocationId, input.now, { + receiptSequence, + executionAttemptId: invocation.executionAttemptId, + }), + ]; + if (award) { + terminalEvents.push(invocationEvent('invocation_award_measured', invocation.invocationId, input.now, { + awardId: award.awardId, + amountAtomic: award.amountAtomic, + })); + if (award.state === 'earned') { + terminalEvents.push(invocationEvent('invocation_award_earned', invocation.invocationId, input.now, { + awardId: award.awardId, + amountAtomic: award.amountAtomic, + })); + } + } + return nextState(current, { + budget: money.budget, + reservations: mapWith(current.reservations, reservation.reservationId, money.reservation), + invocations: mapWith(current.invocations, invocation.invocationId, terminalInvocation), + awards: award ? mapWith(current.awards, award.awardId, award) : current.awards, + events: deepFreeze([...current.events, ...terminalEvents]), + nextReceiptSequence: receiptSequence + 1, + }); + }); + const invocation = finalized.invocations[startedInvocation.invocationId]; + const reservation = finalized.reservations[startedReservation.reservationId]; + const award = invocation.awardId ? finalized.awards[invocation.awardId] : null; + return deepFreeze({ + state: finalized, + budget: finalized.budget, + invocation, + reservation, + award, + allocation: resultAllocation, + events: deepFreeze(finalized.events.slice(beforeCount)), + }); +} diff --git a/spikes/internal-invocation-awards/src/store.mjs b/spikes/internal-invocation-awards/src/store.mjs new file mode 100644 index 0000000..c5559ec --- /dev/null +++ b/spikes/internal-invocation-awards/src/store.mjs @@ -0,0 +1,90 @@ +import { deepFreeze, requireExactKeys } from './schema.mjs'; + +const RECORD_CAS_KEYS = [ + 'invocationId', 'expectedInvocationRevision', 'reservationId', + 'expectedReservationRevision', 'executionAttemptId', +]; + +function requireRevision(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative integer`); + } +} + +export class InMemoryEngineStore { + #state; + + #tail = Promise.resolve(); + + constructor(initialState) { + if (initialState === null || typeof initialState !== 'object' || !Object.isFrozen(initialState)) { + throw new Error('initial engine state must be created and frozen by createEngineState'); + } + requireRevision(initialState.revision, 'initial engine revision'); + this.#state = initialState; + } + + snapshot() { + return this.#state; + } + + #enqueue(operation) { + const result = this.#tail.then(operation); + this.#tail = result.then(() => undefined, () => undefined); + return result; + } + + transact(expectedRevision, transition) { + requireRevision(expectedRevision, 'expected engine revision'); + if (typeof transition !== 'function') throw new Error('transition must be a function'); + return this.#enqueue(async () => { + const current = this.#state; + if (current.revision !== expectedRevision) { + throw new Error(`stale engine revision: expected ${expectedRevision}, received ${current.revision}`); + } + const next = await transition(current); + if (next === null || typeof next !== 'object') throw new Error('transition must return engine state'); + if (next.revision !== current.revision + 1) { + throw new Error('transition must advance engine revision exactly once'); + } + this.#state = deepFreeze(next); + return this.#state; + }); + } + + transactRecord(cas, transition) { + requireExactKeys(cas, RECORD_CAS_KEYS, 'record CAS'); + requireRevision(cas.expectedInvocationRevision, 'expected Invocation revision'); + requireRevision(cas.expectedReservationRevision, 'expected reservation revision'); + if (typeof transition !== 'function') throw new Error('transition must be a function'); + return this.#enqueue(async () => { + const current = this.#state; + const invocation = current.invocations[cas.invocationId]; + const reservation = current.reservations[cas.reservationId]; + if (!invocation) throw new Error('Invocation record does not exist'); + if (!reservation) throw new Error('reservation record does not exist'); + if (invocation.revision !== cas.expectedInvocationRevision) { + throw new Error( + `stale Invocation revision: expected ${cas.expectedInvocationRevision}, received ${invocation.revision}`, + ); + } + if (reservation.revision !== cas.expectedReservationRevision) { + throw new Error( + `stale reservation revision: expected ${cas.expectedReservationRevision}, received ${reservation.revision}`, + ); + } + if (typeof cas.executionAttemptId !== 'string' || cas.executionAttemptId.length === 0 + || invocation.executionAttemptId !== cas.executionAttemptId + || reservation.executionAttemptId !== cas.executionAttemptId) { + throw new Error('execution attempt does not match current records'); + } + const next = await transition(current, { invocation, reservation }); + if (next === null || typeof next !== 'object') throw new Error('transition must return engine state'); + if (next.revision !== current.revision + 1) { + throw new Error('record transition must advance engine revision exactly once'); + } + this.#state = deepFreeze(next); + return this.#state; + }); + } +} diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs new file mode 100644 index 0000000..8e89d8c --- /dev/null +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -0,0 +1,444 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import test from 'node:test'; + +import { signBudget } from '../src/budget.mjs'; +import { + signCredential, + signManagerApproval, + verifyCredential, +} from '../src/credentials.mjs'; +import { + authorizeInternalInvocation, + cancelInternalAuthorization, + createEngineState, + executeAuthorizedInvocation, +} from '../src/engine.mjs'; +import { InMemoryEngineStore } from '../src/store.mjs'; + +const NOW = '2026-07-17T00:01:00.000Z'; +const AFTER_EXPIRY = '2026-07-17T00:06:00.000Z'; +const SKILL_HASH = `sha256:${'1'.repeat(64)}`; +const OUTPUT_HASH = `sha256:${'a'.repeat(64)}`; + +function policy(overrides = {}) { + return { + schemaVersion: 1, + policyId: 'policy-megacorp-ledger-recon', + version: 1, + status: 'active', + currency: 'USD', + atomicScale: 6, + employerId: 'megacorp', + effectiveAt: '2026-07-17T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + permittedSkillIds: ['ledger-recon'], + permittedCreatorIds: ['sam'], + permittedWielderIds: ['megacorp-internal-agent'], + permittedCostCenters: ['platform-engineering'], + maxQuoteAtomic: '4000000', + awardRule: { + type: 'residual_after_execution_fee_and_reserve', + awardRateBps: 10000, + rateBase: 'post_cost_residual', + rounding: 'floor_atomic', + }, + maxAwardPerInvocationAtomic: '2000000', + maxAwardPerPeriodAtomic: '100000000', + selfInvocation: 'manager_approval_required', + permittedManagerSignerIds: ['manager-alex'], + permittedCredentialAuthorizerIds: ['megacorp-collar-authorizer'], + permittedFinanceSignerIds: ['megacorp-finance'], + vestingRule: 'none', + paymentSchedule: 'monthly_in_arrears', + terminationTreatment: 'earned_remains_payable_unearned_cancelled', + paymentRail: 'employer_payroll_or_ap', + ...overrides, + }; +} + +function quote(suffix = '001', overrides = {}) { + return { + schemaVersion: 1, + quoteId: `quote-inv-${suffix}`, + invocationId: `inv-${suffix}`, + idempotencyKey: `run-ledger-recon-${suffix}`, + skillId: 'ledger-recon', + skillVersionHash: SKILL_HASH, + creatorId: 'sam', + wielderId: 'megacorp-internal-agent', + beneficiaryId: 'megacorp', + costCenter: 'platform-engineering', + policyId: 'policy-megacorp-ledger-recon', + policyVersion: 1, + maxExecutionCostAtomic: '1000000', + protocolFeeAtomic: '25000', + refundReserveAtomic: '25000', + maxInvocationAwardAtomic: '2000000', + maxGrossAtomic: '3050000', + expiresAt: '2026-07-17T00:05:00.000Z', + ...overrides, + }; +} + +function nonce(number = 1) { + return number.toString(16).padStart(64, '0'); +} + +function fixture({ policyOverrides = {}, budgetOverrides = {} } = {}) { + const finance = generateKeyPairSync('ed25519'); + const authorizer = generateKeyPairSync('ed25519'); + const manager = generateKeyPairSync('ed25519'); + const activePolicy = policy(policyOverrides); + const managerSignerId = activePolicy.permittedManagerSignerIds[0]; + const signedBudget = signBudget({ + schemaVersion: 1, + budgetId: 'budget-megacorp-2026-07', + policyId: activePolicy.policyId, + policyVersion: activePolicy.version, + period: '2026-07', + currency: activePolicy.currency, + atomicScale: activePolicy.atomicScale, + allocatedAtomic: '1000000000', + effectiveAt: '2026-07-17T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + signerId: 'megacorp-finance', + ...budgetOverrides, + }, finance.privateKey); + const state = createEngineState({ + signedBudget, + policies: { [`${activePolicy.policyId}@${activePolicy.version}`]: activePolicy }, + financeSigners: { + 'megacorp-finance': finance.publicKey.export({ type: 'spki', format: 'pem' }), + }, + managerSigners: { + [managerSignerId]: manager.publicKey.export({ type: 'spki', format: 'pem' }), + }, + credentialAuthorizers: { + 'megacorp-collar-authorizer': authorizer.publicKey.export({ type: 'spki', format: 'pem' }), + }, + now: NOW, + }); + return { + store: new InMemoryEngineStore(state), + activePolicy, + finance, + authorizer, + manager, + managerSignerId, + }; +} + +async function authorize(fx, q = quote(), overrides = {}) { + return authorizeInternalInvocation({ + store: fx.store, + quote: q, + expectedRevision: 0, + expectedBudgetRevision: 0, + reservationId: `res-${q.invocationId}`, + credentialNonce: nonce(1), + credentialIssuedAt: NOW, + credentialExpiresAt: '2026-07-17T00:10:00.000Z', + credentialAuthorizerId: 'megacorp-collar-authorizer', + managerApproval: null, + now: NOW, + ...overrides, + }); +} + +test('credential signatures bind exact fields, lowercase nonce, and expiry', () => { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const payload = { + schemaVersion: 1, + credentialAuthorizerId: 'megacorp-collar-authorizer', + invocationId: 'inv-001', + reservationId: 'res-inv-001', + idempotencyKey: 'run-ledger-recon-001', + skillId: 'ledger-recon', + skillVersionHash: SKILL_HASH, + policyId: 'policy-megacorp-ledger-recon', + policyVersion: 1, + nonce: nonce(1), + issuedAt: NOW, + expiresAt: '2026-07-17T00:05:00.000Z', + }; + const signed = signCredential(payload, privateKey); + assert.equal(verifyCredential(signed, publicKey, NOW).invocationId, 'inv-001'); + assert.throws( + () => verifyCredential({ ...signed, skillVersionHash: `sha256:${'2'.repeat(64)}` }, publicKey, NOW), + /signature/, + ); + assert.throws(() => verifyCredential(signed, publicKey, AFTER_EXPIRY), /expired/); + assert.throws(() => signCredential({ ...payload, nonce: `0x${nonce(1)}` }, privateKey), /lowercase 64-character hex/); +}); + +test('authorization reserves before signing and successful execution conserves exact gross', async () => { + const fx = fixture(); + const q = quote(); + const authorized = await authorize(fx, q); + assert.equal(authorized.reservation.state, 'reserved'); + assert.equal(authorized.credentialPayload.expiresAt, q.expiresAt); + assert.equal(authorized.invocation.state, 'authorized'); + assert.equal(authorized.invocation.externalRoyaltyCreditsAtomic, '0'); + assert.equal(authorized.invocation.employerSelfCreditAtomic, '0'); + + const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); + const result = await executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential, + executor: async () => ({ + kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH, + }), + now: NOW, + }); + assert.equal(result.invocation.state, 'succeeded'); + assert.equal(result.award.amountAtomic, '2000000'); + assert.equal(result.award.state, 'earned'); + assert.equal(result.budget.consumedAtomic, '2750000'); + assert.equal(result.budget.releasedAtomic, '300000'); + assert.equal(result.allocation.journalEntries.length, 4); + assert.doesNotThrow(() => JSON.stringify(result)); + assert.doesNotThrow(() => JSON.stringify(result.state)); + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential, + executor: async () => { throw new Error('must not run'); }, + now: NOW, + }), /credential already consumed|Invocation is not authorized|idempotency/); +}); + +test('execution re-establishes signed budget trust and expiry at start', async () => { + const fx = fixture({ budgetOverrides: { expiresAt: '2026-07-17T00:03:00.000Z' } }); + const q = quote(); + const authorized = await authorize(fx, q); + const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); + let calls = 0; + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential, + executor: async () => { calls += 1; return {}; }, + now: '2026-07-17T00:04:00.000Z', + }), /budget authorization expired/); + assert.equal(calls, 0); + + const fabricated = Object.freeze({ ...fx.store.snapshot() }); + const fabricatedStore = new InMemoryEngineStore(fabricated); + await assert.rejects(() => executeAuthorizedInvocation({ + store: fabricatedStore, + quote: q, + credential, + executor: async () => { calls += 1; return {}; }, + now: NOW, + }), /trusted engine boundary/); + assert.equal(calls, 0); +}); + +test('validated failure consumes exact COGS and creates no award', async () => { + const fx = fixture(); + const q = quote(); + const authorized = await authorize(fx, q); + const result = await executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), + executor: async () => ({ + kind: 'failed_after_start', executionCostAtomic: '700000', failureClass: 'provider_error', + }), + now: NOW, + }); + assert.equal(result.invocation.state, 'failed'); + assert.equal(result.budget.consumedAtomic, '700000'); + assert.equal(result.budget.releasedAtomic, '2350000'); + assert.equal(result.award, null); +}); + +test('every malformed or unknown-cost post-start outcome keeps the full hold and no award', async (t) => { + const outcomes = [ + async () => { throw new Error('provider vanished'); }, + async () => ({ kind: 'unresolved_after_start', reason: 'cost_unknown' }), + async () => ({ kind: 'unknown' }), + async () => ({ kind: 'failed_after_start', failureClass: 'provider_error' }), + async () => ({ kind: 'failed_after_start', executionCostAtomic: '-1', failureClass: 'provider_error' }), + async () => ({ kind: 'failed_after_start', executionCostAtomic: '1.5', failureClass: 'provider_error' }), + async () => ({ kind: 'failed_after_start', executionCostAtomic: 1, failureClass: 'provider_error' }), + async () => ({ kind: 'failed_after_start', executionCostAtomic: '1000001', failureClass: 'provider_error' }), + async () => ({ kind: 'failed_after_start', executionCostAtomic: '1', failureClass: 'provider_error', extra: true }), + async () => ({ kind: 'succeeded', executionCostAtomic: '1', outputHash: 'bad' }), + ]; + for (const [index, executor] of outcomes.entries()) { + await t.test(`unresolved case ${index + 1}`, async () => { + const fx = fixture(); + const q = quote(String(index + 1).padStart(3, '0')); + const authorized = await authorize(fx, q, { credentialNonce: nonce(index + 1) }); + const result = await executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), + executor, + now: NOW, + }); + assert.equal(result.invocation.state, 'unresolved'); + assert.equal(result.reservation.state, 'held_unresolved'); + assert.equal(result.budget.reservedAtomic, '3050000'); + assert.equal(result.budget.consumedAtomic, '0'); + assert.equal(result.budget.releasedAtomic, '0'); + assert.equal(result.award, null); + assert.equal(result.invocation.executionCostAtomic, null); + assert.ok(result.events.some((event) => event.type === 'execution_cost_unresolved')); + assert.ok(Object.hasOwn(fx.store.snapshot().consumedNonces, authorized.credentialPayload.nonce)); + }); + } +}); + +test('pre-execution trust, identity, idempotency, and manager failures never call executor', async () => { + const fx = fixture(); + await assert.rejects(() => authorize(fx, quote('001', { wielderId: 'outsider' })), /Wielder is not permitted/); + assert.equal(fx.store.snapshot().revision, 0); + + const authorized = await authorize(fx); + const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); + const attacker = generateKeyPairSync('ed25519'); + let calls = 0; + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: quote(), + credential: signCredential(authorized.credentialPayload, attacker.privateKey), + executor: async () => { calls += 1; return { kind: 'succeeded', executionCostAtomic: '0', outputHash: OUTPUT_HASH }; }, + now: NOW, + }), /signature/); + assert.equal(calls, 0); + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: quote(), + credential: { ...credential, publicKeyPem: 'self-declared' }, + executor: async () => { calls += 1; return {}; }, + now: NOW, + }), /unknown key publicKeyPem/); + assert.equal(calls, 0); +}); + +test('self Invocation requires a separately signed, trusted, non-self manager approval', async () => { + const fx = fixture({ policyOverrides: { permittedWielderIds: ['megacorp-internal-agent', 'sam'] } }); + const selfQuote = quote('self', { creatorId: 'sam', wielderId: 'sam' }); + await assert.rejects(() => authorize(fx, selfQuote), /manager approval is required/); + const approval = signManagerApproval({ + schemaVersion: 1, + approvalId: 'approval-self-1', + managerSignerId: 'manager-alex', + invocationId: selfQuote.invocationId, + creatorId: 'sam', + policyId: selfQuote.policyId, + policyVersion: 1, + issuedAt: NOW, + expiresAt: selfQuote.expiresAt, + }, fx.manager.privateKey); + const authorized = await authorize(fx, selfQuote, { managerApproval: approval }); + assert.equal(authorized.reservation.state, 'reserved'); + + const fxSelf = fixture({ + policyOverrides: { + permittedWielderIds: ['megacorp-internal-agent', 'sam'], + permittedManagerSignerIds: ['sam'], + }, + }); + const selfManager = generateKeyPairSync('ed25519'); + const badState = fxSelf.store.snapshot(); + // A manager signer cannot be injected through an approval; the trust map remains authoritative. + const selfApproval = signManagerApproval({ + schemaVersion: 1, approvalId: 'self-approved', managerSignerId: 'sam', + invocationId: selfQuote.invocationId, creatorId: 'sam', policyId: selfQuote.policyId, + policyVersion: 1, issuedAt: NOW, expiresAt: selfQuote.expiresAt, + }, selfManager.privateKey); + assert.equal(badState.revision, 0); + await assert.rejects(() => authorize(fxSelf, selfQuote, { managerApproval: selfApproval }), /self-approve|manager signer/); +}); + +test('cancelled reservation makes its signed credential unusable', async () => { + const fx = fixture(); + const q = quote(); + const authorized = await authorize(fx, q); + const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); + const cancelled = await cancelInternalAuthorization({ + store: fx.store, + expectedRevision: 1, + reservationId: authorized.reservation.reservationId, + reason: 'operator_cancelled', + now: NOW, + }); + assert.equal(cancelled.invocation.state, 'cancelled'); + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, quote: q, credential, + executor: async () => { throw new Error('must not execute'); }, now: NOW, + }), /Invocation is not authorized|reservation must be reserved/); +}); + +test('serialized CAS permits one stale authorization and one execution attempt', async () => { + const fx = fixture(); + const q1 = quote('001'); + const q2 = quote('002'); + const pending = [ + authorize(fx, q1, { reservationId: 'res-race-1', credentialNonce: nonce(1) }), + authorize(fx, q2, { reservationId: 'res-race-2', credentialNonce: nonce(2) }), + ]; + const settled = await Promise.allSettled(pending); + assert.equal(settled.filter((item) => item.status === 'fulfilled').length, 1); + assert.match(settled.find((item) => item.status === 'rejected').reason.message, /stale engine revision/); + assert.equal(Object.keys(fx.store.snapshot().reservations).length, 1); + assert.equal(Object.keys(fx.store.snapshot().idempotency).length, 1); + + const authorized = settled.find((item) => item.status === 'fulfilled').value; + const q = authorized.invocation.invocationId === q1.invocationId ? q1 : q2; + const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); + let calls = 0; + const input = () => executeAuthorizedInvocation({ + store: fx.store, quote: q, credential, + executor: async () => { + calls += 1; + await Promise.resolve(); + return { kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH }; + }, + now: NOW, + }); + const executions = await Promise.allSettled([input(), input()]); + assert.equal(executions.filter((item) => item.status === 'fulfilled').length, 1); + assert.equal(calls, 1); + assert.equal(Object.keys(fx.store.snapshot().awards).length, 1); +}); + +test('period cap counts conservative earned awards and every open maximum exposure', async () => { + const fx = fixture({ policyOverrides: { maxAwardPerPeriodAtomic: '3000000' } }); + await authorize(fx, quote('001')); + await assert.rejects(() => authorize(fx, quote('002'), { + expectedRevision: 1, + expectedBudgetRevision: 1, + reservationId: 'res-inv-002', + credentialNonce: nonce(2), + }), /period award cap/); + assert.equal(Object.keys(fx.store.snapshot().reservations).length, 1); +}); + +test('engine configuration rejects missing and extra trust roots', () => { + const fx = fixture(); + const snapshot = fx.store.snapshot(); + assert.ok(Object.isFrozen(snapshot.policies)); + assert.ok(Object.isFrozen(snapshot.credentialAuthorizers)); + assert.throws(() => createEngineState({ + signedBudget: snapshot.budget.authorization, + policies: snapshot.policies, + financeSigners: snapshot.financeSigners, + managerSigners: snapshot.managerSigners, + credentialAuthorizers: {}, + now: NOW, + }), /missing trusted credential authorizer/); + assert.throws(() => createEngineState({ + signedBudget: snapshot.budget.authorization, + policies: snapshot.policies, + financeSigners: snapshot.financeSigners, + managerSigners: snapshot.managerSigners, + credentialAuthorizers: { ...snapshot.credentialAuthorizers, attacker: 'key' }, + now: NOW, + }), /unexpected credential authorizer/); +}); From b8444aa0c9020f4374ce3dbd96d000f1d5ffd897 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:19:56 -0400 Subject: [PATCH 073/165] fix: keep local attestation journal ignored --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3f50c13..dd83b77 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,8 @@ out/ # Run artifacts (belt-and-braces; spikes also ignore locally) runs/ *.jsonl -!phase0/attestations.jsonl phase0/.attestation-checkouts.local.json +phase0/attestations.jsonl.* spikes/pi-wielder/**/*.lock spikes/pi-wielder/**/*.claim spikes/pi-wielder/**/*.pem From d394ce141fe7a810183997e2fab978e65cc7a5f2 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:21:10 -0400 Subject: [PATCH 074/165] fix: bind attestation config checks to file descriptors --- phase0/src/attestation-config.ts | 71 ++++++++++++++++++++-------- phase0/tests/attestation-git.test.ts | 11 ++++- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/phase0/src/attestation-config.ts b/phase0/src/attestation-config.ts index d8ab478..dad9ba0 100644 --- a/phase0/src/attestation-config.ts +++ b/phase0/src/attestation-config.ts @@ -1,4 +1,5 @@ -import { lstat, readFile, realpath, stat } from "node:fs/promises"; +import { constants } from "node:fs"; +import { open, realpath } from "node:fs/promises"; import { isAbsolute, relative, resolve } from "node:path"; import { normalizeRepositoryUrl } from "./attestations"; @@ -15,21 +16,33 @@ interface FileMetadata { isSymbolicLink(): boolean; mode: number; uid: number; + dev?: number; + ino?: number; +} + +interface SecureReadHandle { + stat(): Promise; + readFile(): Promise; + close(): Promise; } export interface AttestationConfigFileSystem { - lstat(path: string): Promise; - stat(path: string): Promise; realpath(path: string): Promise; - readFile(path: string): Promise; + openNoFollow(path: string, kind: "file" | "directory"): Promise; currentUid(): number; } const NODE_FS: AttestationConfigFileSystem = { - lstat, - stat, realpath, - readFile: (path) => readFile(path, "utf8"), + openNoFollow: async (path, kind) => { + const directoryFlag = kind === "directory" ? constants.O_DIRECTORY : 0; + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | directoryFlag); + return { + stat: () => handle.stat(), + readFile: () => handle.readFile("utf8"), + close: () => handle.close(), + }; + }, currentUid: () => { const uid = process.getuid?.(); if (uid === undefined) throw new Error("repository snapshot mapping requires an operating-system owner identity"); @@ -87,20 +100,29 @@ export async function loadLocalCheckoutMap(input: { throw new Error("an in-repository checkout mapping override must equal the exact ignored default path"); } - let metadata: FileMetadata; - try { metadata = await fs.lstat(configPath); } catch (error) { + let configHandle: SecureReadHandle; + try { configHandle = await fs.openNoFollow(configPath, "file"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { throw new Error(`repository snapshot mapping unavailable: ${configPath}`, { cause: error }); } - throw error; + throw new Error("repository snapshot mapping must be a non-symlink regular file", { cause: error }); } - if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error("repository snapshot mapping must be a non-symlink regular file"); - if ((metadata.mode & 0o777) !== 0o600) throw new Error("repository snapshot mapping must have mode 0600"); - if (metadata.uid !== fs.currentUid()) throw new Error("repository snapshot mapping must be owned by the current user"); - let parsed: unknown; - try { parsed = JSON.parse(await fs.readFile(configPath)); } catch (error) { - throw new Error("repository snapshot mapping contains malformed JSON", { cause: error }); + try { + const metadata = await configHandle.stat(); + if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error("repository snapshot mapping must be a non-symlink regular file"); + if ((metadata.mode & 0o777) !== 0o600) throw new Error("repository snapshot mapping must have mode 0600"); + if (metadata.uid !== fs.currentUid()) throw new Error("repository snapshot mapping must be owned by the current user"); + try { parsed = JSON.parse(await configHandle.readFile()); } catch (error) { + throw new Error("repository snapshot mapping contains malformed JSON", { cause: error }); + } + const afterRead = await configHandle.stat(); + if ((metadata.dev !== undefined && afterRead.dev !== metadata.dev) + || (metadata.ino !== undefined && afterRead.ino !== metadata.ino)) { + throw new Error("repository snapshot mapping changed while it was being read"); + } + } finally { + await configHandle.close(); } const config = object(parsed, "repository snapshot mapping"); exactKeys(config, ["schemaVersion", "checkouts"], "repository snapshot mapping"); @@ -125,10 +147,19 @@ export async function loadLocalCheckoutMap(input: { throw new Error(`checkout ${key} does not exist`, { cause: error }); } if (canonical !== checkoutPath) throw new Error(`checkout ${key} must use its real canonical path`); - const checkoutMetadata = await fs.stat(checkoutPath); - if (!checkoutMetadata.isDirectory()) throw new Error(`checkout ${key} must be a directory`); - if (checkoutMetadata.uid !== fs.currentUid()) throw new Error(`checkout ${key} must be owned by the current user`); - if ((checkoutMetadata.mode & 0o022) !== 0) throw new Error(`checkout ${key} must not be group- or world-writable`); + let checkoutHandle: SecureReadHandle; + try { checkoutHandle = await fs.openNoFollow(checkoutPath, "directory"); } catch (error) { + throw new Error(`checkout ${key} must be a non-symlink directory`, { cause: error }); + } + try { + const checkoutMetadata = await checkoutHandle.stat(); + if (checkoutMetadata.isSymbolicLink() || !checkoutMetadata.isDirectory()) throw new Error(`checkout ${key} must be a directory`); + if (checkoutMetadata.uid !== fs.currentUid()) throw new Error(`checkout ${key} must be owned by the current user`); + if ((checkoutMetadata.mode & 0o022) !== 0) throw new Error(`checkout ${key} must not be group- or world-writable`); + if (await fs.realpath(checkoutPath) !== checkoutPath) throw new Error(`checkout ${key} changed during validation`); + } finally { + await checkoutHandle.close(); + } result[key] = checkoutPath; } return deepFreeze(result); diff --git a/phase0/tests/attestation-git.test.ts b/phase0/tests/attestation-git.test.ts index 228e689..aebdd2c 100644 --- a/phase0/tests/attestation-git.test.ts +++ b/phase0/tests/attestation-git.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash, generateKeyPairSync, sign as signBytes } from "node:crypto"; -import { chmod, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { execFileSync } from "node:child_process"; @@ -197,3 +197,12 @@ test("missing checkout mapping fails with an explicit unavailable error", async t.after(() => rm(phase0Root, { recursive: true, force: true })); await assert.rejects(loadLocalCheckoutMap({ env: {}, phase0Root: await realpath(phase0Root), referencedCheckoutKeys: [] }), /repository snapshot mapping unavailable/); }); + +test("checkout mapping rejects a symlink instead of following a swapped path", async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), "phase0-attestation-symlink-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const target = join(root, "target.json"); + await writeFile(target, '{"schemaVersion":1,"checkouts":{}}\n', { mode: 0o600 }); + await symlink(target, join(root, ".attestation-checkouts.local.json")); + await assert.rejects(loadLocalCheckoutMap({ env: {}, phase0Root: root, referencedCheckoutKeys: [] }), /non-symlink regular file/); +}); From 32af80557c0c1dd13681dfc68640bf88c0ea241d Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:21:21 -0400 Subject: [PATCH 075/165] feat: persist append-only registration attestations --- phase0/attestation-admins.json | 4 + phase0/forge-signers.json | 4 + phase0/organization-signers.json | 4 + phase0/repository-trust.json | 4 + phase0/src/attestation-store.ts | 291 +++++++++++++++++++++++++ phase0/tests/attestation-store.test.ts | 172 +++++++++++++++ 6 files changed, 479 insertions(+) create mode 100644 phase0/attestation-admins.json create mode 100644 phase0/forge-signers.json create mode 100644 phase0/organization-signers.json create mode 100644 phase0/repository-trust.json create mode 100644 phase0/src/attestation-store.ts create mode 100644 phase0/tests/attestation-store.test.ts diff --git a/phase0/attestation-admins.json b/phase0/attestation-admins.json new file mode 100644 index 0000000..313911f --- /dev/null +++ b/phase0/attestation-admins.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "admins": {} +} diff --git a/phase0/forge-signers.json b/phase0/forge-signers.json new file mode 100644 index 0000000..bf5746b --- /dev/null +++ b/phase0/forge-signers.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "forgeSigners": {} +} diff --git a/phase0/organization-signers.json b/phase0/organization-signers.json new file mode 100644 index 0000000..e12cc87 --- /dev/null +++ b/phase0/organization-signers.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "organizations": {} +} diff --git a/phase0/repository-trust.json b/phase0/repository-trust.json new file mode 100644 index 0000000..babd1be --- /dev/null +++ b/phase0/repository-trust.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "repositories": [] +} diff --git a/phase0/src/attestation-store.ts b/phase0/src/attestation-store.ts new file mode 100644 index 0000000..fd687e5 --- /dev/null +++ b/phase0/src/attestation-store.ts @@ -0,0 +1,291 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { link, mkdir, open, rename, stat, unlink, type FileHandle } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { + parseAttestationEvent, + reduceAttestationEvents, + type AttestationEvent, + type RegistrationSubject, + type RepositoryControlEvent, +} from "./attestations"; +import { reverifyRepositoryEvent, type GitReader, type TrustedRepositoryResolver } from "./attestation-git"; + +export interface AttestationRepositoryContext { + repositories: TrustedRepositoryResolver; + forgeSigners: Readonly>; + git: GitReader; +} + +export interface AttestationStoreOptions { + baseSubjects: readonly RegistrationSubject[]; + organizationSigners?: Readonly>; + adminSigners?: Readonly>; + repositories?: TrustedRepositoryResolver; + forgeSigners?: Readonly>; + git?: GitReader; + repositoryContextLoader?: () => Promise; + hooks?: { + afterLockCreated?(): void | Promise; + beforeAppendWrite?(): void | Promise; + afterLockClaim?(claimPath: string): void | Promise; + }; +} + +export interface AttestationLockMetadata { + schemaVersion: 1; + pid: number; + token: string; + targetPath: string; + acquiredAt: string; +} + +export interface WriteAllHandle { + write(buffer: Uint8Array, offset: number, length: number, position: number | null): Promise<{ bytesWritten: number }>; +} + +const LOCK_TOKEN = /^[0-9a-f]{32}$/; + +export async function writeAll(handle: WriteAllHandle, bytes: Uint8Array): Promise { + let offset = 0; + while (offset < bytes.byteLength) { + const result = await handle.write(bytes, offset, bytes.byteLength - offset, null); + const bytesWritten = result?.bytesWritten; + if (!Number.isSafeInteger(bytesWritten) || bytesWritten <= 0 || bytesWritten > bytes.byteLength - offset) { + throw new Error("attestation store write made no progress or returned an invalid byte count"); + } + offset += bytesWritten; + } +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, "r"); + try { await handle.sync(); } finally { await handle.close(); } +} + +function object(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value as Record; +} + +function parseLock(value: unknown): AttestationLockMetadata { + const owner = object(value, "attestation store lock"); + const expected = ["schemaVersion", "pid", "token", "targetPath", "acquiredAt"]; + const actual = Object.keys(owner); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new Error("attestation store lock fields are malformed"); + } + if (owner.schemaVersion !== 1) throw new Error("attestation store lock schemaVersion must be 1"); + if (!Number.isSafeInteger(owner.pid) || (owner.pid as number) <= 0) throw new Error("attestation store lock PID is malformed"); + if (typeof owner.token !== "string" || !LOCK_TOKEN.test(owner.token)) throw new Error("attestation store lock token is malformed"); + if (typeof owner.targetPath !== "string" || !owner.targetPath.startsWith("/")) throw new Error("attestation store lock target path is malformed"); + if (typeof owner.acquiredAt !== "string" || Number.isNaN(Date.parse(owner.acquiredAt)) || new Date(owner.acquiredAt).toISOString() !== owner.acquiredAt) { + throw new Error("attestation store lock timestamp is malformed"); + } + return owner as unknown as AttestationLockMetadata; +} + +async function readMode0600(path: string, label: string): Promise { + const handle = await open(path, "r"); + try { + const metadata = await handle.stat(); + if ((metadata.mode & 0o777) !== 0o600) throw new Error(`${label} must have mode 0600`); + return await handle.readFile("utf8"); + } finally { + await handle.close(); + } +} + +function parseJsonLine(line: string, index: number): AttestationEvent { + try { return parseAttestationEvent(JSON.parse(line)); } catch (error) { + throw new Error(`attestation log line ${index + 1} is malformed`, { cause: error }); + } +} + +export class FileAttestationStore { + readonly path: string; + readonly lockPath: string; + private readonly options: AttestationStoreOptions; + + constructor(path: string, options: AttestationStoreOptions) { + if (!path.startsWith("/")) throw new Error("attestation store path must be absolute"); + if (!Array.isArray(options.baseSubjects)) throw new Error("attestation store requires verifier-provided base subjects"); + this.path = path; + this.lockPath = `${path}.lock`; + this.options = options; + } + + async load(): Promise { + return this.withLock(() => this.loadUnlocked()); + } + + async nextSequence(): Promise { + return this.withLock(async () => (await this.loadUnlocked()).length + 1); + } + + async append(eventValue: AttestationEvent): Promise { + await this.withLock(async () => { + const events = await this.loadUnlocked(); + const event = parseAttestationEvent(eventValue); + if (event.sequence !== events.length + 1) throw new Error(`attestation event sequence must equal ${events.length + 1}`); + if (events.some((prior) => prior.eventId === event.eventId)) throw new Error(`duplicate attestation event ID ${event.eventId}`); + const candidate = [...events, event]; + await this.validateEvents(candidate); + await this.options.hooks?.beforeAppendWrite?.(); + await mkdir(dirname(this.path), { recursive: true }); + let handle: FileHandle | null = null; + try { + handle = await open(this.path, "a", 0o600); + const bytes = Buffer.from(`${JSON.stringify(event)}\n`, "utf8"); + await writeAll(handle, bytes); + await handle.sync(); + } finally { + await handle?.close(); + } + await syncDirectory(dirname(this.path)); + const replayed = await this.loadUnlocked(); + if (replayed.length !== candidate.length || replayed.at(-1)?.eventId !== event.eventId) { + throw new Error("attestation append did not replay as the exact candidate event"); + } + }); + } + + async readLockMetadata(): Promise { + return (await this.readLock()).owner; + } + + async recoverStaleLock(input: { + expectedToken: string; + isProcessAlive: (pid: number) => boolean | Promise; + }): Promise { + if (!LOCK_TOKEN.test(input.expectedToken)) throw new Error("expected lock token must be exactly 128 lowercase bits"); + const initial = await this.readLock(); + if (initial.owner.targetPath !== this.path) throw new Error("attestation store lock target path does not match this store"); + if (initial.owner.token !== input.expectedToken) throw new Error("recorded attestation lock token does not match the expected token"); + const alive = await input.isProcessAlive(initial.owner.pid); + if (typeof alive !== "boolean") throw new Error(`cannot prove PID ${initial.owner.pid} is absent`); + if (alive) throw new Error(`attestation store lock PID ${initial.owner.pid} is still alive`); + await this.claimAndRemoveLock(initial.owner, initial.bytes, "attestation store lock changed during stale recovery"); + } + + private async repositoryContext(): Promise { + if (this.options.repositories && this.options.forgeSigners && this.options.git) { + return { repositories: this.options.repositories, forgeSigners: this.options.forgeSigners, git: this.options.git }; + } + if (this.options.repositoryContextLoader) return this.options.repositoryContextLoader(); + throw new Error("repository verifier context required"); + } + + private async validateEvents(events: readonly AttestationEvent[]): Promise { + const hasRepositoryEvidence = events.some((event) => event.type === "repository_control_verified"); + let context: AttestationRepositoryContext | null = null; + if (hasRepositoryEvidence) context = await this.repositoryContext(); + await reduceAttestationEvents(events, { + baseSubjects: this.options.baseSubjects, + organizationSigners: this.options.organizationSigners, + adminSigners: this.options.adminSigners, + repositoryVerifier: context + ? (event: RepositoryControlEvent) => reverifyRepositoryEvent(event, context!) + : undefined, + }); + } + + private async loadUnlocked(): Promise { + let bytes: string; + try { bytes = await open(this.path, "r").then(async (handle) => { + try { return await handle.readFile("utf8"); } finally { await handle.close(); } + }); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + if (bytes === "") return []; + if (!bytes.endsWith("\n")) throw new Error("attestation log has a malformed trailing fragment"); + const lines = bytes.slice(0, -1).split("\n"); + if (lines.some((line) => line.length === 0)) throw new Error("attestation log contains an empty or malformed line"); + const events = lines.map(parseJsonLine); + await this.validateEvents(events); + return events; + } + + private async withLock(operation: () => Promise): Promise { + await mkdir(dirname(this.path), { recursive: true }); + const owner: AttestationLockMetadata = { + schemaVersion: 1, + pid: process.pid, + token: randomBytes(16).toString("hex"), + targetPath: this.path, + acquiredAt: new Date().toISOString(), + }; + await this.acquireLock(owner); + try { return await operation(); } finally { await this.releaseLock(owner); } + } + + private async acquireLock(owner: AttestationLockMetadata): Promise { + let handle: FileHandle | null = null; + let created = false; + try { + try { + handle = await open(this.lockPath, "wx", 0o600); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const existing = await this.readLock(); + throw new Error(`attestation store locked by PID ${existing.owner.pid}, token ${existing.owner.token}`, { cause: error }); + } + await writeAll(handle, Buffer.from(`${JSON.stringify(owner)}\n`, "utf8")); + await handle.sync(); + await handle.close(); + handle = null; + await syncDirectory(dirname(this.lockPath)); + await this.options.hooks?.afterLockCreated?.(); + } catch (error) { + const unfinishedHandle = handle as FileHandle | null; + if (unfinishedHandle) await unfinishedHandle.close().catch(() => undefined); + if (created) await unlink(this.lockPath).catch(() => undefined); + throw error; + } + } + + private async releaseLock(owner: AttestationLockMetadata): Promise { + await this.claimAndRemoveLock(owner, `${JSON.stringify(owner)}\n`, "attestation store lock ownership changed before release"); + } + + private async claimAndRemoveLock(owner: AttestationLockMetadata, expectedBytes: string, message: string): Promise { + const claimPath = `${this.lockPath}.${process.pid}.${randomUUID()}.claim`; + await rename(this.lockPath, claimPath); + await this.options.hooks?.afterLockClaim?.(claimPath); + let observed: { owner: AttestationLockMetadata; bytes: string } | null = null; + let failure: unknown = null; + try { observed = await this.readLock(claimPath); } catch (error) { failure = error; } + if (failure || !observed || observed.owner.token !== owner.token || observed.owner.targetPath !== owner.targetPath || observed.bytes !== expectedBytes) { + let disposition = `retained at ${claimPath}`; + try { + await link(claimPath, this.lockPath); + await unlink(claimPath); + disposition = "restored"; + await syncDirectory(dirname(this.lockPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw new Error(`${message}; claim retained at ${claimPath}`, { cause: error }); + } + } + throw new Error(`${message}; claimed lock was ${disposition}`, { cause: failure ?? undefined }); + } + await unlink(claimPath); + await syncDirectory(dirname(this.lockPath)); + } + + private async readLock(path = this.lockPath): Promise<{ owner: AttestationLockMetadata; bytes: string }> { + let bytes: string; + try { bytes = await readMode0600(path, "attestation store lock"); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("attestation store lock does not exist", { cause: error }); + throw error; + } + if (!bytes.endsWith("\n") || bytes.endsWith("\n\n")) throw new Error("attestation store lock is malformed"); + let value: unknown; + try { value = JSON.parse(bytes); } catch (error) { throw new Error("attestation store lock is malformed", { cause: error }); } + const owner = parseLock(value); + if (owner.targetPath !== this.path) throw new Error("attestation store lock target path does not match this store"); + return { owner, bytes }; + } +} diff --git a/phase0/tests/attestation-store.test.ts b/phase0/tests/attestation-store.test.ts new file mode 100644 index 0000000..ffc73a2 --- /dev/null +++ b/phase0/tests/attestation-store.test.ts @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +import { + canonicalChallengeEventStatement, + canonicalRepositoryStatement, + challengeEventStatementHash, + deterministicConflictId, + repositoryStatementHash, + type ChallengeOpenedEvent, + type RegistrationSubject, + type RepositoryControlEvent, +} from "../src/attestations"; +import { FileAttestationStore, writeAll } from "../src/attestation-store"; + +const IP_A = `0x${"a".repeat(40)}` as const; +const IP_B = `0x${"b".repeat(40)}` as const; +const ARTIFACT_HASH = `0x${"1".repeat(64)}` as const; +const NOW = "2026-07-18T12:00:00.000Z"; + +function subject(ipId: `0x${string}`, wallet: `0x${string}`): RegistrationSubject { + return { + registrationId: `eip155:1315:${ipId}`, + ipId, + wallet: wallet.toLowerCase() as `0x${string}`, + artifactHash: ARTIFACT_HASH, + declaredParentIpIds: [], + }; +} + +async function fixture(t: test.TestContext, hooks?: ConstructorParameters[1]["hooks"]) { + const directory = await mkdtemp(join(tmpdir(), "phase0-attestation-store-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const first = privateKeyToAccount(generatePrivateKey()); + const second = privateKeyToAccount(generatePrivateKey()); + const a = subject(IP_A, first.address); + const b = subject(IP_B, second.address); + const path = resolve(directory, "attestations.jsonl"); + const store = new FileAttestationStore(path, { baseSubjects: [a, b], hooks }); + const base = { + type: "challenge_opened" as const, + eventId: "challenge-1", + sequence: 1, + occurredAt: NOW, + conflictId: deterministicConflictId(a, b), + challengedRegistrationId: a.registrationId, + challengerRegistrationId: b.registrationId, + challengerWallet: b.wallet, + evidenceUris: ["https://example.com/evidence"], + reason: "duplicate_bytes" as const, + statementHash: ARTIFACT_HASH, + signature: "0x00" as `0x${string}`, + }; + const event: ChallengeOpenedEvent = { + ...base, + statementHash: challengeEventStatementHash(base), + signature: await second.signMessage({ message: canonicalChallengeEventStatement({ ...base, statementHash: challengeEventStatementHash(base) }) }), + }; + return { directory, path, store, event, a, b, first }; +} + +test("absent store loads empty and append is newline-terminated and replay-validated", async (t) => { + const f = await fixture(t); + assert.deepEqual(await f.store.load(), []); + assert.equal(await f.store.nextSequence(), 1); + await f.store.append(f.event); + assert.deepEqual(await f.store.load(), [f.event]); + const bytes = await readFile(f.path, "utf8"); + assert.ok(bytes.endsWith("\n")); + assert.equal(bytes.trim().split("\n").length, 1); +}); + +test("sequence, duplicate ID, and malformed trailing bytes fail closed without successful append", async (t) => { + const f = await fixture(t); + await assert.rejects(f.store.append({ ...f.event, sequence: 2 }), /sequence must equal 1/); + await f.store.append(f.event); + const before = await readFile(f.path); + await assert.rejects(f.store.append({ ...f.event, sequence: 2 }), /duplicate attestation event ID/); + assert.deepEqual(await readFile(f.path), before); + await writeFile(f.path, Buffer.concat([before, Buffer.from("{partial")])); + await assert.rejects(f.store.load(), /malformed trailing fragment/); +}); + +test("fabricated repository event cannot append without verifier context", async (t) => { + const f = await fixture(t); + const challenge = { + schemaVersion: 1 as const, + subject: f.a, + repositoryUrl: "https://github.com/example/skill", + artifactCommitSha: "1".repeat(40), + artifactPath: "skills/demo/SKILL.md", + challengePath: "attestations/demo.json", + nonce: `0x${"3".repeat(64)}` as `0x${string}`, + issuedAt: "2026-07-18T10:00:00.000Z", + expiresAt: "2026-07-18T14:00:00.000Z", + }; + const event: RepositoryControlEvent = { + type: "repository_control_verified", + eventId: "repo-1", + sequence: 1, + occurredAt: NOW, + subject: f.a, + challenge, + forgeObservation: { + schemaVersion: 1, + repositoryId: "demo", + repositoryUrl: challenge.repositoryUrl, + trustedRef: "refs/heads/main", + proofCommitSha: "2".repeat(40), + challengeNonce: challenge.nonce, + observedAt: "2026-07-18T11:00:00.000Z", + forgeSignerId: "forge-1", + signature: "fabricated", + }, + statementHash: repositoryStatementHash(challenge), + signature: await f.first.signMessage({ message: canonicalRepositoryStatement(challenge) }), + }; + await assert.rejects(f.store.append(event), /repository verifier context required/); + await assert.rejects(readFile(f.path), /ENOENT/); +}); + +test("only one concurrent writer may hold the append lock", async (t) => { + let entered!: () => void; + const enteredPromise = new Promise((resolveEntered) => { entered = resolveEntered; }); + let release!: () => void; + const releasePromise = new Promise((resolveRelease) => { release = resolveRelease; }); + const f = await fixture(t, { beforeAppendWrite: async () => { entered(); await releasePromise; } }); + const firstAppend = f.store.append(f.event); + await enteredPromise; + const contender = new FileAttestationStore(f.path, { baseSubjects: [f.a, f.b] }); + await assert.rejects(contender.append(f.event), /attestation store locked/); + release(); + await firstAppend; + assert.equal((await f.store.load()).length, 1); +}); + +test("crash-left lock requires exact token and absent PID for explicit recovery", async (t) => { + const f = await fixture(t); + const owner = { + schemaVersion: 1, + pid: 999_999, + token: "0123456789abcdef0123456789abcdef", + targetPath: f.path, + acquiredAt: NOW, + }; + await writeFile(`${f.path}.lock`, `${JSON.stringify(owner)}\n`, { mode: 0o600 }); + await assert.rejects(f.store.load(), /attestation store locked/); + await assert.rejects(f.store.recoverStaleLock({ expectedToken: "f".repeat(32), isProcessAlive: () => false }), /does not match/); + await assert.rejects(f.store.recoverStaleLock({ expectedToken: owner.token, isProcessAlive: () => true }), /still alive/); + await f.store.recoverStaleLock({ expectedToken: owner.token, isProcessAlive: () => false }); + assert.deepEqual(await f.store.load(), []); +}); + +test("writeAll handles short writes and rejects zero or invalid progress", async () => { + const source = Buffer.from("complete-record\n"); + const chunks: Buffer[] = []; + await writeAll({ + async write(buffer, offset, length) { + const count = Math.min(3, length); + chunks.push(Buffer.from(buffer).subarray(offset, offset + count)); + return { bytesWritten: count }; + }, + }, source); + assert.deepEqual(Buffer.concat(chunks), source); + await assert.rejects(writeAll({ async write() { return { bytesWritten: 0 }; } }, source), /made no progress/); + await assert.rejects(writeAll({ async write(_buffer, _offset, length) { return { bytesWritten: length + 1 }; } }, source), /invalid byte count/); +}); From 8ca7c3b7605f1d53b9d95d07bec240b7b353f68e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:22:28 -0400 Subject: [PATCH 076/165] fix: sequence cancelled Invocation receipts --- spikes/internal-invocation-awards/src/engine.mjs | 2 ++ spikes/internal-invocation-awards/test/engine.test.mjs | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/spikes/internal-invocation-awards/src/engine.mjs b/spikes/internal-invocation-awards/src/engine.mjs index c7c4c6a..d1d13e1 100644 --- a/spikes/internal-invocation-awards/src/engine.mjs +++ b/spikes/internal-invocation-awards/src/engine.mjs @@ -460,6 +460,7 @@ export async function cancelInternalAuthorization(input) { revision: invocation.revision + 1, finalizedAt: input.now, releasedAtomic: reservation.reservedAtomic, + receiptSequence: current.nextReceiptSequence, }); return nextState(current, { budget: released.budget, @@ -472,6 +473,7 @@ export async function cancelInternalAuthorization(input) { reason: input.reason, }), ]), + nextReceiptSequence: current.nextReceiptSequence + 1, }); }); const reservation = state.reservations[input.reservationId]; diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs index 8e89d8c..9adbfb2 100644 --- a/spikes/internal-invocation-awards/test/engine.test.mjs +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -369,6 +369,13 @@ test('cancelled reservation makes its signed credential unusable', async () => { now: NOW, }); assert.equal(cancelled.invocation.state, 'cancelled'); + assert.equal(cancelled.invocation.receiptSequence, 1); + assert.equal(cancelled.state.nextReceiptSequence, 2); + assert.deepEqual(cancelled.events.map((event) => event.type), [ + 'budget_released', + 'invocation_cancelled', + ]); + assert.equal(cancelled.state.events.filter((event) => event.type === 'budget_released').length, 1); await assert.rejects(() => executeAuthorizedInvocation({ store: fx.store, quote: q, credential, executor: async () => { throw new Error('must not execute'); }, now: NOW, From 2363050c0ac88e2fa22711da5daffc00b4ba1ce8 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:25:43 -0400 Subject: [PATCH 077/165] feat: make Collar invocation-authoritative --- spikes/pi-wielder/src/collar.mjs | 535 +++++++++++++++--- spikes/pi-wielder/src/invocation-journal.mjs | 20 +- .../pi-wielder/tests/collar-failure.test.mjs | 512 +++++++++++++++++ .../tests/invocation-journal.test.mjs | 11 + 4 files changed, 992 insertions(+), 86 deletions(-) create mode 100644 spikes/pi-wielder/tests/collar-failure.test.mjs diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index 5431a9f..832744c 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -1,97 +1,456 @@ -// collar.mjs — the mock Collar: the single trusted component on the seller side. +// Authoritative seller-side Collar for the Pi-Wielder spike. // -// The Collar is the sole platform-key holder, the x402 resource server, and -// the off-chain meter for ONE hosted Skill: this repo's own -// `.claude/skills/optimizing-claude-code-prompts`. Its contract: -// -// * The skill CONTENT never leaves this process. The Wielder pays for an -// INVOCATION and receives OUTPUT ONLY — artifact scarcity is preserved by -// hosting (ADR-0001), and the x402-settled txHash is the single-use -// execution credential ("no credential, no run", ADR-0003). -// * Every settled invocation is metered into the prototype settlement -// engine (prototype/settlement-engine.mjs), which computes the royalty -// split (creator 100% here) net of the 2.5% protocol fee. The engine is -// the accounting mirror of the on-chain USDC payment: value arrives once -// via EIP-3009, the engine attributes it. -// -// The engine's public economic event is `invoke()` (pay -> mint credential -> -// consume -> settle); its internal `distribute()` does the recursive royalty -// flow-through. `distribute` is not exported, so we drive it through -// `invoke()` and use the returned breakdown — same math, public API. -// -// Engine amounts are kept in ATOMIC USDC (6-decimal integers) because the -// engine rounds to 2 decimals — fine for dollars, lossy for $0.25 micro- -// royalties. 0.25 USDC = 250_000 atomic -> fee 6_250, creator 243_750, exact. +// The append-only Invocation journal owns payment, execution, accounting, and +// signed receipts. A settled payment is never erased by an execution failure; +// ambiguous settlement or execution stays unresolved until a trusted resolver +// advances it. The hosted Skill artifact is read server-side and is not +// directly serialized in responses. +import crypto from 'node:crypto'; import fs from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { Hono } from 'hono'; + import { serve } from '@hono/node-server'; -import { x402Paywall, atomicToUsdc, usdcToAtomic } from './x402-seller.mjs'; +import { Hono } from 'hono'; + +import { allocateExternalGross } from '../../../prototype/atomic-money.mjs'; +import { + APPROVED_LIVE_FACILITATOR_BASE, + createLiveFacilitatorTransport, + createMockFacilitatorTransport, + usdcToAtomic, + x402Paywall, +} from './x402-seller.mjs'; import { - createState, addParty, registerSkill, setRoyalty, invoke, -} from '../../../prototype/settlement-engine.mjs'; + canonicalJson, + createInvocationJournal, +} from './invocation-journal.mjs'; export const SKILL_ID = 'optimizing-claude-code-prompts'; const SKILL_PATH = fileURLToPath( new URL(`../../../.claude/skills/${SKILL_ID}/SKILL.md`, import.meta.url), ); -const DEFAULT_PRICE_USDC = 0.25; +const DEFAULT_PRICE_USDC = '0.25'; +const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']); + +const hash = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; + +const royaltyGraph = Object.freeze({ + [SKILL_ID]: Object.freeze({ + parentIds: Object.freeze([]), + inheritBps: 0, + holders: Object.freeze([{ recipientId: 'creator', bps: 10_000 }]), + }), +}); + +function serializeEntry(entry) { + return { ...entry, amountAtomic: entry.amountAtomic.toString() }; +} + +function serializeCredit(credit) { + return { ...credit, amountAtomic: credit.amountAtomic.toString() }; +} + +function serializeAccounting(result) { + return { + allocationState: 'finalized', + allocationPolicy: result.allocationPolicy, + grossAtomic: result.grossAtomic.toString(), + executionCostAtomic: result.executionCostAtomic.toString(), + settlementCostAtomic: result.settlementCostAtomic.toString(), + protocolFeeAtomic: result.protocolFeeAtomic.toString(), + royaltyPoolAtomic: result.royaltyPoolAtomic.toString(), + refundReserveAtomic: result.refundReserveAtomic.toString(), + holderCredits: result.holderCredits.map(serializeCredit), + ancestorCredits: result.ancestorCredits.map(serializeCredit), + journalEntries: result.journalEntries.map(serializeEntry), + }; +} + +function pendingFailureAccounting(amountAtomic) { + return { + grossAtomic: String(amountAtomic), + allocationState: 'pending_cogs_reconciliation', + holderCredits: [], + ancestorCredits: [], + journalEntries: [{ + category: 'unresolved-execution-accounting', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'hold:execution-accounting-reconciliation', + amountAtomic: String(amountAtomic), + }], + }; +} + +function validTxHash(value) { + return /^0x[0-9a-fA-F]{64}$/.test(String(value ?? '')); +} + +function sameAddress(left, right) { + return /^0x[0-9a-fA-F]{40}$/.test(String(left ?? '')) + && String(left).toLowerCase() === String(right ?? '').toLowerCase(); +} + +export async function chooseFacilitator({ + env = process.env, + createMock = async () => (await import('./facilitator-mock.mjs')).createMockFacilitator(), +} = {}) { + if (env.ALLOW_LIVE_X402 !== '1') { + const app = await createMock(); + return { + transport: createMockFacilitatorTransport((url, init) => app.request(url, init)), + mode: 'mock', + }; + } + if (!env.FACILITATOR_URL) { + throw new Error('ALLOW_LIVE_X402=1 requires an explicit Base Sepolia FACILITATOR_URL'); + } + return { + transport: createLiveFacilitatorTransport(env.FACILITATOR_URL), + mode: 'approved-base-sepolia', + }; +} export function createCollar({ - facilitatorUrl, - payTo = process.env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dEaD', - priceUsdc = Number(process.env.SKILL_PRICE_USDC || DEFAULT_PRICE_USDC), + facilitatorTransport, + payTo = process.env.PAY_TO_ADDRESS || `0x${'d'.repeat(40)}`, + priceUsdc = process.env.SKILL_PRICE_USDC || DEFAULT_PRICE_USDC, mockLlm = process.env.MOCK_LLM === '1', + journal = null, + journalFile = process.env.COLLAR_JOURNAL_FILE || null, + signingKeyFile = process.env.COLLAR_SIGNING_KEY_FILE || null, + receiptSigner = null, + executeSkill = null, + lifecycleFaults = {}, + resolveSettlement = null, + executeRefund = null, } = {}) { - // The platform key: the skill content, loaded once, never serialized out. + if (journal && (journalFile || signingKeyFile || receiptSigner)) { + throw new Error('injected journal cannot be combined with journal/key paths or signer'); + } + if (!journal && Boolean(journalFile) !== Boolean(signingKeyFile)) { + throw new Error('COLLAR_JOURNAL_FILE and COLLAR_SIGNING_KEY_FILE must be set together'); + } + journal ??= createInvocationJournal({ + filePath: journalFile, + signingKeyPath: signingKeyFile, + signer: receiptSigner, + }); + if (facilitatorTransport?.mode === 'live') { + if (!journal.isPersistent) throw new Error('live settlement requires a persistent journal and signing key'); + if (typeof resolveSettlement !== 'function') throw new Error('live settlement requires a trusted settlement resolver'); + if (typeof executeRefund !== 'function') throw new Error('live settlement requires a trusted refund executor'); + } + const settlementResolver = resolveSettlement ?? (async () => ({ settled: false })); + const refundExecutor = executeRefund ?? null; const skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); + const skillVersionHash = hash(skillContent); + const priceAtomic = usdcToAtomic(priceUsdc); + const executor = executeSkill ?? (mockLlm + ? async ({ input }) => ({ output: mockSkillOutput(input) }) + : async ({ input }) => ({ output: await runSkillViaAnthropic(skillContent, input) })); + + const lifecycle = { + async onOffered({ idempotencyKey, requirements, expiresAt }) { + journal.requestInvocation({ + idempotencyKey, + mode: 'external', + skillId: SKILL_ID, + skillVersionHash, + requestHash: requirements.extra.requestHash, + creatorId: 'creator', + beneficiaryId: null, + }); + journal.offerExternalPayment(idempotencyKey, { + quoteId: requirements.extra.quoteId, + amountAtomic: requirements.maxAmountRequired, + currency: 'USDC', + network: requirements.network, + asset: requirements.asset, + payTo: requirements.payTo, + resource: requirements.resource, + requestHash: requirements.extra.requestHash, + requirementsHash: hash(canonicalJson(requirements)), + expiresAt, + requirements, + }); + }, - // --- the off-chain meter: settlement-engine state for this one skill ----- - const state = createState(); // feeBps: 250 (2.5% protocol fee) - addParty(state, { id: 'creator', name: 'Skill creator', role: 'Creator' }); - // The engine debits the wielder's balance as the mirror of the on-chain - // USDC transfer; seed it deep enough for any session. - addParty(state, { id: 'wielder', name: 'Session wallet', role: 'Wielder/Beneficiary', balance: 1e12 }); - registerSkill(state, { id: SKILL_ID, name: SKILL_ID, creatorId: 'creator', price: Number(usdcToAtomic(priceUsdc)), mode: 'marketplace' }); - setRoyalty(state, SKILL_ID, [{ partyId: 'creator', bps: 10000 }]); // creator holds 100% + async loadFrozenOffer({ idempotencyKey }) { + return journal.getByIdempotencyKey(idempotencyKey)?.quote?.requirements ?? null; + }, + + async onSigned({ idempotencyKey, settlementReference, payer, requirements }) { + const existing = journal.getByIdempotencyKey(idempotencyKey); + if (!existing?.quote) throw new Error('paid retry has no prior quoted Invocation'); + if (existing.quote.requirementsHash !== hash(canonicalJson(requirements)) + || existing.quote.requestHash !== requirements.extra.requestHash) { + throw new Error('paid retry does not match the frozen x402 requirements'); + } + const claim = journal.claimExternalPaymentSigned(idempotencyKey, { settlementReference, payer }); + const record = claim.record; + if (TERMINAL.has(record.execution.state)) { + if (!['settled', 'refunded'].includes(record.payment.state) || !record.payment.txHash) { + throw new Error('terminal Invocation does not carry a replayable settled payment'); + } + return { + kind: 'terminal', + paymentState: record.payment.state, + receipt: record.receipt ?? journal.issueReceipt(idempotencyKey), + txHash: record.payment.txHash, + payer: record.payment.payer, + httpStatus: record.execution.httpStatus, + }; + } + if (record.execution.state === 'executing') { + return { kind: 'execution_unresolved', executionAttemptId: record.execution.executionAttemptId }; + } + if (record.payment.state === 'unresolved' || (!claim.claimed && record.payment.state === 'signed')) { + return { kind: 'payment_unresolved', settlementReference: record.payment.settlementReference }; + } + if (record.payment.state === 'settled' && record.execution.state === 'authorized') { + return { + kind: 'settled', + txHash: record.payment.txHash, + payer: record.payment.payer, + }; + } + return null; + }, + + async onSettled({ idempotencyKey, settlementReference, txHash, payer }) { + await lifecycleFaults.beforeSettlementRecorded?.({ idempotencyKey, settlementReference, txHash, payer }); + const record = journal.markExternalPaymentSettled(idempotencyKey, { + settlementReference, + txHash, + payer, + }); + await lifecycleFaults.afterSettlementRecorded?.({ idempotencyKey, record }); + }, + + async onUnresolved({ idempotencyKey, reason }) { + const record = journal.getByIdempotencyKey(idempotencyKey); + if (!record) throw new Error('unresolved payment has no Invocation'); + if (['settled', 'refunded'].includes(record.payment.state)) return record; + if (record.payment.state === 'unresolved') return record; + return journal.markExternalPaymentUnresolved(idempotencyKey, { reason }); + }, + + async onRejected({ idempotencyKey, reason }) { + const record = journal.getByIdempotencyKey(idempotencyKey); + if (!record) throw new Error('rejected payment has no Invocation'); + if (['settled', 'refunded'].includes(record.payment.state)) return record; + if (record.payment.state === 'rejected') return record; + return journal.rejectExternalPayment(idempotencyKey, { reason }); + }, + }; const app = new Hono(); - app.get('/healthz', (c) => c.json({ ok: true, skill: SKILL_ID, priceUsdc })); + app.get('/healthz', (c) => c.json({ + ok: true, + skill: SKILL_ID, + skillVersionHash, + priceAtomic, + currency: 'USDC', + receiptAlgorithm: 'Ed25519', + signingPublicKeyPem: journal.signingPublicKeyPem, + signingKeyId: journal.signingKeyId, + authority: 'collar-invocation-journal', + })); + + app.get('/receipts/by-settlement/:reference', (c) => { + let record; + try { record = journal.getBySettlementReference(c.req.param('reference')); } catch (error) { + return c.json({ error: error.message }, 400); + } + if (!record) return c.json({ error: 'unknown settlement reference' }, 404); + if (!record.receipt) { + return c.json({ + invocationId: record.invocationId, + paymentState: record.payment.state, + executionState: record.execution.state, + }, 202); + } + return c.json({ receipt: record.receipt }); + }); + + app.post('/reconcile/by-settlement/:reference', async (c) => { + const settlementReference = c.req.param('reference').toLowerCase(); + let record; + try { record = journal.getBySettlementReference(settlementReference); } catch (error) { + return c.json({ error: error.message }, 400); + } + if (!record) return c.json({ error: 'unknown settlement reference' }, 404); + if (['settled', 'refunded'].includes(record.payment.state)) { + return c.json({ paymentState: record.payment.state, txHash: record.payment.txHash }); + } + if (record.payment.state !== 'unresolved') { + return c.json({ error: `payment state '${record.payment.state}' is not reconcilable` }, 409); + } + let resolution; + try { + resolution = await settlementResolver({ + settlementReference, + payer: record.payment.payer, + amountAtomic: record.quote.amountAtomic, + network: record.quote.network, + asset: record.quote.asset, + payTo: record.quote.payTo, + }); + } catch (error) { + return c.json({ error: `trusted settlement resolver failed: ${error.message}` }, 502); + } + if (!resolution?.settled) { + return c.json({ paymentState: 'unresolved', settlementReference }, 202); + } + if (String(resolution.settlementReference ?? '').toLowerCase() !== settlementReference + || !sameAddress(resolution.payer, record.payment.payer) + || typeof resolution.amountAtomic !== 'string' + || resolution.amountAtomic !== record.quote.amountAtomic + || !validTxHash(resolution.txHash)) { + return c.json({ error: 'trusted settlement resolver returned a mismatched proof' }, 502); + } + const reconciled = journal.reconcileExternalSettlement({ + settlementReference, + txHash: resolution.txHash, + payer: resolution.payer, + }); + return c.json({ paymentState: reconciled.payment.state, txHash: reconciled.payment.txHash }); + }); + + app.post('/refund/by-settlement/:reference', async (c) => { + const settlementReference = c.req.param('reference').toLowerCase(); + let record; + try { record = journal.getBySettlementReference(settlementReference); } catch (error) { + return c.json({ error: error.message }, 400); + } + if (!record) return c.json({ error: 'unknown settlement reference' }, 404); + if (record.payment.state === 'refunded') { + return c.json({ receipt: record.receipt ?? journal.issueReceipt(record.idempotencyKey) }); + } + if (record.payment.state !== 'settled' + || record.execution.state !== 'failed' + || record.accounting?.allocationState !== 'pending_cogs_reconciliation') { + return c.json({ error: 'refund requires a settled failed full-gross reconciliation hold' }, 409); + } + if (!refundExecutor) return c.json({ error: 'trusted refund executor is not configured' }, 501); + let resolution; + try { + resolution = await refundExecutor({ + invocationId: record.invocationId, + settlementReference, + originalTxHash: record.payment.txHash, + payer: record.payment.payer, + amountAtomic: record.quote.amountAtomic, + network: record.quote.network, + asset: record.quote.asset, + payTo: record.quote.payTo, + }); + } catch (error) { + return c.json({ error: `trusted refund executor failed: ${error.message}` }, 502); + } + if (resolution?.refunded !== true + || String(resolution.settlementReference ?? '').toLowerCase() !== settlementReference + || String(resolution.originalTxHash ?? '').toLowerCase() !== record.payment.txHash + || !sameAddress(resolution.payer, record.payment.payer) + || typeof resolution.amountAtomic !== 'string' + || resolution.amountAtomic !== record.quote.amountAtomic + || typeof resolution.refundReference !== 'string' + || !resolution.refundReference.trim()) { + return c.json({ error: 'trusted refund executor returned a mismatched proof' }, 502); + } + journal.refundExternalPayment(record.idempotencyKey, { + reason: 'trusted full-gross refund confirmed', + refundReference: resolution.refundReference, + refundAmountAtomic: resolution.amountAtomic, + }); + return c.json({ receipt: journal.issueReceipt(record.idempotencyKey) }); + }); app.post( '/invoke/:skillId', - x402Paywall({ price: priceUsdc, payTo, facilitatorUrl, description: `hosted-skill invocation: ${SKILL_ID}` }), + x402Paywall({ + price: priceUsdc, + payTo, + facilitatorTransport, + description: `hosted-skill Invocation: ${SKILL_ID}`, + lifecycle, + }), async (c) => { - if (c.req.param('skillId') !== SKILL_ID) return c.json({ error: `unknown skill '${c.req.param('skillId')}'` }, 404); - const { input } = await c.req.json().catch(() => ({})); - if (!input) return c.json({ error: 'body must be JSON: { "input": "..." }' }, 400); - const payment = c.get('x402'); // { txHash, payer, amountUsdc } from the paywall - - // Execute the skill: SKILL.md is the system prompt, the buyer's input is - // the user turn. Output only ever flows out. - const output = mockLlm ? mockSkillOutput(input) : await runSkillViaAnthropic(skillContent, input); - - // Meter the settled invocation. invoke() re-runs pay -> credential -> - // distribute() inside the engine and returns the royalty breakdown. - const result = invoke(state, SKILL_ID, 'wielder'); - const splits = [ - ...result.breakdown.map((b) => ({ party: b.partyId, amountUSDC: atomicToUsdc(b.amount) })), - { party: 'treasury', amountUSDC: atomicToUsdc(result.fee) }, - ]; + const payment = c.get('x402'); + const key = payment.idempotencyKey; + const claim = journal.startExecution(key); + if (!claim.started) { + return c.json({ + error: 'execution outcome unresolved; trusted executor reconciliation is required', + executionAttemptId: claim.record.execution.executionAttemptId, + }, 503); + } + const executionAttemptId = claim.record.execution.executionAttemptId; + const finishFailure = (failureClass, message, status) => { + journal.finishExecution(key, { + executionAttemptId, + outcome: 'failed', + failureClass, + message, + outcomeHash: null, + httpStatus: status, + accounting: pendingFailureAccounting(payment.amountAtomic), + }); + return c.json({ error: message, receipt: journal.issueReceipt(key) }, status); + }; - return c.json({ - output, // and ONLY the output — never skillContent - receipt: { skillId: SKILL_ID, txHash: payment.txHash, payer: payment.payer, amountUSDC: payment.amountUsdc, splits }, + if (c.req.param('skillId') !== SKILL_ID) { + return finishFailure('UNKNOWN_SKILL', `unknown skill '${c.req.param('skillId')}'`, 404); + } + const body = await c.req.json().catch(() => null); + if (typeof body?.input !== 'string' || !body.input) { + return finishFailure('INVALID_REQUEST', 'body must be JSON: { "input": "..." }', 400); + } + + let execution; + try { + execution = await executor({ + skillId: SKILL_ID, + skillVersionHash, + skillContent, + input: body.input, + executionAttemptId, + }); + } catch (error) { + return finishFailure('UPSTREAM_500', error.message, 500); + } + if (!execution || typeof execution.output !== 'string') { + return finishFailure('INVALID_EXECUTOR_RESULT', 'executor must return { output: string }', 500); + } + await lifecycleFaults.afterExecutorReturned?.({ idempotencyKey: key, executionAttemptId }); + + const allocation = allocateExternalGross({ + grossAtomic: BigInt(payment.amountAtomic), + executionCostAtomic: 0n, + settlementCostAtomic: 0n, + protocolFeeBps: 250, + refundReserveAtomic: 0n, + leafSkillId: SKILL_ID, + skills: royaltyGraph, }); + journal.finishExecution(key, { + executionAttemptId, + outcome: 'succeeded', + failureClass: null, + message: null, + outcomeHash: hash(execution.output), + httpStatus: 200, + accounting: serializeAccounting(allocation), + }); + await lifecycleFaults.afterExecutionFinished?.({ idempotencyKey: key, executionAttemptId }); + return c.json({ output: execution.output, receipt: journal.issueReceipt(key) }); }, ); - return app; + return { app, journal, skillVersionHash }; } -// Canned skill output for MOCK_LLM=1: recognizably an *optimized prompt*, -// recognizably NOT the skill's own text. function mockSkillOutput(input) { return [ `[mock ${SKILL_ID}] Optimized prompt for: "${String(input).slice(0, 120)}"`, @@ -106,37 +465,49 @@ function mockSkillOutput(input) { async function runSkillViaAnthropic(skillContent, input) { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) throw new Error('ANTHROPIC_API_KEY required unless MOCK_LLM=1'); - const res = await fetch('https://api.anthropic.com/v1/messages', { + const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', - headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + }, body: JSON.stringify({ model: 'claude-sonnet-4-6', max_tokens: 2048, - system: skillContent, // the platform key stays server-side + system: skillContent, messages: [{ role: 'user', content: String(input) }], }), }); - if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${await res.text()}`); - const data = await res.json(); - return data.content?.map((b) => b.text ?? '').join('') ?? ''; + if (!response.ok) throw new Error(`Anthropic API ${response.status}: ${await response.text()}`); + const data = await response.json(); + return data.content?.map((block) => block.text ?? '').join('') ?? ''; } -/** Boot helper shared by the standalone script and e2e.mjs. */ -export function startCollar({ port = 0, ...opts } = {}) { - const app = createCollar(opts); +export function startCollar({ port = 0, ...options } = {}) { + const { app, journal, skillVersionHash } = createCollar(options); return new Promise((resolve) => { - const server = serve({ fetch: app.fetch, port }, (info) => { - resolve({ url: `http://127.0.0.1:${info.port}`, port: info.port, close: () => server.close() }); + const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) => { + resolve({ + url: `http://127.0.0.1:${info.port}`, + port: info.port, + journal, + skillVersionHash, + signingPublicKeyPem: journal.signingPublicKeyPem, + signingKeyId: journal.signingKeyId, + close: () => server.close(), + }); }); }); } -// Standalone: `npm run collar` if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const { startMockFacilitator } = await import('./facilitator-mock.mjs'); - const facilitatorUrl = process.env.MOCK_FACILITATOR === '1' - ? (await startMockFacilitator()).url - : (process.env.FACILITATOR_URL || 'https://x402.org/facilitator'); - const { url } = await startCollar({ port: Number(process.env.COLLAR_PORT || 8404), facilitatorUrl }); - console.log(`[collar] hosted skill '${SKILL_ID}' at ${url}/invoke/${SKILL_ID} (facilitator: ${facilitatorUrl})`); + const selected = await chooseFacilitator(); + const { url } = await startCollar({ + port: Number(process.env.COLLAR_PORT || 8404), + facilitatorTransport: selected.transport, + }); + console.log(`[collar] hosted Skill '${SKILL_ID}' at ${url}/invoke/${SKILL_ID} (${selected.mode})`); } + +export { APPROVED_LIVE_FACILITATOR_BASE }; diff --git a/spikes/pi-wielder/src/invocation-journal.mjs b/spikes/pi-wielder/src/invocation-journal.mjs index c372614..1e9b767 100644 --- a/spikes/pi-wielder/src/invocation-journal.mjs +++ b/spikes/pi-wielder/src/invocation-journal.mjs @@ -824,7 +824,7 @@ export function createInvocationJournal({ return copy(records.get(key)); } - function markExternalPaymentSigned(key, input) { + function claimExternalPaymentSigned(key, input) { refreshFromAuthority(); const record = requireRecord(records, key); const reference = canonicalBytes32(input.settlementReference, 'settlementReference'); @@ -833,16 +833,27 @@ export function createInvocationJournal({ if (record.payment.settlementReference !== reference || record.payment.payer !== normalizedPayer) { throw new Error('idempotency key already binds a different signed payment'); } - return copy(record); + return { claimed: false, record: copy(record) }; } if (record.payment.state !== 'offered') { throw new Error(`markExternalPaymentSigned cannot run from payment state '${record.payment.state}'`); } assertUnique(settlementReferences, reference, key, 'settlement reference'); - append('payment.signed', key, { settlementReference: reference, payer: normalizedPayer }); - return copy(records.get(key)); + try { + append('payment.signed', key, { settlementReference: reference, payer: normalizedPayer }); + return { claimed: true, record: copy(records.get(key)) }; + } catch (error) { + if (error.code !== 'JOURNAL_CONFLICT') throw error; + const winner = requireRecord(records, key); + if (winner.payment.settlementReference !== reference || winner.payment.payer !== normalizedPayer) { + throw new Error('idempotency key concurrently bound a different signed payment', { cause: error }); + } + return { claimed: false, record: copy(winner) }; + } } + const markExternalPaymentSigned = (key, input) => claimExternalPaymentSigned(key, input).record; + function markExternalPaymentSettled(key, input) { refreshFromAuthority(); const record = requireRecord(records, key); @@ -1014,6 +1025,7 @@ export function createInvocationJournal({ return Object.freeze({ requestInvocation, offerExternalPayment, + claimExternalPaymentSigned, markExternalPaymentSigned, markExternalPaymentSettled, markExternalPaymentUnresolved, diff --git a/spikes/pi-wielder/tests/collar-failure.test.mjs b/spikes/pi-wielder/tests/collar-failure.test.mjs new file mode 100644 index 0000000..d78f9f4 --- /dev/null +++ b/spikes/pi-wielder/tests/collar-failure.test.mjs @@ -0,0 +1,512 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { chooseFacilitator, createCollar, SKILL_ID } from '../src/collar.mjs'; +import { createMockFacilitator } from '../src/facilitator-mock.mjs'; +import { verifySignedReceipt } from '../src/invocation-journal.mjs'; +import { payingFetch } from '../src/proxy.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; +import { + APPROVED_LIVE_FACILITATOR_BASE, + createLiveFacilitatorTransport, + createMockFacilitatorTransport, +} from '../src/x402-seller.mjs'; + +const invokeUrl = `http://collar.test/invoke/${SKILL_ID}`; +const requestBody = JSON.stringify({ input: 'same bytes' }); + +function mockTransport(fetchImpl = null) { + const facilitator = createMockFacilitator(); + return createMockFacilitatorTransport(fetchImpl ?? ((url, init) => facilitator.request(url, init))); +} + +const trustFor = (collar) => ({ + publicKeyPem: collar.journal.signingPublicKeyPem, + keyId: collar.journal.signingKeyId, +}); + +async function prepareReconciledRetry({ executeSkill, lifecycleFaults = {} }) { + const facilitator = createMockFacilitator(); + let lostSettlement; + const transport = createMockFacilitatorTransport(async (url, init) => { + const response = await facilitator.request(url, init); + if (new URL(url).pathname === '/settle') { + lostSettlement = await response.clone().json(); + throw new Error('injected response loss before execution'); + } + return response; + }); + const collar = createCollar({ + facilitatorTransport: transport, + executeSkill, + lifecycleFaults, + resolveSettlement: async ({ settlementReference, amountAtomic, payer }) => ({ + settled: true, + settlementReference, + amountAtomic, + txHash: lostSettlement.transaction, + payer, + }), + }); + const idempotencyKey = `idem-crash-${crypto.randomUUID()}`; + const first = await payingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey, + fetchImpl: (url, init) => collar.app.request(url, init), + }); + assert.equal(first.res.status, 503); + const reconcile = await collar.app.request( + `http://collar.test/reconcile/by-settlement/${first.settlementReference}`, + { method: 'POST' }, + ); + assert.equal(reconcile.status, 200); + const retry = () => collar.app.request(invokeUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': first.xPayment, + }, + body: requestBody, + }); + return { collar, idempotencyKey, retry }; +} + +async function invokeSettledFailure({ executeRefund = null } = {}) { + const collar = createCollar({ + facilitatorTransport: mockTransport(), + executeSkill: async () => { throw new Error('refund-target provider fault'); }, + executeRefund, + }); + const result = await payingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey: `idem-refund-${crypto.randomUUID()}`, + fetchImpl: (url, init) => collar.app.request(url, init), + }); + assert.equal(result.res.status, 500); + const body = await result.res.json(); + return { collar, result, body }; +} + +test('standalone selection defaults to injected offline mock and live mode is explicit', async () => { + let mockStarts = 0; + const selected = await chooseFacilitator({ + env: {}, + createMock: async () => { mockStarts += 1; return createMockFacilitator(); }, + }); + assert.equal(selected.mode, 'mock'); + assert.equal(selected.transport.mode, 'mock'); + assert.equal(mockStarts, 1); + await assert.rejects(() => chooseFacilitator({ env: { ALLOW_LIVE_X402: '1' } }), /requires/); + const live = await chooseFacilitator({ + env: { ALLOW_LIVE_X402: '1', FACILITATOR_URL: APPROVED_LIVE_FACILITATOR_BASE }, + }); + assert.equal(live.transport.mode, 'live'); +}); + +test('live settlement refuses ephemeral authority and accepts only paired persistent paths', () => { + const live = createLiveFacilitatorTransport(APPROVED_LIVE_FACILITATOR_BASE, async () => { + throw new Error('network must not run'); + }); + assert.throws(() => createCollar({ facilitatorTransport: live, mockLlm: true }), /persistent journal/); + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'collar-live-authority-'))); + assert.throws(() => createCollar({ + facilitatorTransport: live, + mockLlm: true, + journalFile: path.join(directory, 'events.jsonl'), + }), /set together/); + assert.throws(() => createCollar({ + facilitatorTransport: live, + mockLlm: true, + journalFile: path.join(directory, 'events.jsonl'), + signingKeyFile: path.join(directory, 'receipt-key.pem'), + }), /trusted settlement resolver|refund executor/); + const collar = createCollar({ + facilitatorTransport: live, + mockLlm: true, + journalFile: path.join(directory, 'events.jsonl'), + signingKeyFile: path.join(directory, 'receipt-key.pem'), + resolveSettlement: async () => ({ settled: false }), + executeRefund: async () => ({ refunded: false }), + }); + assert.equal(collar.journal.isPersistent, true); +}); + +test('settled-then-500 stays authoritative, full-gross held, and exact replay preserves 500', async () => { + const facilitator = createMockFacilitator(); + let settleCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + if (new URL(url).pathname === '/settle') settleCalls += 1; + return facilitator.request(url, init); + }); + const collar = createCollar({ + facilitatorTransport: transport, + executeSkill: async () => { executions += 1; throw new Error('injected provider fault'); }, + }); + const account = throwawayAccount(); + const idempotencyKey = 'idem-settled-failure'; + const result = await payingFetch(account, invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey, + fetchImpl: (url, init) => collar.app.request(url, init), + }); + assert.equal(result.res.status, 500); + const body = await result.res.json(); + assert.equal(verifySignedReceipt(body.receipt, trustFor(collar)), true); + assert.equal(body.receipt.receipt.payment.state, 'settled'); + assert.match(body.receipt.receipt.payment.txHash, /^0x[0-9a-f]{64}$/); + assert.equal(body.receipt.receipt.execution.state, 'failed'); + assert.equal(body.receipt.receipt.execution.httpStatus, 500); + assert.deepEqual(body.receipt.receipt.accounting.journalEntries, [{ + category: 'unresolved-execution-accounting', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'hold:execution-accounting-reconciliation', + amountAtomic: '250000', + }]); + + const eventCount = collar.journal.events.length; + const lookup = await collar.app.request( + `http://collar.test/receipts/by-settlement/${result.settlementReference}`, + ); + assert.equal(lookup.status, 200); + assert.deepEqual((await lookup.json()).receipt, body.receipt); + assert.equal(collar.journal.events.length, eventCount); + + const replay = await collar.app.request(invokeUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': result.xPayment, + }, + body: requestBody, + }); + assert.equal(replay.status, 500); + const replayBody = await replay.json(); + assert.equal(replayBody.replayed, true); + assert.deepEqual(replayBody.receipt, body.receipt); + assert.equal(executions, 1); + assert.equal(settleCalls, 1); +}); + +test('response-loss reconciliation advances once and exact retry never duplicates debit or execution', async () => { + const facilitator = createMockFacilitator(); + let lostSettlement = null; + let settleCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + const response = await facilitator.request(url, init); + if (new URL(url).pathname === '/settle') { + settleCalls += 1; + lostSettlement = await response.clone().json(); + throw new Error('injected lost facilitator response'); + } + return response; + }); + const collar = createCollar({ + facilitatorTransport: transport, + resolveSettlement: async ({ settlementReference, amountAtomic, payer }) => ({ + settled: true, + settlementReference, + amountAtomic, + txHash: lostSettlement.transaction, + payer, + }), + executeSkill: async ({ input }) => { executions += 1; return { output: `executed ${input}` }; }, + }); + const idempotencyKey = 'idem-response-loss'; + const first = await payingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey, + fetchImpl: (url, init) => collar.app.request(url, init), + }); + assert.equal(first.res.status, 503); + assert.equal(collar.journal.getBySettlementReference(first.settlementReference).payment.state, 'unresolved'); + + const retryRequest = (body = requestBody) => collar.app.request(invokeUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': first.xPayment, + }, + body, + }); + assert.equal((await retryRequest()).status, 503); + assert.equal(settleCalls, 1); + const reconcile = await collar.app.request( + `http://collar.test/reconcile/by-settlement/${first.settlementReference}`, + { method: 'POST' }, + ); + assert.equal(reconcile.status, 200); + assert.equal((await reconcile.json()).txHash, lostSettlement.transaction); + const completed = await retryRequest(); + assert.equal(completed.status, 200); + const completedBody = await completed.json(); + assert.equal(verifySignedReceipt(completedBody.receipt, trustFor(collar)), true); + const exact = await retryRequest(); + assert.equal(exact.status, 200); + assert.equal((await exact.json()).replayed, true); + assert.equal((await retryRequest(JSON.stringify({ input: 'different bytes' }))).status, 409); + assert.equal(executions, 1); + assert.equal(settleCalls, 1); + assert.equal(collar.journal.events.filter((event) => event.type === 'payment.settled').length, 1); + assert.equal(collar.journal.events.filter((event) => event.type === 'execution.started').length, 1); +}); + +test('settlement success followed by journal fault is persisted unresolved before retry', async () => { + const facilitator = createMockFacilitator(); + let settleCalls = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + if (new URL(url).pathname === '/settle') settleCalls += 1; + return facilitator.request(url, init); + }); + const collar = createCollar({ + facilitatorTransport: transport, + lifecycleFaults: { + beforeSettlementRecorded: async () => { throw new Error('injected append boundary fault'); }, + }, + executeSkill: async () => { throw new Error('must not execute'); }, + }); + const first = await payingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey: 'idem-post-settle-collar', + fetchImpl: (url, init) => collar.app.request(url, init), + }); + assert.equal(first.res.status, 503); + assert.equal(collar.journal.getBySettlementReference(first.settlementReference).payment.state, 'unresolved'); + const retry = await collar.app.request(invokeUrl, { + method: 'POST', + headers: { 'Idempotency-Key': first.idempotencyKey, 'X-PAYMENT': first.xPayment }, + body: requestBody, + }); + assert.equal(retry.status, 503); + assert.equal(settleCalls, 1); +}); + +test('crash after the provider returns leaves one unresolved attempt and never calls the provider again', async () => { + let executions = 0; + const prepared = await prepareReconciledRetry({ + executeSkill: async () => { + executions += 1; + return { output: 'completed but not journaled' }; + }, + lifecycleFaults: { + afterExecutorReturned: async () => { throw new Error('crash after provider return'); }, + }, + }); + assert.equal((await prepared.retry()).status, 500); + assert.equal(executions, 1); + assert.equal((await prepared.retry()).status, 503); + assert.equal(executions, 1); + const record = prepared.collar.journal.getByIdempotencyKey(prepared.idempotencyKey); + assert.equal(record.execution.state, 'executing'); + assert.match(record.execution.executionAttemptId, /^attempt:/); +}); + +test('crash after finish but before receipt issuance replays without another provider call', async () => { + let executions = 0; + let crash = true; + const prepared = await prepareReconciledRetry({ + executeSkill: async () => { + executions += 1; + return { output: 'journaled output' }; + }, + lifecycleFaults: { + afterExecutionFinished: async () => { + if (crash) { + crash = false; + throw new Error('crash before receipt append'); + } + }, + }, + }); + assert.equal((await prepared.retry()).status, 500); + assert.equal(executions, 1); + assert.equal( + prepared.collar.journal.getByIdempotencyKey(prepared.idempotencyKey).receipt, + null, + ); + const replay = await prepared.retry(); + assert.equal(replay.status, 200); + const body = await replay.json(); + assert.equal(body.replayed, true); + assert.equal(executions, 1); + assert.equal(verifySignedReceipt(body.receipt, trustFor(prepared.collar)), true); +}); + +test('overlapping paid retries atomically claim one execution attempt', async () => { + let executions = 0; + let releaseExecution; + let announceStarted; + const started = new Promise((resolve) => { announceStarted = resolve; }); + const gate = new Promise((resolve) => { releaseExecution = resolve; }); + const prepared = await prepareReconciledRetry({ + executeSkill: async ({ executionAttemptId }) => { + executions += 1; + assert.match(executionAttemptId, /^attempt:/); + announceStarted(); + await gate; + return { output: 'one output' }; + }, + }); + const winner = prepared.retry(); + await started; + const overlap = await prepared.retry(); + assert.equal(overlap.status, 503); + assert.equal(executions, 1); + releaseExecution(); + assert.equal((await winner).status, 200); + assert.equal(executions, 1); + assert.equal( + prepared.collar.journal.events.filter((event) => event.type === 'execution.started').length, + 1, + ); +}); + +test('refund endpoint ignores client proof and fails closed without a trusted executor', async () => { + const { collar, result, body } = await invokeSettledFailure(); + const before = collar.journal.events.length; + const response = await collar.app.request( + `http://collar.test/refund/by-settlement/${result.settlementReference}`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + refunded: true, + payer: body.receipt.receipt.payment.payer, + amountAtomic: '250000', + refundReference: 'client-forged-proof', + }), + }, + ); + assert.equal(response.status, 501); + assert.equal(collar.journal.events.length, before); + assert.deepEqual( + collar.journal.getBySettlementReference(result.settlementReference).receipt, + body.receipt, + ); +}); + +test('trusted refund requires exact journal-bound proof and issues one signed revision', async () => { + let executorResult = null; + const calls = []; + const { collar, result, body } = await invokeSettledFailure({ + executeRefund: async (request) => { + calls.push(structuredClone(request)); + return executorResult; + }, + }); + const original = structuredClone(body.receipt); + const payment = original.receipt.payment; + const endpoint = `http://collar.test/refund/by-settlement/${result.settlementReference}`; + const stateBefore = () => collar.journal.getBySettlementReference(result.settlementReference); + const eventCount = collar.journal.events.length; + + const mismatches = [ + null, + { + refunded: true, + settlementReference: `0x${'f'.repeat(64)}`, + originalTxHash: payment.txHash, + payer: payment.payer, + amountAtomic: '250000', + refundReference: 'refund-wrong-reference', + }, + { + refunded: true, + settlementReference: payment.settlementReference, + originalTxHash: payment.txHash, + payer: `0x${'a'.repeat(40)}`, + amountAtomic: '250000', + refundReference: 'refund-wrong-payer', + }, + { + refunded: true, + settlementReference: payment.settlementReference, + originalTxHash: payment.txHash, + payer: payment.payer, + amountAtomic: '249999', + refundReference: 'refund-partial', + }, + { + refunded: true, + settlementReference: payment.settlementReference, + originalTxHash: `0x${'e'.repeat(64)}`, + payer: payment.payer, + amountAtomic: '250000', + refundReference: 'refund-wrong-original-tx', + }, + ]; + for (const mismatch of mismatches) { + executorResult = mismatch; + const rejected = await collar.app.request(endpoint, { method: 'POST' }); + assert.equal(rejected.status, 502); + assert.equal(collar.journal.events.length, eventCount); + assert.equal(stateBefore().payment.state, 'settled'); + assert.deepEqual(stateBefore().receipt, original); + } + + assert.deepEqual(calls[0], { + invocationId: original.receipt.invocationId, + settlementReference: payment.settlementReference, + originalTxHash: payment.txHash, + payer: payment.payer, + amountAtomic: '250000', + network: original.receipt.quote.network, + asset: original.receipt.quote.asset, + payTo: original.receipt.quote.payTo, + }); + executorResult = { + refunded: true, + settlementReference: payment.settlementReference, + originalTxHash: payment.txHash, + payer: payment.payer, + amountAtomic: '250000', + refundReference: 'trusted-refund-0001', + }; + const refunded = await collar.app.request(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ refundReference: 'client-must-not-win' }), + }); + assert.equal(refunded.status, 200); + const revised = (await refunded.json()).receipt; + assert.equal(verifySignedReceipt(revised, trustFor(collar)), true); + assert.equal(revised.receipt.revision, 2); + assert.equal(revised.receipt.supersedesReceiptHash, original.receiptHash); + assert.equal(revised.receipt.payment.state, 'refunded'); + assert.equal(revised.receipt.payment.refundReference, 'trusted-refund-0001'); + assert.equal(revised.receipt.payment.refundAmountAtomic, '250000'); + assert.deepEqual(revised.receipt.payment.refundAccounting.reversalEntries, [ + { + category: 'refund-reverse-reconciliation-hold', + debitAccountId: 'hold:execution-accounting-reconciliation', + creditAccountId: 'wielder:external-gross', + amountAtomic: '250000', + }, + { + category: 'refund-disbursement', + debitAccountId: 'wielder:external-gross', + creditAccountId: `refund:${payment.payer}`, + amountAtomic: '250000', + }, + ]); + assert.deepEqual(original, body.receipt); + + const callCount = calls.length; + const replay = await collar.app.request(endpoint, { method: 'POST' }); + assert.equal(replay.status, 200); + assert.deepEqual((await replay.json()).receipt, revised); + assert.equal(calls.length, callCount); +}); diff --git a/spikes/pi-wielder/tests/invocation-journal.test.mjs b/spikes/pi-wielder/tests/invocation-journal.test.mjs index 69b4263..185e750 100644 --- a/spikes/pi-wielder/tests/invocation-journal.test.mjs +++ b/spikes/pi-wielder/tests/invocation-journal.test.mjs @@ -183,6 +183,17 @@ test('settlement and transaction indexes canonicalize and reject collisions', () }), /settlement reference already binds/); }); +test('payment authorization claim distinguishes the first signer from exact retries', () => { + const journal = fixture(); + offer(journal); + const first = journal.claimExternalPaymentSigned(declaration.idempotencyKey, { settlementReference, payer }); + const retry = journal.claimExternalPaymentSigned(declaration.idempotencyKey, { settlementReference, payer }); + assert.equal(first.claimed, true); + assert.equal(retry.claimed, false); + assert.equal(retry.record.payment.state, 'signed'); + assert.equal(journal.events.filter((event) => event.type === 'payment.signed').length, 1); +}); + function temporaryAuthority(prefix = 'collar-journal-') { const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); return { From 91bf2ac66daa7afb75e91728ee0d6f3a455bdd1d Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:26:09 -0400 Subject: [PATCH 078/165] spike: sign internal award receipts and statements --- .../src/statements.mjs | 671 ++++++++++++++++++ .../test/statements.test.mjs | 376 ++++++++++ 2 files changed, 1047 insertions(+) create mode 100644 spikes/internal-invocation-awards/src/statements.mjs create mode 100644 spikes/internal-invocation-awards/test/statements.test.mjs diff --git a/spikes/internal-invocation-awards/src/statements.mjs b/spikes/internal-invocation-awards/src/statements.mjs new file mode 100644 index 0000000..3fe0f22 --- /dev/null +++ b/spikes/internal-invocation-awards/src/statements.mjs @@ -0,0 +1,671 @@ +import { + createHash, + sign as cryptoSign, + verify as cryptoVerify, +} from 'node:crypto'; + +import { + cloneFrozen, + deepFreeze, + fromAtomic, + parseUtc, + requireExactKeys, + toAtomic, +} from './schema.mjs'; + +const JOURNAL_ENTRY_KEYS = [ + 'category', 'debitAccountId', 'creditAccountId', 'amountAtomic', +]; +const RECEIPT_KEYS = [ + 'schemaVersion', 'receiptId', 'sequence', 'receiptType', 'invocationId', + 'reservationId', 'employerId', 'creatorId', 'skillId', 'skillVersionHash', + 'policyId', 'policyVersion', 'period', 'currency', 'atomicScale', + 'invocationState', 'reservationState', 'executionAttemptId', 'reservedAtomic', + 'consumedAtomic', 'releasedAtomic', 'heldReservationAtomic', + 'executionCostStatus', 'executionCostAtomic', 'outputHash', 'failureClass', + 'unresolvedReason', 'protocolFeeAtomic', 'refundReserveAtomic', + 'invocationAwardAtomic', 'awardState', 'externalSettlementHash', + 'externalRoyaltyCreditsAtomic', 'employerSelfCreditAtomic', 'journalEntries', + 'occurredAt', 'receiptSignerId', +]; +const SIGNED_RECEIPT_KEYS = [...RECEIPT_KEYS, 'signature']; +const PAYMENT_KEYS = ['paymentId', 'amountAtomic', 'paidAt', 'railReference']; +const ADVANCE_KEYS = ['advanceId', 'receiptHash', 'amountAtomic', 'advancedAt']; +const REVERSAL_KEYS = [ + 'reversalId', 'receiptHash', 'amountAtomic', 'balanceEffect', 'reason', 'occurredAt', +]; +const STATEMENT_KEYS = [ + 'schemaVersion', 'statementId', 'employerId', 'creatorId', 'period', 'currency', + 'atomicScale', 'openingPayableAtomic', 'firstReceiptSequence', + 'lastReceiptSequence', 'receiptHashes', 'receiptMerkleRoot', + 'reservationTotalAtomic', 'releaseTotalAtomic', 'chargeTotalAtomic', + 'earnedAwardTotalAtomic', 'payableAdvances', 'payableAdvanceTotalAtomic', + 'reversals', 'reversalTotalAtomic', 'payableReversalTotalAtomic', 'payments', + 'paymentTotalAtomic', 'closingPayableAtomic', 'statementSignerId', +]; +const SIGNED_STATEMENT_KEYS = [...STATEMENT_KEYS, 'signature']; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; + +function ordered(source, keys) { + return Object.fromEntries(keys.map((key) => [key, source[key]])); +} + +function canonicalBytes(source, keys) { + return new TextEncoder().encode(JSON.stringify(ordered(source, keys))); +} + +function requireString(value, label) { + if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be non-empty`); +} + +function decodeSignature(value, label) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + throw new Error(`${label} signature must be canonical base64`); + } + const decoded = Buffer.from(value, 'base64'); + if (decoded.length !== 64 || decoded.toString('base64') !== value) { + throw new Error(`${label} signature must be a 64-byte Ed25519 signature`); + } + return decoded; +} + +function trustedKey(map, keyId, label) { + if (map === null || typeof map !== 'object' || Array.isArray(map)) { + throw new Error(`${label} trust map must be an object`); + } + const key = map[keyId]; + if (typeof key !== 'string' || key.length === 0) { + throw new Error(`${label} key ID ${keyId} is not trusted`); + } + return key; +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest(); +} + +function taggedHash(bytes) { + return `sha256:${sha256(bytes).toString('hex')}`; +} + +function validateJournalEntries(entries) { + if (!Array.isArray(entries)) throw new Error('receipt journalEntries must be an array'); + return entries.map((entry, index) => { + requireExactKeys(entry, JOURNAL_ENTRY_KEYS, `journal entry ${index}`); + for (const key of ['category', 'debitAccountId', 'creditAccountId']) { + requireString(entry[key], `journal entry ${key}`); + } + toAtomic(entry.amountAtomic); + if (entry.debitAccountId !== 'employer:invocation-gross') { + throw new Error('internal journal entry must debit employer:invocation-gross'); + } + return cloneFrozen(entry); + }); +} + +function validateReceiptPayload(input) { + requireExactKeys(input, RECEIPT_KEYS, 'receipt'); + if (input.schemaVersion !== 1) throw new Error('receipt schemaVersion must equal 1'); + for (const key of [ + 'receiptId', 'receiptType', 'invocationId', 'reservationId', 'employerId', + 'creatorId', 'skillId', 'policyId', 'period', 'currency', 'invocationState', + 'reservationState', 'receiptSignerId', + ]) requireString(input[key], key); + if (!Number.isSafeInteger(input.sequence) || input.sequence < 1) { + throw new Error('receipt sequence must be a positive integer'); + } + if (!Number.isSafeInteger(input.policyVersion) || input.policyVersion < 1) { + throw new Error('receipt policyVersion must be a positive integer'); + } + if (!Number.isSafeInteger(input.atomicScale) || input.atomicScale < 0 || input.atomicScale > 18) { + throw new Error('receipt atomicScale must be an integer from 0 through 18'); + } + if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(input.period)) throw new Error('receipt period must be YYYY-MM'); + if (!SHA256_PATTERN.test(input.skillVersionHash)) throw new Error('receipt Skill hash is invalid'); + parseUtc(input.occurredAt, 'receipt occurredAt'); + for (const key of [ + 'reservedAtomic', 'consumedAtomic', 'releasedAtomic', 'heldReservationAtomic', + 'protocolFeeAtomic', 'refundReserveAtomic', 'invocationAwardAtomic', + 'externalRoyaltyCreditsAtomic', 'employerSelfCreditAtomic', + ]) toAtomic(input[key]); + if (input.executionCostAtomic !== null) toAtomic(input.executionCostAtomic); + if (input.externalSettlementHash !== null) { + throw new Error('internal Invocation receipt cannot contain an external settlement hash'); + } + if (input.externalRoyaltyCreditsAtomic !== '0' || input.employerSelfCreditAtomic !== '0') { + throw new Error('internal Invocation receipt cannot create external or employer-self credits'); + } + const journalEntries = validateJournalEntries(input.journalEntries); + const reserved = toAtomic(input.reservedAtomic); + const consumed = toAtomic(input.consumedAtomic); + const released = toAtomic(input.releasedAtomic); + const held = toAtomic(input.heldReservationAtomic); + if (reserved !== consumed + released + held) { + throw new Error('receipt reserved amount must equal consumed, released, and held amounts'); + } + + if (input.invocationState === 'succeeded') { + if (input.receiptType !== 'internal_invocation_finalized' + || input.reservationState !== 'consumed' + || input.executionCostStatus !== 'known' + || input.executionCostAtomic === null + || !SHA256_PATTERN.test(input.outputHash) + || input.failureClass !== null + || input.unresolvedReason !== null + || !['earned', 'vesting_pending', 'payable', 'paid'].includes(input.awardState)) { + throw new Error('invalid successful Invocation receipt state'); + } + const componentTotal = toAtomic(input.executionCostAtomic) + + toAtomic(input.protocolFeeAtomic) + + toAtomic(input.refundReserveAtomic) + + toAtomic(input.invocationAwardAtomic); + if (componentTotal !== consumed) throw new Error('receipt consumedAtomic does not equal components'); + const journalTotal = journalEntries.reduce((sum, entry) => sum + toAtomic(entry.amountAtomic), 0n); + if (journalTotal !== consumed || journalEntries.length !== 4) { + throw new Error('receipt journal entries do not conserve consumed gross'); + } + } else if (input.invocationState === 'failed') { + if (input.receiptType !== 'internal_invocation_finalized' + || input.reservationState !== 'released' + || input.executionCostStatus !== 'known' + || input.executionCostAtomic === null + || input.outputHash !== null + || typeof input.failureClass !== 'string' + || input.unresolvedReason !== null + || input.protocolFeeAtomic !== '0' + || input.refundReserveAtomic !== '0' + || input.invocationAwardAtomic !== '0' + || input.awardState !== null + || journalEntries.length !== 0 + || consumed !== toAtomic(input.executionCostAtomic)) { + throw new Error('invalid failed Invocation receipt state'); + } + } else if (input.invocationState === 'unresolved') { + if (input.receiptType !== 'internal_invocation_finalized' + || input.reservationState !== 'held_unresolved' + || input.executionCostStatus !== 'unresolved' + || input.executionCostAtomic !== null + || input.outputHash !== null + || input.failureClass !== null + || typeof input.unresolvedReason !== 'string' + || input.protocolFeeAtomic !== '0' + || input.refundReserveAtomic !== '0' + || input.invocationAwardAtomic !== '0' + || input.awardState !== null + || consumed !== 0n || released !== 0n || held !== reserved + || journalEntries.length !== 0) { + throw new Error('invalid unresolved Invocation receipt state'); + } + } else if (input.invocationState === 'cancelled') { + if (input.receiptType !== 'internal_invocation_cancelled' + || input.reservationState !== 'released' + || input.executionAttemptId !== null + || input.executionCostStatus !== null + || input.executionCostAtomic !== null + || input.outputHash !== null + || input.failureClass !== null + || input.unresolvedReason !== null + || input.protocolFeeAtomic !== '0' + || input.refundReserveAtomic !== '0' + || input.invocationAwardAtomic !== '0' + || input.awardState !== null + || consumed !== 0n || released !== reserved || held !== 0n + || journalEntries.length !== 0) { + throw new Error('invalid cancelled Invocation receipt state'); + } + } else { + throw new Error('unsupported terminal Invocation receipt state'); + } + if (input.invocationState !== 'cancelled' + && (typeof input.executionAttemptId !== 'string' || input.executionAttemptId.length === 0)) { + throw new Error('finalized receipt requires executionAttemptId'); + } + return cloneFrozen({ ...input, journalEntries }); +} + +export function buildInvocationReceipt({ + invocation, + reservation, + award, + employerId, + receiptSignerId, +}) { + if (!invocation || !reservation || invocation.reservationId !== reservation.reservationId + || invocation.invocationId !== reservation.quote.invocationId) { + throw new Error('Invocation and reservation bindings do not match'); + } + if (invocation.beneficiaryId !== employerId) throw new Error('receipt employer does not match Beneficiary'); + const success = invocation.state === 'succeeded'; + const failed = invocation.state === 'failed'; + const unresolved = invocation.state === 'unresolved'; + const cancelled = invocation.state === 'cancelled'; + if (!success && !failed && !unresolved && !cancelled) { + throw new Error('receipt requires a terminal Invocation'); + } + if (success && (!award || award.awardId !== invocation.awardId + || award.amountAtomic !== invocation.invocationAwardAtomic)) { + throw new Error('successful Invocation receipt requires its exact award'); + } + if (!success && award !== null) throw new Error('non-success receipt cannot include an award'); + let consumedAtomic = '0'; + if (success) { + consumedAtomic = fromAtomic( + toAtomic(invocation.executionCostAtomic) + + toAtomic(invocation.protocolFeeAtomic) + + toAtomic(invocation.refundReserveAtomic) + + toAtomic(invocation.invocationAwardAtomic), + ); + } else if (failed) { + consumedAtomic = invocation.executionCostAtomic; + } + return validateReceiptPayload({ + schemaVersion: 1, + receiptId: `receipt-${invocation.invocationId}`, + sequence: invocation.receiptSequence, + receiptType: cancelled ? 'internal_invocation_cancelled' : 'internal_invocation_finalized', + invocationId: invocation.invocationId, + reservationId: reservation.reservationId, + employerId, + creatorId: invocation.creatorId, + skillId: invocation.skillId, + skillVersionHash: invocation.skillVersionHash, + policyId: invocation.policyId, + policyVersion: invocation.policyVersion, + period: invocation.period, + currency: invocation.currency, + atomicScale: invocation.atomicScale, + invocationState: invocation.state, + reservationState: reservation.state, + executionAttemptId: invocation.executionAttemptId, + reservedAtomic: reservation.reservedAtomic, + consumedAtomic, + releasedAtomic: invocation.releasedAtomic, + heldReservationAtomic: invocation.heldReservationAtomic, + executionCostStatus: invocation.executionCostStatus, + executionCostAtomic: invocation.executionCostAtomic, + outputHash: invocation.outputHash, + failureClass: invocation.failureClass, + unresolvedReason: invocation.unresolvedReason, + protocolFeeAtomic: invocation.protocolFeeAtomic, + refundReserveAtomic: invocation.refundReserveAtomic, + invocationAwardAtomic: invocation.invocationAwardAtomic, + awardState: award?.state ?? null, + externalSettlementHash: null, + externalRoyaltyCreditsAtomic: invocation.externalRoyaltyCreditsAtomic, + employerSelfCreditAtomic: invocation.employerSelfCreditAtomic, + journalEntries: invocation.journalEntries, + occurredAt: invocation.finalizedAt, + receiptSignerId, + }); +} + +export function canonicalReceiptBytes(receipt) { + const validated = validateReceiptPayload(receipt); + return canonicalBytes(validated, RECEIPT_KEYS); +} + +export function signReceipt(receipt, privateKey) { + const validated = validateReceiptPayload(receipt); + return cloneFrozen({ + ...validated, + signature: cryptoSign(null, canonicalReceiptBytes(validated), privateKey).toString('base64'), + }); +} + +function validateSignedReceiptShape(signedReceipt) { + requireExactKeys(signedReceipt, SIGNED_RECEIPT_KEYS, 'signed receipt'); + const receipt = validateReceiptPayload(ordered(signedReceipt, RECEIPT_KEYS)); + decodeSignature(signedReceipt.signature, 'receipt'); + return receipt; +} + +export function verifyReceipt(signedReceipt, { trustedReceiptSigners }) { + const receipt = validateSignedReceiptShape(signedReceipt); + const key = trustedKey(trustedReceiptSigners, receipt.receiptSignerId, 'receipt signer'); + if (!cryptoVerify( + null, + canonicalReceiptBytes(receipt), + key, + decodeSignature(signedReceipt.signature, 'receipt'), + )) throw new Error('receipt signature verification failed'); + return receipt; +} + +function canonicalSignedReceiptBytes(signedReceipt) { + validateSignedReceiptShape(signedReceipt); + return canonicalBytes(signedReceipt, SIGNED_RECEIPT_KEYS); +} + +export function receiptHash(signedReceipt) { + return taggedHash(canonicalSignedReceiptBytes(signedReceipt)); +} + +export function receiptMerkleRoot(signedReceipts) { + if (!Array.isArray(signedReceipts)) throw new Error('signedReceipts must be an array'); + if (signedReceipts.length === 0) { + return taggedHash(Buffer.from('internal-invocation-awards:receipt-merkle:v1:empty')); + } + let level = signedReceipts.map((receipt) => { + const digest = Buffer.from(receiptHash(receipt).slice(7), 'hex'); + return sha256(Buffer.concat([ + Buffer.from('internal-invocation-awards:receipt-merkle:v1:leaf\0'), + digest, + ])); + }); + while (level.length > 1) { + const next = []; + for (let index = 0; index < level.length; index += 2) { + const left = level[index]; + const right = level[index + 1] ?? left; + next.push(sha256(Buffer.concat([ + Buffer.from('internal-invocation-awards:receipt-merkle:v1:node\0'), + left, + right, + ]))); + } + level = next; + } + return `sha256:${level[0].toString('hex')}`; +} + +function uniqueSorted(items, idKey, label, validator) { + if (!Array.isArray(items)) throw new Error(`${label} must be an array`); + const result = items.map((item, index) => validator(item, index)); + result.sort((left, right) => left[idKey].localeCompare(right[idKey])); + const seen = new Set(); + for (const item of result) { + if (seen.has(item[idKey])) throw new Error(`duplicate ${label} ID ${item[idKey]}`); + seen.add(item[idKey]); + } + return result; +} + +function validatePayment(payment, index) { + requireExactKeys(payment, PAYMENT_KEYS, `payment ${index}`); + requireString(payment.paymentId, 'paymentId'); + toAtomic(payment.amountAtomic); + parseUtc(payment.paidAt, 'payment paidAt'); + requireString(payment.railReference, 'payment railReference'); + return cloneFrozen(payment); +} + +function validateAdvance(advance, index) { + requireExactKeys(advance, ADVANCE_KEYS, `payable advance ${index}`); + requireString(advance.advanceId, 'advanceId'); + if (!SHA256_PATTERN.test(advance.receiptHash)) throw new Error('advance receiptHash is invalid'); + toAtomic(advance.amountAtomic); + parseUtc(advance.advancedAt, 'advance advancedAt'); + return cloneFrozen(advance); +} + +function validateReversal(reversal, index) { + requireExactKeys(reversal, REVERSAL_KEYS, `reversal ${index}`); + requireString(reversal.reversalId, 'reversalId'); + if (!SHA256_PATTERN.test(reversal.receiptHash)) throw new Error('reversal receiptHash is invalid'); + toAtomic(reversal.amountAtomic); + if (!['earned_only', 'payable'].includes(reversal.balanceEffect)) { + throw new Error('reversal balanceEffect must be earned_only or payable'); + } + requireString(reversal.reason, 'reversal reason'); + parseUtc(reversal.occurredAt, 'reversal occurredAt'); + return cloneFrozen(reversal); +} + +function statementReceiptRows(receipts, identity) { + if (!Array.isArray(receipts)) throw new Error('receipts must be an array'); + const rows = receipts.map((signed) => ({ signed, receipt: validateSignedReceiptShape(signed) })); + rows.sort((left, right) => left.receipt.sequence - right.receipt.sequence); + const seenIds = new Set(); + const seenHashes = new Set(); + let expected = rows.length === 0 ? null : rows[0].receipt.sequence; + for (const row of rows) { + if (row.receipt.sequence !== expected) { + throw new Error(`statement sequence gap: expected ${expected}, received ${row.receipt.sequence}`); + } + expected += 1; + if (seenIds.has(row.receipt.receiptId)) throw new Error(`duplicate receipt ID ${row.receipt.receiptId}`); + seenIds.add(row.receipt.receiptId); + const hash = receiptHash(row.signed); + if (seenHashes.has(hash)) throw new Error(`duplicate receipt hash ${hash}`); + seenHashes.add(hash); + for (const [field, expectedValue] of Object.entries(identity)) { + if (row.receipt[field] !== expectedValue) { + throw new Error(`receipt ${field} does not match statement`); + } + } + } + return rows; +} + +export function buildStatement({ + statementId, + employerId, + creatorId, + period, + currency, + atomicScale, + openingPayableAtomic, + receipts, + payableAdvances, + reversals, + payments, + statementSignerId, +}) { + for (const [value, label] of [ + [statementId, 'statementId'], [employerId, 'employerId'], [creatorId, 'creatorId'], + [currency, 'currency'], [statementSignerId, 'statementSignerId'], + ]) requireString(value, label); + if (typeof period !== 'string' || !/^\d{4}-(0[1-9]|1[0-2])$/.test(period)) { + throw new Error('statement period must be YYYY-MM'); + } + if (!Number.isSafeInteger(atomicScale) || atomicScale < 0 || atomicScale > 18) { + throw new Error('statement atomicScale must be an integer from 0 through 18'); + } + const opening = toAtomic(openingPayableAtomic); + const rows = statementReceiptRows(receipts, { + employerId, creatorId, period, currency, atomicScale, + }); + const sortedReceipts = rows.map((row) => row.signed); + const hashes = rows.map((row) => receiptHash(row.signed)); + const reservationTotal = rows.reduce( + (sum, row) => sum + toAtomic(row.receipt.reservedAtomic), 0n, + ); + const releaseTotal = rows.reduce( + (sum, row) => sum + toAtomic(row.receipt.releasedAtomic), 0n, + ); + const chargeTotal = rows.reduce( + (sum, row) => sum + toAtomic(row.receipt.consumedAtomic), 0n, + ); + const earnedAwardTotal = rows.reduce((sum, row) => ( + ['earned', 'payable', 'paid'].includes(row.receipt.awardState) + ? sum + toAtomic(row.receipt.invocationAwardAtomic) + : sum + ), 0n); + const awardsByHash = new Map(rows.map((row) => [ + receiptHash(row.signed), + { + amount: ['earned', 'payable', 'paid'].includes(row.receipt.awardState) + ? toAtomic(row.receipt.invocationAwardAtomic) + : 0n, + advance: 0n, + earnedReversal: 0n, + payableReversal: 0n, + }, + ])); + + const advances = uniqueSorted(payableAdvances, 'advanceId', 'payable advances', validateAdvance); + for (const advance of advances) { + const award = awardsByHash.get(advance.receiptHash); + if (!award) throw new Error('payable advance references an unknown receipt'); + award.advance += toAtomic(advance.amountAtomic); + if (award.advance > award.amount) throw new Error('payable advance exceeds earned award'); + } + const reversalRows = uniqueSorted(reversals, 'reversalId', 'reversals', validateReversal); + for (const reversal of reversalRows) { + const award = awardsByHash.get(reversal.receiptHash); + if (!award) throw new Error('reversal references an unknown receipt'); + if (reversal.balanceEffect === 'earned_only') { + award.earnedReversal += toAtomic(reversal.amountAtomic); + if (award.advance + award.earnedReversal > award.amount) { + throw new Error('earned-only reversal exceeds unadvanced earned award'); + } + } else { + award.payableReversal += toAtomic(reversal.amountAtomic); + if (award.payableReversal > award.advance) { + throw new Error('payable reversal exceeds advanced amount'); + } + } + } + const paymentRows = uniqueSorted(payments, 'paymentId', 'payments', validatePayment); + const payableAdvanceTotal = advances.reduce((sum, row) => sum + toAtomic(row.amountAtomic), 0n); + const reversalTotal = reversalRows.reduce((sum, row) => sum + toAtomic(row.amountAtomic), 0n); + const payableReversalTotal = reversalRows.reduce((sum, row) => ( + row.balanceEffect === 'payable' ? sum + toAtomic(row.amountAtomic) : sum + ), 0n); + const paymentTotal = paymentRows.reduce((sum, row) => sum + toAtomic(row.amountAtomic), 0n); + const payableBeforePayment = opening + payableAdvanceTotal - payableReversalTotal; + if (paymentTotal > payableBeforePayment) throw new Error('payments exceed payable balance'); + const closing = payableBeforePayment - paymentTotal; + + return validateStatementPayload({ + schemaVersion: 1, + statementId, + employerId, + creatorId, + period, + currency, + atomicScale, + openingPayableAtomic, + firstReceiptSequence: rows.length === 0 ? null : rows[0].receipt.sequence, + lastReceiptSequence: rows.length === 0 ? null : rows.at(-1).receipt.sequence, + receiptHashes: hashes, + receiptMerkleRoot: receiptMerkleRoot(sortedReceipts), + reservationTotalAtomic: fromAtomic(reservationTotal), + releaseTotalAtomic: fromAtomic(releaseTotal), + chargeTotalAtomic: fromAtomic(chargeTotal), + earnedAwardTotalAtomic: fromAtomic(earnedAwardTotal), + payableAdvances: advances, + payableAdvanceTotalAtomic: fromAtomic(payableAdvanceTotal), + reversals: reversalRows, + reversalTotalAtomic: fromAtomic(reversalTotal), + payableReversalTotalAtomic: fromAtomic(payableReversalTotal), + payments: paymentRows, + paymentTotalAtomic: fromAtomic(paymentTotal), + closingPayableAtomic: fromAtomic(closing), + statementSignerId, + }); +} + +function validateStatementPayload(input) { + requireExactKeys(input, STATEMENT_KEYS, 'statement'); + if (input.schemaVersion !== 1) throw new Error('statement schemaVersion must equal 1'); + for (const key of ['statementId', 'employerId', 'creatorId', 'period', 'currency', 'statementSignerId']) { + requireString(input[key], key); + } + if (!Number.isSafeInteger(input.atomicScale) || input.atomicScale < 0 || input.atomicScale > 18) { + throw new Error('statement atomicScale must be an integer from 0 through 18'); + } + const bothNull = input.firstReceiptSequence === null && input.lastReceiptSequence === null; + const bothIntegers = Number.isSafeInteger(input.firstReceiptSequence) + && Number.isSafeInteger(input.lastReceiptSequence) + && input.firstReceiptSequence >= 1 + && input.lastReceiptSequence >= input.firstReceiptSequence; + if (!bothNull && !bothIntegers) throw new Error('statement receipt sequence bounds are invalid'); + if (!Array.isArray(input.receiptHashes) || input.receiptHashes.some((hash) => !SHA256_PATTERN.test(hash))) { + throw new Error('statement receiptHashes are invalid'); + } + if (!SHA256_PATTERN.test(input.receiptMerkleRoot)) throw new Error('statement receiptMerkleRoot is invalid'); + for (const key of [ + 'openingPayableAtomic', 'reservationTotalAtomic', 'releaseTotalAtomic', + 'chargeTotalAtomic', 'earnedAwardTotalAtomic', 'payableAdvanceTotalAtomic', + 'reversalTotalAtomic', 'payableReversalTotalAtomic', 'paymentTotalAtomic', + 'closingPayableAtomic', + ]) toAtomic(input[key]); + uniqueSorted(input.payableAdvances, 'advanceId', 'payable advances', validateAdvance); + uniqueSorted(input.reversals, 'reversalId', 'reversals', validateReversal); + uniqueSorted(input.payments, 'paymentId', 'payments', validatePayment); + return cloneFrozen(input); +} + +export function canonicalStatementBytes(unsignedStatement) { + const validated = validateStatementPayload(unsignedStatement); + return canonicalBytes(validated, STATEMENT_KEYS); +} + +export function signStatement(unsignedStatement, privateKey) { + const validated = validateStatementPayload(unsignedStatement); + return cloneFrozen({ + ...validated, + signature: cryptoSign(null, canonicalStatementBytes(validated), privateKey).toString('base64'), + }); +} + +export function verifyStatement(signedStatement, { + signedReceipts, + trustedReceiptSigners, + trustedStatementSigners, +}) { + requireExactKeys(signedStatement, SIGNED_STATEMENT_KEYS, 'signed statement'); + const statement = validateStatementPayload(ordered(signedStatement, STATEMENT_KEYS)); + const key = trustedKey( + trustedStatementSigners, + statement.statementSignerId, + 'statement signer', + ); + if (!cryptoVerify( + null, + canonicalStatementBytes(statement), + key, + decodeSignature(signedStatement.signature, 'statement'), + )) throw new Error('statement signature verification failed'); + for (const receipt of signedReceipts) { + verifyReceipt(receipt, { trustedReceiptSigners }); + } + const recomputed = buildStatement({ + statementId: statement.statementId, + employerId: statement.employerId, + creatorId: statement.creatorId, + period: statement.period, + currency: statement.currency, + atomicScale: statement.atomicScale, + openingPayableAtomic: statement.openingPayableAtomic, + receipts: signedReceipts, + payableAdvances: statement.payableAdvances, + reversals: statement.reversals, + payments: statement.payments, + statementSignerId: statement.statementSignerId, + }); + if (!Buffer.from(canonicalStatementBytes(recomputed)) + .equals(Buffer.from(canonicalStatementBytes(statement)))) { + throw new Error('statement does not match deterministic economic recomputation'); + } + return statement; +} + +function stableJson(value) { + if (typeof value === 'bigint') throw new Error('JSON-safe records cannot contain BigInt'); + if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('JSON-safe records require finite numbers'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`; + } + throw new Error('JSON-safe records require plain objects and arrays'); +} + +export function renderJsonl(events) { + if (!Array.isArray(events)) throw new Error('events must be an array'); + if (events.length === 0) return ''; + return `${events.map(stableJson).join('\n')}\n`; +} + +export const STATEMENT_SCHEMAS = cloneFrozen({ + InvocationReceiptV1: RECEIPT_KEYS, + StatementV1: STATEMENT_KEYS, + PayableAdvanceV1: ADVANCE_KEYS, + AwardReversalV1: REVERSAL_KEYS, + EmployerPaymentV1: PAYMENT_KEYS, +}); diff --git a/spikes/internal-invocation-awards/test/statements.test.mjs b/spikes/internal-invocation-awards/test/statements.test.mjs new file mode 100644 index 0000000..5372064 --- /dev/null +++ b/spikes/internal-invocation-awards/test/statements.test.mjs @@ -0,0 +1,376 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import test from 'node:test'; + +import { + buildInvocationReceipt, + buildStatement, + canonicalReceiptBytes, + canonicalStatementBytes, + receiptHash, + renderJsonl, + signReceipt, + signStatement, + verifyReceipt, + verifyStatement, +} from '../src/statements.mjs'; + +const NOW = '2026-07-17T00:01:00.000Z'; +const SKILL_HASH = `sha256:${'1'.repeat(64)}`; +const OUTPUT_HASH = `sha256:${'a'.repeat(64)}`; + +function successRecords(sequence = 1, suffix = '001') { + const quote = { + schemaVersion: 1, + quoteId: `quote-${suffix}`, + invocationId: `inv-${suffix}`, + idempotencyKey: `run-${suffix}`, + skillId: 'ledger-recon', + skillVersionHash: SKILL_HASH, + creatorId: 'sam', + wielderId: 'megacorp-internal-agent', + beneficiaryId: 'megacorp', + costCenter: 'platform-engineering', + policyId: 'policy-megacorp-ledger-recon', + policyVersion: 1, + maxExecutionCostAtomic: '1000000', + protocolFeeAtomic: '25000', + refundReserveAtomic: '25000', + maxInvocationAwardAtomic: '2000000', + maxGrossAtomic: '3050000', + expiresAt: '2026-07-17T00:05:00.000Z', + }; + const reservation = { + schemaVersion: 1, + reservationId: `res-${suffix}`, + quote, + state: 'consumed', + reservedAtomic: '3050000', + revision: 2, + executionAttemptId: `attempt-${suffix}`, + authorizedAt: NOW, + startedAt: NOW, + finalizedAt: NOW, + }; + const invocation = { + schemaVersion: 1, + invocationId: quote.invocationId, + idempotencyKey: quote.idempotencyKey, + quoteId: quote.quoteId, + reservationId: reservation.reservationId, + skillId: quote.skillId, + skillVersionHash: quote.skillVersionHash, + creatorId: 'sam', + wielderId: quote.wielderId, + beneficiaryId: 'megacorp', + costCenter: quote.costCenter, + policyId: quote.policyId, + policyVersion: 1, + period: '2026-07', + currency: 'USD', + atomicScale: 6, + state: 'succeeded', + revision: 2, + credentialNonce: '1'.padStart(64, '0'), + credentialIssuedAt: NOW, + credentialExpiresAt: quote.expiresAt, + executionAttemptId: reservation.executionAttemptId, + authorizedAt: NOW, + startedAt: NOW, + finalizedAt: NOW, + executionCostStatus: 'known', + executionCostAtomic: '700000', + protocolFeeAtomic: '25000', + refundReserveAtomic: '25000', + maxInvocationAwardAtomic: '2000000', + invocationAwardAtomic: '2000000', + releasedAtomic: '300000', + heldReservationAtomic: '0', + awardId: `award-${suffix}`, + outputHash: OUTPUT_HASH, + failureClass: null, + unresolvedReason: null, + externalRoyaltyCreditsAtomic: '0', + employerSelfCreditAtomic: '0', + journalEntries: [ + { category: 'execution-cogs', debitAccountId: 'employer:invocation-gross', creditAccountId: 'provider:execution', amountAtomic: '700000' }, + { category: 'protocol-fee', debitAccountId: 'employer:invocation-gross', creditAccountId: 'protocol:treasury', amountAtomic: '25000' }, + { category: 'refund-reserve', debitAccountId: 'employer:invocation-gross', creditAccountId: 'reserve:refund', amountAtomic: '25000' }, + { category: 'invocation-award', debitAccountId: 'employer:invocation-gross', creditAccountId: 'employee:sam', amountAtomic: '2000000' }, + ], + receiptSequence: sequence, + }; + const award = { + schemaVersion: 1, + awardId: invocation.awardId, + invocationId: invocation.invocationId, + recipientId: 'sam', + policyId: invocation.policyId, + policyVersion: 1, + period: '2026-07', + currency: 'USD', + atomicScale: 6, + amountAtomic: '2000000', + state: 'earned', + measuredAt: NOW, + earnedAt: NOW, + payableAt: null, + paidAt: null, + }; + return { invocation, reservation, award }; +} + +function unresolvedRecords(sequence = 2) { + const records = successRecords(sequence, 'unresolved'); + return { + reservation: { ...records.reservation, state: 'held_unresolved' }, + award: null, + invocation: { + ...records.invocation, + state: 'unresolved', + executionCostStatus: 'unresolved', + executionCostAtomic: null, + protocolFeeAtomic: '0', + refundReserveAtomic: '0', + invocationAwardAtomic: '0', + releasedAtomic: '0', + heldReservationAtomic: '3050000', + awardId: null, + outputHash: null, + unresolvedReason: 'cost_unknown', + journalEntries: [], + }, + }; +} + +function signerFixture() { + const receipt = generateKeyPairSync('ed25519'); + const statement = generateKeyPairSync('ed25519'); + return { + receipt, + statement, + receiptTrust: { + 'collar-receipt-key-2026-07': receipt.publicKey.export({ type: 'spki', format: 'pem' }), + }, + statementTrust: { + 'collar-statement-key-2026-07': statement.publicKey.export({ type: 'spki', format: 'pem' }), + }, + }; +} + +function signedSuccess(signers, sequence = 1, suffix = '001') { + return signReceipt(buildInvocationReceipt({ + ...successRecords(sequence, suffix), + employerId: 'megacorp', + receiptSignerId: 'collar-receipt-key-2026-07', + }), signers.receipt.privateKey); +} + +test('employer and employee verify identical exact receipt bytes through a trusted key ID', () => { + const signers = signerFixture(); + const unsigned = buildInvocationReceipt({ + ...successRecords(), employerId: 'megacorp', receiptSignerId: 'collar-receipt-key-2026-07', + }); + const signed = signReceipt(unsigned, signers.receipt.privateKey); + const employer = verifyReceipt(signed, { trustedReceiptSigners: signers.receiptTrust }); + const employee = verifyReceipt(signed, { trustedReceiptSigners: signers.receiptTrust }); + assert.deepEqual(employer, employee); + assert.deepEqual(canonicalReceiptBytes(employer), canonicalReceiptBytes(employee)); + assert.equal(employer.externalSettlementHash, null); + assert.equal(employer.externalRoyaltyCreditsAtomic, '0'); + assert.equal(employer.employerSelfCreditAtomic, '0'); + assert.equal(employer.journalEntries.length, 4); + assert.match(receiptHash(signed), /^sha256:[0-9a-f]{64}$/); + assert.doesNotThrow(() => JSON.stringify(signed)); + assert.throws( + () => verifyReceipt({ ...signed, invocationAwardAtomic: '1999999' }, { trustedReceiptSigners: signers.receiptTrust }), + /signature|journal|consumed/, + ); + assert.throws( + () => verifyReceipt({ ...signed, publicKeyPem: 'self-declared' }, { trustedReceiptSigners: signers.receiptTrust }), + /unknown key publicKeyPem/, + ); +}); + +test('unresolved receipt claims neither zero COGS nor release nor award', () => { + const signers = signerFixture(); + const unsigned = buildInvocationReceipt({ + ...unresolvedRecords(), employerId: 'megacorp', receiptSignerId: 'collar-receipt-key-2026-07', + }); + const signed = signReceipt(unsigned, signers.receipt.privateKey); + const verified = verifyReceipt(signed, { trustedReceiptSigners: signers.receiptTrust }); + assert.equal(verified.executionCostStatus, 'unresolved'); + assert.equal(verified.executionCostAtomic, null); + assert.equal(verified.heldReservationAtomic, '3050000'); + assert.equal(verified.releasedAtomic, '0'); + assert.equal(verified.invocationAwardAtomic, '0'); + assert.equal(verified.awardState, null); + assert.deepEqual(verified.journalEntries, []); +}); + +test('cancelled authorization has one signed release receipt in the independent sequence', () => { + const signers = signerFixture(); + const records = successRecords(1, 'cancelled'); + const reservation = { ...records.reservation, state: 'released', executionAttemptId: null }; + const invocation = { + ...records.invocation, + state: 'cancelled', + executionAttemptId: null, + executionCostStatus: null, + executionCostAtomic: null, + protocolFeeAtomic: '0', + refundReserveAtomic: '0', + invocationAwardAtomic: '0', + releasedAtomic: '3050000', + awardId: null, + outputHash: null, + journalEntries: [], + }; + const receipt = signReceipt(buildInvocationReceipt({ + invocation, + reservation, + award: null, + employerId: 'megacorp', + receiptSignerId: 'collar-receipt-key-2026-07', + }), signers.receipt.privateKey); + const verified = verifyReceipt(receipt, { trustedReceiptSigners: signers.receiptTrust }); + assert.equal(verified.receiptType, 'internal_invocation_cancelled'); + assert.equal(verified.releasedAtomic, '3050000'); + const statement = buildStatement({ + statementId: 'statement-cancelled', employerId: 'megacorp', creatorId: 'sam', + period: '2026-07', currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', + receipts: [receipt], payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }); + assert.equal(statement.releaseTotalAtomic, '3050000'); + assert.equal(statement.earnedAwardTotalAtomic, '0'); + assert.equal(statement.closingPayableAtomic, '0'); +}); + +test('whole statement authenticates economics and earned is not payable until advanced', () => { + const signers = signerFixture(); + const receipt = signedSuccess(signers); + const unsigned = buildStatement({ + statementId: 'statement-megacorp-sam-2026-07', + employerId: 'megacorp', + creatorId: 'sam', + period: '2026-07', + currency: 'USD', + atomicScale: 6, + openingPayableAtomic: '0', + receipts: [receipt], + payableAdvances: [], + reversals: [], + payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }); + assert.equal(unsigned.earnedAwardTotalAtomic, '2000000'); + assert.equal(unsigned.payableAdvanceTotalAtomic, '0'); + assert.equal(unsigned.closingPayableAtomic, '0'); + const signed = signStatement(unsigned, signers.statement.privateKey); + const options = { + signedReceipts: [receipt], + trustedReceiptSigners: signers.receiptTrust, + trustedStatementSigners: signers.statementTrust, + }; + const employer = verifyStatement(signed, options); + const employee = verifyStatement(signed, options); + assert.deepEqual(employer, employee); + assert.deepEqual(canonicalStatementBytes(employer), canonicalStatementBytes(employee)); + assert.doesNotThrow(() => JSON.stringify(signed)); +}); + +test('payable advances, reversal semantics, and payments determine payable closing', () => { + const signers = signerFixture(); + const receipt = signedSuccess(signers); + const hash = receiptHash(receipt); + const unsigned = buildStatement({ + statementId: 'statement-with-payable-events', + employerId: 'megacorp', creatorId: 'sam', period: '2026-07', + currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt], + payableAdvances: [{ + advanceId: 'advance-001', receiptHash: hash, amountAtomic: '1000000', advancedAt: NOW, + }], + reversals: [ + { reversalId: 'reversal-earned', receiptHash: hash, amountAtomic: '200000', balanceEffect: 'earned_only', reason: 'quality_adjustment', occurredAt: NOW }, + { reversalId: 'reversal-payable', receiptHash: hash, amountAtomic: '100000', balanceEffect: 'payable', reason: 'duplicate_advance', occurredAt: NOW }, + ], + payments: [{ paymentId: 'payment-001', amountAtomic: '250000', paidAt: NOW, railReference: 'simulated-payroll-ref' }], + statementSignerId: 'collar-statement-key-2026-07', + }); + assert.equal(unsigned.earnedAwardTotalAtomic, '2000000'); + assert.equal(unsigned.reversalTotalAtomic, '300000'); + assert.equal(unsigned.payableReversalTotalAtomic, '100000'); + assert.equal(unsigned.paymentTotalAtomic, '250000'); + assert.equal(unsigned.closingPayableAtomic, '650000'); + const signed = signStatement(unsigned, signers.statement.privateKey); + assert.doesNotThrow(() => verifyStatement(signed, { + signedReceipts: [receipt], + trustedReceiptSigners: signers.receiptTrust, + trustedStatementSigners: signers.statementTrust, + })); + assert.throws(() => buildStatement({ + statementId: 'over-reversed', employerId: 'megacorp', creatorId: 'sam', period: '2026-07', + currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt], + payableAdvances: [], payments: [], statementSignerId: 'collar-statement-key-2026-07', + reversals: [{ reversalId: 'r', receiptHash: hash, amountAtomic: '1', balanceEffect: 'payable', reason: 'bad', occurredAt: NOW }], + }), /payable reversal exceeds advanced amount/); +}); + +test('statement sequence continuity and domain-separated Merkle rules are deterministic', () => { + const signers = signerFixture(); + const receipt1 = signedSuccess(signers, 1, '001'); + const receipt2 = signedSuccess(signers, 2, '002'); + const receipt3 = signedSuccess(signers, 3, '003'); + const build = (receipts) => buildStatement({ + statementId: 'statement-sequences', employerId: 'megacorp', creatorId: 'sam', period: '2026-07', + currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts, + payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }); + const oddA = build([receipt3, receipt1, receipt2]); + const oddB = build([receipt1, receipt2, receipt3]); + assert.equal(oddA.receiptMerkleRoot, oddB.receiptMerkleRoot); + assert.deepEqual(oddA.receiptHashes, oddB.receiptHashes); + assert.throws(() => build([receipt1, receipt3]), /statement sequence gap: expected 2, received 3/); + + const empty = build([]); + assert.equal(empty.firstReceiptSequence, null); + assert.equal(empty.lastReceiptSequence, null); + assert.match(empty.receiptMerkleRoot, /^sha256:[0-9a-f]{64}$/); + assert.notEqual(empty.receiptMerkleRoot, build([receipt1]).receiptMerkleRoot); +}); + +test('whole-statement and receipt trust roots reject tampering and attacker resigning', () => { + const signers = signerFixture(); + const receipt = signedSuccess(signers); + const unsigned = buildStatement({ + statementId: 'statement-trust', employerId: 'megacorp', creatorId: 'sam', period: '2026-07', + currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt], + payableAdvances: [], reversals: [], payments: [], statementSignerId: 'collar-statement-key-2026-07', + }); + const signed = signStatement(unsigned, signers.statement.privateKey); + const options = { + signedReceipts: [receipt], trustedReceiptSigners: signers.receiptTrust, + trustedStatementSigners: signers.statementTrust, + }; + for (const mutation of [ + { statementId: 'changed' }, + { openingPayableAtomic: '1' }, + { earnedAwardTotalAtomic: '1999999' }, + { closingPayableAtomic: '1' }, + { receiptMerkleRoot: `sha256:${'0'.repeat(64)}` }, + ]) { + assert.throws(() => verifyStatement({ ...signed, ...mutation }, options), /signature|recompute/); + } + const attacker = generateKeyPairSync('ed25519'); + const attackerSigned = signStatement(unsigned, attacker.privateKey); + assert.throws(() => verifyStatement(attackerSigned, options), /signature/); + assert.throws(() => verifyStatement({ ...signed, publicKeyPem: 'self-declared' }, options), /unknown key publicKeyPem/); +}); + +test('JSONL output is canonical, newline terminated, and rejects BigInt', () => { + const rendered = renderJsonl([{ z: 1, a: { y: 2, b: 3 } }]); + assert.equal(rendered, '{"a":{"b":3,"y":2},"z":1}\n'); + assert.throws(() => renderJsonl([{ amountAtomic: 1n }]), /BigInt|JSON-safe/); +}); From 7d6cb45ff2a9e93a3bd92ac86eea2ddbc4e29983 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:26:57 -0400 Subject: [PATCH 079/165] fix: preserve attestation lock and log ownership --- phase0/src/attestation-store.ts | 62 +++++++++++++++++++++----- phase0/tests/attestation-store.test.ts | 35 ++++++++++++++- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/phase0/src/attestation-store.ts b/phase0/src/attestation-store.ts index fd687e5..b68a6a0 100644 --- a/phase0/src/attestation-store.ts +++ b/phase0/src/attestation-store.ts @@ -1,4 +1,5 @@ import { randomBytes, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; import { link, mkdir, open, rename, stat, unlink, type FileHandle } from "node:fs/promises"; import { dirname } from "node:path"; @@ -59,7 +60,7 @@ export async function writeAll(handle: WriteAllHandle, bytes: Uint8Array): Promi } async function syncDirectory(path: string): Promise { - const handle = await open(path, "r"); + const handle = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); try { await handle.sync(); } finally { await handle.close(); } } @@ -86,9 +87,10 @@ function parseLock(value: unknown): AttestationLockMetadata { } async function readMode0600(path: string, label: string): Promise { - const handle = await open(path, "r"); + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { const metadata = await handle.stat(); + if (!metadata.isFile()) throw new Error(`${label} must be a non-symlink regular file`); if ((metadata.mode & 0o777) !== 0o600) throw new Error(`${label} must have mode 0600`); return await handle.readFile("utf8"); } finally { @@ -125,17 +127,28 @@ export class FileAttestationStore { async append(eventValue: AttestationEvent): Promise { await this.withLock(async () => { - const events = await this.loadUnlocked(); + const snapshot = await this.readLogSnapshot(); + const events = snapshot.events; const event = parseAttestationEvent(eventValue); if (event.sequence !== events.length + 1) throw new Error(`attestation event sequence must equal ${events.length + 1}`); if (events.some((prior) => prior.eventId === event.eventId)) throw new Error(`duplicate attestation event ID ${event.eventId}`); const candidate = [...events, event]; await this.validateEvents(candidate); - await this.options.hooks?.beforeAppendWrite?.(); await mkdir(dirname(this.path), { recursive: true }); let handle: FileHandle | null = null; try { - handle = await open(this.path, "a", 0o600); + handle = await open( + this.path, + constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, + 0o600, + ); + const metadata = await handle.stat(); + if (!metadata.isFile() || (metadata.mode & 0o777) !== 0o600) { + throw new Error("attestation log must be a non-symlink regular file with mode 0600"); + } + const boundBytes = await handle.readFile("utf8"); + if (boundBytes !== snapshot.bytes) throw new Error("attestation log changed between validation and append"); + await this.options.hooks?.beforeAppendWrite?.(); const bytes = Buffer.from(`${JSON.stringify(event)}\n`, "utf8"); await writeAll(handle, bytes); await handle.sync(); @@ -191,20 +204,31 @@ export class FileAttestationStore { } private async loadUnlocked(): Promise { + return (await this.readLogSnapshot()).events; + } + + private async readLogSnapshot(): Promise<{ events: AttestationEvent[]; bytes: string }> { let bytes: string; - try { bytes = await open(this.path, "r").then(async (handle) => { - try { return await handle.readFile("utf8"); } finally { await handle.close(); } + try { bytes = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW).then(async (handle) => { + try { + const metadata = await handle.stat(); + if (!metadata.isFile() || (metadata.mode & 0o777) !== 0o600) { + throw new Error("attestation log must be a non-symlink regular file with mode 0600"); + } + return await handle.readFile("utf8"); + } finally { await handle.close(); } }); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { events: [], bytes: "" }; + if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("attestation log must not be a symlink", { cause: error }); throw error; } - if (bytes === "") return []; + if (bytes === "") return { events: [], bytes }; if (!bytes.endsWith("\n")) throw new Error("attestation log has a malformed trailing fragment"); const lines = bytes.slice(0, -1).split("\n"); if (lines.some((line) => line.length === 0)) throw new Error("attestation log contains an empty or malformed line"); const events = lines.map(parseJsonLine); await this.validateEvents(events); - return events; + return { events, bytes }; } private async withLock(operation: () => Promise): Promise { @@ -225,13 +249,19 @@ export class FileAttestationStore { let created = false; try { try { - handle = await open(this.lockPath, "wx", 0o600); + handle = await open( + this.lockPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); created = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; const existing = await this.readLock(); throw new Error(`attestation store locked by PID ${existing.owner.pid}, token ${existing.owner.token}`, { cause: error }); } + const metadata = await handle.stat(); + if (!metadata.isFile() || (metadata.mode & 0o777) !== 0o600) throw new Error("attestation store lock must be a mode-0600 regular file"); await writeAll(handle, Buffer.from(`${JSON.stringify(owner)}\n`, "utf8")); await handle.sync(); await handle.close(); @@ -241,7 +271,15 @@ export class FileAttestationStore { } catch (error) { const unfinishedHandle = handle as FileHandle | null; if (unfinishedHandle) await unfinishedHandle.close().catch(() => undefined); - if (created) await unlink(this.lockPath).catch(() => undefined); + if (created) { + try { + await this.claimAndRemoveLock(owner, `${JSON.stringify(owner)}\n`, "attestation store lock acquisition cleanup failed"); + } catch (cleanupError) { + throw new Error("attestation store lock acquisition failed and the observed owner was retained", { + cause: new AggregateError([error, cleanupError]), + }); + } + } throw error; } } diff --git a/phase0/tests/attestation-store.test.ts b/phase0/tests/attestation-store.test.ts index ffc73a2..4d95478 100644 --- a/phase0/tests/attestation-store.test.ts +++ b/phase0/tests/attestation-store.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -170,3 +170,36 @@ test("writeAll handles short writes and rejects zero or invalid progress", async await assert.rejects(writeAll({ async write() { return { bytesWritten: 0 }; } }, source), /made no progress/); await assert.rejects(writeAll({ async write(_buffer, _offset, length) { return { bytesWritten: length + 1 }; } }, source), /invalid byte count/); }); + +test("failed acquisition never deletes a replacement lock owner", async (t) => { + let store!: FileAttestationStore; + const f = await fixture(t, { + afterLockCreated: async () => { + await rename(`${f.path}.lock`, `${f.path}.lock.original`); + await writeFile(`${f.path}.lock`, `${JSON.stringify({ + schemaVersion: 1, + pid: 777_777, + token: "f".repeat(32), + targetPath: f.path, + acquiredAt: NOW, + })}\n`, { mode: 0o600 }); + throw new Error("injected failure after replacement"); + }, + }); + store = f.store; + await assert.rejects(store.load(), /observed owner was retained/); + const replacement = JSON.parse(await readFile(`${f.path}.lock`, "utf8")); + assert.equal(replacement.token, "f".repeat(32)); +}); + +test("event log rejects symlinks and non-owner-only modes", async (t) => { + const f = await fixture(t); + const victim = resolve(f.directory, "victim.jsonl"); + await writeFile(victim, "", { mode: 0o600 }); + await symlink(victim, f.path); + await assert.rejects(f.store.load(), /must not be a symlink|non-symlink/); + await rm(f.path); + await writeFile(f.path, "", { mode: 0o644 }); + await chmod(f.path, 0o644); + await assert.rejects(f.store.load(), /mode 0600/); +}); From e66a1acc85c69fceefdd5d6de501150d03b7935e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:28:11 -0400 Subject: [PATCH 080/165] docs: report internal Invocation award spike --- spikes/internal-invocation-awards/README.md | 167 +++++++++++++++ spikes/internal-invocation-awards/demo.mjs | 215 ++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 spikes/internal-invocation-awards/README.md create mode 100644 spikes/internal-invocation-awards/demo.mjs diff --git a/spikes/internal-invocation-awards/README.md b/spikes/internal-invocation-awards/README.md new file mode 100644 index 0000000..db7d952 --- /dev/null +++ b/spikes/internal-invocation-awards/README.md @@ -0,0 +1,167 @@ +# SPIKE — deterministic accounting evidence only + +This isolated Node spike demonstrates one employer-funded internal **Invocation** +accounting path. It uses no network, wallet, chain, API key, funded account, payroll +transfer, platform-held prepaid balance, or real money. Every key is a throwaway +Ed25519 key generated in memory for the current process, and the executor is an +injected fake. The demo installs a global `fetch` trap so an accidental network call +fails immediately. + +This is executable design evidence, not product doctrine. It does not edit the +protected ubiquitous language, PRD, or ADR corpus. + +## What the spike demonstrates + +The example employer signs an immutable `EmployerBudgetAuthorizationV1` for a +denomination and period. Mutable reservation, consumption, and release counters live +in a separate `BudgetStateV1`; changing a signed authorization field invalidates its +signature. Effective and expiry times are checked at both reservation and Execution +start. + +The accounting flow is: + +```text +signed employer budget authorization + -> atomically reserve quote maximum + -> return exact unsigned Execution-credential payload + -> caller signs that persisted payload + -> atomically consume its nonce and start one execution attempt + -> fake executor reports validated COGS + -> shared atomic-money kernel partitions actual gross + -> release unused reservation + -> record employee-Creator Invocation award + -> sign one receipt and one full economic statement +``` + +The successful example reserves `3.050000 USD`, records `0.700000 USD` execution +COGS, `0.025000 USD` protocol fee, `0.025000 USD` refund reserve, and a `2.000000 +USD` Invocation award, then releases `0.300000 USD`. Actual gross is the exact sum of +reported COGS, the quote-final fee and reserve, and the authorized maximum award. All +persisted atomic amounts are non-negative decimal strings. Arithmetic converts them +to `bigint`; no floating-point value participates in money arithmetic. + +The gross partition is imported from +`prototype/atomic-money.mjs#allocateInternalGross`. This spike does not implement a +second fee, remainder, or account-allocation formula. It consumes the kernel-returned +account-identified journal entries. + +The result requires an authorized internal **Wielder**, but no external Wielder. It +creates neither an external **Royalty claim** credit nor a circular employer +self-credit. An internal Invocation award is proposed employer-compensation +accounting; it is distinct from external Invocation revenue distributed through a +co-held Royalty claim. + +## Policy and lifecycle boundaries + +Version 1 accepts only the immutable 100% residual award rule: + +- `type: residual_after_execution_fee_and_reserve` +- `awardRateBps: 10000` +- `rateBase: post_cost_residual` +- `rounding: floor_atomic` + +Variable award rates and any destination for non-award residual are not implemented. +Policy and budget authorization are immutable, effective-dated, expiry-bounded, and +denomination-neutral. A self Invocation is exactly one whose `creatorId` equals its +`wielderId`; its manager approval is a separate signed object, never a quote field. + +The store is a serialized, single-process CAS demonstration. It is not a distributed +database lock. The engine uses exact global, budget, Invocation, reservation, and +execution-attempt revisions so one stale authorization or duplicate completion wins +at most once. It conservatively counts every earned award plus the maximum award of +every reserved, executing, or unresolved-held authorization against the period cap. +There is no automated award-reversal lifecycle in this v1 engine, so it never reduces +that exposure based on an unsupported reversal claim. + +The tested pre-execution rejection set includes: + +- inactive, not-yet-effective, expired, or malformed policy; +- expired, not-yet-effective, altered, self-signed-untrusted, or disallowed budget; +- malformed atomic amount, Skill hash, quote total, or credential nonce; +- unauthorized Skill, Creator, Wielder, Beneficiary, cost center, signer, or + authorizer; +- unknown, embedded, or untrusted public-key material; +- insufficient remaining budget or exceeded per-Invocation or period award cap; +- stale budget/engine/record revision, duplicate idempotency key, nonce, reservation, + or Invocation; +- missing, expired, mismatched, already-consumed, cancelled, or released Execution + credential/reservation binding; and +- self Invocation without a trusted, policy-permitted, non-self manager approval. + +Every failure before `executing` calls the executor zero times. + +## Executor outcome discipline + +The injected executor may return exactly one strict union member: + +```text +succeeded(executionCostAtomic, outputHash) +failed_after_start(executionCostAtomic, failureClass) +unresolved_after_start(reason) +``` + +Successful and known post-start failures require a canonical atomic COGS string at or +below the quote maximum. A validated failure consumes exactly that unavoidable COGS, +releases the remainder, and creates no award. + +A thrown executor, explicit unknown cost, unknown outcome kind, extra field, missing +cost, malformed cost/hash, or over-cap cost transitions to `unresolved` and +`held_unresolved`. The full original reservation remains held. The spike never +substitutes zero COGS, releases that hold, or creates an award automatically. Operator +reconciliation of an unresolved hold is a human-only future gate. + +## Receipts and statements + +Every terminal success, known failure, unresolved Execution, or pre-execution +cancellation receives one independent, monotonic receipt sequence. A trusted receipt +key ID selects the provisioned verification key; receipts and requests cannot inject +key material. Receipt canonical bytes bind the Invocation, reservation, Skill hash, +effective policy, outcome, atomic totals, kernel journal entries, and absence of an +external settlement. + +Employer and employee verify the same signed receipt bytes. They also verify a +separate whole-statement signature that binds: + +- identity, denomination, period, and payable opening balance; +- ordered receipt hashes and contiguous sequence bounds; +- reservation, release, charge, and earned-award audit totals; +- the complete payable-advance, reversal, and payment arrays; +- payable and non-payable reversal semantics; and +- the closing payable balance. + +An earned-but-unpaid award is not yet payable. It affects +`earnedAwardTotalAtomic`, but does not enter `closingPayableAtomic` until a separately +authenticated payable-advance record is present. A reversal declares whether it +changes only earned accounting or an already-advanced payable balance. Payments +cannot exceed the authenticated payable balance. + +The receipt inclusion root uses domain-separated binary SHA-256 leaves and internal +nodes. Odd levels duplicate the last node. The empty set has a fixed +domain-separated root. This is an inclusion root, not a completeness proof; sequence +continuity, the signed ordered hash list, and employer/employee comparison supply the +completeness signal. Individually signed receipts do not authenticate a mutable +statement shell—the trusted whole-statement signature and deterministic recomputation +are both required. + +## Run the offline evidence + +From this directory: + +```bash +npm test +npm run demo +``` + +The test suite and demo use only Node built-ins and local source. The demo output is +labeled `SIMULATED ACCOUNTING, NO REAL FUNDS`, and the employee-Creator award is +reported as `earned, not paid`. + +## Not validated and human-only gates + +This spike does not validate demand, employer adoption, production persistence, +distributed concurrency, payroll, tax, employment law, securities treatment, +custody, banking, or actual payment. A counsel-drafted compensation instrument, an +employer agreement and approved program, production trust-key provisioning, operator +reconciliation procedures, and payment through the employer's payroll or +accounts-payable rail remain human-only gates. No automated path advances `payable` +to `paid`. diff --git a/spikes/internal-invocation-awards/demo.mjs b/spikes/internal-invocation-awards/demo.mjs new file mode 100644 index 0000000..89aff1d --- /dev/null +++ b/spikes/internal-invocation-awards/demo.mjs @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; + +import { signBudget } from './src/budget.mjs'; +import { signCredential } from './src/credentials.mjs'; +import { + authorizeInternalInvocation, + createEngineState, + executeAuthorizedInvocation, +} from './src/engine.mjs'; +import { + buildInvocationReceipt, + buildStatement, + signReceipt, + signStatement, + verifyReceipt, + verifyStatement, +} from './src/statements.mjs'; +import { InMemoryEngineStore } from './src/store.mjs'; + +const NOW = '2026-07-17T00:01:00.000Z'; +const POLICY_ID = 'policy-megacorp-ledger-recon'; +const RECEIPT_SIGNER_ID = 'collar-receipt-key-2026-07'; +const STATEMENT_SIGNER_ID = 'collar-statement-key-2026-07'; + +// Any accidental attempt to leave the process fails the demonstration immediately. +globalThis.fetch = async () => { + throw new Error('network access is forbidden in the internal Invocation award spike'); +}; + +const finance = generateKeyPairSync('ed25519'); +const authorizer = generateKeyPairSync('ed25519'); +const manager = generateKeyPairSync('ed25519'); +const receiptSigner = generateKeyPairSync('ed25519'); +const statementSigner = generateKeyPairSync('ed25519'); + +const policy = { + schemaVersion: 1, + policyId: POLICY_ID, + version: 1, + status: 'active', + currency: 'USD', + atomicScale: 6, + employerId: 'megacorp', + effectiveAt: '2026-07-17T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + permittedSkillIds: ['ledger-recon'], + permittedCreatorIds: ['sam'], + permittedWielderIds: ['megacorp-internal-agent'], + permittedCostCenters: ['platform-engineering'], + maxQuoteAtomic: '4000000', + awardRule: { + type: 'residual_after_execution_fee_and_reserve', + awardRateBps: 10000, + rateBase: 'post_cost_residual', + rounding: 'floor_atomic', + }, + maxAwardPerInvocationAtomic: '2000000', + maxAwardPerPeriodAtomic: '100000000', + selfInvocation: 'manager_approval_required', + permittedManagerSignerIds: ['manager-alex'], + permittedCredentialAuthorizerIds: ['megacorp-collar-authorizer'], + permittedFinanceSignerIds: ['megacorp-finance'], + vestingRule: 'none', + paymentSchedule: 'monthly_in_arrears', + terminationTreatment: 'earned_remains_payable_unearned_cancelled', + paymentRail: 'employer_payroll_or_ap', +}; + +const signedBudget = signBudget({ + schemaVersion: 1, + budgetId: 'budget-megacorp-2026-07', + policyId: POLICY_ID, + policyVersion: 1, + period: '2026-07', + currency: 'USD', + atomicScale: 6, + allocatedAtomic: '1000000000', + effectiveAt: '2026-07-17T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + signerId: 'megacorp-finance', +}, finance.privateKey); + +const quote = { + schemaVersion: 1, + quoteId: 'quote-inv-001', + invocationId: 'inv-001', + idempotencyKey: 'run-ledger-recon-001', + skillId: 'ledger-recon', + skillVersionHash: `sha256:${'1'.repeat(64)}`, + creatorId: 'sam', + wielderId: 'megacorp-internal-agent', + beneficiaryId: 'megacorp', + costCenter: 'platform-engineering', + policyId: POLICY_ID, + policyVersion: 1, + maxExecutionCostAtomic: '1000000', + protocolFeeAtomic: '25000', + refundReserveAtomic: '25000', + maxInvocationAwardAtomic: '2000000', + maxGrossAtomic: '3050000', + expiresAt: '2026-07-17T00:05:00.000Z', +}; + +const store = new InMemoryEngineStore(createEngineState({ + signedBudget, + policies: { [`${POLICY_ID}@1`]: policy }, + financeSigners: { + 'megacorp-finance': finance.publicKey.export({ type: 'spki', format: 'pem' }), + }, + managerSigners: { + 'manager-alex': manager.publicKey.export({ type: 'spki', format: 'pem' }), + }, + credentialAuthorizers: { + 'megacorp-collar-authorizer': authorizer.publicKey.export({ type: 'spki', format: 'pem' }), + }, + now: NOW, +})); + +const authorized = await authorizeInternalInvocation({ + store, + quote, + expectedRevision: 0, + expectedBudgetRevision: 0, + reservationId: 'res-inv-001', + credentialNonce: '1'.padStart(64, '0'), + credentialIssuedAt: NOW, + credentialExpiresAt: '2026-07-17T00:10:00.000Z', + credentialAuthorizerId: 'megacorp-collar-authorizer', + managerApproval: null, + now: NOW, +}); +assert.equal(authorized.reservation.state, 'reserved'); +assert.equal(authorized.reservation.reservedAtomic, '3050000'); + +// The credential is signed only after the exact reservation-bound payload is persisted. +const credential = signCredential(authorized.credentialPayload, authorizer.privateKey); +const completed = await executeAuthorizedInvocation({ + store, + quote, + credential, + executor: async () => ({ + kind: 'succeeded', + executionCostAtomic: '700000', + outputHash: `sha256:${'a'.repeat(64)}`, + }), + now: NOW, +}); +assert.equal(completed.invocation.state, 'succeeded'); +assert.equal(completed.budget.consumedAtomic, '2750000'); +assert.equal(completed.budget.releasedAtomic, '300000'); +assert.equal(completed.award.amountAtomic, '2000000'); +assert.equal(completed.award.state, 'earned'); +assert.doesNotThrow(() => JSON.stringify(completed)); + +const receiptTrust = { + [RECEIPT_SIGNER_ID]: receiptSigner.publicKey.export({ type: 'spki', format: 'pem' }), +}; +const signedReceipt = signReceipt(buildInvocationReceipt({ + invocation: completed.invocation, + reservation: completed.reservation, + award: completed.award, + employerId: 'megacorp', + receiptSignerId: RECEIPT_SIGNER_ID, +}), receiptSigner.privateKey); +const employerReceipt = verifyReceipt(signedReceipt, { trustedReceiptSigners: receiptTrust }); +const employeeReceipt = verifyReceipt(signedReceipt, { trustedReceiptSigners: receiptTrust }); +assert.deepEqual(employerReceipt, employeeReceipt); + +const unsignedStatement = buildStatement({ + statementId: 'statement-megacorp-sam-2026-07', + employerId: 'megacorp', + creatorId: 'sam', + period: '2026-07', + currency: 'USD', + atomicScale: 6, + openingPayableAtomic: '0', + receipts: [signedReceipt], + payableAdvances: [], + reversals: [], + payments: [], + statementSignerId: STATEMENT_SIGNER_ID, +}); +assert.equal(unsignedStatement.earnedAwardTotalAtomic, '2000000'); +assert.equal(unsignedStatement.closingPayableAtomic, '0'); +const signedStatement = signStatement(unsignedStatement, statementSigner.privateKey); +const statementVerification = { + signedReceipts: [signedReceipt], + trustedReceiptSigners: receiptTrust, + trustedStatementSigners: { + [STATEMENT_SIGNER_ID]: statementSigner.publicKey.export({ type: 'spki', format: 'pem' }), + }, +}; +const employerStatement = verifyStatement(signedStatement, statementVerification); +const employeeStatement = verifyStatement(signedStatement, statementVerification); +assert.deepEqual(employerStatement, employeeStatement); + +function formatAtomic(value, scale) { + const amount = BigInt(value); + const denominator = 10n ** BigInt(scale); + return `${amount / denominator}.${String(amount % denominator).padStart(scale, '0')}`; +} + +console.log('INTERNAL INVOCATION AWARD SPIKE — SIMULATED ACCOUNTING, NO REAL FUNDS'); +console.log(`invocation ${completed.invocation.invocationId}: ${completed.invocation.state}`); +console.log(`reserved ${formatAtomic(authorized.reservation.reservedAtomic, 6)} USD`); +console.log(`consumed ${formatAtomic(completed.budget.consumedAtomic, 6)} USD`); +console.log(`released ${formatAtomic(completed.budget.releasedAtomic, 6)} USD`); +console.log(`employee-Creator Invocation award ${formatAtomic(completed.award.amountAtomic, 6)} USD: earned, not paid`); +console.log('external Wielder required: no'); +console.log('external Royalty-claim credits: 0'); +console.log('platform-held balance: 0'); +console.log('receipt signature: verified by employer and employee'); +console.log('statement signature and economic totals: verified by employer and employee'); +console.log('RESULT: accounting path demonstrated; demand, payroll, tax, employment-law, securities, and custody validation remain not-run'); From e97b8918858731a340bccea1396a512384c1d885 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:29:07 -0400 Subject: [PATCH 081/165] fix: snapshot verifier inputs before attestation replay --- phase0/src/attestation-config.ts | 3 +++ phase0/src/attestation-store.ts | 27 ++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/phase0/src/attestation-config.ts b/phase0/src/attestation-config.ts index dad9ba0..dd72fa8 100644 --- a/phase0/src/attestation-config.ts +++ b/phase0/src/attestation-config.ts @@ -109,6 +109,9 @@ export async function loadLocalCheckoutMap(input: { } let parsed: unknown; try { + if (await fs.realpath(configPath) !== configPath) { + throw new Error("repository snapshot mapping path must be canonical"); + } const metadata = await configHandle.stat(); if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error("repository snapshot mapping must be a non-symlink regular file"); if ((metadata.mode & 0o777) !== 0o600) throw new Error("repository snapshot mapping must have mode 0600"); diff --git a/phase0/src/attestation-store.ts b/phase0/src/attestation-store.ts index b68a6a0..fbcc462 100644 --- a/phase0/src/attestation-store.ts +++ b/phase0/src/attestation-store.ts @@ -69,6 +69,13 @@ function object(value: unknown, label: string): Record { return value as Record; } +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const item of Object.values(value as Record)) deepFreeze(item); + return value; +} + function parseLock(value: unknown): AttestationLockMetadata { const owner = object(value, "attestation store lock"); const expected = ["schemaVersion", "pid", "token", "targetPath", "acquiredAt"]; @@ -114,7 +121,15 @@ export class FileAttestationStore { if (!Array.isArray(options.baseSubjects)) throw new Error("attestation store requires verifier-provided base subjects"); this.path = path; this.lockPath = `${path}.lock`; - this.options = options; + this.options = { + ...options, + baseSubjects: deepFreeze(structuredClone(options.baseSubjects)), + organizationSigners: options.organizationSigners + ? deepFreeze(structuredClone(options.organizationSigners)) + : undefined, + adminSigners: options.adminSigners ? deepFreeze(structuredClone(options.adminSigners)) : undefined, + forgeSigners: options.forgeSigners ? deepFreeze(structuredClone(options.forgeSigners)) : undefined, + }; } async load(): Promise { @@ -218,11 +233,17 @@ export class FileAttestationStore { return await handle.readFile("utf8"); } finally { await handle.close(); } }); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return { events: [], bytes: "" }; + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + await this.validateEvents([]); + return { events: [], bytes: "" }; + } if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("attestation log must not be a symlink", { cause: error }); throw error; } - if (bytes === "") return { events: [], bytes }; + if (bytes === "") { + await this.validateEvents([]); + return { events: [], bytes }; + } if (!bytes.endsWith("\n")) throw new Error("attestation log has a malformed trailing fragment"); const lines = bytes.slice(0, -1).split("\n"); if (lines.some((line) => line.length === 0)) throw new Error("attestation log contains an empty or malformed line"); From f155480f4442cfaafe4894f494883a9f120d6cd6 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:30:03 -0400 Subject: [PATCH 082/165] fix: bind credentials and receipt journals --- .../internal-invocation-awards/src/engine.mjs | 40 +++++-------------- .../src/statements.mjs | 18 ++++++++- .../test/engine.test.mjs | 1 + .../test/statements.test.mjs | 7 ++++ 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/spikes/internal-invocation-awards/src/engine.mjs b/spikes/internal-invocation-awards/src/engine.mjs index d1d13e1..2b0e31d 100644 --- a/spikes/internal-invocation-awards/src/engine.mjs +++ b/spikes/internal-invocation-awards/src/engine.mjs @@ -302,6 +302,13 @@ export async function authorizeInternalInvocation(input) { const issuedAt = parseUtc(input.credentialIssuedAt, 'credential issuedAt'); const at = parseUtc(input.now, 'now'); if (issuedAt > at) throw new Error('credential issuedAt cannot be in the future'); + const earliestIssue = Math.max( + parseUtc(validatedPolicy.effectiveAt, 'policy effectiveAt'), + parseUtc(current.budget.authorization.effectiveAt, 'budget effectiveAt'), + ); + if (issuedAt < earliestIssue) { + throw new Error('credential issuedAt precedes the effective policy or budget'); + } if (requestedExpiry <= issuedAt || requestedExpiry <= at) { throw new Error('credential expiry must follow issuance and authorization'); } @@ -358,6 +365,7 @@ export async function authorizeInternalInvocation(input) { atomicScale: current.budget.atomicScale, state: 'authorized', revision: 0, + credentialPayload, credentialNonce: input.credentialNonce, credentialIssuedAt: input.credentialIssuedAt, credentialExpiresAt: expiresAt, @@ -414,22 +422,7 @@ export async function authorizeInternalInvocation(input) { budget: state.budget, invocation, reservation, - credentialPayload: state.invocations[input.quote.invocationId] - ? deepFreeze({ - schemaVersion: 1, - credentialAuthorizerId: input.credentialAuthorizerId, - invocationId: invocation.invocationId, - reservationId: reservation.reservationId, - idempotencyKey: invocation.idempotencyKey, - skillId: invocation.skillId, - skillVersionHash: invocation.skillVersionHash, - policyId: invocation.policyId, - policyVersion: invocation.policyVersion, - nonce: invocation.credentialNonce, - issuedAt: invocation.credentialIssuedAt, - expiresAt: invocation.credentialExpiresAt, - }) - : null, + credentialPayload: invocation?.credentialPayload ?? null, events: deepFreeze(state.events.slice(beforeCount)), }); } @@ -512,20 +505,7 @@ export async function executeAuthorizedInvocation(input) { const trustedKey = current.credentialAuthorizers[authorizerId]; if (!trustedKey) throw new Error('credential authorizer is not provisioned'); const credentialPayload = verifyCredential(input.credential, trustedKey, input.now); - const expectedPayload = { - schemaVersion: 1, - credentialAuthorizerId: authorizerId, - invocationId: invocation.invocationId, - reservationId: reservation.reservationId, - idempotencyKey: invocation.idempotencyKey, - skillId: invocation.skillId, - skillVersionHash: invocation.skillVersionHash, - policyId: invocation.policyId, - policyVersion: invocation.policyVersion, - nonce: invocation.credentialNonce, - issuedAt: invocation.credentialIssuedAt, - expiresAt: invocation.credentialExpiresAt, - }; + const expectedPayload = invocation.credentialPayload; if (!Buffer.from(canonicalCredentialBytes(credentialPayload)) .equals(Buffer.from(canonicalCredentialBytes(expectedPayload)))) { throw new Error('credential does not match persisted authorization'); diff --git a/spikes/internal-invocation-awards/src/statements.mjs b/spikes/internal-invocation-awards/src/statements.mjs index 3fe0f22..be95e2d 100644 --- a/spikes/internal-invocation-awards/src/statements.mjs +++ b/spikes/internal-invocation-awards/src/statements.mjs @@ -164,13 +164,27 @@ function validateReceiptPayload(input) { if (journalTotal !== consumed || journalEntries.length !== 4) { throw new Error('receipt journal entries do not conserve consumed gross'); } + const expectedJournal = [ + ['execution-cogs', 'provider:execution', input.executionCostAtomic], + ['protocol-fee', 'protocol:treasury', input.protocolFeeAtomic], + ['refund-reserve', 'reserve:refund', input.refundReserveAtomic], + ['invocation-award', `employee:${input.creatorId}`, input.invocationAwardAtomic], + ]; + for (const [index, [category, creditAccountId, amountAtomic]] of expectedJournal.entries()) { + const entry = journalEntries[index]; + if (entry.category !== category + || entry.creditAccountId !== creditAccountId + || entry.amountAtomic !== amountAtomic) { + throw new Error('receipt journal entries do not match the shared atomic allocation'); + } + } } else if (input.invocationState === 'failed') { if (input.receiptType !== 'internal_invocation_finalized' || input.reservationState !== 'released' || input.executionCostStatus !== 'known' || input.executionCostAtomic === null || input.outputHash !== null - || typeof input.failureClass !== 'string' + || !['provider_error', 'skill_error', 'invalid_output'].includes(input.failureClass) || input.unresolvedReason !== null || input.protocolFeeAtomic !== '0' || input.refundReserveAtomic !== '0' @@ -187,7 +201,7 @@ function validateReceiptPayload(input) { || input.executionCostAtomic !== null || input.outputHash !== null || input.failureClass !== null - || typeof input.unresolvedReason !== 'string' + || !['executor_threw', 'malformed_outcome', 'cost_unknown'].includes(input.unresolvedReason) || input.protocolFeeAtomic !== '0' || input.refundReserveAtomic !== '0' || input.invocationAwardAtomic !== '0' diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs index 9adbfb2..8db24f3 100644 --- a/spikes/internal-invocation-awards/test/engine.test.mjs +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -178,6 +178,7 @@ test('authorization reserves before signing and successful execution conserves e const authorized = await authorize(fx, q); assert.equal(authorized.reservation.state, 'reserved'); assert.equal(authorized.credentialPayload.expiresAt, q.expiresAt); + assert.deepEqual(authorized.invocation.credentialPayload, authorized.credentialPayload); assert.equal(authorized.invocation.state, 'authorized'); assert.equal(authorized.invocation.externalRoyaltyCreditsAtomic, '0'); assert.equal(authorized.invocation.employerSelfCreditAtomic, '0'); diff --git a/spikes/internal-invocation-awards/test/statements.test.mjs b/spikes/internal-invocation-awards/test/statements.test.mjs index 5372064..6c77449 100644 --- a/spikes/internal-invocation-awards/test/statements.test.mjs +++ b/spikes/internal-invocation-awards/test/statements.test.mjs @@ -190,6 +190,13 @@ test('employer and employee verify identical exact receipt bytes through a trust () => verifyReceipt({ ...signed, publicKeyPem: 'self-declared' }, { trustedReceiptSigners: signers.receiptTrust }), /unknown key publicKeyPem/, ); + const wrongJournal = { + ...unsigned, + journalEntries: unsigned.journalEntries.map((entry, index) => ( + index === 3 ? { ...entry, creditAccountId: 'employee:attacker' } : entry + )), + }; + assert.throws(() => signReceipt(wrongJournal, signers.receipt.privateKey), /shared atomic allocation/); }); test('unresolved receipt claims neither zero COGS nor release nor award', () => { From 3c7d6171813319178eda8b9db88e90f97c03d71d Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:32:53 -0400 Subject: [PATCH 083/165] feat: expose honest Phase 0 attestation status --- phase0/README.md | 85 +++++++ phase0/package.json | 8 + phase0/src/attestation-cli.ts | 344 +++++++++++++++++++++++++++ phase0/src/attestations.ts | 4 +- phase0/src/index.ts | 65 ++++- phase0/tests/attestation-cli.test.ts | 196 +++++++++++++++ phase0/tests/attestations.test.ts | 23 ++ phase0/tests/index.test.ts | 19 +- 8 files changed, 736 insertions(+), 8 deletions(-) create mode 100644 phase0/src/attestation-cli.ts create mode 100644 phase0/tests/attestation-cli.test.ts diff --git a/phase0/README.md b/phase0/README.md index ed84552..22545d9 100644 --- a/phase0/README.md +++ b/phase0/README.md @@ -36,6 +36,91 @@ transaction hashes, event-derived IP IDs and license fields, declared parent edges, fee caps, and exact metadata URI/hash pairs. These are wallet and chain facts, not proof that the wallet authored the artifacts. +## Offline attestation sidecar + +Registration remains immutable. Optional evidence is recorded in the local, +ignored `attestations.jsonl` append-only sidecar and rendered at one of three +levels: + +1. `wallet_asserted`: a wallet registered these bytes and declared this + ancestry; +2. `repository_control_verified`: a wallet signature and matching bytes were + verified against a trusted forge observation and verifier-provisioned Git + snapshot; +3. `organization_approved`: a named, allow-listed organization signer approved + the Skill and Creator relationship. + +Safety review is a separate status. Duplicate artifact bytes registered by +different wallets produce a visible deterministic conflict; no arrival order +chooses an owner. Challenge openings must be signed by the existing challenger +registration wallet. Resolution and revocation bundles must be pre-signed by an +admin listed in `attestation-admins.json`. Revocation removes only the named +higher evidence level and does not delete registration, event, conflict, or +chain history. The `wallet_asserted` confirmed-proof floor cannot be created or +revoked through the sidecar. + +Inspect evidence without a network or chain write: + +```bash +npm run attestation-status -- --artifact-hash 0x<64-lowercase-hex> --json +npm run attestation-status -- --registration-id eip155:1315:0x<40-lowercase-hex> +npm run attestation-conflicts -- --json +``` + +Append only fully signed, offline-verifiable bundles: + +```bash +npm run attestation-verify-repository -- --bundle /absolute/path/repository-bundle.json +npm run attestation-verify-organization -- --bundle /absolute/path/organization-event.json +npm run attestation-append-challenge -- --bundle /absolute/path/challenge-event.json +npm run attestation-resolve -- --bundle /absolute/path/resolution-event.json +npm run attestation-revoke -- --bundle /absolute/path/revocation-event.json +``` + +Repository bundles contain the wallet-signed challenge and the forge +observation. They do not accept a repository path, trusted ref, public key, or +trust-root override. `repository-trust.json` fixes the repository URL, checkout +key, trusted ref, and allowed forge signer IDs. Public forge keys live in +`forge-signers.json`; both trust-root files are empty by default. + +The verifier resolves each checkout key through the machine-local file +`phase0/.attestation-checkouts.local.json`: + +```json +{ + "schemaVersion": 1, + "checkouts": { + "example-checkout-key": "/canonical/absolute/path/to/verifier-checkout" + } +} +``` + +The file must be a non-symlink regular file owned by the current user with mode +`0600` (`chmod 600 phase0/.attestation-checkouts.local.json`). Every checkout +must already be its canonical absolute real path, be owned by the current user, +and not be group- or world-writable. An optional absolute +`PHASE0_ATTESTATION_CHECKOUTS_FILE` may point outside the repository; an +in-repository override must equal the exact ignored default path. Missing +configuration fails with `repository snapshot mapping unavailable` before Git +runs. This machine-local mapping must never be staged, copied into a bundle, or +used as claimant evidence. + +`repository_control_verified` means a trusted forge observer and a +verifier-provisioned Git snapshot matched the wallet-signed bytes at an +observation time. It does not prove current remote account ownership or +continuing hosting. + +An attestation records evidence about who made or approved a registration. It +does not prove originality, legal ownership, absence of prior art, or Skill +safety. Safety review is a separate status. + +The local sidecar uses an owner-only lock and exact-token stale recovery. Inspect +the printed lock metadata and prove its PID is absent before running: + +```bash +npm run attestation-recover-lock -- --lock-token +``` + Before broadcast, the demo signs the transaction locally and atomically saves its hash, serialized testnet transaction, and canonical operation intent to the mode-0600, ignored `pending-transactions.json`. The whole demo holds an exclusive diff --git a/phase0/package.json b/phase0/package.json index ca92ea0..d927aec 100644 --- a/phase0/package.json +++ b/phase0/package.json @@ -8,6 +8,14 @@ "check": "node --import tsx src/index.ts check", "demo": "node --import tsx src/index.ts demo", "recover-stale-lock": "node --import tsx src/index.ts recover-stale-lock", + "attestation-status": "node --import tsx src/index.ts attestation-status", + "attestation-verify-repository": "node --import tsx src/index.ts attestation-verify-repository", + "attestation-verify-organization": "node --import tsx src/index.ts attestation-verify-organization", + "attestation-append-challenge": "node --import tsx src/index.ts attestation-append-challenge", + "attestation-resolve": "node --import tsx src/index.ts attestation-resolve", + "attestation-conflicts": "node --import tsx src/index.ts attestation-conflicts", + "attestation-revoke": "node --import tsx src/index.ts attestation-revoke", + "attestation-recover-lock": "node --import tsx src/index.ts attestation-recover-lock", "test": "node --import tsx --test tests/*.test.ts", "typecheck": "tsc --noEmit" }, diff --git a/phase0/src/attestation-cli.ts b/phase0/src/attestation-cli.ts new file mode 100644 index 0000000..719e931 --- /dev/null +++ b/phase0/src/attestation-cli.ts @@ -0,0 +1,344 @@ +import { readFile, realpath } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { + displayAttestation, + parseAttestationEvent, + reduceAttestationEvents, + registrationSubjectsFromManifest, + type AttestationEvent, + type AttestationIndex, + type ForgeObservationV1, + type RegistrationSubject, +} from "./attestations"; +import { + createTrustedRepositoryResolver, + loadLocalCheckoutMap, + parseRepositoryTrustConfig, + referencedCheckoutKeys, +} from "./attestation-config"; +import { + canonicalChallengeFileBytes, + ExecGitReader, + verifyRepositoryControl, + type GitReader, + type SignedRepositoryChallengeFileV1, +} from "./attestation-git"; +import { FileAttestationStore, type AttestationRepositoryContext } from "./attestation-store"; +import { FileRegistrationStore } from "./registrations"; + +export type AttestationCommand = + | "attestation-status" + | "attestation-verify-repository" + | "attestation-verify-organization" + | "attestation-append-challenge" + | "attestation-resolve" + | "attestation-conflicts" + | "attestation-revoke" + | "attestation-recover-lock"; + +export interface AttestationCommandOptions { + artifactHash?: string; + registrationId?: string; + bundle?: string; + lockToken?: string; + json?: boolean; +} + +const DEFAULT_PHASE0_ROOT = fileURLToPath(new URL("../", import.meta.url)); +const DEFAULT_PATHS = { + registrations: fileURLToPath(new URL("../registrations.json", import.meta.url)), + attestations: fileURLToPath(new URL("../attestations.jsonl", import.meta.url)), + organizations: fileURLToPath(new URL("../organization-signers.json", import.meta.url)), + admins: fileURLToPath(new URL("../attestation-admins.json", import.meta.url)), + repositories: fileURLToPath(new URL("../repository-trust.json", import.meta.url)), + forgeSigners: fileURLToPath(new URL("../forge-signers.json", import.meta.url)), +}; + +export interface AttestationRuntimePaths { + registrations: string; + attestations: string; + organizations: string; + admins: string; + repositories: string; + forgeSigners: string; +} + +export interface AttestationRuntimeInput { + phase0Root?: string; + paths?: AttestationRuntimePaths; + env?: Readonly>; + git?: GitReader; + now?: () => Date; +} + +function object(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} has unexpected or missing fields`); +} + +async function jsonFile(path: string, label: string): Promise { + try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { + throw new Error(`${label} could not be loaded`, { cause: error }); + } +} + +function validateNoPrivateMaterial(value: unknown, label: string): void { + const text = JSON.stringify(value); + if (/private[_ -]?key|BEGIN [A-Z ]*PRIVATE KEY/i.test(text)) throw new Error(`${label} must never contain private key material`); +} + +async function organizationSigners(path: string): Promise>> { + const value = object(await jsonFile(path, "organization signer trust"), "organization signer trust"); + exactKeys(value, ["schemaVersion", "organizations"], "organization signer trust"); + if (value.schemaVersion !== 1) throw new Error("organization signer trust schemaVersion must be 1"); + const organizations = object(value.organizations, "organization signer allow-list"); + const result: Record = {}; + for (const [organizationId, walletsValue] of Object.entries(organizations)) { + if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(organizationId) || !Array.isArray(walletsValue)) throw new Error("organization signer allow-list is malformed"); + const wallets = walletsValue.map((wallet) => { + if (typeof wallet !== "string" || !/^0x[0-9a-f]{40}$/.test(wallet)) throw new Error("organization signer must be a lowercase address"); + return wallet as `0x${string}`; + }); + if (new Set(wallets).size !== wallets.length) throw new Error("organization signer addresses must be unique"); + result[organizationId] = Object.freeze(wallets); + } + validateNoPrivateMaterial(value, "organization signer trust"); + return Object.freeze(result); +} + +async function adminSigners(path: string): Promise>> { + const value = object(await jsonFile(path, "attestation admin trust"), "attestation admin trust"); + exactKeys(value, ["schemaVersion", "admins"], "attestation admin trust"); + if (value.schemaVersion !== 1) throw new Error("attestation admin trust schemaVersion must be 1"); + const admins = object(value.admins, "attestation admins"); + const result: Record = {}; + const wallets = new Set(); + for (const [id, wallet] of Object.entries(admins)) { + if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(id) || typeof wallet !== "string" || !/^0x[0-9a-f]{40}$/.test(wallet)) throw new Error("attestation admin trust is malformed"); + if (wallets.has(wallet)) throw new Error("attestation admin addresses must be unique"); + wallets.add(wallet); + result[id] = wallet as `0x${string}`; + } + validateNoPrivateMaterial(value, "attestation admin trust"); + return Object.freeze(result); +} + +async function forgeSigners(path: string): Promise>> { + const value = object(await jsonFile(path, "forge signer trust"), "forge signer trust"); + exactKeys(value, ["schemaVersion", "forgeSigners"], "forge signer trust"); + if (value.schemaVersion !== 1) throw new Error("forge signer trust schemaVersion must be 1"); + const signers = object(value.forgeSigners, "forge signers"); + const result: Record = {}; + for (const [id, publicKey] of Object.entries(signers)) { + if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(id) || typeof publicKey !== "string" || !publicKey.includes("PUBLIC KEY")) throw new Error("forge signer trust is malformed"); + result[id] = publicKey; + } + validateNoPrivateMaterial(value, "forge signer trust"); + return Object.freeze(result); +} + +export interface AttestationRuntime { + store: FileAttestationStore; + baseSubjects: readonly RegistrationSubject[]; + organizationSigners: Readonly>; + adminSigners: Readonly>; + repositoryContext: () => Promise; + now: () => Date; +} + +export async function createAttestationRuntime(input: AttestationRuntimeInput = {}): Promise { + const selectedPaths = input.paths ?? DEFAULT_PATHS; + const selectedRoot = input.phase0Root ?? DEFAULT_PHASE0_ROOT; + const manifest = await new FileRegistrationStore(selectedPaths.registrations).load(); + const baseSubjects = registrationSubjectsFromManifest(manifest); + const organizations = await organizationSigners(selectedPaths.organizations); + const admins = await adminSigners(selectedPaths.admins); + let contextPromise: Promise | null = null; + const repositoryContext = () => { + contextPromise ??= (async () => { + const trustValue = await jsonFile(selectedPaths.repositories, "repository trust"); + const trust = parseRepositoryTrustConfig(trustValue); + const canonicalRoot = await realpath(selectedRoot); + const checkoutPaths = await loadLocalCheckoutMap({ + env: input.env ?? process.env, + phase0Root: canonicalRoot, + referencedCheckoutKeys: referencedCheckoutKeys(trust), + }); + return { + repositories: createTrustedRepositoryResolver({ trustConfig: trust, checkoutPaths }), + forgeSigners: await forgeSigners(selectedPaths.forgeSigners), + git: input.git ?? new ExecGitReader(), + }; + })(); + return contextPromise; + }; + return { + baseSubjects, + organizationSigners: organizations, + adminSigners: admins, + repositoryContext, + now: input.now ?? (() => new Date()), + store: new FileAttestationStore(selectedPaths.attestations, { + baseSubjects, + organizationSigners: organizations, + adminSigners: admins, + repositoryContextLoader: repositoryContext, + }), + }; +} + +async function loadIndex(runtime: AttestationRuntime): Promise { + const events = await runtime.store.load(); + let verifier: ((event: Extract) => Promise) | undefined; + if (events.some((event) => event.type === "repository_control_verified")) { + const context = await runtime.repositoryContext(); + const { reverifyRepositoryEvent } = await import("./attestation-git"); + verifier = (event) => reverifyRepositoryEvent(event, context); + } + return reduceAttestationEvents(events, { + baseSubjects: runtime.baseSubjects, + organizationSigners: runtime.organizationSigners, + adminSigners: runtime.adminSigners, + repositoryVerifier: verifier, + }); +} + +function statusPayload(index: AttestationIndex, options: AttestationCommandOptions): { + registrations: unknown[]; + conflicts: unknown[]; +} { + if (options.artifactHash !== undefined && !/^0x[0-9a-f]{64}$/.test(options.artifactHash)) throw new Error("--artifact-hash must be a lowercase 32-byte hash"); + if (options.registrationId !== undefined && !/^eip155:1315:0x[0-9a-f]{40}$/.test(options.registrationId)) throw new Error("--registration-id must be an Aeneid registration ID"); + if (options.artifactHash !== undefined && options.registrationId !== undefined) throw new Error("choose only one of --artifact-hash or --registration-id"); + const selected = Object.entries(index.registrations).filter(([id, registration]) => + (options.artifactHash === undefined || registration.subject.artifactHash === options.artifactHash) + && (options.registrationId === undefined || id === options.registrationId)); + const ids = new Set(selected.map(([id]) => id)); + return { + registrations: selected.map(([registrationId, registration]) => ({ + registrationId, + subject: registration.subject, + ...displayAttestation(index, registrationId), + revocations: registration.revocations, + })), + conflicts: index.conflicts.filter((conflict) => conflict.registrationIds.some((id) => ids.has(id))), + }; +} + +export function renderAttestationStatus(index: AttestationIndex, options: AttestationCommandOptions = {}): string[] { + const payload = statusPayload(index, options); + if (options.json) return [JSON.stringify(payload, null, 2)]; + if (payload.registrations.length === 0) return ["No matching confirmed registrations.", "conflicts: 0"]; + const lines: string[] = []; + for (const itemValue of payload.registrations) { + const item = itemValue as ReturnType & { registrationId: string }; + lines.push(`registration: ${item.registrationId}`); + lines.push(`attestation: ${item.level}`); + lines.push(`claim: ${item.claim}`); + lines.push(`safety review: ${item.safetyReviewStatus}`); + for (const warning of item.warnings) lines.push(`warning: ${warning}`); + } + lines.push(`conflicts: ${payload.conflicts.length}`); + return lines; +} + +async function readBundle(path: string): Promise { + if (!path.startsWith("/")) throw new Error("--bundle must be an absolute path"); + return jsonFile(path, "pre-signed attestation bundle"); +} + +async function appendTypedBundle(runtime: AttestationRuntime, bundlePath: string, expectedType: AttestationEvent["type"]): Promise { + const event = parseAttestationEvent(await readBundle(bundlePath)); + if (event.type !== expectedType) throw new Error(`bundle must contain a ${expectedType} event`); + await runtime.store.append(event); + return event; +} + +function assertOnlyOptions(options: AttestationCommandOptions, permitted: readonly (keyof AttestationCommandOptions)[]): void { + for (const [key, value] of Object.entries(options)) { + if (value !== undefined && value !== false && !permitted.includes(key as keyof AttestationCommandOptions)) throw new Error(`option --${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} is not valid for this command`); + } +} + +function outputValue(value: unknown, json: boolean): string[] { + return json ? [JSON.stringify(value, null, 2)] : [typeof value === "string" ? value : JSON.stringify(value)]; +} + +export async function executeAttestationCommand( + command: AttestationCommand, + options: AttestationCommandOptions, + log: (line: string) => void = console.log, + runtimeInput: AttestationRuntimeInput = {}, +): Promise { + const runtime = await createAttestationRuntime(runtimeInput); + let lines: string[]; + if (command === "attestation-status") { + assertOnlyOptions(options, ["artifactHash", "registrationId", "json"]); + lines = renderAttestationStatus(await loadIndex(runtime), options); + } else if (command === "attestation-conflicts") { + assertOnlyOptions(options, ["json"]); + const conflicts = (await loadIndex(runtime)).conflicts; + lines = outputValue({ conflicts }, Boolean(options.json)); + } else if (command === "attestation-verify-repository") { + assertOnlyOptions(options, ["bundle", "json"]); + if (!options.bundle) throw new Error("attestation-verify-repository requires --bundle "); + const bundle = object(await readBundle(options.bundle), "repository attestation bundle"); + exactKeys(bundle, ["schemaVersion", "eventId", "sequence", "occurredAt", "challengeFile", "forgeObservation"], "repository attestation bundle"); + if (bundle.schemaVersion !== 1 || typeof bundle.eventId !== "string" || !Number.isSafeInteger(bundle.sequence) || typeof bundle.occurredAt !== "string") throw new Error("repository attestation bundle metadata is malformed"); + const signedFileObject = object(bundle.challengeFile, "signed challenge file"); + exactKeys(signedFileObject, ["challenge", "statementHash", "signature"], "signed challenge file"); + const signedFile = signedFileObject as unknown as SignedRepositoryChallengeFileV1; + const context = await runtime.repositoryContext(); + const event = await verifyRepositoryControl({ + challengeFile: canonicalChallengeFileBytes(signedFile), + forgeObservation: bundle.forgeObservation as ForgeObservationV1, + eventId: bundle.eventId, + sequence: bundle.sequence as number, + occurredAt: bundle.occurredAt, + now: runtime.now(), + ...context, + }); + await runtime.store.append(event); + lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + } else if (command === "attestation-verify-organization") { + assertOnlyOptions(options, ["bundle", "json"]); + if (!options.bundle) throw new Error("attestation-verify-organization requires --bundle "); + const event = await appendTypedBundle(runtime, options.bundle, "organization_approved"); + lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + } else if (command === "attestation-append-challenge") { + assertOnlyOptions(options, ["bundle", "json"]); + if (!options.bundle) throw new Error("attestation-append-challenge requires --bundle "); + const event = await appendTypedBundle(runtime, options.bundle, "challenge_opened"); + lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + } else if (command === "attestation-resolve") { + assertOnlyOptions(options, ["bundle", "json"]); + if (!options.bundle) throw new Error("attestation-resolve requires --bundle "); + const event = await appendTypedBundle(runtime, options.bundle, "challenge_resolved"); + lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + } else if (command === "attestation-revoke") { + assertOnlyOptions(options, ["bundle", "json"]); + if (!options.bundle) throw new Error("attestation-revoke requires --bundle "); + const event = await appendTypedBundle(runtime, options.bundle, "attestation_revoked"); + lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + } else { + assertOnlyOptions(options, ["lockToken", "json"]); + if (!options.lockToken) throw new Error("attestation-recover-lock requires --lock-token "); + const metadata = await runtime.store.readLockMetadata(); + const isProcessAlive = (pid: number): boolean => { + try { process.kill(pid, 0); return true; } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw new Error(`cannot prove PID ${pid} is absent`, { cause: error }); + } + }; + await runtime.store.recoverStaleLock({ expectedToken: options.lockToken, isProcessAlive }); + lines = outputValue({ recovered: true, lock: metadata }, Boolean(options.json)); + } + for (const line of lines) log(line); +} diff --git a/phase0/src/attestations.ts b/phase0/src/attestations.ts index 78c593b..6b8eb3d 100644 --- a/phase0/src/attestations.ts +++ b/phase0/src/attestations.ts @@ -620,13 +620,13 @@ export async function verifyAdminEventSignature( export function registrationSubjectsFromManifest(manifest: RegistrationManifest): RegistrationSubject[] { if (manifest.status === "not-run") return []; if (!manifest.wallet) throw new Error("confirmed registration manifest wallet is required"); - return (Object.values(manifest.registrations).filter((proof) => proof !== null)).map((proof) => ({ + return deepFreeze((Object.values(manifest.registrations).filter((proof) => proof !== null)).map((proof) => ({ registrationId: `eip155:1315:${proof.ipId.toLowerCase()}` as `eip155:1315:${string}`, ipId: proof.ipId.toLowerCase() as `0x${string}`, wallet: manifest.wallet!.toLowerCase() as `0x${string}`, artifactHash: proof.metadata.artifact.mediaHash.toLowerCase() as `0x${string}`, declaredParentIpIds: proof.parentIpIds.map((parent) => parent.toLowerCase() as `0x${string}`), - })); + }))); } export function deterministicConflictId(a: RegistrationSubject, b: RegistrationSubject): string { diff --git a/phase0/src/index.ts b/phase0/src/index.ts index 341b3f4..3985d38 100644 --- a/phase0/src/index.ts +++ b/phase0/src/index.ts @@ -2,6 +2,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import { FileOperationJournal } from "./transactions"; +import type { AttestationCommand, AttestationCommandOptions } from "./attestation-cli"; const registrationsPath = fileURLToPath(new URL("../registrations.json", import.meta.url)); const pendingTransactionsPath = fileURLToPath( @@ -80,6 +81,7 @@ export interface CommandDependencies { check(): Promise; demo(): Promise; recoverStaleLock(expectedLeaseId: string): Promise; + attestation?(command: AttestationCommand, options: AttestationCommandOptions): Promise; log(line?: string): void; } @@ -87,6 +89,10 @@ const REAL_DEPENDENCIES: CommandDependencies = { check, demo, recoverStaleLock, + attestation: async (command, options) => { + const { executeAttestationCommand } = await import("./attestation-cli"); + await executeAttestationCommand(command, options); + }, log: (line = "") => console.log(line), }; @@ -97,11 +103,35 @@ function printHelp(log: CommandDependencies["log"]): void { log(" npm run demo"); log(" npm run check"); log(" npm run recover-stale-lock -- "); + log(" npm run attestation-status -- [--artifact-hash | --registration-id ] [--json]"); + log(" npm run attestation-conflicts -- [--json]"); + log(" npm run attestation-verify-repository -- --bundle [--json]"); + log(" npm run attestation-verify-organization -- --bundle [--json]"); + log(" npm run attestation-append-challenge -- --bundle [--json]"); + log(" npm run attestation-resolve -- --bundle [--json]"); + log(" npm run attestation-revoke -- --bundle [--json]"); + log(" npm run attestation-recover-lock -- --lock-token [--json]"); +} + +const ATTESTATION_COMMANDS = new Set([ + "attestation-status", + "attestation-verify-repository", + "attestation-verify-organization", + "attestation-append-challenge", + "attestation-resolve", + "attestation-conflicts", + "attestation-revoke", + "attestation-recover-lock", +]); + +function hasOptions(options: AttestationCommandOptions): boolean { + return Object.values(options).some((value) => value !== undefined && value !== false); } export async function runCommand( positionals: readonly string[], dependencies: CommandDependencies = REAL_DEPENDENCIES, + options: AttestationCommandOptions = {}, ): Promise { const [command, ...args] = positionals; if (!command) { @@ -109,17 +139,17 @@ export async function runCommand( return 0; } if (command === "check") { - if (args.length !== 0) throw new Error("Usage: npm run check"); + if (args.length !== 0 || hasOptions(options)) throw new Error("Usage: npm run check"); await dependencies.check(); return 0; } if (command === "demo") { - if (args.length !== 0) throw new Error("Usage: npm run demo"); + if (args.length !== 0 || hasOptions(options)) throw new Error("Usage: npm run demo"); await dependencies.demo(); return 0; } if (command === "recover-stale-lock") { - if (args.length !== 1) { + if (args.length !== 1 || hasOptions(options)) { throw new Error("Usage: npm run recover-stale-lock -- "); } if (!/^[0-9a-f]{32}$/.test(args[0])) { @@ -129,13 +159,38 @@ export async function runCommand( dependencies.log(`Stale-lock recovery complete for lease ${args[0]}`); return 0; } + if (ATTESTATION_COMMANDS.has(command as AttestationCommand)) { + if (args.length !== 0) throw new Error(`Usage: npm run ${command} -- [options]`); + const handler = dependencies.attestation ?? (async (selected, selectedOptions) => { + const { executeAttestationCommand } = await import("./attestation-cli"); + await executeAttestationCommand(selected, selectedOptions, (line) => dependencies.log(line)); + }); + await handler(command as AttestationCommand, options); + return 0; + } printHelp(dependencies.log); return 1; } async function main(): Promise { - const { positionals } = parseArgs({ allowPositionals: true, strict: true }); - process.exitCode = await runCommand(positionals); + const { positionals, values } = parseArgs({ + allowPositionals: true, + strict: true, + options: { + "artifact-hash": { type: "string" }, + "registration-id": { type: "string" }, + bundle: { type: "string" }, + "lock-token": { type: "string" }, + json: { type: "boolean", default: false }, + }, + }); + process.exitCode = await runCommand(positionals, REAL_DEPENDENCIES, { + artifactHash: values["artifact-hash"], + registrationId: values["registration-id"], + bundle: values.bundle, + lockToken: values["lock-token"], + json: values.json, + }); } const invokedPath = process.argv[1]; diff --git a/phase0/tests/attestation-cli.test.ts b/phase0/tests/attestation-cli.test.ts new file mode 100644 index 0000000..26ee114 --- /dev/null +++ b/phase0/tests/attestation-cli.test.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { createHash, generateKeyPairSync, sign as signBytes } from "node:crypto"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +import { executeAttestationCommand, type AttestationRuntimePaths } from "../src/attestation-cli"; +import { canonicalRepositoryStatement, repositoryStatementHash } from "../src/attestations"; +import { canonicalForgeObservationBytes, type GitReader } from "../src/attestation-git"; +import { createEmptyRegistrationManifest, FileRegistrationStore, type RegistrationProof } from "../src/registrations"; + +const REPOSITORY_URL = "https://github.com/example/attestation-cli"; +const NOW = new Date("2026-07-18T04:00:00.000Z"); + +function git(repositoryPath: string, ...args: string[]): string { + return execFileSync("git", ["-C", repositoryPath, ...args], { + encoding: "utf8", + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" }, + }).trim(); +} + +test("production repository command fails before Git for missing/insecure mapping and succeeds with canonical 0600 mapping", async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), "phase0-attestation-cli-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const checkout = join(root, "checkout"); + await mkdir(checkout, { mode: 0o700 }); + git(checkout, "init", "-b", "main"); + git(checkout, "config", "user.name", "Offline CLI Test"); + git(checkout, "config", "user.email", "offline-cli@example.invalid"); + git(checkout, "remote", "add", "origin", REPOSITORY_URL); + const artifact = Buffer.from("# CLI Skill\n\nExact offline bytes.\n"); + await mkdir(join(checkout, "skills/demo"), { recursive: true }); + await writeFile(join(checkout, "skills/demo/SKILL.md"), artifact); + git(checkout, "add", "skills/demo/SKILL.md"); + git(checkout, "commit", "-m", "add CLI Skill bytes"); + const artifactCommitSha = git(checkout, "rev-parse", "HEAD"); + + const wallet = privateKeyToAccount(generatePrivateKey()); + const ipId = `0x${"a".repeat(40)}` as const; + const artifactHash = `0x${createHash("sha256").update(artifact).digest("hex")}` as `0x${string}`; + const manifest = createEmptyRegistrationManifest(); + manifest.status = "partial"; + manifest.wallet = wallet.address.toLowerCase() as `0x${string}`; + manifest.spgNftContract = `0x${"b".repeat(40)}`; + manifest.collectionTxHash = `0x${"c".repeat(64)}`; + manifest.registrations.root = { + stage: "root", + kind: "Skill", + name: "CLI Skill", + ipId, + tokenId: "1", + txHash: `0x${"d".repeat(64)}`, + licenseTermsId: "1", + licenseTemplate: `0x${"e".repeat(40)}`, + parentIpIds: [], + defaultMintingFee: "1", + maxMintingFee: null, + metadata: { + ip: { uri: "https://example.invalid/ip", hash: `0x${"1".repeat(64)}` }, + nft: { uri: "https://example.invalid/nft", hash: `0x${"2".repeat(64)}` }, + artifact: { path: "skills/demo/SKILL.md", mediaHash: artifactHash, mediaType: "text/markdown" }, + }, + } satisfies RegistrationProof; + + const paths: AttestationRuntimePaths = { + registrations: join(root, "registrations.json"), + attestations: join(root, "attestations.jsonl"), + organizations: join(root, "organization-signers.json"), + admins: join(root, "attestation-admins.json"), + repositories: join(root, "repository-trust.json"), + forgeSigners: join(root, "forge-signers.json"), + }; + await new FileRegistrationStore(paths.registrations).save(manifest); + await writeFile(paths.organizations, '{"schemaVersion":1,"organizations":{}}\n'); + await writeFile(paths.admins, '{"schemaVersion":1,"admins":{}}\n'); + + const challenge = { + schemaVersion: 1 as const, + subject: { + registrationId: `eip155:1315:${ipId}` as const, + ipId, + wallet: manifest.wallet, + artifactHash, + declaredParentIpIds: [], + }, + repositoryUrl: REPOSITORY_URL, + artifactCommitSha, + artifactPath: "skills/demo/SKILL.md", + challengePath: "attestations/repository-control.json", + nonce: `0x${"3".repeat(64)}` as `0x${string}`, + issuedAt: "2026-07-18T00:00:00.000Z", + expiresAt: "2026-07-18T06:00:00.000Z", + }; + const challengeFile = { + challenge, + statementHash: repositoryStatementHash(challenge), + signature: await wallet.signMessage({ message: canonicalRepositoryStatement(challenge) }), + }; + await mkdir(join(checkout, "attestations")); + await writeFile(join(checkout, challenge.challengePath), `${JSON.stringify(challengeFile)}\n`); + git(checkout, "add", challenge.challengePath); + git(checkout, "commit", "-m", "add CLI repository challenge"); + const proofCommitSha = git(checkout, "rev-parse", "HEAD"); + const forge = generateKeyPairSync("ed25519"); + const unsignedObservation = { + schemaVersion: 1 as const, + repositoryId: "demo", + repositoryUrl: REPOSITORY_URL, + trustedRef: "refs/heads/main" as const, + proofCommitSha, + challengeNonce: challenge.nonce, + observedAt: "2026-07-18T01:00:00.000Z", + forgeSignerId: "forge-1", + }; + const forgeObservation = { + ...unsignedObservation, + signature: signBytes(null, canonicalForgeObservationBytes(unsignedObservation), forge.privateKey).toString("base64"), + }; + await writeFile(paths.repositories, `${JSON.stringify({ + schemaVersion: 1, + repositories: [{ + repositoryId: "demo", + repositoryUrl: REPOSITORY_URL, + checkoutKey: "demo-checkout", + trustedRef: "refs/heads/main", + permittedForgeSignerIds: ["forge-1"], + }], + })}\n`); + await writeFile(paths.forgeSigners, `${JSON.stringify({ + schemaVersion: 1, + forgeSigners: { "forge-1": forge.publicKey.export({ type: "spki", format: "pem" }).toString() }, + })}\n`); + const bundlePath = join(root, "repository-bundle.json"); + await writeFile(bundlePath, `${JSON.stringify({ + schemaVersion: 1, + eventId: "repository-cli-1", + sequence: 1, + occurredAt: "2026-07-18T02:00:00.000Z", + challengeFile, + forgeObservation, + })}\n`); + + let gitCalls = 0; + const failIfCalled: GitReader = { + commitExists: async () => { gitCalls += 1; throw new Error("Git must not run"); }, + readBlob: async () => { gitCalls += 1; throw new Error("Git must not run"); }, + isAncestor: async () => { gitCalls += 1; throw new Error("Git must not run"); }, + remoteUrl: async () => { gitCalls += 1; throw new Error("Git must not run"); }, + }; + await assert.rejects(executeAttestationCommand( + "attestation-verify-repository", + { bundle: bundlePath, json: true }, + () => undefined, + { phase0Root: root, paths, env: {}, git: failIfCalled, now: () => NOW }, + ), /repository snapshot mapping unavailable/); + assert.equal(gitCalls, 0); + + const mappingPath = join(root, ".attestation-checkouts.local.json"); + await writeFile(mappingPath, `${JSON.stringify({ schemaVersion: 1, checkouts: { "demo-checkout": checkout } })}\n`, { mode: 0o644 }); + await chmod(mappingPath, 0o644); + await assert.rejects(executeAttestationCommand( + "attestation-verify-repository", + { bundle: bundlePath }, + () => undefined, + { phase0Root: root, paths, env: {}, git: failIfCalled, now: () => NOW }, + ), /mode 0600/); + assert.equal(gitCalls, 0); + + await chmod(mappingPath, 0o600); + const output: string[] = []; + await executeAttestationCommand( + "attestation-verify-repository", + { bundle: bundlePath, json: true }, + (line) => output.push(line), + { phase0Root: root, paths, env: {}, now: () => NOW }, + ); + assert.match(output.join("\n"), /repository-cli-1/); + const persisted = await readFile(paths.attestations, "utf8"); + assert.equal(JSON.parse(persisted).type, "repository_control_verified"); +}); + +for (const option of ["--repository-path", "--trusted-ref"] as const) { + test(`production CLI rejects claimant option ${option} as unknown`, () => { + const result = spawnSync(process.execPath, ["--import", "tsx", "src/index.ts", "attestation-status", option, "/tmp/claimant"], { + cwd: new URL("..", import.meta.url), + encoding: "utf8", + env: { ...process.env, HTTP_PROXY: "", HTTPS_PROXY: "", ALL_PROXY: "" }, + }); + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}\n${result.stderr}`, /Unknown option/); + }); +} diff --git a/phase0/tests/attestations.test.ts b/phase0/tests/attestations.test.ts index 21cb84d..cdf3e47 100644 --- a/phase0/tests/attestations.test.ts +++ b/phase0/tests/attestations.test.ts @@ -14,6 +14,7 @@ import { displayAttestation, organizationStatementHash, parseAttestationEvent, + registrationSubjectsFromManifest, reduceAttestationEvents, repositoryStatementHash, type AttestationRevokedEvent, @@ -24,6 +25,8 @@ import { type RepositoryControlChallengeV1, type RepositoryControlEvent, } from "../src/attestations"; +import { renderAttestationStatus } from "../src/attestation-cli"; +import { createEmptyRegistrationManifest } from "../src/registrations"; const HASH_A = `0x${"1".repeat(64)}` as const; const HASH_B = `0x${"2".repeat(64)}` as const; @@ -103,6 +106,15 @@ test("wallet assertion is seeded only by base subjects", async () => { ); }); +test("not-run manifests produce no base assertion and status JSON stays empty", async () => { + assert.deepEqual(registrationSubjectsFromManifest(createEmptyRegistrationManifest()), []); + const index = await reduceAttestationEvents([], { baseSubjects: [] }); + assert.deepEqual(JSON.parse(renderAttestationStatus(index, { + artifactHash: `0x${"0".repeat(64)}`, + json: true, + })[0]), { registrations: [], conflicts: [] }); +}); + test("wallet-signed repository evidence requires the injected repository verifier", async () => { const account = privateKeyToAccount(generatePrivateKey()); const base = subject(IP_A, account.address); @@ -264,3 +276,14 @@ test("sequence gaps, duplicate IDs, malformed normalized inputs, and overclaim t const rendered = JSON.stringify(displayAttestation(state, base.registrationId)).toLowerCase(); assert.doesNotMatch(rendered, /authored by|safe skill|proves originality|proves safety/); }); + +test("human status output uses explicit evidence-level language", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const index = await reduceAttestationEvents([], { baseSubjects: [base] }); + const output = renderAttestationStatus(index, { registrationId: base.registrationId }).join("\n"); + assert.match(output, /attestation: wallet_asserted/); + assert.match(output, /claim: wallet registered these bytes and declared this ancestry/); + assert.match(output, /safety review: not_reviewed/); + assert.match(output, /warning: registration does not prove authorship, originality, legal ownership, or safety/); +}); diff --git a/phase0/tests/index.test.ts b/phase0/tests/index.test.ts index 3656476..a381969 100644 --- a/phase0/tests/index.test.ts +++ b/phase0/tests/index.test.ts @@ -38,16 +38,33 @@ for (const args of [ }); } -test("help exposes only demo, check, and explicit stale-lock recovery", async () => { +test("help exposes chain commands and read-only attestation surfaces", async () => { const fixture = dependencies(); assert.equal(await runCommand([], fixture.deps), 0); const output = fixture.lines.join("\n"); assert.match(output, /npm run demo/); assert.match(output, /npm run check/); assert.match(output, /npm run recover-stale-lock/); + assert.match(output, /npm run attestation-status/); + assert.match(output, /npm run attestation-verify-repository/); assert.doesNotMatch(output, /create-collection|register-skill|register-derivative/); }); +test("attestation commands receive parsed machine-readable options without Story construction", async () => { + const fixture = dependencies(); + const calls: unknown[] = []; + fixture.deps.attestation = async (command, options) => { calls.push({ command, options }); }; + assert.equal(await runCommand(["attestation-status"], fixture.deps, { + artifactHash: `0x${"0".repeat(64)}`, + json: true, + }), 0); + assert.deepEqual(calls, [{ + command: "attestation-status", + options: { artifactHash: `0x${"0".repeat(64)}`, json: true }, + }]); + assert.deepEqual(fixture.calls, { check: 0, demo: 0, recover: [] }); +}); + for (const retired of ["create-collection", "register-skill", "register-derivative"] as const) { test(`retired ${retired} route stays rejected`, async () => { const fixture = dependencies(); From 3753baa37b7aa1248f36ce04fdcdb7440df2ff8e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:35:20 -0400 Subject: [PATCH 084/165] test: harden statement determinism and tamper coverage --- .../src/statements.mjs | 18 ++- .../test/statements.test.mjs | 124 ++++++++++++++++-- 2 files changed, 124 insertions(+), 18 deletions(-) diff --git a/spikes/internal-invocation-awards/src/statements.mjs b/spikes/internal-invocation-awards/src/statements.mjs index be95e2d..7aebb04 100644 --- a/spikes/internal-invocation-awards/src/statements.mjs +++ b/spikes/internal-invocation-awards/src/statements.mjs @@ -58,6 +58,12 @@ function requireString(value, label) { if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be non-empty`); } +function requireSortableId(value, label) { + if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/.test(value)) { + throw new Error(`${label} must be a normalized ASCII identifier`); + } +} + function decodeSignature(value, label) { if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { throw new Error(`${label} signature must be canonical base64`); @@ -385,7 +391,11 @@ export function receiptMerkleRoot(signedReceipts) { function uniqueSorted(items, idKey, label, validator) { if (!Array.isArray(items)) throw new Error(`${label} must be an array`); const result = items.map((item, index) => validator(item, index)); - result.sort((left, right) => left[idKey].localeCompare(right[idKey])); + result.sort((left, right) => { + if (left[idKey] < right[idKey]) return -1; + if (left[idKey] > right[idKey]) return 1; + return 0; + }); const seen = new Set(); for (const item of result) { if (seen.has(item[idKey])) throw new Error(`duplicate ${label} ID ${item[idKey]}`); @@ -396,7 +406,7 @@ function uniqueSorted(items, idKey, label, validator) { function validatePayment(payment, index) { requireExactKeys(payment, PAYMENT_KEYS, `payment ${index}`); - requireString(payment.paymentId, 'paymentId'); + requireSortableId(payment.paymentId, 'paymentId'); toAtomic(payment.amountAtomic); parseUtc(payment.paidAt, 'payment paidAt'); requireString(payment.railReference, 'payment railReference'); @@ -405,7 +415,7 @@ function validatePayment(payment, index) { function validateAdvance(advance, index) { requireExactKeys(advance, ADVANCE_KEYS, `payable advance ${index}`); - requireString(advance.advanceId, 'advanceId'); + requireSortableId(advance.advanceId, 'advanceId'); if (!SHA256_PATTERN.test(advance.receiptHash)) throw new Error('advance receiptHash is invalid'); toAtomic(advance.amountAtomic); parseUtc(advance.advancedAt, 'advance advancedAt'); @@ -414,7 +424,7 @@ function validateAdvance(advance, index) { function validateReversal(reversal, index) { requireExactKeys(reversal, REVERSAL_KEYS, `reversal ${index}`); - requireString(reversal.reversalId, 'reversalId'); + requireSortableId(reversal.reversalId, 'reversalId'); if (!SHA256_PATTERN.test(reversal.receiptHash)) throw new Error('reversal receiptHash is invalid'); toAtomic(reversal.amountAtomic); if (!['earned_only', 'payable'].includes(reversal.balanceEffect)) { diff --git a/spikes/internal-invocation-awards/test/statements.test.mjs b/spikes/internal-invocation-awards/test/statements.test.mjs index 6c77449..4f1236c 100644 --- a/spikes/internal-invocation-awards/test/statements.test.mjs +++ b/spikes/internal-invocation-awards/test/statements.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { generateKeyPairSync } from 'node:crypto'; +import { createHash, generateKeyPairSync } from 'node:crypto'; import test from 'node:test'; import { @@ -8,6 +8,7 @@ import { canonicalReceiptBytes, canonicalStatementBytes, receiptHash, + receiptMerkleRoot, renderJsonl, signReceipt, signStatement, @@ -166,6 +167,31 @@ function signedSuccess(signers, sequence = 1, suffix = '001') { }), signers.receipt.privateKey); } +function expectedMerkleRoot(receipts) { + const digest = (bytes) => createHash('sha256').update(bytes).digest(); + if (receipts.length === 0) { + return `sha256:${digest(Buffer.from('internal-invocation-awards:receipt-merkle:v1:empty')).toString('hex')}`; + } + let level = receipts.map((receipt) => digest(Buffer.concat([ + Buffer.from('internal-invocation-awards:receipt-merkle:v1:leaf\0'), + Buffer.from(receiptHash(receipt).slice(7), 'hex'), + ]))); + while (level.length > 1) { + const next = []; + for (let index = 0; index < level.length; index += 2) { + const left = level[index]; + const right = level[index + 1] ?? left; + next.push(digest(Buffer.concat([ + Buffer.from('internal-invocation-awards:receipt-merkle:v1:node\0'), + left, + right, + ]))); + } + level = next; + } + return `sha256:${level[0].toString('hex')}`; +} + test('employer and employee verify identical exact receipt bytes through a trusted key ID', () => { const signers = signerFixture(); const unsigned = buildInvocationReceipt({ @@ -338,37 +364,83 @@ test('statement sequence continuity and domain-separated Merkle rules are determ const oddA = build([receipt3, receipt1, receipt2]); const oddB = build([receipt1, receipt2, receipt3]); assert.equal(oddA.receiptMerkleRoot, oddB.receiptMerkleRoot); + assert.equal(oddA.receiptMerkleRoot, expectedMerkleRoot([receipt1, receipt2, receipt3])); assert.deepEqual(oddA.receiptHashes, oddB.receiptHashes); assert.throws(() => build([receipt1, receipt3]), /statement sequence gap: expected 2, received 3/); const empty = build([]); assert.equal(empty.firstReceiptSequence, null); assert.equal(empty.lastReceiptSequence, null); - assert.match(empty.receiptMerkleRoot, /^sha256:[0-9a-f]{64}$/); - assert.notEqual(empty.receiptMerkleRoot, build([receipt1]).receiptMerkleRoot); + assert.equal(empty.receiptMerkleRoot, expectedMerkleRoot([])); + assert.equal(receiptMerkleRoot([receipt1]), expectedMerkleRoot([receipt1])); + assert.notEqual(empty.receiptMerkleRoot, receiptMerkleRoot([receipt1])); }); test('whole-statement and receipt trust roots reject tampering and attacker resigning', () => { const signers = signerFixture(); const receipt = signedSuccess(signers); + const receipt2 = signedSuccess(signers, 2, '002'); + const receipt3 = signedSuccess(signers, 3, '003'); + const hash = receiptHash(receipt); const unsigned = buildStatement({ statementId: 'statement-trust', employerId: 'megacorp', creatorId: 'sam', period: '2026-07', - currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt], - payableAdvances: [], reversals: [], payments: [], statementSignerId: 'collar-statement-key-2026-07', + currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt, receipt2, receipt3], + payableAdvances: [{ advanceId: 'advance-001', receiptHash: hash, amountAtomic: '1000000', advancedAt: NOW }], + reversals: [{ reversalId: 'reversal-001', receiptHash: hash, amountAtomic: '100000', balanceEffect: 'payable', reason: 'duplicate_advance', occurredAt: NOW }], + payments: [{ paymentId: 'payment-001', amountAtomic: '250000', paidAt: NOW, railReference: 'simulated-payroll-ref' }], + statementSignerId: 'collar-statement-key-2026-07', }); const signed = signStatement(unsigned, signers.statement.privateKey); const options = { - signedReceipts: [receipt], trustedReceiptSigners: signers.receiptTrust, + signedReceipts: [receipt, receipt2, receipt3], trustedReceiptSigners: signers.receiptTrust, trustedStatementSigners: signers.statementTrust, }; - for (const mutation of [ - { statementId: 'changed' }, - { openingPayableAtomic: '1' }, - { earnedAwardTotalAtomic: '1999999' }, - { closingPayableAtomic: '1' }, - { receiptMerkleRoot: `sha256:${'0'.repeat(64)}` }, - ]) { - assert.throws(() => verifyStatement({ ...signed, ...mutation }, options), /signature|recompute/); + const replacePayment = (changes) => ({ payments: [{ ...signed.payments[0], ...changes }] }); + const replaceReversal = (changes) => ({ reversals: [{ ...signed.reversals[0], ...changes }] }); + const replaceAdvance = (changes) => ({ payableAdvances: [{ ...signed.payableAdvances[0], ...changes }] }); + const mutations = [ + ['statementId', { statementId: 'changed' }], + ['employerId', { employerId: 'other-employer' }], + ['creatorId', { creatorId: 'other-creator' }], + ['period', { period: '2026-08' }], + ['currency', { currency: 'EUR' }], + ['atomicScale', { atomicScale: 2 }], + ['openingPayableAtomic', { openingPayableAtomic: '1' }], + ['firstReceiptSequence', { firstReceiptSequence: 2 }], + ['lastReceiptSequence', { lastReceiptSequence: 2 }], + ['receiptHashes', { receiptHashes: [`sha256:${'0'.repeat(64)}`] }], + ['receiptMerkleRoot', { receiptMerkleRoot: `sha256:${'0'.repeat(64)}` }], + ['reservationTotalAtomic', { reservationTotalAtomic: '1' }], + ['releaseTotalAtomic', { releaseTotalAtomic: '1' }], + ['chargeTotalAtomic', { chargeTotalAtomic: '1' }], + ['earnedAwardTotalAtomic', { earnedAwardTotalAtomic: '1' }], + ['payableAdvanceTotalAtomic', { payableAdvanceTotalAtomic: '1' }], + ['reversalTotalAtomic', { reversalTotalAtomic: '1' }], + ['payableReversalTotalAtomic', { payableReversalTotalAtomic: '1' }], + ['paymentTotalAtomic', { paymentTotalAtomic: '1' }], + ['closingPayableAtomic', { closingPayableAtomic: '1' }], + ['statementSignerId', { statementSignerId: 'unknown-statement-key' }], + ['advanceId', replaceAdvance({ advanceId: 'advance-002' })], + ['advance receiptHash', replaceAdvance({ receiptHash: `sha256:${'0'.repeat(64)}` })], + ['advance amountAtomic', replaceAdvance({ amountAtomic: '999999' })], + ['advance advancedAt', replaceAdvance({ advancedAt: '2026-07-17T00:02:00.000Z' })], + ['reversalId', replaceReversal({ reversalId: 'reversal-002' })], + ['reversal receiptHash', replaceReversal({ receiptHash: `sha256:${'0'.repeat(64)}` })], + ['reversal amountAtomic', replaceReversal({ amountAtomic: '99999' })], + ['reversal balanceEffect', replaceReversal({ balanceEffect: 'earned_only' })], + ['reversal reason', replaceReversal({ reason: 'other_reason' })], + ['reversal occurredAt', replaceReversal({ occurredAt: '2026-07-17T00:02:00.000Z' })], + ['paymentId', replacePayment({ paymentId: 'payment-002' })], + ['payment amountAtomic', replacePayment({ amountAtomic: '249999' })], + ['payment paidAt', replacePayment({ paidAt: '2026-07-17T00:02:00.000Z' })], + ['payment railReference', replacePayment({ railReference: 'other-reference' })], + ]; + for (const [label, mutation] of mutations) { + assert.throws( + () => verifyStatement({ ...signed, ...mutation }, options), + /signature|recompute|trusted/, + label, + ); } const attacker = generateKeyPairSync('ed25519'); const attackerSigned = signStatement(unsigned, attacker.privateKey); @@ -376,6 +448,30 @@ test('whole-statement and receipt trust roots reject tampering and attacker resi assert.throws(() => verifyStatement({ ...signed, publicKeyPem: 'self-declared' }, options), /unknown key publicKeyPem/); }); +test('sortable statement event IDs are normalized ASCII and code-unit deterministic', () => { + const signers = signerFixture(); + const receipt = signedSuccess(signers); + const hash = receiptHash(receipt); + const base = { + statementId: 'statement-id-order', employerId: 'megacorp', creatorId: 'sam', period: '2026-07', + currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt], + reversals: [], payments: [], statementSignerId: 'collar-statement-key-2026-07', + }; + const statement = buildStatement({ + ...base, + payableAdvances: [ + { advanceId: 'b', receiptHash: hash, amountAtomic: '1', advancedAt: NOW }, + { advanceId: 'A', receiptHash: hash, amountAtomic: '1', advancedAt: NOW }, + { advanceId: 'a', receiptHash: hash, amountAtomic: '1', advancedAt: NOW }, + ], + }); + assert.deepEqual(statement.payableAdvances.map((row) => row.advanceId), ['A', 'a', 'b']); + assert.throws(() => buildStatement({ + ...base, + payableAdvances: [{ advanceId: 'é', receiptHash: hash, amountAtomic: '1', advancedAt: NOW }], + }), /normalized ASCII identifier/); +}); + test('JSONL output is canonical, newline terminated, and rejects BigInt', () => { const rendered = renderJsonl([{ z: 1, a: { y: 2, b: 3 } }]); assert.equal(rendered, '{"a":{"b":3,"y":2},"z":1}\n'); From 4aee3c757e52a71b5bc94226654e4b5e05ec91bd Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:35:41 -0400 Subject: [PATCH 085/165] fix: serialize refunds and sanitize provider failures --- spikes/pi-wielder/src/collar.mjs | 142 ++++++-- spikes/pi-wielder/src/invocation-journal.mjs | 154 ++++++++- spikes/pi-wielder/src/x402-seller.mjs | 20 +- .../pi-wielder/tests/collar-failure.test.mjs | 311 ++++++++++++++---- .../tests/invocation-journal.test.mjs | 74 +++++ .../pi-wielder/tests/x402-lifecycle.test.mjs | 2 +- 6 files changed, 576 insertions(+), 127 deletions(-) diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index 832744c..c4fb8fc 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -124,6 +124,7 @@ export function createCollar({ lifecycleFaults = {}, resolveSettlement = null, executeRefund = null, + resolveRefund = null, } = {}) { if (journal && (journalFile || signingKeyFile || receiptSigner)) { throw new Error('injected journal cannot be combined with journal/key paths or signer'); @@ -140,9 +141,11 @@ export function createCollar({ if (!journal.isPersistent) throw new Error('live settlement requires a persistent journal and signing key'); if (typeof resolveSettlement !== 'function') throw new Error('live settlement requires a trusted settlement resolver'); if (typeof executeRefund !== 'function') throw new Error('live settlement requires a trusted refund executor'); + if (typeof resolveRefund !== 'function') throw new Error('live settlement requires a trusted refund resolver'); } const settlementResolver = resolveSettlement ?? (async () => ({ settled: false })); const refundExecutor = executeRefund ?? null; + const refundResolver = resolveRefund ?? null; const skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); const skillVersionHash = hash(skillContent); const priceAtomic = usdcToAtomic(priceUsdc); @@ -260,8 +263,8 @@ export function createCollar({ app.get('/receipts/by-settlement/:reference', (c) => { let record; - try { record = journal.getBySettlementReference(c.req.param('reference')); } catch (error) { - return c.json({ error: error.message }, 400); + try { record = journal.getBySettlementReference(c.req.param('reference')); } catch { + return c.json({ error: 'invalid settlement reference' }, 400); } if (!record) return c.json({ error: 'unknown settlement reference' }, 404); if (!record.receipt) { @@ -277,8 +280,8 @@ export function createCollar({ app.post('/reconcile/by-settlement/:reference', async (c) => { const settlementReference = c.req.param('reference').toLowerCase(); let record; - try { record = journal.getBySettlementReference(settlementReference); } catch (error) { - return c.json({ error: error.message }, 400); + try { record = journal.getBySettlementReference(settlementReference); } catch { + return c.json({ error: 'invalid settlement reference' }, 400); } if (!record) return c.json({ error: 'unknown settlement reference' }, 404); if (['settled', 'refunded'].includes(record.payment.state)) { @@ -297,8 +300,8 @@ export function createCollar({ asset: record.quote.asset, payTo: record.quote.payTo, }); - } catch (error) { - return c.json({ error: `trusted settlement resolver failed: ${error.message}` }, 502); + } catch { + return c.json({ error: 'trusted settlement resolution failed' }, 502); } if (!resolution?.settled) { return c.json({ paymentState: 'unresolved', settlementReference }, 202); @@ -318,11 +321,51 @@ export function createCollar({ return c.json({ paymentState: reconciled.payment.state, txHash: reconciled.payment.txHash }); }); + const refundRequestFor = (record) => ({ + invocationId: record.invocationId, + settlementReference: record.payment.settlementReference, + originalTxHash: record.payment.txHash, + payer: record.payment.payer, + amountAtomic: record.quote.amountAtomic, + network: record.quote.network, + asset: record.quote.asset, + payTo: record.quote.payTo, + refundAttemptId: record.payment.refundExecution.refundAttemptId, + }); + + const refundEvidenceMatches = (resolution, request) => resolution?.refunded === true + && String(resolution.settlementReference ?? '').toLowerCase() === request.settlementReference + && String(resolution.originalTxHash ?? '').toLowerCase() === request.originalTxHash + && sameAddress(resolution.payer, request.payer) + && typeof resolution.amountAtomic === 'string' + && resolution.amountAtomic === request.amountAtomic + && typeof resolution.refundReference === 'string' + && Boolean(resolution.refundReference.trim()); + + const markRefundOutcomeUnresolved = (record) => { + if (record.payment.state === 'refunded' + || record.payment.refundExecution?.state === 'unresolved') return; + journal.markRefundUnresolved(record.idempotencyKey, { + refundAttemptId: record.payment.refundExecution.refundAttemptId, + reason: 'trusted refund outcome unresolved', + }); + }; + + const finalizeRefund = (record, resolution) => { + journal.refundExternalPayment(record.idempotencyKey, { + refundAttemptId: record.payment.refundExecution.refundAttemptId, + reason: 'trusted full-gross refund confirmed', + refundReference: resolution.refundReference, + refundAmountAtomic: resolution.amountAtomic, + }); + return journal.issueReceipt(record.idempotencyKey); + }; + app.post('/refund/by-settlement/:reference', async (c) => { const settlementReference = c.req.param('reference').toLowerCase(); let record; - try { record = journal.getBySettlementReference(settlementReference); } catch (error) { - return c.json({ error: error.message }, 400); + try { record = journal.getBySettlementReference(settlementReference); } catch { + return c.json({ error: 'invalid settlement reference' }, 400); } if (!record) return c.json({ error: 'unknown settlement reference' }, 404); if (record.payment.state === 'refunded') { @@ -334,37 +377,64 @@ export function createCollar({ return c.json({ error: 'refund requires a settled failed full-gross reconciliation hold' }, 409); } if (!refundExecutor) return c.json({ error: 'trusted refund executor is not configured' }, 501); + const claim = journal.startRefund(record.idempotencyKey); + if (!claim.started) { + return c.json({ + error: 'refund outcome unresolved; trusted reconciliation is required', + refundAttemptId: claim.record.payment.refundExecution?.refundAttemptId ?? null, + }, 503); + } + record = claim.record; + const request = refundRequestFor(record); let resolution; try { - resolution = await refundExecutor({ - invocationId: record.invocationId, - settlementReference, - originalTxHash: record.payment.txHash, - payer: record.payment.payer, - amountAtomic: record.quote.amountAtomic, - network: record.quote.network, - asset: record.quote.asset, - payTo: record.quote.payTo, + resolution = await refundExecutor(request); + await lifecycleFaults.afterRefundExecutorReturned?.({ + idempotencyKey: record.idempotencyKey, + refundAttemptId: request.refundAttemptId, }); - } catch (error) { - return c.json({ error: `trusted refund executor failed: ${error.message}` }, 502); + } catch { + markRefundOutcomeUnresolved(record); + return c.json({ error: 'refund outcome unresolved; trusted reconciliation is required' }, 503); } - if (resolution?.refunded !== true - || String(resolution.settlementReference ?? '').toLowerCase() !== settlementReference - || String(resolution.originalTxHash ?? '').toLowerCase() !== record.payment.txHash - || !sameAddress(resolution.payer, record.payment.payer) - || typeof resolution.amountAtomic !== 'string' - || resolution.amountAtomic !== record.quote.amountAtomic - || typeof resolution.refundReference !== 'string' - || !resolution.refundReference.trim()) { - return c.json({ error: 'trusted refund executor returned a mismatched proof' }, 502); + if (!refundEvidenceMatches(resolution, request)) { + markRefundOutcomeUnresolved(record); + return c.json({ error: 'refund outcome unresolved; trusted reconciliation is required' }, 503); } - journal.refundExternalPayment(record.idempotencyKey, { - reason: 'trusted full-gross refund confirmed', - refundReference: resolution.refundReference, - refundAmountAtomic: resolution.amountAtomic, - }); - return c.json({ receipt: journal.issueReceipt(record.idempotencyKey) }); + return c.json({ receipt: finalizeRefund(record, resolution) }); + }); + + app.post('/reconcile/refund/by-settlement/:reference', async (c) => { + const settlementReference = c.req.param('reference').toLowerCase(); + let record; + try { record = journal.getBySettlementReference(settlementReference); } catch { + return c.json({ error: 'invalid settlement reference' }, 400); + } + if (!record) return c.json({ error: 'unknown settlement reference' }, 404); + if (record.payment.state === 'refunded') { + return c.json({ receipt: record.receipt ?? journal.issueReceipt(record.idempotencyKey) }); + } + if (!['executing', 'unresolved'].includes(record.payment.refundExecution?.state)) { + return c.json({ error: 'refund has no durable execution claim to reconcile' }, 409); + } + if (!refundResolver) return c.json({ error: 'trusted refund resolver is not configured' }, 501); + const request = refundRequestFor(record); + let resolution; + try { + resolution = await refundResolver(request); + } catch { + markRefundOutcomeUnresolved(record); + return c.json({ error: 'trusted refund resolution failed' }, 502); + } + if (!resolution?.refunded) { + markRefundOutcomeUnresolved(record); + return c.json({ refundState: 'unresolved', refundAttemptId: request.refundAttemptId }, 202); + } + if (!refundEvidenceMatches(resolution, request)) { + markRefundOutcomeUnresolved(record); + return c.json({ error: 'trusted refund resolver returned mismatched evidence' }, 502); + } + return c.json({ receipt: finalizeRefund(record, resolution) }); }); app.post( @@ -418,7 +488,7 @@ export function createCollar({ executionAttemptId, }); } catch (error) { - return finishFailure('UPSTREAM_500', error.message, 500); + return finishFailure('UPSTREAM_500', 'Skill execution failed after settlement', 500); } if (!execution || typeof execution.output !== 'string') { return finishFailure('INVALID_EXECUTOR_RESULT', 'executor must return { output: string }', 500); @@ -479,7 +549,7 @@ async function runSkillViaAnthropic(skillContent, input) { messages: [{ role: 'user', content: String(input) }], }), }); - if (!response.ok) throw new Error(`Anthropic API ${response.status}: ${await response.text()}`); + if (!response.ok) throw new Error(`Anthropic API returned HTTP ${response.status}`); const data = await response.json(); return data.content?.map((block) => block.text ?? '').join('') ?? ''; } diff --git a/spikes/pi-wielder/src/invocation-journal.mjs b/spikes/pi-wielder/src/invocation-journal.mjs index 1e9b767..95e1658 100644 --- a/spikes/pi-wielder/src/invocation-journal.mjs +++ b/spikes/pi-wielder/src/invocation-journal.mjs @@ -324,7 +324,11 @@ const EVENT_DATA_KEYS = Object.freeze({ 'payment.settled': ['settlementReference', 'txHash', 'payer'], 'payment.unresolved': ['reason'], 'payment.rejected': ['reason'], - 'payment.refunded': ['reason', 'refundReference', 'refundAmountAtomic', 'reversalEntries'], + 'refund.started': ['refundAttemptId'], + 'refund.unresolved': ['refundAttemptId', 'reason'], + 'payment.refunded': [ + 'refundAttemptId', 'reason', 'refundReference', 'refundAmountAtomic', 'reversalEntries', + ], 'execution.started': ['executionAttemptId'], 'execution.finished': ['executionAttemptId', 'outcome', 'outcomeHash', 'failureClass', 'message', 'httpStatus', 'accounting'], 'receipt.issued': ['bundle'], @@ -362,6 +366,11 @@ function deriveFullGrossRefundReversal(record) { } function receiptPayload(record) { + // Refund execution leases are internal control-plane state. Omitting them + // keeps the signed receipt schema stable while payment.refunded carries the + // externally meaningful confirmation and exact reversal accounting. + const payment = copy(record.payment); + delete payment.refundExecution; return { schemaVersion: 1, revision: record.receiptHistory.length + 1, @@ -370,15 +379,15 @@ function receiptPayload(record) { invocationId: record.invocationId, idempotencyKey: record.idempotencyKey, mode: record.mode, - skill: record.skill, + skill: copy(record.skill), requestHash: record.requestHash, creatorId: record.creatorId, wielderId: record.wielderId, beneficiaryId: record.beneficiaryId, - quote: record.quote, - payment: record.payment, - execution: record.execution, - accounting: record.accounting, + quote: copy(record.quote), + payment, + execution: copy(record.execution), + accounting: copy(record.accounting), createdAt: record.createdAt, completedAt: record.updatedAt, }; @@ -519,8 +528,24 @@ export function createInvocationJournal({ throw new Error('payment.rejected has invalid predecessor'); } break; + case 'refund.started': + if (!record || record.payment.refundExecution !== null) { + throw new Error('refund.started requires an unclaimed refund'); + } + deriveFullGrossRefundReversal(record); + requireText(event.data.refundAttemptId, 'refundAttemptId'); + break; + case 'refund.unresolved': + if (!record || record.payment.refundExecution?.state !== 'executing' + || record.payment.refundExecution.refundAttemptId !== event.data.refundAttemptId) { + throw new Error('refund.unresolved requires the claimed refund attempt'); + } + requireText(event.data.reason, 'reason'); + break; case 'payment.refunded': - if (!record || event.data.refundAmountAtomic !== record.quote.amountAtomic + if (!record || !['executing', 'unresolved'].includes(record.payment.refundExecution?.state) + || event.data.refundAttemptId !== record.payment.refundExecution.refundAttemptId + || event.data.refundAmountAtomic !== record.quote.amountAtomic || !same(event.data.reversalEntries, deriveFullGrossRefundReversal(record))) { throw new Error('refund must exactly reverse the full settled gross hold'); } @@ -578,6 +603,7 @@ export function createInvocationJournal({ payment: { state: null, settlementReference: null, txHash: null, payer: null, reason: null, refundReference: null, refundAmountAtomic: null, refundAccounting: null, + refundExecution: null, }, execution: { state: 'requested', executionAttemptId: null, outcomeHash: null, @@ -621,6 +647,20 @@ export function createInvocationJournal({ record.execution.state = 'cancelled'; record.execution.httpStatus = 402; break; + case 'refund.started': + record.payment.refundExecution = { + state: 'executing', + refundAttemptId: event.data.refundAttemptId, + reason: null, + }; + break; + case 'refund.unresolved': + record.payment.refundExecution = { + state: 'unresolved', + refundAttemptId: event.data.refundAttemptId, + reason: event.data.reason, + }; + break; case 'payment.refunded': record.payment.state = 'refunded'; record.payment.reason = event.data.reason; @@ -630,6 +670,11 @@ export function createInvocationJournal({ priorAllocationState: 'pending_cogs_reconciliation', reversalEntries: event.data.reversalEntries, }; + record.payment.refundExecution = { + state: 'confirmed', + refundAttemptId: event.data.refundAttemptId, + reason: null, + }; record.receipt = null; break; case 'execution.started': @@ -918,6 +963,7 @@ export function createInvocationJournal({ refreshFromAuthority(); const record = requireRecord(records, key); const refund = { + refundAttemptId: requireText(input.refundAttemptId, 'refundAttemptId'), reason: requireText(input.reason, 'reason'), refundReference: requireText(input.refundReference, 'refundReference'), refundAmountAtomic: requireAtomicString(input.refundAmountAtomic, 'refundAmountAtomic'), @@ -925,19 +971,88 @@ export function createInvocationJournal({ if (record.payment.state === 'refunded') { if (record.payment.reason !== refund.reason || record.payment.refundReference !== refund.refundReference - || record.payment.refundAmountAtomic !== refund.refundAmountAtomic) { + || record.payment.refundAmountAtomic !== refund.refundAmountAtomic + || record.payment.refundExecution?.refundAttemptId !== refund.refundAttemptId) { throw new Error('Invocation already binds a different refund'); } return copy(record); } + if (!['executing', 'unresolved'].includes(record.payment.refundExecution?.state) + || record.payment.refundExecution.refundAttemptId !== refund.refundAttemptId) { + throw new Error('refund attempt does not match the durable execution claim'); + } if (refund.refundAmountAtomic !== record.quote.amountAtomic) { throw new Error('refund must return the full settled gross'); } - append('payment.refunded', key, { - ...refund, - reversalEntries: deriveFullGrossRefundReversal(record), - }); - return copy(records.get(key)); + try { + append('payment.refunded', key, { + ...refund, + reversalEntries: deriveFullGrossRefundReversal(record), + }); + return copy(records.get(key)); + } catch (error) { + if (error.code !== 'JOURNAL_CONFLICT') throw error; + const winner = requireRecord(records, key); + if (winner.payment.state !== 'refunded' + || winner.payment.reason !== refund.reason + || winner.payment.refundReference !== refund.refundReference + || winner.payment.refundAmountAtomic !== refund.refundAmountAtomic + || winner.payment.refundExecution?.refundAttemptId !== refund.refundAttemptId) { + throw error; + } + return copy(winner); + } + } + + function startRefund(key, { refundAttemptId = null } = {}) { + refreshFromAuthority(); + const record = requireRecord(records, key); + if (record.payment.state === 'refunded' || record.payment.refundExecution) { + return { started: false, record: copy(record) }; + } + deriveFullGrossRefundReversal(record); + const attempt = refundAttemptId ?? `refund-attempt:${crypto.createHash('sha256') + .update(`${record.invocationId}\n${record.payment.txHash}\n${record.quote.amountAtomic}`) + .digest('hex')}`; + try { + append('refund.started', key, { refundAttemptId: requireText(attempt, 'refundAttemptId') }); + return { started: true, record: copy(records.get(key)) }; + } catch (error) { + if (error.code !== 'JOURNAL_CONFLICT') throw error; + const winner = requireRecord(records, key); + if (!winner.payment.refundExecution && winner.payment.state !== 'refunded') throw error; + return { started: false, record: copy(winner) }; + } + } + + function markRefundUnresolved(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const refundAttemptId = requireText(input.refundAttemptId, 'refundAttemptId'); + const reason = requireText(input.reason, 'reason'); + if (record.payment.refundExecution?.state === 'unresolved') { + if (record.payment.refundExecution.refundAttemptId !== refundAttemptId + || record.payment.refundExecution.reason !== reason) { + throw new Error('Invocation already binds a different unresolved refund outcome'); + } + return copy(record); + } + if (record.payment.refundExecution?.state !== 'executing' + || record.payment.refundExecution.refundAttemptId !== refundAttemptId) { + throw new Error('unresolved refund does not match the durable execution claim'); + } + try { + append('refund.unresolved', key, { refundAttemptId, reason }); + return copy(records.get(key)); + } catch (error) { + if (error.code !== 'JOURNAL_CONFLICT') throw error; + const winner = requireRecord(records, key); + if (winner.payment.state === 'refunded') return copy(winner); + if (winner.payment.refundExecution?.state === 'unresolved' + && winner.payment.refundExecution.refundAttemptId === refundAttemptId + && winner.payment.refundExecution.reason === reason) return copy(winner); + throw error; + } } function startExecution(key, { executionAttemptId = null } = {}) { @@ -994,8 +1109,15 @@ export function createInvocationJournal({ algorithm: receiptSigner.algorithm, keyId: receiptSigner.keyId, }; - append('receipt.issued', key, { bundle }); - return copy(bundle); + try { + append('receipt.issued', key, { bundle }); + return copy(bundle); + } catch (error) { + if (error.code !== 'JOURNAL_CONFLICT') throw error; + const winner = requireRecord(records, key); + if (winner.receipt && same(winner.receipt, bundle)) return copy(winner.receipt); + throw error; + } } function recoverStaleLock({ expectedLeaseId }) { @@ -1031,6 +1153,8 @@ export function createInvocationJournal({ markExternalPaymentUnresolved, reconcileExternalSettlement, rejectExternalPayment, + startRefund, + markRefundUnresolved, refundExternalPayment, startExecution, finishExecution, diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index c836f7f..372dcc6 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -186,8 +186,8 @@ export function x402Paywall({ requirements: structuredClone(requirements), expiresAt: requirements.extra.expiresAt, }); - } catch (error) { - return c.json({ error: error.message }, error.code === 'JOURNAL_CONFLICT' ? 409 : 409); + } catch { + return c.json({ error: 'Invocation offer conflicts with authoritative state' }, 409); } return c.json({ x402Version: X402_VERSION, @@ -228,8 +228,8 @@ export function x402Paywall({ payer, requirements: structuredClone(requirements), }); - } catch (error) { - return c.json({ error: error.message }, 409); + } catch { + return c.json({ error: 'paid retry conflicts with authoritative Invocation state' }, 409); } if (locallyUnresolvedSettlements.has(idempotencyKey) @@ -248,7 +248,7 @@ export function x402Paywall({ replayed: true, receipt: priorDecision.receipt, ...(priorDecision.httpStatus >= 400 - ? { error: priorDecision.receipt?.receipt?.execution?.message ?? 'terminal execution failed' } + ? { error: 'terminal execution failed' } : {}), }; const replay = c.json(body, priorDecision.httpStatus); @@ -306,13 +306,13 @@ export function x402Paywall({ }, 402); } settle = await postJson(transport, 'settle', facilitatorBody); - } catch (error) { + } catch { locallyUnresolvedSettlements.add(idempotencyKey); await notifyUnresolved({ idempotencyKey, settlementReference, payer, - reason: `facilitator response unresolved: ${error.message}`, + reason: 'facilitator response unresolved', }); return c.json({ error: 'payment settlement unresolved', settlementReference }, 503); } @@ -329,7 +329,7 @@ export function x402Paywall({ }); return c.json({ error: 'payment settlement unresolved', settlementReference }, 503); } - const reason = `payment settlement failed: ${settle?.errorReason ?? 'unknown'}`; + const reason = 'payment settlement failed'; await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); return c.json({ x402Version: X402_VERSION, @@ -364,13 +364,13 @@ export function x402Paywall({ amountAtomic: requirements.maxAmountRequired, requirements: structuredClone(requirements), }); - } catch (error) { + } catch { locallyUnresolvedSettlements.add(idempotencyKey); await notifyUnresolved({ idempotencyKey, settlementReference, payer, - reason: `settlement confirmed but journal persistence unresolved: ${error.message}`, + reason: 'settlement confirmed but journal persistence unresolved', }); return c.json({ error: 'payment settlement unresolved: authoritative persistence requires reconciliation', diff --git a/spikes/pi-wielder/tests/collar-failure.test.mjs b/spikes/pi-wielder/tests/collar-failure.test.mjs index d78f9f4..995708b 100644 --- a/spikes/pi-wielder/tests/collar-failure.test.mjs +++ b/spikes/pi-wielder/tests/collar-failure.test.mjs @@ -77,11 +77,18 @@ async function prepareReconciledRetry({ executeSkill, lifecycleFaults = {} }) { return { collar, idempotencyKey, retry }; } -async function invokeSettledFailure({ executeRefund = null } = {}) { +async function invokeSettledFailure({ + executeRefund = null, + resolveRefund = null, + lifecycleFaults = {}, + executeSkill = async () => { throw new Error('refund-target provider fault'); }, +} = {}) { const collar = createCollar({ facilitatorTransport: mockTransport(), - executeSkill: async () => { throw new Error('refund-target provider fault'); }, + executeSkill, executeRefund, + resolveRefund, + lifecycleFaults, }); const result = await payingFetch(throwawayAccount(), invokeUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, @@ -134,6 +141,7 @@ test('live settlement refuses ephemeral authority and accepts only paired persis signingKeyFile: path.join(directory, 'receipt-key.pem'), resolveSettlement: async () => ({ settled: false }), executeRefund: async () => ({ refunded: false }), + resolveRefund: async () => ({ refunded: false }), }); assert.equal(collar.journal.isPersistent, true); }); @@ -399,65 +407,31 @@ test('refund endpoint ignores client proof and fails closed without a trusted ex }); test('trusted refund requires exact journal-bound proof and issues one signed revision', async () => { - let executorResult = null; const calls = []; + let expected; const { collar, result, body } = await invokeSettledFailure({ executeRefund: async (request) => { calls.push(structuredClone(request)); - return executorResult; + return { + refunded: true, + settlementReference: request.settlementReference, + originalTxHash: request.originalTxHash, + payer: request.payer, + amountAtomic: request.amountAtomic, + refundReference: 'trusted-refund-0001', + }; }, }); const original = structuredClone(body.receipt); const payment = original.receipt.payment; const endpoint = `http://collar.test/refund/by-settlement/${result.settlementReference}`; - const stateBefore = () => collar.journal.getBySettlementReference(result.settlementReference); - const eventCount = collar.journal.events.length; - - const mismatches = [ - null, - { - refunded: true, - settlementReference: `0x${'f'.repeat(64)}`, - originalTxHash: payment.txHash, - payer: payment.payer, - amountAtomic: '250000', - refundReference: 'refund-wrong-reference', - }, - { - refunded: true, - settlementReference: payment.settlementReference, - originalTxHash: payment.txHash, - payer: `0x${'a'.repeat(40)}`, - amountAtomic: '250000', - refundReference: 'refund-wrong-payer', - }, - { - refunded: true, - settlementReference: payment.settlementReference, - originalTxHash: payment.txHash, - payer: payment.payer, - amountAtomic: '249999', - refundReference: 'refund-partial', - }, - { - refunded: true, - settlementReference: payment.settlementReference, - originalTxHash: `0x${'e'.repeat(64)}`, - payer: payment.payer, - amountAtomic: '250000', - refundReference: 'refund-wrong-original-tx', - }, - ]; - for (const mismatch of mismatches) { - executorResult = mismatch; - const rejected = await collar.app.request(endpoint, { method: 'POST' }); - assert.equal(rejected.status, 502); - assert.equal(collar.journal.events.length, eventCount); - assert.equal(stateBefore().payment.state, 'settled'); - assert.deepEqual(stateBefore().receipt, original); - } - - assert.deepEqual(calls[0], { + const refunded = await collar.app.request(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ refundReference: 'client-must-not-win' }), + }); + assert.equal(refunded.status, 200); + expected = { invocationId: original.receipt.invocationId, settlementReference: payment.settlementReference, originalTxHash: payment.txHash, @@ -466,21 +440,12 @@ test('trusted refund requires exact journal-bound proof and issues one signed re network: original.receipt.quote.network, asset: original.receipt.quote.asset, payTo: original.receipt.quote.payTo, - }); - executorResult = { - refunded: true, - settlementReference: payment.settlementReference, - originalTxHash: payment.txHash, - payer: payment.payer, - amountAtomic: '250000', - refundReference: 'trusted-refund-0001', }; - const refunded = await collar.app.request(endpoint, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ refundReference: 'client-must-not-win' }), + assert.match(calls[0].refundAttemptId, /^refund-attempt:/); + assert.deepEqual({ ...calls[0], refundAttemptId: undefined }, { + ...expected, + refundAttemptId: undefined, }); - assert.equal(refunded.status, 200); const revised = (await refunded.json()).receipt; assert.equal(verifySignedReceipt(revised, trustFor(collar)), true); assert.equal(revised.receipt.revision, 2); @@ -510,3 +475,219 @@ test('trusted refund requires exact journal-bound proof and issues one signed re assert.deepEqual((await replay.json()).receipt, revised); assert.equal(calls.length, callCount); }); + +test('crash after refund provider return stays unresolved until a trusted resolver advances it', async () => { + let executorCalls = 0; + let resolverResult = null; + const { collar, result, body } = await invokeSettledFailure({ + executeRefund: async (request) => { + executorCalls += 1; + return { + refunded: true, + settlementReference: request.settlementReference, + originalTxHash: request.originalTxHash, + payer: request.payer, + amountAtomic: request.amountAtomic, + refundReference: 'response-lost-after-provider-return', + }; + }, + resolveRefund: async () => resolverResult, + lifecycleFaults: { + afterRefundExecutorReturned: async () => { throw new Error('crash after refund return'); }, + }, + }); + const payment = body.receipt.receipt.payment; + const refundUrl = `http://collar.test/refund/by-settlement/${result.settlementReference}`; + const reconcileUrl = `http://collar.test/reconcile/refund/by-settlement/${result.settlementReference}`; + const first = await collar.app.request(refundUrl, { method: 'POST' }); + assert.equal(first.status, 503); + assert.doesNotMatch(JSON.stringify(await first.json()), /null|undefined/i); + assert.equal(executorCalls, 1); + assert.equal((await collar.app.request(refundUrl, { method: 'POST' })).status, 503); + assert.equal(executorCalls, 1); + const unresolved = collar.journal.getBySettlementReference(result.settlementReference); + assert.equal(unresolved.payment.refundExecution.state, 'unresolved'); + assert.deepEqual(unresolved.receipt, body.receipt); + assert.equal(verifySignedReceipt(unresolved.receipt, trustFor(collar)), true); + + const mismatches = [ + null, + { + refunded: true, + settlementReference: `0x${'f'.repeat(64)}`, + originalTxHash: payment.txHash, + payer: payment.payer, + amountAtomic: '250000', + refundReference: 'wrong-reference', + }, + { + refunded: true, + settlementReference: payment.settlementReference, + originalTxHash: payment.txHash, + payer: `0x${'a'.repeat(40)}`, + amountAtomic: '250000', + refundReference: 'wrong-payer', + }, + { + refunded: true, + settlementReference: payment.settlementReference, + originalTxHash: payment.txHash, + payer: payment.payer, + amountAtomic: '249999', + refundReference: 'partial-refund', + }, + ]; + const eventCount = collar.journal.events.length; + for (const mismatch of mismatches) { + resolverResult = mismatch; + const response = await collar.app.request(reconcileUrl, { method: 'POST' }); + assert.equal(response.status, mismatch ? 502 : 202); + assert.equal(collar.journal.events.length, eventCount); + assert.equal( + collar.journal.getBySettlementReference(result.settlementReference).payment.state, + 'settled', + ); + } + resolverResult = { + refunded: true, + settlementReference: payment.settlementReference, + originalTxHash: payment.txHash, + payer: payment.payer, + amountAtomic: '250000', + refundReference: 'reconciled-refund', + }; + const reconciled = await collar.app.request(reconcileUrl, { method: 'POST' }); + assert.equal(reconciled.status, 200); + const revised = (await reconciled.json()).receipt; + assert.equal(revised.receipt.payment.state, 'refunded'); + assert.equal(revised.receipt.payment.refundReference, 'reconciled-refund'); + assert.equal(verifySignedReceipt(revised, trustFor(collar)), true); +}); + +test('overlapping refund requests durably claim one external execution', async () => { + let calls = 0; + let announceStarted; + let release; + const started = new Promise((resolve) => { announceStarted = resolve; }); + const gate = new Promise((resolve) => { release = resolve; }); + const { collar, result } = await invokeSettledFailure({ + executeRefund: async (request) => { + calls += 1; + announceStarted(); + await gate; + return { + refunded: true, + settlementReference: request.settlementReference, + originalTxHash: request.originalTxHash, + payer: request.payer, + amountAtomic: request.amountAtomic, + refundReference: 'one-refund', + }; + }, + }); + const endpoint = `http://collar.test/refund/by-settlement/${result.settlementReference}`; + const winner = collar.app.request(endpoint, { method: 'POST' }); + await started; + const overlap = await collar.app.request(endpoint, { method: 'POST' }); + assert.equal(overlap.status, 503); + assert.equal(calls, 1); + release(); + const completed = await winner; + assert.equal(completed.status, 200); + const receipt = (await completed.json()).receipt; + const replay = await collar.app.request(endpoint, { method: 'POST' }); + assert.equal(replay.status, 200); + assert.deepEqual((await replay.json()).receipt, receipt); + assert.equal(calls, 1); + assert.equal(collar.journal.events.filter((event) => event.type === 'refund.started').length, 1); +}); + +test('refund crash boundary stays unresolved and provider secrets never reach clients', async () => { + const secret = 'sk-refund-super-secret'; + let calls = 0; + const { collar, result } = await invokeSettledFailure({ + executeRefund: async () => { + calls += 1; + throw new Error(secret); + }, + }); + const endpoint = `http://collar.test/refund/by-settlement/${result.settlementReference}`; + const response = await collar.app.request(endpoint, { method: 'POST' }); + assert.equal(response.status, 503); + const text = await response.text(); + assert.doesNotMatch(text, new RegExp(secret)); + assert.equal(calls, 1); + assert.equal((await collar.app.request(endpoint, { method: 'POST' })).status, 503); + assert.equal(calls, 1); + assert.equal( + collar.journal.getBySettlementReference(result.settlementReference).payment.refundExecution.state, + 'unresolved', + ); +}); + +test('Skill provider and settlement resolver secrets are replaced with stable public errors', async () => { + const providerSecret = 'sk-provider-secret-response-body'; + const failed = await invokeSettledFailure({ + executeSkill: async () => { throw new Error(providerSecret); }, + }); + const failureText = JSON.stringify(failed.body); + assert.doesNotMatch(failureText, new RegExp(providerSecret)); + assert.equal(failed.body.error, 'Skill execution failed after settlement'); + assert.equal(failed.body.receipt.receipt.execution.message, 'Skill execution failed after settlement'); + + const resolverSecret = 'resolver-secret-token'; + const facilitator = createMockFacilitator(); + const transport = createMockFacilitatorTransport(async (url, init) => { + const response = await facilitator.request(url, init); + if (new URL(url).pathname === '/settle') throw new Error('lost response'); + return response; + }); + const collar = createCollar({ + facilitatorTransport: transport, + resolveSettlement: async () => { throw new Error(resolverSecret); }, + executeSkill: async () => ({ output: 'must not run' }), + }); + const first = await payingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey: 'idem-secret-resolver', + fetchImpl: (url, init) => collar.app.request(url, init), + }); + const resolution = await collar.app.request( + `http://collar.test/reconcile/by-settlement/${first.settlementReference}`, + { method: 'POST' }, + ); + assert.equal(resolution.status, 502); + assert.doesNotMatch(await resolution.text(), new RegExp(resolverSecret)); +}); + +test('Anthropic error response bodies are never copied into the failed receipt', async () => { + const responseSecret = 'sk-ant-secret-inside-upstream-body'; + const previousFetch = globalThis.fetch; + const previousKey = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'test-only-key'; + globalThis.fetch = async (url) => { + assert.equal(url, 'https://api.anthropic.com/v1/messages'); + return new Response(JSON.stringify({ error: responseSecret }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }); + }; + try { + const collar = createCollar({ facilitatorTransport: mockTransport(), mockLlm: false }); + const result = await payingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey: 'idem-anthropic-secret-body', + fetchImpl: (url, init) => collar.app.request(url, init), + }); + assert.equal(result.res.status, 500); + const text = await result.res.text(); + assert.doesNotMatch(text, new RegExp(responseSecret)); + assert.match(text, /Skill execution failed after settlement/); + } finally { + globalThis.fetch = previousFetch; + if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = previousKey; + } +}); diff --git a/spikes/pi-wielder/tests/invocation-journal.test.mjs b/spikes/pi-wielder/tests/invocation-journal.test.mjs index 185e750..5cd7e6e 100644 --- a/spikes/pi-wielder/tests/invocation-journal.test.mjs +++ b/spikes/pi-wielder/tests/invocation-journal.test.mjs @@ -310,10 +310,16 @@ test('refund reverses only a terminal failed full-gross hold and issues a signed }); const original = journal.issueReceipt(declaration.idempotencyKey); const request = { + refundAttemptId: 'refund-attempt-0001', reason: 'settled execution failure', refundReference: `refund:${'5'.repeat(64)}`, refundAmountAtomic: '250000', }; + const firstClaim = journal.startRefund(declaration.idempotencyKey, { + refundAttemptId: request.refundAttemptId, + }); + assert.equal(firstClaim.started, true); + assert.equal(journal.startRefund(declaration.idempotencyKey).started, false); assert.throws(() => journal.refundExternalPayment(declaration.idempotencyKey, { ...request, refundAmountAtomic: '249999' }), /full settled gross/); journal.refundExternalPayment(declaration.idempotencyKey, request); const revised = journal.issueReceipt(declaration.idempotencyKey); @@ -326,6 +332,74 @@ test('refund reverses only a terminal failed full-gross hold and issues a signed assert.equal(verifySignedReceipt(revised, trustFor(journal)), true); }); +test('refund execution is durably claimed once and ambiguous outcomes remain unresolved', () => { + const journal = fixture(); + settle(journal); + const execution = journal.startExecution(declaration.idempotencyKey); + journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: execution.record.execution.executionAttemptId, + outcome: 'failed', failureClass: 'COGS_UNKNOWN', message: 'safe failure', outcomeHash: null, + httpStatus: 500, accounting: pendingFailureAccounting(), + }); + journal.issueReceipt(declaration.idempotencyKey); + + const claim = journal.startRefund(declaration.idempotencyKey, { + refundAttemptId: 'refund-attempt-crash', + }); + assert.equal(claim.started, true); + assert.equal(journal.startRefund(declaration.idempotencyKey).started, false); + journal.markRefundUnresolved(declaration.idempotencyKey, { + refundAttemptId: 'refund-attempt-crash', + reason: 'trusted refund outcome unresolved', + }); + assert.equal(journal.startRefund(declaration.idempotencyKey).started, false); + const unresolved = journal.getByIdempotencyKey(declaration.idempotencyKey); + assert.deepEqual(unresolved.payment.refundExecution, { + state: 'unresolved', + refundAttemptId: 'refund-attempt-crash', + reason: 'trusted refund outcome unresolved', + }); + assert.equal(journal.events.filter((event) => event.type === 'refund.started').length, 1); + assert.equal(journal.events.filter((event) => event.type === 'refund.unresolved').length, 1); + + assert.throws(() => journal.refundExternalPayment(declaration.idempotencyKey, { + refundAttemptId: 'another-attempt', + reason: 'trusted full-gross refund confirmed', + refundReference: 'trusted-refund', + refundAmountAtomic: '250000', + }), /refund attempt/); + journal.refundExternalPayment(declaration.idempotencyKey, { + refundAttemptId: 'refund-attempt-crash', + reason: 'trusted full-gross refund confirmed', + refundReference: 'trusted-refund', + refundAmountAtomic: '250000', + }); + const terminal = journal.getByIdempotencyKey(declaration.idempotencyKey); + assert.equal(terminal.payment.refundExecution.state, 'confirmed'); + assert.equal(journal.startRefund(declaration.idempotencyKey).started, false); +}); + +test('separate journal instances observe one durable refund claim', () => { + const { filePath, signingKeyPath } = temporaryAuthority('collar-refund-claim-'); + const first = createInvocationJournal({ filePath, signingKeyPath }); + settle(first); + const execution = first.startExecution(declaration.idempotencyKey); + first.finishExecution(declaration.idempotencyKey, { + executionAttemptId: execution.record.execution.executionAttemptId, + outcome: 'failed', failureClass: 'COGS_UNKNOWN', message: 'safe failure', outcomeHash: null, + httpStatus: 500, accounting: pendingFailureAccounting(), + }); + first.issueReceipt(declaration.idempotencyKey); + const second = createInvocationJournal({ filePath, signingKeyPath }); + assert.equal(first.startRefund(declaration.idempotencyKey).started, true); + assert.equal(second.startRefund(declaration.idempotencyKey).started, false); + assert.equal(second.events.filter((event) => event.type === 'refund.started').length, 1); + assert.equal( + first.getByIdempotencyKey(declaration.idempotencyKey).payment.refundExecution.refundAttemptId, + second.getByIdempotencyKey(declaration.idempotencyKey).payment.refundExecution.refundAttemptId, + ); +}); + const waitForExit = (child) => new Promise((resolve, reject) => { let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; }); diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs index e400a19..021f4e6 100644 --- a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -231,7 +231,7 @@ test('terminal replay requires settled or refunded payment with a transaction an const body = await result.res.json(); if (expectedStatus === 500) { assert.equal(body.replayed, true); - assert.equal(body.error, 'provider failed'); + assert.equal(body.error, 'terminal execution failed'); assert.equal(result.txHash, decision.txHash); } } From d035c3e1c6342d7069467e392bca1077ff7fe7dc Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:41:23 -0400 Subject: [PATCH 086/165] fix: close cross-process lifecycle races --- spikes/pi-wielder/src/collar.mjs | 29 +++++++++---- spikes/pi-wielder/src/invocation-journal.mjs | 22 +++++++--- .../pi-wielder/tests/collar-failure.test.mjs | 43 +++++++++++++++++++ .../tests/invocation-journal.test.mjs | 25 +++++++++++ 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index c4fb8fc..8de8b57 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -343,14 +343,19 @@ export function createCollar({ && Boolean(resolution.refundReference.trim()); const markRefundOutcomeUnresolved = (record) => { - if (record.payment.state === 'refunded' - || record.payment.refundExecution?.state === 'unresolved') return; - journal.markRefundUnresolved(record.idempotencyKey, { + const current = journal.getByIdempotencyKey(record.idempotencyKey); + if (current.payment.state === 'refunded' + || current.payment.refundExecution?.state === 'unresolved') return current; + return journal.markRefundUnresolved(record.idempotencyKey, { refundAttemptId: record.payment.refundExecution.refundAttemptId, reason: 'trusted refund outcome unresolved', }); }; + const refundTerminalResponse = (c, record) => c.json({ + receipt: record.receipt ?? journal.issueReceipt(record.idempotencyKey), + }); + const finalizeRefund = (record, resolution) => { journal.refundExternalPayment(record.idempotencyKey, { refundAttemptId: record.payment.refundExecution.refundAttemptId, @@ -379,6 +384,9 @@ export function createCollar({ if (!refundExecutor) return c.json({ error: 'trusted refund executor is not configured' }, 501); const claim = journal.startRefund(record.idempotencyKey); if (!claim.started) { + if (claim.record.payment.state === 'refunded') { + return refundTerminalResponse(c, claim.record); + } return c.json({ error: 'refund outcome unresolved; trusted reconciliation is required', refundAttemptId: claim.record.payment.refundExecution?.refundAttemptId ?? null, @@ -394,11 +402,13 @@ export function createCollar({ refundAttemptId: request.refundAttemptId, }); } catch { - markRefundOutcomeUnresolved(record); + const current = markRefundOutcomeUnresolved(record); + if (current.payment.state === 'refunded') return refundTerminalResponse(c, current); return c.json({ error: 'refund outcome unresolved; trusted reconciliation is required' }, 503); } if (!refundEvidenceMatches(resolution, request)) { - markRefundOutcomeUnresolved(record); + const current = markRefundOutcomeUnresolved(record); + if (current.payment.state === 'refunded') return refundTerminalResponse(c, current); return c.json({ error: 'refund outcome unresolved; trusted reconciliation is required' }, 503); } return c.json({ receipt: finalizeRefund(record, resolution) }); @@ -423,15 +433,18 @@ export function createCollar({ try { resolution = await refundResolver(request); } catch { - markRefundOutcomeUnresolved(record); + const current = markRefundOutcomeUnresolved(record); + if (current.payment.state === 'refunded') return refundTerminalResponse(c, current); return c.json({ error: 'trusted refund resolution failed' }, 502); } if (!resolution?.refunded) { - markRefundOutcomeUnresolved(record); + const current = markRefundOutcomeUnresolved(record); + if (current.payment.state === 'refunded') return refundTerminalResponse(c, current); return c.json({ refundState: 'unresolved', refundAttemptId: request.refundAttemptId }, 202); } if (!refundEvidenceMatches(resolution, request)) { - markRefundOutcomeUnresolved(record); + const current = markRefundOutcomeUnresolved(record); + if (current.payment.state === 'refunded') return refundTerminalResponse(c, current); return c.json({ error: 'trusted refund resolver returned mismatched evidence' }, 502); } return c.json({ receipt: finalizeRefund(record, resolution) }); diff --git a/spikes/pi-wielder/src/invocation-journal.mjs b/spikes/pi-wielder/src/invocation-journal.mjs index 95e1658..770b2c3 100644 --- a/spikes/pi-wielder/src/invocation-journal.mjs +++ b/spikes/pi-wielder/src/invocation-journal.mjs @@ -251,14 +251,19 @@ function withLease(lockPath, operation, hooks = {}) { } } +export function receiptKeyId(publicKey) { + const keyObject = publicKey?.type === 'public' ? publicKey : crypto.createPublicKey(publicKey); + return `sha256:${crypto.createHash('sha256') + .update(keyObject.export({ type: 'spki', format: 'der' })) + .digest('hex')}`; +} + export function createReceiptSigner(keys = {}, { persistent = false } = {}) { const pair = keys.privateKey && keys.publicKey ? { privateKey: keys.privateKey, publicKey: keys.publicKey } : crypto.generateKeyPairSync('ed25519'); const publicKeyPem = pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); - const keyId = `sha256:${crypto.createHash('sha256') - .update(pair.publicKey.export({ type: 'spki', format: 'der' })) - .digest('hex')}`; + const keyId = receiptKeyId(pair.publicKey); return Object.freeze({ algorithm: 'Ed25519', publicKeyPem, @@ -1063,8 +1068,15 @@ export function createInvocationJournal({ assertState(record, ['authorized'], 'startExecution'); const attempt = executionAttemptId ?? `attempt:${crypto.createHash('sha256') .update(`${record.invocationId}\n${record.requestHash}`).digest('hex')}`; - append('execution.started', key, { executionAttemptId: requireText(attempt, 'executionAttemptId') }); - return { started: true, record: copy(records.get(key)) }; + try { + append('execution.started', key, { executionAttemptId: requireText(attempt, 'executionAttemptId') }); + return { started: true, record: copy(records.get(key)) }; + } catch (error) { + if (error.code !== 'JOURNAL_CONFLICT') throw error; + const winner = requireRecord(records, key); + if (winner.execution.state === 'authorized') throw error; + return { started: false, record: copy(winner) }; + } } function finishExecution(key, input) { diff --git a/spikes/pi-wielder/tests/collar-failure.test.mjs b/spikes/pi-wielder/tests/collar-failure.test.mjs index 995708b..0cc9093 100644 --- a/spikes/pi-wielder/tests/collar-failure.test.mjs +++ b/spikes/pi-wielder/tests/collar-failure.test.mjs @@ -82,6 +82,7 @@ async function invokeSettledFailure({ resolveRefund = null, lifecycleFaults = {}, executeSkill = async () => { throw new Error('refund-target provider fault'); }, + onCollarCreated = null, } = {}) { const collar = createCollar({ facilitatorTransport: mockTransport(), @@ -90,6 +91,7 @@ async function invokeSettledFailure({ resolveRefund, lifecycleFaults, }); + onCollarCreated?.(collar); const result = await payingFetch(throwawayAccount(), invokeUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, }, { @@ -602,6 +604,47 @@ test('overlapping refund requests durably claim one external execution', async ( assert.equal(collar.journal.events.filter((event) => event.type === 'refund.started').length, 1); }); +test('a concurrently confirmed refund wins over an unresolved executor return path', async () => { + let collarAuthority; + let trustedResolution; + const { collar, result } = await invokeSettledFailure({ + onCollarCreated: (created) => { collarAuthority = created; }, + executeRefund: async (request) => { + trustedResolution = { + refunded: true, + settlementReference: request.settlementReference, + originalTxHash: request.originalTxHash, + payer: request.payer, + amountAtomic: request.amountAtomic, + refundReference: 'concurrent-winner-refund', + }; + return trustedResolution; + }, + lifecycleFaults: { + afterRefundExecutorReturned: async ({ idempotencyKey, refundAttemptId }) => { + collarAuthority.journal.refundExternalPayment(idempotencyKey, { + refundAttemptId, + reason: 'trusted full-gross refund confirmed', + refundReference: trustedResolution.refundReference, + refundAmountAtomic: trustedResolution.amountAtomic, + }); + collarAuthority.journal.issueReceipt(idempotencyKey); + throw new Error('stale worker lost the completion race'); + }, + }, + }); + const endpoint = `http://collar.test/refund/by-settlement/${result.settlementReference}`; + const response = await collar.app.request(endpoint, { method: 'POST' }); + assert.equal(response.status, 200); + const receipt = (await response.json()).receipt; + assert.equal(receipt.receipt.payment.state, 'refunded'); + assert.equal(receipt.receipt.payment.refundReference, 'concurrent-winner-refund'); + assert.equal(verifySignedReceipt(receipt, trustFor(collar)), true); + const replay = await collar.app.request(endpoint, { method: 'POST' }); + assert.equal(replay.status, 200); + assert.deepEqual((await replay.json()).receipt, receipt); +}); + test('refund crash boundary stays unresolved and provider secrets never reach clients', async () => { const secret = 'sk-refund-super-secret'; let calls = 0; diff --git a/spikes/pi-wielder/tests/invocation-journal.test.mjs b/spikes/pi-wielder/tests/invocation-journal.test.mjs index 5cd7e6e..51e6bfa 100644 --- a/spikes/pi-wielder/tests/invocation-journal.test.mjs +++ b/spikes/pi-wielder/tests/invocation-journal.test.mjs @@ -12,6 +12,7 @@ import { createInvocationJournal, createReceiptSigner, loadOrCreateReceiptSigner, + receiptKeyId, verifySignedReceipt, } from '../src/invocation-journal.mjs'; @@ -79,6 +80,18 @@ const trustFor = (journal) => ({ keyId: journal.signingKeyId, }); +test('receipt key IDs are exactly one SHA-256 digest of SPKI DER', () => { + const signer = createReceiptSigner(); + const publicKey = crypto.createPublicKey(signer.publicKeyPem); + const der = publicKey.export({ type: 'spki', format: 'der' }); + const expected = `sha256:${crypto.createHash('sha256').update(der).digest('hex')}`; + const doubleHashed = `sha256:${crypto.createHash('sha256') + .update(Buffer.from(expected.slice('sha256:'.length), 'hex')).digest('hex')}`; + assert.equal(receiptKeyId(signer.publicKeyPem), expected); + assert.equal(signer.keyId, expected); + assert.notEqual(signer.keyId, doubleHashed); +}); + function offer(journal, input = declaration) { journal.requestInvocation(input); journal.offerExternalPayment(input.idempotencyKey, quote); @@ -400,6 +413,18 @@ test('separate journal instances observe one durable refund claim', () => { ); }); +test('separate journal instances observe one durable execution claim', () => { + const { filePath, signingKeyPath } = temporaryAuthority('collar-execution-claim-'); + const first = createInvocationJournal({ filePath, signingKeyPath }); + settle(first); + const second = createInvocationJournal({ filePath, signingKeyPath }); + assert.equal(first.startExecution(declaration.idempotencyKey).started, true); + const losingClaim = second.startExecution(declaration.idempotencyKey); + assert.equal(losingClaim.started, false); + assert.equal(losingClaim.record.execution.state, 'executing'); + assert.equal(second.events.filter((event) => event.type === 'execution.started').length, 1); +}); + const waitForExit = (child) => new Promise((resolve, reject) => { let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; }); From 11fae53c9478f1dc9c81ac5121735894238ce917 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:45:07 -0400 Subject: [PATCH 087/165] feat: make Wielder ledger a trusted receipt view --- spikes/pi-wielder/e2e.mjs | 323 +++++++++++-------- spikes/pi-wielder/pi-extension/x402.ts | 40 ++- spikes/pi-wielder/src/ledger.mjs | 49 ++- spikes/pi-wielder/src/proxy.mjs | 154 ++++++++- spikes/pi-wielder/tests/proxy-trust.test.mjs | 167 ++++++++++ 5 files changed, 560 insertions(+), 173 deletions(-) create mode 100644 spikes/pi-wielder/tests/proxy-trust.test.mjs diff --git a/spikes/pi-wielder/e2e.mjs b/spikes/pi-wielder/e2e.mjs index 17e6b69..50e89fe 100644 --- a/spikes/pi-wielder/e2e.mjs +++ b/spikes/pi-wielder/e2e.mjs @@ -1,56 +1,64 @@ -// e2e.mjs — the proof: ONE WALLET, TWO ASSET CLASSES, ONE ATTRIBUTED LEDGER. -// -// Fully offline under MOCK_FACILITATOR=1 + MOCK_LLM=1 (the default when run -// as `npm run e2e`): no network, no API keys, no funds. It boots the collar, -// the inference gateway, and the Wielder proxy on ephemeral ports, then — -// through THE PROXY ONLY — makes a claude "plan" completion, a gpt -// "implement" completion, and one hosted-skill invocation, asserting the -// whole x402 + settlement story along the way. +// Offline proof: one Wielder wallet, model payments plus a Skill Invocation, +// and one local receipt view. Every HTTP hop is an in-process Hono request; +// no listener, network route, funded wallet, or live facilitator is used. -process.env.MOCK_FACILITATOR ??= '1'; -process.env.MOCK_LLM ??= '1'; +process.env.MOCK_LLM = '1'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { startMockFacilitator } from './src/facilitator-mock.mjs'; -import { startCollar, SKILL_ID } from './src/collar.mjs'; -import { startGateway, MODEL_PRICES_USDC } from './src/gateway.mjs'; -import { startProxy } from './src/proxy.mjs'; + +import { createCollar, SKILL_ID } from './src/collar.mjs'; +import { createMockFacilitator } from './src/facilitator-mock.mjs'; +import { createGateway, MODEL_PRICES_USDC } from './src/gateway.mjs'; +import { verifySignedReceipt } from './src/invocation-journal.mjs'; +import { payingFetch, createProxy } from './src/proxy.mjs'; import { throwawayAccount } from './src/wallet.mjs'; -import { usdcToAtomic, atomicToUsdc } from './src/x402-seller.mjs'; -import { - createState, addParty, registerSkill, setRoyalty, invoke, -} from '../../prototype/settlement-engine.mjs'; +import { createMockFacilitatorTransport, usdcToAtomic } from './src/x402-seller.mjs'; -// --- tiny assertion harness -------------------------------------------------- let checks = 0; -function ok(cond, label) { +function ok(condition, label) { checks += 1; - if (!cond) { console.error(` ✗ ${label}`); process.exitCode = 1; throw new Error(`FAILED: ${label}`); } + if (!condition) throw new Error(`FAILED: ${label}`); console.log(` ✓ ${label}`); } -const eq = (a, b, label) => ok(JSON.stringify(a) === JSON.stringify(b), `${label} (${JSON.stringify(a)} === ${JSON.stringify(b)})`); - -const here = (p) => fileURLToPath(new URL(p, import.meta.url)); -const LEDGER_FILE = here('./session-ledger.jsonl'); -fs.rmSync(LEDGER_FILE, { force: true }); - -// --- boot: facilitator -> sellers -> the one wallet's paying proxy ------------ -const account = throwawayAccount(); // zero funds needed: mock facilitator verifies signatures, fakes settlement -const facilitator = await startMockFacilitator(); -const collar = await startCollar({ facilitatorUrl: facilitator.url }); -const gateway = await startGateway({ facilitatorUrl: facilitator.url }); -const proxy = await startProxy({ account, gatewayUrl: gateway.url, collarUrl: collar.url, ledgerFile: LEDGER_FILE }); - -console.log(`\nPi-Wielder e2e (MOCK_FACILITATOR=${process.env.MOCK_FACILITATOR}, MOCK_LLM=${process.env.MOCK_LLM})`); -console.log(`wallet ${account.address}`); -console.log(`facilitator ${facilitator.url} · collar ${collar.url} · gateway ${gateway.url} · proxy ${proxy.url}\n`); +const eq = (actual, expected, label) => ok( + JSON.stringify(actual) === JSON.stringify(expected), + `${label} (${JSON.stringify(actual)} === ${JSON.stringify(expected)})`, +); +const here = (relative) => fileURLToPath(new URL(relative, import.meta.url)); + +const account = throwawayAccount(); +const facilitator = createMockFacilitator(); +const facilitatorTransport = createMockFacilitatorTransport( + (url, init) => facilitator.request(url, init), +); +const collar = createCollar({ facilitatorTransport, mockLlm: true }); +const gateway = createGateway({ facilitatorTransport, mockLlm: true }); +const proxy = createProxy({ + account, + gatewayUrl: 'http://gateway.test', + collarUrl: 'http://collar.test', + gatewayFetch: (url, init) => gateway.request(url, init), + collarFetch: (url, init) => collar.app.request(url, init), + trustedCollarPublicKeyPem: collar.journal.signingPublicKeyPem, + trustedCollarKeyId: collar.journal.signingKeyId, +}); +const trust = { + publicKeyPem: collar.journal.signingPublicKeyPem, + keyId: collar.journal.signingKeyId, +}; + +console.log('\nPi-Wielder offline in-process e2e'); +console.log(`wallet ${account.address} (throwaway, unfunded)`); const overheads = []; -async function viaProxy(path, body, label) { - const res = await fetch(`${proxy.url}${path}`, { +async function viaProxy(path, body, label = null) { + const res = await proxy.app.request(`http://proxy.test${path}`, { method: 'POST', - headers: { 'content-type': 'application/json', ...(label ? { 'x-session-label': label } : {}) }, + headers: { + 'content-type': 'application/json', + ...(label ? { 'x-session-label': label } : {}), + }, body: JSON.stringify(body), }); const json = await res.json(); @@ -59,99 +67,152 @@ async function viaProxy(path, body, label) { return { res, json }; } -try { - // --- 0. the gates are real: unpaid direct requests are refused -------------- - console.log('unpaid requests are 402-challenged:'); - for (const [name, url] of [['gateway', `${gateway.url}/v1/chat/completions`], ['collar', `${collar.url}/invoke/${SKILL_ID}`]]) { - const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model: 'gpt-x', input: 'x' }) }); - const b = await r.json(); - ok(r.status === 402 && b.x402Version === 1 && b.accepts?.[0]?.scheme === 'exact', `${name} answers 402 with an "exact" payment offer`); - } - - // --- 1. asset class one: per-call model inference (claude plans...) --------- - console.log('\nleg 1 — model inference, claude/plan:'); - const plan = await viaProxy('/v1/chat/completions', { - model: 'claude-sonnet-4-6', - messages: [{ role: 'user', content: 'Plan a refactor of the settlement engine tests.' }], - }, 'plan'); - ok(plan.res.status === 200, 'completion succeeded through the proxy'); - ok(plan.res.headers.get('x-wielder-402') === '1', 'proxy hit a 402 first and paid to proceed'); - ok(plan.json.choices?.[0]?.message?.content?.length > 0, 'got assistant content back'); - - // --- 2. ...and gpt implements — same wallet, different upstream ------------- - console.log('\nleg 2 — model inference, gpt/implement:'); - const impl = await viaProxy('/v1/chat/completions', { - model: 'gpt-5.2', - messages: [{ role: 'user', content: 'Implement the plan.' }], - }, 'implement'); - ok(impl.res.status === 200, 'completion succeeded through the proxy'); - ok(impl.res.headers.get('x-wielder-402') === '1', 'proxy hit a 402 first and paid to proceed'); - - // --- 3. asset class two: hosted-skill invocation behind the collar ---------- - console.log(`\nleg 3 — hosted skill, ${SKILL_ID}:`); - const skill = await viaProxy(`/invoke/${SKILL_ID}`, { input: 'make the checkout page faster' }); - ok(skill.res.status === 200, 'invocation succeeded through the proxy'); - ok(skill.res.headers.get('x-wielder-402') === '1', 'proxy hit a 402 first and paid to proceed'); - ok(skill.json.output?.length > 0, 'skill returned output'); - - // Output only — the skill's content must never cross the collar boundary. - const skillMd = fs.readFileSync(here(`../../.claude/skills/${SKILL_ID}/SKILL.md`), 'utf8'); - const fullResponse = JSON.stringify(skill.json); - const fingerprints = ['The one rule that makes this skill worth invoking', 'The seven ingredients', skillMd.slice(0, 400)]; - ok(fingerprints.every((f) => !fullResponse.includes(f)), 'response contains NO skill content (checked 3 fingerprints)'); - - // --- 4. the settled txHash is a single-use credential: replay refused ------- - console.log('\nreplay protection:'); - const usedPayment = skill.res.headers.get('x-wielder-payment'); - const replay = await fetch(`${collar.url}/invoke/${SKILL_ID}`, { +console.log('unpaid requests are challenged:'); +for (const [name, app, url, body] of [ + ['gateway', gateway, 'http://gateway.test/v1/chat/completions', { model: 'gpt-x' }], + ['collar', collar.app, `http://collar.test/invoke/${SKILL_ID}`, { input: 'x' }], +]) { + const response = await app.request(url, { method: 'POST', - headers: { 'content-type': 'application/json', 'X-PAYMENT': usedPayment }, - body: JSON.stringify({ input: 'try to run again on the same payment' }), + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': `unpaid-${name}`, + }, + body: JSON.stringify(body), }); - ok(replay.status === 409, `replaying the settled credential is rejected (HTTP ${replay.status})`); - ok((await replay.json()).error?.includes('replay'), 'rejection names the replay'); - - // --- 5. the unified ledger: 3 entries, exact engine-computed skill split ---- - console.log('\nunified session ledger:'); - const entries = await (await fetch(`${proxy.url}/ledger?format=json`)).json(); - eq(entries.length, 3, 'ledger has exactly 3 entries'); - eq(entries.map((e) => e.leg), ['model', 'model', 'skill'], 'legs attributed: model, model, skill'); - eq(entries.map((e) => e.label), ['claude/plan', 'gpt/implement', `skill/${SKILL_ID}`], 'labels attributed'); - eq(entries.map((e) => e.amountUSDC), [MODEL_PRICES_USDC.claude, MODEL_PRICES_USDC.gpt, 0.25], 'amounts match the quoted 402 offers'); - ok(entries.every((e) => /^0x[0-9a-f]{64}$/.test(e.txHash)), 'every entry carries a settlement txHash'); - - // Recompute the skill split with the settlement engine itself (same seed - // shapes the collar uses) and demand an exact match. distribute() is not - // exported by the prototype, so we drive it through the public invoke(). - const ref = createState(); - addParty(ref, { id: 'creator', name: 'Skill creator', role: 'Creator' }); - addParty(ref, { id: 'wielder', name: 'Session wallet', role: 'Wielder/Beneficiary', balance: 1e12 }); - registerSkill(ref, { id: SKILL_ID, name: SKILL_ID, creatorId: 'creator', price: Number(usdcToAtomic(0.25)), mode: 'marketplace' }); - setRoyalty(ref, SKILL_ID, [{ partyId: 'creator', bps: 10000 }]); - const expected = invoke(ref, SKILL_ID, 'wielder'); - const expectedSplits = [ - ...expected.breakdown.map((b) => ({ party: b.partyId, amountUSDC: atomicToUsdc(b.amount) })), - { party: 'treasury', amountUSDC: atomicToUsdc(expected.fee) }, - ]; - eq(entries[2].splits, expectedSplits, 'skill split matches settlement-engine distribute() exactly'); - eq(entries[2].splits, skill.json.receipt.splits, 'collar receipt and ledger agree'); + const challenge = await response.json(); + ok( + response.status === 402 + && challenge.x402Version === 1 + && challenge.accepts?.[0]?.scheme === 'exact', + `${name} returns one exact x402 offer`, + ); +} - // --- 6. payment overhead (MOCK numbers) -------------------------------------- - console.log('\nper-call x402 payment overhead — MOCK numbers (localhost, fake settlement;'); - console.log('testnet adds real facilitator HTTP + Base Sepolia inclusion time):'); - for (const o of overheads) { - console.log(` ${o.call.padEnd(10)} 402-roundtrip ${o.ms402.toFixed(1)}ms · sign ${o.msSign.toFixed(1)}ms · verify+settle ${o.msFacilitator.toFixed(1)}ms · total overhead ${o.msOverhead.toFixed(1)}ms`); +console.log('\nmodel receipt views:'); +const plan = await viaProxy('/v1/chat/completions', { + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'Plan a refactor.' }], +}, 'plan'); +ok(plan.res.status === 200, 'claude model leg settles and succeeds'); +ok(plan.json.choices?.[0]?.message?.content?.length > 0, 'claude mock output returned'); +const implementation = await viaProxy('/v1/chat/completions', { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'Implement the plan.' }], +}, 'implement'); +ok(implementation.res.status === 200, 'gpt model leg settles and succeeds'); + +console.log('\nSkill receipt authority:'); +const skillRequestBody = JSON.stringify({ input: 'make the checkout page faster' }); +const skill = await viaProxy(`/invoke/${SKILL_ID}`, JSON.parse(skillRequestBody)); +ok(skill.res.status === 200, 'Skill Invocation settles and succeeds'); +ok(skill.json.output?.length > 0, 'Skill output returned'); +ok(verifySignedReceipt(skill.json.receipt, trust), 'response receipt verifies against pinned Collar key'); +const skillContent = fs.readFileSync(here(`../../.claude/skills/${SKILL_ID}/SKILL.md`), 'utf8'); +const responseBytes = JSON.stringify(skill.json); +ok([ + 'The one rule that makes this skill worth invoking', + 'The seven ingredients', + skillContent.slice(0, 400), +].every((fingerprint) => !responseBytes.includes(fingerprint)), 'hosted Skill artifact never crosses the Collar'); + +const entries = proxy.ledger.entries; +eq(entries.length, 3, 'Wielder view has three settled calls'); +eq(entries.map((entry) => entry.leg), ['model', 'model', 'skill'], 'both asset classes are attributed'); +ok(entries.every((entry) => entry.view === 'wielder-receipt'), 'every local entry identifies itself as a receipt view'); +ok(entries.every((entry) => entry.status === 'succeeded'), 'successful terminal state is retained'); +eq(entries.map((entry) => entry.amountAtomic), [ + usdcToAtomic(MODEL_PRICES_USDC.claude), + usdcToAtomic(MODEL_PRICES_USDC.gpt), + usdcToAtomic('0.25'), +], 'quoted amounts remain canonical atomic strings'); +ok(entries.every((entry) => /^0x[0-9a-f]{64}$/.test(entry.txHash)), 'every settled view carries a transaction hash'); +ok( + JSON.stringify(entries[2].receipt) === JSON.stringify(skill.json.receipt), + 'response and Wielder cache contain the identical signed receipt', +); +const authoritative = collar.journal.getByTxHash(entries[2].txHash); +eq(entries[2].receipt.receipt.invocationId, authoritative.invocationId, 'receipt view points to the authoritative Collar Invocation'); +const accounting = entries[2].receipt.receipt.accounting; +eq(entries[2].splits, [ + ...accounting.holderCredits.map((credit) => ({ + party: credit.recipientId, + amountAtomic: credit.amountAtomic, + })), + ...accounting.ancestorCredits.map((credit) => ({ + party: credit.recipientId, + amountAtomic: credit.amountAtomic, + })), + { party: 'treasury', amountAtomic: accounting.protocolFeeAtomic }, +], 'displayed claims are projected only from finalized signed accounting'); + +console.log('\nidempotency and replay:'); +const skillEntry = entries[2]; +const usedPayment = skill.res.headers.get('x-wielder-payment'); +const eventCount = collar.journal.events.length; +const exactReplay = await collar.app.request(`http://collar.test/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': skillEntry.idempotencyKey, + 'X-PAYMENT': usedPayment, + }, + body: skillRequestBody, +}); +ok(exactReplay.status === 200 && (await exactReplay.json()).replayed === true, 'exact paid retry replays terminal receipt'); +eq(collar.journal.events.length, eventCount, 'terminal replay appends no event and executes no Skill'); +const conflict = await collar.app.request(`http://collar.test/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': skillEntry.idempotencyKey, + 'X-PAYMENT': usedPayment, + }, + body: JSON.stringify({ input: 'different bytes' }), +}); +ok(conflict.status === 409, 'conflicting body under the same key fails before execution'); + +console.log('\nunresolved settlement:'); +const lossyFacilitator = createMockFacilitator(); +let settleCalls = 0; +const lossyTransport = createMockFacilitatorTransport(async (url, init) => { + const response = await lossyFacilitator.request(url, init); + if (new URL(url).pathname === '/settle') { + settleCalls += 1; + throw new Error('synthetic response loss'); } - const sorted = overheads.map((o) => o.msOverhead).sort((a, b) => a - b); - const p50 = sorted[Math.floor(sorted.length / 2)]; - console.log(` p50 ${p50.toFixed(1)}ms · max ${sorted.at(-1).toFixed(1)}ms (n=${sorted.length}, mock)`); - - // --- the money shot ----------------------------------------------------------- - console.log('\nsession ledger (one wallet, two asset classes, three payees):'); - console.log(' ' + (await (await fetch(`${proxy.url}/ledger`)).text()).split('\n').join('\n ')); - console.log(` (JSONL at ${LEDGER_FILE})`); - - console.log(`\nPASS — ${checks} checks green.`); -} finally { - proxy.close(); gateway.close(); collar.close(); facilitator.close(); + return response; +}); +const unresolvedCollar = createCollar({ + facilitatorTransport: lossyTransport, + mockLlm: true, +}); +const unresolvedKey = 'e2e-unresolved-payment'; +const unresolved = await payingFetch(account, `http://unresolved.test/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: skillRequestBody, +}, { + idempotencyKey: unresolvedKey, + fetchImpl: (url, init) => unresolvedCollar.app.request(url, init), +}); +ok(unresolved.res.status === 503, 'lost settlement response is explicitly unresolved'); +const unresolvedRetry = await unresolvedCollar.app.request(`http://unresolved.test/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': unresolvedKey, + 'X-PAYMENT': unresolved.xPayment, + }, + body: skillRequestBody, +}); +ok(unresolvedRetry.status === 503, 'exact unresolved retry remains blocked for trusted reconciliation'); +eq(settleCalls, 1, 'unresolved retry never re-verifies or re-settles'); + +console.log('\nWielder receipt view:'); +console.log(` ${(await (await proxy.app.request('http://proxy.test/ledger')).text()).split('\n').join('\n ')}`); +console.log('\nSynthetic in-process payment overhead (not network measurements):'); +for (const sample of overheads) { + console.log(` ${sample.call}: ${sample.msOverhead.toFixed(1)}ms synthetic`); } +console.log(`\nPASS — ${checks} checks green (offline, in-process, synthetic timings).`); diff --git a/spikes/pi-wielder/pi-extension/x402.ts b/spikes/pi-wielder/pi-extension/x402.ts index fe41540..6b1c669 100644 --- a/spikes/pi-wielder/pi-extension/x402.ts +++ b/spikes/pi-wielder/pi-extension/x402.ts @@ -19,6 +19,30 @@ const PROXY = process.env.PI_WIELDER_PROXY ?? "http://localhost:8402"; +const displayUsdc = (amountAtomic: string) => { + const padded = BigInt(amountAtomic).toString().padStart(7, "0"); + const value = `${padded.slice(0, -6)}.${padded.slice(-6)}`.replace(/0+$/, "").replace(/\.$/, ""); + return `$${value}`; +}; + +type SignedInvocationReceipt = { + receipt: { + quote: { amountAtomic: string }; + payment: { state: "settled" | "refunded"; txHash: string }; + execution: { state: "succeeded" | "failed" | "cancelled" }; + accounting: { + allocationState: "finalized" | "pending_cogs_reconciliation"; + protocolFeeAtomic?: string; + holderCredits: { recipientId: string; amountAtomic: string }[]; + ancestorCredits: { recipientId: string; amountAtomic: string }[]; + }; + }; + receiptHash: string; + signature: string; + algorithm: "Ed25519"; + keyId: string; +}; + // Minimal structural type for the documented extension surface, so this file // stands alone without pi's type package. type Pi = { @@ -93,16 +117,24 @@ export default function activate(pi: Pi) { if (!res.ok) return `invoke_skill failed (HTTP ${res.status}): ${await res.text()}`; const { output, receipt } = (await res.json()) as { output: string; - receipt: { txHash: string; amountUSDC: number; splits: { party: string; amountUSDC: number }[] }; + receipt: SignedInvocationReceipt; }; - const split = receipt.splits.map((s) => `${s.party} $${s.amountUSDC}`).join(" / "); - return `${output}\n\n[paid $${receipt.amountUSDC} · tx ${receipt.txHash.slice(0, 10)}… · split ${split}]`; + const invocation = receipt.receipt; + const accounting = invocation.accounting; + const claims = accounting.allocationState === "finalized" && accounting.protocolFeeAtomic + ? [ + ...accounting.holderCredits.map((credit) => `${credit.recipientId} ${displayUsdc(credit.amountAtomic)}`), + ...accounting.ancestorCredits.map((credit) => `${credit.recipientId} ${displayUsdc(credit.amountAtomic)}`), + `treasury ${displayUsdc(accounting.protocolFeeAtomic)}`, + ].join(" / ") + : "full gross held for accounting reconciliation"; + return `${output}\n\n[${invocation.execution.state} · paid ${displayUsdc(invocation.quote.amountAtomic)} · tx ${invocation.payment.txHash.slice(0, 10)}… · ${claims} · receipt ${receipt.receiptHash.slice(0, 10)}…]`; }, }); // --- /ledger: the unified session meter, rendered by the proxy ----------- pi.registerCommand("ledger", { - description: "Show this session's unified x402 ledger (inference + skills)", + description: "Show this session's local x402 receipt view (inference + Skills)", async handler() { const res = await fetch(`${PROXY}/ledger`); return res.ok ? await res.text() : `ledger unavailable (HTTP ${res.status}) — is the proxy running?`; diff --git a/spikes/pi-wielder/src/ledger.mjs b/spikes/pi-wielder/src/ledger.mjs index e75cf6c..5b7c97b 100644 --- a/spikes/pi-wielder/src/ledger.mjs +++ b/spikes/pi-wielder/src/ledger.mjs @@ -1,46 +1,41 @@ -// ledger.mjs — the unified, attributed session ledger. THE product claim. +// Wielder-side receipt view. // -// Inference calls and skill invocations are different asset classes with -// different settlement stories (pass-through vs royalty split), but they are -// entries in the SAME ledger, attributed to the SAME wallet session. That -// "unified meter" is what the design doc says differentiates this from the -// commoditizing x402 inference resellers (Router402, ClawRouter, tx402.ai). -// -// Format: JSONL, one entry per paid call: -// { ts, leg: "model"|"skill", label, amountUSDC, txHash, splits } -// splits: [{ party, amountUSDC }] for skill legs (royalty breakdown + -// protocol treasury, produced by the settlement engine), null for -// plain pass-through model legs. +// The Collar journal is authoritative for Skill Invocations. This store keeps +// only what the payer observed: payment identity plus the pinned, verified +// signed Collar receipt when the leg is a Skill. It never recomputes claims. import fs from 'node:fs'; +import { formatUsdc } from '../../../prototype/atomic-money.mjs'; + export function createLedger(filePath = null) { const entries = []; return { entries, record(entry) { - const full = { ts: new Date().toISOString(), ...entry }; + if (entry.view !== 'wielder-receipt') { + throw new Error("Wielder ledger entries must use view 'wielder-receipt'"); + } + const full = { ts: new Date().toISOString(), ...structuredClone(entry) }; entries.push(full); - if (filePath) fs.appendFileSync(filePath, JSON.stringify(full) + '\n'); + if (filePath) fs.appendFileSync(filePath, `${JSON.stringify(full)}\n`); return full; }, }; } -const fmt = (n) => '$' + Number(n).toFixed(6).replace(/0+$/, '').replace(/\.$/, ''); +const display = (amountAtomic) => `$${formatUsdc(BigInt(amountAtomic)) + .replace(/0+$/, '').replace(/\.$/, '')}`; -/** - * One-line session view, e.g.: - * claude/plan $0.041 · gpt/implement $0.087 · skill/optimizing-claude-code-prompts $0.25 - * → creator $0.24375 / treasury $0.00625 - */ export function renderLedger(entries) { - if (!entries.length) return '(empty session ledger)'; - const parts = entries.map((e) => { - let s = `${e.label} ${fmt(e.amountUSDC)}`; - if (e.splits?.length) s += ` → ${e.splits.map((x) => `${x.party} ${fmt(x.amountUSDC)}`).join(' / ')}`; - return s; + if (!entries.length) return '(empty Wielder receipt view)'; + const parts = entries.map((entry) => { + let line = `${entry.label} ${display(entry.amountAtomic)} [${entry.status}]`; + if (entry.splits?.length) { + line += ` → ${entry.splits.map((split) => `${split.party} ${display(split.amountAtomic)}`).join(' / ')}`; + } + return line; }); - const total = entries.reduce((a, e) => a + Number(e.amountUSDC), 0); - return `${parts.join(' · ')}\n session total ${fmt(total)} across ${entries.length} paid calls, one wallet`; + const totalAtomic = entries.reduce((sum, entry) => sum + BigInt(entry.amountAtomic), 0n); + return `${parts.join(' · ')}\n session receipt total ${display(totalAtomic)} across ${entries.length} settled calls, one wallet`; } diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index c7e79f5..f4e0895 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -14,12 +14,15 @@ // ╚══════════════════════════════════════════════════════════════════════════╝ import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { Hono } from 'hono'; import { serve } from '@hono/node-server'; import { formatUsdc } from '../../../prototype/atomic-money.mjs'; import { loadAccount } from './wallet.mjs'; import { createLedger, renderLedger } from './ledger.mjs'; +import { receiptKeyId, verifySignedReceipt } from './invocation-journal.mjs'; // EIP-712 typed data for EIP-3009 transferWithAuthorization — the single // signature that IS the payment. (Constants restated here on purpose: the @@ -100,36 +103,161 @@ export async function payingFetch(account, url, init, { }; } +export function loadPinnedCollarTrust(env = process.env) { + const publicKeyFile = env.COLLAR_PUBLIC_KEY_FILE || null; + const expectedKeyId = env.COLLAR_KEY_ID || null; + if (!publicKeyFile || !expectedKeyId) { + throw new Error('Skill routes require COLLAR_PUBLIC_KEY_FILE and COLLAR_KEY_ID'); + } + if (!path.isAbsolute(publicKeyFile)) throw new Error('COLLAR_PUBLIC_KEY_FILE must be absolute'); + const stat = fs.lstatSync(publicKeyFile); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('COLLAR_PUBLIC_KEY_FILE must be a regular non-symlink file'); + } + const descriptor = fs.openSync( + publicKeyFile, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + let publicKeyPem; + try { + if (!fs.fstatSync(descriptor).isFile()) { + throw new Error('COLLAR_PUBLIC_KEY_FILE must remain a regular file'); + } + publicKeyPem = fs.readFileSync(descriptor, 'utf8'); + } finally { + fs.closeSync(descriptor); + } + const actualKeyId = receiptKeyId(publicKeyPem); + if (actualKeyId !== expectedKeyId) { + throw new Error('COLLAR_KEY_ID does not match COLLAR_PUBLIC_KEY_FILE'); + } + return { trustedCollarPublicKeyPem: publicKeyPem, trustedCollarKeyId: expectedKeyId }; +} + +export function assertReceiptMatchesPayment(bundle, expected) { + const receipt = bundle?.receipt; + const lower = (value) => String(value ?? '').toLowerCase(); + const terminal = new Set(['succeeded', 'failed', 'cancelled']); + const paymentTerminal = new Set(['settled', 'refunded']); + const executionState = receipt?.execution?.state; + const httpStatus = receipt?.execution?.httpStatus; + const statusSemanticsMatch = executionState === 'succeeded' + ? httpStatus >= 200 && httpStatus < 400 + : Number.isSafeInteger(httpStatus) && httpStatus >= 400 && httpStatus <= 599; + if (!receipt + || receipt.schemaVersion !== 1 + || receipt.mode !== 'external' + || receipt.idempotencyKey !== expected.idempotencyKey + || receipt.requestHash !== expected.requestHash + || receipt.skill?.id !== expected.skillId + || receipt.quote?.requestHash !== expected.requestHash + || receipt.quote?.quoteId !== expected.quoteId + || receipt.quote?.amountAtomic !== expected.amountAtomic + || receipt.quote?.currency !== 'USDC' + || receipt.quote?.network !== 'base-sepolia' + || receipt.quote?.resource !== expected.resource + || lower(receipt.wielderId) !== lower(expected.payer) + || !paymentTerminal.has(receipt.payment?.state) + || lower(receipt.payment?.payer) !== lower(expected.payer) + || lower(receipt.payment?.settlementReference) !== lower(expected.settlementReference) + || lower(receipt.payment?.txHash) !== lower(expected.txHash) + || (receipt.payment?.state === 'refunded' + && receipt.payment.refundAmountAtomic !== expected.amountAtomic) + || !terminal.has(executionState) + || httpStatus !== expected.httpStatus + || !statusSemanticsMatch + || receipt.accounting?.grossAtomic !== expected.amountAtomic) { + throw new Error('signed Collar receipt does not semantically match the current paid request'); + } + return receipt; +} + export function createProxy({ account = loadAccount(), gatewayUrl = process.env.GATEWAY_URL || 'http://127.0.0.1:8403', collarUrl = process.env.COLLAR_URL || 'http://127.0.0.1:8404', ledgerFile = process.env.LEDGER_FILE ?? null, + gatewayFetch = fetch, + collarFetch = fetch, + trustedCollarPublicKeyPem = null, + trustedCollarKeyId = null, } = {}) { + if (!trustedCollarPublicKeyPem || !trustedCollarKeyId) { + throw new Error('Skill routes require a pinned Collar public key and key ID'); + } + if (receiptKeyId(trustedCollarPublicKeyPem) !== trustedCollarKeyId) { + throw new Error('pinned Collar public key and key ID do not match'); + } const ledger = createLedger(ledgerFile); const app = new Hono(); // One handler for both asset classes: /v1/* -> inference gateway (leg: // "model"), /invoke/* -> collar (leg: "skill"). Same wallet, same ledger. - const forward = (upstreamBase, leg) => async (c) => { + const forward = (upstreamBase, leg, fetchImpl) => async (c) => { const path = c.req.path; const bodyText = await c.req.text(); - const { res, paid, xPayment, amountUSDC, timings } = await payingFetch(account, `${upstreamBase}${path}`, { + const { + res, paid, xPayment, idempotencyKey, amountAtomic, txHash, payer, + requestHash, quoteId, settlementReference, timings, + } = await payingFetch(account, `${upstreamBase}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: bodyText, - }); + }, { fetchImpl }); const resBody = await res.text(); - if (paid && res.ok) { + if (paid && txHash) { let parsed = {}; try { parsed = JSON.parse(resBody); } catch { /* SSE bodies are not JSON */ } - const model = JSON.parse(bodyText || '{}').model ?? ''; + let requestBody = {}; + try { requestBody = JSON.parse(bodyText || '{}'); } catch { /* seller owns request validation */ } + const model = requestBody.model ?? ''; const label = leg === 'skill' ? `skill/${path.split('/').pop()}` : `${model.startsWith('claude') ? 'claude' : 'gpt'}/${c.req.header('x-session-label') || 'chat'}`; + const receipt = parsed.receipt ?? null; + if (leg === 'skill') { + if (!receipt || !verifySignedReceipt(receipt, { + publicKeyPem: trustedCollarPublicKeyPem, + keyId: trustedCollarKeyId, + })) { + throw new Error('Skill receipt signature does not match the pinned Collar key'); + } + assertReceiptMatchesPayment(receipt, { + idempotencyKey, + requestHash, + quoteId, + amountAtomic, + payer, + settlementReference, + txHash, + httpStatus: res.status, + skillId: path.split('/').pop(), + resource: `${upstreamBase}${path}`, + }); + } + const accounting = receipt?.receipt?.accounting ?? null; + const finalizedAccounting = accounting?.allocationState === 'finalized' + && typeof accounting.protocolFeeAtomic === 'string'; + const splits = finalizedAccounting ? [ + ...(accounting.holderCredits ?? []).map((credit) => ({ + party: credit.recipientId, + amountAtomic: credit.amountAtomic, + })), + ...(accounting.ancestorCredits ?? []).map((credit) => ({ + party: credit.recipientId, + amountAtomic: credit.amountAtomic, + })), + { party: 'treasury', amountAtomic: accounting.protocolFeeAtomic }, + ] : null; ledger.record({ - leg, label, amountUSDC, - txHash: unb64(res.headers.get('X-PAYMENT-RESPONSE')).transaction, - splits: parsed.receipt?.splits ?? null, // royalty breakdown rides back on the skill leg only + view: 'wielder-receipt', + idempotencyKey, + leg, + label, + amountAtomic, + txHash, + status: receipt?.receipt?.execution?.state ?? (res.ok ? 'succeeded' : 'failed'), + receipt, + splits, }); } @@ -144,8 +272,8 @@ export function createProxy({ return c.newResponse(resBody, res.status, headers); }; - app.post('/v1/*', forward(gatewayUrl, 'model')); - app.post('/invoke/*', forward(collarUrl, 'skill')); + app.post('/v1/*', forward(gatewayUrl, 'model', gatewayFetch)); + app.post('/invoke/*', forward(collarUrl, 'skill', collarFetch)); // The unified session ledger — what Pi's /ledger command renders. app.get('/ledger', (c) => @@ -166,6 +294,10 @@ export function startProxy({ port = 0, ...opts } = {}) { // Standalone: `npm run proxy` if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const { url, account } = await startProxy({ port: Number(process.env.PROXY_PORT || 8402) }); + const trust = loadPinnedCollarTrust(process.env); + const { url, account } = await startProxy({ + port: Number(process.env.PROXY_PORT || 8402), + ...trust, + }); console.log(`[proxy] Wielder wallet ${account.address} paying at ${url} (/v1/* -> gateway, /invoke/* -> collar, /ledger)`); } diff --git a/spikes/pi-wielder/tests/proxy-trust.test.mjs b/spikes/pi-wielder/tests/proxy-trust.test.mjs new file mode 100644 index 0000000..38ac0ba --- /dev/null +++ b/spikes/pi-wielder/tests/proxy-trust.test.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createCollar, SKILL_ID } from '../src/collar.mjs'; +import { createMockFacilitator } from '../src/facilitator-mock.mjs'; +import { canonicalJson, createReceiptSigner, verifySignedReceipt } from '../src/invocation-journal.mjs'; +import { assertReceiptMatchesPayment, createProxy, loadPinnedCollarTrust } from '../src/proxy.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; +import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; + +function signReceipt(signer, receipt) { + const receiptHash = crypto.createHash('sha256').update(canonicalJson(receipt)).digest('hex'); + return { + receipt, + receiptHash, + signature: signer.signHash(receiptHash), + algorithm: signer.algorithm, + keyId: signer.keyId, + }; +} + +const expected = Object.freeze({ + idempotencyKey: 'idem-current', + requestHash: `sha256:${'1'.repeat(64)}`, + quoteId: `sha256:${'2'.repeat(64)}`, + amountAtomic: '250000', + payer: `0x${'a'.repeat(40)}`, + settlementReference: `0x${'b'.repeat(64)}`, + txHash: `0x${'c'.repeat(64)}`, + httpStatus: 200, + skillId: 'skill-current', + resource: 'http://collar.test/invoke/skill-current', +}); + +function receiptFor(overrides = {}) { + return { + schemaVersion: 1, + revision: 1, + supersedesReceiptHash: null, + invocationId: 'inv-current', + idempotencyKey: expected.idempotencyKey, + mode: 'external', + skill: { id: expected.skillId, versionHash: `sha256:${'d'.repeat(64)}` }, + requestHash: expected.requestHash, + wielderId: expected.payer, + quote: { + requestHash: expected.requestHash, + quoteId: expected.quoteId, + amountAtomic: expected.amountAtomic, + currency: 'USDC', + network: 'base-sepolia', + resource: expected.resource, + }, + payment: { + state: 'settled', + payer: expected.payer, + settlementReference: expected.settlementReference, + txHash: expected.txHash, + refundAmountAtomic: null, + }, + execution: { state: 'succeeded', httpStatus: expected.httpStatus }, + accounting: { grossAtomic: expected.amountAtomic, allocationState: 'finalized' }, + ...overrides, + }; +} + +test('proxy startup accepts only an explicitly pinned public key and matching key ID', () => { + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'collar-public-'))); + const publicKeyFile = path.join(directory, 'collar-public.pem'); + const signer = createReceiptSigner(); + fs.writeFileSync(publicKeyFile, signer.publicKeyPem); + const trust = loadPinnedCollarTrust({ + COLLAR_PUBLIC_KEY_FILE: publicKeyFile, + COLLAR_KEY_ID: signer.keyId, + }); + assert.equal(trust.trustedCollarKeyId, signer.keyId); + assert.doesNotThrow(() => createProxy({ account: throwawayAccount(), ...trust })); + assert.throws(() => loadPinnedCollarTrust({ COLLAR_PUBLIC_KEY_FILE: publicKeyFile }), /require/); + assert.throws(() => loadPinnedCollarTrust({ + COLLAR_PUBLIC_KEY_FILE: publicKeyFile, + COLLAR_KEY_ID: `sha256:${'0'.repeat(64)}`, + }), /does not match/); + assert.throws(() => loadPinnedCollarTrust({ + COLLAR_PUBLIC_KEY_FILE: path.basename(publicKeyFile), + COLLAR_KEY_ID: signer.keyId, + }), /absolute/); + const symlink = path.join(directory, 'collar-link.pem'); + fs.symlinkSync(publicKeyFile, symlink); + assert.throws(() => loadPinnedCollarTrust({ + COLLAR_PUBLIC_KEY_FILE: symlink, + COLLAR_KEY_ID: signer.keyId, + }), /regular non-symlink/); + assert.throws(() => createProxy({ account: throwawayAccount() }), /pinned Collar/); + assert.throws(() => createProxy({ + account: throwawayAccount(), + trustedCollarPublicKeyPem: signer.publicKeyPem, + trustedCollarKeyId: `sha256:${'0'.repeat(64)}`, + }), /do not match/); +}); + +test('a valid signature is insufficient when receipt semantics do not match the paid request', () => { + const signer = createReceiptSigner(); + const valid = signReceipt(signer, receiptFor()); + assert.equal(verifySignedReceipt(valid, { + publicKeyPem: signer.publicKeyPem, + keyId: signer.keyId, + }), true); + assert.equal(assertReceiptMatchesPayment(valid, expected).invocationId, 'inv-current'); + + const stale = signReceipt(signer, receiptFor({ idempotencyKey: 'idem-previous' })); + assert.equal(verifySignedReceipt(stale, { + publicKeyPem: signer.publicKeyPem, + keyId: signer.keyId, + }), true); + assert.throws(() => assertReceiptMatchesPayment(stale, expected), /does not semantically match/); + + for (const mutation of [ + { payment: { ...receiptFor().payment, state: 'signed' } }, + { execution: { state: 'executing', httpStatus: 200 } }, + { execution: { state: 'succeeded', httpStatus: 500 } }, + { accounting: { grossAtomic: '249999', allocationState: 'finalized' } }, + { quote: { ...receiptFor().quote, resource: 'http://evil.test/invoke/skill-current' } }, + ]) { + const bundle = signReceipt(signer, receiptFor(mutation)); + assert.throws(() => assertReceiptMatchesPayment(bundle, expected), /does not semantically match/); + } +}); + +test('a settled Skill failure is cached without inventing finalized treasury or Royalty claims', async () => { + const facilitator = createMockFacilitator(); + const collar = createCollar({ + facilitatorTransport: createMockFacilitatorTransport( + (url, init) => facilitator.request(url, init), + ), + executeSkill: async () => { throw new Error('provider detail must stay inside seller logs'); }, + }); + const proxy = createProxy({ + account: throwawayAccount(), + collarUrl: 'http://collar.test', + collarFetch: (url, init) => collar.app.request(url, init), + gatewayFetch: async () => { throw new Error('model gateway must not run'); }, + trustedCollarPublicKeyPem: collar.journal.signingPublicKeyPem, + trustedCollarKeyId: collar.journal.signingKeyId, + }); + const response = await proxy.app.request(`http://proxy.test/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'settled failure' }), + }); + assert.equal(response.status, 500); + const body = await response.json(); + assert.equal(body.receipt.receipt.execution.state, 'failed'); + assert.equal(proxy.ledger.entries.length, 1); + const [entry] = proxy.ledger.entries; + assert.equal(entry.view, 'wielder-receipt'); + assert.equal(entry.status, 'failed'); + assert.equal(entry.amountAtomic, '250000'); + assert.equal(entry.receipt.receipt.accounting.allocationState, 'pending_cogs_reconciliation'); + assert.equal(entry.splits, null); + const rendered = await proxy.app.request('http://proxy.test/ledger'); + assert.equal(rendered.status, 200); + assert.match(await rendered.text(), /\[failed\]/); +}); From eed439f67d1c193186a166dfaee6668af244dabd Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:53:16 -0400 Subject: [PATCH 088/165] fix: sanitize unhandled Collar failures --- spikes/pi-wielder/src/collar.mjs | 4 ++++ spikes/pi-wielder/tests/collar-failure.test.mjs | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index 8de8b57..b2b2f35 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -249,6 +249,10 @@ export function createCollar({ }; const app = new Hono(); + app.onError(() => new Response(JSON.stringify({ error: 'internal Collar error' }), { + status: 500, + headers: { 'content-type': 'application/json; charset=UTF-8' }, + })); app.get('/healthz', (c) => c.json({ ok: true, skill: SKILL_ID, diff --git a/spikes/pi-wielder/tests/collar-failure.test.mjs b/spikes/pi-wielder/tests/collar-failure.test.mjs index 0cc9093..4a26f68 100644 --- a/spikes/pi-wielder/tests/collar-failure.test.mjs +++ b/spikes/pi-wielder/tests/collar-failure.test.mjs @@ -315,7 +315,9 @@ test('crash after the provider returns leaves one unresolved attempt and never c afterExecutorReturned: async () => { throw new Error('crash after provider return'); }, }, }); - assert.equal((await prepared.retry()).status, 500); + const crashed = await prepared.retry(); + assert.equal(crashed.status, 500); + assert.doesNotMatch(await crashed.text(), /crash after provider return/); assert.equal(executions, 1); assert.equal((await prepared.retry()).status, 503); assert.equal(executions, 1); From ffe17104b073a711363d58b9ff54a86235530791 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:53:42 -0400 Subject: [PATCH 089/165] docs: define Collar journal authority --- spikes/pi-wielder/.env.example | 25 +- spikes/pi-wielder/README.md | 347 ++++++++++----------- spikes/pi-wielder/RUNBOOK.md | 242 +++++++++----- spikes/pi-wielder/e2e.mjs | 10 +- spikes/pi-wielder/package.json | 4 +- spikes/pi-wielder/pi-extension/x402.ts | 10 +- spikes/pi-wielder/src/facilitator-mock.mjs | 2 +- spikes/pi-wielder/src/gateway.mjs | 4 +- spikes/pi-wielder/src/proxy.mjs | 4 +- spikes/pi-wielder/src/wallet.mjs | 2 +- 10 files changed, 368 insertions(+), 282 deletions(-) diff --git a/spikes/pi-wielder/.env.example b/spikes/pi-wielder/.env.example index 7231ede..6dd77db 100644 --- a/spikes/pi-wielder/.env.example +++ b/spikes/pi-wielder/.env.example @@ -4,7 +4,7 @@ # --- the Wielder wallet (testnet ONLY — never a mainnet key) ----------------- # 0x-prefixed private key of a throwaway Base Sepolia account. -# Fund it with testnet USDC + ETH via the Coinbase CDP faucet (see RUNBOOK.md). +# Fund only for a separately reviewed Base Sepolia integration; mock mode needs none. PRIVATE_KEY= # Where sellers receive USDC (collar + gateway payTo). Any address you control. @@ -14,12 +14,23 @@ PAY_TO_ADDRESS= ANTHROPIC_API_KEY= OPENAI_API_KEY= -# --- modes ------------------------------------------------------------------- -# 1 = in-process fake facilitator (offline). Unset/0 = real facilitator below. -MOCK_FACILITATOR=1 -# 1 = canned completions/skill output, no model keys needed. +# --- safe runtime modes ------------------------------------------------------ +# Offline in-process mock settlement is the default. Set 1 only for an +# explicitly approved Base Sepolia integration; arbitrary URLs are rejected. +ALLOW_LIVE_X402=0 +FACILITATOR_URL= +# 1 = canned completions/Skill output, no model keys needed. MOCK_LLM=1 -FACILITATOR_URL=https://x402.org/facilitator + +# --- Collar authority (paired, absolute, outside this checkout) ------------- +# Live settlement refuses ephemeral authority. Set both or neither. +COLLAR_JOURNAL_FILE= +COLLAR_SIGNING_KEY_FILE= + +# --- Wielder trust (public material only) ------------------------------------ +# The proxy refuses Skill routes unless this public key hashes to COLLAR_KEY_ID. +COLLAR_PUBLIC_KEY_FILE= +COLLAR_KEY_ID= # --- ports (defaults shown; 8402 is a nod to ClawRouter's paying proxy) ------ PROXY_PORT=8402 @@ -28,7 +39,7 @@ COLLAR_PORT=8404 # Proxy -> seller wiring (defaults match the ports above) GATEWAY_URL=http://127.0.0.1:8403 COLLAR_URL=http://127.0.0.1:8404 -# Optional JSONL session-ledger path for the proxy +# Optional JSONL Wielder receipt-view path for the proxy LEDGER_FILE= # --- pricing (USDC per call) -------------------------------------------------- diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index c9da754..d0d6eca 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -1,203 +1,182 @@ -# Pi-Wielder spike — one wallet, two asset classes - -Design: [section 4 of the 2026-07-11 reframe & Pi-Wielder design](../../docs/plans/2026-07-11-reframe-and-pi-wielder-design.md). - -## What this proves - -A coding harness (Pi) pays **per-call for model inference** and **per-invocation -for a hosted Skill** from the **same wallet**, and every payment lands in **one -attributed session ledger** — with a royalty split on the skill leg computed by -the same settlement engine prototyped in `prototype/settlement-engine.mjs`: - -``` -claude/plan $0.041 · gpt/implement $0.087 · skill/optimizing-claude-code-prompts $0.25 → creator $0.24375 / treasury $0.00625 +# Pi-Wielder spike — Collar-authoritative Invocations + +Design context: [Pi-Wielder design](../../docs/plans/2026-07-11-reframe-and-pi-wielder-design.md). + +This is an executable design spike, not a production payment service. Its automated +proof is fully offline: an unfunded throwaway wallet signs x402 authorizations, an +injected mock verifies the signatures and synthesizes settlement, and canned model +responses avoid external APIs. + +## Accounting authority + +The Collar's append-only Invocation journal is authoritative for hosted Skill +Invocations. Payment and execution are independent state machines, so a settled +Invocation remains attached to its transaction when execution fails or a response is +lost. The journal freezes the complete x402 offer, atomically claims execution and +refund attempts, records accounting, and issues an Ed25519-signed terminal receipt. + +The Wielder's `/ledger` endpoint is a session-local **receipt view**, not an +authoritative protocol ledger. For Skill legs it caches a receipt only after verifying +the signature against a separately pinned Collar public key and checking that the +receipt matches the current idempotency key, request hash, quote, payer, settlement, +transaction, terminal HTTP status, and gross amount. It renders finalized claims from +that receipt; it never calculates Royalty claims itself. Model legs have no Collar +receipt and remain local payment observations. + +Mock receipts use an ephemeral Collar key unless paired persistent paths are supplied. +Mock transaction hashes and timings are synthetic protocol evidence. They are not +evidence of live funds, mainnet readiness, production custody, distributed locking, or +durable production key management. + +## What the offline proof demonstrates + +`npm run e2e` exercises one wallet across two paid asset classes without opening a +socket: + +1. Model inference and a hosted Skill both return an x402 `exact` challenge before + execution. +2. The Wielder signs one EIP-3009 authorization per challenge and retries with the same + client idempotency key. +3. The Collar records one authoritative external Invocation and returns derived output + plus a signed receipt. The hosted `SKILL.md` bytes are read server-side and are not + directly returned. +4. An exact terminal retry returns the same receipt without another settlement or Skill + execution. Different request bytes under the same key return `409`. +5. A lost settlement response becomes `unresolved`; exact retries return `503` and do + not verify, settle, or execute again until a trusted resolver advances it. +6. The Wielder view contains canonical atomic-USDC strings. Finalized Skill claims are + projected from the signed receipt; a failed full-gross hold produces no invented + creator or treasury claim. + +The displayed successful mock session currently looks like: + +```text +claude/plan $0.041 [succeeded] · gpt/implement $0.087 [succeeded] · skill/optimizing-claude-code-prompts $0.25 [succeeded] → creator $0.24375 / treasury $0.00625 + session receipt total $0.378 across 3 settled calls, one wallet ``` -Three claims, each demonstrated end-to-end by `npm run e2e` (offline, zero -keys, zero funds): - -1. **The Wielder is a wallet, not a harness (ADR-0008).** The *entire* - Wielder-side protocol footprint is one file, `src/proxy.mjs`: answer - HTTP 402 with a signed USDC `transferWithAuthorization` (EIP-3009) and - retry. No Story SDK, no token custody, no chain reads. Pi itself contains - zero payment code — its extension just points at a localhost baseUrl - (precedent: BlockRun's ClawRouter does exactly this for OpenClaw on 8402). -2. **The unified meter is the differentiator.** Inference payments are - commoditizing (Router402, tx402.ai, ClawRouter). What they don't have is - the second asset class in the same ledger: skill invocations with royalty - attribution. Here both legs are entries in one JSONL session ledger. -3. **The collar keeps the platform key.** The Wielder pays for an - *invocation* and receives *output only*; the skill content - (`.claude/skills/optimizing-claude-code-prompts/SKILL.md`) never leaves the - collar process. The settled txHash is a single-use execution credential — - replays are rejected ("no credential, no run", ADR-0003). +This is a payer-side view across independent sellers. It is deliberately not described +as a unified authoritative ledger. + +## Failure and refund semantics + +- Settlement success is never erased by a later `400`, `404`, or `500` execution + outcome. Exact terminal replay preserves that HTTP status. +- If the Skill executor fails after settlement, the receipt records one full-gross + `pending_cogs_reconciliation` hold. No Royalty or treasury claim is finalized. +- A response lost after the provider returns leaves the durable execution attempt + `executing`; retries return `503` rather than call the provider twice. +- Settlement reconciliation accepts no caller-supplied transaction proof. An injected + trusted resolver must return the exact settlement reference, payer, gross atomic + amount, and transaction hash. +- Refund execution is also atomically claimed. An ambiguous or crashed external refund + remains unresolved and is never executed a second time. A separate trusted refund + resolver may confirm it. +- Refund v1 is intentionally narrow: only a settled terminal failure with one exact + full-gross hold and no finalized claims is refundable. Confirmation must match payer, + settlement reference, original transaction, and full gross. The signed revision + supersedes the original receipt and carries balanced hold-reversal and refund entries. +- Provider and resolver exception details are replaced with stable public errors; API + response bodies and secret-bearing exception strings are not copied into receipts. ## Architecture -``` - Pi (the harness — knows NOTHING about payments) - │ .pi/extensions/x402.ts: - │ provider "x402" → baseUrl localhost:8402/v1 - │ tool invoke_skill → localhost:8402/invoke/… - │ command /ledger → localhost:8402/ledger - ▼ - ┌──────────────────────────────────────────────────────────┐ - │ src/proxy.mjs — THE WIELDER (= wallet + paying fetch) │ - │ · viem account from PRIVATE_KEY (src/wallet.mjs) │ - │ · on 402: sign EIP-3009 USDC auth → X-PAYMENT → retry │ - │ · appends every paid call to the SESSION LEDGER (JSONL) │ - │ {ts, leg, label, amountUSDC, txHash, splits} │ - └────────────┬─────────────────────────────┬───────────────┘ - /v1/* (leg: model) /invoke/* (leg: skill) - ▼ ▼ - ┌─────────────────────────┐ ┌──────────────────────────────────┐ - │ src/gateway.mjs │ │ src/collar.mjs — MOCK COLLAR │ - │ x402 inference reseller │ │ 402-gate → verify → settle → │ - │ (simulated, testnet) │ │ txHash = single-use credential → │ - │ 402-gate → claude-* to │ │ run SKILL.md via Anthropic API │ - │ Anthropic, gpt-* to │ │ (output ONLY) → meter split via │ - │ OpenAI (or MOCK_LLM) │ │ prototype/settlement-engine.mjs │ - └────────────┬────────────┘ └────────────┬─────────────────────┘ - │ verify/settle │ - └───────────┬─────────────────┘ - ▼ - x402 facilitator (Base Sepolia) - · real: https://x402.org/facilitator - · MOCK_FACILITATOR=1: src/facilitator-mock.mjs — in-process; - really verifies the EIP-712 signature, fakes only settlement - (deterministic txHash preserves the replay property) +```text +Pi or another HTTP client + │ + ▼ +src/proxy.mjs — Wielder wallet + paying fetch + local receipt view + │ + ├── /v1/* ─────► src/gateway.mjs (model seller) + │ + └── /invoke/* ─► src/collar.mjs (Skill seller) + │ + ├── authoritative signed JSONL journal + ├── server-side Skill artifact + └── signed terminal receipt + +Both sellers use an explicitly constructed facilitator transport. +Offline tests inject src/facilitator-mock.mjs; no arbitrary URL is accepted. ``` -One wallet (top), two asset classes (the two sellers), three payees in the -demo scenario (inference reseller, skill creator, protocol treasury), one -ledger (in the proxy — where the wallet is, because the meter belongs to the -payer's session). +The proxy contains wallet signing and HTTP 402 retry behavior but no Story SDK, token +custody, or Royalty calculator. That is the constructive ADR-0008 claim: the Wielder is +a wallet boundary, not the coding harness. -## How it maps to ADR-0008 - -ADR-0008 rejects the token-holding client and the full-protocol client. This -spike is the constructive proof: grep `src/proxy.mjs` — the only protocol -concepts in it are *HTTP 402*, *EIP-3009 signature*, and *retry*. Everything -skill-economic (credentials, royalty tables, `distribute()`, provenance) -lives seller-side behind the collar. If a cron job or `curl` replaced Pi -tomorrow, nothing in the protocol would notice: **any client that can pay is -a Wielder**. - -## Run it +## Run the verified path ```bash npm install -npm run e2e # offline proof: MOCK_FACILITATOR=1 MOCK_LLM=1, no keys, no funds +npm test +npm run e2e ``` -The e2e boots facilitator-mock + collar + gateway + proxy on ephemeral ports, -drives all three legs through the proxy only, and asserts: 402-first on every -leg, no skill-content leak, replay rejection, exact split match against the -settlement engine, and prints the rendered ledger plus (mock) payment-overhead -timings. +Expected current results are 57 offline unit/integration tests and 24 offline e2e +checks. Counts can increase as regressions are added; zero failures is the contract. +The e2e labels all timing output synthetic and uses in-process Hono requests only. + +Focused commands: + +```bash +npm run test:journal +npm run test:collar +npm run test:proxy +``` -For the real-facilitator testnet run and the live Pi demo, see -[RUNBOOK.md](./RUNBOOK.md). +For standalone mock processes, persistent trust bootstrapping, and the intentionally +blocked live boundary, see [RUNBOOK.md](./RUNBOOK.md). ## Files | File | Role | |---|---| -| `src/proxy.mjs` | The Wielder: paying proxy; the whole client-side protocol footprint | -| `src/wallet.mjs` | viem account from `PRIVATE_KEY`; throwaway key for mock mode | -| `src/gateway.mjs` | Simulated x402 inference reseller (OpenAI-compatible, 402-gated) | -| `src/collar.mjs` | Mock collar: hosts + gates the skill, meters splits via the settlement engine | -| `src/x402-seller.mjs` | Seller half of x402 v1 (`exact` scheme), hand-written Hono middleware | -| `src/facilitator-mock.mjs` | Offline facilitator: real signature verification, fake settlement | -| `src/ledger.mjs` | JSONL session ledger + `renderLedger()` | -| `pi-extension/x402.ts` | Pi extension: provider `x402`, tool `invoke_skill`, command `/ledger` | -| `e2e.mjs` | The offline proof (`npm run e2e`) | - -## Deviations from the design's research notes - -- **`@x402/*` packages not used.** The published `@x402/fetch` / `@x402/evm` / - `@x402/hono` (v2.18.0) implement protocol **v2** — class-based scheme - registries, CAIP-2 network ids, facilitator sync-on-start — while the free - testnet facilitator speaks **v1**, and the sync-on-start network coupling - breaks the zero-network mock mode. The design blesses the manual path - ("shows the protocol plainly"); the v1 `exact` scheme is implemented by hand - in `src/proxy.mjs` (buyer, ~40 protocol lines) and `src/x402-seller.mjs` - (seller). Only `viem` is used for cryptography. -- **`distribute()` is not exported** by `prototype/settlement-engine.mjs` (it - is an internal). The collar and the e2e drive it through the engine's public - economic event `invoke()` and use the returned `breakdown` — same math, - public API, and the e2e still asserts an *exact* match. -- **Engine amounts are atomic USDC** (6-decimal integers): the prototype - rounds to 2 decimals, which is lossy for $0.25 micro-royalties; integers - keep the split exact (250000 → creator 243750 / treasury 6250). - -## Measured results — first real-network run (Base Sepolia, 2026-07-12) - -Executed per RUNBOOK §1–2 with a Circle-faucet-funded Wielder wallet -(`0xdddf…053F`), the free `x402.org/facilitator`, and a real Anthropic key -(no OPENAI key was present, so the gpt leg was skipped — two paid legs, not -three). Everything below is on-chain-verifiable. - -**Session ledger (real settlements):** - -| leg | label | paid | txHash | split | -|---|---|---|---|---| -| model | claude/plan | $0.041 | `0x01daa723…38ff49` | — | -| skill | optimizing-claude-code-prompts | $0.25 | `0xaf1ba2fe…7af522` | creator $0.24375 / treasury $0.00625 | -| model | claude/plan2 (overhead capture) | $0.041 | — | — | - -On-chain balance check after the session: Wielder 20 → **19.668** USDC; -sellers' address received exactly **0.332** — every cent accounted for. - -**Measured x402 payment overhead (real facilitator, n=1 instrumented call):** -402-roundtrip **3.9 ms** · EIP-3009 sign **1.1 ms** · facilitator -verify+settle **776 ms** · **total ≈ 781 ms** per paid call. The facilitator -leg dominates; mainnet Base with Flashblocks claims ~200 ms, so testnet -numbers are likely an upper bound. End-to-end including inference: 6.7 s -(plan leg), 14.5 s (skill invocation — includes the hosted skill's own -model run). - -**What this run proved beyond the offline e2e:** real 402 → sign → settle -against a live facilitator; real USDC moving on a public chain per call; -skill executed behind the Collar with output-only response; splits credited -per the settlement engine — the protocol's Phase-1 Leg-1 loop, end to end, -for $0.33 of play money. - -## Historical overhead summary — quarantined (2026-07-15) - -The 2026-07-15 run was previously summarized as 48 settled calls across two -providers. Its per-call normalized samples and evidence hashes were not retained, -so a clean checkout cannot recompute the reported distribution. The historical -aggregate is preserved in -`evidence/2026-07-15-overhead/manifest.json` with -`evidenceStatus: historical_unreproducible`. - -**Publication status:** do not cite the historical sample count, p50, or p95 in -public copy. A future authorized testnet run must use a new dated evidence -directory and must never overwrite the tombstone. No rerun is performed by this -documentation change. - -**The gpt leg ran for the first time** (skipped 2026-07-12 for lack of a -key) — after fixing a real gateway bug the bench surfaced: newer OpenAI -models reject `max_tokens` (400 `unsupported_parameter`), so the gateway now -translates it to `max_completion_tokens`. The first 10 gpt attempts -**settled and then failed upstream** — $0.87 paid for ten 500s. Two -protocol observations worth keeping: - -1. **Pay-then-fail is the buyer's risk under pay-first-then-run.** A seller - bug after settlement costs the Wielder real money with no refund path in - x402 v1. (Design note for the Collar: attempt-then-settle ordering, or a - retry-credit convention.) -2. **Settled-but-rejected happens.** 1 of 50 calls settled on-chain but the - facilitator's response to the seller failed, so the seller returned 402 - anyway — buyer charged, no output (confirmed by exact balance - reconciliation). A second 402-after-signing did *not* settle. Testnet - facilitator flake rate over this run: ~4% of calls errored mid-payment. - -**Live pi session (same day):** unmodified pi v0.80.6 with the extension -paid 7 streaming calls ($0.287) through the proxy in a real agentic session -— one human prompt produced 7 paid model turns, a live datapoint that flat -per-call pricing amplifies agentic chattiness (relevant to the PRD's -pricing-model spike). (The session ledger renders 8 entries / $0.328 -because it also caught the pre-demo smoke call; the wallet reconciliation -attributes $0.287 to pi.) +| `src/invocation-journal.mjs` | Authoritative transition reducer, persistent signed JSONL, indexes, receipts, reconciliation, and refund claims | +| `src/collar.mjs` | Hosted Skill boundary, execution outcomes, receipts, settlement/refund operator routes | +| `src/x402-seller.mjs` | Seller x402 v1 `exact` middleware and approved transport constructors | +| `src/proxy.mjs` | Wielder wallet, paying fetch, pinned receipt verification, and local receipt view | +| `src/ledger.mjs` | JSONL-capable Wielder receipt-view storage and rendering | +| `src/gateway.mjs` | Simulated x402 model reseller | +| `src/facilitator-mock.mjs` | Offline signature verification plus synthetic settlement | +| `pi-extension/x402.ts` | Manual Pi adapter for provider, Skill tool, and `/ledger` view | +| `e2e.mjs` | Fully in-process offline proof | + +## Security and operational boundaries + +- Base Sepolia only; no mainnet and no real funds in automated verification. +- Live facilitator construction accepts only the byte-exact approved HTTPS base and + disables redirects for `/verify` and `/settle`. +- Live settlement requires paired absolute journal/private-key paths outside the + checkout plus injected trusted settlement, refund-execution, and refund-resolution + adapters. The standalone CLI intentionally provides no such live adapters and refuses + to start live. +- Persistent files are regular non-symlink files with mode `0600`. Same-host writers use + a signed hash chain, fsync, a process lease, and compare-and-swap transitions. This is + not a distributed consensus mechanism. +- The proxy trusts an operator-pinned public key file and one SHA-256 key ID of its SPKI + DER. A key ID or key embedded in a receipt cannot authenticate that receipt. +- The Pi extension is a manual demo adapter and is not compiled by this spike's test + suite. +- Successful mock accounting currently passes zero execution and settlement COGS into + the atomic allocator. That is explicit spike behavior, not a validated production + margin model. + +## Protocol implementation note + +The published `@x402/*` packages evaluated for this spike implement a different +protocol/version shape than the free testnet facilitator used by the original research. +This spike therefore keeps the small x402 v1 buyer and seller boundaries explicit and +uses `viem` for EIP-712 signing and verification. + +## Historical network evidence — not current verification + +An earlier pre-journal version was exercised on Base Sepolia on 2026-07-12. That run +observed two paid legs (Claude and one Skill), 0.332 testnet USDC received in total, and +one instrumented payment-overhead sample of roughly 781 ms. The current +Collar-authoritative implementation was **not** rerun against live infrastructure in +this remediation, so those figures are historical context rather than evidence for the +current code. + +A separate 2026-07-15 overhead summary is quarantined at +[`evidence/2026-07-15-overhead/manifest.json`](./evidence/2026-07-15-overhead/manifest.json) +with `evidenceStatus: historical_unreproducible`. Its sample count and percentiles must +not be used in public claims until a new authorized run retains per-call evidence. diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md index 44cd6d1..9b596d8 100644 --- a/spikes/pi-wielder/RUNBOOK.md +++ b/spikes/pi-wielder/RUNBOOK.md @@ -1,105 +1,191 @@ -# RUNBOOK — testnet run & live Pi demo +# RUNBOOK — offline verification and Collar trust -Everything in [README.md](./README.md) up to `npm run e2e` is fully offline. -This runbook covers the two things that are **manual by design**: funding a -testnet wallet, and installing Pi with the extension for the live demo. +The supported automated workflow is offline and in-process. No funded wallet, API key, +listener, or live facilitator is needed. -## 1. Create and fund the Wielder wallet (Base Sepolia — manual) +## 1. Run the verified workflow -1. Generate a throwaway key (never reuse a real one): +From `spikes/pi-wielder`: + +```bash +npm install +npm test +npm run e2e +``` + +The unit/integration suite uses injected Hono apps. The e2e uses one unfunded throwaway +wallet, canned model output, an ephemeral receipt signer, and synthetic settlement. +Timing output is explicitly synthetic. + +## 2. Run standalone processes in mock mode + +The multi-process demo needs a stable Collar signing key because the proxy refuses Skill +routes without a separately pinned public key and expected key ID. + +1. Copy `.env.example` to the already ignored `.env`. +2. Create a private directory **outside this checkout**. Put absolute paths in `.env`: + + ```dotenv + ALLOW_LIVE_X402=0 + MOCK_LLM=1 + COLLAR_JOURNAL_FILE=/absolute/outside-checkout/pi-wielder/events.jsonl + COLLAR_SIGNING_KEY_FILE=/absolute/outside-checkout/pi-wielder/receipt-private.pem + COLLAR_PUBLIC_KEY_FILE=/absolute/outside-checkout/pi-wielder/receipt-public.pem + COLLAR_KEY_ID= + ``` + + The directory must already exist. Never put the private key or journal in this repo. + The Collar creates new journal/key files with mode `0600`, rejects symlinks, and + refuses a path inside the checkout. Both private paths must be set together. + +3. Load the environment and start the Collar. With `ALLOW_LIVE_X402=0`, it constructs an + in-process mock transport; `FACILITATOR_URL` is ignored. ```bash - node -e "import('viem/accounts').then(m => { const pk = m.generatePrivateKey(); console.log('PRIVATE_KEY=' + pk); console.log('address:', m.privateKeyToAccount(pk).address); })" + set -a + source .env + set +a + npm run collar ``` -2. `cp .env.example .env`, paste the `PRIVATE_KEY`, and set `PAY_TO_ADDRESS` - to a second address you control (that's where the sellers receive USDC — - generate one the same way if needed). **Never commit `.env`.** +4. In another terminal, bootstrap the local demo's public trust file from the loopback + health endpoint. This command refuses to overwrite an existing public key file and + prints the key ID: -3. Fund the Wielder address from the **Coinbase CDP faucet** - (, free, requires a CDP - account): - - network **Base Sepolia**, asset **USDC** → request (typically 10 USDC/day); - - network **Base Sepolia**, asset **ETH** → request a small amount. - (EIP-3009 settlement is facilitator-sponsored, so the buyer mostly needs - USDC; the ETH covers you if you later broadcast anything yourself.) + ```bash + set -a + source .env + set +a + node --input-type=module -e 'import fs from "node:fs"; const h=await (await fetch("http://127.0.0.1:8404/healthz")).json(); fs.writeFileSync(process.env.COLLAR_PUBLIC_KEY_FILE,h.signingPublicKeyPem,{flag:"wx",mode:0o644}); console.log(h.signingKeyId)' + ``` -4. Sanity-check the balance on (search the - address; USDC contract `0x036CbD53842c5426634e7929541eC2318f3dCF7e`). + Copy the printed `sha256:...` value into `COLLAR_KEY_ID` in `.env`. For anything + beyond a loopback mock demo, provision the public key and its one-hash SPKI-DER ID by + an authenticated out-of-band channel; do not bootstrap trust from an untrusted server + response. -## 2. Testnet run (real facilitator, real model APIs) +5. Start the mock gateway and pinned proxy in separate terminals, loading `.env` in + each: -In `.env`: unset the mocks and add model keys — + ```bash + npm run gateway + npm run proxy + ``` -```bash -MOCK_FACILITATOR=0 -MOCK_LLM=0 -FACILITATOR_URL=https://x402.org/facilitator # free, no-auth, Base Sepolia -ANTHROPIC_API_KEY=sk-ant-… -OPENAI_API_KEY=sk-… -``` +6. Exercise both routes through the proxy: + + ```bash + curl -s http://127.0.0.1:8402/v1/chat/completions \ + -H 'content-type: application/json' \ + -H 'x-session-label: plan' \ + -d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"Plan a refactor."}]}' -Three terminals (or background them), sellers first: + curl -s http://127.0.0.1:8402/invoke/optimizing-claude-code-prompts \ + -H 'content-type: application/json' \ + -d '{"input":"make the checkout page faster"}' -```bash -set -a; source .env; set +a # in each terminal -npm run collar # :8404 — hosted skill behind the collar -npm run gateway # :8403 — 402-gated inference reseller -npm run proxy # :8402 — THE WIELDER (paying proxy) + curl -s http://127.0.0.1:8402/ledger + ``` + +`/ledger` is a payer-side receipt view. It is not the Collar journal and is not a +cross-seller accounting authority. + +## 3. Persistent authority contract + +`COLLAR_JOURNAL_FILE` and `COLLAR_SIGNING_KEY_FILE` are one authority pair: + +- both are explicit absolute paths outside the checkout; +- both are regular non-symlink files with exact mode `0600` once created; +- the journal is append-only JSONL, fsynced, hash-chained, and Ed25519-signed per event; +- same-host processes serialize writes with a private lease file and record-level + compare-and-swap checks; +- stale lease removal is an explicit exact-lease-ID API operation, not an automatic + timeout deletion; +- the private key never enters the proxy. The proxy receives only + `COLLAR_PUBLIC_KEY_FILE` plus `COLLAR_KEY_ID` and independently recomputes the ID. + +Changing the private signing key without starting a new journal is rejected. Rotating +the Collar key also requires updating the proxy's pinned public key and ID through a +trusted operator process. + +## 4. Trusted reconciliation and refunds + +The HTTP endpoints do not accept caller-supplied settlement or refund proofs: + +- `GET /receipts/by-settlement/:reference` is read-only. +- `POST /reconcile/by-settlement/:reference` calls the injected + `resolveSettlement` adapter and requires exact reference, payer, gross amount, and + transaction evidence. +- `POST /refund/by-settlement/:reference` durably claims one refund attempt before it + calls the injected `executeRefund` adapter. +- `POST /reconcile/refund/by-settlement/:reference` calls the separate injected + `resolveRefund` adapter after an ambiguous/crashed refund outcome. + +An integration must construct the Collar with trusted code, not with proof fields from +an HTTP request: + +```js +createCollar({ + facilitatorTransport, + journalFile, + signingKeyFile, + resolveSettlement, + executeRefund, + resolveRefund, +}); ``` -Exercise all three legs through the proxy only: +Every adapter result is checked against journal-bound payer, reference, transaction, +and canonical atomic amount. Exceptions are returned as stable public errors. A refund +resolver may confirm the already claimed attempt; it must not initiate a second refund. -```bash -curl -s localhost:8402/v1/chat/completions -H 'content-type: application/json' \ - -H 'x-session-label: plan' \ - -d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"Plan a refactor of the settlement engine tests."}]}' +There is currently no operator endpoint that resolves a Skill attempt left `executing` +after the provider returned but before the terminal journal append. Such an Invocation +remains explicit `503 execution outcome unresolved` until a future trusted execution +reconciliation design exists. Do not manually rewrite the journal. -curl -s localhost:8402/v1/chat/completions -H 'content-type: application/json' \ - -H 'x-session-label: implement' \ - -d '{"model":"gpt-5.2","messages":[{"role":"user","content":"Implement the plan."}]}' - # use any chat model your OpenAI key can access +## 5. Live Base Sepolia boundary — intentionally blocked in the CLI -curl -s localhost:8402/invoke/optimizing-claude-code-prompts \ - -H 'content-type: application/json' -d '{"input":"make the checkout page faster"}' +Live mode is opt-in only: -curl -s localhost:8402/ledger # the unified session ledger +```dotenv +ALLOW_LIVE_X402=1 +FACILITATOR_URL=https://x402.org/facilitator ``` -Each response carries `x-wielder-overhead` (402 roundtrip + sign + -facilitator verify/settle, in ms) — these are the **real** payment-overhead -numbers to feed back into the PRD's demand-side section, and every settled -txHash is checkable on . +The URL must match the single approved HTTPS base byte-for-byte. Redirects are disabled, +and only `/verify` and `/settle` are constructed. Mainnet is unsupported. -Replay check on testnet: re-sending a captured `X-PAYMENT` header fails at -the facilitator (the EIP-3009 nonce is already used on-chain) or, if it were -somehow re-settled, at the collar's consumed-set (HTTP 409). +The standalone `npm run collar` command intentionally does **not** load settlement or +refund adapters from environment variables. Therefore it refuses live startup even when +the URL and persistent files are present. A future authorized Base Sepolia run must add +reviewed, injected implementations for all three trusted adapters above, preserve the +persistent authority pair, pin the proxy trust out of band, and use a human-funded +testnet-only wallet. No such live run was performed during this remediation. -## 3. Live Pi demo (manual — pi is not installed by this spike) +This fail-closed boundary is expected behavior, not a setup bug. -1. Install Pi (v0.80.x): `npm install -g @earendil-works/pi-coding-agent` -2. Install the extension into the project you'll demo from: +## 6. Manual Pi adapter - ```bash - mkdir -p .pi/extensions - cp /spikes/pi-wielder/pi-extension/x402.ts .pi/extensions/ - ``` +The Pi extension is optional and not compiled in CI: + +```bash +mkdir -p .pi/extensions +cp /spikes/pi-wielder/pi-extension/x402.ts .pi/extensions/ +``` + +With the three standalone mock processes running, start the compatible Pi version and +reload extensions. The extension points model calls and `invoke_skill` at the local +proxy and renders the signed receipt bundle shape. `/ledger` shows the proxy's local +view. Verify the extension API against the installed Pi version before a demo. + +## 7. Manual-only boundaries + +- Provisioning and protecting persistent key material. +- Supplying reviewed trusted settlement/refund adapters. +- Funding any future Base Sepolia wallet; never automate faucets here. +- Installing and smoke-testing Pi. +- Authorizing and retaining evidence for any future live measurement. - (or `~/.pi/agent/extensions/` for a global install). If the proxy is not - on the default port, export `PI_WIELDER_PROXY=http://localhost:`. -3. With collar + gateway + proxy running (section 2), start `pi` in that - project and `/reload` to pick up the extension. Verify against pi's docs - that the `registerProvider` config fields (`api`, model entries) match - your installed version — the extension is written against the documented - v0.80.x API but is exercised manually, not in CI. -4. Demo script ("Claude plans, GPT implements, one skill invocation"): - - select the `x402` provider's claude model → ask for a plan; - - switch to the gpt model → ask it to implement; - - have Pi call the `invoke_skill` tool (e.g. "optimize this prompt: …"); - - run `/ledger` → one wallet, three payees, unified attributed ledger. - -## What stays manual, on purpose - -- CDP faucet funding (no faucet automation — ToS and flakiness). -- Pi installation and the extension smoke-test (pi may not exist in CI). -- Feeding the measured testnet overhead numbers back into the PRD. +Secure card, billing, private-key, and wallet-funding details do not belong in chat, +tracked files, receipts, or logs. diff --git a/spikes/pi-wielder/e2e.mjs b/spikes/pi-wielder/e2e.mjs index 50e89fe..da33f98 100644 --- a/spikes/pi-wielder/e2e.mjs +++ b/spikes/pi-wielder/e2e.mjs @@ -32,7 +32,12 @@ const facilitator = createMockFacilitator(); const facilitatorTransport = createMockFacilitatorTransport( (url, init) => facilitator.request(url, init), ); -const collar = createCollar({ facilitatorTransport, mockLlm: true }); +const collar = createCollar({ + facilitatorTransport, + mockLlm: true, + journalFile: null, + signingKeyFile: null, +}); const gateway = createGateway({ facilitatorTransport, mockLlm: true }); const proxy = createProxy({ account, @@ -40,6 +45,7 @@ const proxy = createProxy({ collarUrl: 'http://collar.test', gatewayFetch: (url, init) => gateway.request(url, init), collarFetch: (url, init) => collar.app.request(url, init), + ledgerFile: null, trustedCollarPublicKeyPem: collar.journal.signingPublicKeyPem, trustedCollarKeyId: collar.journal.signingKeyId, }); @@ -186,6 +192,8 @@ const lossyTransport = createMockFacilitatorTransport(async (url, init) => { const unresolvedCollar = createCollar({ facilitatorTransport: lossyTransport, mockLlm: true, + journalFile: null, + signingKeyFile: null, }); const unresolvedKey = 'e2e-unresolved-payment'; const unresolved = await payingFetch(account, `http://unresolved.test/invoke/${SKILL_ID}`, { diff --git a/spikes/pi-wielder/package.json b/spikes/pi-wielder/package.json index 2b1572a..24aa2e9 100644 --- a/spikes/pi-wielder/package.json +++ b/spikes/pi-wielder/package.json @@ -7,7 +7,9 @@ "scripts": { "test": "node --test tests/*.test.mjs", "test:journal": "node --test tests/invocation-journal.test.mjs", - "e2e": "MOCK_FACILITATOR=1 MOCK_LLM=1 node e2e.mjs", + "test:collar": "node --test tests/collar-failure.test.mjs tests/x402-lifecycle.test.mjs", + "test:proxy": "node --test tests/proxy-trust.test.mjs", + "e2e": "MOCK_LLM=1 node e2e.mjs", "collar": "node src/collar.mjs", "gateway": "node src/gateway.mjs", "proxy": "node src/proxy.mjs" diff --git a/spikes/pi-wielder/pi-extension/x402.ts b/spikes/pi-wielder/pi-extension/x402.ts index 6b1c669..c96c815 100644 --- a/spikes/pi-wielder/pi-extension/x402.ts +++ b/spikes/pi-wielder/pi-extension/x402.ts @@ -68,7 +68,7 @@ export default function activate(pi: Pi) { reasoning: false, input: ["text"], // pi tracks per-token cost; ours is flat per-call and lands on the - // /ledger — zeros here so pi's meter doesn't double-count. + // /ledger receipt view — zeros here so pi's meter doesn't double-count. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 8_192, @@ -79,7 +79,7 @@ export default function activate(pi: Pi) { reasoning: false, input: ["text"], // pi tracks per-token cost; ours is flat per-call and lands on the - // /ledger — zeros here so pi's meter doesn't double-count. + // /ledger receipt view — zeros here so pi's meter doesn't double-count. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 8_192, @@ -93,8 +93,8 @@ export default function activate(pi: Pi) { description: "Invoke the hosted, x402-paid skill 'optimizing-claude-code-prompts'. " + "Send a rough prompt/request as `input`; returns the optimized prompt. " + - "Costs testnet USDC per call; the payment, royalty split, and ledger " + - "entry are handled by the local paying proxy.", + "Costs testnet USDC per call; payment and the pinned Collar receipt view " + + "are handled by the local paying proxy.", parameters: { type: "object", properties: { @@ -132,7 +132,7 @@ export default function activate(pi: Pi) { }, }); - // --- /ledger: the unified session meter, rendered by the proxy ----------- + // --- /ledger: the session-local receipt view rendered by the proxy ------- pi.registerCommand("ledger", { description: "Show this session's local x402 receipt view (inference + Skills)", async handler() { diff --git a/spikes/pi-wielder/src/facilitator-mock.mjs b/spikes/pi-wielder/src/facilitator-mock.mjs index 82d5e1f..9b993c0 100644 --- a/spikes/pi-wielder/src/facilitator-mock.mjs +++ b/spikes/pi-wielder/src/facilitator-mock.mjs @@ -1,5 +1,5 @@ // facilitator-mock.mjs — an in-process stand-in for https://x402.org/facilitator -// (MOCK_FACILITATOR=1). Zero network, zero keys, zero funds. +// through createMockFacilitatorTransport(). Zero network, zero keys, zero funds. // // It is deliberately NOT a rubber stamp: // /verify really recovers the EIP-712 signer of the EIP-3009 diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index 5285f57..807ea77 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -8,8 +8,8 @@ // completions under MOCK_LLM=1. // // Economically this leg is plain pass-through: no royalty table, no split. -// The interesting part is that it lands in the SAME session ledger as the -// skill leg — that contrast is the spike's whole point. +// The Wielder displays this observation beside its pinned Collar receipt view; +// that payer-side display is useful, but it is not a shared accounting authority. import { pathToFileURL } from 'node:url'; import { Hono } from 'hono'; diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index f4e0895..50ebd7b 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -192,7 +192,7 @@ export function createProxy({ const app = new Hono(); // One handler for both asset classes: /v1/* -> inference gateway (leg: - // "model"), /invoke/* -> collar (leg: "skill"). Same wallet, same ledger. + // "model"), /invoke/* -> collar (leg: "skill"). Same wallet, one local view. const forward = (upstreamBase, leg, fetchImpl) => async (c) => { const path = c.req.path; const bodyText = await c.req.text(); @@ -275,7 +275,7 @@ export function createProxy({ app.post('/v1/*', forward(gatewayUrl, 'model', gatewayFetch)); app.post('/invoke/*', forward(collarUrl, 'skill', collarFetch)); - // The unified session ledger — what Pi's /ledger command renders. + // The payer's session-local receipt view — what Pi's /ledger command renders. app.get('/ledger', (c) => c.req.query('format') === 'json' ? c.json(ledger.entries) : c.text(renderLedger(ledger.entries))); diff --git a/spikes/pi-wielder/src/wallet.mjs b/spikes/pi-wielder/src/wallet.mjs index f51e490..d1b6840 100644 --- a/spikes/pi-wielder/src/wallet.mjs +++ b/spikes/pi-wielder/src/wallet.mjs @@ -18,7 +18,7 @@ export function accountFromEnv(env = process.env) { return privateKeyToAccount(pk.startsWith('0x') ? pk : `0x${pk}`); } -/** A fresh, unfunded, in-memory account. Perfectly fine for MOCK_FACILITATOR=1. */ +/** A fresh, unfunded, in-memory account for explicitly injected offline settlement. */ export function throwawayAccount() { return privateKeyToAccount(generatePrivateKey()); } From a83294b5c13c9983eda800aa226988475dc57d2e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 03:55:37 -0400 Subject: [PATCH 090/165] feat: allocate internal failure COGS in shared kernel --- prototype/atomic-money.mjs | 69 +++++++++++++++++++++++++++ prototype/tests/atomic-money.test.mjs | 39 +++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/prototype/atomic-money.mjs b/prototype/atomic-money.mjs index bd2ff51..2e2788e 100644 --- a/prototype/atomic-money.mjs +++ b/prototype/atomic-money.mjs @@ -352,6 +352,7 @@ export function allocateInternalGross({ protocolFeeAtomic, refundReserveAtomic, recipientId, + employerId, }) { const gross = assertAtomic(grossAtomic, 'grossAtomic'); const executionCost = assertAtomic(executionCostAtomic, 'executionCostAtomic'); @@ -363,6 +364,12 @@ export function allocateInternalGross({ ); const recipient = String(recipientId ?? ''); if (!recipient) fail('RECIPIENT_REQUIRED', 'recipientId must be non-empty'); + if (employerId != null && recipient === String(employerId)) { + fail( + 'EMPLOYER_AWARD_RECIPIENT', + 'employer cannot receive an employee Invocation award', + ); + } const debitAccountId = 'employer:invocation-gross'; return { grossAtomic: gross, @@ -384,3 +391,65 @@ export function allocateInternalGross({ ], }; } + +export function allocateInternalFailureGross({ executionCostAtomic }) { + const executionCost = assertAtomic(executionCostAtomic, 'executionCostAtomic'); + const debitAccountId = 'employer:invocation-gross'; + return { + grossAtomic: executionCost, + executionCostAtomic: executionCost, + journalEntries: [ + journalEntry('execution-cogs', debitAccountId, 'provider:execution', executionCost), + ], + }; +} + +function sameInternalJournalEntry(actual, expected) { + if (!actual || typeof actual !== 'object' || Array.isArray(actual)) return false; + const keys = Object.keys(actual).sort(); + const expectedKeys = ['amountAtomic', 'category', 'creditAccountId', 'debitAccountId']; + if (keys.length !== expectedKeys.length + || keys.some((key, index) => key !== expectedKeys[index])) return false; + return actual.category === expected.category + && actual.debitAccountId === expected.debitAccountId + && actual.creditAccountId === expected.creditAccountId + && actual.amountAtomic === expected.amountAtomic; +} + +export function validateInternalJournalEntries(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + fail('INTERNAL_JOURNAL_INPUT', 'internal journal validation input must be an object'); + } + let expected; + if (input.kind === 'succeeded') { + expected = allocateInternalGross({ + grossAtomic: input.grossAtomic, + executionCostAtomic: input.executionCostAtomic, + protocolFeeAtomic: input.protocolFeeAtomic, + refundReserveAtomic: input.refundReserveAtomic, + recipientId: input.recipientId, + employerId: input.employerId, + }); + } else if (input.kind === 'failed_after_start') { + const allocation = allocateInternalFailureGross({ + executionCostAtomic: input.executionCostAtomic, + }); + if (assertAtomic(input.grossAtomic, 'grossAtomic') !== allocation.grossAtomic) { + fail('INTERNAL_JOURNAL_GROSS', 'known failure gross must equal exact execution COGS'); + } + expected = allocation; + } else { + fail('INTERNAL_JOURNAL_KIND', 'unsupported internal journal validation kind'); + } + if (!Array.isArray(input.journalEntries) + || input.journalEntries.length !== expected.journalEntries.length + || input.journalEntries.some((entry, index) => ( + !sameInternalJournalEntry(entry, expected.journalEntries[index]) + ))) { + fail( + 'INTERNAL_JOURNAL_MISMATCH', + 'internal journal entries do not match shared kernel allocation', + ); + } + return input.journalEntries; +} diff --git a/prototype/tests/atomic-money.test.mjs b/prototype/tests/atomic-money.test.mjs index 6580427..9c52fea 100644 --- a/prototype/tests/atomic-money.test.mjs +++ b/prototype/tests/atomic-money.test.mjs @@ -8,12 +8,14 @@ import { allocateByBps, allocateByWeights, allocateExternalGross, + allocateInternalFailureGross, allocateInternalGross, allocateRoyaltyGraph, assertAtomic, floorBps, formatUsdc, parseUsdc, + validateInternalJournalEntries, } from '../atomic-money.mjs'; test('parseUsdc converts exact display values to six-decimal atomic units', () => { @@ -390,6 +392,43 @@ test('allocateInternalGross leaves one exact employee Invocation award', () => { ]); }); +test('shared kernel emits the only known-failure internal COGS journal row', () => { + const result = allocateInternalFailureGross({ executionCostAtomic: 700_000n }); + assert.deepEqual(result, { + grossAtomic: 700_000n, + executionCostAtomic: 700_000n, + journalEntries: [{ + category: 'execution-cogs', + debitAccountId: 'employer:invocation-gross', + creditAccountId: 'provider:execution', + amountAtomic: 700_000n, + }], + }); + assert.deepEqual(validateInternalJournalEntries({ + kind: 'failed_after_start', + grossAtomic: 700_000n, + executionCostAtomic: 700_000n, + journalEntries: result.journalEntries, + }), result.journalEntries); + assert.throws(() => validateInternalJournalEntries({ + kind: 'failed_after_start', + grossAtomic: 700_000n, + executionCostAtomic: 700_000n, + journalEntries: [{ ...result.journalEntries[0], creditAccountId: 'employee:attacker' }], + }), /internal journal entries do not match shared kernel allocation/); +}); + +test('internal kernel rejects an employer as the employee award recipient', () => { + assert.throws(() => allocateInternalGross({ + grossAtomic: 10n, + executionCostAtomic: 0n, + protocolFeeAtomic: 0n, + refundReserveAtomic: 0n, + recipientId: 'megacorp', + employerId: 'megacorp', + }), /employer cannot receive an employee Invocation award/); +}); + test('gross partitions reject insufficient, negative, and non-bigint monetary inputs', () => { const external = { grossAtomic: 100n, From 884c20f20119bafe6c6fe14d4fecc0c1cc917f48 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:16:20 -0400 Subject: [PATCH 091/165] fix: make signed attestations replay-safe --- phase0/src/attestations.ts | 185 +++++++--- phase0/tests/attestation-adversarial.test.ts | 342 +++++++++++++++++++ phase0/tests/attestations.test.ts | 4 +- 3 files changed, 481 insertions(+), 50 deletions(-) create mode 100644 phase0/tests/attestation-adversarial.test.ts diff --git a/phase0/src/attestations.ts b/phase0/src/attestations.ts index 6b8eb3d..0d77e86 100644 --- a/phase0/src/attestations.ts +++ b/phase0/src/attestations.ts @@ -461,21 +461,21 @@ function subjectEquals(a: RegistrationSubject, b: RegistrationSubject): boolean export function canonicalRepositoryStatement(challengeValue: RepositoryControlChallengeV1): string { const challenge = parseRepositoryChallenge(challengeValue); - return [ - "skill-asset-protocol/repository-control/v1", - `registration=${challenge.subject.registrationId}`, - `ipId=${challenge.subject.ipId}`, - `wallet=${challenge.subject.wallet}`, - `artifactSha256=${challenge.subject.artifactHash}`, - `repository=${challenge.repositoryUrl}`, - `artifactCommit=${challenge.artifactCommitSha}`, - `artifactPath=${challenge.artifactPath}`, - `challengePath=${challenge.challengePath}`, - `nonce=${challenge.nonce}`, - `issuedAt=${challenge.issuedAt}`, - `expiresAt=${challenge.expiresAt}`, - "", - ].join("\n"); + return canonicalSignedJson("skill-asset-protocol/repository-control/v2", { + schemaVersion: 2, + registrationId: challenge.subject.registrationId, + ipId: challenge.subject.ipId, + wallet: challenge.subject.wallet, + artifactSha256: challenge.subject.artifactHash, + declaredParentIpIds: [...challenge.subject.declaredParentIpIds].sort(), + repository: challenge.repositoryUrl, + artifactCommit: challenge.artifactCommitSha, + artifactPath: challenge.artifactPath, + challengePath: challenge.challengePath, + nonce: challenge.nonce, + issuedAt: challenge.issuedAt, + expiresAt: challenge.expiresAt, + }); } export function repositoryStatementHash(challenge: RepositoryControlChallengeV1): `0x${string}` { @@ -504,19 +504,18 @@ export function canonicalOrganizationStatement(approvalValue: UnsignedApproval): statementHash: `0x${"0".repeat(64)}`, signature: "0x00", }); - return [ - "skill-asset-protocol/organization-approval/v1", - `registration=${approval.subject.registrationId}`, - `ipId=${approval.subject.ipId}`, - `wallet=${approval.subject.wallet}`, - `artifactSha256=${approval.subject.artifactHash}`, - `declaredParentIpIds=${[...approval.subject.declaredParentIpIds].sort().join(",")}`, - `organizationId=${approval.organizationId}`, - `approverWallet=${approval.approverWallet}`, - `role=${approval.role}`, - `approvedAt=${approval.approvedAt}`, - "", - ].join("\n"); + return canonicalSignedJson("skill-asset-protocol/organization-approval/v2", { + schemaVersion: 2, + registrationId: approval.subject.registrationId, + ipId: approval.subject.ipId, + wallet: approval.subject.wallet, + artifactSha256: approval.subject.artifactHash, + declaredParentIpIds: [...approval.subject.declaredParentIpIds].sort(), + organizationId: approval.organizationId, + approverWallet: approval.approverWallet, + role: approval.role, + approvedAt: approval.approvedAt, + }); } export function organizationStatementHash(approval: UnsignedApproval): `0x${string}` { @@ -544,36 +543,52 @@ export async function verifyOrganizationApproval( } } -function eventStatement(header: string, rows: readonly [string, string][]): string { - return [header, ...rows.map(([key, value]) => `${key}=${value}`), ""].join("\n"); +function canonicalSignedJson(domain: string, payload: Record): string { + return `${domain}\n${JSON.stringify(payload)}\n`; } export function canonicalChallengeEventStatement(eventValue: ChallengeOpenedEvent): string { const event = parseAttestationEvent(eventValue); if (event.type !== "challenge_opened") throw new Error("challenge event required"); - return eventStatement("skill-asset-protocol/challenge-opened/v1", [ - ["eventId", event.eventId], ["sequence", String(event.sequence)], ["occurredAt", event.occurredAt], - ["conflictId", event.conflictId], ["challengedRegistrationId", event.challengedRegistrationId], - ["challengerRegistrationId", event.challengerRegistrationId], ["challengerWallet", event.challengerWallet], - ["evidenceUris", [...event.evidenceUris].sort().join(",")], ["reason", event.reason], - ]); + return canonicalSignedJson("skill-asset-protocol/challenge-opened/v2", { + schemaVersion: 2, + eventId: event.eventId, + sequence: event.sequence, + occurredAt: event.occurredAt, + conflictId: event.conflictId, + challengedRegistrationId: event.challengedRegistrationId, + challengerRegistrationId: event.challengerRegistrationId, + challengerWallet: event.challengerWallet, + evidenceUris: [...event.evidenceUris].sort(), + reason: event.reason, + }); } export function canonicalAdminEventStatement(eventValue: ChallengeResolvedEvent | AttestationRevokedEvent): string { const event = parseAttestationEvent(eventValue); if (event.type === "challenge_resolved") { - return eventStatement("skill-asset-protocol/challenge-resolved/v1", [ - ["eventId", event.eventId], ["sequence", String(event.sequence)], ["occurredAt", event.occurredAt], - ["conflictId", event.conflictId], ["outcome", event.outcome], ["rationale", event.rationale], - ["adminSignerId", event.adminSignerId], - ]); + return canonicalSignedJson("skill-asset-protocol/challenge-resolved/v2", { + schemaVersion: 2, + eventId: event.eventId, + sequence: event.sequence, + occurredAt: event.occurredAt, + conflictId: event.conflictId, + outcome: event.outcome, + rationale: event.rationale, + adminSignerId: event.adminSignerId, + }); } if (event.type === "attestation_revoked") { - return eventStatement("skill-asset-protocol/attestation-revoked/v1", [ - ["eventId", event.eventId], ["sequence", String(event.sequence)], ["occurredAt", event.occurredAt], - ["registrationId", event.registrationId], ["level", event.level], ["reason", event.reason], - ["adminSignerId", event.adminSignerId], - ]); + return canonicalSignedJson("skill-asset-protocol/attestation-revoked/v2", { + schemaVersion: 2, + eventId: event.eventId, + sequence: event.sequence, + occurredAt: event.occurredAt, + registrationId: event.registrationId, + level: event.level, + reason: event.reason, + adminSignerId: event.adminSignerId, + }); } throw new Error("admin event required"); } @@ -647,8 +662,11 @@ export async function reduceAttestationEvents( adminSigners?: Readonly>; baseSubjects?: readonly RegistrationSubject[]; repositoryVerifier?: (event: RepositoryControlEvent) => Promise; + now?: Date; } = {}, ): Promise { + const verifierNow = trust.now?.getTime(); + if (verifierNow !== undefined && !Number.isFinite(verifierNow)) throw new Error("attestation verifier clock is invalid"); const subjects: Record = {}; for (const raw of trust.baseSubjects ?? []) { const subject = parseSubject(raw, "base registration subject"); @@ -656,7 +674,12 @@ export async function reduceAttestationEvents( subjects[subject.registrationId] = subject; } - const registrations: Record = {}; + const registrations: Record = {}; for (const subject of Object.values(subjects)) { registrations[subject.registrationId] = { subject, @@ -668,6 +691,8 @@ export async function reduceAttestationEvents( revocations: [], repositoryActive: false, organizationActive: false, + repositoryActivatedAt: null, + organizationActivatedAt: null, }; } @@ -694,30 +719,80 @@ export async function reduceAttestationEvents( const parsedEvents: AttestationEvent[] = []; const eventIds = new Set(); + const consumedRepositoryStatementHashes = new Set(); + const consumedRepositoryNonces = new Set(); + const consumedRepositorySignatures = new Set(); + const consumedForgeObservations = new Set(); + const consumedOrganizationStatementHashes = new Set(); + const consumedOrganizationSignatures = new Set(); + const challengeOpenedAt = new Map(); + let priorOccurredAt: number | null = null; for (let index = 0; index < eventValues.length; index += 1) { const event = parseAttestationEvent(eventValues[index]); if (event.sequence !== index + 1) throw new Error(`attestation sequence must be contiguous at ${index + 1}`); if (eventIds.has(event.eventId)) throw new Error(`duplicate attestation event ID ${event.eventId}`); + const occurredAt = Date.parse(event.occurredAt); + if (priorOccurredAt !== null && occurredAt < priorOccurredAt) { + throw new Error("attestation event occurredAt must be nondecreasing"); + } + if (verifierNow !== undefined && occurredAt > verifierNow) throw new Error("attestation event occurredAt is future-dated"); + priorOccurredAt = occurredAt; eventIds.add(event.eventId); parsedEvents.push(event); if (event.type === "repository_control_verified") { const registration = registrations[event.subject.registrationId]; if (!registration || !subjectEquals(registration.subject, event.subject)) throw new Error("repository event subject drift or unknown registration"); + const forgeCredential = JSON.stringify([ + event.forgeObservation.schemaVersion, + event.forgeObservation.repositoryId, + event.forgeObservation.repositoryUrl, + event.forgeObservation.trustedRef, + event.forgeObservation.proofCommitSha, + event.forgeObservation.challengeNonce, + event.forgeObservation.observedAt, + event.forgeObservation.forgeSignerId, + event.forgeObservation.signature, + ]); + if (consumedRepositoryStatementHashes.has(event.statementHash) + || consumedRepositoryNonces.has(event.challenge.nonce) + || consumedRepositorySignatures.has(event.signature) + || consumedForgeObservations.has(forgeCredential)) { + throw new Error("repository credential, statement, nonce, or forge observation was already consumed"); + } await verifyRepositoryEventSignature(event); if (!trust.repositoryVerifier) throw new Error("repository verifier context required"); await trust.repositoryVerifier(event); + consumedRepositoryStatementHashes.add(event.statementHash); + consumedRepositoryNonces.add(event.challenge.nonce); + consumedRepositorySignatures.add(event.signature); + consumedForgeObservations.add(forgeCredential); registration.repositoryActive = true; + registration.repositoryActivatedAt = occurredAt; registration.evidenceEventIds = Object.freeze([...registration.evidenceEventIds, event.eventId]); } else if (event.type === "organization_approved") { const registration = registrations[event.subject.registrationId]; if (!registration || !subjectEquals(registration.subject, event.subject) || !subjectEquals(event.subject, event.approval.subject)) throw new Error("organization event subject drift or unknown registration"); if (!registration.repositoryActive) throw new Error("organization approval requires active repository evidence"); + if (consumedOrganizationStatementHashes.has(event.approval.statementHash) + || consumedOrganizationSignatures.has(event.approval.signature)) { + throw new Error("organization approval credential was already consumed"); + } + const approvedAt = Date.parse(event.approval.approvedAt); + if (approvedAt > occurredAt) throw new Error("organization approvedAt must not follow its event envelope"); + if (registration.repositoryActivatedAt === null || approvedAt < registration.repositoryActivatedAt) { + throw new Error("organization approvedAt must not precede active repository evidence"); + } await verifyOrganizationApproval(event.approval, trust.organizationSigners ?? {}); + consumedOrganizationStatementHashes.add(event.approval.statementHash); + consumedOrganizationSignatures.add(event.approval.signature); registration.organizationActive = true; + registration.organizationActivatedAt = occurredAt; registration.evidenceEventIds = Object.freeze([...registration.evidenceEventIds, event.eventId]); } else if (event.type === "challenge_opened") { await verifyChallengeEventSignature(event, subjects); + const priorChallenge = challengeOpenedAt.get(event.conflictId); + if (priorChallenge === undefined || occurredAt < priorChallenge) challengeOpenedAt.set(event.conflictId, occurredAt); const existing = conflicts.get(event.conflictId); if (existing) { const ids = new Set(existing.registrationIds); @@ -743,6 +818,9 @@ export async function reduceAttestationEvents( await verifyAdminEventSignature(event, trust.adminSigners ?? {}); const conflict = conflicts.get(event.conflictId); if (!conflict) throw new Error("resolution targets an unknown conflict"); + const openedAt = challengeOpenedAt.get(event.conflictId); + if (openedAt === undefined) throw new Error("resolution requires a preceding signed challenge"); + if (occurredAt < openedAt) throw new Error("resolution must follow its signed challenge"); if (conflict.status === "resolved") throw new Error("conflict is already resolved"); conflicts.set(event.conflictId, { ...conflict, status: "resolved", outcome: event.outcome, eventIds: [...conflict.eventIds, event.eventId] }); } else { @@ -751,11 +829,16 @@ export async function reduceAttestationEvents( if (!registration) throw new Error("revocation targets an unknown registration"); if (event.level === "repository_control_verified") { if (!registration.repositoryActive) throw new Error("repository evidence is not active"); + if (registration.repositoryActivatedAt === null || occurredAt < registration.repositoryActivatedAt) throw new Error("revocation must follow active repository evidence"); registration.repositoryActive = false; registration.organizationActive = false; + registration.repositoryActivatedAt = null; + registration.organizationActivatedAt = null; } else { if (!registration.organizationActive) throw new Error("organization evidence is not active"); + if (registration.organizationActivatedAt === null || occurredAt < registration.organizationActivatedAt) throw new Error("revocation must follow active organization evidence"); registration.organizationActive = false; + registration.organizationActivatedAt = null; } registration.revocations = Object.freeze([...registration.revocations, { level: event.level, @@ -781,7 +864,13 @@ export async function reduceAttestationEvents( } const publicRegistrations = Object.fromEntries(Object.entries(registrations).map(([id, value]) => { - const { repositoryActive: _repositoryActive, organizationActive: _organizationActive, ...publicValue } = value; + const { + repositoryActive: _repositoryActive, + organizationActive: _organizationActive, + repositoryActivatedAt: _repositoryActivatedAt, + organizationActivatedAt: _organizationActivatedAt, + ...publicValue + } = value; return [id, deepFreeze(publicValue)]; })); return deepFreeze({ diff --git a/phase0/tests/attestation-adversarial.test.ts b/phase0/tests/attestation-adversarial.test.ts new file mode 100644 index 0000000..615db42 --- /dev/null +++ b/phase0/tests/attestation-adversarial.test.ts @@ -0,0 +1,342 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +import { + adminEventStatementHash, + canonicalAdminEventStatement, + canonicalChallengeEventStatement, + canonicalOrganizationStatement, + canonicalRepositoryStatement, + challengeEventStatementHash, + deterministicConflictId, + organizationStatementHash, + reduceAttestationEvents, + repositoryStatementHash, + type AttestationRevokedEvent, + type ChallengeOpenedEvent, + type ChallengeResolvedEvent, + type OrganizationApprovedEvent, + type RegistrationSubject, + type RepositoryControlEvent, +} from "../src/attestations"; + +const IP_A = `0x${"a".repeat(40)}` as const; +const IP_B = `0x${"b".repeat(40)}` as const; +const HASH = `0x${"1".repeat(64)}` as const; +const T0 = "2026-07-18T00:00:00.000Z"; +const T1 = "2026-07-18T01:00:00.000Z"; +const T2 = "2026-07-18T02:00:00.000Z"; +const T3 = "2026-07-18T03:00:00.000Z"; +const T4 = "2026-07-18T04:00:00.000Z"; + +function subject(ipId: `0x${string}`, wallet: `0x${string}`): RegistrationSubject { + return { + registrationId: `eip155:1315:${ipId}`, + ipId, + wallet: wallet.toLowerCase() as `0x${string}`, + artifactHash: HASH, + declaredParentIpIds: [], + }; +} + +async function repositoryEvent(input: { + subject: RegistrationSubject; + account: ReturnType; + eventId: string; + sequence: number; + nonceDigit: string; + occurredAt?: string; +}): Promise { + const challenge = { + schemaVersion: 1 as const, + subject: input.subject, + repositoryUrl: "https://github.com/example/adversarial", + artifactCommitSha: "1".repeat(40), + artifactPath: "skills/demo/SKILL.md", + challengePath: `attestations/${input.nonceDigit}.json`, + nonce: `0x${input.nonceDigit.repeat(64)}` as `0x${string}`, + issuedAt: T0, + expiresAt: T4, + }; + return { + type: "repository_control_verified", + eventId: input.eventId, + sequence: input.sequence, + occurredAt: input.occurredAt ?? T1, + subject: input.subject, + challenge, + forgeObservation: { + schemaVersion: 1, + repositoryId: "demo", + repositoryUrl: challenge.repositoryUrl, + trustedRef: "refs/heads/main", + proofCommitSha: input.nonceDigit.repeat(40), + challengeNonce: challenge.nonce, + observedAt: T1, + forgeSignerId: "forge-1", + signature: `forge-${input.nonceDigit}`, + }, + statementHash: repositoryStatementHash(challenge), + signature: await input.account.signMessage({ message: canonicalRepositoryStatement(challenge) }), + }; +} + +async function revocation(input: { + admin: ReturnType; + registrationId: string; + eventId: string; + sequence: number; + occurredAt: string; +}): Promise { + const base = { + type: "attestation_revoked" as const, + eventId: input.eventId, + sequence: input.sequence, + occurredAt: input.occurredAt, + registrationId: input.registrationId, + level: "repository_control_verified" as const, + reason: "Verifier snapshot trust withdrawn.", + adminSignerId: "admin-1", + statementHash: HASH, + signature: "0x00" as `0x${string}`, + }; + const statementHash = adminEventStatementHash(base); + return { + ...base, + statementHash, + signature: await input.admin.signMessage({ message: canonicalAdminEventStatement({ ...base, statementHash }) }), + }; +} + +async function organizationEvent(input: { + subject: RegistrationSubject; + approver: ReturnType; + eventId: string; + sequence: number; + approvedAt: string; + occurredAt: string; +}): Promise { + const unsigned = { + schemaVersion: 1 as const, + subject: input.subject, + organizationId: "example-org", + approverWallet: input.approver.address.toLowerCase() as `0x${string}`, + role: "ip_admin" as const, + approvedAt: input.approvedAt, + }; + const approval = { + ...unsigned, + statementHash: organizationStatementHash(unsigned), + signature: await input.approver.signMessage({ message: canonicalOrganizationStatement(unsigned) }), + }; + return { + type: "organization_approved", + eventId: input.eventId, + sequence: input.sequence, + occurredAt: input.occurredAt, + subject: input.subject, + approval, + }; +} + +test("all signed semantic encodings are injective across adversarial delimiter redistribution", () => { + const account = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const repositoryBase = { + schemaVersion: 1 as const, + subject: base, + repositoryUrl: "https://github.com/example/adversarial", + artifactCommitSha: "1".repeat(40), + nonce: `0x${"2".repeat(64)}` as `0x${string}`, + issuedAt: T0, + expiresAt: T4, + }; + const repositoryA = { + ...repositoryBase, + artifactPath: "a\nchallengePath=b", + challengePath: "c", + }; + const repositoryB = { + ...repositoryBase, + artifactPath: "a", + challengePath: "b\nchallengePath=c", + }; + assert.notEqual(canonicalRepositoryStatement(repositoryA), canonicalRepositoryStatement(repositoryB)); + + const challengeBase = { + type: "challenge_opened" as const, + eventId: "challenge-1", + sequence: 1, + occurredAt: T1, + conflictId: "conflict-1", + challengedRegistrationId: base.registrationId, + challengerRegistrationId: `eip155:1315:${IP_B}`, + challengerWallet: `0x${"b".repeat(40)}` as `0x${string}`, + reason: "duplicate_bytes" as const, + statementHash: HASH, + signature: "0x00" as `0x${string}`, + }; + assert.notEqual( + canonicalChallengeEventStatement({ ...challengeBase, evidenceUris: ["https://example.test/a,b", "https://example.test/c"] }), + canonicalChallengeEventStatement({ ...challengeBase, evidenceUris: ["https://example.test/a", "https://example.test/b,c"] }), + ); + + const resolutionBase = { + type: "challenge_resolved" as const, + eventId: "resolution-1", + sequence: 1, + occurredAt: T1, + adminSignerId: "admin-1", + statementHash: HASH, + signature: "0x00" as `0x${string}`, + }; + const resolutionA: ChallengeResolvedEvent = { + ...resolutionBase, + conflictId: "x\noutcome=rejected\nrationale=y", + outcome: "upheld", + rationale: "z", + }; + const resolutionB: ChallengeResolvedEvent = { + ...resolutionBase, + conflictId: "x", + outcome: "rejected", + rationale: "y\noutcome=upheld\nrationale=z", + }; + assert.notEqual(canonicalAdminEventStatement(resolutionA), canonicalAdminEventStatement(resolutionB)); + + const revocationBase = { + type: "attestation_revoked" as const, + sequence: 1, + occurredAt: T1, + registrationId: base.registrationId, + level: "repository_control_verified" as const, + adminSignerId: "admin-1", + statementHash: HASH, + signature: "0x00" as `0x${string}`, + }; + const injectedPrefix = `x\nsequence=1\noccurredAt=${T1}\nregistrationId=${base.registrationId}\nlevel=repository_control_verified\nreason=y`; + const revocationA: AttestationRevokedEvent = { ...revocationBase, eventId: injectedPrefix, reason: "z" }; + const revocationB: AttestationRevokedEvent = { + ...revocationBase, + eventId: "x", + reason: `y\nsequence=1\noccurredAt=${T1}\nregistrationId=${base.registrationId}\nlevel=repository_control_verified\nreason=z`, + }; + assert.notEqual(canonicalAdminEventStatement(revocationA), canonicalAdminEventStatement(revocationB)); +}); + +test("stable repository and organization credentials cannot be replayed in fresh envelopes", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const approver = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const repo = await repositoryEvent({ subject: base, account, eventId: "repo-1", sequence: 1, nonceDigit: "2" }); + await assert.rejects(reduceAttestationEvents([repo, { ...repo, eventId: "repo-2", sequence: 2, occurredAt: T2 }], { + baseSubjects: [base], repositoryVerifier: async () => undefined, now: new Date(T4), + }), /repository (credential|statement|nonce|observation).*already consumed/i); + + const org = await organizationEvent({ subject: base, approver, eventId: "org-1", sequence: 2, approvedAt: T2, occurredAt: T2 }); + await assert.rejects(reduceAttestationEvents([repo, org, { ...org, eventId: "org-2", sequence: 3, occurredAt: T3 }], { + baseSubjects: [base], repositoryVerifier: async () => undefined, + organizationSigners: { "example-org": [org.approval.approverWallet] }, now: new Date(T4), + }), /organization (credential|approval).*already consumed/i); +}); + +test("revocation permanently consumes old evidence while genuinely fresh credentials may reactivate", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const admin = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const repo = await repositoryEvent({ subject: base, account, eventId: "repo-1", sequence: 1, nonceDigit: "2" }); + const revoked = await revocation({ admin, registrationId: base.registrationId, eventId: "revoke-1", sequence: 2, occurredAt: T2 }); + const trust = { + baseSubjects: [base], repositoryVerifier: async () => undefined, + adminSigners: { "admin-1": admin.address.toLowerCase() as `0x${string}` }, now: new Date(T4), + }; + await assert.rejects(reduceAttestationEvents([repo, revoked, { ...repo, eventId: "repo-replay", sequence: 3, occurredAt: T3 }], trust), /already consumed/i); + const fresh = await repositoryEvent({ subject: base, account, eventId: "repo-fresh", sequence: 3, nonceDigit: "3", occurredAt: T3 }); + const state = await reduceAttestationEvents([repo, revoked, fresh], trust); + assert.equal(state.registrations[base.registrationId].level, "repository_control_verified"); + + const approver = privateKeyToAccount(generatePrivateKey()); + const organization = await organizationEvent({ + subject: base, + approver, + eventId: "organization-before-revocation", + sequence: 2, + approvedAt: T2, + occurredAt: T2, + }); + const revocationAfterOrganization = await revocation({ + admin, + registrationId: base.registrationId, + eventId: "revoke-with-organization", + sequence: 3, + occurredAt: T3, + }); + const freshAfterOrganization = await repositoryEvent({ + subject: base, + account, + eventId: "repo-fresh-after-organization", + sequence: 4, + nonceDigit: "4", + occurredAt: T3, + }); + await assert.rejects(reduceAttestationEvents([ + repo, + organization, + revocationAfterOrganization, + freshAfterOrganization, + { ...organization, eventId: "old-organization-replay", sequence: 5, occurredAt: T4 }, + ], { + ...trust, + organizationSigners: { "example-org": [organization.approval.approverWallet] }, + }), /organization approval credential was already consumed/i); +}); + +test("event chronology and causal approval/resolution ordering fail closed against an injected clock", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const approver = privateKeyToAccount(generatePrivateKey()); + const admin = privateKeyToAccount(generatePrivateKey()); + const challenger = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const second = subject(IP_B, challenger.address); + const repo = await repositoryEvent({ subject: base, account, eventId: "repo-1", sequence: 1, nonceDigit: "2", occurredAt: T2 }); + const freshEarlier = await repositoryEvent({ subject: base, account, eventId: "repo-2", sequence: 2, nonceDigit: "3", occurredAt: T1 }); + await assert.rejects(reduceAttestationEvents([repo, freshEarlier], { + baseSubjects: [base], repositoryVerifier: async () => undefined, now: new Date(T4), + }), /occurredAt must be nondecreasing/i); + + const futureRepo = await repositoryEvent({ subject: base, account, eventId: "repo-future", sequence: 1, nonceDigit: "6", occurredAt: T4 }); + await assert.rejects(reduceAttestationEvents([futureRepo], { + baseSubjects: [base], repositoryVerifier: async () => undefined, now: new Date(T3), + }), /future-dated/i); + + const futureOrg = await organizationEvent({ subject: base, approver, eventId: "org-future", sequence: 2, approvedAt: T4, occurredAt: T3 }); + await assert.rejects(reduceAttestationEvents([repo, futureOrg], { + baseSubjects: [base], repositoryVerifier: async () => undefined, + organizationSigners: { "example-org": [futureOrg.approval.approverWallet] }, now: new Date(T3), + }), /approvedAt.*envelope|future/i); + + const conflictId = deterministicConflictId(base, second); + const resolutionBase = { + type: "challenge_resolved" as const, + eventId: "resolution-1", + sequence: 1, + occurredAt: T2, + conflictId, + outcome: "inconclusive" as const, + rationale: "No signed challenge preceded this resolution.", + adminSignerId: "admin-1", + statementHash: HASH, + signature: "0x00" as `0x${string}`, + }; + const statementHash = adminEventStatementHash(resolutionBase); + const resolution: ChallengeResolvedEvent = { + ...resolutionBase, + statementHash, + signature: await admin.signMessage({ message: canonicalAdminEventStatement({ ...resolutionBase, statementHash }) }), + }; + await assert.rejects(reduceAttestationEvents([resolution], { + baseSubjects: [base, second], adminSigners: { "admin-1": admin.address.toLowerCase() as `0x${string}` }, now: new Date(T4), + }), /resolution requires a preceding signed challenge/i); +}); diff --git a/phase0/tests/attestations.test.ts b/phase0/tests/attestations.test.ts index cdf3e47..b22a8f5 100644 --- a/phase0/tests/attestations.test.ts +++ b/phase0/tests/attestations.test.ts @@ -86,8 +86,8 @@ async function repositoryEvent(value: RegistrationSubject, account: ReturnType { const account = privateKeyToAccount(generatePrivateKey()); const statement = canonicalRepositoryStatement(challengeFor(subject(IP_A, account.address))); - assert.equal(statement.split("\n")[0], "skill-asset-protocol/repository-control/v1"); - assert.match(statement, /\nregistration=eip155:1315:/); + assert.equal(statement.split("\n")[0], "skill-asset-protocol/repository-control/v2"); + assert.match(statement, /\n\{"schemaVersion":2,"registrationId":"eip155:1315:/); assert.ok(statement.endsWith("\n")); assert.ok(!statement.endsWith("\n\n")); }); From fa441f27a19fd66d6b794a2cbf1406b81720f4a9 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:16:34 -0400 Subject: [PATCH 092/165] fix: isolate offline Git attestation verification --- phase0/src/attestation-config.ts | 30 +++++-- phase0/src/attestation-git.ts | 81 +++++++++++++++++-- phase0/tests/attestation-cli.test.ts | 1 + phase0/tests/attestation-git.test.ts | 112 ++++++++++++++++++++++++++- 4 files changed, 207 insertions(+), 17 deletions(-) diff --git a/phase0/src/attestation-config.ts b/phase0/src/attestation-config.ts index dd72fa8..f207e25 100644 --- a/phase0/src/attestation-config.ts +++ b/phase0/src/attestation-config.ts @@ -10,14 +10,20 @@ export interface LocalCheckoutMapV1 { checkouts: Record; } +export interface PinnedLocalCheckout { + repositoryPath: string; + device: number; + inode: number; +} + interface FileMetadata { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; mode: number; uid: number; - dev?: number; - ino?: number; + dev: number; + ino: number; } interface SecureReadHandle { @@ -83,7 +89,7 @@ export async function loadLocalCheckoutMap(input: { phase0Root: string; referencedCheckoutKeys: readonly string[]; fs?: AttestationConfigFileSystem; -}): Promise>> { +}): Promise>> { const fs = input.fs ?? NODE_FS; if (!isAbsolute(input.phase0Root)) throw new Error("phase0Root must be an absolute canonical path"); let canonicalRoot: string; @@ -139,7 +145,7 @@ export async function loadLocalCheckoutMap(input: { throw new Error("repository snapshot mapping keys must exactly match tracked repository trust"); } - const result: Record = {}; + const result: Record = {}; for (const key of actual) { const checkoutPath = checkouts[key]; if (typeof checkoutPath !== "string" || !isAbsolute(checkoutPath) || resolve(checkoutPath) !== checkoutPath) { @@ -159,11 +165,19 @@ export async function loadLocalCheckoutMap(input: { if (checkoutMetadata.isSymbolicLink() || !checkoutMetadata.isDirectory()) throw new Error(`checkout ${key} must be a directory`); if (checkoutMetadata.uid !== fs.currentUid()) throw new Error(`checkout ${key} must be owned by the current user`); if ((checkoutMetadata.mode & 0o022) !== 0) throw new Error(`checkout ${key} must not be group- or world-writable`); + if (!Number.isSafeInteger(checkoutMetadata.dev) || checkoutMetadata.dev < 0 + || !Number.isSafeInteger(checkoutMetadata.ino) || checkoutMetadata.ino <= 0) { + throw new Error(`checkout ${key} filesystem identity is unavailable`); + } if (await fs.realpath(checkoutPath) !== checkoutPath) throw new Error(`checkout ${key} changed during validation`); + result[key] = deepFreeze({ + repositoryPath: checkoutPath, + device: checkoutMetadata.dev, + inode: checkoutMetadata.ino, + }); } finally { await checkoutHandle.close(); } - result[key] = checkoutPath; } return deepFreeze(result); } @@ -222,7 +236,7 @@ export function parseRepositoryTrustConfig(value: unknown): RepositoryTrustConfi export function createTrustedRepositoryResolver(input: { trustConfig: unknown; - checkoutPaths: Readonly>; + checkoutPaths: Readonly>; }): TrustedRepositoryResolver { const config = parseRepositoryTrustConfig(input.trustConfig); const configuredKeys = config.repositories.map((entry) => entry.checkoutKey).sort(); @@ -234,7 +248,9 @@ export function createTrustedRepositoryResolver(input: { const repository: TrustedRepository = deepFreeze({ repositoryId: entry.repositoryId, repositoryUrl: entry.repositoryUrl, - repositoryPath: input.checkoutPaths[entry.checkoutKey], + repositoryPath: input.checkoutPaths[entry.checkoutKey].repositoryPath, + repositoryDevice: input.checkoutPaths[entry.checkoutKey].device, + repositoryInode: input.checkoutPaths[entry.checkoutKey].inode, trustedRef: entry.trustedRef, permittedForgeSignerIds: [...entry.permittedForgeSignerIds], }); diff --git a/phase0/src/attestation-git.ts b/phase0/src/attestation-git.ts index d26201c..4310f78 100644 --- a/phase0/src/attestation-git.ts +++ b/phase0/src/attestation-git.ts @@ -1,5 +1,8 @@ import { createHash, verify as verifySignature } from "node:crypto"; import { execFile } from "node:child_process"; +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import { isAbsolute } from "node:path"; import { canonicalRepositoryStatement, @@ -14,6 +17,7 @@ import { } from "./attestations"; export interface GitReader { + repositoryIdentity(repositoryPath: string): Promise<{ device: number; inode: number }>; commitExists(repositoryPath: string, commitSha: string): Promise; readBlob(repositoryPath: string, commitSha: string, relativePath: string): Promise; isAncestor(repositoryPath: string, ancestor: string, descendant: string): Promise; @@ -24,6 +28,8 @@ export interface TrustedRepository { repositoryId: string; repositoryUrl: string; repositoryPath: string; + repositoryDevice: number; + repositoryInode: number; trustedRef: `refs/heads/${string}` | `refs/remotes/${string}`; permittedForgeSignerIds: readonly string[]; } @@ -38,13 +44,33 @@ export interface SignedRepositoryChallengeFileV1 { signature: `0x${string}`; } -function runGit(args: readonly string[]): Promise { +const MINIMAL_GIT_ENVIRONMENT = Object.freeze({ + LANG: "C", + LC_ALL: "C", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_COUNT: "0", + GIT_ATTR_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_OPTIONAL_LOCKS: "0", + GIT_NO_REPLACE_OBJECTS: "1", + GIT_NO_LAZY_FETCH: "1", +}); + +const SAFE_GIT_CONFIG = [ + "-c", "protocol.allow=never", + "-c", "core.fsmonitor=false", + "-c", "maintenance.auto=false", +] as const; + +function runGit(executable: string, args: readonly string[]): Promise { return new Promise((resolve, reject) => { - execFile("git", [...args], { + execFile(executable, [...SAFE_GIT_CONFIG, ...args], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, windowsHide: true, - env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }, + env: MINIMAL_GIT_ENVIRONMENT, }, (error, stdout, stderr) => { if (error) { const detail = Buffer.from(stderr).toString("utf8").trim(); @@ -78,11 +104,36 @@ function validRelativePath(value: string): void { } export class ExecGitReader implements GitReader { + private readonly gitExecutable: string; + + constructor(options: { gitExecutable?: string } = {}) { + this.gitExecutable = options.gitExecutable ?? "/usr/bin/git"; + if (!isAbsolute(this.gitExecutable)) throw new Error("an absolute Git executable path is required"); + } + + private run(args: readonly string[]): Promise { + return runGit(this.gitExecutable, args); + } + + async repositoryIdentity(repositoryPath: string): Promise<{ device: number; inode: number }> { + validRepositoryPath(repositoryPath); + const handle = await open(repositoryPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + try { + const metadata = await handle.stat(); + if (!metadata.isDirectory() || !Number.isSafeInteger(metadata.dev) || !Number.isSafeInteger(metadata.ino)) { + throw new Error("trusted repository filesystem identity is unavailable"); + } + return { device: metadata.dev, inode: metadata.ino }; + } finally { + await handle.close(); + } + } + async commitExists(repositoryPath: string, commitSha: string): Promise { validRepositoryPath(repositoryPath); validObjectName(commitSha, "Git commit"); try { - await runGit(["-C", repositoryPath, "cat-file", "-e", `${commitSha}^{commit}`]); + await this.run(["-C", repositoryPath, "cat-file", "-e", `${commitSha}^{commit}`]); return true; } catch (error) { const cause = (error as Error & { cause?: { code?: string | number } }).cause; @@ -95,7 +146,7 @@ export class ExecGitReader implements GitReader { validRepositoryPath(repositoryPath); validObjectName(commitSha, "Git commit"); validRelativePath(relativePath); - return runGit(["-C", repositoryPath, "show", `${commitSha}:${relativePath}`]); + return this.run(["-C", repositoryPath, "show", `${commitSha}:${relativePath}`]); } async isAncestor(repositoryPath: string, ancestor: string, descendant: string): Promise { @@ -103,7 +154,7 @@ export class ExecGitReader implements GitReader { validObjectName(ancestor, "Git ancestor"); validObjectName(descendant, "Git descendant"); try { - await runGit(["-C", repositoryPath, "merge-base", "--is-ancestor", ancestor, descendant]); + await this.run(["-C", repositoryPath, "merge-base", "--is-ancestor", ancestor, descendant]); return true; } catch (error) { const cause = (error as Error & { cause?: { code?: string | number } }).cause; @@ -115,7 +166,7 @@ export class ExecGitReader implements GitReader { async remoteUrl(repositoryPath: string, remoteName: string): Promise { validRepositoryPath(repositoryPath); if (remoteName !== "origin") throw new Error("only the configured origin remote may be verified"); - const bytes = await runGit(["-C", repositoryPath, "remote", "get-url", remoteName]); + const bytes = await this.run(["-C", repositoryPath, "remote", "get-url", remoteName]); return Buffer.from(bytes).toString("utf8").trim(); } } @@ -226,6 +277,13 @@ function sha256(bytes: Uint8Array): `0x${string}` { return `0x${createHash("sha256").update(bytes).digest("hex")}`; } +async function assertTrustedRepositoryIdentity(git: GitReader, trusted: TrustedRepository): Promise { + const identity = await git.repositoryIdentity(trusted.repositoryPath); + if (identity.device !== trusted.repositoryDevice || identity.inode !== trusted.repositoryInode) { + throw new Error("trusted repository checkout filesystem identity changed during verification"); + } +} + async function verifyRepositorySnapshot(input: { event: RepositoryControlEvent; challengeFile: Uint8Array; @@ -252,22 +310,31 @@ async function verifyRepositorySnapshot(input: { if (occurredAt < observedAt) throw new Error("repository event occurred before the forge observation"); if (occurredAt > now.getTime() || observedAt > now.getTime()) throw new Error("repository evidence is dated in the future"); + await assertTrustedRepositoryIdentity(git, trusted); const origin = normalizeRepositoryUrl(await git.remoteUrl(trusted.repositoryPath, "origin")); + await assertTrustedRepositoryIdentity(git, trusted); if (origin !== trusted.repositoryUrl || origin !== event.challenge.repositoryUrl || origin !== event.forgeObservation.repositoryUrl) { throw new Error("trusted checkout origin does not match signed repository URL"); } if (!await git.commitExists(trusted.repositoryPath, event.challenge.artifactCommitSha)) throw new Error("artifact commit is absent"); + await assertTrustedRepositoryIdentity(git, trusted); if (!await git.commitExists(trusted.repositoryPath, event.forgeObservation.proofCommitSha)) throw new Error("proof commit is absent"); + await assertTrustedRepositoryIdentity(git, trusted); if (!await git.commitExists(trusted.repositoryPath, trusted.trustedRef)) throw new Error("configured trusted ref is absent"); + await assertTrustedRepositoryIdentity(git, trusted); if (!await git.isAncestor(trusted.repositoryPath, event.challenge.artifactCommitSha, event.forgeObservation.proofCommitSha)) { throw new Error("proof commit does not descend from artifact commit"); } + await assertTrustedRepositoryIdentity(git, trusted); if (!await git.isAncestor(trusted.repositoryPath, event.forgeObservation.proofCommitSha, trusted.trustedRef)) { throw new Error("proof commit is not reachable from the configured trusted ref"); } + await assertTrustedRepositoryIdentity(git, trusted); const artifact = await git.readBlob(trusted.repositoryPath, event.challenge.artifactCommitSha, event.challenge.artifactPath); + await assertTrustedRepositoryIdentity(git, trusted); if (sha256(artifact) !== event.subject.artifactHash) throw new Error("registered artifact hash does not match exact Git bytes"); const challengeBlob = await git.readBlob(trusted.repositoryPath, event.forgeObservation.proofCommitSha, event.challenge.challengePath); + await assertTrustedRepositoryIdentity(git, trusted); if (!Buffer.from(challengeBlob).equals(Buffer.from(input.challengeFile))) throw new Error("committed challenge bytes do not match the signed challenge file"); } diff --git a/phase0/tests/attestation-cli.test.ts b/phase0/tests/attestation-cli.test.ts index 26ee114..ddb25b2 100644 --- a/phase0/tests/attestation-cli.test.ts +++ b/phase0/tests/attestation-cli.test.ts @@ -146,6 +146,7 @@ test("production repository command fails before Git for missing/insecure mappin let gitCalls = 0; const failIfCalled: GitReader = { + repositoryIdentity: async () => { gitCalls += 1; throw new Error("Git must not run"); }, commitExists: async () => { gitCalls += 1; throw new Error("Git must not run"); }, readBlob: async () => { gitCalls += 1; throw new Error("Git must not run"); }, isAncestor: async () => { gitCalls += 1; throw new Error("Git must not run"); }, diff --git a/phase0/tests/attestation-git.test.ts b/phase0/tests/attestation-git.test.ts index aebdd2c..87038d2 100644 --- a/phase0/tests/attestation-git.test.ts +++ b/phase0/tests/attestation-git.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash, generateKeyPairSync, sign as signBytes } from "node:crypto"; -import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, realpath, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { execFileSync } from "node:child_process"; @@ -106,7 +106,11 @@ async function fixture(t: test.TestContext) { permittedForgeSignerIds: ["forge-1"], }], }; - const repositories = createTrustedRepositoryResolver({ trustConfig, checkoutPaths: { "demo-checkout": root } }); + const rootMetadata = await stat(root); + const repositories = createTrustedRepositoryResolver({ + trustConfig, + checkoutPaths: { "demo-checkout": { repositoryPath: root, device: rootMetadata.dev, inode: rootMetadata.ino } }, + }); const forgeSigners = { "forge-1": forge.publicKey.export({ type: "spki", format: "pem" }).toString() }; return { root, artifactCommit, proofCommit, subject, challenge, challengeFile, forgeObservation, repositories, forgeSigners, trustConfig }; } @@ -153,6 +157,7 @@ test("resolver rejects claimant-selected repositories before Git runs", async (t const f = await fixture(t); let calls = 0; const gitReader = { + repositoryIdentity: async () => { calls += 1; return { device: 1, inode: 1 }; }, commitExists: async () => { calls += 1; return true; }, readBlob: async () => { calls += 1; return new Uint8Array(); }, isAncestor: async () => { calls += 1; return true; }, @@ -181,7 +186,10 @@ test("local checkout mapping requires exact owner-only canonical configuration", await writeFile(mappingPath, `${JSON.stringify({ schemaVersion: 1, checkouts: { "demo-checkout": f.root } })}\n`, { mode: 0o600 }); await chmod(mappingPath, 0o600); const loaded = await loadLocalCheckoutMap({ env: {}, phase0Root: canonicalPhase0Root, referencedCheckoutKeys: ["demo-checkout"] }); - assert.deepEqual(loaded, { "demo-checkout": f.root }); + const checkoutMetadata = await stat(f.root); + assert.deepEqual(loaded, { + "demo-checkout": { repositoryPath: f.root, device: checkoutMetadata.dev, inode: checkoutMetadata.ino }, + }); assert.ok(Object.isFrozen(loaded)); await chmod(mappingPath, 0o644); @@ -206,3 +214,101 @@ test("checkout mapping rejects a symlink instead of following a swapped path", a await symlink(target, join(root, ".attestation-checkouts.local.json")); await assert.rejects(loadLocalCheckoutMap({ env: {}, phase0Root: root, referencedCheckoutKeys: [] }), /non-symlink regular file/); }); + +test("Git verification ignores poisoned process environment, PATH, and replacement refs", async (t) => { + const f = await fixture(t); + await writeFile(join(f.root, "skills/demo/SKILL.md"), "tampered replacement bytes\n"); + git(f.root, "add", "skills/demo/SKILL.md"); + git(f.root, "commit", "-m", "replacement object not trusted"); + const replacementCommit = git(f.root, "rev-parse", "HEAD"); + git(f.root, "reset", "--hard", f.proofCommit); + git(f.root, "replace", f.artifactCommit, replacementCommit); + assert.match(git(f.root, "show", `${f.artifactCommit}:skills/demo/SKILL.md`), /tampered replacement bytes/); + + const poisoned: Record = { + PATH: "/definitely/not/a/git/path", + GIT_DIR: "/attacker/git-dir", + GIT_WORK_TREE: "/attacker/work-tree", + GIT_OBJECT_DIRECTORY: "/attacker/objects", + GIT_ALTERNATE_OBJECT_DIRECTORIES: "/attacker/alternates", + GIT_NAMESPACE: "attacker", + GIT_CONFIG_GLOBAL: "/attacker/global-config", + GIT_CONFIG_SYSTEM: "/attacker/system-config", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.sshCommand", + GIT_CONFIG_VALUE_0: "/attacker/command", + }; + const prior = new Map(); + for (const [key, value] of Object.entries(poisoned)) { + prior.set(key, process.env[key]); + process.env[key] = value; + } + try { + const event = await verifyRepositoryControl({ + challengeFile: f.challengeFile, + forgeObservation: f.forgeObservation, + eventId: "repository-poisoned-env", + sequence: 1, + occurredAt: "2026-07-18T12:00:00.000Z", + now: new Date("2026-07-18T12:30:00.000Z"), + git: new ExecGitReader(), + repositories: f.repositories, + forgeSigners: f.forgeSigners, + }); + assert.equal(event.subject.artifactHash, f.subject.artifactHash); + } finally { + for (const [key, value] of prior) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +test("ExecGitReader requires an absolute verifier-controlled executable", () => { + assert.throws(() => new ExecGitReader({ gitExecutable: "git" }), /absolute Git executable/); +}); + +test("missing partial-clone objects fail without invoking a remote helper", async (t) => { + const f = await fixture(t); + const blobOid = git(f.root, "rev-parse", `${f.artifactCommit}:skills/demo/SKILL.md`); + const objectPath = join(f.root, ".git", "objects", blobOid.slice(0, 2), blobOid.slice(2)); + await unlink(objectPath); + const marker = join(f.root, "remote-helper-invoked"); + const helper = join(f.root, "remote-helper.sh"); + await writeFile(helper, `#!/bin/sh\ntouch '${marker}'\nexit 1\n`, { mode: 0o700 }); + await chmod(helper, 0o700); + git(f.root, "config", "extensions.partialClone", "origin"); + git(f.root, "config", "remote.origin.promisor", "true"); + git(f.root, "config", "remote.origin.partialclonefilter", "blob:none"); + git(f.root, "remote", "set-url", "origin", `ext::${helper}`); + await assert.rejects(new ExecGitReader().readBlob(f.root, f.artifactCommit, "skills/demo/SKILL.md"), /offline Git verification failed/); + await assert.rejects(realpath(marker), /ENOENT/); +}); + +test("repository verification fails if checkout device/inode changes between Git operations", async (t) => { + const f = await fixture(t); + const delegate = new ExecGitReader(); + let identityChecks = 0; + const changingIdentity = { + repositoryIdentity: async (path: string) => { + identityChecks += 1; + const identity = await delegate.repositoryIdentity(path); + return identityChecks === 1 ? identity : { ...identity, inode: identity.inode + 1 }; + }, + commitExists: delegate.commitExists.bind(delegate), + readBlob: delegate.readBlob.bind(delegate), + isAncestor: delegate.isAncestor.bind(delegate), + remoteUrl: delegate.remoteUrl.bind(delegate), + }; + await assert.rejects(verifyRepositoryControl({ + challengeFile: f.challengeFile, + forgeObservation: f.forgeObservation, + eventId: "repository-identity-drift", + sequence: 1, + occurredAt: "2026-07-18T12:00:00.000Z", + now: new Date("2026-07-18T12:30:00.000Z"), + git: changingIdentity, + repositories: f.repositories, + forgeSigners: f.forgeSigners, + }), /filesystem identity changed/); +}); From 42f814e1bb1cc1850f5749d784eed8e67face9cb Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:16:40 -0400 Subject: [PATCH 093/165] fix: harden attestation replay validation --- phase0/README.md | 32 ++++ phase0/src/attestation-cli.ts | 8 +- phase0/src/attestation-store.ts | 15 +- phase0/tests/attestation-store.test.ts | 250 ++++++++++++++++++++++++- 4 files changed, 297 insertions(+), 8 deletions(-) diff --git a/phase0/README.md b/phase0/README.md index 22545d9..495d5eb 100644 --- a/phase0/README.md +++ b/phase0/README.md @@ -59,6 +59,22 @@ higher evidence level and does not delete registration, event, conflict, or chain history. The `wallet_asserted` confirmed-proof floor cannot be created or revoked through the sidecar. +Signed repository, organization, challenge, resolution, and revocation +statements use domain-separated `v2` canonical JSON with fixed key order and +real JSON arrays. Free-text fields and URI arrays are never newline- or +comma-joined, so delimiter redistribution cannot preserve a signature while +changing semantics. Event timestamps must be nondecreasing. Organization +approval must follow active repository evidence and precede its event envelope; +resolution must follow a signed challenge; revocation must follow the active +evidence it removes. + +A repository statement hash, challenge nonce, wallet signature, and bound forge +observation are single-use credentials across the entire log. An organization +statement hash and signature are also single-use. Revocation does not make an +old credential reusable under a fresh event ID or sequence. Reactivation +requires genuinely fresh signed repository evidence and, where applicable, a +fresh organization approval. + Inspect evidence without a network or chain write: ```bash @@ -105,6 +121,22 @@ configuration fails with `repository snapshot mapping unavailable` before Git runs. This machine-local mapping must never be staged, copied into a bundle, or used as claimant evidence. +The verifier invokes the fixed absolute `/usr/bin/git` executable with a minimal +allow-listed environment. It does not inherit `PATH`, `GIT_DIR`, +`GIT_WORK_TREE`, object-directory, namespace, or config-injection variables. +Global and system config are disabled, replacement objects are ignored, all +protocol transports are disabled, and lazy fetching is disabled. Missing local +objects therefore fail offline rather than contacting a promisor remote. + +At mapping load, the verifier pins the checkout directory's device and inode. +It reopens and compares that identity before, between, and after external Git +operations. This detects ordinary checkout-path replacement, but the spike +cannot portably keep one directory file descriptor bound across every external +Git process. A privileged same-machine attacker capable of replacing and +restoring the path inside a single check-to-exec interval remains a residual +local-verifier risk. Production hardening would require a platform-specific +descriptor-bound execution boundary or an isolated immutable snapshot. + `repository_control_verified` means a trusted forge observer and a verifier-provisioned Git snapshot matched the wallet-signed bytes at an observation time. It does not prove current remote account ownership or diff --git a/phase0/src/attestation-cli.ts b/phase0/src/attestation-cli.ts index 719e931..6b02fd6 100644 --- a/phase0/src/attestation-cli.ts +++ b/phase0/src/attestation-cli.ts @@ -160,6 +160,7 @@ export async function createAttestationRuntime(input: AttestationRuntimeInput = const baseSubjects = registrationSubjectsFromManifest(manifest); const organizations = await organizationSigners(selectedPaths.organizations); const admins = await adminSigners(selectedPaths.admins); + const now = input.now ?? (() => new Date()); let contextPromise: Promise | null = null; const repositoryContext = () => { contextPromise ??= (async () => { @@ -184,29 +185,32 @@ export async function createAttestationRuntime(input: AttestationRuntimeInput = organizationSigners: organizations, adminSigners: admins, repositoryContext, - now: input.now ?? (() => new Date()), + now, store: new FileAttestationStore(selectedPaths.attestations, { baseSubjects, organizationSigners: organizations, adminSigners: admins, repositoryContextLoader: repositoryContext, + now, }), }; } async function loadIndex(runtime: AttestationRuntime): Promise { const events = await runtime.store.load(); + const now = runtime.now(); let verifier: ((event: Extract) => Promise) | undefined; if (events.some((event) => event.type === "repository_control_verified")) { const context = await runtime.repositoryContext(); const { reverifyRepositoryEvent } = await import("./attestation-git"); - verifier = (event) => reverifyRepositoryEvent(event, context); + verifier = (event) => reverifyRepositoryEvent(event, { ...context, now }); } return reduceAttestationEvents(events, { baseSubjects: runtime.baseSubjects, organizationSigners: runtime.organizationSigners, adminSigners: runtime.adminSigners, repositoryVerifier: verifier, + now, }); } diff --git a/phase0/src/attestation-store.ts b/phase0/src/attestation-store.ts index fbcc462..e2617c5 100644 --- a/phase0/src/attestation-store.ts +++ b/phase0/src/attestation-store.ts @@ -2,6 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { link, mkdir, open, rename, stat, unlink, type FileHandle } from "node:fs/promises"; import { dirname } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { parseAttestationEvent, @@ -26,9 +27,11 @@ export interface AttestationStoreOptions { forgeSigners?: Readonly>; git?: GitReader; repositoryContextLoader?: () => Promise; + now?: () => Date; hooks?: { afterLockCreated?(): void | Promise; beforeAppendWrite?(): void | Promise; + afterAppendSync?(): void | Promise; afterLockClaim?(claimPath: string): void | Promise; }; } @@ -167,13 +170,15 @@ export class FileAttestationStore { const bytes = Buffer.from(`${JSON.stringify(event)}\n`, "utf8"); await writeAll(handle, bytes); await handle.sync(); + await this.options.hooks?.afterAppendSync?.(); } finally { await handle?.close(); } await syncDirectory(dirname(this.path)); - const replayed = await this.loadUnlocked(); - if (replayed.length !== candidate.length || replayed.at(-1)?.eventId !== event.eventId) { - throw new Error("attestation append did not replay as the exact candidate event"); + const replayed = await this.readLogSnapshot(); + const candidateBytes = `${candidate.map((candidateEvent) => JSON.stringify(candidateEvent)).join("\n")}\n`; + if (replayed.bytes !== candidateBytes || !isDeepStrictEqual(replayed.events, candidate)) { + throw new Error("attestation append did not replay byte-for-byte as the exact candidate log"); } }); } @@ -208,13 +213,15 @@ export class FileAttestationStore { const hasRepositoryEvidence = events.some((event) => event.type === "repository_control_verified"); let context: AttestationRepositoryContext | null = null; if (hasRepositoryEvidence) context = await this.repositoryContext(); + const now = this.options.now?.(); await reduceAttestationEvents(events, { baseSubjects: this.options.baseSubjects, organizationSigners: this.options.organizationSigners, adminSigners: this.options.adminSigners, repositoryVerifier: context - ? (event: RepositoryControlEvent) => reverifyRepositoryEvent(event, context!) + ? (event: RepositoryControlEvent) => reverifyRepositoryEvent(event, { ...context!, now }) : undefined, + now, }); } diff --git a/phase0/tests/attestation-store.test.ts b/phase0/tests/attestation-store.test.ts index 4d95478..c2c29e8 100644 --- a/phase0/tests/attestation-store.test.ts +++ b/phase0/tests/attestation-store.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { chmod, mkdtemp, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { createHash, generateKeyPairSync, sign as signBytes } from "node:crypto"; +import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -8,14 +9,26 @@ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { canonicalChallengeEventStatement, + canonicalAdminEventStatement, + canonicalOrganizationStatement, canonicalRepositoryStatement, challengeEventStatementHash, deterministicConflictId, + adminEventStatementHash, + organizationStatementHash, repositoryStatementHash, + type AttestationRevokedEvent, type ChallengeOpenedEvent, + type OrganizationApprovedEvent, type RegistrationSubject, type RepositoryControlEvent, } from "../src/attestations"; +import { + canonicalChallengeFileBytes, + canonicalForgeObservationBytes, + type GitReader, + type TrustedRepositoryResolver, +} from "../src/attestation-git"; import { FileAttestationStore, writeAll } from "../src/attestation-store"; const IP_A = `0x${"a".repeat(40)}` as const; @@ -61,7 +74,105 @@ async function fixture(t: test.TestContext, hooks?: ConstructorParameters rm(directory, { recursive: true, force: true })); + const wallet = privateKeyToAccount(generatePrivateKey()); + const approver = privateKeyToAccount(generatePrivateKey()); + const admin = privateKeyToAccount(generatePrivateKey()); + const artifact = Buffer.from("# Persisted Skill\n\nExact replay bytes.\n"); + const artifactHash = `0x${createHash("sha256").update(artifact).digest("hex")}` as `0x${string}`; + const ipId = `0x${"c".repeat(40)}` as const; + const base: RegistrationSubject = { + registrationId: `eip155:1315:${ipId}`, + ipId, + wallet: wallet.address.toLowerCase() as `0x${string}`, + artifactHash, + declaredParentIpIds: [], + }; + const challenge = { + schemaVersion: 1 as const, + subject: base, + repositoryUrl: "https://github.com/example/persisted", + artifactCommitSha: "1".repeat(40), + artifactPath: "skills/demo/SKILL.md", + challengePath: "attestations/repository.json", + nonce: `0x${"2".repeat(64)}` as `0x${string}`, + issuedAt: "2026-07-18T10:00:00.000Z", + expiresAt: "2026-07-18T14:00:00.000Z", + }; + const signedChallenge = { + challenge, + statementHash: repositoryStatementHash(challenge), + signature: await wallet.signMessage({ message: canonicalRepositoryStatement(challenge) }), + }; + const challengeFile = canonicalChallengeFileBytes(signedChallenge); + const forge = generateKeyPairSync("ed25519"); + const unsignedObservation = { + schemaVersion: 1 as const, + repositoryId: "persisted", + repositoryUrl: challenge.repositoryUrl, + trustedRef: "refs/heads/main" as const, + proofCommitSha: "2".repeat(40), + challengeNonce: challenge.nonce, + observedAt: "2026-07-18T11:00:00.000Z", + forgeSignerId: "forge-1", + }; + const forgeObservation = { + ...unsignedObservation, + signature: signBytes(null, canonicalForgeObservationBytes(unsignedObservation), forge.privateKey).toString("base64"), + }; + const metadata = await stat(directory); + const trusted = { + repositoryId: "persisted", + repositoryUrl: challenge.repositoryUrl, + repositoryPath: directory, + repositoryDevice: metadata.dev, + repositoryInode: metadata.ino, + trustedRef: "refs/heads/main" as const, + permittedForgeSignerIds: ["forge-1"], + }; + const repositories: TrustedRepositoryResolver = { + resolve(repositoryId, repositoryUrl) { + if (repositoryId !== trusted.repositoryId || repositoryUrl !== trusted.repositoryUrl) throw new Error("untrusted repository"); + return trusted; + }, + }; + const git: GitReader = { + repositoryIdentity: async () => ({ device: metadata.dev, inode: metadata.ino }), + commitExists: async () => true, + isAncestor: async () => true, + remoteUrl: async () => challenge.repositoryUrl, + readBlob: async (_path, _commit, relativePath) => { + if (relativePath === challenge.artifactPath) return artifact; + if (relativePath === challenge.challengePath) return challengeFile; + throw new Error("unexpected blob path"); + }, + }; + const repositoryEvent: RepositoryControlEvent = { + type: "repository_control_verified", + eventId: "repository-1", + sequence: 1, + occurredAt: "2026-07-18T12:00:00.000Z", + subject: base, + challenge, + forgeObservation, + statementHash: signedChallenge.statementHash, + signature: signedChallenge.signature, + }; + const options = { + baseSubjects: [base], + organizationSigners: { "example-org": [approver.address.toLowerCase() as `0x${string}`] }, + adminSigners: { "admin-1": admin.address.toLowerCase() as `0x${string}` }, + repositories, + forgeSigners: { "forge-1": forge.publicKey.export({ type: "spki", format: "pem" }).toString() }, + git, + now: () => new Date("2026-07-18T14:00:00.000Z"), + }; + return { directory, path: join(directory, "attestations.jsonl"), wallet, approver, admin, base, repositoryEvent, options }; } test("absent store loads empty and append is newline-terminated and replay-validated", async (t) => { @@ -203,3 +314,138 @@ test("event log rejects symlinks and non-owner-only modes", async (t) => { await chmod(f.path, 0o644); await assert.rejects(f.store.load(), /mode 0600/); }); + +test("post-append replay compares every byte and event, not only length and last ID", async (t) => { + let appendSyncs = 0; + let replacementBytes = ""; + const f = await fixture(t, { + afterAppendSync: async () => { + appendSyncs += 1; + if (appendSyncs === 2) await writeFile(f.path, replacementBytes, { mode: 0o600 }); + }, + }); + await f.store.append(f.event); + const alternateBase = { + ...f.event, + eventId: "alternate-first", + evidenceUris: ["https://example.com/alternate"], + statementHash: ARTIFACT_HASH, + signature: "0x00" as `0x${string}`, + }; + const alternate: ChallengeOpenedEvent = { + ...alternateBase, + statementHash: challengeEventStatementHash(alternateBase), + signature: await f.second.signMessage({ message: canonicalChallengeEventStatement({ + ...alternateBase, + statementHash: challengeEventStatementHash(alternateBase), + }) }), + }; + const secondBase = { + ...f.event, + eventId: "final-event", + sequence: 2, + occurredAt: "2026-07-18T13:00:00.000Z", + evidenceUris: ["https://example.com/final"], + statementHash: ARTIFACT_HASH, + signature: "0x00" as `0x${string}`, + }; + const second: ChallengeOpenedEvent = { + ...secondBase, + statementHash: challengeEventStatementHash(secondBase), + signature: await f.second.signMessage({ message: canonicalChallengeEventStatement({ + ...secondBase, + statementHash: challengeEventStatementHash(secondBase), + }) }), + }; + replacementBytes = `${JSON.stringify(alternate)}\n${JSON.stringify(second)}\n`; + await assert.rejects(f.store.append(second), /exact candidate|byte-for-byte/); +}); + +test("repository credentials remain consumed across close, reopen, and revocation", async (t) => { + const f = await repositoryFixture(t); + const store = new FileAttestationStore(f.path, f.options); + await store.append(f.repositoryEvent); + const reopened = new FileAttestationStore(f.path, f.options); + const beforeReplay = await readFile(f.path); + await assert.rejects(reopened.append({ + ...f.repositoryEvent, + eventId: "repository-replay", + sequence: 2, + occurredAt: "2026-07-18T13:00:00.000Z", + }), /already consumed/); + assert.deepEqual(await readFile(f.path), beforeReplay); + + const revocationBase = { + type: "attestation_revoked" as const, + eventId: "repository-revoked", + sequence: 2, + occurredAt: "2026-07-18T13:00:00.000Z", + registrationId: f.base.registrationId, + level: "repository_control_verified" as const, + reason: "The pinned snapshot is no longer trusted.", + adminSignerId: "admin-1", + statementHash: ARTIFACT_HASH, + signature: "0x00" as `0x${string}`, + }; + const revocationHash = adminEventStatementHash(revocationBase); + const revocation: AttestationRevokedEvent = { + ...revocationBase, + statementHash: revocationHash, + signature: await f.admin.signMessage({ message: canonicalAdminEventStatement({ ...revocationBase, statementHash: revocationHash }) }), + }; + await reopened.append(revocation); + const afterRevocation = new FileAttestationStore(f.path, f.options); + await assert.rejects(afterRevocation.append({ + ...f.repositoryEvent, + eventId: "repository-after-revocation", + sequence: 3, + occurredAt: "2026-07-18T14:00:00.000Z", + }), /already consumed/); +}); + +test("organization approval credentials remain consumed after close and reopen", async (t) => { + const f = await repositoryFixture(t); + const store = new FileAttestationStore(f.path, f.options); + await store.append(f.repositoryEvent); + const unsignedApproval = { + schemaVersion: 1 as const, + subject: f.base, + organizationId: "example-org", + approverWallet: f.approver.address.toLowerCase() as `0x${string}`, + role: "ip_admin" as const, + approvedAt: "2026-07-18T12:30:00.000Z", + }; + const approval = { + ...unsignedApproval, + statementHash: organizationStatementHash(unsignedApproval), + signature: await f.approver.signMessage({ message: canonicalOrganizationStatement(unsignedApproval) }), + }; + const organization: OrganizationApprovedEvent = { + type: "organization_approved", + eventId: "organization-1", + sequence: 2, + occurredAt: "2026-07-18T13:00:00.000Z", + subject: f.base, + approval, + }; + await store.append(organization); + const reopened = new FileAttestationStore(f.path, f.options); + const beforeReplay = await readFile(f.path); + await assert.rejects(reopened.append({ + ...organization, + eventId: "organization-replay", + sequence: 3, + occurredAt: "2026-07-18T14:00:00.000Z", + }), /already consumed/); + assert.deepEqual(await readFile(f.path), beforeReplay); +}); + +test("store replay applies the injected verifier clock deterministically", async (t) => { + const f = await fixture(t); + const clocked = new FileAttestationStore(f.path, { + baseSubjects: [f.a, f.b], + now: () => new Date("2026-07-18T11:00:00.000Z"), + }); + await assert.rejects(clocked.append(f.event), /future-dated/); + await assert.rejects(readFile(f.path), /ENOENT/); +}); From af2f455fd53fb85c08018444ce19a00b8db408dc Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:24:13 -0400 Subject: [PATCH 094/165] fix: close Collar adversarial review gaps --- spikes/pi-wielder/README.md | 13 +-- spikes/pi-wielder/e2e.mjs | 2 +- spikes/pi-wielder/src/collar.mjs | 8 +- spikes/pi-wielder/src/gateway.mjs | 16 +++- spikes/pi-wielder/src/invocation-journal.mjs | 82 ++++++++++++++----- spikes/pi-wielder/src/proxy.mjs | 36 +++++--- spikes/pi-wielder/src/x402-seller.mjs | 2 +- .../pi-wielder/tests/collar-failure.test.mjs | 45 ++++++++++ .../tests/gateway-transport.test.mjs | 19 ++++- .../tests/invocation-journal.test.mjs | 25 ++++++ spikes/pi-wielder/tests/proxy-trust.test.mjs | 72 +++++++++++++++- 11 files changed, 273 insertions(+), 47 deletions(-) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index d0d6eca..c1b41e9 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -60,8 +60,9 @@ as a unified authoritative ledger. ## Failure and refund semantics -- Settlement success is never erased by a later `400`, `404`, or `500` execution - outcome. Exact terminal replay preserves that HTTP status. +- Settlement success is never erased by a later `400` or `500` execution outcome. + Exact terminal replay preserves that HTTP status. An unknown Skill is rejected with + `404` before the Collar offers or claims payment. - If the Skill executor fails after settlement, the receipt records one full-gross `pending_cogs_reconciliation` hold. No Royalty or treasury claim is finalized. - A response lost after the provider returns leaves the durable execution attempt @@ -99,9 +100,9 @@ Both sellers use an explicitly constructed facilitator transport. Offline tests inject src/facilitator-mock.mjs; no arbitrary URL is accepted. ``` -The proxy contains wallet signing and HTTP 402 retry behavior but no Story SDK, token -custody, or Royalty calculator. That is the constructive ADR-0008 claim: the Wielder is -a wallet boundary, not the coding harness. +The proxy demonstrates the wallet-bound HTTP 402 transport shape contemplated by +ADR-0008, but it contains no Story SDK, token custody, Royalty calculator, or Plan 6 +payment policy. It is not proof of the complete protocol or production readiness. ## Run the verified path @@ -111,7 +112,7 @@ npm test npm run e2e ``` -Expected current results are 57 offline unit/integration tests and 24 offline e2e +Expected current results are 62 offline unit/integration tests and 24 offline e2e checks. Counts can increase as regressions are added; zero failures is the contract. The e2e labels all timing output synthetic and uses in-process Hono requests only. diff --git a/spikes/pi-wielder/e2e.mjs b/spikes/pi-wielder/e2e.mjs index da33f98..804bcbb 100644 --- a/spikes/pi-wielder/e2e.mjs +++ b/spikes/pi-wielder/e2e.mjs @@ -120,7 +120,7 @@ ok([ 'The one rule that makes this skill worth invoking', 'The seven ingredients', skillContent.slice(0, 400), -].every((fingerprint) => !responseBytes.includes(fingerprint)), 'hosted Skill artifact never crosses the Collar'); +].every((fingerprint) => !responseBytes.includes(fingerprint)), 'mock response omits tested direct Skill artifact fingerprints and bytes'); const entries = proxy.ledger.entries; eq(entries.length, 3, 'Wielder view has three settled calls'); diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index b2b2f35..b319536 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -456,6 +456,11 @@ export function createCollar({ app.post( '/invoke/:skillId', + async (c, next) => { + const skillId = c.req.param('skillId'); + if (skillId !== SKILL_ID) return c.json({ error: `unknown Skill '${skillId}'` }, 404); + await next(); + }, x402Paywall({ price: priceUsdc, payTo, @@ -487,9 +492,6 @@ export function createCollar({ return c.json({ error: message, receipt: journal.issueReceipt(key) }, status); }; - if (c.req.param('skillId') !== SKILL_ID) { - return finishFailure('UNKNOWN_SKILL', `unknown skill '${c.req.param('skillId')}'`, 404); - } const body = await c.req.json().catch(() => null); if (typeof body?.input !== 'string' || !body.input) { return finishFailure('INVALID_REQUEST', 'body must be JSON: { "input": "..." }', 400); diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index 807ea77..8f26820 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -192,8 +192,20 @@ async function viaOpenAI(body) { export function startGateway({ port = 0, ...opts } = {}) { const app = createGateway(opts); return new Promise((resolve) => { - const server = serve({ fetch: app.fetch, port }, (info) => { - resolve({ url: `http://127.0.0.1:${info.port}`, port: info.port, close: () => server.close() }); + const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) => { + let closePromise = null; + const close = () => { + closePromise ??= new Promise((closeResolve, closeReject) => { + server.close((error) => (error ? closeReject(error) : closeResolve())); + }); + return closePromise; + }; + resolve({ + url: `http://127.0.0.1:${info.port}`, + port: info.port, + address: info.address, + close, + }); }); }); } diff --git a/spikes/pi-wielder/src/invocation-journal.mjs b/spikes/pi-wielder/src/invocation-journal.mjs index 770b2c3..be7489e 100644 --- a/spikes/pi-wielder/src/invocation-journal.mjs +++ b/spikes/pi-wielder/src/invocation-journal.mjs @@ -258,13 +258,57 @@ export function receiptKeyId(publicKey) { .digest('hex')}`; } +function normalizeReceiptSigner(signer, { requirePersistent = false } = {}) { + if (!signer || typeof signer !== 'object' || typeof signer.signHash !== 'function') { + throw new Error('receipt signer must provide signHash'); + } + if (signer.algorithm !== 'Ed25519') { + throw new Error("receipt signer algorithm must be 'Ed25519'"); + } + let publicKey; + try { + publicKey = crypto.createPublicKey(signer.publicKeyPem); + } catch (error) { + throw new Error('receipt signer must provide a valid public key', { cause: error }); + } + if (publicKey.asymmetricKeyType !== 'ed25519') { + throw new Error('receipt signer public key must be Ed25519'); + } + const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const keyId = receiptKeyId(publicKey); + if (signer.keyId !== keyId) { + throw new Error('receipt signer key ID must be the SPKI-derived SHA-256 identifier'); + } + if (requirePersistent && signer.persistent !== true) { + throw new Error('persistent journal refuses an ephemeral receipt signer'); + } + return Object.freeze({ + algorithm: 'Ed25519', + publicKeyPem, + keyId, + persistent: signer.persistent === true, + signHash: (hashHex) => signer.signHash.call(signer, hashHex), + }); +} + +function verifyHashSignature(hashHex, signature, publicKey) { + if (!/^[0-9a-f]{64}$/.test(String(hashHex ?? '')) || typeof signature !== 'string') return false; + const signatureBytes = Buffer.from(signature, 'base64'); + if (signatureBytes.length !== 64 || signatureBytes.toString('base64') !== signature) return false; + try { + return crypto.verify(null, Buffer.from(hashHex, 'hex'), publicKey, signatureBytes); + } catch { + return false; + } +} + export function createReceiptSigner(keys = {}, { persistent = false } = {}) { const pair = keys.privateKey && keys.publicKey ? { privateKey: keys.privateKey, publicKey: keys.publicKey } : crypto.generateKeyPairSync('ed25519'); const publicKeyPem = pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); const keyId = receiptKeyId(pair.publicKey); - return Object.freeze({ + return normalizeReceiptSigner({ algorithm: 'Ed25519', publicKeyPem, keyId, @@ -311,12 +355,9 @@ export function verifySignedReceipt(bundle, { publicKeyPem, keyId }) { if (bundle?.algorithm !== 'Ed25519' || bundle.keyId !== keyId) return false; const expectedHash = crypto.createHash('sha256').update(canonicalJson(bundle.receipt)).digest('hex'); if (bundle.receiptHash !== expectedHash) return false; - return crypto.verify( - null, - Buffer.from(expectedHash, 'hex'), - crypto.createPublicKey(publicKeyPem), - Buffer.from(bundle.signature, 'base64'), - ); + const publicKey = crypto.createPublicKey(publicKeyPem); + return publicKey.asymmetricKeyType === 'ed25519' + && verifyHashSignature(expectedHash, bundle.signature, publicKey); } catch { return false; } @@ -434,14 +475,15 @@ export function createInvocationJournal({ if (journalPath && journalPath === canonicalSigningKeyPath) { throw new Error('journal and signing key paths must differ'); } - if (journalPath && signer && signer.persistent !== true) { - throw new Error('persistent journal refuses an ephemeral receipt signer'); - } + const injectedSigner = signer + ? normalizeReceiptSigner(signer, { requirePersistent: Boolean(journalPath) }) + : null; const diskSigner = journalPath ? loadOrCreateReceiptSigner(canonicalSigningKeyPath) : null; - if (signer && diskSigner && signer.keyId !== diskSigner.keyId) { + if (injectedSigner && diskSigner && injectedSigner.keyId !== diskSigner.keyId) { throw new Error('injected receipt signer does not match the persistent signing key'); } - const receiptSigner = signer ?? diskSigner ?? createReceiptSigner(); + const receiptSigner = injectedSigner ?? diskSigner ?? createReceiptSigner(); + const receiptPublicKey = crypto.createPublicKey(receiptSigner.publicKeyPem); const records = new Map(); const settlementReferences = new Map(); const transactionHashes = new Map(); @@ -729,12 +771,10 @@ export function createInvocationJournal({ const { eventHash, eventSignature, ...unsigned } = event; const expectedHash = calculateEventHash(unsigned); if (eventHash !== expectedHash) throw new Error(`journal event hash mismatch at ${index + 1}`); - if (event.keyId !== receiptSigner.keyId || !crypto.verify( - null, - Buffer.from(eventHash, 'hex'), - crypto.createPublicKey(receiptSigner.publicKeyPem), - Buffer.from(eventSignature, 'base64'), - )) throw new Error(`journal event signature mismatch at ${index + 1}`); + if (event.keyId !== receiptSigner.keyId + || !verifyHashSignature(eventHash, eventSignature, receiptPublicKey)) { + throw new Error(`journal event signature mismatch at ${index + 1}`); + } previousHash = eventHash; return event; }); @@ -782,10 +822,14 @@ export function createInvocationJournal({ keyId: receiptSigner.keyId, }; const eventHash = calculateEventHash(unsigned); + const eventSignature = receiptSigner.signHash(eventHash); + if (!verifyHashSignature(eventHash, eventSignature, receiptPublicKey)) { + throw new Error('generated journal event signature does not match the persistent receipt key'); + } const event = { ...unsigned, eventHash, - eventSignature: receiptSigner.signHash(eventHash), + eventSignature, }; // Validation must happen before the first durable byte. Replay must // never encounter an event that this process already knew was invalid. diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index 50ebd7b..3814789 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -1,16 +1,12 @@ -// proxy.mjs — THE WIELDER. +// proxy.mjs — Wielder-side payment transport skeleton for this spike. // // ╔══════════════════════════════════════════════════════════════════════════╗ -// ║ THIS FILE IS THE ENTIRE WIELDER-SIDE PROTOCOL FOOTPRINT. ║ +// ║ THIS FILE COVERS THE SPIKE'S X402 CHALLENGE, SIGN, AND RETRY TRANSPORT. ║ // ║ ║ -// ║ Everything a client needs in order to consume BOTH asset classes ║ -// ║ (per-call model inference AND hosted-skill invocations) is below: ║ -// ║ answer HTTP 402 with a signed USDC payment and retry. No Story SDK, ║ -// ║ no token custody, no chain reads. The harness (Pi) never sees any of ║ -// ║ it — it just talks OpenAI-compatible HTTP to localhost. That is ║ -// ║ ADR-0008 ("the Wielder is a wallet, not a harness") proved by ║ -// ║ construction. Precedent: BlockRun's ClawRouter runs the same paying- ║ -// ║ proxy pattern for OpenClaw on port 8402. ║ +// ║ It also verifies pinned Collar receipts and maintains a payer-local ║ +// ║ receipt view. It is not the complete protocol, accounting authority, ║ +// ║ custody design, or proof that ADR-0008 is production-ready. Plan 6 ║ +// ║ payment policy is not implemented here. ║ // ╚══════════════════════════════════════════════════════════════════════════╝ import crypto from 'node:crypto'; @@ -38,7 +34,7 @@ const EIP3009_TYPES = { const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64'); const unb64 = (s) => JSON.parse(Buffer.from(s, 'base64').toString('utf8')); -// The whole buyer protocol: request -> 402 -> sign EIP-3009 -> retry once. +// Buyer transport loop used by this spike: request -> 402 -> sign EIP-3009 -> retry once. // Returns the accepted quote/authorization identity with the response. Timings are the spike's // payment-overhead measurement (402 roundtrip + sign + facilitator). export async function payingFetch(account, url, init, { @@ -286,8 +282,22 @@ export function createProxy({ export function startProxy({ port = 0, ...opts } = {}) { const { app, ledger, account } = createProxy(opts); return new Promise((resolve) => { - const server = serve({ fetch: app.fetch, port }, (info) => { - resolve({ url: `http://127.0.0.1:${info.port}`, port: info.port, ledger, account, close: () => server.close() }); + const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) => { + let closePromise = null; + const close = () => { + closePromise ??= new Promise((closeResolve, closeReject) => { + server.close((error) => (error ? closeReject(error) : closeResolve())); + }); + return closePromise; + }; + resolve({ + url: `http://127.0.0.1:${info.port}`, + port: info.port, + address: info.address, + ledger, + account, + close, + }); }); }); } diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index 372dcc6..a7d511e 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -297,7 +297,7 @@ export function x402Paywall({ try { const verify = await postJson(transport, 'verify', facilitatorBody); if (!verify?.isValid) { - const reason = `payment verification failed: ${verify?.invalidReason ?? 'unknown'}`; + const reason = 'payment verification failed'; await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); return c.json({ x402Version: X402_VERSION, diff --git a/spikes/pi-wielder/tests/collar-failure.test.mjs b/spikes/pi-wielder/tests/collar-failure.test.mjs index 4a26f68..b75c3df 100644 --- a/spikes/pi-wielder/tests/collar-failure.test.mjs +++ b/spikes/pi-wielder/tests/collar-failure.test.mjs @@ -706,6 +706,51 @@ test('Skill provider and settlement resolver secrets are replaced with stable pu assert.doesNotMatch(await resolution.text(), new RegExp(resolverSecret)); }); +test('facilitator verification detail is absent from the response and durable journal', async () => { + const secret = 'verify-invalidReason-secret-sentinel'; + let settleCalls = 0; + let executionCalls = 0; + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'collar-verify-secret-'))); + const journalFile = path.join(directory, 'events.jsonl'); + const signingKeyFile = path.join(directory, 'receipt-key.pem'); + const collar = createCollar({ + facilitatorTransport: createMockFacilitatorTransport(async (url) => { + if (new URL(url).pathname === '/verify') { + return new Response(JSON.stringify({ isValid: false, invalidReason: secret }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + settleCalls += 1; + throw new Error('settlement must not run after rejected verification'); + }), + journalFile, + signingKeyFile, + executeSkill: async () => { + executionCalls += 1; + return { output: 'must not run' }; + }, + }); + const result = await payingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey: 'idem-verifier-secret', + fetchImpl: (url, init) => collar.app.request(url, init), + }); + assert.equal(result.res.status, 402); + const responseText = await result.res.text(); + assert.doesNotMatch(responseText, new RegExp(secret)); + assert.equal(JSON.parse(responseText).error, 'payment verification failed'); + const record = collar.journal.getByIdempotencyKey('idem-verifier-secret'); + assert.equal(record.payment.state, 'rejected'); + assert.equal(record.payment.reason, 'payment verification failed'); + const durableBytes = fs.readFileSync(journalFile, 'utf8'); + assert.doesNotMatch(durableBytes, new RegExp(secret)); + assert.match(durableBytes, /payment verification failed/); + assert.equal(settleCalls, 0); + assert.equal(executionCalls, 0); +}); + test('Anthropic error response bodies are never copied into the failed receipt', async () => { const responseSecret = 'sk-ant-secret-inside-upstream-body'; const previousFetch = globalThis.fetch; diff --git a/spikes/pi-wielder/tests/gateway-transport.test.mjs b/spikes/pi-wielder/tests/gateway-transport.test.mjs index c13ddfd..b9cfa01 100644 --- a/spikes/pi-wielder/tests/gateway-transport.test.mjs +++ b/spikes/pi-wielder/tests/gateway-transport.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createMockFacilitator } from '../src/facilitator-mock.mjs'; -import { createGateway, MODEL_PRICES_USDC } from '../src/gateway.mjs'; +import { createGateway, MODEL_PRICES_USDC, startGateway } from '../src/gateway.mjs'; import { payingFetch } from '../src/proxy.mjs'; import { throwawayAccount } from '../src/wallet.mjs'; import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; @@ -32,3 +32,20 @@ test('gateway rejects an unapproved structural transport or legacy facilitator U }), /approved live or injected-mock/); assert.throws(() => createGateway({ facilitatorUrl: 'https://evil.test', mockLlm: true }), /facilitatorTransport/); }); + +test('gateway listener binds only IPv4 loopback and closes cleanly', async () => { + const facilitator = createMockFacilitator(); + const gateway = await startGateway({ + facilitatorTransport: createMockFacilitatorTransport( + (url, init) => facilitator.request(url, init), + ), + mockLlm: true, + }); + try { + assert.equal(gateway.address, '127.0.0.1'); + assert.equal(new URL(gateway.url).hostname, '127.0.0.1'); + assert.equal((await fetch(`${gateway.url}/healthz`)).status, 200); + } finally { + await gateway.close(); + } +}); diff --git a/spikes/pi-wielder/tests/invocation-journal.test.mjs b/spikes/pi-wielder/tests/invocation-journal.test.mjs index 51e6bfa..46d33f3 100644 --- a/spikes/pi-wielder/tests/invocation-journal.test.mjs +++ b/spikes/pi-wielder/tests/invocation-journal.test.mjs @@ -268,6 +268,31 @@ test('a rejected transition is validated before append and leaves durable bytes assert.equal(reopened.getByIdempotencyKey(declaration.idempotencyKey).execution.state, 'executing'); }); +test('a malicious persistent signer cannot write an unverifiable first event', () => { + const { filePath, signingKeyPath } = temporaryAuthority('collar-malicious-signer-'); + const authority = loadOrCreateReceiptSigner(signingKeyPath); + const maliciousSigner = Object.freeze({ + persistent: true, + algorithm: 'Ed25519', + publicKeyPem: authority.publicKeyPem, + keyId: authority.keyId, + signHash: () => Buffer.alloc(64, 0x5a).toString('base64'), + }); + const journal = createInvocationJournal({ filePath, signingKeyPath, signer: maliciousSigner }); + assert.throws( + () => journal.requestInvocation(declaration), + /generated journal event signature does not match the persistent receipt key/, + ); + assert.deepEqual(journal.events, []); + assert.equal(fs.existsSync(filePath), false); + assert.equal(fs.existsSync(`${filePath}.lock`), false); + + const reopened = createInvocationJournal({ filePath, signingKeyPath }); + assert.deepEqual(reopened.events, []); + assert.doesNotThrow(() => reopened.requestInvocation(declaration)); + assert.equal(reopened.events.length, 1); +}); + test('persistent authority rejects checkout, symlink, relative, non-file, and broad-permission paths', () => { const { directory, filePath, signingKeyPath } = temporaryAuthority('collar-paths-'); const journal = createInvocationJournal({ filePath, signingKeyPath }); diff --git a/spikes/pi-wielder/tests/proxy-trust.test.mjs b/spikes/pi-wielder/tests/proxy-trust.test.mjs index 38ac0ba..567abac 100644 --- a/spikes/pi-wielder/tests/proxy-trust.test.mjs +++ b/spikes/pi-wielder/tests/proxy-trust.test.mjs @@ -8,7 +8,12 @@ import test from 'node:test'; import { createCollar, SKILL_ID } from '../src/collar.mjs'; import { createMockFacilitator } from '../src/facilitator-mock.mjs'; import { canonicalJson, createReceiptSigner, verifySignedReceipt } from '../src/invocation-journal.mjs'; -import { assertReceiptMatchesPayment, createProxy, loadPinnedCollarTrust } from '../src/proxy.mjs'; +import { + assertReceiptMatchesPayment, + createProxy, + loadPinnedCollarTrust, + startProxy, +} from '../src/proxy.mjs'; import { throwawayAccount } from '../src/wallet.mjs'; import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; @@ -165,3 +170,68 @@ test('a settled Skill failure is cached without inventing finalized treasury or assert.equal(rendered.status, 200); assert.match(await rendered.text(), /\[failed\]/); }); + +test('proxy listener binds only IPv4 loopback and closes cleanly', async () => { + const signer = createReceiptSigner(); + const proxy = await startProxy({ + account: throwawayAccount(), + trustedCollarPublicKeyPem: signer.publicKeyPem, + trustedCollarKeyId: signer.keyId, + }); + try { + assert.equal(proxy.address, '127.0.0.1'); + assert.equal(new URL(proxy.url).hostname, '127.0.0.1'); + assert.equal((await fetch(`${proxy.url}/ledger?format=json`)).status, 200); + } finally { + await proxy.close(); + } +}); + +test('unknown Skill fails before payment through both Collar and proxy', async () => { + let facilitatorCalls = 0; + let settlementCalls = 0; + let executionCalls = 0; + const facilitator = createMockFacilitator(); + const collar = createCollar({ + facilitatorTransport: createMockFacilitatorTransport(async (url, init) => { + facilitatorCalls += 1; + if (new URL(url).pathname === '/settle') settlementCalls += 1; + return facilitator.request(url, init); + }), + executeSkill: async () => { + executionCalls += 1; + return { output: 'must not run' }; + }, + }); + const unknownUrl = 'http://collar.test/invoke/not-a-known-skill'; + const direct = await collar.app.request(unknownUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'must remain unpaid' }), + }); + assert.equal(direct.status, 404); + assert.deepEqual(await direct.json(), { error: "unknown Skill 'not-a-known-skill'" }); + + const proxy = createProxy({ + account: throwawayAccount(), + collarUrl: 'http://collar.test', + collarFetch: (url, init) => collar.app.request(url, init), + gatewayFetch: async () => { throw new Error('model gateway must not run'); }, + trustedCollarPublicKeyPem: collar.journal.signingPublicKeyPem, + trustedCollarKeyId: collar.journal.signingKeyId, + }); + const forwarded = await proxy.app.request('http://proxy.test/invoke/not-a-known-skill', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'still unpaid' }), + }); + assert.equal(forwarded.status, 404); + const forwardedBody = await forwarded.json(); + assert.deepEqual(forwardedBody, { error: "unknown Skill 'not-a-known-skill'" }); + assert.equal('receipt' in forwardedBody, false); + assert.equal(facilitatorCalls, 0); + assert.equal(settlementCalls, 0); + assert.equal(executionCalls, 0); + assert.equal(collar.journal.events.length, 0); + assert.equal(proxy.ledger.entries.length, 0); +}); From 9d0ccd93b2a5c4d2d659a52b3a4bab3503093ce9 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:25:42 -0400 Subject: [PATCH 095/165] fix: close internal award lifecycle trust gaps --- spikes/internal-invocation-awards/README.md | 63 +- spikes/internal-invocation-awards/demo.mjs | 72 +- .../internal-invocation-awards/src/budget.mjs | 24 +- .../src/credentials.mjs | 160 +++- .../internal-invocation-awards/src/engine.mjs | 547 ++++++++++---- .../src/receipt-ledger.mjs | 74 ++ .../internal-invocation-awards/src/schema.mjs | 112 ++- .../src/statements.mjs | 384 +++++++++- .../test/budget.test.mjs | 30 + .../test/engine.test.mjs | 691 ++++++++++++------ .../test/receipt-ledger.test.mjs | 49 ++ .../test/statements.test.mjs | 246 ++++++- 12 files changed, 2026 insertions(+), 426 deletions(-) create mode 100644 spikes/internal-invocation-awards/src/receipt-ledger.mjs create mode 100644 spikes/internal-invocation-awards/test/receipt-ledger.test.mjs diff --git a/spikes/internal-invocation-awards/README.md b/spikes/internal-invocation-awards/README.md index db7d952..034d29a 100644 --- a/spikes/internal-invocation-awards/README.md +++ b/spikes/internal-invocation-awards/README.md @@ -22,6 +22,8 @@ The accounting flow is: ```text signed employer budget authorization + -> resolve an active engine-provisioned Skill-version registration + -> verify a trusted, nonce-bound initiating-principal attestation -> atomically reserve quote maximum -> return exact unsigned Execution-credential payload -> caller signs that persisted payload @@ -30,7 +32,8 @@ signed employer budget authorization -> shared atomic-money kernel partitions actual gross -> release unused reservation -> record employee-Creator Invocation award - -> sign one receipt and one full economic statement + -> atomically sign and commit terminal state plus one scoped receipt + -> sign one full economic statement ``` The successful example reserves `3.050000 USD`, records `0.700000 USD` execution @@ -40,10 +43,10 @@ reported COGS, the quote-final fee and reserve, and the authorized maximum award persisted atomic amounts are non-negative decimal strings. Arithmetic converts them to `bigint`; no floating-point value participates in money arithmetic. -The gross partition is imported from -`prototype/atomic-money.mjs#allocateInternalGross`. This spike does not implement a -second fee, remainder, or account-allocation formula. It consumes the kernel-returned -account-identified journal entries. +Successful gross partitioning and known-failure COGS allocation are imported from +`prototype/atomic-money.mjs`. This spike does not implement a second fee, remainder, +or account-allocation formula. A known failure records exactly one shared-kernel +`execution-cogs` double-entry; cancellation and unresolved holds remain journal-free. The result requires an authorized internal **Wielder**, but no external Wielder. It creates neither an external **Royalty claim** credit nor a circular employer @@ -62,8 +65,20 @@ Version 1 accepts only the immutable 100% residual award rule: Variable award rates and any destination for non-award residual are not implemented. Policy and budget authorization are immutable, effective-dated, expiry-bounded, and -denomination-neutral. A self Invocation is exactly one whose `creatorId` equals its -`wielderId`; its manager approval is a separate signed object, never a quote field. +denomination-neutral. Canonical policy bytes are hashed into the budget authorization, +quote, Execution credential, Invocation, award, and receipt, so changing a policy +under the same ID and version fails closed. A self Invocation is exactly one whose +trusted initiating principal equals its `creatorId`; the shared agent Wielder is not +treated as the human principal. Its manager approval is a separate signed object, +never a quote field. + +The engine is provisioned with an immutable `(skillId, skillVersionHash)` registration +map that binds the canonical Creator and employer. Missing, expired, revoked, +wrong-Creator, or wrong-employer registrations fail before reservation and are +rechecked before Execution. Callers cannot inject registration mappings, clocks, +public keys, or receipt-signing capabilities into lifecycle requests. The initiating +principal is separately attested by a provisioned identity signer; its nonce and exact +Invocation bindings prevent replay even when many employees share one agent Wielder. The store is a serialized, single-process CAS demonstration. It is not a distributed database lock. The engine uses exact global, budget, Invocation, reservation, and @@ -80,6 +95,9 @@ The tested pre-execution rejection set includes: - malformed atomic amount, Skill hash, quote total, or credential nonce; - unauthorized Skill, Creator, Wielder, Beneficiary, cost center, signer, or authorizer; +- missing, revoked, expired, or mismatched Skill-version registration; +- untrusted, altered, expired, mismatched, or replayed initiating-principal + attestation; - unknown, embedded, or untrusted public-key material; - insufficient remaining budget or exceeded per-Invocation or period award cap; - stale budget/engine/record revision, duplicate idempotency key, nonce, reservation, @@ -113,19 +131,27 @@ reconciliation of an unresolved hold is a human-only future gate. ## Receipts and statements Every terminal success, known failure, unresolved Execution, or pre-execution -cancellation receives one independent, monotonic receipt sequence. A trusted receipt -key ID selects the provisioned verification key; receipts and requests cannot inject -key material. Receipt canonical bytes bind the Invocation, reservation, Skill hash, -effective policy, outcome, atomic totals, kernel journal entries, and absence of an -external settlement. +cancellation receives one monotonic receipt sequence scoped by employer, Creator, +denomination, and atomic scale. Terminal state, signed receipt, receipt hash, and +scoped sequence advancement commit in one serialized transaction. A terminal retry +returns the same persisted receipt without calling the executor again; a signing or +commit failure leaves no terminal state or receipt. A trusted receipt key ID selects +the provisioned verification key; receipts and lifecycle requests cannot inject key +material. Receipt canonical bytes bind the Invocation, reservation, Skill registration, +initiating-principal attestation, Skill hash, canonical policy hash, outcome, atomic +totals, kernel journal entries, and absence of an external settlement. Employer and employee verify the same signed receipt bytes. They also verify a separate whole-statement signature that binds: - identity, denomination, period, and payable opening balance; -- ordered receipt hashes and contiguous sequence bounds; +- prior statement hash and authenticated prior closing balance; +- ordered receipt hashes, current sequence bounds, and a cumulative scoped receipt + cursor that survives receipt-free periods; - reservation, release, charge, and earned-award audit totals; - the complete payable-advance, reversal, and payment arrays; +- cumulative event IDs and payment rail references, preventing renamed cross-period + replay; - payable and non-payable reversal semantics; and - the closing payable balance. @@ -133,13 +159,16 @@ An earned-but-unpaid award is not yet payable. It affects `earnedAwardTotalAtomic`, but does not enter `closingPayableAtomic` until a separately authenticated payable-advance record is present. A reversal declares whether it changes only earned accounting or an already-advanced payable balance. Payments -cannot exceed the authenticated payable balance. +cannot exceed the authenticated payable balance. Advance, reversal, and payment +timestamps must fall within the signed statement period. A later statement may cite +an authenticated historical receipt for an advance, reversal, or payment without +recounting that receipt's prior-period economics. The receipt inclusion root uses domain-separated binary SHA-256 leaves and internal nodes. Odd levels duplicate the last node. The empty set has a fixed -domain-separated root. This is an inclusion root, not a completeness proof; sequence -continuity, the signed ordered hash list, and employer/employee comparison supply the -completeness signal. Individually signed receipts do not authenticate a mutable +domain-separated root. This is an inclusion root, not a completeness proof; the +cross-period receipt cursor, signed ordered hash list, and employer/employee comparison +supply the completeness signal. Individually signed receipts do not authenticate a mutable statement shell—the trusted whole-statement signature and deterministic recomputation are both required. diff --git a/spikes/internal-invocation-awards/demo.mjs b/spikes/internal-invocation-awards/demo.mjs index 89aff1d..48df4c8 100644 --- a/spikes/internal-invocation-awards/demo.mjs +++ b/spikes/internal-invocation-awards/demo.mjs @@ -1,21 +1,21 @@ import assert from 'node:assert/strict'; -import { generateKeyPairSync } from 'node:crypto'; +import { generateKeyPairSync, sign as cryptoSign } from 'node:crypto'; import { signBudget } from './src/budget.mjs'; -import { signCredential } from './src/credentials.mjs'; +import { signCredential, signPrincipalAttestation } from './src/credentials.mjs'; import { authorizeInternalInvocation, createEngineState, executeAuthorizedInvocation, } from './src/engine.mjs'; import { - buildInvocationReceipt, buildStatement, - signReceipt, + receiptHash, signStatement, verifyReceipt, verifyStatement, } from './src/statements.mjs'; +import { policyHash, skillRegistrationKey } from './src/schema.mjs'; import { InMemoryEngineStore } from './src/store.mjs'; const NOW = '2026-07-17T00:01:00.000Z'; @@ -31,6 +31,7 @@ globalThis.fetch = async () => { const finance = generateKeyPairSync('ed25519'); const authorizer = generateKeyPairSync('ed25519'); const manager = generateKeyPairSync('ed25519'); +const identity = generateKeyPairSync('ed25519'); const receiptSigner = generateKeyPairSync('ed25519'); const statementSigner = generateKeyPairSync('ed25519'); @@ -47,6 +48,7 @@ const policy = { permittedSkillIds: ['ledger-recon'], permittedCreatorIds: ['sam'], permittedWielderIds: ['megacorp-internal-agent'], + permittedInitiatingPrincipalIds: ['jordan'], permittedCostCenters: ['platform-engineering'], maxQuoteAtomic: '4000000', awardRule: { @@ -60,6 +62,7 @@ const policy = { selfInvocation: 'manager_approval_required', permittedManagerSignerIds: ['manager-alex'], permittedCredentialAuthorizerIds: ['megacorp-collar-authorizer'], + permittedIdentitySignerIds: ['megacorp-identity'], permittedFinanceSignerIds: ['megacorp-finance'], vestingRule: 'none', paymentSchedule: 'monthly_in_arrears', @@ -72,6 +75,7 @@ const signedBudget = signBudget({ budgetId: 'budget-megacorp-2026-07', policyId: POLICY_ID, policyVersion: 1, + policyHash: policyHash(policy), period: '2026-07', currency: 'USD', atomicScale: 6, @@ -90,10 +94,13 @@ const quote = { skillVersionHash: `sha256:${'1'.repeat(64)}`, creatorId: 'sam', wielderId: 'megacorp-internal-agent', + initiatingPrincipalId: 'jordan', + principalAttestationId: 'principal-attestation-inv-001', beneficiaryId: 'megacorp', costCenter: 'platform-engineering', policyId: POLICY_ID, policyVersion: 1, + policyHash: policyHash(policy), maxExecutionCostAtomic: '1000000', protocolFeeAtomic: '25000', refundReserveAtomic: '25000', @@ -105,6 +112,19 @@ const quote = { const store = new InMemoryEngineStore(createEngineState({ signedBudget, policies: { [`${POLICY_ID}@1`]: policy }, + skillRegistrations: { + [skillRegistrationKey(quote.skillId, quote.skillVersionHash)]: { + schemaVersion: 1, + registrationId: 'registration-ledger-recon-v1', + skillId: quote.skillId, + skillVersionHash: quote.skillVersionHash, + creatorId: quote.creatorId, + employerId: 'megacorp', + status: 'active', + effectiveAt: '2026-07-17T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + }, + }, financeSigners: { 'megacorp-finance': finance.publicKey.export({ type: 'spki', format: 'pem' }), }, @@ -114,9 +134,39 @@ const store = new InMemoryEngineStore(createEngineState({ credentialAuthorizers: { 'megacorp-collar-authorizer': authorizer.publicKey.export({ type: 'spki', format: 'pem' }), }, - now: NOW, + identitySigners: { + 'megacorp-identity': identity.publicKey.export({ type: 'spki', format: 'pem' }), + }, + receiptSigners: { + [RECEIPT_SIGNER_ID]: receiptSigner.publicKey.export({ type: 'spki', format: 'pem' }), + }, + clock: () => NOW, + receiptSigner: { + signerId: RECEIPT_SIGNER_ID, + sign: (bytes) => cryptoSign(null, bytes, receiptSigner.privateKey).toString('base64'), + }, })); +const initiatingPrincipalAttestation = signPrincipalAttestation({ + schemaVersion: 1, + attestationId: quote.principalAttestationId, + identitySignerId: 'megacorp-identity', + principalId: quote.initiatingPrincipalId, + invocationId: quote.invocationId, + idempotencyKey: quote.idempotencyKey, + skillId: quote.skillId, + skillVersionHash: quote.skillVersionHash, + creatorId: quote.creatorId, + wielderId: quote.wielderId, + beneficiaryId: quote.beneficiaryId, + policyId: quote.policyId, + policyVersion: quote.policyVersion, + policyHash: quote.policyHash, + nonce: '2'.padStart(64, '0'), + issuedAt: NOW, + expiresAt: quote.expiresAt, +}, identity.privateKey); + const authorized = await authorizeInternalInvocation({ store, quote, @@ -127,8 +177,8 @@ const authorized = await authorizeInternalInvocation({ credentialIssuedAt: NOW, credentialExpiresAt: '2026-07-17T00:10:00.000Z', credentialAuthorizerId: 'megacorp-collar-authorizer', + principalAttestation: initiatingPrincipalAttestation, managerApproval: null, - now: NOW, }); assert.equal(authorized.reservation.state, 'reserved'); assert.equal(authorized.reservation.reservedAtomic, '3050000'); @@ -144,7 +194,6 @@ const completed = await executeAuthorizedInvocation({ executionCostAtomic: '700000', outputHash: `sha256:${'a'.repeat(64)}`, }), - now: NOW, }); assert.equal(completed.invocation.state, 'succeeded'); assert.equal(completed.budget.consumedAtomic, '2750000'); @@ -156,13 +205,8 @@ assert.doesNotThrow(() => JSON.stringify(completed)); const receiptTrust = { [RECEIPT_SIGNER_ID]: receiptSigner.publicKey.export({ type: 'spki', format: 'pem' }), }; -const signedReceipt = signReceipt(buildInvocationReceipt({ - invocation: completed.invocation, - reservation: completed.reservation, - award: completed.award, - employerId: 'megacorp', - receiptSignerId: RECEIPT_SIGNER_ID, -}), receiptSigner.privateKey); +const signedReceipt = completed.receipt; +assert.equal(completed.receiptHash, receiptHash(signedReceipt)); const employerReceipt = verifyReceipt(signedReceipt, { trustedReceiptSigners: receiptTrust }); const employeeReceipt = verifyReceipt(signedReceipt, { trustedReceiptSigners: receiptTrust }); assert.deepEqual(employerReceipt, employeeReceipt); diff --git a/spikes/internal-invocation-awards/src/budget.mjs b/spikes/internal-invocation-awards/src/budget.mjs index 932d71a..6e1dc78 100644 --- a/spikes/internal-invocation-awards/src/budget.mjs +++ b/spikes/internal-invocation-awards/src/budget.mjs @@ -1,11 +1,15 @@ import { sign as cryptoSign, verify as cryptoVerify } from 'node:crypto'; -import { allocateInternalGross } from '../../../prototype/atomic-money.mjs'; +import { + allocateInternalFailureGross, + allocateInternalGross, +} from '../../../prototype/atomic-money.mjs'; import { cloneFrozen, deepFreeze, fromAtomic, parseUtc, + policyHash, requireExactKeys, sumAtomic, toAtomic, @@ -14,12 +18,14 @@ import { } from './schema.mjs'; const BUDGET_AUTHORIZATION_KEYS = [ - 'schemaVersion', 'budgetId', 'policyId', 'policyVersion', 'period', 'currency', + 'schemaVersion', 'budgetId', 'policyId', 'policyVersion', 'policyHash', + 'period', 'currency', 'atomicScale', 'allocatedAtomic', 'effectiveAt', 'expiresAt', 'signerId', ]; const SIGNED_BUDGET_AUTHORIZATION_KEYS = [...BUDGET_AUTHORIZATION_KEYS, 'signature']; const BUDGET_STATE_KEYS = [ - 'schemaVersion', 'budgetId', 'policyId', 'policyVersion', 'period', 'currency', + 'schemaVersion', 'budgetId', 'policyId', 'policyVersion', 'policyHash', + 'period', 'currency', 'atomicScale', 'authorization', 'policy', 'allocatedAtomic', 'reservedAtomic', 'consumedAtomic', 'releasedAtomic', 'revision', ]; @@ -54,7 +60,7 @@ function decodeSignature(value) { function validateUnsignedAuthorization(input) { requireExactKeys(input, BUDGET_AUTHORIZATION_KEYS, 'budget authorization'); if (input.schemaVersion !== 1) throw new Error('budget authorization schemaVersion must equal 1'); - for (const key of ['budgetId', 'policyId', 'currency', 'signerId']) { + for (const key of ['budgetId', 'policyId', 'policyHash', 'currency', 'signerId']) { requireNonEmpty(input[key], key); } if (!Number.isSafeInteger(input.policyVersion) || input.policyVersion < 1) { @@ -102,6 +108,9 @@ export function createBudget(signedBudget, { trustedFinanceSigners, policy: poli if (unsigned.policyId !== policy.policyId || unsigned.policyVersion !== policy.version) { throw new Error('budget authorization policy binding does not match effective policy'); } + if (unsigned.policyHash !== policyHash(policy)) { + throw new Error('budget authorization policyHash does not match canonical policy'); + } if (unsigned.currency !== policy.currency || unsigned.atomicScale !== policy.atomicScale) { throw new Error('budget authorization denomination does not match policy'); } @@ -130,6 +139,7 @@ export function createBudget(signedBudget, { trustedFinanceSigners, policy: poli budgetId: unsigned.budgetId, policyId: unsigned.policyId, policyVersion: unsigned.policyVersion, + policyHash: unsigned.policyHash, period: unsigned.period, currency: unsigned.currency, atomicScale: unsigned.atomicScale, @@ -334,6 +344,7 @@ export function finalizeReservation(budgetInput, reservationInput, actual) { protocolFeeAtomic: fee, refundReserveAtomic: reserve, recipientId: actual.recipientId, + employerId: reservation.quote.beneficiaryId, })); if (allocation.invocationAwardAtomic !== maximumAward) { throw new Error('kernel Invocation award does not equal the authorized maximum award'); @@ -373,6 +384,7 @@ export function releaseReservation(budgetInput, reservationInput, options) { requireTransitionRevisions(budget, reservation, options); parseUtc(options.now, 'now'); const cost = toAtomic(options.executionCostAtomic); + let allocation = null; if (options.reason === 'cancelled_before_start') { if (reservation.state !== 'reserved') throw new Error('reservation must be reserved'); if (options.executionAttemptId !== null || cost !== 0n) { @@ -386,6 +398,7 @@ export function releaseReservation(budgetInput, reservationInput, options) { if (cost > toAtomic(reservation.quote.maxExecutionCostAtomic)) { throw new Error('execution cost exceeds quote maximum'); } + allocation = deepFreeze(allocateInternalFailureGross({ executionCostAtomic: cost })); } else { throw new Error('unsupported reservation release reason'); } @@ -402,13 +415,16 @@ export function releaseReservation(budgetInput, reservationInput, options) { revision: reservation.revision + 1, finalizedAt: options.now, }); + const journalEntries = allocation ? serializeJournalEntries(allocation.journalEntries) : []; return deepFreeze({ budget: nextBudget, reservation: nextReservation, + allocation, event: event('budget_released', nextReservation, nextBudget, options.now, { reason: options.reason, executionCostAtomic: options.executionCostAtomic, releasedAtomic: fromAtomic(released), + journalEntries, }), }); } diff --git a/spikes/internal-invocation-awards/src/credentials.mjs b/spikes/internal-invocation-awards/src/credentials.mjs index 7837cb3..b3ebd26 100644 --- a/spikes/internal-invocation-awards/src/credentials.mjs +++ b/spikes/internal-invocation-awards/src/credentials.mjs @@ -1,15 +1,17 @@ -import { sign as cryptoSign, verify as cryptoVerify } from 'node:crypto'; +import { createHash, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto'; import { cloneFrozen, parseUtc, + policyHash, requireExactKeys, } from './schema.mjs'; const CREDENTIAL_KEYS = [ 'schemaVersion', 'credentialAuthorizerId', 'invocationId', 'reservationId', - 'idempotencyKey', 'skillId', 'skillVersionHash', 'policyId', 'policyVersion', - 'nonce', 'issuedAt', 'expiresAt', + 'idempotencyKey', 'skillId', 'skillVersionHash', 'creatorId', 'wielderId', + 'initiatingPrincipalId', 'principalAttestationId', 'principalAttestationHash', + 'policyId', 'policyVersion', 'policyHash', 'nonce', 'issuedAt', 'expiresAt', ]; const SIGNED_CREDENTIAL_KEYS = [...CREDENTIAL_KEYS, 'signature']; const MANAGER_APPROVAL_KEYS = [ @@ -17,6 +19,13 @@ const MANAGER_APPROVAL_KEYS = [ 'policyId', 'policyVersion', 'issuedAt', 'expiresAt', ]; const SIGNED_MANAGER_APPROVAL_KEYS = [...MANAGER_APPROVAL_KEYS, 'signature']; +const PRINCIPAL_ATTESTATION_KEYS = [ + 'schemaVersion', 'attestationId', 'identitySignerId', 'principalId', + 'invocationId', 'idempotencyKey', 'skillId', 'skillVersionHash', 'creatorId', + 'wielderId', 'beneficiaryId', 'policyId', 'policyVersion', 'policyHash', 'nonce', + 'issuedAt', 'expiresAt', +]; +const SIGNED_PRINCIPAL_ATTESTATION_KEYS = [...PRINCIPAL_ATTESTATION_KEYS, 'signature']; const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; function ordered(source, keys) { @@ -47,7 +56,8 @@ function validateCredentialPayload(input) { if (input.schemaVersion !== 1) throw new Error('credential schemaVersion must equal 1'); for (const key of [ 'credentialAuthorizerId', 'invocationId', 'reservationId', 'idempotencyKey', - 'skillId', 'policyId', + 'skillId', 'creatorId', 'wielderId', 'initiatingPrincipalId', + 'principalAttestationId', 'policyId', ]) requireString(input[key], key); if (!SHA256_PATTERN.test(input.skillVersionHash)) { throw new Error('credential skillVersionHash must be a lowercase SHA-256 hash'); @@ -55,6 +65,10 @@ function validateCredentialPayload(input) { if (!Number.isSafeInteger(input.policyVersion) || input.policyVersion < 1) { throw new Error('credential policyVersion must be a positive integer'); } + if (!SHA256_PATTERN.test(input.policyHash)) throw new Error('credential policyHash is invalid'); + if (!SHA256_PATTERN.test(input.principalAttestationHash)) { + throw new Error('credential principalAttestationHash is invalid'); + } if (typeof input.nonce !== 'string' || !/^[0-9a-f]{64}$/.test(input.nonce)) { throw new Error('credential nonce must be lowercase 64-character hex without 0x'); } @@ -78,19 +92,148 @@ export function signCredential(payload, privateKey) { } export function verifyCredential(signed, trustedPublicKey, now) { + const payload = verifyCredentialSignature(signed, trustedPublicKey); + const at = parseUtc(now, 'now'); + if (at < parseUtc(payload.issuedAt, 'credential issuedAt')) { + throw new Error('credential is not yet valid'); + } + if (at >= parseUtc(payload.expiresAt, 'credential expiresAt')) { + throw new Error('credential expired'); + } + return payload; +} + +export function verifyCredentialSignature(signed, trustedPublicKey) { requireExactKeys(signed, SIGNED_CREDENTIAL_KEYS, 'signed credential'); const payload = validateCredentialPayload(ordered(signed, CREDENTIAL_KEYS)); const signature = decodeSignature(signed.signature, 'credential'); if (!cryptoVerify(null, canonicalCredentialBytes(payload), trustedPublicKey, signature)) { throw new Error('credential signature verification failed'); } + return payload; +} + +function validatePrincipalAttestationPayload(input) { + requireExactKeys(input, PRINCIPAL_ATTESTATION_KEYS, 'initiating-principal attestation'); + if (input.schemaVersion !== 1) { + throw new Error('initiating-principal attestation schemaVersion must equal 1'); + } + for (const key of [ + 'attestationId', 'identitySignerId', 'principalId', 'invocationId', + 'idempotencyKey', 'skillId', 'creatorId', 'wielderId', 'beneficiaryId', 'policyId', + ]) requireString(input[key], key); + if (!SHA256_PATTERN.test(input.skillVersionHash)) { + throw new Error('initiating-principal attestation Skill hash is invalid'); + } + if (!SHA256_PATTERN.test(input.policyHash)) { + throw new Error('initiating-principal attestation policyHash is invalid'); + } + if (!Number.isSafeInteger(input.policyVersion) || input.policyVersion < 1) { + throw new Error('initiating-principal attestation policyVersion must be positive'); + } + if (typeof input.nonce !== 'string' || !/^[0-9a-f]{64}$/.test(input.nonce)) { + throw new Error('initiating-principal attestation nonce must be lowercase 64-character hex'); + } + const issuedAt = parseUtc(input.issuedAt, 'initiating-principal attestation issuedAt'); + const expiresAt = parseUtc(input.expiresAt, 'initiating-principal attestation expiresAt'); + if (expiresAt <= issuedAt) { + throw new Error('initiating-principal attestation expiresAt must follow issuedAt'); + } + return cloneFrozen(input); +} + +export function canonicalPrincipalAttestationBytes(payload) { + const validated = validatePrincipalAttestationPayload(payload); + return bytes(validated, PRINCIPAL_ATTESTATION_KEYS); +} + +export function signPrincipalAttestation(payload, privateKey) { + const validated = validatePrincipalAttestationPayload(payload); + return cloneFrozen({ + ...validated, + signature: cryptoSign( + null, + canonicalPrincipalAttestationBytes(validated), + privateKey, + ).toString('base64'), + }); +} + +export function principalAttestationHash(signed) { + requireExactKeys( + signed, + SIGNED_PRINCIPAL_ATTESTATION_KEYS, + 'signed initiating-principal attestation', + ); + validatePrincipalAttestationPayload(ordered(signed, PRINCIPAL_ATTESTATION_KEYS)); + decodeSignature(signed.signature, 'initiating-principal attestation'); + return `sha256:${createHash('sha256') + .update(bytes(signed, SIGNED_PRINCIPAL_ATTESTATION_KEYS)) + .digest('hex')}`; +} + +export function verifyPrincipalAttestation(signed, { + policy, + quote, + identitySigners, + now, +}) { + requireExactKeys( + signed, + SIGNED_PRINCIPAL_ATTESTATION_KEYS, + 'signed initiating-principal attestation', + ); + const payload = validatePrincipalAttestationPayload( + ordered(signed, PRINCIPAL_ATTESTATION_KEYS), + ); + if (!policy.permittedIdentitySignerIds.includes(payload.identitySignerId)) { + throw new Error('identity signer is not permitted by policy'); + } + const key = identitySigners[payload.identitySignerId]; + if (typeof key !== 'string' || key.length === 0) { + throw new Error('identity signer is not provisioned'); + } + if (!policy.permittedInitiatingPrincipalIds.includes(payload.principalId)) { + throw new Error('initiating principal is not permitted by policy'); + } + const bindings = { + attestationId: quote.principalAttestationId, + principalId: quote.initiatingPrincipalId, + invocationId: quote.invocationId, + idempotencyKey: quote.idempotencyKey, + skillId: quote.skillId, + skillVersionHash: quote.skillVersionHash, + creatorId: quote.creatorId, + wielderId: quote.wielderId, + beneficiaryId: quote.beneficiaryId, + policyId: quote.policyId, + policyVersion: quote.policyVersion, + policyHash: quote.policyHash, + }; + for (const [keyName, expected] of Object.entries(bindings)) { + if (payload[keyName] !== expected) { + throw new Error(`initiating-principal attestation ${keyName} binding does not match quote`); + } + } + if (payload.policyHash !== policyHash(policy)) { + throw new Error('initiating-principal attestation policyHash is stale'); + } const at = parseUtc(now, 'now'); - if (at < parseUtc(payload.issuedAt, 'credential issuedAt')) { - throw new Error('credential is not yet valid'); + if (at < parseUtc(payload.issuedAt, 'attestation issuedAt')) { + throw new Error('initiating-principal attestation is not yet valid'); } - if (at >= parseUtc(payload.expiresAt, 'credential expiresAt')) { - throw new Error('credential expired'); + if (at >= parseUtc(payload.expiresAt, 'attestation expiresAt')) { + throw new Error('initiating-principal attestation expired'); + } + if (parseUtc(payload.expiresAt, 'attestation expiresAt') > parseUtc(quote.expiresAt, 'quote expiresAt')) { + throw new Error('initiating-principal attestation expiry exceeds quote'); } + if (!cryptoVerify( + null, + canonicalPrincipalAttestationBytes(payload), + key, + decodeSignature(signed.signature, 'initiating-principal attestation'), + )) throw new Error('initiating-principal attestation signature verification failed'); return payload; } @@ -164,5 +307,6 @@ export function verifyManagerApproval(approval, { export const CREDENTIAL_SCHEMAS = cloneFrozen({ InternalExecutionCredentialV1: CREDENTIAL_KEYS, + InitiatingPrincipalAttestationV1: PRINCIPAL_ATTESTATION_KEYS, ManagerApprovalV1: MANAGER_APPROVAL_KEYS, }); diff --git a/spikes/internal-invocation-awards/src/engine.mjs b/spikes/internal-invocation-awards/src/engine.mjs index 2b0e31d..76e5e29 100644 --- a/spikes/internal-invocation-awards/src/engine.mjs +++ b/spikes/internal-invocation-awards/src/engine.mjs @@ -11,9 +11,17 @@ import { } from './budget.mjs'; import { canonicalCredentialBytes, + principalAttestationHash, verifyCredential, + verifyCredentialSignature, verifyManagerApproval, + verifyPrincipalAttestation, } from './credentials.mjs'; +import { + appendSignedReceipt, + createReceiptLedgerState, + receiptSequenceScope, +} from './receipt-ledger.mjs'; import { cloneFrozen, deepFreeze, @@ -21,29 +29,42 @@ import { parseExecutorOutcome, parseUtc, requireExactKeys, + skillRegistrationKey, toAtomic, validatePolicy, validateQuote, + validateSkillRegistration, } from './schema.mjs'; +import { + buildInvocationReceipt, + receiptHash, + signReceiptWithCapability, + verifyReceipt, +} from './statements.mjs'; const TRUSTED_ENGINE_STATES = new WeakSet(); +const ENGINE_CAPABILITIES = new WeakMap(); const CREATE_STATE_KEYS = [ - 'signedBudget', 'policies', 'financeSigners', 'managerSigners', - 'credentialAuthorizers', 'now', + 'signedBudget', 'policies', 'skillRegistrations', 'financeSigners', + 'managerSigners', 'credentialAuthorizers', 'identitySigners', 'receiptSigners', + 'clock', 'receiptSigner', ]; const AUTHORIZE_KEYS = [ 'store', 'quote', 'expectedRevision', 'expectedBudgetRevision', 'reservationId', 'credentialNonce', 'credentialIssuedAt', 'credentialExpiresAt', - 'credentialAuthorizerId', 'managerApproval', 'now', + 'credentialAuthorizerId', 'principalAttestation', 'managerApproval', ]; -const CANCEL_KEYS = ['store', 'expectedRevision', 'reservationId', 'reason', 'now']; -const EXECUTE_KEYS = ['store', 'quote', 'credential', 'executor', 'now']; +const CANCEL_KEYS = ['store', 'expectedRevision', 'reservationId', 'reason']; +const EXECUTE_KEYS = ['store', 'quote', 'credential', 'executor']; const ENGINE_STATE_KEYS = [ - 'revision', 'budget', 'policies', 'financeSigners', 'managerSigners', - 'credentialAuthorizers', 'invocations', 'reservations', 'awards', - 'consumedNonces', 'issuedNonces', 'idempotency', 'events', 'nextReceiptSequence', + 'revision', 'budget', 'policies', 'skillRegistrations', 'financeSigners', + 'managerSigners', 'credentialAuthorizers', 'identitySigners', 'receiptSigners', + 'invocations', 'reservations', 'awards', 'consumedNonces', 'issuedNonces', + 'consumedPrincipalNonces', 'idempotency', 'events', 'receipts', + 'receiptHashes', 'receiptSequenceIndex', 'nextReceiptSequences', ]; +const TERMINAL_STATES = new Set(['succeeded', 'failed', 'unresolved', 'cancelled']); function requirePlainMap(value, label) { if (value === null || typeof value !== 'object' || Array.isArray(value) @@ -72,16 +93,48 @@ function validateTrustMap(mapInput, allowedIds, label) { return cloneFrozen(map); } -function markTrusted(state) { +function provisionCapabilities(input) { + if (typeof input.clock !== 'function') throw new Error('engine clock must be a function'); + requireExactKeys(input.receiptSigner, ['signerId', 'sign'], 'receipt signer capability'); + if (typeof input.receiptSigner.signerId !== 'string' + || input.receiptSigner.signerId.length === 0 + || typeof input.receiptSigner.sign !== 'function') { + throw new Error('receipt signer capability is invalid'); + } + const capabilities = Object.freeze({ + clock: input.clock, + receiptSigner: Object.freeze({ + signerId: input.receiptSigner.signerId, + sign: input.receiptSigner.sign, + }), + }); + const now = capabilities.clock(); + parseUtc(now, 'engine clock'); + return { capabilities, now }; +} + +function markTrusted(state, capabilities) { const frozen = deepFreeze(state); TRUSTED_ENGINE_STATES.add(frozen); + ENGINE_CAPABILITIES.set(frozen, capabilities); return frozen; } -function assertTrustedState(state, now) { - if (!TRUSTED_ENGINE_STATES.has(state)) { +function capabilitiesFor(state) { + if (!TRUSTED_ENGINE_STATES.has(state) || !ENGINE_CAPABILITIES.has(state)) { throw new Error('engine state was not created by the trusted engine boundary'); } + return ENGINE_CAPABILITIES.get(state); +} + +function engineNow(state) { + const now = capabilitiesFor(state).clock(); + parseUtc(now, 'engine clock'); + return now; +} + +function assertTrustedState(state, now) { + capabilitiesFor(state); requireExactKeys(state, ENGINE_STATE_KEYS, 'engine state'); const policyKey = `${state.budget.policyId}@${state.budget.policyVersion}`; const policy = validatePolicy(state.policies[policyKey], now); @@ -91,8 +144,8 @@ function assertTrustedState(state, now) { now, }); for (const key of [ - 'budgetId', 'policyId', 'policyVersion', 'period', 'currency', 'atomicScale', - 'allocatedAtomic', + 'budgetId', 'policyId', 'policyVersion', 'policyHash', 'period', 'currency', + 'atomicScale', 'allocatedAtomic', ]) { if (state.budget[key] !== verified[key]) throw new Error(`budget state changed signed ${key}`); } @@ -101,7 +154,10 @@ function assertTrustedState(state, now) { } function nextState(state, changes) { - return markTrusted({ ...state, ...changes, revision: state.revision + 1 }); + return markTrusted( + { ...state, ...changes, revision: state.revision + 1 }, + capabilitiesFor(state), + ); } function mapWith(map, key, value) { @@ -126,7 +182,9 @@ function effectiveCredentialExpiry(requested, quote, policy, budget) { policy.expiresAt, budget.authorization.expiresAt, ]; - for (const [index, candidate] of candidates.entries()) parseUtc(candidate, `credential bound ${index}`); + for (const [index, candidate] of candidates.entries()) { + parseUtc(candidate, `credential bound ${index}`); + } return candidates.reduce((earliest, candidate) => ( parseUtc(candidate, 'credential bound') < parseUtc(earliest, 'credential bound') ? candidate @@ -146,9 +204,15 @@ function compareQuote(left, right) { } } +function compareCredentialPayload(left, right) { + if (!Buffer.from(canonicalCredentialBytes(left)) + .equals(Buffer.from(canonicalCredentialBytes(right)))) { + throw new Error('credential does not match persisted authorization'); + } +} + function awardExposureAtomic(state, policy, period) { let exposure = 0n; - // V1 has no automated reversal API, so every recorded award remains in exposure. for (const award of Object.values(state.awards)) { if (award.policyId === policy.policyId && award.policyVersion === policy.version @@ -167,37 +231,154 @@ function awardExposureAtomic(state, policy, period) { return exposure; } -function jsonAllocation(allocation) { +function resolveActiveRegistration(state, quote, policy, now) { + const key = skillRegistrationKey(quote.skillId, quote.skillVersionHash); + const registration = state.skillRegistrations[key]; + if (!registration) throw new Error('Skill version is not provisioned'); + const active = validateSkillRegistration(registration, now); + if (active.creatorId !== quote.creatorId) { + throw new Error('Skill registration Creator does not match quote Creator'); + } + if (active.employerId !== quote.beneficiaryId || active.employerId !== policy.employerId) { + throw new Error('Skill registration employer does not match Beneficiary'); + } + return active; +} + +function serializedAllocation(allocation) { if (!allocation) return null; - return deepFreeze({ + const result = { grossAtomic: fromAtomic(allocation.grossAtomic), executionCostAtomic: fromAtomic(allocation.executionCostAtomic), - protocolFeeAtomic: fromAtomic(allocation.protocolFeeAtomic), - refundReserveAtomic: fromAtomic(allocation.refundReserveAtomic), - invocationAwardAtomic: fromAtomic(allocation.invocationAwardAtomic), - awardCredit: { - recipientId: allocation.awardCredit.recipientId, - amountAtomic: fromAtomic(allocation.awardCredit.amountAtomic), - }, journalEntries: allocation.journalEntries.map((entry) => ({ category: entry.category, debitAccountId: entry.debitAccountId, creditAccountId: entry.creditAccountId, amountAtomic: fromAtomic(entry.amountAtomic), })), + }; + if (Object.hasOwn(allocation, 'protocolFeeAtomic')) { + Object.assign(result, { + protocolFeeAtomic: fromAtomic(allocation.protocolFeeAtomic), + refundReserveAtomic: fromAtomic(allocation.refundReserveAtomic), + invocationAwardAtomic: fromAtomic(allocation.invocationAwardAtomic), + awardCredit: { + recipientId: allocation.awardCredit.recipientId, + amountAtomic: fromAtomic(allocation.awardCredit.amountAtomic), + }, + }); + } + return deepFreeze(result); +} + +function receiptLedgerFields(state) { + return { + receipts: state.receipts, + receiptHashes: state.receiptHashes, + receiptSequenceIndex: state.receiptSequenceIndex, + nextReceiptSequences: state.nextReceiptSequences, + }; +} + +function commitReceipt(state, invocation, reservation, award) { + const capability = capabilitiesFor(state).receiptSigner; + const unsigned = buildInvocationReceipt({ + invocation, + reservation, + award, + employerId: invocation.beneficiaryId, + receiptSignerId: capability.signerId, }); + const signedReceipt = signReceiptWithCapability(unsigned, capability); + verifyReceipt(signedReceipt, { trustedReceiptSigners: state.receiptSigners }); + const hash = receiptHash(signedReceipt); + const ledger = appendSignedReceipt(receiptLedgerFields(state), { signedReceipt, hash }); + return { signedReceipt, hash, ledger }; +} + +function allocationFromInvocation(invocation) { + if (invocation.state === 'succeeded') { + return deepFreeze({ + grossAtomic: fromAtomic( + toAtomic(invocation.executionCostAtomic) + + toAtomic(invocation.protocolFeeAtomic) + + toAtomic(invocation.refundReserveAtomic) + + toAtomic(invocation.invocationAwardAtomic), + ), + executionCostAtomic: invocation.executionCostAtomic, + protocolFeeAtomic: invocation.protocolFeeAtomic, + refundReserveAtomic: invocation.refundReserveAtomic, + invocationAwardAtomic: invocation.invocationAwardAtomic, + awardCredit: { + recipientId: invocation.creatorId, + amountAtomic: invocation.invocationAwardAtomic, + }, + journalEntries: invocation.journalEntries, + }); + } + if (invocation.state === 'failed') { + return deepFreeze({ + grossAtomic: invocation.executionCostAtomic, + executionCostAtomic: invocation.executionCostAtomic, + journalEntries: invocation.journalEntries, + }); + } + return null; +} + +function terminalResult(state, invocationId) { + const invocation = state.invocations[invocationId]; + const reservation = state.reservations[invocation.reservationId]; + const award = invocation.awardId ? state.awards[invocation.awardId] : null; + return deepFreeze({ + state, + budget: state.budget, + invocation, + reservation, + award, + allocation: allocationFromInvocation(invocation), + receipt: state.receipts[invocation.receiptId], + receiptHash: invocation.receiptHash, + events: state.events.filter((event) => event.invocationId === invocationId), + }); +} + +function verifyTerminalReplay(state, quote, credential) { + const invocation = state.invocations[quote.invocationId]; + if (!invocation || !TERMINAL_STATES.has(invocation.state)) return null; + const reservation = state.reservations[invocation.reservationId]; + compareQuote(quote, reservation.quote); + const authorizerId = credential?.credentialAuthorizerId; + const trustedKey = state.credentialAuthorizers[authorizerId]; + if (!trustedKey) throw new Error('credential authorizer is not provisioned'); + compareCredentialPayload( + verifyCredentialSignature(credential, trustedKey), + invocation.credentialPayload, + ); + if (!invocation.receiptId || !invocation.receiptHash) { + throw new Error('terminal Invocation is missing its committed receipt'); + } + const signedReceipt = state.receipts[invocation.receiptId]; + verifyReceipt(signedReceipt, { trustedReceiptSigners: state.receiptSigners }); + if (receiptHash(signedReceipt) !== invocation.receiptHash + || state.receiptHashes[invocation.receiptId] !== invocation.receiptHash) { + throw new Error('committed receipt hash does not match terminal Invocation'); + } + return terminalResult(state, invocation.invocationId); } export function createEngineState(input) { requireExactKeys(input, CREATE_STATE_KEYS, 'engine configuration'); + const { capabilities, now } = provisionCapabilities(input); const rawPolicies = requirePlainMap(input.policies, 'policies'); if (Object.keys(rawPolicies).length === 0) throw new Error('at least one policy is required'); const policies = {}; const financeIds = new Set(); const managerIds = new Set(); const authorizerIds = new Set(); + const identityIds = new Set(); for (const [key, rawPolicy] of Object.entries(rawPolicies)) { - const validated = validatePolicy(rawPolicy, input.now); + const validated = validatePolicy(rawPolicy, now); if (key !== `${validated.policyId}@${validated.version}`) { throw new Error(`policy map key ${key} does not match policy identity`); } @@ -205,6 +386,7 @@ export function createEngineState(input) { for (const id of validated.permittedFinanceSignerIds) financeIds.add(id); for (const id of validated.permittedManagerSignerIds) managerIds.add(id); for (const id of validated.permittedCredentialAuthorizerIds) authorizerIds.add(id); + for (const id of validated.permittedIdentitySignerIds) identityIds.add(id); } const frozenPolicies = cloneFrozen(policies); const financeSigners = validateTrustMap(input.financeSigners, financeIds, 'finance signers'); @@ -214,47 +396,78 @@ export function createEngineState(input) { authorizerIds, 'credential authorizers', ); + const identitySigners = validateTrustMap(input.identitySigners, identityIds, 'identity signers'); + const receiptSigners = validateTrustMap( + input.receiptSigners, + [capabilities.receiptSigner.signerId], + 'receipt signers', + ); + + const rawRegistrations = requirePlainMap(input.skillRegistrations, 'Skill registrations'); + if (Object.keys(rawRegistrations).length === 0) { + throw new Error('at least one Skill registration is required'); + } + const registrations = {}; + for (const [key, rawRegistration] of Object.entries(rawRegistrations)) { + const registration = validateSkillRegistration(rawRegistration, now, { allowInactive: true }); + if (key !== skillRegistrationKey(registration.skillId, registration.skillVersionHash)) { + throw new Error(`Skill registration map key ${key} does not match Skill version identity`); + } + const compatible = Object.values(frozenPolicies).some((policy) => ( + policy.employerId === registration.employerId + && policy.permittedSkillIds.includes(registration.skillId) + && policy.permittedCreatorIds.includes(registration.creatorId) + )); + if (!compatible) throw new Error('Skill registration is not compatible with a provisioned policy'); + registrations[key] = registration; + } + const skillRegistrations = cloneFrozen(registrations); const policy = frozenPolicies[`${input.signedBudget.policyId}@${input.signedBudget.policyVersion}`]; if (!policy) throw new Error('signed budget policy is not provisioned'); const budget = createBudget(input.signedBudget, { trustedFinanceSigners: financeSigners, policy, - now: input.now, + now, }); + const receiptLedger = createReceiptLedgerState(); return markTrusted({ revision: 0, budget, policies: frozenPolicies, + skillRegistrations, financeSigners, managerSigners, credentialAuthorizers, + identitySigners, + receiptSigners, invocations: deepFreeze({}), reservations: deepFreeze({}), awards: deepFreeze({}), consumedNonces: deepFreeze({}), issuedNonces: deepFreeze({}), + consumedPrincipalNonces: deepFreeze({}), idempotency: deepFreeze({}), events: deepFreeze([]), - nextReceiptSequence: 1, - }); + ...receiptLedger, + }, capabilities); } export async function authorizeInternalInvocation(input) { requireExactKeys(input, AUTHORIZE_KEYS, 'authorization input'); - const beforeCount = input.store.snapshot().events.length; const state = await input.store.transact(input.expectedRevision, (current) => { + const now = engineNow(current); + const trustedPolicy = assertTrustedState(current, now); const policyKey = `${input.quote.policyId}@${input.quote.policyVersion}`; - const trustedPolicy = assertTrustedState(current, input.now); const policy = current.policies[policyKey]; - if (!policy || policy !== trustedPolicy) { - // Object identity is stable because createEngineState freezes the same policy instance. - if (!policy) throw new Error('quote policy is not provisioned'); + if (!policy) throw new Error('quote policy is not provisioned'); + if (policyKey !== `${trustedPolicy.policyId}@${trustedPolicy.version}`) { + throw new Error('quote policy is outside the active employer budget'); } - const validatedPolicy = validatePolicy(policy, input.now); - const quote = validateQuote(input.quote, validatedPolicy, input.now); - if (current.budget.policyId !== quote.policyId - || current.budget.policyVersion !== quote.policyVersion - || current.budget.period !== String(input.now).slice(0, 7)) { + const validatedPolicy = validatePolicy(policy, now); + const quote = validateQuote(input.quote, validatedPolicy, now); + const registration = resolveActiveRegistration(current, quote, validatedPolicy, now); + if (current.budget.policyHash !== quote.policyHash + || current.budget.period !== now.slice(0, 7)) { throw new Error('quote is outside the active employer budget'); } if (current.budget.revision !== input.expectedBudgetRevision) { @@ -282,25 +495,38 @@ export async function authorizeInternalInvocation(input) { throw new Error('credential authorizer is not provisioned'); } - const isSelfInvocation = quote.creatorId === quote.wielderId; + const principal = verifyPrincipalAttestation(input.principalAttestation, { + policy: validatedPolicy, + quote, + identitySigners: current.identitySigners, + now, + }); + if (Object.hasOwn(current.consumedPrincipalNonces, principal.nonce)) { + throw new Error('initiating-principal attestation nonce already consumed'); + } + const attestationHash = principalAttestationHash(input.principalAttestation); + + const isSelfInvocation = quote.initiatingPrincipalId === quote.creatorId; if (isSelfInvocation) { if (validatedPolicy.selfInvocation === 'excluded') { throw new Error('self Invocation is excluded by policy'); } - if (input.managerApproval === null) throw new Error('manager approval is required for self Invocation'); + if (input.managerApproval === null) { + throw new Error('manager approval is required for self Invocation'); + } verifyManagerApproval(input.managerApproval, { policy: validatedPolicy, quote, managerSigners: current.managerSigners, - now: input.now, + now, }); } else if (input.managerApproval !== null) { - throw new Error('manager approval must be separate and null for non-self Invocation'); + throw new Error('manager approval must be null for non-self Invocation'); } const requestedExpiry = parseUtc(input.credentialExpiresAt, 'credential expiresAt'); const issuedAt = parseUtc(input.credentialIssuedAt, 'credential issuedAt'); - const at = parseUtc(input.now, 'now'); + const at = parseUtc(now, 'engine clock'); if (issuedAt > at) throw new Error('credential issuedAt cannot be in the future'); const earliestIssue = Math.max( parseUtc(validatedPolicy.effectiveAt, 'policy effectiveAt'), @@ -330,7 +556,7 @@ export async function authorizeInternalInvocation(input) { const reserved = reserveBudget(current.budget, quote, { expectedRevision: input.expectedBudgetRevision, reservationId: input.reservationId, - now: input.now, + now, }); const credentialPayload = deepFreeze({ schemaVersion: 1, @@ -340,8 +566,14 @@ export async function authorizeInternalInvocation(input) { idempotencyKey: quote.idempotencyKey, skillId: quote.skillId, skillVersionHash: quote.skillVersionHash, + creatorId: quote.creatorId, + wielderId: quote.wielderId, + initiatingPrincipalId: quote.initiatingPrincipalId, + principalAttestationId: principal.attestationId, + principalAttestationHash: attestationHash, policyId: quote.policyId, policyVersion: quote.policyVersion, + policyHash: quote.policyHash, nonce: input.credentialNonce, issuedAt: input.credentialIssuedAt, expiresAt, @@ -354,12 +586,18 @@ export async function authorizeInternalInvocation(input) { reservationId: input.reservationId, skillId: quote.skillId, skillVersionHash: quote.skillVersionHash, + skillRegistrationId: registration.registrationId, creatorId: quote.creatorId, wielderId: quote.wielderId, + initiatingPrincipalId: quote.initiatingPrincipalId, + principalAttestationId: principal.attestationId, + principalAttestationHash: attestationHash, + principalAttestation: cloneFrozen(input.principalAttestation), beneficiaryId: quote.beneficiaryId, costCenter: quote.costCenter, policyId: quote.policyId, policyVersion: quote.policyVersion, + policyHash: quote.policyHash, period: current.budget.period, currency: current.budget.currency, atomicScale: current.budget.atomicScale, @@ -370,7 +608,7 @@ export async function authorizeInternalInvocation(input) { credentialIssuedAt: input.credentialIssuedAt, credentialExpiresAt: expiresAt, executionAttemptId: null, - authorizedAt: input.now, + authorizedAt: now, startedAt: null, finalizedAt: null, executionCostStatus: null, @@ -388,14 +626,19 @@ export async function authorizeInternalInvocation(input) { externalRoyaltyCreditsAtomic: '0', employerSelfCreditAtomic: '0', journalEntries: deepFreeze([]), + receiptSequenceScope: null, receiptSequence: null, + receiptId: null, + receiptHash: null, }); const lifecycleEvents = [ - invocationEvent('invocation_requested', quote.invocationId, input.now), - invocationEvent('invocation_quoted', quote.invocationId, input.now, { quoteId: quote.quoteId }), + invocationEvent('invocation_requested', quote.invocationId, now), + invocationEvent('invocation_quoted', quote.invocationId, now, { quoteId: quote.quoteId }), reserved.event, - invocationEvent('invocation_authorized', quote.invocationId, input.now, { + invocationEvent('invocation_authorized', quote.invocationId, now, { reservationId: input.reservationId, + skillRegistrationId: registration.registrationId, + initiatingPrincipalId: principal.principalId, credentialAuthorizerId: input.credentialAuthorizerId, }), ]; @@ -407,6 +650,12 @@ export async function authorizeInternalInvocation(input) { invocationId: quote.invocationId, reservationId: input.reservationId, }), + consumedPrincipalNonces: mapWith(current.consumedPrincipalNonces, principal.nonce, { + invocationId: quote.invocationId, + attestationId: principal.attestationId, + principalId: principal.principalId, + consumedAt: now, + }), idempotency: mapWith(current.idempotency, quote.idempotencyKey, { invocationId: quote.invocationId, reservationId: input.reservationId, @@ -416,14 +665,13 @@ export async function authorizeInternalInvocation(input) { }); }); const invocation = state.invocations[input.quote.invocationId]; - const reservation = state.reservations[input.reservationId]; return deepFreeze({ state, budget: state.budget, invocation, - reservation, - credentialPayload: invocation?.credentialPayload ?? null, - events: deepFreeze(state.events.slice(beforeCount)), + reservation: state.reservations[input.reservationId], + credentialPayload: invocation.credentialPayload, + events: state.events.filter((event) => event.invocationId === invocation.invocationId), }); } @@ -432,28 +680,45 @@ export async function cancelInternalAuthorization(input) { if (typeof input.reason !== 'string' || input.reason.length === 0) { throw new Error('cancellation reason must be non-empty'); } - const beforeCount = input.store.snapshot().events.length; const state = await input.store.transact(input.expectedRevision, (current) => { - assertTrustedState(current, input.now); + const now = engineNow(current); + assertTrustedState(current, now); const reservation = current.reservations[input.reservationId]; if (!reservation) throw new Error('reservation does not exist'); const invocation = current.invocations[reservation.quote.invocationId]; - if (!invocation || invocation.state !== 'authorized') throw new Error('Invocation is not authorized'); + if (!invocation || invocation.state !== 'authorized') { + throw new Error('Invocation is not authorized'); + } const released = releaseReservation(current.budget, reservation, { expectedBudgetRevision: current.budget.revision, expectedReservationRevision: reservation.revision, executionAttemptId: null, executionCostAtomic: '0', reason: 'cancelled_before_start', - now: input.now, + now, }); - const cancelled = deepFreeze({ + const scope = receiptSequenceScope({ + employerId: invocation.beneficiaryId, + creatorId: invocation.creatorId, + currency: invocation.currency, + atomicScale: invocation.atomicScale, + }); + const sequence = current.nextReceiptSequences[scope] ?? 1; + const receiptId = `receipt-${invocation.invocationId}`; + const preReceiptInvocation = deepFreeze({ ...invocation, state: 'cancelled', revision: invocation.revision + 1, - finalizedAt: input.now, + finalizedAt: now, releasedAtomic: reservation.reservedAtomic, - receiptSequence: current.nextReceiptSequence, + receiptSequenceScope: scope, + receiptSequence: sequence, + receiptId, + }); + const committed = commitReceipt(current, preReceiptInvocation, released.reservation, null); + const cancelled = deepFreeze({ + ...preReceiptInvocation, + receiptHash: committed.hash, }); return nextState(current, { budget: released.budget, @@ -462,33 +727,41 @@ export async function cancelInternalAuthorization(input) { events: deepFreeze([ ...current.events, released.event, - invocationEvent('invocation_cancelled', invocation.invocationId, input.now, { + invocationEvent('invocation_cancelled', invocation.invocationId, now, { reason: input.reason, + receiptId, + receiptHash: committed.hash, + receiptSequence: sequence, + receiptSequenceScope: scope, }), ]), - nextReceiptSequence: current.nextReceiptSequence + 1, + ...committed.ledger, }); }); const reservation = state.reservations[input.reservationId]; - return deepFreeze({ - state, - budget: state.budget, - reservation, - invocation: state.invocations[reservation.quote.invocationId], - events: deepFreeze(state.events.slice(beforeCount)), - }); + return terminalResult(state, reservation.quote.invocationId); } export async function executeAuthorizedInvocation(input) { requireExactKeys(input, EXECUTE_KEYS, 'execution input'); - if (typeof input.executor !== 'function') throw new Error('executor must be an injected function'); const initial = input.store.snapshot(); - const beforeCount = initial.events.length; + capabilitiesFor(initial); + const replay = verifyTerminalReplay(initial, input.quote, input.credential); + if (replay) return replay; + if (typeof input.executor !== 'function') throw new Error('executor must be an injected function'); + const started = await input.store.transact(initial.revision, (current) => { - const policy = assertTrustedState(current, input.now); - const quote = validateQuote(input.quote, policy, input.now); + const now = engineNow(current); + const policy = assertTrustedState(current, now); + const quote = validateQuote(input.quote, policy, now); const invocation = current.invocations[quote.invocationId]; if (!invocation) throw new Error('Invocation has no persisted authorization'); + if (TERMINAL_STATES.has(invocation.state)) { + throw new Error('Invocation became terminal; retry execution to read its committed receipt'); + } + if (invocation.state === 'executing') { + throw new Error('Invocation execution is already in progress or requires reconciliation'); + } const reservation = current.reservations[invocation.reservationId]; if (!reservation) throw new Error('Invocation has no persisted reservation'); if (Object.hasOwn(current.consumedNonces, invocation.credentialNonce)) { @@ -497,6 +770,20 @@ export async function executeAuthorizedInvocation(input) { if (invocation.state !== 'authorized') throw new Error('Invocation is not authorized'); if (reservation.state !== 'reserved') throw new Error('reservation must be reserved'); compareQuote(quote, reservation.quote); + const registration = resolveActiveRegistration(current, quote, policy, now); + if (registration.registrationId !== invocation.skillRegistrationId) { + throw new Error('persisted Skill registration binding changed'); + } + verifyPrincipalAttestation(invocation.principalAttestation, { + policy, + quote, + identitySigners: current.identitySigners, + now, + }); + if (principalAttestationHash(invocation.principalAttestation) + !== invocation.principalAttestationHash) { + throw new Error('persisted initiating-principal attestation hash changed'); + } const authorizerId = input.credential?.credentialAuthorizerId; if (typeof authorizerId !== 'string' || !policy.permittedCredentialAuthorizerIds.includes(authorizerId)) { @@ -504,25 +791,23 @@ export async function executeAuthorizedInvocation(input) { } const trustedKey = current.credentialAuthorizers[authorizerId]; if (!trustedKey) throw new Error('credential authorizer is not provisioned'); - const credentialPayload = verifyCredential(input.credential, trustedKey, input.now); - const expectedPayload = invocation.credentialPayload; - if (!Buffer.from(canonicalCredentialBytes(credentialPayload)) - .equals(Buffer.from(canonicalCredentialBytes(expectedPayload)))) { - throw new Error('credential does not match persisted authorization'); - } + compareCredentialPayload( + verifyCredential(input.credential, trustedKey, now), + invocation.credentialPayload, + ); const executionAttemptId = `attempt-${invocation.invocationId}-${invocation.credentialNonce}`; const execution = startReservationExecution(current.budget, reservation, { expectedBudgetRevision: current.budget.revision, expectedReservationRevision: reservation.revision, executionAttemptId, - now: input.now, + now, }); const executingInvocation = deepFreeze({ ...invocation, state: 'executing', revision: invocation.revision + 1, executionAttemptId, - startedAt: input.now, + startedAt: now, }); return nextState(current, { budget: execution.budget, @@ -532,12 +817,12 @@ export async function executeAuthorizedInvocation(input) { invocationId: invocation.invocationId, reservationId: reservation.reservationId, executionAttemptId, - consumedAt: input.now, + consumedAt: now, }), events: deepFreeze([ ...current.events, execution.event, - invocationEvent('invocation_executing', invocation.invocationId, input.now, { + invocationEvent('invocation_executing', invocation.invocationId, now, { executionAttemptId, }), ]), @@ -554,12 +839,14 @@ export async function executeAuthorizedInvocation(input) { executionAttemptId: startedInvocation.executionAttemptId, skillId: startedInvocation.skillId, skillVersionHash: startedInvocation.skillVersionHash, + skillRegistrationId: startedInvocation.skillRegistrationId, + initiatingPrincipalId: startedInvocation.initiatingPrincipalId, + policyHash: startedInvocation.policyHash, })); } catch { rawOutcome = { kind: 'unresolved_after_start', reason: 'executor_threw' }; } const outcome = parseExecutorOutcome(rawOutcome, startedReservation.quote); - let resultAllocation = null; const finalized = await input.store.transactRecord({ invocationId: startedInvocation.invocationId, expectedInvocationRevision: startedInvocation.revision, @@ -567,13 +854,19 @@ export async function executeAuthorizedInvocation(input) { expectedReservationRevision: startedReservation.revision, executionAttemptId: startedInvocation.executionAttemptId, }, (current, { invocation, reservation }) => { - if (!TRUSTED_ENGINE_STATES.has(current)) { - throw new Error('engine state was not created by the trusted engine boundary'); - } + capabilitiesFor(current); + const now = engineNow(current); let money; - let terminalInvocation; + let preReceiptInvocation; let award = null; - const receiptSequence = current.nextReceiptSequence; + const scope = receiptSequenceScope({ + employerId: invocation.beneficiaryId, + creatorId: invocation.creatorId, + currency: invocation.currency, + atomicScale: invocation.atomicScale, + }); + const receiptSequence = current.nextReceiptSequences[scope] ?? 1; + const receiptId = `receipt-${invocation.invocationId}`; if (outcome.kind === 'succeeded') { const gross = toAtomic(outcome.executionCostAtomic) + toAtomic(reservation.quote.protocolFeeAtomic) @@ -588,12 +881,12 @@ export async function executeAuthorizedInvocation(input) { protocolFeeAtomic: reservation.quote.protocolFeeAtomic, refundReserveAtomic: reservation.quote.refundReserveAtomic, recipientId: invocation.creatorId, - now: input.now, + now, }); - resultAllocation = jsonAllocation(money.allocation); - const awardState = current.policies[`${invocation.policyId}@${invocation.policyVersion}`].vestingRule === 'none' - ? 'earned' - : 'vesting_pending'; + const allocation = serializedAllocation(money.allocation); + const awardState = current.policies[ + `${invocation.policyId}@${invocation.policyVersion}` + ].vestingRule === 'none' ? 'earned' : 'vesting_pending'; award = deepFreeze({ schemaVersion: 1, awardId: `award-${invocation.invocationId}`, @@ -601,31 +894,34 @@ export async function executeAuthorizedInvocation(input) { recipientId: invocation.creatorId, policyId: invocation.policyId, policyVersion: invocation.policyVersion, + policyHash: invocation.policyHash, period: invocation.period, currency: invocation.currency, atomicScale: invocation.atomicScale, - amountAtomic: resultAllocation.invocationAwardAtomic, + amountAtomic: allocation.invocationAwardAtomic, state: awardState, - measuredAt: input.now, - earnedAt: awardState === 'earned' ? input.now : null, + measuredAt: now, + earnedAt: awardState === 'earned' ? now : null, payableAt: null, paidAt: null, }); - terminalInvocation = deepFreeze({ + preReceiptInvocation = deepFreeze({ ...invocation, state: 'succeeded', revision: invocation.revision + 1, - finalizedAt: input.now, + finalizedAt: now, executionCostStatus: 'known', executionCostAtomic: outcome.executionCostAtomic, protocolFeeAtomic: reservation.quote.protocolFeeAtomic, refundReserveAtomic: reservation.quote.refundReserveAtomic, - invocationAwardAtomic: resultAllocation.invocationAwardAtomic, + invocationAwardAtomic: allocation.invocationAwardAtomic, releasedAtomic: money.event.releasedUnusedAtomic, awardId: award.awardId, outputHash: outcome.outputHash, - journalEntries: resultAllocation.journalEntries, + journalEntries: allocation.journalEntries, + receiptSequenceScope: scope, receiptSequence, + receiptId, }); } else if (outcome.kind === 'failed_after_start') { money = releaseReservation(current.budget, reservation, { @@ -634,18 +930,22 @@ export async function executeAuthorizedInvocation(input) { executionAttemptId: invocation.executionAttemptId, executionCostAtomic: outcome.executionCostAtomic, reason: 'failed_after_start', - now: input.now, + now, }); - terminalInvocation = deepFreeze({ + const allocation = serializedAllocation(money.allocation); + preReceiptInvocation = deepFreeze({ ...invocation, state: 'failed', revision: invocation.revision + 1, - finalizedAt: input.now, + finalizedAt: now, executionCostStatus: 'known', executionCostAtomic: outcome.executionCostAtomic, releasedAtomic: money.event.releasedAtomic, failureClass: outcome.failureClass, + journalEntries: allocation.journalEntries, + receiptSequenceScope: scope, receiptSequence, + receiptId, }); } else { money = holdUnresolvedReservation(current.budget, reservation, { @@ -653,34 +953,46 @@ export async function executeAuthorizedInvocation(input) { expectedReservationRevision: reservation.revision, executionAttemptId: invocation.executionAttemptId, reason: outcome.reason, - now: input.now, + now, }); - terminalInvocation = deepFreeze({ + preReceiptInvocation = deepFreeze({ ...invocation, state: 'unresolved', revision: invocation.revision + 1, - finalizedAt: input.now, + finalizedAt: now, executionCostStatus: 'unresolved', executionCostAtomic: null, heldReservationAtomic: reservation.reservedAtomic, unresolvedReason: outcome.reason, + journalEntries: deepFreeze([]), + receiptSequenceScope: scope, receiptSequence, + receiptId, }); } + + const committed = commitReceipt(current, preReceiptInvocation, money.reservation, award); + const terminalInvocation = deepFreeze({ + ...preReceiptInvocation, + receiptHash: committed.hash, + }); const terminalEvents = [ money.event, - invocationEvent(`invocation_${terminalInvocation.state}`, invocation.invocationId, input.now, { + invocationEvent(`invocation_${terminalInvocation.state}`, invocation.invocationId, now, { + receiptId, + receiptHash: committed.hash, receiptSequence, + receiptSequenceScope: scope, executionAttemptId: invocation.executionAttemptId, }), ]; if (award) { - terminalEvents.push(invocationEvent('invocation_award_measured', invocation.invocationId, input.now, { + terminalEvents.push(invocationEvent('invocation_award_measured', invocation.invocationId, now, { awardId: award.awardId, amountAtomic: award.amountAtomic, })); if (award.state === 'earned') { - terminalEvents.push(invocationEvent('invocation_award_earned', invocation.invocationId, input.now, { + terminalEvents.push(invocationEvent('invocation_award_earned', invocation.invocationId, now, { awardId: award.awardId, amountAtomic: award.amountAtomic, })); @@ -692,19 +1004,8 @@ export async function executeAuthorizedInvocation(input) { invocations: mapWith(current.invocations, invocation.invocationId, terminalInvocation), awards: award ? mapWith(current.awards, award.awardId, award) : current.awards, events: deepFreeze([...current.events, ...terminalEvents]), - nextReceiptSequence: receiptSequence + 1, + ...committed.ledger, }); }); - const invocation = finalized.invocations[startedInvocation.invocationId]; - const reservation = finalized.reservations[startedReservation.reservationId]; - const award = invocation.awardId ? finalized.awards[invocation.awardId] : null; - return deepFreeze({ - state: finalized, - budget: finalized.budget, - invocation, - reservation, - award, - allocation: resultAllocation, - events: deepFreeze(finalized.events.slice(beforeCount)), - }); + return terminalResult(finalized, startedInvocation.invocationId); } diff --git a/spikes/internal-invocation-awards/src/receipt-ledger.mjs b/spikes/internal-invocation-awards/src/receipt-ledger.mjs new file mode 100644 index 0000000..b448e7d --- /dev/null +++ b/spikes/internal-invocation-awards/src/receipt-ledger.mjs @@ -0,0 +1,74 @@ +import { cloneFrozen, deepFreeze } from './schema.mjs'; + +function requireString(value, label) { + if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be non-empty`); +} + +export function receiptSequenceScope({ employerId, creatorId, currency, atomicScale }) { + for (const [value, label] of [ + [employerId, 'receipt employerId'], + [creatorId, 'receipt creatorId'], + [currency, 'receipt currency'], + ]) requireString(value, label); + if (!Number.isSafeInteger(atomicScale) || atomicScale < 0 || atomicScale > 18) { + throw new Error('receipt atomicScale must be an integer from 0 through 18'); + } + return JSON.stringify([employerId, creatorId, currency, atomicScale]); +} + +export function createReceiptLedgerState() { + return deepFreeze({ + receipts: {}, + receiptHashes: {}, + receiptSequenceIndex: {}, + nextReceiptSequences: {}, + }); +} + +export function appendSignedReceipt(state, { signedReceipt, hash }) { + if (!state || typeof state !== 'object') throw new Error('receipt ledger state is required'); + for (const key of [ + 'receipts', 'receiptHashes', 'receiptSequenceIndex', 'nextReceiptSequences', + ]) { + if (!state[key] || typeof state[key] !== 'object' || Array.isArray(state[key])) { + throw new Error(`receipt ledger ${key} must be an object`); + } + } + if (!signedReceipt || typeof signedReceipt !== 'object' || Array.isArray(signedReceipt)) { + throw new Error('signed receipt must be an object'); + } + requireString(signedReceipt.receiptId, 'receiptId'); + requireString(signedReceipt.receiptSequenceScope, 'receiptSequenceScope'); + if (!Number.isSafeInteger(signedReceipt.sequence) || signedReceipt.sequence < 1) { + throw new Error('receipt sequence must be a positive integer'); + } + if (typeof hash !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(hash)) { + throw new Error('receipt hash must be a lowercase SHA-256 hash'); + } + if (Object.hasOwn(state.receipts, signedReceipt.receiptId)) { + throw new Error('receipt ID already committed'); + } + if (Object.hasOwn(state.receiptHashes, hash)) { + throw new Error('receipt hash already committed'); + } + const indexKey = JSON.stringify([signedReceipt.receiptSequenceScope, signedReceipt.sequence]); + if (Object.hasOwn(state.receiptSequenceIndex, indexKey)) { + throw new Error('receipt sequence already committed for scope'); + } + const expected = state.nextReceiptSequences[signedReceipt.receiptSequenceScope] ?? 1; + if (signedReceipt.sequence !== expected) { + throw new Error(`expected receipt sequence ${expected}, received ${signedReceipt.sequence}`); + } + return deepFreeze({ + receipts: { ...state.receipts, [signedReceipt.receiptId]: cloneFrozen(signedReceipt) }, + receiptHashes: { ...state.receiptHashes, [signedReceipt.receiptId]: hash }, + receiptSequenceIndex: { + ...state.receiptSequenceIndex, + [indexKey]: signedReceipt.receiptId, + }, + nextReceiptSequences: { + ...state.nextReceiptSequences, + [signedReceipt.receiptSequenceScope]: expected + 1, + }, + }); +} diff --git a/spikes/internal-invocation-awards/src/schema.mjs b/spikes/internal-invocation-awards/src/schema.mjs index 1ae863d..67df02c 100644 --- a/spikes/internal-invocation-awards/src/schema.mjs +++ b/spikes/internal-invocation-awards/src/schema.mjs @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + export const AWARD_STATES = deepFreeze([ 'measured', 'vesting_pending', @@ -20,10 +22,12 @@ export const INVOCATION_STATES = deepFreeze([ const POLICY_KEYS = [ 'schemaVersion', 'policyId', 'version', 'status', 'currency', 'atomicScale', 'employerId', 'effectiveAt', 'expiresAt', 'permittedSkillIds', - 'permittedCreatorIds', 'permittedWielderIds', 'permittedCostCenters', + 'permittedCreatorIds', 'permittedWielderIds', 'permittedInitiatingPrincipalIds', + 'permittedCostCenters', 'maxQuoteAtomic', 'awardRule', 'maxAwardPerInvocationAtomic', 'maxAwardPerPeriodAtomic', 'selfInvocation', 'permittedManagerSignerIds', - 'permittedCredentialAuthorizerIds', 'permittedFinanceSignerIds', 'vestingRule', + 'permittedCredentialAuthorizerIds', 'permittedIdentitySignerIds', + 'permittedFinanceSignerIds', 'vestingRule', 'paymentSchedule', 'terminationTreatment', 'paymentRail', ]; @@ -31,13 +35,26 @@ const AWARD_RULE_KEYS = ['type', 'awardRateBps', 'rateBase', 'rounding']; const QUOTE_KEYS = [ 'schemaVersion', 'quoteId', 'invocationId', 'idempotencyKey', 'skillId', - 'skillVersionHash', 'creatorId', 'wielderId', 'beneficiaryId', 'costCenter', - 'policyId', 'policyVersion', 'maxExecutionCostAtomic', 'protocolFeeAtomic', + 'skillVersionHash', 'creatorId', 'wielderId', 'initiatingPrincipalId', + 'principalAttestationId', 'beneficiaryId', 'costCenter', 'policyId', + 'policyVersion', 'policyHash', 'maxExecutionCostAtomic', 'protocolFeeAtomic', 'refundReserveAtomic', 'maxInvocationAwardAtomic', 'maxGrossAtomic', 'expiresAt', ]; const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; +const POLICY_SET_KEYS = new Set([ + 'permittedSkillIds', 'permittedCreatorIds', 'permittedWielderIds', + 'permittedInitiatingPrincipalIds', 'permittedCostCenters', + 'permittedManagerSignerIds', 'permittedCredentialAuthorizerIds', + 'permittedIdentitySignerIds', 'permittedFinanceSignerIds', +]); + +const SKILL_REGISTRATION_KEYS = [ + 'schemaVersion', 'registrationId', 'skillId', 'skillVersionHash', 'creatorId', + 'employerId', 'status', 'effectiveAt', 'expiresAt', +]; + export function deepFreeze(value) { if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value; for (const child of Object.values(value)) deepFreeze(child); @@ -67,6 +84,37 @@ export function sumAtomic(values) { return values.reduce((sum, value) => sum + toAtomic(value), 0n); } +function codeUnitSort(values) { + return [...values].sort((left, right) => { + if (left < right) return -1; + if (left > right) return 1; + return 0; + }); +} + +export function canonicalPolicyBytes(policy) { + requireExactKeys(policy, POLICY_KEYS, 'policy'); + requireExactKeys(policy.awardRule, AWARD_RULE_KEYS, 'awardRule'); + const canonical = {}; + for (const key of POLICY_KEYS) { + if (key === 'awardRule') { + canonical[key] = Object.fromEntries( + AWARD_RULE_KEYS.map((ruleKey) => [ruleKey, policy.awardRule[ruleKey]]), + ); + } else if (POLICY_SET_KEYS.has(key)) { + if (!Array.isArray(policy[key])) throw new Error(`${key} must be an array`); + canonical[key] = codeUnitSort(policy[key]); + } else { + canonical[key] = policy[key]; + } + } + return new TextEncoder().encode(JSON.stringify(canonical)); +} + +export function policyHash(policy) { + return `sha256:${createHash('sha256').update(canonicalPolicyBytes(policy)).digest('hex')}`; +} + function requirePlainObject(value, label) { if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { @@ -146,9 +194,13 @@ export function validatePolicy(input, now) { for (const key of [ 'permittedSkillIds', 'permittedCreatorIds', 'permittedWielderIds', - 'permittedCostCenters', 'permittedManagerSignerIds', - 'permittedCredentialAuthorizerIds', 'permittedFinanceSignerIds', + 'permittedInitiatingPrincipalIds', 'permittedCostCenters', + 'permittedManagerSignerIds', 'permittedCredentialAuthorizerIds', + 'permittedIdentitySignerIds', 'permittedFinanceSignerIds', ]) requireStringSet(input[key], key); + if (input.permittedCreatorIds.includes(input.employerId)) { + throw new Error('policy employer cannot be a permitted employee-Creator award recipient'); + } toAtomic(input.maxQuoteAtomic); toAtomic(input.maxAwardPerInvocationAtomic); @@ -191,7 +243,8 @@ export function validateQuote(input, policyInput, now) { const policy = validatePolicy(policyInput, now); if (input.schemaVersion !== 1) throw new Error('quote schemaVersion must equal 1'); for (const key of ['quoteId', 'invocationId', 'idempotencyKey', 'skillId', 'creatorId', - 'wielderId', 'beneficiaryId', 'costCenter', 'policyId']) { + 'wielderId', 'initiatingPrincipalId', 'principalAttestationId', 'beneficiaryId', + 'costCenter', 'policyId']) { requireString(input[key], key); } if (!SHA256_PATTERN.test(input.skillVersionHash)) { @@ -200,9 +253,17 @@ export function validateQuote(input, policyInput, now) { if (input.policyId !== policy.policyId || input.policyVersion !== policy.version) { throw new Error('quote policy binding does not match effective policy'); } + if (input.policyHash !== policyHash(policy)) { + throw new Error('quote policyHash does not match canonical effective policy'); + } assertPermitted(input.skillId, policy.permittedSkillIds, 'Skill'); assertPermitted(input.creatorId, policy.permittedCreatorIds, 'Creator'); assertPermitted(input.wielderId, policy.permittedWielderIds, 'Wielder'); + assertPermitted( + input.initiatingPrincipalId, + policy.permittedInitiatingPrincipalIds, + 'initiating principal', + ); assertPermitted(input.costCenter, policy.permittedCostCenters, 'cost center'); if (input.beneficiaryId !== policy.employerId) { throw new Error('Beneficiary must equal the policy employer'); @@ -223,6 +284,10 @@ export function validateQuote(input, policyInput, now) { if (toAtomic(input.maxInvocationAwardAtomic) > toAtomic(policy.maxAwardPerInvocationAtomic)) { throw new Error('maxInvocationAwardAtomic exceeds policy cap'); } + if (toAtomic(input.maxInvocationAwardAtomic) > 0n + && (input.creatorId === policy.employerId || input.creatorId === input.beneficiaryId)) { + throw new Error('employer or Beneficiary cannot receive a positive employee Invocation award'); + } const expiry = parseUtc(input.expiresAt, 'quote expiresAt'); const at = nowMillis(now); if (at >= expiry) throw new Error('quote expired'); @@ -232,6 +297,39 @@ export function validateQuote(input, policyInput, now) { return cloneFrozen(input); } +export function skillRegistrationKey(skillId, skillVersionHash) { + requireString(skillId, 'Skill registration skillId'); + if (!SHA256_PATTERN.test(skillVersionHash)) { + throw new Error('Skill registration version hash must be a lowercase SHA-256 hash'); + } + return `${skillId}@${skillVersionHash}`; +} + +export function validateSkillRegistration(input, now, { allowInactive = false } = {}) { + requireExactKeys(input, SKILL_REGISTRATION_KEYS, 'Skill registration'); + if (input.schemaVersion !== 1) throw new Error('Skill registration schemaVersion must equal 1'); + for (const key of ['registrationId', 'skillId', 'creatorId', 'employerId']) { + requireString(input[key], `Skill registration ${key}`); + } + skillRegistrationKey(input.skillId, input.skillVersionHash); + if (!['active', 'revoked'].includes(input.status)) { + throw new Error('Skill registration status must be active or revoked'); + } + if (input.creatorId === input.employerId) { + throw new Error('Skill registration employer cannot be the employee-Creator'); + } + const effectiveAt = parseUtc(input.effectiveAt, 'Skill registration effectiveAt'); + const expiresAt = parseUtc(input.expiresAt, 'Skill registration expiresAt'); + if (expiresAt <= effectiveAt) throw new Error('Skill registration expiresAt must follow effectiveAt'); + const at = nowMillis(now); + if (!allowInactive) { + if (input.status !== 'active') throw new Error('Skill registration is revoked'); + if (at < effectiveAt) throw new Error('Skill registration is not yet effective'); + if (at >= expiresAt) throw new Error('Skill registration expired'); + } + return cloneFrozen(input); +} + const UNRESOLVED_SENTINEL = deepFreeze({ kind: 'unresolved_after_start', reason: 'malformed_outcome', diff --git a/spikes/internal-invocation-awards/src/statements.mjs b/spikes/internal-invocation-awards/src/statements.mjs index 7aebb04..3e5681a 100644 --- a/spikes/internal-invocation-awards/src/statements.mjs +++ b/spikes/internal-invocation-awards/src/statements.mjs @@ -4,6 +4,9 @@ import { verify as cryptoVerify, } from 'node:crypto'; +import { validateInternalJournalEntries } from '../../../prototype/atomic-money.mjs'; +import { receiptSequenceScope } from './receipt-ledger.mjs'; + import { cloneFrozen, deepFreeze, @@ -17,9 +20,11 @@ const JOURNAL_ENTRY_KEYS = [ 'category', 'debitAccountId', 'creditAccountId', 'amountAtomic', ]; const RECEIPT_KEYS = [ - 'schemaVersion', 'receiptId', 'sequence', 'receiptType', 'invocationId', + 'schemaVersion', 'receiptId', 'sequence', 'receiptSequenceScope', 'receiptType', 'invocationId', 'reservationId', 'employerId', 'creatorId', 'skillId', 'skillVersionHash', - 'policyId', 'policyVersion', 'period', 'currency', 'atomicScale', + 'skillRegistrationId', 'initiatingPrincipalId', 'principalAttestationId', + 'principalAttestationHash', 'policyId', 'policyVersion', 'policyHash', + 'period', 'currency', 'atomicScale', 'invocationState', 'reservationState', 'executionAttemptId', 'reservedAtomic', 'consumedAtomic', 'releasedAtomic', 'heldReservationAtomic', 'executionCostStatus', 'executionCostAtomic', 'outputHash', 'failureClass', @@ -34,14 +39,22 @@ const ADVANCE_KEYS = ['advanceId', 'receiptHash', 'amountAtomic', 'advancedAt']; const REVERSAL_KEYS = [ 'reversalId', 'receiptHash', 'amountAtomic', 'balanceEffect', 'reason', 'occurredAt', ]; +const AWARD_ACTIVITY_KEYS = [ + 'receiptHash', 'earnedAtomic', 'advancedAtomic', 'earnedReversedAtomic', + 'payableReversedAtomic', +]; const STATEMENT_KEYS = [ 'schemaVersion', 'statementId', 'employerId', 'creatorId', 'period', 'currency', - 'atomicScale', 'openingPayableAtomic', 'firstReceiptSequence', - 'lastReceiptSequence', 'receiptHashes', 'receiptMerkleRoot', + 'atomicScale', 'openingPayableAtomic', 'priorStatementHash', + 'priorClosingPayableAtomic', 'firstReceiptSequence', 'lastReceiptSequence', + 'lastRecognizedReceiptSequence', + 'receiptHashes', 'receiptMerkleRoot', 'historicalReceiptHashes', 'reservationTotalAtomic', 'releaseTotalAtomic', 'chargeTotalAtomic', 'earnedAwardTotalAtomic', 'payableAdvances', 'payableAdvanceTotalAtomic', 'reversals', 'reversalTotalAtomic', 'payableReversalTotalAtomic', 'payments', - 'paymentTotalAtomic', 'closingPayableAtomic', 'statementSignerId', + 'paymentTotalAtomic', 'cumulativeAdvanceIds', 'cumulativeReversalIds', + 'cumulativePaymentIds', 'cumulativePaymentRailReferences', 'awardActivity', + 'closingPayableAtomic', 'statementSignerId', ]; const SIGNED_STATEMENT_KEYS = [...STATEMENT_KEYS, 'signature']; const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; @@ -114,8 +127,9 @@ function validateReceiptPayload(input) { if (input.schemaVersion !== 1) throw new Error('receipt schemaVersion must equal 1'); for (const key of [ 'receiptId', 'receiptType', 'invocationId', 'reservationId', 'employerId', - 'creatorId', 'skillId', 'policyId', 'period', 'currency', 'invocationState', - 'reservationState', 'receiptSignerId', + 'creatorId', 'skillId', 'skillRegistrationId', 'initiatingPrincipalId', + 'principalAttestationId', 'policyId', 'period', 'currency', 'invocationState', + 'reservationState', 'receiptSignerId', 'receiptSequenceScope', ]) requireString(input[key], key); if (!Number.isSafeInteger(input.sequence) || input.sequence < 1) { throw new Error('receipt sequence must be a positive integer'); @@ -128,6 +142,16 @@ function validateReceiptPayload(input) { } if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(input.period)) throw new Error('receipt period must be YYYY-MM'); if (!SHA256_PATTERN.test(input.skillVersionHash)) throw new Error('receipt Skill hash is invalid'); + if (!SHA256_PATTERN.test(input.policyHash)) throw new Error('receipt policyHash is invalid'); + if (!SHA256_PATTERN.test(input.principalAttestationHash)) { + throw new Error('receipt principalAttestationHash is invalid'); + } + if (input.receiptSequenceScope !== receiptSequenceScope({ + employerId: input.employerId, + creatorId: input.creatorId, + currency: input.currency, + atomicScale: input.atomicScale, + })) throw new Error('receipt sequence scope does not match receipt identity'); parseUtc(input.occurredAt, 'receipt occurredAt'); for (const key of [ 'reservedAtomic', 'consumedAtomic', 'releasedAtomic', 'heldReservationAtomic', @@ -170,20 +194,18 @@ function validateReceiptPayload(input) { if (journalTotal !== consumed || journalEntries.length !== 4) { throw new Error('receipt journal entries do not conserve consumed gross'); } - const expectedJournal = [ - ['execution-cogs', 'provider:execution', input.executionCostAtomic], - ['protocol-fee', 'protocol:treasury', input.protocolFeeAtomic], - ['refund-reserve', 'reserve:refund', input.refundReserveAtomic], - ['invocation-award', `employee:${input.creatorId}`, input.invocationAwardAtomic], - ]; - for (const [index, [category, creditAccountId, amountAtomic]] of expectedJournal.entries()) { - const entry = journalEntries[index]; - if (entry.category !== category - || entry.creditAccountId !== creditAccountId - || entry.amountAtomic !== amountAtomic) { - throw new Error('receipt journal entries do not match the shared atomic allocation'); - } - } + validateInternalJournalEntries({ + kind: 'succeeded', + grossAtomic: consumed, + executionCostAtomic: toAtomic(input.executionCostAtomic), + protocolFeeAtomic: toAtomic(input.protocolFeeAtomic), + refundReserveAtomic: toAtomic(input.refundReserveAtomic), + recipientId: input.creatorId, + employerId: input.employerId, + journalEntries: journalEntries.map((entry) => ({ + ...entry, amountAtomic: toAtomic(entry.amountAtomic), + })), + }); } else if (input.invocationState === 'failed') { if (input.receiptType !== 'internal_invocation_finalized' || input.reservationState !== 'released' @@ -196,10 +218,18 @@ function validateReceiptPayload(input) { || input.refundReserveAtomic !== '0' || input.invocationAwardAtomic !== '0' || input.awardState !== null - || journalEntries.length !== 0 + || journalEntries.length !== 1 || consumed !== toAtomic(input.executionCostAtomic)) { throw new Error('invalid failed Invocation receipt state'); } + validateInternalJournalEntries({ + kind: 'failed_after_start', + grossAtomic: consumed, + executionCostAtomic: toAtomic(input.executionCostAtomic), + journalEntries: journalEntries.map((entry) => ({ + ...entry, amountAtomic: toAtomic(entry.amountAtomic), + })), + }); } else if (input.invocationState === 'unresolved') { if (input.receiptType !== 'internal_invocation_finalized' || input.reservationState !== 'held_unresolved' @@ -282,6 +312,7 @@ export function buildInvocationReceipt({ schemaVersion: 1, receiptId: `receipt-${invocation.invocationId}`, sequence: invocation.receiptSequence, + receiptSequenceScope: invocation.receiptSequenceScope, receiptType: cancelled ? 'internal_invocation_cancelled' : 'internal_invocation_finalized', invocationId: invocation.invocationId, reservationId: reservation.reservationId, @@ -289,8 +320,13 @@ export function buildInvocationReceipt({ creatorId: invocation.creatorId, skillId: invocation.skillId, skillVersionHash: invocation.skillVersionHash, + skillRegistrationId: invocation.skillRegistrationId, + initiatingPrincipalId: invocation.initiatingPrincipalId, + principalAttestationId: invocation.principalAttestationId, + principalAttestationHash: invocation.principalAttestationHash, policyId: invocation.policyId, policyVersion: invocation.policyVersion, + policyHash: invocation.policyHash, period: invocation.period, currency: invocation.currency, atomicScale: invocation.atomicScale, @@ -332,6 +368,19 @@ export function signReceipt(receipt, privateKey) { }); } +export function signReceiptWithCapability(receipt, capability) { + const validated = validateReceiptPayload(receipt); + if (!capability || typeof capability !== 'object' + || capability.signerId !== validated.receiptSignerId + || typeof capability.sign !== 'function') { + throw new Error('receipt signer capability does not match receiptSignerId'); + } + const signature = capability.sign(canonicalReceiptBytes(validated)); + const signed = { ...validated, signature }; + validateSignedReceiptShape(signed); + return cloneFrozen(signed); +} + function validateSignedReceiptShape(signedReceipt) { requireExactKeys(signedReceipt, SIGNED_RECEIPT_KEYS, 'signed receipt'); const receipt = validateReceiptPayload(ordered(signedReceipt, RECEIPT_KEYS)); @@ -435,7 +484,7 @@ function validateReversal(reversal, index) { return cloneFrozen(reversal); } -function statementReceiptRows(receipts, identity) { +function statementReceiptRows(receipts, identity, { enforceContinuity = true } = {}) { if (!Array.isArray(receipts)) throw new Error('receipts must be an array'); const rows = receipts.map((signed) => ({ signed, receipt: validateSignedReceiptShape(signed) })); rows.sort((left, right) => left.receipt.sequence - right.receipt.sequence); @@ -443,7 +492,7 @@ function statementReceiptRows(receipts, identity) { const seenHashes = new Set(); let expected = rows.length === 0 ? null : rows[0].receipt.sequence; for (const row of rows) { - if (row.receipt.sequence !== expected) { + if (enforceContinuity && row.receipt.sequence !== expected) { throw new Error(`statement sequence gap: expected ${expected}, received ${row.receipt.sequence}`); } expected += 1; @@ -461,6 +510,40 @@ function statementReceiptRows(receipts, identity) { return rows; } +function sortedStrings(values) { + return [...values].sort((left, right) => { + if (left < right) return -1; + if (left > right) return 1; + return 0; + }); +} + +function validateAwardActivity(row, index) { + requireExactKeys(row, AWARD_ACTIVITY_KEYS, `award activity ${index}`); + if (!SHA256_PATTERN.test(row.receiptHash)) throw new Error('award activity receiptHash is invalid'); + for (const key of [ + 'earnedAtomic', 'advancedAtomic', 'earnedReversedAtomic', 'payableReversedAtomic', + ]) toAtomic(row[key]); + return cloneFrozen(row); +} + +function cumulativeIds(priorValues, currentValues, label) { + const prior = Array.isArray(priorValues) ? priorValues : []; + const seen = new Set(prior); + for (const id of currentValues) { + if (seen.has(id)) throw new Error(`${label} ID already appeared in a prior statement: ${id}`); + seen.add(id); + } + return sortedStrings(seen); +} + +function assertTimestampInPeriod(timestamp, period, label) { + parseUtc(timestamp, label); + if (timestamp.slice(0, 7) !== period) { + throw new Error(`${label} must fall within statement period ${period}`); + } +} + export function buildStatement({ statementId, employerId, @@ -470,6 +553,8 @@ export function buildStatement({ atomicScale, openingPayableAtomic, receipts, + historicalReceipts = [], + priorStatement = null, payableAdvances, reversals, payments, @@ -486,11 +571,48 @@ export function buildStatement({ throw new Error('statement atomicScale must be an integer from 0 through 18'); } const opening = toAtomic(openingPayableAtomic); + let priorPayload = null; + let priorHash = null; + if (priorStatement !== null) { + priorPayload = validateSignedStatementShape(priorStatement); + priorHash = statementHash(priorStatement); + for (const key of ['employerId', 'creatorId', 'currency', 'atomicScale']) { + if (priorPayload[key] !== { employerId, creatorId, currency, atomicScale }[key]) { + throw new Error(`prior statement ${key} does not match current statement`); + } + } + if (priorPayload.period >= period) throw new Error('prior statement period must precede current period'); + if (openingPayableAtomic !== priorPayload.closingPayableAtomic) { + throw new Error('opening payable must equal authenticated prior closing payable'); + } + } + const rows = statementReceiptRows(receipts, { employerId, creatorId, period, currency, atomicScale, }); + const priorReceiptCursor = priorPayload?.lastRecognizedReceiptSequence ?? 0; + if (rows.length > 0 && rows[0].receipt.sequence !== priorReceiptCursor + 1) { + throw new Error( + `statement sequence gap across periods: expected ${priorReceiptCursor + 1}, received ${rows[0].receipt.sequence}`, + ); + } + const lastRecognizedReceiptSequence = rows.length === 0 + ? priorReceiptCursor + : rows.at(-1).receipt.sequence; + const historicalRows = statementReceiptRows(historicalReceipts, { + employerId, creatorId, currency, atomicScale, + }, { enforceContinuity: false }); + for (const row of historicalRows) { + if (row.receipt.period >= period) { + throw new Error('historical receipt period must precede current statement period'); + } + } const sortedReceipts = rows.map((row) => row.signed); const hashes = rows.map((row) => receiptHash(row.signed)); + const historicalHashes = sortedStrings(historicalRows.map((row) => receiptHash(row.signed))); + if (new Set([...hashes, ...historicalHashes]).size !== hashes.length + historicalHashes.length) { + throw new Error('receipt cannot be both current and historical'); + } const reservationTotal = rows.reduce( (sum, row) => sum + toAtomic(row.receipt.reservedAtomic), 0n, ); @@ -505,29 +627,64 @@ export function buildStatement({ ? sum + toAtomic(row.receipt.invocationAwardAtomic) : sum ), 0n); - const awardsByHash = new Map(rows.map((row) => [ - receiptHash(row.signed), - { + + const awardsByHash = new Map(); + for (const activity of priorPayload?.awardActivity ?? []) { + awardsByHash.set(activity.receiptHash, { + amount: toAtomic(activity.earnedAtomic), + advance: toAtomic(activity.advancedAtomic), + earnedReversal: toAtomic(activity.earnedReversedAtomic), + payableReversal: toAtomic(activity.payableReversedAtomic), + }); + } + for (const row of rows) { + const hash = receiptHash(row.signed); + if (awardsByHash.has(hash)) throw new Error('current receipt was already recognized by prior statement'); + awardsByHash.set(hash, { amount: ['earned', 'payable', 'paid'].includes(row.receipt.awardState) ? toAtomic(row.receipt.invocationAwardAtomic) : 0n, advance: 0n, earnedReversal: 0n, payableReversal: 0n, - }, - ])); + }); + } + const historicalSet = new Set(historicalHashes); + for (const row of historicalRows) { + const hash = receiptHash(row.signed); + const activity = awardsByHash.get(hash); + if (!priorPayload || !activity) { + throw new Error('historical receipt is not authenticated by the prior statement chain'); + } + const receiptEarned = ['earned', 'payable', 'paid'].includes(row.receipt.awardState) + ? toAtomic(row.receipt.invocationAwardAtomic) + : 0n; + if (receiptEarned !== activity.amount) { + throw new Error('historical receipt award does not match prior statement activity'); + } + } const advances = uniqueSorted(payableAdvances, 'advanceId', 'payable advances', validateAdvance); + for (const advance of advances) { + assertTimestampInPeriod(advance.advancedAt, period, 'advance advancedAt'); + } for (const advance of advances) { const award = awardsByHash.get(advance.receiptHash); - if (!award) throw new Error('payable advance references an unknown receipt'); + if (!award || (!hashes.includes(advance.receiptHash) && !historicalSet.has(advance.receiptHash))) { + throw new Error('payable advance references an unauthenticated current or historical receipt'); + } award.advance += toAtomic(advance.amountAtomic); if (award.advance > award.amount) throw new Error('payable advance exceeds earned award'); } const reversalRows = uniqueSorted(reversals, 'reversalId', 'reversals', validateReversal); + for (const reversal of reversalRows) { + assertTimestampInPeriod(reversal.occurredAt, period, 'reversal occurredAt'); + } for (const reversal of reversalRows) { const award = awardsByHash.get(reversal.receiptHash); - if (!award) throw new Error('reversal references an unknown receipt'); + if (!award || (!hashes.includes(reversal.receiptHash) && !historicalSet.has(reversal.receiptHash))) { + throw new Error('reversal references an unauthenticated current or historical receipt'); + } if (reversal.balanceEffect === 'earned_only') { award.earnedReversal += toAtomic(reversal.amountAtomic); if (award.advance + award.earnedReversal > award.amount) { @@ -541,6 +698,32 @@ export function buildStatement({ } } const paymentRows = uniqueSorted(payments, 'paymentId', 'payments', validatePayment); + for (const payment of paymentRows) { + assertTimestampInPeriod(payment.paidAt, period, 'payment paidAt'); + } + if (new Set(paymentRows.map((row) => row.railReference)).size !== paymentRows.length) { + throw new Error('duplicate payment railReference in statement'); + } + const cumulativeAdvanceIds = cumulativeIds( + priorPayload?.cumulativeAdvanceIds, + advances.map((row) => row.advanceId), + 'payable advance', + ); + const cumulativeReversalIds = cumulativeIds( + priorPayload?.cumulativeReversalIds, + reversalRows.map((row) => row.reversalId), + 'reversal', + ); + const cumulativePaymentIds = cumulativeIds( + priorPayload?.cumulativePaymentIds, + paymentRows.map((row) => row.paymentId), + 'payment', + ); + const cumulativePaymentRailReferences = cumulativeIds( + priorPayload?.cumulativePaymentRailReferences, + paymentRows.map((row) => row.railReference), + 'payment railReference', + ); const payableAdvanceTotal = advances.reduce((sum, row) => sum + toAtomic(row.amountAtomic), 0n); const reversalTotal = reversalRows.reduce((sum, row) => sum + toAtomic(row.amountAtomic), 0n); const payableReversalTotal = reversalRows.reduce((sum, row) => ( @@ -550,6 +733,16 @@ export function buildStatement({ const payableBeforePayment = opening + payableAdvanceTotal - payableReversalTotal; if (paymentTotal > payableBeforePayment) throw new Error('payments exceed payable balance'); const closing = payableBeforePayment - paymentTotal; + const awardActivity = sortedStrings(awardsByHash.keys()).map((hash) => { + const value = awardsByHash.get(hash); + return deepFreeze({ + receiptHash: hash, + earnedAtomic: fromAtomic(value.amount), + advancedAtomic: fromAtomic(value.advance), + earnedReversedAtomic: fromAtomic(value.earnedReversal), + payableReversedAtomic: fromAtomic(value.payableReversal), + }); + }); return validateStatementPayload({ schemaVersion: 1, @@ -560,10 +753,14 @@ export function buildStatement({ currency, atomicScale, openingPayableAtomic, + priorStatementHash: priorHash, + priorClosingPayableAtomic: priorPayload?.closingPayableAtomic ?? null, firstReceiptSequence: rows.length === 0 ? null : rows[0].receipt.sequence, lastReceiptSequence: rows.length === 0 ? null : rows.at(-1).receipt.sequence, + lastRecognizedReceiptSequence, receiptHashes: hashes, receiptMerkleRoot: receiptMerkleRoot(sortedReceipts), + historicalReceiptHashes: historicalHashes, reservationTotalAtomic: fromAtomic(reservationTotal), releaseTotalAtomic: fromAtomic(releaseTotal), chargeTotalAtomic: fromAtomic(chargeTotal), @@ -575,6 +772,11 @@ export function buildStatement({ payableReversalTotalAtomic: fromAtomic(payableReversalTotal), payments: paymentRows, paymentTotalAtomic: fromAtomic(paymentTotal), + cumulativeAdvanceIds, + cumulativeReversalIds, + cumulativePaymentIds, + cumulativePaymentRailReferences, + awardActivity, closingPayableAtomic: fromAtomic(closing), statementSignerId, }); @@ -589,15 +791,39 @@ function validateStatementPayload(input) { if (!Number.isSafeInteger(input.atomicScale) || input.atomicScale < 0 || input.atomicScale > 18) { throw new Error('statement atomicScale must be an integer from 0 through 18'); } + if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(input.period)) throw new Error('statement period must be YYYY-MM'); + const noPrior = input.priorStatementHash === null && input.priorClosingPayableAtomic === null; + const hasPrior = SHA256_PATTERN.test(input.priorStatementHash) + && typeof input.priorClosingPayableAtomic === 'string'; + if (!noPrior && !hasPrior) throw new Error('statement prior chain fields are invalid'); + if (input.priorClosingPayableAtomic !== null) toAtomic(input.priorClosingPayableAtomic); const bothNull = input.firstReceiptSequence === null && input.lastReceiptSequence === null; const bothIntegers = Number.isSafeInteger(input.firstReceiptSequence) && Number.isSafeInteger(input.lastReceiptSequence) && input.firstReceiptSequence >= 1 && input.lastReceiptSequence >= input.firstReceiptSequence; if (!bothNull && !bothIntegers) throw new Error('statement receipt sequence bounds are invalid'); + if (!Number.isSafeInteger(input.lastRecognizedReceiptSequence) + || input.lastRecognizedReceiptSequence < 0) { + throw new Error('statement lastRecognizedReceiptSequence must be a non-negative integer'); + } + if (bothIntegers && input.lastRecognizedReceiptSequence !== input.lastReceiptSequence) { + throw new Error('statement receipt cursor must equal the current last receipt sequence'); + } + if (input.priorStatementHash === null) { + const expectedGenesisCursor = bothNull ? 0 : input.lastReceiptSequence; + if (input.lastRecognizedReceiptSequence !== expectedGenesisCursor + || (bothIntegers && input.firstReceiptSequence !== 1)) { + throw new Error('genesis statement receipt sequence must begin at 1'); + } + } if (!Array.isArray(input.receiptHashes) || input.receiptHashes.some((hash) => !SHA256_PATTERN.test(hash))) { throw new Error('statement receiptHashes are invalid'); } + if (!Array.isArray(input.historicalReceiptHashes) + || input.historicalReceiptHashes.some((hash) => !SHA256_PATTERN.test(hash))) { + throw new Error('statement historicalReceiptHashes are invalid'); + } if (!SHA256_PATTERN.test(input.receiptMerkleRoot)) throw new Error('statement receiptMerkleRoot is invalid'); for (const key of [ 'openingPayableAtomic', 'reservationTotalAtomic', 'releaseTotalAtomic', @@ -608,6 +834,44 @@ function validateStatementPayload(input) { uniqueSorted(input.payableAdvances, 'advanceId', 'payable advances', validateAdvance); uniqueSorted(input.reversals, 'reversalId', 'reversals', validateReversal); uniqueSorted(input.payments, 'paymentId', 'payments', validatePayment); + for (const advance of input.payableAdvances) { + assertTimestampInPeriod(advance.advancedAt, input.period, 'advance advancedAt'); + } + for (const reversal of input.reversals) { + assertTimestampInPeriod(reversal.occurredAt, input.period, 'reversal occurredAt'); + } + for (const payment of input.payments) { + assertTimestampInPeriod(payment.paidAt, input.period, 'payment paidAt'); + } + if (new Set(input.payments.map((row) => row.railReference)).size !== input.payments.length) { + throw new Error('duplicate payment railReference in statement'); + } + for (const [values, label] of [ + [input.cumulativeAdvanceIds, 'cumulativeAdvanceIds'], + [input.cumulativeReversalIds, 'cumulativeReversalIds'], + [input.cumulativePaymentIds, 'cumulativePaymentIds'], + [input.cumulativePaymentRailReferences, 'cumulativePaymentRailReferences'], + ]) { + if (!Array.isArray(values)) throw new Error(`${label} must be an array`); + for (const value of values) { + if (label === 'cumulativePaymentRailReferences') { + requireString(value, `${label} entry`); + } else { + requireSortableId(value, `${label} entry`); + } + } + if (new Set(values).size !== values.length + || JSON.stringify(values) !== JSON.stringify(sortedStrings(values))) { + throw new Error(`${label} must be unique and code-unit sorted`); + } + } + if (!Array.isArray(input.awardActivity)) throw new Error('awardActivity must be an array'); + const activity = input.awardActivity.map(validateAwardActivity); + if (new Set(activity.map((row) => row.receiptHash)).size !== activity.length + || JSON.stringify(activity.map((row) => row.receiptHash)) + !== JSON.stringify(sortedStrings(activity.map((row) => row.receiptHash)))) { + throw new Error('awardActivity must have unique code-unit-sorted receipt hashes'); + } return cloneFrozen(input); } @@ -624,13 +888,30 @@ export function signStatement(unsignedStatement, privateKey) { }); } -export function verifyStatement(signedStatement, { +function validateSignedStatementShape(signedStatement) { + requireExactKeys(signedStatement, SIGNED_STATEMENT_KEYS, 'signed statement'); + const statement = validateStatementPayload(ordered(signedStatement, STATEMENT_KEYS)); + decodeSignature(signedStatement.signature, 'statement'); + return statement; +} + +function canonicalSignedStatementBytes(signedStatement) { + validateSignedStatementShape(signedStatement); + return canonicalBytes(signedStatement, SIGNED_STATEMENT_KEYS); +} + +export function statementHash(signedStatement) { + return taggedHash(canonicalSignedStatementBytes(signedStatement)); +} + +function verifyOneStatement(signedStatement, { signedReceipts, + historicalSignedReceipts, + priorStatement, trustedReceiptSigners, trustedStatementSigners, }) { - requireExactKeys(signedStatement, SIGNED_STATEMENT_KEYS, 'signed statement'); - const statement = validateStatementPayload(ordered(signedStatement, STATEMENT_KEYS)); + const statement = validateSignedStatementShape(signedStatement); const key = trustedKey( trustedStatementSigners, statement.statementSignerId, @@ -642,7 +923,7 @@ export function verifyStatement(signedStatement, { key, decodeSignature(signedStatement.signature, 'statement'), )) throw new Error('statement signature verification failed'); - for (const receipt of signedReceipts) { + for (const receipt of [...signedReceipts, ...historicalSignedReceipts]) { verifyReceipt(receipt, { trustedReceiptSigners }); } const recomputed = buildStatement({ @@ -654,6 +935,8 @@ export function verifyStatement(signedStatement, { atomicScale: statement.atomicScale, openingPayableAtomic: statement.openingPayableAtomic, receipts: signedReceipts, + historicalReceipts: historicalSignedReceipts, + priorStatement, payableAdvances: statement.payableAdvances, reversals: statement.reversals, payments: statement.payments, @@ -666,6 +949,37 @@ export function verifyStatement(signedStatement, { return statement; } +export function verifyStatement(signedStatement, { + signedReceipts, + historicalSignedReceipts = [], + priorStatements = [], + trustedReceiptSigners, + trustedStatementSigners, +}) { + if (!Array.isArray(priorStatements)) throw new Error('priorStatements must be an array'); + let prior = null; + for (const [index, entry] of priorStatements.entries()) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error(`prior statement entry ${index} must be an object`); + } + verifyOneStatement(entry.signedStatement, { + signedReceipts: entry.signedReceipts ?? [], + historicalSignedReceipts: entry.historicalSignedReceipts ?? [], + priorStatement: prior, + trustedReceiptSigners, + trustedStatementSigners, + }); + prior = entry.signedStatement; + } + return verifyOneStatement(signedStatement, { + signedReceipts, + historicalSignedReceipts, + priorStatement: prior, + trustedReceiptSigners, + trustedStatementSigners, + }); +} + function stableJson(value) { if (typeof value === 'bigint') throw new Error('JSON-safe records cannot contain BigInt'); if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value); diff --git a/spikes/internal-invocation-awards/test/budget.test.mjs b/spikes/internal-invocation-awards/test/budget.test.mjs index 00f71fa..79079b3 100644 --- a/spikes/internal-invocation-awards/test/budget.test.mjs +++ b/spikes/internal-invocation-awards/test/budget.test.mjs @@ -14,7 +14,9 @@ import { startReservationExecution, } from '../src/budget.mjs'; import { + canonicalPolicyBytes, parseExecutorOutcome, + policyHash, sumAtomic, toAtomic, validatePolicy, @@ -35,6 +37,7 @@ const ACTIVE_POLICY = { permittedSkillIds: ['ledger-recon'], permittedCreatorIds: ['sam'], permittedWielderIds: ['megacorp-internal-agent'], + permittedInitiatingPrincipalIds: ['sam', 'jordan'], permittedCostCenters: ['platform-engineering'], maxQuoteAtomic: '4000000', awardRule: { @@ -48,6 +51,7 @@ const ACTIVE_POLICY = { selfInvocation: 'manager_approval_required', permittedManagerSignerIds: ['manager-alex'], permittedCredentialAuthorizerIds: ['megacorp-collar-authorizer'], + permittedIdentitySignerIds: ['megacorp-identity'], permittedFinanceSignerIds: ['megacorp-finance'], vestingRule: 'none', paymentSchedule: 'monthly_in_arrears', @@ -64,10 +68,13 @@ const QUOTE = { skillVersionHash: `sha256:${'1'.repeat(64)}`, creatorId: 'sam', wielderId: 'megacorp-internal-agent', + initiatingPrincipalId: 'jordan', + principalAttestationId: 'principal-attestation-inv-001', beneficiaryId: 'megacorp', costCenter: 'platform-engineering', policyId: ACTIVE_POLICY.policyId, policyVersion: 1, + policyHash: policyHash(ACTIVE_POLICY), maxExecutionCostAtomic: '1000000', protocolFeeAtomic: '25000', refundReserveAtomic: '25000', @@ -109,6 +116,14 @@ test('policy validation is effective-dated, exact, recursively frozen, and denom assert.equal(validatePolicy({ ...ACTIVE_POLICY, currency: 'EUR', atomicScale: 2 }, NOW).currency, 'EUR'); }); +test('canonical policy bytes hash every field under the same ID and version', () => { + const hash = policyHash(ACTIVE_POLICY); + assert.match(hash, /^sha256:[0-9a-f]{64}$/); + assert.ok(canonicalPolicyBytes(ACTIVE_POLICY) instanceof Uint8Array); + assert.notEqual(hash, policyHash({ ...ACTIVE_POLICY, paymentRail: 'another_employer_rail' })); + assert.equal(hash, policyHash(structuredClone(ACTIVE_POLICY))); +}); + test('quote validation binds exact maximums and keeps manager approval separate', () => { const quote = validateQuote(QUOTE, ACTIVE_POLICY, NOW); assert.ok(Object.isFrozen(quote)); @@ -156,6 +171,7 @@ const UNSIGNED_BUDGET_AUTHORIZATION = { budgetId: 'budget-megacorp-2026-07', policyId: ACTIVE_POLICY.policyId, policyVersion: 1, + policyHash: policyHash(ACTIVE_POLICY), period: '2026-07', currency: 'USD', atomicScale: 6, @@ -318,6 +334,18 @@ test('insufficient budget, exact failed COGS, cancellation, and unresolved holds assert.equal(failed.budget.consumedAtomic, '700000'); assert.equal(failed.budget.releasedAtomic, '2350000'); assert.equal(failed.budget.reservedAtomic, '0'); + assert.deepEqual(failed.allocation.journalEntries, [{ + category: 'execution-cogs', + debitAccountId: 'employer:invocation-gross', + creditAccountId: 'provider:execution', + amountAtomic: 700_000n, + }]); + assert.deepEqual(failed.event.journalEntries, [{ + category: 'execution-cogs', + debitAccountId: 'employer:invocation-gross', + creditAccountId: 'provider:execution', + amountAtomic: '700000', + }]); const cancelReserved = reserveBudget(verifiedBudget(), QUOTE, { expectedRevision: 0, reservationId: 'res-cancel', now: NOW, @@ -328,6 +356,8 @@ test('insufficient budget, exact failed COGS, cancellation, and unresolved holds }); assert.equal(cancelled.budget.releasedAtomic, '3050000'); assert.equal(cancelled.budget.consumedAtomic, '0'); + assert.equal(cancelled.allocation, null); + assert.deepEqual(cancelled.event.journalEntries, []); const heldReserved = reserveBudget(verifiedBudget(), QUOTE, { expectedRevision: 0, reservationId: 'res-held', now: NOW, diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs index 8db24f3..a733cc3 100644 --- a/spikes/internal-invocation-awards/test/engine.test.mjs +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -1,11 +1,16 @@ import assert from 'node:assert/strict'; -import { generateKeyPairSync } from 'node:crypto'; +import { + createHash, + generateKeyPairSync, + sign as cryptoSign, +} from 'node:crypto'; import test from 'node:test'; import { signBudget } from '../src/budget.mjs'; import { signCredential, signManagerApproval, + signPrincipalAttestation, verifyCredential, } from '../src/credentials.mjs'; import { @@ -14,13 +19,25 @@ import { createEngineState, executeAuthorizedInvocation, } from '../src/engine.mjs'; +import { policyHash, skillRegistrationKey } from '../src/schema.mjs'; +import { receiptHash, verifyReceipt } from '../src/statements.mjs'; import { InMemoryEngineStore } from '../src/store.mjs'; const NOW = '2026-07-17T00:01:00.000Z'; const AFTER_EXPIRY = '2026-07-17T00:06:00.000Z'; const SKILL_HASH = `sha256:${'1'.repeat(64)}`; +const KIM_SKILL_HASH = `sha256:${'2'.repeat(64)}`; +const UNKNOWN_SKILL_HASH = `sha256:${'3'.repeat(64)}`; const OUTPUT_HASH = `sha256:${'a'.repeat(64)}`; +function publicPem(keyPair) { + return keyPair.publicKey.export({ type: 'spki', format: 'pem' }); +} + +function nonce(label) { + return createHash('sha256').update(String(label)).digest('hex'); +} + function policy(overrides = {}) { return { schemaVersion: 1, @@ -33,8 +50,9 @@ function policy(overrides = {}) { effectiveAt: '2026-07-17T00:00:00.000Z', expiresAt: '2026-08-01T00:00:00.000Z', permittedSkillIds: ['ledger-recon'], - permittedCreatorIds: ['sam'], + permittedCreatorIds: ['sam', 'kim'], permittedWielderIds: ['megacorp-internal-agent'], + permittedInitiatingPrincipalIds: ['sam', 'kim', 'jordan'], permittedCostCenters: ['platform-engineering'], maxQuoteAtomic: '4000000', awardRule: { @@ -48,6 +66,7 @@ function policy(overrides = {}) { selfInvocation: 'manager_approval_required', permittedManagerSignerIds: ['manager-alex'], permittedCredentialAuthorizerIds: ['megacorp-collar-authorizer'], + permittedIdentitySignerIds: ['megacorp-identity'], permittedFinanceSignerIds: ['megacorp-finance'], vestingRule: 'none', paymentSchedule: 'monthly_in_arrears', @@ -57,7 +76,27 @@ function policy(overrides = {}) { }; } -function quote(suffix = '001', overrides = {}) { +function registration({ + creatorId = 'sam', + skillVersionHash = SKILL_HASH, + status = 'active', + effectiveAt = '2026-07-17T00:00:00.000Z', + expiresAt = '2026-08-01T00:00:00.000Z', +} = {}) { + return { + schemaVersion: 1, + registrationId: `registration-ledger-recon-${creatorId}-${skillVersionHash.slice(-4)}`, + skillId: 'ledger-recon', + skillVersionHash, + creatorId, + employerId: 'megacorp', + status, + effectiveAt, + expiresAt, + }; +} + +function makeQuote(activePolicy, suffix = '001', overrides = {}) { return { schemaVersion: 1, quoteId: `quote-inv-${suffix}`, @@ -67,10 +106,13 @@ function quote(suffix = '001', overrides = {}) { skillVersionHash: SKILL_HASH, creatorId: 'sam', wielderId: 'megacorp-internal-agent', + initiatingPrincipalId: 'jordan', + principalAttestationId: `principal-attestation-${suffix}`, beneficiaryId: 'megacorp', costCenter: 'platform-engineering', - policyId: 'policy-megacorp-ledger-recon', - policyVersion: 1, + policyId: activePolicy.policyId, + policyVersion: activePolicy.version, + policyHash: policyHash(activePolicy), maxExecutionCostAtomic: '1000000', protocolFeeAtomic: '25000', refundReserveAtomic: '25000', @@ -81,21 +123,62 @@ function quote(suffix = '001', overrides = {}) { }; } -function nonce(number = 1) { - return number.toString(16).padStart(64, '0'); +function principalAttestation(fx, quote, overrides = {}, signer = fx.identity.privateKey) { + return signPrincipalAttestation({ + schemaVersion: 1, + attestationId: quote.principalAttestationId, + identitySignerId: 'megacorp-identity', + principalId: quote.initiatingPrincipalId, + invocationId: quote.invocationId, + idempotencyKey: quote.idempotencyKey, + skillId: quote.skillId, + skillVersionHash: quote.skillVersionHash, + creatorId: quote.creatorId, + wielderId: quote.wielderId, + beneficiaryId: quote.beneficiaryId, + policyId: quote.policyId, + policyVersion: quote.policyVersion, + policyHash: quote.policyHash, + nonce: nonce(`principal:${quote.invocationId}`), + issuedAt: NOW, + expiresAt: quote.expiresAt, + ...overrides, + }, signer); +} + +function managerApproval(fx, quote) { + return signManagerApproval({ + schemaVersion: 1, + approvalId: `approval-${quote.invocationId}`, + managerSignerId: 'manager-alex', + invocationId: quote.invocationId, + creatorId: quote.creatorId, + policyId: quote.policyId, + policyVersion: quote.policyVersion, + issuedAt: NOW, + expiresAt: quote.expiresAt, + }, fx.manager.privateKey); } -function fixture({ policyOverrides = {}, budgetOverrides = {} } = {}) { +function fixture({ + policyOverrides = {}, + budgetOverrides = {}, + registrations, + receiptSign = null, +} = {}) { const finance = generateKeyPairSync('ed25519'); const authorizer = generateKeyPairSync('ed25519'); const manager = generateKeyPairSync('ed25519'); + const identity = generateKeyPairSync('ed25519'); + const receipt = generateKeyPairSync('ed25519'); const activePolicy = policy(policyOverrides); - const managerSignerId = activePolicy.permittedManagerSignerIds[0]; + const clock = { now: NOW }; const signedBudget = signBudget({ schemaVersion: 1, budgetId: 'budget-megacorp-2026-07', policyId: activePolicy.policyId, policyVersion: activePolicy.version, + policyHash: policyHash(activePolicy), period: '2026-07', currency: activePolicy.currency, atomicScale: activePolicy.atomicScale, @@ -105,48 +188,79 @@ function fixture({ policyOverrides = {}, budgetOverrides = {} } = {}) { signerId: 'megacorp-finance', ...budgetOverrides, }, finance.privateKey); - const state = createEngineState({ + const registrationRows = registrations ?? [ + registration(), + registration({ creatorId: 'kim', skillVersionHash: KIM_SKILL_HASH }), + ]; + const skillRegistrations = Object.fromEntries(registrationRows.map((row) => [ + skillRegistrationKey(row.skillId, row.skillVersionHash), + row, + ])); + const configuration = { signedBudget, policies: { [`${activePolicy.policyId}@${activePolicy.version}`]: activePolicy }, - financeSigners: { - 'megacorp-finance': finance.publicKey.export({ type: 'spki', format: 'pem' }), + skillRegistrations, + financeSigners: { 'megacorp-finance': publicPem(finance) }, + managerSigners: { 'manager-alex': publicPem(manager) }, + credentialAuthorizers: { 'megacorp-collar-authorizer': publicPem(authorizer) }, + identitySigners: { 'megacorp-identity': publicPem(identity) }, + receiptSigners: { 'megacorp-receipts': publicPem(receipt) }, + clock: () => clock.now, + receiptSigner: { + signerId: 'megacorp-receipts', + sign: receiptSign ?? ((bytes) => ( + cryptoSign(null, bytes, receipt.privateKey).toString('base64') + )), }, - managerSigners: { - [managerSignerId]: manager.publicKey.export({ type: 'spki', format: 'pem' }), - }, - credentialAuthorizers: { - 'megacorp-collar-authorizer': authorizer.publicKey.export({ type: 'spki', format: 'pem' }), - }, - now: NOW, - }); + }; + const state = createEngineState(configuration); return { store: new InMemoryEngineStore(state), activePolicy, finance, authorizer, manager, - managerSignerId, + identity, + receipt, + clock, + configuration, }; } -async function authorize(fx, q = quote(), overrides = {}) { +async function authorize(fx, quote, overrides = {}) { + const snapshot = fx.store.snapshot(); + const self = quote.initiatingPrincipalId === quote.creatorId; + const defaultApproval = self ? managerApproval(fx, quote) : null; return authorizeInternalInvocation({ store: fx.store, - quote: q, - expectedRevision: 0, - expectedBudgetRevision: 0, - reservationId: `res-${q.invocationId}`, - credentialNonce: nonce(1), + quote, + expectedRevision: snapshot.revision, + expectedBudgetRevision: snapshot.budget.revision, + reservationId: `res-${quote.invocationId}`, + credentialNonce: nonce(`credential:${quote.invocationId}`), credentialIssuedAt: NOW, credentialExpiresAt: '2026-07-17T00:10:00.000Z', credentialAuthorizerId: 'megacorp-collar-authorizer', - managerApproval: null, - now: NOW, + principalAttestation: principalAttestation(fx, quote), + managerApproval: defaultApproval, ...overrides, }); } -test('credential signatures bind exact fields, lowercase nonce, and expiry', () => { +async function executeSuccess(fx, quote, authorized, executor = null) { + return executeAuthorizedInvocation({ + store: fx.store, + quote, + credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), + executor: executor ?? (async () => ({ + kind: 'succeeded', + executionCostAtomic: '700000', + outputHash: OUTPUT_HASH, + })), + }); +} + +test('credential signatures bind principal, policy, Skill registration inputs, nonce, and expiry', () => { const { publicKey, privateKey } = generateKeyPairSync('ed25519'); const payload = { schemaVersion: 1, @@ -156,297 +270,442 @@ test('credential signatures bind exact fields, lowercase nonce, and expiry', () idempotencyKey: 'run-ledger-recon-001', skillId: 'ledger-recon', skillVersionHash: SKILL_HASH, + creatorId: 'sam', + wielderId: 'megacorp-internal-agent', + initiatingPrincipalId: 'jordan', + principalAttestationId: 'principal-attestation-001', + principalAttestationHash: `sha256:${'b'.repeat(64)}`, policyId: 'policy-megacorp-ledger-recon', policyVersion: 1, - nonce: nonce(1), + policyHash: `sha256:${'c'.repeat(64)}`, + nonce: nonce('credential'), issuedAt: NOW, expiresAt: '2026-07-17T00:05:00.000Z', }; const signed = signCredential(payload, privateKey); - assert.equal(verifyCredential(signed, publicKey, NOW).invocationId, 'inv-001'); + assert.equal(verifyCredential(signed, publicKey, NOW).initiatingPrincipalId, 'jordan'); assert.throws( - () => verifyCredential({ ...signed, skillVersionHash: `sha256:${'2'.repeat(64)}` }, publicKey, NOW), + () => verifyCredential({ ...signed, policyHash: `sha256:${'d'.repeat(64)}` }, publicKey, NOW), /signature/, ); assert.throws(() => verifyCredential(signed, publicKey, AFTER_EXPIRY), /expired/); - assert.throws(() => signCredential({ ...payload, nonce: `0x${nonce(1)}` }, privateKey), /lowercase 64-character hex/); + assert.throws( + () => signCredential({ ...payload, nonce: `0x${nonce('credential')}` }, privateKey), + /lowercase 64-character hex/, + ); +}); + +test('authorization requires an active engine-provisioned Skill version and canonical Creator', async (t) => { + await t.test('missing and wrong hashes fail before reservation', async () => { + const fx = fixture({ + registrations: [registration({ creatorId: 'kim', skillVersionHash: KIM_SKILL_HASH })], + }); + const q = makeQuote(fx.activePolicy); + await assert.rejects(() => authorize(fx, q), /Skill version is not provisioned/); + assert.equal(fx.store.snapshot().revision, 0); + await assert.rejects( + () => authorize(fx, makeQuote(fx.activePolicy, 'wrong-hash', { + skillVersionHash: UNKNOWN_SKILL_HASH, + })), + /Skill version is not provisioned/, + ); + assert.equal(fx.store.snapshot().budget.reservedAtomic, '0'); + }); + + await t.test('revoked and wrong-Creator registrations fail before reservation', async () => { + const revoked = fixture({ registrations: [registration({ status: 'revoked' })] }); + await assert.rejects( + () => authorize(revoked, makeQuote(revoked.activePolicy)), + /registration is revoked/i, + ); + assert.equal(revoked.store.snapshot().budget.reservedAtomic, '0'); + + const wrongCreator = fixture({ + registrations: [registration({ creatorId: 'kim', skillVersionHash: SKILL_HASH })], + }); + await assert.rejects( + () => authorize(wrongCreator, makeQuote(wrongCreator.activePolicy)), + /Creator does not match quote Creator/, + ); + assert.equal(wrongCreator.store.snapshot().budget.reservedAtomic, '0'); + }); + + await t.test('caller cannot inject or replace a registration at authorization', async () => { + const fx = fixture(); + const q = makeQuote(fx.activePolicy); + await assert.rejects(() => authorize(fx, q, { + skillRegistration: registration({ creatorId: 'kim' }), + }), /unknown key skillRegistration/); + assert.equal(fx.store.snapshot().revision, 0); + }); +}); + +test('shared Wielder self policy is based on initiating principal, not agent identity', async () => { + const fx = fixture(); + const sam = makeQuote(fx.activePolicy, 'sam-principal', { + initiatingPrincipalId: 'sam', + }); + await assert.rejects( + () => authorize(fx, sam, { managerApproval: null }), + /manager approval is required/, + ); + assert.equal(fx.store.snapshot().revision, 0); + const approved = await authorize(fx, sam); + assert.equal(approved.invocation.initiatingPrincipalId, 'sam'); + + const otherFx = fixture(); + const jordan = makeQuote(otherFx.activePolicy, 'jordan-principal'); + const nonSelf = await authorize(otherFx, jordan); + assert.equal(nonSelf.invocation.wielderId, 'megacorp-internal-agent'); + assert.equal(nonSelf.invocation.initiatingPrincipalId, 'jordan'); }); -test('authorization reserves before signing and successful execution conserves exact gross', async () => { +test('principal attestation trust, binding, and nonce replay fail closed', async () => { const fx = fixture(); - const q = quote(); + const q1 = makeQuote(fx.activePolicy, 'principal-1'); + const tampered = { + ...principalAttestation(fx, q1), + principalId: 'sam', + }; + await assert.rejects( + () => authorize(fx, q1, { principalAttestation: tampered }), + /binding does not match quote|signature/, + ); + assert.equal(fx.store.snapshot().revision, 0); + + const attacker = generateKeyPairSync('ed25519'); + const untrusted = principalAttestation( + fx, + q1, + { identitySignerId: 'attacker-identity' }, + attacker.privateKey, + ); + await assert.rejects( + () => authorize(fx, q1, { principalAttestation: untrusted }), + /identity signer is not permitted/, + ); + + const sharedNonce = nonce('shared-principal-nonce'); + await authorize(fx, q1, { + principalAttestation: principalAttestation(fx, q1, { nonce: sharedNonce }), + }); + const q2 = makeQuote(fx.activePolicy, 'principal-2'); + await assert.rejects( + () => authorize(fx, q2, { + principalAttestation: principalAttestation(fx, q2, { nonce: sharedNonce }), + }), + /attestation nonce already consumed/, + ); + assert.equal(Object.keys(fx.store.snapshot().reservations).length, 1); +}); + +test('successful execution atomically commits policy-bound signed receipt and exact gross', async () => { + const fx = fixture(); + const q = makeQuote(fx.activePolicy); const authorized = await authorize(fx, q); assert.equal(authorized.reservation.state, 'reserved'); assert.equal(authorized.credentialPayload.expiresAt, q.expiresAt); - assert.deepEqual(authorized.invocation.credentialPayload, authorized.credentialPayload); - assert.equal(authorized.invocation.state, 'authorized'); - assert.equal(authorized.invocation.externalRoyaltyCreditsAtomic, '0'); - assert.equal(authorized.invocation.employerSelfCreditAtomic, '0'); - - const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); - const result = await executeAuthorizedInvocation({ - store: fx.store, - quote: q, - credential, - executor: async () => ({ - kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH, - }), - now: NOW, - }); + assert.equal(authorized.invocation.skillRegistrationId, 'registration-ledger-recon-sam-1111'); + const result = await executeSuccess(fx, q, authorized); assert.equal(result.invocation.state, 'succeeded'); assert.equal(result.award.amountAtomic, '2000000'); - assert.equal(result.award.state, 'earned'); + assert.equal(result.award.recipientId, 'sam'); assert.equal(result.budget.consumedAtomic, '2750000'); assert.equal(result.budget.releasedAtomic, '300000'); assert.equal(result.allocation.journalEntries.length, 4); + assert.equal(result.receipt.policyHash, policyHash(fx.activePolicy)); + assert.equal(result.receipt.initiatingPrincipalId, 'jordan'); + assert.equal(result.receipt.sequence, 1); + assert.equal(receiptHash(result.receipt), result.receiptHash); + const { signature: _signature, ...unsignedReceipt } = result.receipt; + assert.deepEqual( + verifyReceipt(result.receipt, { trustedReceiptSigners: fx.store.snapshot().receiptSigners }), + unsignedReceipt, + ); assert.doesNotThrow(() => JSON.stringify(result)); - assert.doesNotThrow(() => JSON.stringify(result.state)); - await assert.rejects(() => executeAuthorizedInvocation({ - store: fx.store, - quote: q, - credential, - executor: async () => { throw new Error('must not run'); }, - now: NOW, - }), /credential already consumed|Invocation is not authorized|idempotency/); }); -test('execution re-establishes signed budget trust and expiry at start', async () => { - const fx = fixture({ budgetOverrides: { expiresAt: '2026-07-17T00:03:00.000Z' } }); - const q = quote(); +test('terminal retry returns the identical committed receipt and never invokes executor again', async () => { + const fx = fixture(); + const q = makeQuote(fx.activePolicy, 'retry'); const authorized = await authorize(fx, q); const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); let calls = 0; - await assert.rejects(() => executeAuthorizedInvocation({ + const first = await executeAuthorizedInvocation({ store: fx.store, quote: q, credential, - executor: async () => { calls += 1; return {}; }, - now: '2026-07-17T00:04:00.000Z', - }), /budget authorization expired/); - assert.equal(calls, 0); - - const fabricated = Object.freeze({ ...fx.store.snapshot() }); - const fabricatedStore = new InMemoryEngineStore(fabricated); - await assert.rejects(() => executeAuthorizedInvocation({ - store: fabricatedStore, + executor: async () => { + calls += 1; + return { kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH }; + }, + }); + const retry = await executeAuthorizedInvocation({ + store: fx.store, quote: q, credential, - executor: async () => { calls += 1; return {}; }, - now: NOW, - }), /trusted engine boundary/); - assert.equal(calls, 0); + executor: async () => { + calls += 1; + throw new Error('must not execute'); + }, + }); + assert.equal(calls, 1); + assert.deepEqual(retry, first); + assert.equal(Object.keys(fx.store.snapshot().receipts).length, 1); }); -test('validated failure consumes exact COGS and creates no award', async () => { +test('validated known failure records exactly one shared-kernel execution COGS row', async () => { const fx = fixture(); - const q = quote(); + const q = makeQuote(fx.activePolicy, 'failure'); const authorized = await authorize(fx, q); const result = await executeAuthorizedInvocation({ store: fx.store, quote: q, credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), executor: async () => ({ - kind: 'failed_after_start', executionCostAtomic: '700000', failureClass: 'provider_error', + kind: 'failed_after_start', + executionCostAtomic: '700000', + failureClass: 'provider_error', }), - now: NOW, }); assert.equal(result.invocation.state, 'failed'); assert.equal(result.budget.consumedAtomic, '700000'); assert.equal(result.budget.releasedAtomic, '2350000'); assert.equal(result.award, null); + assert.deepEqual(result.invocation.journalEntries, [{ + category: 'execution-cogs', + debitAccountId: 'employer:invocation-gross', + creditAccountId: 'provider:execution', + amountAtomic: '700000', + }]); + assert.deepEqual(result.receipt.journalEntries, result.invocation.journalEntries); }); -test('every malformed or unknown-cost post-start outcome keeps the full hold and no award', async (t) => { +test('malformed and unknown-cost outcomes hold the full reservation without journals or award', async (t) => { const outcomes = [ async () => { throw new Error('provider vanished'); }, async () => ({ kind: 'unresolved_after_start', reason: 'cost_unknown' }), - async () => ({ kind: 'unknown' }), - async () => ({ kind: 'failed_after_start', failureClass: 'provider_error' }), - async () => ({ kind: 'failed_after_start', executionCostAtomic: '-1', failureClass: 'provider_error' }), async () => ({ kind: 'failed_after_start', executionCostAtomic: '1.5', failureClass: 'provider_error' }), - async () => ({ kind: 'failed_after_start', executionCostAtomic: 1, failureClass: 'provider_error' }), - async () => ({ kind: 'failed_after_start', executionCostAtomic: '1000001', failureClass: 'provider_error' }), - async () => ({ kind: 'failed_after_start', executionCostAtomic: '1', failureClass: 'provider_error', extra: true }), async () => ({ kind: 'succeeded', executionCostAtomic: '1', outputHash: 'bad' }), ]; for (const [index, executor] of outcomes.entries()) { await t.test(`unresolved case ${index + 1}`, async () => { const fx = fixture(); - const q = quote(String(index + 1).padStart(3, '0')); - const authorized = await authorize(fx, q, { credentialNonce: nonce(index + 1) }); + const q = makeQuote(fx.activePolicy, `unresolved-${index}`); + const authorized = await authorize(fx, q); const result = await executeAuthorizedInvocation({ store: fx.store, quote: q, credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), executor, - now: NOW, }); assert.equal(result.invocation.state, 'unresolved'); assert.equal(result.reservation.state, 'held_unresolved'); assert.equal(result.budget.reservedAtomic, '3050000'); assert.equal(result.budget.consumedAtomic, '0'); - assert.equal(result.budget.releasedAtomic, '0'); assert.equal(result.award, null); - assert.equal(result.invocation.executionCostAtomic, null); - assert.ok(result.events.some((event) => event.type === 'execution_cost_unresolved')); - assert.ok(Object.hasOwn(fx.store.snapshot().consumedNonces, authorized.credentialPayload.nonce)); + assert.deepEqual(result.receipt.journalEntries, []); }); } }); -test('pre-execution trust, identity, idempotency, and manager failures never call executor', async () => { +test('cancellation atomically signs one journal-free terminal receipt', async () => { const fx = fixture(); - await assert.rejects(() => authorize(fx, quote('001', { wielderId: 'outsider' })), /Wielder is not permitted/); - assert.equal(fx.store.snapshot().revision, 0); + const q = makeQuote(fx.activePolicy, 'cancel'); + const authorized = await authorize(fx, q); + const cancelled = await cancelInternalAuthorization({ + store: fx.store, + expectedRevision: fx.store.snapshot().revision, + reservationId: authorized.reservation.reservationId, + reason: 'operator_cancelled', + }); + assert.equal(cancelled.invocation.state, 'cancelled'); + assert.equal(cancelled.receipt.sequence, 1); + assert.deepEqual(cancelled.receipt.journalEntries, []); + assert.equal(cancelled.state.nextReceiptSequences[cancelled.receipt.receiptSequenceScope], 2); + assert.equal(Object.keys(cancelled.state.receipts).length, 1); +}); - const authorized = await authorize(fx); +test('receipt sequence is independent per employer, Creator, currency, and scale', async () => { + const fx = fixture(); + const cases = [ + makeQuote(fx.activePolicy, 'sam-1'), + makeQuote(fx.activePolicy, 'kim-1', { + creatorId: 'kim', + skillVersionHash: KIM_SKILL_HASH, + }), + makeQuote(fx.activePolicy, 'sam-2'), + ]; + const sequences = []; + for (const q of cases) { + const authorized = await authorize(fx, q); + const result = await executeSuccess(fx, q, authorized); + sequences.push(result.receipt.sequence); + } + assert.deepEqual(sequences, [1, 1, 2]); + assert.equal(Object.keys(fx.store.snapshot().nextReceiptSequences).length, 2); +}); + +test('signing failure commits neither terminal state nor receipt and does not re-execute', async () => { + const fx = fixture({ receiptSign: () => { throw new Error('HSM unavailable'); } }); + const q = makeQuote(fx.activePolicy, 'sign-failure'); + const authorized = await authorize(fx, q); const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); - const attacker = generateKeyPairSync('ed25519'); let calls = 0; await assert.rejects(() => executeAuthorizedInvocation({ store: fx.store, - quote: quote(), - credential: signCredential(authorized.credentialPayload, attacker.privateKey), - executor: async () => { calls += 1; return { kind: 'succeeded', executionCostAtomic: '0', outputHash: OUTPUT_HASH }; }, - now: NOW, - }), /signature/); - assert.equal(calls, 0); + quote: q, + credential, + executor: async () => { + calls += 1; + return { kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH }; + }, + }), /HSM unavailable/); + const snapshot = fx.store.snapshot(); + assert.equal(calls, 1); + assert.equal(snapshot.invocations[q.invocationId].state, 'executing'); + assert.equal(Object.keys(snapshot.receipts).length, 0); + assert.equal(Object.keys(snapshot.awards).length, 0); await assert.rejects(() => executeAuthorizedInvocation({ store: fx.store, - quote: quote(), - credential: { ...credential, publicKeyPem: 'self-declared' }, + quote: q, + credential, executor: async () => { calls += 1; return {}; }, - now: NOW, - }), /unknown key publicKeyPem/); - assert.equal(calls, 0); + }), /already in progress|reconciliation/); + assert.equal(calls, 1); }); -test('self Invocation requires a separately signed, trusted, non-self manager approval', async () => { - const fx = fixture({ policyOverrides: { permittedWielderIds: ['megacorp-internal-agent', 'sam'] } }); - const selfQuote = quote('self', { creatorId: 'sam', wielderId: 'sam' }); - await assert.rejects(() => authorize(fx, selfQuote), /manager approval is required/); - const approval = signManagerApproval({ - schemaVersion: 1, - approvalId: 'approval-self-1', - managerSignerId: 'manager-alex', - invocationId: selfQuote.invocationId, - creatorId: 'sam', - policyId: selfQuote.policyId, - policyVersion: 1, - issuedAt: NOW, - expiresAt: selfQuote.expiresAt, - }, fx.manager.privateKey); - const authorized = await authorize(fx, selfQuote, { managerApproval: approval }); - assert.equal(authorized.reservation.state, 'reserved'); - - const fxSelf = fixture({ - policyOverrides: { - permittedWielderIds: ['megacorp-internal-agent', 'sam'], - permittedManagerSignerIds: ['sam'], +test('serialized CAS permits only one execution, award, and signed receipt', async () => { + const fx = fixture(); + const q = makeQuote(fx.activePolicy, 'race'); + const authorized = await authorize(fx, q); + const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); + let calls = 0; + const run = () => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential, + executor: async () => { + calls += 1; + await Promise.resolve(); + return { kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH }; }, }); - const selfManager = generateKeyPairSync('ed25519'); - const badState = fxSelf.store.snapshot(); - // A manager signer cannot be injected through an approval; the trust map remains authoritative. - const selfApproval = signManagerApproval({ - schemaVersion: 1, approvalId: 'self-approved', managerSignerId: 'sam', - invocationId: selfQuote.invocationId, creatorId: 'sam', policyId: selfQuote.policyId, - policyVersion: 1, issuedAt: NOW, expiresAt: selfQuote.expiresAt, - }, selfManager.privateKey); - assert.equal(badState.revision, 0); - await assert.rejects(() => authorize(fxSelf, selfQuote, { managerApproval: selfApproval }), /self-approve|manager signer/); + const results = await Promise.allSettled([run(), run()]); + assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1); + assert.equal(calls, 1); + assert.equal(Object.keys(fx.store.snapshot().awards).length, 1); + assert.equal(Object.keys(fx.store.snapshot().receipts).length, 1); }); -test('cancelled reservation makes its signed credential unusable', async () => { - const fx = fixture(); - const q = quote(); +test('active Skill registration and signed budget trust are re-established before executor start', async () => { + const fx = fixture({ + registrations: [registration({ expiresAt: '2026-07-17T00:02:00.000Z' })], + }); + const q = makeQuote(fx.activePolicy, 'registration-expiry'); const authorized = await authorize(fx, q); - const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); - const cancelled = await cancelInternalAuthorization({ + fx.clock.now = '2026-07-17T00:03:00.000Z'; + let calls = 0; + await assert.rejects(() => executeAuthorizedInvocation({ store: fx.store, - expectedRevision: 1, - reservationId: authorized.reservation.reservationId, - reason: 'operator_cancelled', - now: NOW, - }); - assert.equal(cancelled.invocation.state, 'cancelled'); - assert.equal(cancelled.invocation.receiptSequence, 1); - assert.equal(cancelled.state.nextReceiptSequence, 2); - assert.deepEqual(cancelled.events.map((event) => event.type), [ - 'budget_released', - 'invocation_cancelled', - ]); - assert.equal(cancelled.state.events.filter((event) => event.type === 'budget_released').length, 1); + quote: q, + credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), + executor: async () => { calls += 1; return {}; }, + }), /Skill registration expired/); + assert.equal(calls, 0); + + const fabricated = Object.freeze({ ...fx.store.snapshot() }); + const fabricatedStore = new InMemoryEngineStore(fabricated); await assert.rejects(() => executeAuthorizedInvocation({ - store: fx.store, quote: q, credential, - executor: async () => { throw new Error('must not execute'); }, now: NOW, - }), /Invocation is not authorized|reservation must be reserved/); + store: fabricatedStore, + quote: q, + credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), + executor: async () => { calls += 1; return {}; }, + }), /trusted engine boundary/); + assert.equal(calls, 0); }); -test('serialized CAS permits one stale authorization and one execution attempt', async () => { +test('canonical policy bytes are bound through budget, quote, credential, Invocation, award, and receipt', async () => { const fx = fixture(); - const q1 = quote('001'); - const q2 = quote('002'); - const pending = [ - authorize(fx, q1, { reservationId: 'res-race-1', credentialNonce: nonce(1) }), - authorize(fx, q2, { reservationId: 'res-race-2', credentialNonce: nonce(2) }), - ]; - const settled = await Promise.allSettled(pending); - assert.equal(settled.filter((item) => item.status === 'fulfilled').length, 1); - assert.match(settled.find((item) => item.status === 'rejected').reason.message, /stale engine revision/); - assert.equal(Object.keys(fx.store.snapshot().reservations).length, 1); - assert.equal(Object.keys(fx.store.snapshot().idempotency).length, 1); + const mutated = { + ...fx.activePolicy, + maxAwardPerPeriodAtomic: '99999999', + }; + assert.throws(() => createEngineState({ + ...fx.configuration, + policies: { [`${mutated.policyId}@${mutated.version}`]: mutated }, + }), /policyHash/); + + const q = makeQuote(fx.activePolicy, 'policy-hash'); + await assert.rejects(() => authorize(fx, { ...q, policyHash: policyHash(mutated) }), /policyHash/); + const authorized = await authorize(fx, q); + const result = await executeSuccess(fx, q, authorized); + for (const value of [ + authorized.credentialPayload.policyHash, + authorized.invocation.policyHash, + result.award.policyHash, + result.receipt.policyHash, + ]) assert.equal(value, policyHash(fx.activePolicy)); +}); + +test('employer cannot become a positive employee award recipient at any engine boundary', () => { + assert.throws( + () => fixture({ policyOverrides: { permittedCreatorIds: ['sam', 'megacorp'] } }), + /employer cannot be a permitted employee-Creator/, + ); + assert.throws( + () => fixture({ registrations: [registration({ creatorId: 'megacorp' })] }), + /employer cannot be the employee-Creator/, + ); +}); - const authorized = settled.find((item) => item.status === 'fulfilled').value; - const q = authorized.invocation.invocationId === q1.invocationId ? q1 : q2; +test('lifecycle callers cannot inject clocks, signer capabilities, or registration maps', async () => { + const fx = fixture(); + const q = makeQuote(fx.activePolicy, 'injection'); + await assert.rejects(() => authorize(fx, q, { now: NOW }), /unknown key now/); + await assert.rejects(() => authorize(fx, q, { receiptSigner: {} }), /unknown key receiptSigner/); + assert.equal(fx.store.snapshot().revision, 0); + const authorized = await authorize(fx, q); const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); - let calls = 0; - const input = () => executeAuthorizedInvocation({ - store: fx.store, quote: q, credential, - executor: async () => { - calls += 1; - await Promise.resolve(); - return { kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH }; - }, + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential, + executor: async () => ({}), now: NOW, - }); - const executions = await Promise.allSettled([input(), input()]); - assert.equal(executions.filter((item) => item.status === 'fulfilled').length, 1); - assert.equal(calls, 1); - assert.equal(Object.keys(fx.store.snapshot().awards).length, 1); + }), /unknown key now/); + await assert.rejects(() => cancelInternalAuthorization({ + store: fx.store, + expectedRevision: fx.store.snapshot().revision, + reservationId: authorized.reservation.reservationId, + reason: 'cancel', + receiptSigner: {}, + }), /unknown key receiptSigner/); }); -test('period cap counts conservative earned awards and every open maximum exposure', async () => { +test('period cap counts every open maximum exposure', async () => { const fx = fixture({ policyOverrides: { maxAwardPerPeriodAtomic: '3000000' } }); - await authorize(fx, quote('001')); - await assert.rejects(() => authorize(fx, quote('002'), { - expectedRevision: 1, - expectedBudgetRevision: 1, - reservationId: 'res-inv-002', - credentialNonce: nonce(2), - }), /period award cap/); + await authorize(fx, makeQuote(fx.activePolicy, 'cap-1')); + await assert.rejects( + () => authorize(fx, makeQuote(fx.activePolicy, 'cap-2')), + /period award cap/, + ); assert.equal(Object.keys(fx.store.snapshot().reservations).length, 1); }); -test('engine configuration rejects missing and extra trust roots', () => { +test('engine configuration requires exact immutable trust roots and keeps capabilities private', () => { const fx = fixture(); const snapshot = fx.store.snapshot(); - assert.ok(Object.isFrozen(snapshot.policies)); - assert.ok(Object.isFrozen(snapshot.credentialAuthorizers)); + assert.ok(Object.isFrozen(snapshot.skillRegistrations)); + assert.ok(Object.isFrozen(snapshot.identitySigners)); + assert.equal(Object.hasOwn(snapshot, 'clock'), false); + assert.equal(Object.hasOwn(snapshot, 'receiptSigner'), false); assert.throws(() => createEngineState({ - signedBudget: snapshot.budget.authorization, - policies: snapshot.policies, - financeSigners: snapshot.financeSigners, - managerSigners: snapshot.managerSigners, - credentialAuthorizers: {}, - now: NOW, - }), /missing trusted credential authorizer/); + ...fx.configuration, + identitySigners: {}, + }), /missing trusted identity signer/); assert.throws(() => createEngineState({ - signedBudget: snapshot.budget.authorization, - policies: snapshot.policies, - financeSigners: snapshot.financeSigners, - managerSigners: snapshot.managerSigners, - credentialAuthorizers: { ...snapshot.credentialAuthorizers, attacker: 'key' }, - now: NOW, - }), /unexpected credential authorizer/); + ...fx.configuration, + receiptSigners: { ...fx.configuration.receiptSigners, attacker: publicPem(fx.receipt) }, + }), /unexpected receipt signer attacker/); }); diff --git a/spikes/internal-invocation-awards/test/receipt-ledger.test.mjs b/spikes/internal-invocation-awards/test/receipt-ledger.test.mjs new file mode 100644 index 0000000..ebfad59 --- /dev/null +++ b/spikes/internal-invocation-awards/test/receipt-ledger.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + appendSignedReceipt, + createReceiptLedgerState, + receiptSequenceScope, +} from '../src/receipt-ledger.mjs'; + +function receipt(receiptId, sequence, creatorId = 'sam') { + return Object.freeze({ + receiptId, + sequence, + receiptSequenceScope: receiptSequenceScope({ + employerId: 'megacorp', creatorId, currency: 'USD', atomicScale: 6, + }), + signature: 'signed-by-test-capability', + }); +} + +test('receipt sequence is scoped by employer, Creator, currency, and scale', () => { + assert.notEqual( + receiptSequenceScope({ employerId: 'megacorp', creatorId: 'sam', currency: 'USD', atomicScale: 6 }), + receiptSequenceScope({ employerId: 'megacorp', creatorId: 'kim', currency: 'USD', atomicScale: 6 }), + ); + assert.equal( + receiptSequenceScope({ employerId: 'megacorp', creatorId: 'sam', currency: 'USD', atomicScale: 6 }), + '["megacorp","sam","USD",6]', + ); +}); + +test('atomic receipt append rejects duplicate IDs, sequence conflicts, and gaps', () => { + const empty = createReceiptLedgerState(); + const first = appendSignedReceipt(empty, { + signedReceipt: receipt('receipt-1', 1), + hash: `sha256:${'1'.repeat(64)}`, + }); + assert.equal(first.receipts['receipt-1'].sequence, 1); + assert.equal(first.nextReceiptSequences['["megacorp","sam","USD",6]'], 2); + assert.throws(() => appendSignedReceipt(first, { + signedReceipt: receipt('receipt-1', 2), hash: `sha256:${'2'.repeat(64)}`, + }), /receipt ID already committed/); + assert.throws(() => appendSignedReceipt(first, { + signedReceipt: receipt('receipt-2', 1), hash: `sha256:${'2'.repeat(64)}`, + }), /receipt sequence already committed/); + assert.throws(() => appendSignedReceipt(first, { + signedReceipt: receipt('receipt-2', 3), hash: `sha256:${'2'.repeat(64)}`, + }), /expected receipt sequence 2/); +}); diff --git a/spikes/internal-invocation-awards/test/statements.test.mjs b/spikes/internal-invocation-awards/test/statements.test.mjs index 4f1236c..600856d 100644 --- a/spikes/internal-invocation-awards/test/statements.test.mjs +++ b/spikes/internal-invocation-awards/test/statements.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { createHash, generateKeyPairSync } from 'node:crypto'; import test from 'node:test'; +import { receiptSequenceScope } from '../src/receipt-ledger.mjs'; + import { buildInvocationReceipt, buildStatement, @@ -12,6 +14,7 @@ import { renderJsonl, signReceipt, signStatement, + statementHash, verifyReceipt, verifyStatement, } from '../src/statements.mjs'; @@ -30,10 +33,13 @@ function successRecords(sequence = 1, suffix = '001') { skillVersionHash: SKILL_HASH, creatorId: 'sam', wielderId: 'megacorp-internal-agent', + initiatingPrincipalId: 'jordan', + principalAttestationId: `attestation-${suffix}`, beneficiaryId: 'megacorp', costCenter: 'platform-engineering', policyId: 'policy-megacorp-ledger-recon', policyVersion: 1, + policyHash: `sha256:${'2'.repeat(64)}`, maxExecutionCostAtomic: '1000000', protocolFeeAtomic: '25000', refundReserveAtomic: '25000', @@ -61,12 +67,17 @@ function successRecords(sequence = 1, suffix = '001') { reservationId: reservation.reservationId, skillId: quote.skillId, skillVersionHash: quote.skillVersionHash, + skillRegistrationId: 'registration-ledger-recon-v1', creatorId: 'sam', wielderId: quote.wielderId, + initiatingPrincipalId: quote.initiatingPrincipalId, + principalAttestationId: quote.principalAttestationId, + principalAttestationHash: `sha256:${'3'.repeat(64)}`, beneficiaryId: 'megacorp', costCenter: quote.costCenter, policyId: quote.policyId, policyVersion: 1, + policyHash: quote.policyHash, period: '2026-07', currency: 'USD', atomicScale: 6, @@ -100,6 +111,9 @@ function successRecords(sequence = 1, suffix = '001') { { category: 'invocation-award', debitAccountId: 'employer:invocation-gross', creditAccountId: 'employee:sam', amountAtomic: '2000000' }, ], receiptSequence: sequence, + receiptSequenceScope: receiptSequenceScope({ + employerId: 'megacorp', creatorId: 'sam', currency: 'USD', atomicScale: 6, + }), }; const award = { schemaVersion: 1, @@ -167,6 +181,33 @@ function signedSuccess(signers, sequence = 1, suffix = '001') { }), signers.receipt.privateKey); } +function signedSuccessInPeriod(signers, sequence, suffix, period, occurredAt) { + const records = successRecords(sequence, suffix); + return signReceipt(buildInvocationReceipt({ + invocation: { + ...records.invocation, + period, + authorizedAt: occurredAt, + startedAt: occurredAt, + finalizedAt: occurredAt, + }, + reservation: { + ...records.reservation, + authorizedAt: occurredAt, + startedAt: occurredAt, + finalizedAt: occurredAt, + }, + award: { + ...records.award, + period, + measuredAt: occurredAt, + earnedAt: occurredAt, + }, + employerId: 'megacorp', + receiptSignerId: 'collar-receipt-key-2026-07', + }), signers.receipt.privateKey); +} + function expectedMerkleRoot(receipts) { const digest = (bytes) => createHash('sha256').update(bytes).digest(); if (receipts.length === 0) { @@ -222,7 +263,7 @@ test('employer and employee verify identical exact receipt bytes through a trust index === 3 ? { ...entry, creditAccountId: 'employee:attacker' } : entry )), }; - assert.throws(() => signReceipt(wrongJournal, signers.receipt.privateKey), /shared atomic allocation/); + assert.throws(() => signReceipt(wrongJournal, signers.receipt.privateKey), /shared kernel allocation/); }); test('unresolved receipt claims neither zero COGS nor release nor award', () => { @@ -350,6 +391,206 @@ test('payable advances, reversal semantics, and payments determine payable closi }), /payable reversal exceeds advanced amount/); }); +test('August can authenticate a July award without recounting July economics', () => { + const signers = signerFixture(); + const julyReceipt = signedSuccess(signers, 1, 'july'); + const julyHash = receiptHash(julyReceipt); + const julyUnsigned = buildStatement({ + statementId: 'statement-2026-07', employerId: 'megacorp', creatorId: 'sam', + period: '2026-07', currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', + receipts: [julyReceipt], payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }); + const july = signStatement(julyUnsigned, signers.statement.privateKey); + + const augustUnsigned = buildStatement({ + statementId: 'statement-2026-08', employerId: 'megacorp', creatorId: 'sam', + period: '2026-08', currency: 'USD', atomicScale: 6, + openingPayableAtomic: july.closingPayableAtomic, + receipts: [], historicalReceipts: [julyReceipt], priorStatement: july, + payableAdvances: [{ + advanceId: 'advance-august', receiptHash: julyHash, amountAtomic: '1000000', + advancedAt: '2026-08-01T00:00:00.000Z', + }], + reversals: [{ + reversalId: 'reversal-august', receiptHash: julyHash, amountAtomic: '100000', + balanceEffect: 'payable', reason: 'duplicate_advance', + occurredAt: '2026-08-02T00:00:00.000Z', + }], + payments: [{ + paymentId: 'payment-august', amountAtomic: '250000', + paidAt: '2026-08-03T00:00:00.000Z', railReference: 'simulated-august-payroll', + }], + statementSignerId: 'collar-statement-key-2026-07', + }); + assert.equal(augustUnsigned.priorStatementHash, statementHash(july)); + assert.equal(augustUnsigned.priorClosingPayableAtomic, '0'); + assert.equal(augustUnsigned.lastRecognizedReceiptSequence, 1); + assert.deepEqual(augustUnsigned.historicalReceiptHashes, [julyHash]); + assert.equal(augustUnsigned.reservationTotalAtomic, '0'); + assert.equal(augustUnsigned.releaseTotalAtomic, '0'); + assert.equal(augustUnsigned.chargeTotalAtomic, '0'); + assert.equal(augustUnsigned.earnedAwardTotalAtomic, '0'); + assert.equal(augustUnsigned.closingPayableAtomic, '650000'); + const august = signStatement(augustUnsigned, signers.statement.privateKey); + assert.doesNotThrow(() => verifyStatement(august, { + signedReceipts: [], + historicalSignedReceipts: [julyReceipt], + priorStatements: [{ signedStatement: july, signedReceipts: [julyReceipt] }], + trustedReceiptSigners: signers.receiptTrust, + trustedStatementSigners: signers.statementTrust, + })); + assert.throws(() => buildStatement({ + ...{ + statementId: 'bad-opening', employerId: 'megacorp', creatorId: 'sam', + period: '2026-08', currency: 'USD', atomicScale: 6, openingPayableAtomic: '1', + receipts: [], historicalReceipts: [julyReceipt], priorStatement: july, + payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }, + }), /opening payable must equal authenticated prior closing payable/); + assert.throws(() => buildStatement({ + statementId: 'missing-chain', employerId: 'megacorp', creatorId: 'sam', + period: '2026-08', currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', + receipts: [], historicalReceipts: [julyReceipt], priorStatement: null, + payableAdvances: [{ + advanceId: 'advance-without-chain', receiptHash: julyHash, amountAtomic: '1', + advancedAt: '2026-08-01T00:00:00.000Z', + }], + reversals: [], payments: [], statementSignerId: 'collar-statement-key-2026-07', + }), /historical receipt is not authenticated by the prior statement chain/); + + for (const duplicate of [ + { payableAdvances: [{ advanceId: 'advance-august', receiptHash: julyHash, amountAtomic: '1', advancedAt: '2026-09-01T00:00:00.000Z' }], reversals: [], payments: [] }, + { payableAdvances: [], reversals: [{ reversalId: 'reversal-august', receiptHash: julyHash, amountAtomic: '1', balanceEffect: 'payable', reason: 'duplicate', occurredAt: '2026-09-01T00:00:00.000Z' }], payments: [] }, + { payableAdvances: [], reversals: [], payments: [{ paymentId: 'payment-august', amountAtomic: '1', paidAt: '2026-09-01T00:00:00.000Z', railReference: 'duplicate' }] }, + ]) { + assert.throws(() => buildStatement({ + statementId: 'statement-2026-09', employerId: 'megacorp', creatorId: 'sam', + period: '2026-09', currency: 'USD', atomicScale: 6, + openingPayableAtomic: august.closingPayableAtomic, + receipts: [], historicalReceipts: [julyReceipt], priorStatement: august, + statementSignerId: 'collar-statement-key-2026-07', + ...duplicate, + }), /ID already appeared in a prior statement/); + } + + assert.throws(() => buildStatement({ + statementId: 'renamed-payment-replay', employerId: 'megacorp', creatorId: 'sam', + period: '2026-09', currency: 'USD', atomicScale: 6, + openingPayableAtomic: august.closingPayableAtomic, + receipts: [], historicalReceipts: [julyReceipt], priorStatement: august, + payableAdvances: [], reversals: [], + payments: [{ + paymentId: 'renamed-payment-id', amountAtomic: '1', + paidAt: '2026-09-01T00:00:00.000Z', + railReference: 'simulated-august-payroll', + }], + statementSignerId: 'collar-statement-key-2026-07', + }), /payment railReference ID already appeared/); +}); + +test('signed receipt cursor rejects cross-period gaps and survives receipt-free months', () => { + const signers = signerFixture(); + const julyReceipt = signedSuccess(signers, 1, 'cursor-july'); + const july = signStatement(buildStatement({ + statementId: 'cursor-2026-07', employerId: 'megacorp', creatorId: 'sam', + period: '2026-07', currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', + receipts: [julyReceipt], payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }), signers.statement.privateKey); + assert.equal(july.lastRecognizedReceiptSequence, 1); + + const august = signStatement(buildStatement({ + statementId: 'cursor-2026-08', employerId: 'megacorp', creatorId: 'sam', + period: '2026-08', currency: 'USD', atomicScale: 6, + openingPayableAtomic: july.closingPayableAtomic, + receipts: [], historicalReceipts: [], priorStatement: july, + payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }), signers.statement.privateKey); + assert.equal(august.firstReceiptSequence, null); + assert.equal(august.lastReceiptSequence, null); + assert.equal(august.lastRecognizedReceiptSequence, 1); + + const septemberReceipt = signedSuccessInPeriod( + signers, + 2, + 'cursor-september', + '2026-09', + '2026-09-01T00:01:00.000Z', + ); + const september = signStatement(buildStatement({ + statementId: 'cursor-2026-09', employerId: 'megacorp', creatorId: 'sam', + period: '2026-09', currency: 'USD', atomicScale: 6, + openingPayableAtomic: august.closingPayableAtomic, + receipts: [septemberReceipt], historicalReceipts: [], priorStatement: august, + payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }), signers.statement.privateKey); + assert.equal(september.lastRecognizedReceiptSequence, 2); + assert.doesNotThrow(() => verifyStatement(september, { + signedReceipts: [septemberReceipt], + priorStatements: [ + { signedStatement: july, signedReceipts: [julyReceipt] }, + { signedStatement: august, signedReceipts: [] }, + ], + trustedReceiptSigners: signers.receiptTrust, + trustedStatementSigners: signers.statementTrust, + })); + + const skipped = signedSuccessInPeriod( + signers, + 3, + 'cursor-skipped', + '2026-09', + '2026-09-02T00:01:00.000Z', + ); + assert.throws(() => buildStatement({ + statementId: 'cursor-gap', employerId: 'megacorp', creatorId: 'sam', + period: '2026-09', currency: 'USD', atomicScale: 6, + openingPayableAtomic: august.closingPayableAtomic, + receipts: [skipped], historicalReceipts: [], priorStatement: august, + payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }), /sequence gap across periods: expected 2, received 3/); +}); + +test('statement economic events must occur in their signed statement period', () => { + const signers = signerFixture(); + const receipt = signedSuccess(signers); + const hash = receiptHash(receipt); + const base = { + statementId: 'period-bound-events', employerId: 'megacorp', creatorId: 'sam', + period: '2026-07', currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', + receipts: [receipt], statementSignerId: 'collar-statement-key-2026-07', + }; + assert.throws(() => buildStatement({ + ...base, + payableAdvances: [{ + advanceId: 'advance-outside', receiptHash: hash, amountAtomic: '1', + advancedAt: '2026-08-01T00:00:00.000Z', + }], + reversals: [], payments: [], + }), /advance advancedAt must fall within statement period/); + assert.throws(() => buildStatement({ + ...base, payableAdvances: [], + reversals: [{ + reversalId: 'reversal-outside', receiptHash: hash, amountAtomic: '1', + balanceEffect: 'earned_only', reason: 'outside-period', + occurredAt: '2026-08-01T00:00:00.000Z', + }], + payments: [], + }), /reversal occurredAt must fall within statement period/); + assert.throws(() => buildStatement({ + ...base, payableAdvances: [], reversals: [], + payments: [{ + paymentId: 'payment-outside', amountAtomic: '0', + paidAt: '2026-08-01T00:00:00.000Z', railReference: 'outside-period-ref', + }], + }), /payment paidAt must fall within statement period/); +}); + test('statement sequence continuity and domain-separated Merkle rules are deterministic', () => { const signers = signerFixture(); const receipt1 = signedSuccess(signers, 1, '001'); @@ -408,6 +649,7 @@ test('whole-statement and receipt trust roots reject tampering and attacker resi ['openingPayableAtomic', { openingPayableAtomic: '1' }], ['firstReceiptSequence', { firstReceiptSequence: 2 }], ['lastReceiptSequence', { lastReceiptSequence: 2 }], + ['lastRecognizedReceiptSequence', { lastRecognizedReceiptSequence: 2 }], ['receiptHashes', { receiptHashes: [`sha256:${'0'.repeat(64)}`] }], ['receiptMerkleRoot', { receiptMerkleRoot: `sha256:${'0'.repeat(64)}` }], ['reservationTotalAtomic', { reservationTotalAtomic: '1' }], @@ -438,7 +680,7 @@ test('whole-statement and receipt trust roots reject tampering and attacker resi for (const [label, mutation] of mutations) { assert.throws( () => verifyStatement({ ...signed, ...mutation }, options), - /signature|recompute|trusted/, + /signature|recompute|trusted|period|cursor|sequence/, label, ); } From 0e79d45c0bc95663f1c044cf5446cc921220e707 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:34:59 -0400 Subject: [PATCH 096/165] fix: require fresh evidence after attestation revocation --- phase0/README.md | 15 +- phase0/src/attestations.ts | 19 +++ phase0/tests/attestation-adversarial.test.ts | 142 ++++++++++++++++++- 3 files changed, 167 insertions(+), 9 deletions(-) diff --git a/phase0/README.md b/phase0/README.md index 495d5eb..936d34a 100644 --- a/phase0/README.md +++ b/phase0/README.md @@ -72,8 +72,13 @@ A repository statement hash, challenge nonce, wallet signature, and bound forge observation are single-use credentials across the entire log. An organization statement hash and signature are also single-use. Revocation does not make an old credential reusable under a fresh event ID or sequence. Reactivation -requires genuinely fresh signed repository evidence and, where applicable, a -fresh organization approval. +requires genuinely fresh signed evidence, not merely a later event envelope. +After a repository-level revocation, both the wallet-signed challenge's +`issuedAt` and the forge observation's `observedAt` must be strictly later than +the latest repository revocation. After an organization-level revocation, or a +repository revocation that cascades to organization evidence, the new +organization approval's signed `approvedAt` must be strictly later than that +latest revocation cutoff. Inspect evidence without a network or chain write: @@ -132,8 +137,10 @@ At mapping load, the verifier pins the checkout directory's device and inode. It reopens and compares that identity before, between, and after external Git operations. This detects ordinary checkout-path replacement, but the spike cannot portably keep one directory file descriptor bound across every external -Git process. A privileged same-machine attacker capable of replacing and -restoring the path inside a single check-to-exec interval remains a residual +Git process. Directory identity also does not freeze in-place changes to the +checkout's refs, object database, configuration, or worktree. A privileged +same-machine attacker capable of changing those contents, or of replacing and +restoring the path inside a single check-to-exec interval, remains a residual local-verifier risk. Production hardening would require a platform-specific descriptor-bound execution boundary or an isolated immutable snapshot. diff --git a/phase0/src/attestations.ts b/phase0/src/attestations.ts index 0d77e86..22f4200 100644 --- a/phase0/src/attestations.ts +++ b/phase0/src/attestations.ts @@ -679,6 +679,8 @@ export async function reduceAttestationEvents( organizationActive: boolean; repositoryActivatedAt: number | null; organizationActivatedAt: number | null; + repositoryRevokedAt: number | null; + organizationRevokedAt: number | null; }> = {}; for (const subject of Object.values(subjects)) { registrations[subject.registrationId] = { @@ -693,6 +695,8 @@ export async function reduceAttestationEvents( organizationActive: false, repositoryActivatedAt: null, organizationActivatedAt: null, + repositoryRevokedAt: null, + organizationRevokedAt: null, }; } @@ -760,6 +764,13 @@ export async function reduceAttestationEvents( || consumedForgeObservations.has(forgeCredential)) { throw new Error("repository credential, statement, nonce, or forge observation was already consumed"); } + const challengeIssuedAt = Date.parse(event.challenge.issuedAt); + const forgeObservedAt = Date.parse(event.forgeObservation.observedAt); + if (registration.repositoryRevokedAt !== null + && (challengeIssuedAt <= registration.repositoryRevokedAt + || forgeObservedAt <= registration.repositoryRevokedAt)) { + throw new Error("repository reactivation requires a signed challenge and forge observation strictly after the latest repository revocation"); + } await verifyRepositoryEventSignature(event); if (!trust.repositoryVerifier) throw new Error("repository verifier context required"); await trust.repositoryVerifier(event); @@ -779,6 +790,9 @@ export async function reduceAttestationEvents( throw new Error("organization approval credential was already consumed"); } const approvedAt = Date.parse(event.approval.approvedAt); + if (registration.organizationRevokedAt !== null && approvedAt <= registration.organizationRevokedAt) { + throw new Error("organization reactivation requires an approval strictly after the latest organization revocation"); + } if (approvedAt > occurredAt) throw new Error("organization approvedAt must not follow its event envelope"); if (registration.repositoryActivatedAt === null || approvedAt < registration.repositoryActivatedAt) { throw new Error("organization approvedAt must not precede active repository evidence"); @@ -834,11 +848,14 @@ export async function reduceAttestationEvents( registration.organizationActive = false; registration.repositoryActivatedAt = null; registration.organizationActivatedAt = null; + registration.repositoryRevokedAt = occurredAt; + registration.organizationRevokedAt = occurredAt; } else { if (!registration.organizationActive) throw new Error("organization evidence is not active"); if (registration.organizationActivatedAt === null || occurredAt < registration.organizationActivatedAt) throw new Error("revocation must follow active organization evidence"); registration.organizationActive = false; registration.organizationActivatedAt = null; + registration.organizationRevokedAt = occurredAt; } registration.revocations = Object.freeze([...registration.revocations, { level: event.level, @@ -869,6 +886,8 @@ export async function reduceAttestationEvents( organizationActive: _organizationActive, repositoryActivatedAt: _repositoryActivatedAt, organizationActivatedAt: _organizationActivatedAt, + repositoryRevokedAt: _repositoryRevokedAt, + organizationRevokedAt: _organizationRevokedAt, ...publicValue } = value; return [id, deepFreeze(publicValue)]; diff --git a/phase0/tests/attestation-adversarial.test.ts b/phase0/tests/attestation-adversarial.test.ts index 615db42..a956391 100644 --- a/phase0/tests/attestation-adversarial.test.ts +++ b/phase0/tests/attestation-adversarial.test.ts @@ -48,6 +48,9 @@ async function repositoryEvent(input: { sequence: number; nonceDigit: string; occurredAt?: string; + issuedAt?: string; + observedAt?: string; + expiresAt?: string; }): Promise { const challenge = { schemaVersion: 1 as const, @@ -57,8 +60,8 @@ async function repositoryEvent(input: { artifactPath: "skills/demo/SKILL.md", challengePath: `attestations/${input.nonceDigit}.json`, nonce: `0x${input.nonceDigit.repeat(64)}` as `0x${string}`, - issuedAt: T0, - expiresAt: T4, + issuedAt: input.issuedAt ?? T0, + expiresAt: input.expiresAt ?? T4, }; return { type: "repository_control_verified", @@ -74,7 +77,7 @@ async function repositoryEvent(input: { trustedRef: "refs/heads/main", proofCommitSha: input.nonceDigit.repeat(40), challengeNonce: challenge.nonce, - observedAt: T1, + observedAt: input.observedAt ?? T1, forgeSignerId: "forge-1", signature: `forge-${input.nonceDigit}`, }, @@ -253,7 +256,16 @@ test("revocation permanently consumes old evidence while genuinely fresh credent adminSigners: { "admin-1": admin.address.toLowerCase() as `0x${string}` }, now: new Date(T4), }; await assert.rejects(reduceAttestationEvents([repo, revoked, { ...repo, eventId: "repo-replay", sequence: 3, occurredAt: T3 }], trust), /already consumed/i); - const fresh = await repositoryEvent({ subject: base, account, eventId: "repo-fresh", sequence: 3, nonceDigit: "3", occurredAt: T3 }); + const fresh = await repositoryEvent({ + subject: base, + account, + eventId: "repo-fresh", + sequence: 3, + nonceDigit: "3", + issuedAt: T3, + observedAt: T3, + occurredAt: T3, + }); const state = await reduceAttestationEvents([repo, revoked, fresh], trust); assert.equal(state.registrations[base.registrationId].level, "repository_control_verified"); @@ -279,7 +291,10 @@ test("revocation permanently consumes old evidence while genuinely fresh credent eventId: "repo-fresh-after-organization", sequence: 4, nonceDigit: "4", - occurredAt: T3, + issuedAt: T4, + observedAt: T4, + expiresAt: "2026-07-18T05:00:00.000Z", + occurredAt: T4, }); await assert.rejects(reduceAttestationEvents([ repo, @@ -291,6 +306,123 @@ test("revocation permanently consumes old evidence while genuinely fresh credent ...trust, organizationSigners: { "example-org": [organization.approval.approverWallet] }, }), /organization approval credential was already consumed/i); + + const freshOrganization = await organizationEvent({ + subject: base, + approver, + eventId: "organization-fresh-after-revocation", + sequence: 5, + approvedAt: T4, + occurredAt: T4, + }); + const organizationReactivated = await reduceAttestationEvents([ + repo, + organization, + revocationAfterOrganization, + freshAfterOrganization, + freshOrganization, + ], { + ...trust, + organizationSigners: { "example-org": [organization.approval.approverWallet] }, + }); + assert.equal(organizationReactivated.registrations[base.registrationId].level, "organization_approved"); +}); + +test("unused pre-revocation repository evidence cannot reactivate through a later envelope", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const admin = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const active = await repositoryEvent({ subject: base, account, eventId: "repo-active", sequence: 1, nonceDigit: "2" }); + const revoked = await revocation({ + admin, + registrationId: base.registrationId, + eventId: "repo-revoked", + sequence: 2, + occurredAt: T2, + }); + const staleButUnused = await repositoryEvent({ + subject: base, + account, + eventId: "repo-stale-unused", + sequence: 3, + nonceDigit: "3", + issuedAt: T0, + observedAt: T1, + occurredAt: T3, + }); + const staleChallengeWithFreshObservation = await repositoryEvent({ + subject: base, + account, + eventId: "repo-stale-challenge-fresh-observation", + sequence: 3, + nonceDigit: "4", + issuedAt: T0, + observedAt: T3, + occurredAt: T3, + }); + + await assert.rejects(reduceAttestationEvents([active, revoked, staleButUnused], { + baseSubjects: [base], + repositoryVerifier: async () => undefined, + adminSigners: { "admin-1": admin.address.toLowerCase() as `0x${string}` }, + now: new Date(T4), + }), /repository reactivation.*strictly after.*revocation/i); + await assert.rejects(reduceAttestationEvents([active, revoked, staleChallengeWithFreshObservation], { + baseSubjects: [base], + repositoryVerifier: async () => undefined, + adminSigners: { "admin-1": admin.address.toLowerCase() as `0x${string}` }, + now: new Date(T4), + }), /repository reactivation.*strictly after.*revocation/i); +}); + +test("unused pre-revocation organization approval cannot reactivate through a later envelope", async () => { + const account = privateKeyToAccount(generatePrivateKey()); + const approver = privateKeyToAccount(generatePrivateKey()); + const admin = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, account.address); + const repository = await repositoryEvent({ subject: base, account, eventId: "repo-active", sequence: 1, nonceDigit: "2" }); + const active = await organizationEvent({ + subject: base, + approver, + eventId: "organization-active", + sequence: 2, + approvedAt: T2, + occurredAt: T2, + }); + const revocationBase = { + type: "attestation_revoked" as const, + eventId: "organization-revoked", + sequence: 3, + occurredAt: T3, + registrationId: base.registrationId, + level: "organization_approved" as const, + reason: "Organization approval withdrawn.", + adminSignerId: "admin-1", + statementHash: HASH, + signature: "0x00" as `0x${string}`, + }; + const revocationHash = adminEventStatementHash(revocationBase); + const revoked: AttestationRevokedEvent = { + ...revocationBase, + statementHash: revocationHash, + signature: await admin.signMessage({ message: canonicalAdminEventStatement({ ...revocationBase, statementHash: revocationHash }) }), + }; + const staleButUnused = await organizationEvent({ + subject: base, + approver, + eventId: "organization-stale-unused", + sequence: 4, + approvedAt: "2026-07-18T02:30:00.000Z", + occurredAt: T4, + }); + + await assert.rejects(reduceAttestationEvents([repository, active, revoked, staleButUnused], { + baseSubjects: [base], + repositoryVerifier: async () => undefined, + organizationSigners: { "example-org": [approver.address.toLowerCase() as `0x${string}`] }, + adminSigners: { "admin-1": admin.address.toLowerCase() as `0x${string}` }, + now: new Date(T4), + }), /organization reactivation.*strictly after.*revocation/i); }); test("event chronology and causal approval/resolution ordering fail closed against an injected clock", async () => { From 87cf0250138fa197f8146c99f2ddc841c14c8942 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:53:31 -0400 Subject: [PATCH 097/165] fix: harden internal award trust and arrears --- spikes/internal-invocation-awards/README.md | 19 ++- .../internal-invocation-awards/src/budget.mjs | 12 +- .../src/credentials.mjs | 20 ++- .../internal-invocation-awards/src/engine.mjs | 40 +++-- .../src/public-keys.mjs | 31 ++++ .../src/receipt-ledger.mjs | 25 ++- .../internal-invocation-awards/src/schema.mjs | 16 +- .../src/statements.mjs | 9 +- .../test/budget.test.mjs | 34 +++++ .../test/engine.test.mjs | 62 +++++++- .../test/receipt-ledger.test.mjs | 102 +++++++++++-- .../test/statements.test.mjs | 144 ++++++++++++++---- 12 files changed, 431 insertions(+), 83 deletions(-) create mode 100644 spikes/internal-invocation-awards/src/public-keys.mjs diff --git a/spikes/internal-invocation-awards/README.md b/spikes/internal-invocation-awards/README.md index 034d29a..519d9f3 100644 --- a/spikes/internal-invocation-awards/README.md +++ b/spikes/internal-invocation-awards/README.md @@ -72,6 +72,12 @@ trusted initiating principal equals its `creatorId`; the shared agent Wielder is treated as the human principal. Its manager approval is a separate signed object, never a quote field. +Version 1 accepts only `paymentSchedule: monthly_in_arrears`. A current-period award +remains earned and non-payable for that period. A payable advance must reference the +same authenticated receipt through a later statement's historical-receipt set and +prior signed statement chain; a current-period receipt cannot be advanced or paid +early. + The engine is provisioned with an immutable `(skillId, skillVersionHash)` registration map that binds the canonical Creator and employer. Missing, expired, revoked, wrong-Creator, or wrong-employer registrations fail before reservation and are @@ -141,6 +147,17 @@ material. Receipt canonical bytes bind the Invocation, reservation, Skill regist initiating-principal attestation, Skill hash, canonical policy hash, outcome, atomic totals, kernel journal entries, and absence of an external settlement. +Every verifier accepts only a canonical Ed25519 SPKI public key. RSA, private-key PEM, +noncanonical PEM, and a public PEM with appended material fail closed; only canonical +public PEM is retained in engine state. Engine provisioning signs a random, +domain-separated challenge and verifies it with the configured receipt public key +before any Invocation can start. Each receipt is still independently verified, its +caller-supplied hash is recomputed over the exact strict signed-receipt schema, and +hash reuse is rejected across receipt IDs. If a correctly provisioned signer later +fails or misbehaves after the executor starts, the terminal transaction commits no +award or receipt and leaves the Invocation in `executing` for operator reconciliation; +the executor is not run again automatically. + Employer and employee verify the same signed receipt bytes. They also verify a separate whole-statement signature that binds: @@ -157,7 +174,7 @@ separate whole-statement signature that binds: An earned-but-unpaid award is not yet payable. It affects `earnedAwardTotalAtomic`, but does not enter `closingPayableAtomic` until a separately -authenticated payable-advance record is present. A reversal declares whether it +authenticated later-period payable-advance record is present. A reversal declares whether it changes only earned accounting or an already-advanced payable balance. Payments cannot exceed the authenticated payable balance. Advance, reversal, and payment timestamps must fall within the signed statement period. A later statement may cite diff --git a/spikes/internal-invocation-awards/src/budget.mjs b/spikes/internal-invocation-awards/src/budget.mjs index 6e1dc78..18d825c 100644 --- a/spikes/internal-invocation-awards/src/budget.mjs +++ b/spikes/internal-invocation-awards/src/budget.mjs @@ -16,6 +16,7 @@ import { validatePolicy, validateQuote, } from './schema.mjs'; +import { normalizeEd25519PublicKey } from './public-keys.mjs'; const BUDGET_AUTHORIZATION_KEYS = [ 'schemaVersion', 'budgetId', 'policyId', 'policyVersion', 'policyHash', @@ -91,20 +92,23 @@ export function signBudget(unsignedBudget, privateKey) { } function validateTrustedSignerMap(value) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) { throw new Error('trustedFinanceSigners must be an object'); } + const normalized = {}; for (const [signerId, key] of Object.entries(value)) { requireNonEmpty(signerId, 'finance signer ID'); - requireNonEmpty(key, `trusted key for ${signerId}`); + normalized[signerId] = normalizeEd25519PublicKey(key, `trusted finance signer ${signerId}`); } + return cloneFrozen(normalized); } export function createBudget(signedBudget, { trustedFinanceSigners, policy: policyInput, now }) { requireExactKeys(signedBudget, SIGNED_BUDGET_AUTHORIZATION_KEYS, 'signed budget authorization'); const unsigned = validateUnsignedAuthorization(ordered(signedBudget, BUDGET_AUTHORIZATION_KEYS)); const policy = validatePolicy(policyInput, now); - validateTrustedSignerMap(trustedFinanceSigners); + const normalizedFinanceSigners = validateTrustedSignerMap(trustedFinanceSigners); if (unsigned.policyId !== policy.policyId || unsigned.policyVersion !== policy.version) { throw new Error('budget authorization policy binding does not match effective policy'); } @@ -117,7 +121,7 @@ export function createBudget(signedBudget, { trustedFinanceSigners, policy: poli if (!policy.permittedFinanceSignerIds.includes(unsigned.signerId)) { throw new Error('finance signer is not permitted by policy'); } - const trustedKey = trustedFinanceSigners[unsigned.signerId]; + const trustedKey = normalizedFinanceSigners[unsigned.signerId]; if (typeof trustedKey !== 'string' || trustedKey.length === 0) { throw new Error('trusted finance signer is not provisioned'); } diff --git a/spikes/internal-invocation-awards/src/credentials.mjs b/spikes/internal-invocation-awards/src/credentials.mjs index b3ebd26..c186ae8 100644 --- a/spikes/internal-invocation-awards/src/credentials.mjs +++ b/spikes/internal-invocation-awards/src/credentials.mjs @@ -6,6 +6,7 @@ import { policyHash, requireExactKeys, } from './schema.mjs'; +import { normalizeEd25519PublicKey } from './public-keys.mjs'; const CREDENTIAL_KEYS = [ 'schemaVersion', 'credentialAuthorizerId', 'invocationId', 'reservationId', @@ -107,7 +108,8 @@ export function verifyCredentialSignature(signed, trustedPublicKey) { requireExactKeys(signed, SIGNED_CREDENTIAL_KEYS, 'signed credential'); const payload = validateCredentialPayload(ordered(signed, CREDENTIAL_KEYS)); const signature = decodeSignature(signed.signature, 'credential'); - if (!cryptoVerify(null, canonicalCredentialBytes(payload), trustedPublicKey, signature)) { + const key = normalizeEd25519PublicKey(trustedPublicKey, 'credential verifier key'); + if (!cryptoVerify(null, canonicalCredentialBytes(payload), key, signature)) { throw new Error('credential signature verification failed'); } return payload; @@ -189,10 +191,14 @@ export function verifyPrincipalAttestation(signed, { if (!policy.permittedIdentitySignerIds.includes(payload.identitySignerId)) { throw new Error('identity signer is not permitted by policy'); } - const key = identitySigners[payload.identitySignerId]; - if (typeof key !== 'string' || key.length === 0) { + const configuredKey = identitySigners[payload.identitySignerId]; + if (typeof configuredKey !== 'string' || configuredKey.length === 0) { throw new Error('identity signer is not provisioned'); } + const key = normalizeEd25519PublicKey( + configuredKey, + `identity signer ${payload.identitySignerId}`, + ); if (!policy.permittedInitiatingPrincipalIds.includes(payload.principalId)) { throw new Error('initiating principal is not permitted by policy'); } @@ -279,10 +285,14 @@ export function verifyManagerApproval(approval, { if (!policy.permittedManagerSignerIds.includes(payload.managerSignerId)) { throw new Error('manager signer is not permitted by policy'); } - const trustedKey = managerSigners[payload.managerSignerId]; - if (typeof trustedKey !== 'string' || trustedKey.length === 0) { + const configuredKey = managerSigners[payload.managerSignerId]; + if (typeof configuredKey !== 'string' || configuredKey.length === 0) { throw new Error('manager signer is not provisioned'); } + const trustedKey = normalizeEd25519PublicKey( + configuredKey, + `manager signer ${payload.managerSignerId}`, + ); if (payload.invocationId !== quote.invocationId || payload.creatorId !== quote.creatorId || payload.policyId !== policy.policyId diff --git a/spikes/internal-invocation-awards/src/engine.mjs b/spikes/internal-invocation-awards/src/engine.mjs index 76e5e29..05f404d 100644 --- a/spikes/internal-invocation-awards/src/engine.mjs +++ b/spikes/internal-invocation-awards/src/engine.mjs @@ -1,4 +1,4 @@ -import { createPublicKey } from 'node:crypto'; +import { randomBytes, verify as cryptoVerify } from 'node:crypto'; import { createBudget, @@ -41,6 +41,7 @@ import { signReceiptWithCapability, verifyReceipt, } from './statements.mjs'; +import { normalizeEd25519PublicKey } from './public-keys.mjs'; const TRUSTED_ENGINE_STATES = new WeakSet(); const ENGINE_CAPABILITIES = new WeakMap(); @@ -77,20 +78,37 @@ function requirePlainMap(value, label) { function validateTrustMap(mapInput, allowedIds, label) { const map = requirePlainMap(mapInput, label); const allowed = new Set(allowedIds); + const normalized = {}; + for (const [id, key] of Object.entries(map)) { + if (!allowed.has(id)) throw new Error(`unexpected ${label.slice(0, -1)} ${id}`); + normalized[id] = normalizeEd25519PublicKey(key, `${label.slice(0, -1)} ${id}`); + } for (const id of allowed) { - if (typeof map[id] !== 'string' || map[id].length === 0) { + if (!Object.hasOwn(normalized, id)) { throw new Error(`missing trusted ${label.slice(0, -1)} ${id}`); } } - for (const [id, key] of Object.entries(map)) { - if (!allowed.has(id)) throw new Error(`unexpected ${label.slice(0, -1)} ${id}`); - try { - createPublicKey(key); - } catch { - throw new Error(`invalid public key for ${label.slice(0, -1)} ${id}`); + return cloneFrozen(normalized); +} + +function verifyReceiptSignerProvisioning(capability, trustedPublicKey) { + const challenge = Buffer.concat([ + Buffer.from('internal-invocation-awards:receipt-signer-provisioning:v1\0'), + randomBytes(32), + ]); + try { + const signatureValue = capability.sign(Uint8Array.from(challenge)); + if (typeof signatureValue !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(signatureValue)) { + throw new Error('invalid signature'); + } + const signature = Buffer.from(signatureValue, 'base64'); + if (signature.length !== 64 || signature.toString('base64') !== signatureValue + || !cryptoVerify(null, challenge, trustedPublicKey, signature)) { + throw new Error('invalid signature'); } + } catch { + throw new Error('receipt signer provisioning challenge failed'); } - return cloneFrozen(map); } function provisionCapabilities(input) { @@ -429,6 +447,10 @@ export function createEngineState(input) { policy, now, }); + verifyReceiptSignerProvisioning( + capabilities.receiptSigner, + receiptSigners[capabilities.receiptSigner.signerId], + ); const receiptLedger = createReceiptLedgerState(); return markTrusted({ revision: 0, diff --git a/spikes/internal-invocation-awards/src/public-keys.mjs b/spikes/internal-invocation-awards/src/public-keys.mjs new file mode 100644 index 0000000..9e1ea2c --- /dev/null +++ b/spikes/internal-invocation-awards/src/public-keys.mjs @@ -0,0 +1,31 @@ +import { KeyObject, createPublicKey } from 'node:crypto'; + +export function normalizeEd25519PublicKey(input, label = 'trusted key') { + let key; + const stringInput = typeof input === 'string' ? input : null; + if (input instanceof KeyObject) { + if (input.type !== 'public') { + throw new Error(`${label} must be a public SPKI PEM or public KeyObject`); + } + key = input; + } else if (typeof input === 'string') { + if (!input.startsWith('-----BEGIN PUBLIC KEY-----\n')) { + throw new Error(`${label} must be a public SPKI PEM`); + } + try { + key = createPublicKey(input); + } catch { + throw new Error(`${label} must be a valid public SPKI PEM`); + } + } else { + throw new Error(`${label} must be a public SPKI PEM or public KeyObject`); + } + if (key.asymmetricKeyType !== 'ed25519') { + throw new Error(`${label} must be an Ed25519 public key`); + } + const canonical = key.export({ type: 'spki', format: 'pem' }); + if (stringInput !== null && stringInput !== canonical) { + throw new Error(`${label} must be a canonical public SPKI PEM`); + } + return canonical; +} diff --git a/spikes/internal-invocation-awards/src/receipt-ledger.mjs b/spikes/internal-invocation-awards/src/receipt-ledger.mjs index b448e7d..d64a7e6 100644 --- a/spikes/internal-invocation-awards/src/receipt-ledger.mjs +++ b/spikes/internal-invocation-awards/src/receipt-ledger.mjs @@ -1,21 +1,16 @@ -import { cloneFrozen, deepFreeze } from './schema.mjs'; +import { + cloneFrozen, + deepFreeze, + receiptSequenceScope, +} from './schema.mjs'; +import { receiptHash } from './statements.mjs'; + +export { receiptSequenceScope } from './schema.mjs'; function requireString(value, label) { if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be non-empty`); } -export function receiptSequenceScope({ employerId, creatorId, currency, atomicScale }) { - for (const [value, label] of [ - [employerId, 'receipt employerId'], - [creatorId, 'receipt creatorId'], - [currency, 'receipt currency'], - ]) requireString(value, label); - if (!Number.isSafeInteger(atomicScale) || atomicScale < 0 || atomicScale > 18) { - throw new Error('receipt atomicScale must be an integer from 0 through 18'); - } - return JSON.stringify([employerId, creatorId, currency, atomicScale]); -} - export function createReceiptLedgerState() { return deepFreeze({ receipts: {}, @@ -45,10 +40,12 @@ export function appendSignedReceipt(state, { signedReceipt, hash }) { if (typeof hash !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(hash)) { throw new Error('receipt hash must be a lowercase SHA-256 hash'); } + const computedHash = receiptHash(signedReceipt); + if (hash !== computedHash) throw new Error('supplied receipt hash does not match signed receipt'); if (Object.hasOwn(state.receipts, signedReceipt.receiptId)) { throw new Error('receipt ID already committed'); } - if (Object.hasOwn(state.receiptHashes, hash)) { + if (Object.values(state.receiptHashes).includes(hash)) { throw new Error('receipt hash already committed'); } const indexKey = JSON.stringify([signedReceipt.receiptSequenceScope, signedReceipt.sequence]); diff --git a/spikes/internal-invocation-awards/src/schema.mjs b/spikes/internal-invocation-awards/src/schema.mjs index 67df02c..75fb530 100644 --- a/spikes/internal-invocation-awards/src/schema.mjs +++ b/spikes/internal-invocation-awards/src/schema.mjs @@ -84,6 +84,18 @@ export function sumAtomic(values) { return values.reduce((sum, value) => sum + toAtomic(value), 0n); } +export function receiptSequenceScope({ employerId, creatorId, currency, atomicScale }) { + for (const [value, label] of [ + [employerId, 'receipt employerId'], + [creatorId, 'receipt creatorId'], + [currency, 'receipt currency'], + ]) requireString(value, label); + if (!Number.isSafeInteger(atomicScale) || atomicScale < 0 || atomicScale > 18) { + throw new Error('receipt atomicScale must be an integer from 0 through 18'); + } + return JSON.stringify([employerId, creatorId, currency, atomicScale]); +} + function codeUnitSort(values) { return [...values].sort((left, right) => { if (left < right) return -1; @@ -232,7 +244,9 @@ export function validatePolicy(input, now) { if (!['none', 'future_policy_controlled'].includes(input.vestingRule)) { throw new Error('unsupported vestingRule'); } - requireString(input.paymentSchedule, 'paymentSchedule'); + if (input.paymentSchedule !== 'monthly_in_arrears') { + throw new Error('paymentSchedule must equal monthly_in_arrears'); + } requireString(input.terminationTreatment, 'terminationTreatment'); requireString(input.paymentRail, 'paymentRail'); return cloneFrozen(input); diff --git a/spikes/internal-invocation-awards/src/statements.mjs b/spikes/internal-invocation-awards/src/statements.mjs index 3e5681a..bec75b3 100644 --- a/spikes/internal-invocation-awards/src/statements.mjs +++ b/spikes/internal-invocation-awards/src/statements.mjs @@ -5,16 +5,17 @@ import { } from 'node:crypto'; import { validateInternalJournalEntries } from '../../../prototype/atomic-money.mjs'; -import { receiptSequenceScope } from './receipt-ledger.mjs'; import { cloneFrozen, deepFreeze, fromAtomic, parseUtc, + receiptSequenceScope, requireExactKeys, toAtomic, } from './schema.mjs'; +import { normalizeEd25519PublicKey } from './public-keys.mjs'; const JOURNAL_ENTRY_KEYS = [ 'category', 'debitAccountId', 'creditAccountId', 'amountAtomic', @@ -96,7 +97,7 @@ function trustedKey(map, keyId, label) { if (typeof key !== 'string' || key.length === 0) { throw new Error(`${label} key ID ${keyId} is not trusted`); } - return key; + return normalizeEd25519PublicKey(key, `${label} ${keyId}`); } function sha256(bytes) { @@ -670,8 +671,8 @@ export function buildStatement({ } for (const advance of advances) { const award = awardsByHash.get(advance.receiptHash); - if (!award || (!hashes.includes(advance.receiptHash) && !historicalSet.has(advance.receiptHash))) { - throw new Error('payable advance references an unauthenticated current or historical receipt'); + if (!award || !historicalSet.has(advance.receiptHash)) { + throw new Error('payable advance must reference an authenticated historical receipt'); } award.advance += toAtomic(advance.amountAtomic); if (award.advance > award.amount) throw new Error('payable advance exceeds earned award'); diff --git a/spikes/internal-invocation-awards/test/budget.test.mjs b/spikes/internal-invocation-awards/test/budget.test.mjs index 79079b3..30fcca9 100644 --- a/spikes/internal-invocation-awards/test/budget.test.mjs +++ b/spikes/internal-invocation-awards/test/budget.test.mjs @@ -112,6 +112,10 @@ test('policy validation is effective-dated, exact, recursively frozen, and denom }, NOW), /unsupported award rule.*awardRateBps must equal 10000/, ); + assert.throws( + () => validatePolicy({ ...ACTIVE_POLICY, paymentSchedule: 'weekly' }, NOW), + /paymentSchedule must equal monthly_in_arrears/, + ); assert.throws(() => validatePolicy({ ...ACTIVE_POLICY, surprise: true }, NOW), /unknown key surprise/); assert.equal(validatePolicy({ ...ACTIVE_POLICY, currency: 'EUR', atomicScale: 2 }, NOW).currency, 'EUR'); }); @@ -236,6 +240,36 @@ test('signed budget authorization is immutable and separate from mutable counter ); }); +test('finance verification accepts canonical Ed25519 public SPKI only', () => { + const rsa = generateKeyPairSync('rsa', { modulusLength: 512 }); + const rsaBudget = signBudget(UNSIGNED_BUDGET_AUTHORIZATION, rsa.privateKey); + assert.throws(() => createBudget(rsaBudget, { + trustedFinanceSigners: { + 'megacorp-finance': rsa.publicKey.export({ type: 'spki', format: 'pem' }), + }, + policy: ACTIVE_POLICY, + now: NOW, + }), /Ed25519/); + + const ed25519 = financeFixture(); + assert.throws(() => createBudget(ed25519.signedBudget, { + trustedFinanceSigners: { + 'megacorp-finance': ed25519.privateKey.export({ type: 'pkcs8', format: 'pem' }), + }, + policy: ACTIVE_POLICY, + now: NOW, + }), /public SPKI PEM/); + + const publicWithAppendedPrivate = `${ed25519.publicKey.export({ + type: 'spki', format: 'pem', + })}${ed25519.privateKey.export({ type: 'pkcs8', format: 'pem' })}`; + assert.throws(() => createBudget(ed25519.signedBudget, { + trustedFinanceSigners: { 'megacorp-finance': publicWithAppendedPrivate }, + policy: ACTIVE_POLICY, + now: NOW, + }), /canonical public SPKI PEM/); +}); + test('budget authorization validates its own effective window and policy signer allow-list', () => { const future = financeFixture({ effectiveAt: '2026-07-18T00:00:00.000Z' }); assert.throws(() => verifiedBudget(future), /budget authorization is not yet effective/); diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs index a733cc3..854488a 100644 --- a/spikes/internal-invocation-awards/test/engine.test.mjs +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -208,9 +208,9 @@ function fixture({ clock: () => clock.now, receiptSigner: { signerId: 'megacorp-receipts', - sign: receiptSign ?? ((bytes) => ( - cryptoSign(null, bytes, receipt.privateKey).toString('base64') - )), + sign: receiptSign + ? (bytes) => receiptSign(bytes, receipt.privateKey) + : (bytes) => cryptoSign(null, bytes, receipt.privateKey).toString('base64'), }, }; const state = createEngineState(configuration); @@ -293,6 +293,18 @@ test('credential signatures bind principal, policy, Skill registration inputs, n () => signCredential({ ...payload, nonce: `0x${nonce('credential')}` }, privateKey), /lowercase 64-character hex/, ); + + const rsa = generateKeyPairSync('rsa', { modulusLength: 512 }); + const rsaSigned = signCredential(payload, rsa.privateKey); + assert.throws(() => verifyCredential(rsaSigned, rsa.publicKey, NOW), /Ed25519/); + assert.throws( + () => verifyCredential( + signed, + privateKey.export({ type: 'pkcs8', format: 'pem' }), + NOW, + ), + /public SPKI PEM/, + ); }); test('authorization requires an active engine-provisioned Skill version and canonical Creator', async (t) => { @@ -547,7 +559,14 @@ test('receipt sequence is independent per employer, Creator, currency, and scale }); test('signing failure commits neither terminal state nor receipt and does not re-execute', async () => { - const fx = fixture({ receiptSign: () => { throw new Error('HSM unavailable'); } }); + let signerCalls = 0; + const fx = fixture({ + receiptSign: (bytes, privateKey) => { + signerCalls += 1; + if (signerCalls > 1) throw new Error('HSM unavailable'); + return cryptoSign(null, bytes, privateKey).toString('base64'); + }, + }); const q = makeQuote(fx.activePolicy, 'sign-failure'); const authorized = await authorize(fx, q); const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); @@ -563,6 +582,7 @@ test('signing failure commits neither terminal state nor receipt and does not re }), /HSM unavailable/); const snapshot = fx.store.snapshot(); assert.equal(calls, 1); + assert.equal(signerCalls, 2); assert.equal(snapshot.invocations[q.invocationId].state, 'executing'); assert.equal(Object.keys(snapshot.receipts).length, 0); assert.equal(Object.keys(snapshot.awards).length, 0); @@ -709,3 +729,37 @@ test('engine configuration requires exact immutable trust roots and keeps capabi receiptSigners: { ...fx.configuration.receiptSigners, attacker: publicPem(fx.receipt) }, }), /unexpected receipt signer attacker/); }); + +test('engine provisioning rejects non-Ed25519, private PEM, and mismatched receipt capabilities', () => { + const fx = fixture(); + const rsa = generateKeyPairSync('rsa', { modulusLength: 512 }); + const rsaPem = publicPem(rsa); + for (const [field, signerId] of [ + ['financeSigners', 'megacorp-finance'], + ['managerSigners', 'manager-alex'], + ['credentialAuthorizers', 'megacorp-collar-authorizer'], + ['identitySigners', 'megacorp-identity'], + ['receiptSigners', 'megacorp-receipts'], + ]) { + assert.throws(() => createEngineState({ + ...fx.configuration, + [field]: { [signerId]: rsaPem }, + }), /Ed25519/); + } + + const privatePem = fx.finance.privateKey.export({ type: 'pkcs8', format: 'pem' }); + assert.throws(() => createEngineState({ + ...fx.configuration, + financeSigners: { 'megacorp-finance': privatePem }, + }), /public SPKI PEM/); + assert.equal(JSON.stringify(fx.store.snapshot()).includes('PRIVATE KEY'), false); + + const attacker = generateKeyPairSync('ed25519'); + assert.throws(() => createEngineState({ + ...fx.configuration, + receiptSigner: { + signerId: 'megacorp-receipts', + sign: (bytes) => cryptoSign(null, bytes, attacker.privateKey).toString('base64'), + }, + }), /receipt signer provisioning challenge failed/); +}); diff --git a/spikes/internal-invocation-awards/test/receipt-ledger.test.mjs b/spikes/internal-invocation-awards/test/receipt-ledger.test.mjs index ebfad59..e0ba03e 100644 --- a/spikes/internal-invocation-awards/test/receipt-ledger.test.mjs +++ b/spikes/internal-invocation-awards/test/receipt-ledger.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; import test from 'node:test'; import { @@ -6,16 +7,69 @@ import { createReceiptLedgerState, receiptSequenceScope, } from '../src/receipt-ledger.mjs'; +import { + buildInvocationReceipt, + receiptHash, + signReceipt, +} from '../src/statements.mjs'; + +const NOW = '2026-07-17T00:01:00.000Z'; -function receipt(receiptId, sequence, creatorId = 'sam') { - return Object.freeze({ - receiptId, - sequence, - receiptSequenceScope: receiptSequenceScope({ - employerId: 'megacorp', creatorId, currency: 'USD', atomicScale: 6, - }), - signature: 'signed-by-test-capability', +function cancelledReceipt(privateKey, sequence, suffix, creatorId = 'sam') { + const invocationId = `inv-${suffix}`; + const reservationId = `res-${suffix}`; + const scope = receiptSequenceScope({ + employerId: 'megacorp', creatorId, currency: 'USD', atomicScale: 6, }); + const reservation = { + reservationId, + quote: { invocationId }, + state: 'released', + reservedAtomic: '1', + }; + const invocation = { + invocationId, + reservationId, + beneficiaryId: 'megacorp', + creatorId, + skillId: 'ledger-recon', + skillVersionHash: `sha256:${'1'.repeat(64)}`, + skillRegistrationId: `registration-${suffix}`, + initiatingPrincipalId: 'jordan', + principalAttestationId: `attestation-${suffix}`, + principalAttestationHash: `sha256:${'2'.repeat(64)}`, + policyId: 'policy-megacorp-ledger-recon', + policyVersion: 1, + policyHash: `sha256:${'3'.repeat(64)}`, + period: '2026-07', + currency: 'USD', + atomicScale: 6, + state: 'cancelled', + executionAttemptId: null, + releasedAtomic: '1', + heldReservationAtomic: '0', + executionCostStatus: null, + executionCostAtomic: null, + outputHash: null, + failureClass: null, + unresolvedReason: null, + protocolFeeAtomic: '0', + refundReserveAtomic: '0', + invocationAwardAtomic: '0', + externalRoyaltyCreditsAtomic: '0', + employerSelfCreditAtomic: '0', + journalEntries: [], + finalizedAt: NOW, + receiptSequence: sequence, + receiptSequenceScope: scope, + }; + return signReceipt(buildInvocationReceipt({ + invocation, + reservation, + award: null, + employerId: 'megacorp', + receiptSignerId: 'receipt-signer', + }), privateKey); } test('receipt sequence is scoped by employer, Creator, currency, and scale', () => { @@ -30,20 +84,40 @@ test('receipt sequence is scoped by employer, Creator, currency, and scale', () }); test('atomic receipt append rejects duplicate IDs, sequence conflicts, and gaps', () => { + const { privateKey } = generateKeyPairSync('ed25519'); const empty = createReceiptLedgerState(); + const receipt1 = cancelledReceipt(privateKey, 1, '1'); + const hash1 = receiptHash(receipt1); const first = appendSignedReceipt(empty, { - signedReceipt: receipt('receipt-1', 1), - hash: `sha256:${'1'.repeat(64)}`, + signedReceipt: receipt1, + hash: hash1, }); - assert.equal(first.receipts['receipt-1'].sequence, 1); + assert.equal(first.receipts['receipt-inv-1'].sequence, 1); assert.equal(first.nextReceiptSequences['["megacorp","sam","USD",6]'], 2); + const duplicateId = cancelledReceipt(privateKey, 2, '1'); assert.throws(() => appendSignedReceipt(first, { - signedReceipt: receipt('receipt-1', 2), hash: `sha256:${'2'.repeat(64)}`, + signedReceipt: duplicateId, hash: receiptHash(duplicateId), }), /receipt ID already committed/); + const sequenceConflict = cancelledReceipt(privateKey, 1, '2'); assert.throws(() => appendSignedReceipt(first, { - signedReceipt: receipt('receipt-2', 1), hash: `sha256:${'2'.repeat(64)}`, + signedReceipt: sequenceConflict, hash: receiptHash(sequenceConflict), }), /receipt sequence already committed/); + const gap = cancelledReceipt(privateKey, 3, '2'); assert.throws(() => appendSignedReceipt(first, { - signedReceipt: receipt('receipt-2', 3), hash: `sha256:${'2'.repeat(64)}`, + signedReceipt: gap, hash: receiptHash(gap), }), /expected receipt sequence 2/); + + const receipt2 = cancelledReceipt(privateKey, 2, '2'); + const hash2 = receiptHash(receipt2); + assert.throws(() => appendSignedReceipt(first, { + signedReceipt: receipt2, hash: hash1, + }), /supplied receipt hash does not match signed receipt/); + + const seededDuplicateHash = Object.freeze({ + ...first, + receiptHashes: Object.freeze({ 'receipt-inv-1': hash2 }), + }); + assert.throws(() => appendSignedReceipt(seededDuplicateHash, { + signedReceipt: receipt2, hash: hash2, + }), /receipt hash already committed/); }); diff --git a/spikes/internal-invocation-awards/test/statements.test.mjs b/spikes/internal-invocation-awards/test/statements.test.mjs index 600856d..03ebaf1 100644 --- a/spikes/internal-invocation-awards/test/statements.test.mjs +++ b/spikes/internal-invocation-awards/test/statements.test.mjs @@ -208,6 +208,23 @@ function signedSuccessInPeriod(signers, sequence, suffix, period, occurredAt) { }), signers.receipt.privateKey); } +function signedJulyStatement(signers, receipts, suffix) { + return signStatement(buildStatement({ + statementId: `statement-july-${suffix}`, + employerId: 'megacorp', + creatorId: 'sam', + period: '2026-07', + currency: 'USD', + atomicScale: 6, + openingPayableAtomic: '0', + receipts, + payableAdvances: [], + reversals: [], + payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }), signers.statement.privateKey); +} + function expectedMerkleRoot(receipts) { const digest = (bytes) => createHash('sha256').update(bytes).digest(); if (receipts.length === 0) { @@ -358,39 +375,102 @@ test('payable advances, reversal semantics, and payments determine payable closi const signers = signerFixture(); const receipt = signedSuccess(signers); const hash = receiptHash(receipt); + const prior = signedJulyStatement(signers, [receipt], 'payable-events'); + const eventAt = '2026-08-17T00:01:00.000Z'; const unsigned = buildStatement({ statementId: 'statement-with-payable-events', - employerId: 'megacorp', creatorId: 'sam', period: '2026-07', - currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt], + employerId: 'megacorp', creatorId: 'sam', period: '2026-08', + currency: 'USD', atomicScale: 6, openingPayableAtomic: prior.closingPayableAtomic, + receipts: [], historicalReceipts: [receipt], priorStatement: prior, payableAdvances: [{ - advanceId: 'advance-001', receiptHash: hash, amountAtomic: '1000000', advancedAt: NOW, + advanceId: 'advance-001', receiptHash: hash, amountAtomic: '1000000', advancedAt: eventAt, }], reversals: [ - { reversalId: 'reversal-earned', receiptHash: hash, amountAtomic: '200000', balanceEffect: 'earned_only', reason: 'quality_adjustment', occurredAt: NOW }, - { reversalId: 'reversal-payable', receiptHash: hash, amountAtomic: '100000', balanceEffect: 'payable', reason: 'duplicate_advance', occurredAt: NOW }, + { reversalId: 'reversal-earned', receiptHash: hash, amountAtomic: '200000', balanceEffect: 'earned_only', reason: 'quality_adjustment', occurredAt: eventAt }, + { reversalId: 'reversal-payable', receiptHash: hash, amountAtomic: '100000', balanceEffect: 'payable', reason: 'duplicate_advance', occurredAt: eventAt }, ], - payments: [{ paymentId: 'payment-001', amountAtomic: '250000', paidAt: NOW, railReference: 'simulated-payroll-ref' }], + payments: [{ paymentId: 'payment-001', amountAtomic: '250000', paidAt: eventAt, railReference: 'simulated-payroll-ref' }], statementSignerId: 'collar-statement-key-2026-07', }); - assert.equal(unsigned.earnedAwardTotalAtomic, '2000000'); + assert.equal(unsigned.earnedAwardTotalAtomic, '0'); + assert.equal(unsigned.awardActivity[0].earnedAtomic, '2000000'); assert.equal(unsigned.reversalTotalAtomic, '300000'); assert.equal(unsigned.payableReversalTotalAtomic, '100000'); assert.equal(unsigned.paymentTotalAtomic, '250000'); assert.equal(unsigned.closingPayableAtomic, '650000'); const signed = signStatement(unsigned, signers.statement.privateKey); assert.doesNotThrow(() => verifyStatement(signed, { - signedReceipts: [receipt], + signedReceipts: [], + historicalSignedReceipts: [receipt], + priorStatements: [{ signedStatement: prior, signedReceipts: [receipt] }], trustedReceiptSigners: signers.receiptTrust, trustedStatementSigners: signers.statementTrust, })); assert.throws(() => buildStatement({ - statementId: 'over-reversed', employerId: 'megacorp', creatorId: 'sam', period: '2026-07', - currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt], + statementId: 'over-reversed', employerId: 'megacorp', creatorId: 'sam', period: '2026-08', + currency: 'USD', atomicScale: 6, openingPayableAtomic: prior.closingPayableAtomic, + receipts: [], historicalReceipts: [receipt], priorStatement: prior, payableAdvances: [], payments: [], statementSignerId: 'collar-statement-key-2026-07', - reversals: [{ reversalId: 'r', receiptHash: hash, amountAtomic: '1', balanceEffect: 'payable', reason: 'bad', occurredAt: NOW }], + reversals: [{ reversalId: 'r', receiptHash: hash, amountAtomic: '1', balanceEffect: 'payable', reason: 'bad', occurredAt: eventAt }], }), /payable reversal exceeds advanced amount/); }); +test('monthly-in-arrears rejects advancing or paying a current-period award', () => { + const signers = signerFixture(); + const receipt = signedSuccess(signers); + const hash = receiptHash(receipt); + assert.throws(() => buildStatement({ + statementId: 'same-period-advance', employerId: 'megacorp', creatorId: 'sam', + period: '2026-07', currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', + receipts: [receipt], historicalReceipts: [], priorStatement: null, + payableAdvances: [{ + advanceId: 'advance-same-period', receiptHash: hash, amountAtomic: '1', + advancedAt: NOW, + }], + reversals: [], + payments: [{ + paymentId: 'payment-same-period', amountAtomic: '1', paidAt: NOW, + railReference: 'same-period-payment', + }], + statementSignerId: 'collar-statement-key-2026-07', + }), /payable advance must reference an authenticated historical receipt/); +}); + +test('receipt and statement verification reject RSA-512 and private-key trust material', () => { + const signers = signerFixture(); + const rsa = generateKeyPairSync('rsa', { modulusLength: 512 }); + const unsignedReceipt = buildInvocationReceipt({ + ...successRecords(), employerId: 'megacorp', receiptSignerId: 'rsa-receipt', + }); + const rsaReceipt = signReceipt(unsignedReceipt, rsa.privateKey); + assert.throws(() => verifyReceipt(rsaReceipt, { + trustedReceiptSigners: { + 'rsa-receipt': rsa.publicKey.export({ type: 'spki', format: 'pem' }), + }, + }), /Ed25519/); + + const receipt = signedSuccess(signers); + const unsignedStatement = buildStatement({ + statementId: 'rsa-statement', employerId: 'megacorp', creatorId: 'sam', + period: '2026-07', currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', + receipts: [receipt], payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'rsa-statement-signer', + }); + const rsaStatement = signStatement(unsignedStatement, rsa.privateKey); + assert.throws(() => verifyStatement(rsaStatement, { + signedReceipts: [receipt], + trustedReceiptSigners: signers.receiptTrust, + trustedStatementSigners: { + 'rsa-statement-signer': rsa.publicKey.export({ type: 'spki', format: 'pem' }), + }, + }), /Ed25519/); + + const privatePem = signers.receipt.privateKey.export({ type: 'pkcs8', format: 'pem' }); + assert.throws(() => verifyReceipt(receipt, { + trustedReceiptSigners: { 'collar-receipt-key-2026-07': privatePem }, + }), /public SPKI PEM/); +}); + test('August can authenticate a July award without recounting July economics', () => { const signers = signerFixture(); const julyReceipt = signedSuccess(signers, 1, 'july'); @@ -623,17 +703,22 @@ test('whole-statement and receipt trust roots reject tampering and attacker resi const receipt2 = signedSuccess(signers, 2, '002'); const receipt3 = signedSuccess(signers, 3, '003'); const hash = receiptHash(receipt); + const prior = signedJulyStatement(signers, [receipt, receipt2, receipt3], 'trust'); + const eventAt = '2026-08-17T00:01:00.000Z'; const unsigned = buildStatement({ - statementId: 'statement-trust', employerId: 'megacorp', creatorId: 'sam', period: '2026-07', - currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt, receipt2, receipt3], - payableAdvances: [{ advanceId: 'advance-001', receiptHash: hash, amountAtomic: '1000000', advancedAt: NOW }], - reversals: [{ reversalId: 'reversal-001', receiptHash: hash, amountAtomic: '100000', balanceEffect: 'payable', reason: 'duplicate_advance', occurredAt: NOW }], - payments: [{ paymentId: 'payment-001', amountAtomic: '250000', paidAt: NOW, railReference: 'simulated-payroll-ref' }], + statementId: 'statement-trust', employerId: 'megacorp', creatorId: 'sam', period: '2026-08', + currency: 'USD', atomicScale: 6, openingPayableAtomic: prior.closingPayableAtomic, + receipts: [], historicalReceipts: [receipt], priorStatement: prior, + payableAdvances: [{ advanceId: 'advance-001', receiptHash: hash, amountAtomic: '1000000', advancedAt: eventAt }], + reversals: [{ reversalId: 'reversal-001', receiptHash: hash, amountAtomic: '100000', balanceEffect: 'payable', reason: 'duplicate_advance', occurredAt: eventAt }], + payments: [{ paymentId: 'payment-001', amountAtomic: '250000', paidAt: eventAt, railReference: 'simulated-payroll-ref' }], statementSignerId: 'collar-statement-key-2026-07', }); const signed = signStatement(unsigned, signers.statement.privateKey); const options = { - signedReceipts: [receipt, receipt2, receipt3], trustedReceiptSigners: signers.receiptTrust, + signedReceipts: [], historicalSignedReceipts: [receipt], + priorStatements: [{ signedStatement: prior, signedReceipts: [receipt, receipt2, receipt3] }], + trustedReceiptSigners: signers.receiptTrust, trustedStatementSigners: signers.statementTrust, }; const replacePayment = (changes) => ({ payments: [{ ...signed.payments[0], ...changes }] }); @@ -643,10 +728,12 @@ test('whole-statement and receipt trust roots reject tampering and attacker resi ['statementId', { statementId: 'changed' }], ['employerId', { employerId: 'other-employer' }], ['creatorId', { creatorId: 'other-creator' }], - ['period', { period: '2026-08' }], + ['period', { period: '2026-09' }], ['currency', { currency: 'EUR' }], ['atomicScale', { atomicScale: 2 }], ['openingPayableAtomic', { openingPayableAtomic: '1' }], + ['priorStatementHash', { priorStatementHash: `sha256:${'0'.repeat(64)}` }], + ['historicalReceiptHashes', { historicalReceiptHashes: [] }], ['firstReceiptSequence', { firstReceiptSequence: 2 }], ['lastReceiptSequence', { lastReceiptSequence: 2 }], ['lastRecognizedReceiptSequence', { lastRecognizedReceiptSequence: 2 }], @@ -665,16 +752,16 @@ test('whole-statement and receipt trust roots reject tampering and attacker resi ['advanceId', replaceAdvance({ advanceId: 'advance-002' })], ['advance receiptHash', replaceAdvance({ receiptHash: `sha256:${'0'.repeat(64)}` })], ['advance amountAtomic', replaceAdvance({ amountAtomic: '999999' })], - ['advance advancedAt', replaceAdvance({ advancedAt: '2026-07-17T00:02:00.000Z' })], + ['advance advancedAt', replaceAdvance({ advancedAt: '2026-08-17T00:02:00.000Z' })], ['reversalId', replaceReversal({ reversalId: 'reversal-002' })], ['reversal receiptHash', replaceReversal({ receiptHash: `sha256:${'0'.repeat(64)}` })], ['reversal amountAtomic', replaceReversal({ amountAtomic: '99999' })], ['reversal balanceEffect', replaceReversal({ balanceEffect: 'earned_only' })], ['reversal reason', replaceReversal({ reason: 'other_reason' })], - ['reversal occurredAt', replaceReversal({ occurredAt: '2026-07-17T00:02:00.000Z' })], + ['reversal occurredAt', replaceReversal({ occurredAt: '2026-08-17T00:02:00.000Z' })], ['paymentId', replacePayment({ paymentId: 'payment-002' })], ['payment amountAtomic', replacePayment({ amountAtomic: '249999' })], - ['payment paidAt', replacePayment({ paidAt: '2026-07-17T00:02:00.000Z' })], + ['payment paidAt', replacePayment({ paidAt: '2026-08-17T00:02:00.000Z' })], ['payment railReference', replacePayment({ railReference: 'other-reference' })], ]; for (const [label, mutation] of mutations) { @@ -694,23 +781,26 @@ test('sortable statement event IDs are normalized ASCII and code-unit determinis const signers = signerFixture(); const receipt = signedSuccess(signers); const hash = receiptHash(receipt); + const prior = signedJulyStatement(signers, [receipt], 'id-order'); + const eventAt = '2026-08-17T00:01:00.000Z'; const base = { - statementId: 'statement-id-order', employerId: 'megacorp', creatorId: 'sam', period: '2026-07', - currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', receipts: [receipt], + statementId: 'statement-id-order', employerId: 'megacorp', creatorId: 'sam', period: '2026-08', + currency: 'USD', atomicScale: 6, openingPayableAtomic: prior.closingPayableAtomic, + receipts: [], historicalReceipts: [receipt], priorStatement: prior, reversals: [], payments: [], statementSignerId: 'collar-statement-key-2026-07', }; const statement = buildStatement({ ...base, payableAdvances: [ - { advanceId: 'b', receiptHash: hash, amountAtomic: '1', advancedAt: NOW }, - { advanceId: 'A', receiptHash: hash, amountAtomic: '1', advancedAt: NOW }, - { advanceId: 'a', receiptHash: hash, amountAtomic: '1', advancedAt: NOW }, + { advanceId: 'b', receiptHash: hash, amountAtomic: '1', advancedAt: eventAt }, + { advanceId: 'A', receiptHash: hash, amountAtomic: '1', advancedAt: eventAt }, + { advanceId: 'a', receiptHash: hash, amountAtomic: '1', advancedAt: eventAt }, ], }); assert.deepEqual(statement.payableAdvances.map((row) => row.advanceId), ['A', 'a', 'b']); assert.throws(() => buildStatement({ ...base, - payableAdvances: [{ advanceId: 'é', receiptHash: hash, amountAtomic: '1', advancedAt: NOW }], + payableAdvances: [{ advanceId: 'é', receiptHash: hash, amountAtomic: '1', advancedAt: eventAt }], }), /normalized ASCII identifier/); }); From 57224c6925bb475a5be50c47ae2ad5e5daec7f9c Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:54:19 -0400 Subject: [PATCH 098/165] fix: harden tracked attestation seed --- .gitignore | 1 + phase0/README.md | 15 +++- phase0/attestations.jsonl | 0 phase0/src/attestation-cli.ts | 3 + phase0/src/attestation-store.ts | 102 +++++++++++++++++++++---- phase0/tests/attestation-store.test.ts | 87 +++++++++++++++++++++ 6 files changed, 189 insertions(+), 19 deletions(-) create mode 100644 phase0/attestations.jsonl diff --git a/.gitignore b/.gitignore index dd83b77..685583e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ out/ # Run artifacts (belt-and-braces; spikes also ignore locally) runs/ *.jsonl +!phase0/attestations.jsonl phase0/.attestation-checkouts.local.json phase0/attestations.jsonl.* spikes/pi-wielder/**/*.lock diff --git a/phase0/README.md b/phase0/README.md index 936d34a..d742a1d 100644 --- a/phase0/README.md +++ b/phase0/README.md @@ -38,9 +38,18 @@ facts, not proof that the wallet authored the artifacts. ## Offline attestation sidecar -Registration remains immutable. Optional evidence is recorded in the local, -ignored `attestations.jsonl` append-only sidecar and rendered at one of three -levels: +Registration remains immutable. The repository tracks an exact zero-byte +`attestations.jsonl` seed; optional evidence appended at runtime forms its +append-only sidecar and is rendered at one of three levels. Lock, claim, and +temporary derivative files remain ignored and machine-local: + +A fresh Git checkout normally materializes the tracked zero-byte seed at mode +`0644`. Only for this exact default path, the CLI opens the seed without +following symlinks, verifies that it is an owner-matched regular file with +exactly zero bytes and no group/world write or execute permission, and changes +that same descriptor to mode `0600` before use. A nonempty permissive log, +wrong owner, symlink, path replacement, or arbitrary custom store path fails +closed instead of being changed automatically. 1. `wallet_asserted`: a wallet registered these bytes and declared this ancestry; diff --git a/phase0/attestations.jsonl b/phase0/attestations.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/phase0/src/attestation-cli.ts b/phase0/src/attestation-cli.ts index 6b02fd6..7891da7 100644 --- a/phase0/src/attestation-cli.ts +++ b/phase0/src/attestation-cli.ts @@ -191,6 +191,9 @@ export async function createAttestationRuntime(input: AttestationRuntimeInput = organizationSigners: organizations, adminSigners: admins, repositoryContextLoader: repositoryContext, + trackedEmptySeedPath: selectedPaths.attestations === DEFAULT_PATHS.attestations + ? DEFAULT_PATHS.attestations + : undefined, now, }), }; diff --git a/phase0/src/attestation-store.ts b/phase0/src/attestation-store.ts index e2617c5..546feaf 100644 --- a/phase0/src/attestation-store.ts +++ b/phase0/src/attestation-store.ts @@ -1,6 +1,6 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { constants } from "node:fs"; -import { link, mkdir, open, rename, stat, unlink, type FileHandle } from "node:fs/promises"; +import { constants, type Stats } from "node:fs"; +import { link, lstat, mkdir, open, rename, unlink, type FileHandle } from "node:fs/promises"; import { dirname } from "node:path"; import { isDeepStrictEqual } from "node:util"; @@ -27,12 +27,14 @@ export interface AttestationStoreOptions { forgeSigners?: Readonly>; git?: GitReader; repositoryContextLoader?: () => Promise; + trackedEmptySeedPath?: string; now?: () => Date; hooks?: { afterLockCreated?(): void | Promise; beforeAppendWrite?(): void | Promise; afterAppendSync?(): void | Promise; afterLockClaim?(claimPath: string): void | Promise; + afterTrackedSeedHarden?(): void | Promise; }; } @@ -50,6 +52,14 @@ export interface WriteAllHandle { const LOCK_TOKEN = /^[0-9a-f]{32}$/; +function currentUid(): number { + const uid = process.getuid?.(); + if (!Number.isSafeInteger(uid) || (uid as number) < 0) { + throw new Error("attestation log requires an operating-system owner identity"); + } + return uid as number; +} + export async function writeAll(handle: WriteAllHandle, bytes: Uint8Array): Promise { let offset = 0; while (offset < bytes.byteLength) { @@ -122,6 +132,9 @@ export class FileAttestationStore { constructor(path: string, options: AttestationStoreOptions) { if (!path.startsWith("/")) throw new Error("attestation store path must be absolute"); if (!Array.isArray(options.baseSubjects)) throw new Error("attestation store requires verifier-provided base subjects"); + if (options.trackedEmptySeedPath !== undefined && options.trackedEmptySeedPath !== path) { + throw new Error("tracked empty seed path must exactly match the attestation store path"); + } this.path = path; this.lockPath = `${path}.lock`; this.options = { @@ -161,9 +174,13 @@ export class FileAttestationStore { 0o600, ); const metadata = await handle.stat(); - if (!metadata.isFile() || (metadata.mode & 0o777) !== 0o600) { + if (!metadata.isFile() || metadata.uid !== currentUid() || (metadata.mode & 0o777) !== 0o600) { throw new Error("attestation log must be a non-symlink regular file with mode 0600"); } + if (snapshot.device !== null + && (metadata.dev !== snapshot.device || metadata.ino !== snapshot.inode)) { + throw new Error("attestation log changed between validation and append"); + } const boundBytes = await handle.readFile("utf8"); if (boundBytes !== snapshot.bytes) throw new Error("attestation log changed between validation and append"); await this.options.hooks?.beforeAppendWrite?.(); @@ -229,34 +246,87 @@ export class FileAttestationStore { return (await this.readLogSnapshot()).events; } - private async readLogSnapshot(): Promise<{ events: AttestationEvent[]; bytes: string }> { + private async hardenTrackedEmptySeed(handle: FileHandle, metadata: Stats): Promise { + const mode = metadata.mode & 0o777; + if (metadata.uid !== currentUid()) throw new Error("attestation log must be owned by the current user"); + if (mode === 0o600) return metadata; + if (this.options.trackedEmptySeedPath !== this.path) { + throw new Error("attestation log must be a non-symlink regular file with mode 0600"); + } + if (metadata.size !== 0) { + throw new Error("refusing to harden a nonempty permissive attestation log"); + } + const ownerReadWriteOnly = (mode & 0o700) === 0o600; + const noGroupOrWorldMutation = (mode & 0o033) === 0; + if (!ownerReadWriteOnly || !noGroupOrWorldMutation) { + throw new Error("tracked empty attestation seed must not be group- or world-writable or executable"); + } + + await handle.chmod(0o600); + await handle.sync(); + await this.options.hooks?.afterTrackedSeedHarden?.(); + const hardened = await handle.stat(); + let pathMetadata: Stats; + try { + pathMetadata = await lstat(this.path); + } catch (error) { + throw new Error("attestation log changed during tracked-seed hardening", { cause: error }); + } + if (!hardened.isFile() + || hardened.uid !== currentUid() + || hardened.size !== 0 + || (hardened.mode & 0o777) !== 0o600 + || pathMetadata.isSymbolicLink() + || !pathMetadata.isFile() + || pathMetadata.dev !== hardened.dev + || pathMetadata.ino !== hardened.ino) { + throw new Error("attestation log changed during tracked-seed hardening"); + } + return hardened; + } + + private async readLogSnapshot(): Promise<{ + events: AttestationEvent[]; + bytes: string; + device: number | null; + inode: number | null; + }> { let bytes: string; - try { bytes = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW).then(async (handle) => { - try { - const metadata = await handle.stat(); - if (!metadata.isFile() || (metadata.mode & 0o777) !== 0o600) { - throw new Error("attestation log must be a non-symlink regular file with mode 0600"); - } - return await handle.readFile("utf8"); - } finally { await handle.close(); } - }); } catch (error) { + let device: number | null = null; + let inode: number | null = null; + let handle: FileHandle; + try { + handle = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { await this.validateEvents([]); - return { events: [], bytes: "" }; + return { events: [], bytes: "", device: null, inode: null }; } if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("attestation log must not be a symlink", { cause: error }); throw error; } + try { + let metadata = await handle.stat(); + if (!metadata.isFile()) { + throw new Error("attestation log must be a non-symlink regular file with mode 0600"); + } + metadata = await this.hardenTrackedEmptySeed(handle, metadata); + device = metadata.dev; + inode = metadata.ino; + bytes = await handle.readFile("utf8"); + } finally { + await handle.close(); + } if (bytes === "") { await this.validateEvents([]); - return { events: [], bytes }; + return { events: [], bytes, device, inode }; } if (!bytes.endsWith("\n")) throw new Error("attestation log has a malformed trailing fragment"); const lines = bytes.slice(0, -1).split("\n"); if (lines.some((line) => line.length === 0)) throw new Error("attestation log contains an empty or malformed line"); const events = lines.map(parseJsonLine); await this.validateEvents(events); - return { events, bytes }; + return { events, bytes, device, inode }; } private async withLock(operation: () => Promise): Promise { diff --git a/phase0/tests/attestation-store.test.ts b/phase0/tests/attestation-store.test.ts index c2c29e8..0afc545 100644 --- a/phase0/tests/attestation-store.test.ts +++ b/phase0/tests/attestation-store.test.ts @@ -315,6 +315,93 @@ test("event log rejects symlinks and non-owner-only modes", async (t) => { await assert.rejects(f.store.load(), /mode 0600/); }); +test("an explicitly selected tracked zero-byte seed is hardened before use", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-attestation-tracked-seed-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = resolve(directory, "attestations.jsonl"); + await writeFile(path, "", { mode: 0o644 }); + await chmod(path, 0o644); + + const store = new FileAttestationStore(path, { + baseSubjects: [], + trackedEmptySeedPath: path, + }); + + assert.deepEqual(await store.load(), []); + assert.equal((await stat(path)).mode & 0o777, 0o600); +}); + +test("tracked-seed hardening rejects path mismatch and unsafe seed states", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-attestation-unsafe-seed-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = resolve(directory, "attestations.jsonl"); + + assert.throws(() => new FileAttestationStore(path, { + baseSubjects: [], + trackedEmptySeedPath: resolve(directory, "another.jsonl"), + }), /tracked empty seed path must exactly match/i); + + await writeFile(path, "not empty\n", { mode: 0o644 }); + await chmod(path, 0o644); + const nonempty = new FileAttestationStore(path, { baseSubjects: [], trackedEmptySeedPath: path }); + await assert.rejects(nonempty.load(), /nonempty permissive attestation log/i); + assert.equal(await readFile(path, "utf8"), "not empty\n"); + assert.equal((await stat(path)).mode & 0o777, 0o644); + + await writeFile(path, "", { mode: 0o666 }); + await chmod(path, 0o666); + const writable = new FileAttestationStore(path, { baseSubjects: [], trackedEmptySeedPath: path }); + await assert.rejects(writable.load(), /group- or world-writable|mode 0600/i); + assert.equal((await stat(path)).mode & 0o777, 0o666); + + await rm(path); + const victim = resolve(directory, "victim.jsonl"); + await writeFile(victim, "", { mode: 0o644 }); + await symlink(victim, path); + const symlinked = new FileAttestationStore(path, { baseSubjects: [], trackedEmptySeedPath: path }); + await assert.rejects(symlinked.load(), /must not be a symlink|non-symlink/i); + assert.equal((await stat(victim)).mode & 0o777, 0o644); +}); + +test("tracked-seed hardening detects replacement of the descriptor-bound inode", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-attestation-replaced-seed-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = resolve(directory, "attestations.jsonl"); + await writeFile(path, "", { mode: 0o644 }); + await chmod(path, 0o644); + const store = new FileAttestationStore(path, { + baseSubjects: [], + trackedEmptySeedPath: path, + hooks: { + afterTrackedSeedHarden: async () => { + await rename(path, `${path}.original`); + await writeFile(path, "", { mode: 0o600 }); + }, + }, + }); + + await assert.rejects(store.load(), /attestation log changed during tracked-seed hardening/i); +}); + +test("tracked-seed hardening fails closed if the selected path disappears", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "phase0-attestation-removed-seed-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = resolve(directory, "attestations.jsonl"); + await writeFile(path, "", { mode: 0o644 }); + await chmod(path, 0o644); + const store = new FileAttestationStore(path, { + baseSubjects: [], + trackedEmptySeedPath: path, + hooks: { + afterTrackedSeedHarden: async () => { + await rename(path, `${path}.removed`); + }, + }, + }); + + await assert.rejects(store.load(), /attestation log changed during tracked-seed hardening/i); +}); + test("post-append replay compares every byte and event, not only length and last ID", async (t) => { let appendSyncs = 0; let replacementBytes = ""; From 0c8fbe9867341c48b6e26d5ee0a49b818de93ed5 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:57:15 -0400 Subject: [PATCH 099/165] feat: enforce Wielder payment policy --- spikes/pi-wielder/src/payment-policy.mjs | 730 ++++++++++++++++++ .../pi-wielder/tests/payment-policy.test.mjs | 460 +++++++++++ 2 files changed, 1190 insertions(+) create mode 100644 spikes/pi-wielder/src/payment-policy.mjs create mode 100644 spikes/pi-wielder/tests/payment-policy.test.mjs diff --git a/spikes/pi-wielder/src/payment-policy.mjs b/spikes/pi-wielder/src/payment-policy.mjs new file mode 100644 index 0000000..25c42f9 --- /dev/null +++ b/spikes/pi-wielder/src/payment-policy.mjs @@ -0,0 +1,730 @@ +import crypto from 'node:crypto'; + +export const BASE_SEPOLIA_NETWORK = 'base-sepolia'; +export const BASE_SEPOLIA_CHAIN_ID = 84532; +export const BASE_SEPOLIA_USDC = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; + +export class PaymentPolicyError extends Error { + constructor(code, message) { + super(message); + this.name = 'PaymentPolicyError'; + this.code = code; + } +} + +const fail = (code, message) => { throw new PaymentPolicyError(code, message); }; + +function isPlainObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactObject(value, keys, code, label) { + if (!isPlainObject(value)) fail(code, `${label} must be a plain object`); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + fail(code, `${label} must contain exactly: ${expected.join(', ')}`); + } + return value; +} + +function optionalExactObject(value, required, optional, code, label) { + if (!isPlainObject(value)) fail(code, `${label} must be a plain object`); + const actual = Object.keys(value); + if (required.some((key) => !actual.includes(key)) + || actual.some((key) => !required.includes(key) && !optional.includes(key))) { + fail(code, `${label} contains missing or unknown fields`); + } + return value; +} + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +} + +function frozenCopy(value) { + return deepFreeze(structuredClone(value)); +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); + } + return value; +} + +function canonicalJson(value) { + return JSON.stringify(canonicalize(value)); +} + +function hashJson(value) { + return `sha256:${crypto.createHash('sha256').update(canonicalJson(value)).digest('hex')}`; +} + +function canonicalAtomic(value, label, code = 'AMOUNT_FORMAT') { + if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value)) { + fail(code, `${label} must be a canonical non-negative integer string`); + } + return { text: value, value: BigInt(value) }; +} + +function canonicalAddress(value, label, code) { + if (typeof value !== 'string' || !/^0x[0-9a-f]{40}$/.test(value)) { + fail(code, `${label} must be one canonical lowercase 20-byte hex address`); + } + return value; +} + +function canonicalHash(value, label, code = 'HASH_FORMAT') { + if (typeof value !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value)) { + fail(code, `${label} must be a canonical SHA-256 identifier`); + } + return value; +} + +function canonicalBytes32(value, label, code) { + if (typeof value !== 'string' || !/^0x[0-9a-f]{64}$/.test(value)) { + fail(code, `${label} must be a canonical lowercase 32-byte hex value`); + } + return value; +} + +function canonicalSignature(value) { + if (typeof value !== 'string' || !/^0x[0-9a-f]{130}$/.test(value)) { + fail('SIGNATURE_FORMAT', 'signature must be a canonical lowercase 65-byte hex value'); + } + return value; +} + +function canonicalReason(value) { + if (typeof value !== 'string' || !/^[A-Z][A-Z0-9_]{1,63}$/.test(value)) { + fail('REASON_CODE', 'reasonCode must be a stable uppercase identifier'); + } + return value; +} + +function canonicalMethod(value) { + if (typeof value !== 'string' || !/^[A-Z][A-Z0-9_-]*$/.test(value)) { + fail('REQUEST_METHOD', 'request method must be a canonical uppercase token'); + } + return value; +} + +function exactBodyBytes(value) { + if (typeof value === 'string') return Buffer.from(value, 'utf8'); + if (value instanceof Uint8Array) return Buffer.from(value); + if (value === null) return Buffer.alloc(0); + fail('REQUEST_BODY', 'request body must be a string, Uint8Array, or null'); +} + +function resourceUrl(value) { + if (typeof value !== 'string') fail('RESOURCE_URL', 'resource URL must be a string'); + let parsed; + try { + parsed = new URL(value); + } catch { + fail('RESOURCE_URL', 'resource URL is invalid'); + } + if (!['http:', 'https:'].includes(parsed.protocol) + || parsed.username || parsed.password || parsed.search || parsed.hash + || parsed.href !== value + || /[%\\]/.test(parsed.pathname) + || parsed.pathname.includes('//')) { + fail('RESOURCE_URL', 'resource URL violates the canonical HTTP(S) boundary'); + } + return parsed; +} + +function sellerOrigin(value) { + if (typeof value !== 'string') fail('SELLER_ORIGIN', 'seller origin must be a string'); + let parsed; + try { + parsed = new URL(value); + } catch { + fail('SELLER_ORIGIN', 'seller origin is invalid'); + } + if (!['http:', 'https:'].includes(parsed.protocol) + || parsed.username || parsed.password || parsed.search || parsed.hash + || value !== parsed.origin || parsed.pathname !== '/') { + fail('SELLER_ORIGIN', 'seller origin must be one exact canonical origin without a trailing slash'); + } + return parsed.origin; +} + +function sellerPathPrefix(value) { + if (typeof value !== 'string' || !value.startsWith('/') + || value.includes('?') || value.includes('#') || /[%\\]/.test(value) + || value.includes('//') || value.split('/').includes('..') || value.split('/').includes('.')) { + fail('SELLER_PATH', 'seller pathPrefix must be a canonical absolute path'); + } + const normalized = value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value; + if (normalized === '') fail('SELLER_PATH', 'seller pathPrefix cannot be empty'); + return normalized; +} + +function routeMatches(pathname, prefix) { + return prefix === '/' || pathname === prefix || pathname.startsWith(`${prefix}/`); +} + +function canonicalTimestamp(value, label) { + if (typeof value !== 'string') fail('QUOTE_EXPIRY', `${label} must be an ISO timestamp`); + const milliseconds = Date.parse(value); + if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) { + fail('QUOTE_EXPIRY', `${label} must be a canonical ISO timestamp`); + } + return milliseconds; +} + +function offerSchema(value) { + exactObject(value, [ + 'scheme', 'network', 'maxAmountRequired', 'resource', 'description', 'mimeType', + 'payTo', 'maxTimeoutSeconds', 'asset', 'extra', + ], 'OFFER_SCHEMA', 'x402 offer'); + exactObject(value.extra, [ + 'name', 'version', 'requestHash', 'quoteId', 'issuedAt', 'expiresAt', + ], 'OFFER_EXTRA_SCHEMA', 'x402 offer extra'); + if (typeof value.description !== 'string' || typeof value.mimeType !== 'string') { + fail('OFFER_SCHEMA', 'offer description and mimeType must be strings'); + } + return value; +} + +function challengeSchema(value) { + exactObject(value, ['x402Version', 'error', 'accepts'], 'CHALLENGE_SCHEMA', 'x402 challenge'); + if (value.x402Version !== 1 || typeof value.error !== 'string' + || !Array.isArray(value.accepts) || value.accepts.length !== 1) { + fail('CHALLENGE_SCHEMA', 'x402 challenge must contain exactly one v1 offer'); + } + return offerSchema(value.accepts[0]); +} + +function authorizationSchema(value) { + exactObject(value, ['from', 'to', 'value', 'validAfter', 'validBefore', 'nonce'], + 'AUTHORIZATION_SCHEMA', 'payment authorization'); + canonicalAddress(value.from, 'authorization.from', 'AUTHORIZATION_SCHEMA'); + canonicalAddress(value.to, 'authorization.to', 'AUTHORIZATION_SCHEMA'); + canonicalAtomic(value.value, 'authorization.value', 'AUTHORIZATION_SCHEMA'); + canonicalAtomic(value.validAfter, 'authorization.validAfter', 'AUTHORIZATION_SCHEMA'); + canonicalAtomic(value.validBefore, 'authorization.validBefore', 'AUTHORIZATION_SCHEMA'); + canonicalBytes32(value.nonce, 'authorization.nonce', 'AUTHORIZATION_SCHEMA'); + return value; +} + +function decodeCanonicalBase64Json(value) { + if (typeof value !== 'string' || value.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + fail('PAYMENT_ENCODING', 'X-PAYMENT must be canonical base64'); + } + const bytes = Buffer.from(value, 'base64'); + if (bytes.toString('base64') !== value) fail('PAYMENT_ENCODING', 'X-PAYMENT must be canonical base64'); + try { + return JSON.parse(bytes.toString('utf8')); + } catch { + fail('PAYMENT_ENCODING', 'X-PAYMENT must contain JSON'); + } +} + +function paymentEnvelopeSchema(value) { + exactObject(value, ['x402Version', 'scheme', 'network', 'payload'], + 'PAYMENT_SCHEMA', 'X-PAYMENT envelope'); + exactObject(value.payload, ['signature', 'authorization'], 'PAYMENT_SCHEMA', 'X-PAYMENT payload'); + canonicalSignature(value.payload.signature); + authorizationSchema(value.payload.authorization); + return value; +} + +const SETTLEMENT_FIELDS = [ + 'success', 'authorizationId', 'idempotencyKey', 'network', 'chainId', 'asset', 'payTo', + 'payer', 'value', 'nonce', 'settlementReference', 'requestHash', 'quoteId', 'transaction', +]; + +function settlementSchema(value, code = 'SETTLEMENT_SCHEMA') { + exactObject(value, SETTLEMENT_FIELDS, code, 'settlement evidence'); + if (value.success !== true || value.network !== BASE_SEPOLIA_NETWORK + || value.chainId !== BASE_SEPOLIA_CHAIN_ID) { + fail(code, 'settlement evidence has unsupported protocol values'); + } + if (typeof value.authorizationId !== 'string' || typeof value.idempotencyKey !== 'string') { + fail(code, 'settlement authorization identifiers must be strings'); + } + canonicalAddress(value.asset, 'settlement.asset', code); + canonicalAddress(value.payTo, 'settlement.payTo', code); + canonicalAddress(value.payer, 'settlement.payer', code); + canonicalAtomic(value.value, 'settlement.value', code); + canonicalBytes32(value.nonce, 'settlement.nonce', code); + canonicalBytes32(value.settlementReference, 'settlement.settlementReference', code); + canonicalHash(value.requestHash, 'settlement.requestHash', code); + canonicalHash(value.quoteId, 'settlement.quoteId', code); + canonicalBytes32(value.transaction, 'settlement.transaction', code); + return value; +} + +function rejectionEvidenceSchema(value, code = 'RECONCILIATION_SCHEMA') { + exactObject(value, SETTLEMENT_FIELDS, code, 'rejection evidence'); + if (value.success !== false || value.network !== BASE_SEPOLIA_NETWORK + || value.chainId !== BASE_SEPOLIA_CHAIN_ID) { + fail(code, 'rejection evidence has unsupported protocol values'); + } + if (typeof value.authorizationId !== 'string' || typeof value.idempotencyKey !== 'string') { + fail(code, 'rejection authorization identifiers must be strings'); + } + canonicalAddress(value.asset, 'rejection.asset', code); + canonicalAddress(value.payTo, 'rejection.payTo', code); + canonicalAddress(value.payer, 'rejection.payer', code); + canonicalAtomic(value.value, 'rejection.value', code); + canonicalBytes32(value.nonce, 'rejection.nonce', code); + canonicalBytes32(value.settlementReference, 'rejection.settlementReference', code); + canonicalHash(value.requestHash, 'rejection.requestHash', code); + canonicalHash(value.quoteId, 'rejection.quoteId', code); + if (value.transaction !== null) { + canonicalBytes32(value.transaction, 'rejection.transaction', code); + } + return value; +} + +function reconciliationSchema(value, outcome) { + exactObject(value, [ + ...SETTLEMENT_FIELDS, 'outcome', 'trustToken', ...(outcome === 'rejected' ? ['reasonCode'] : []), + ], 'RECONCILIATION_SCHEMA', 'reconciliation proof'); + if (value.outcome !== outcome || typeof value.trustToken !== 'string' || value.trustToken.length === 0) { + fail('RECONCILIATION_SCHEMA', 'reconciliation proof has an invalid outcome or trust token'); + } + if (outcome === 'rejected') canonicalReason(value.reasonCode); + const evidence = Object.fromEntries(SETTLEMENT_FIELDS.map((field) => [field, value[field]])); + if (outcome === 'settled') settlementSchema(evidence, 'RECONCILIATION_SCHEMA'); + else rejectionEvidenceSchema(evidence, 'RECONCILIATION_SCHEMA'); + return evidence; +} + +export function canonicalRequestHash({ method, requestUrl, bodyBytes }) { + const verb = canonicalMethod(method); + const target = resourceUrl(requestUrl).href; + const body = exactBodyBytes(bodyBytes); + return `sha256:${crypto.createHash('sha256') + .update(Buffer.concat([Buffer.from(`${verb}\n${target}\n`, 'utf8'), body])) + .digest('hex')}`; +} + +export function createPaymentPolicy(config) { + optionalExactObject(config, + ['network', 'chainId', 'asset', 'sessionBudgetAtomic', 'maxQuoteAgeMs', + 'maxAuthorizationSeconds', 'sellers'], + ['now', 'verifySettlementProof', 'verifyRejectionProof'], + 'POLICY_SCHEMA', 'payment policy'); + if (config.network !== BASE_SEPOLIA_NETWORK) fail('NETWORK_CONFIG', 'only Base Sepolia is supported'); + if (config.chainId !== BASE_SEPOLIA_CHAIN_ID) fail('CHAIN_CONFIG', 'only Base Sepolia chain ID 84532 is supported'); + if (config.asset !== BASE_SEPOLIA_USDC) fail('ASSET_CONFIG', 'only canonical Base Sepolia USDC is supported'); + const budget = canonicalAtomic(config.sessionBudgetAtomic, 'sessionBudgetAtomic').value; + if (!Number.isSafeInteger(config.maxQuoteAgeMs) || config.maxQuoteAgeMs < 0) { + fail('FRESHNESS_CONFIG', 'maxQuoteAgeMs must be a non-negative safe integer'); + } + if (!Number.isSafeInteger(config.maxAuthorizationSeconds) || config.maxAuthorizationSeconds <= 0) { + fail('TIMEOUT_CONFIG', 'maxAuthorizationSeconds must be a positive safe integer'); + } + if (!Array.isArray(config.sellers) || config.sellers.length === 0) { + fail('SELLER_CONFIG', 'at least one trusted seller is required'); + } + const now = config.now ?? (() => Date.now()); + if (typeof now !== 'function') fail('CLOCK_CONFIG', 'now must be an injected clock function'); + const verifySettlementProof = config.verifySettlementProof ?? (() => false); + const verifyRejectionProof = config.verifyRejectionProof ?? (() => false); + if (typeof verifySettlementProof !== 'function' || typeof verifyRejectionProof !== 'function') { + fail('PROOF_CONFIG', 'proof verifiers must be injected functions'); + } + + const rules = config.sellers.map((input) => { + exactObject(input, ['origin', 'pathPrefix', 'payTo', 'maxPerCallAtomic'], + 'SELLER_SCHEMA', 'seller rule'); + const rule = { + origin: sellerOrigin(input.origin), + pathPrefix: sellerPathPrefix(input.pathPrefix), + payTo: canonicalAddress(input.payTo, 'seller.payTo', 'SELLER_PAYEE'), + maxPerCallAtomic: canonicalAtomic(input.maxPerCallAtomic, 'maxPerCallAtomic').text, + }; + if (BigInt(rule.maxPerCallAtomic) <= 0n) fail('SELLER_LIMIT', 'seller per-call cap must be positive'); + return deepFreeze(rule); + }).sort((left, right) => right.pathPrefix.length - left.pathPrefix.length); + const ruleKeys = new Set(); + for (const rule of rules) { + const key = `${rule.origin}${rule.pathPrefix}`; + if (ruleKeys.has(key)) fail('SELLER_CONFIG', 'seller routes must be unique'); + ruleKeys.add(key); + } + deepFreeze(rules); + + const receiptTokens = new WeakSet(); + const records = new Map(); + const authorizationNonces = new Map(); + const settlementTransactions = new Map(); + let reservedAtomic = 0n; + let spentAtomic = 0n; + + function trustedNow() { + const value = now(); + if (!Number.isSafeInteger(value) || value < 0) fail('CLOCK_VALUE', 'trusted clock returned an invalid millisecond value'); + return value; + } + + function captureReceivedAt() { + const token = Object.freeze({ receivedAtMs: trustedNow() }); + receiptTokens.add(token); + return token; + } + + function validateOffer({ requestUrl, method, bodyBytes, challenge, receivedAt }) { + if (!receivedAt || !receiptTokens.has(receivedAt)) { + fail('RECEIVED_AT', 'receivedAt must come directly from this policy trusted clock'); + } + receiptTokens.delete(receivedAt); + const target = resourceUrl(requestUrl); + const seller = rules.find((rule) => rule.origin === target.origin + && routeMatches(target.pathname, rule.pathPrefix)); + if (!seller) fail('SELLER_UNTRUSTED', 'request URL is outside every trusted seller route'); + const candidate = frozenCopy(challengeSchema(challenge)); + if (candidate.scheme !== 'exact') fail('SCHEME_UNSUPPORTED', "x402 scheme must be 'exact'"); + if (candidate.network !== BASE_SEPOLIA_NETWORK) fail('NETWORK_MISMATCH', 'x402 network must be Base Sepolia'); + if (candidate.asset !== BASE_SEPOLIA_USDC) fail('ASSET_MISMATCH', 'x402 asset must be canonical Base Sepolia USDC'); + canonicalAddress(candidate.asset, 'offer.asset', 'ASSET_MISMATCH'); + canonicalAddress(candidate.payTo, 'offer.payTo', 'PAYEE_MISMATCH'); + if (candidate.payTo !== seller.payTo) fail('PAYEE_MISMATCH', 'x402 payee does not match the seller rule'); + if (candidate.resource !== target.href) fail('RESOURCE_MISMATCH', 'x402 resource must exactly match the request URL'); + resourceUrl(candidate.resource); + const requestHash = canonicalRequestHash({ method, requestUrl: target.href, bodyBytes }); + if (candidate.extra.requestHash !== requestHash) fail('REQUEST_HASH_MISMATCH', 'offer does not bind exact request bytes'); + if (candidate.extra.name !== 'USDC' || candidate.extra.version !== '2') { + fail('EIP712_DOMAIN', 'offer must use the canonical USDC v2 EIP-712 domain'); + } + canonicalHash(candidate.extra.requestHash, 'requestHash', 'REQUEST_HASH_MISMATCH'); + canonicalHash(candidate.extra.quoteId, 'quoteId', 'QUOTE_ID'); + const issuedAtMs = canonicalTimestamp(candidate.extra.issuedAt, 'issuedAt'); + const expiresAtMs = canonicalTimestamp(candidate.extra.expiresAt, 'expiresAt'); + const receivedAtMs = receivedAt.receivedAtMs; + if (issuedAtMs > receivedAtMs || receivedAtMs - issuedAtMs > config.maxQuoteAgeMs + || expiresAtMs <= receivedAtMs || issuedAtMs >= expiresAtMs) { + fail('QUOTE_EXPIRY', 'x402 quote is stale, future-issued, expired, or inverted'); + } + const amount = canonicalAtomic(candidate.maxAmountRequired, 'maxAmountRequired'); + if (amount.value <= 0n) fail('AMOUNT_ZERO', 'x402 amount must be positive'); + if (amount.value > BigInt(seller.maxPerCallAtomic)) fail('PER_CALL_LIMIT', 'x402 amount exceeds per-call policy'); + if (!Number.isSafeInteger(candidate.maxTimeoutSeconds) || candidate.maxTimeoutSeconds <= 0 + || candidate.maxTimeoutSeconds > config.maxAuthorizationSeconds) { + fail('TIMEOUT_LIMIT', 'x402 timeout exceeds local policy'); + } + const receivedSeconds = Math.floor(receivedAtMs / 1_000); + const expiresSeconds = Math.floor(expiresAtMs / 1_000); + const validAfter = Math.max(0, receivedSeconds - 60).toString(); + const validBefore = Math.min( + expiresSeconds, + receivedSeconds + candidate.maxTimeoutSeconds, + ).toString(); + if (BigInt(validBefore) <= BigInt(validAfter)) fail('TIMEOUT_LIMIT', 'authorization validity window is empty'); + return { + amountAtomic: amount.text, + requestUrl: target.href, + method: canonicalMethod(method), + requestHash, + quoteId: candidate.extra.quoteId, + offerFingerprint: hashJson(candidate), + offer: candidate, + receivedAtMs, + validAfter, + validBefore, + }; + } + + function id(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9._:-]{1,200}$/.test(value)) { + fail('AUTHORIZATION_ID', 'authorizationId must be a bounded canonical token'); + } + return value; + } + + function get(authorizationId) { + const record = records.get(id(authorizationId)); + if (!record) fail('AUTHORIZATION_UNKNOWN', 'authorization does not exist'); + return record; + } + + function publicRecord(record) { + return frozenCopy(record); + } + + function reserveAuthorization(input) { + exactObject(input, ['authorizationId', 'requestUrl', 'method', 'bodyBytes', 'challenge', 'receivedAt'], + 'RESERVATION_SCHEMA', 'authorization reservation'); + const authorizationId = id(input.authorizationId); + const validated = validateOffer(input); + const existing = records.get(authorizationId); + if (existing) { + if (existing.offerFingerprint !== validated.offerFingerprint + || existing.requestUrl !== validated.requestUrl + || existing.method !== validated.method + || existing.requestHash !== validated.requestHash) { + fail('AUTHORIZATION_CONFLICT', 'authorizationId already binds different request or offer bytes'); + } + return publicRecord(existing); + } + const amount = BigInt(validated.amountAtomic); + if (spentAtomic + reservedAtomic + amount > budget) { + fail('SESSION_BUDGET', 'offer exceeds remaining one-process session budget'); + } + const record = { + authorizationId, + ...validated, + state: 'reserved', + retryCount: 0, + txHash: null, + reasonCode: null, + authorization: null, + signature: null, + xPayment: null, + }; + records.set(authorizationId, record); + reservedAtomic += amount; + return publicRecord(record); + } + + function claimSignature(authorizationId, input) { + exactObject(input, ['offerFingerprint'], 'SIGNATURE_CLAIM_SCHEMA', 'signature claim'); + const record = get(authorizationId); + if (input.offerFingerprint !== record.offerFingerprint) { + fail('AUTHORIZATION_CONFLICT', 'signature claim does not match the frozen offer'); + } + if (record.state !== 'reserved') return Object.freeze({ claimed: false, authorization: publicRecord(record) }); + record.state = 'signing'; + return Object.freeze({ claimed: true, authorization: publicRecord(record) }); + } + + function releaseUnsigned(authorizationId, input) { + exactObject(input, ['reasonCode'], 'UNSIGNED_RELEASE_SCHEMA', 'unsigned release'); + const record = get(authorizationId); + if (!['reserved', 'signing'].includes(record.state)) { + fail('UNSIGNED_RELEASE_STATE', 'only an authorization that cannot have produced a signature may be released'); + } + reservedAtomic -= BigInt(record.amountAtomic); + record.state = 'released'; + record.reasonCode = canonicalReason(input.reasonCode); + return publicRecord(record); + } + + function markPotentiallySigned(authorizationId, input) { + exactObject(input, ['reasonCode'], 'UNRESOLVED_SCHEMA', 'potential signature result'); + const record = get(authorizationId); + if (record.state !== 'signing') fail('POTENTIAL_SIGNATURE_STATE', 'potential signature requires a signing claim'); + record.state = 'unresolved'; + record.reasonCode = canonicalReason(input.reasonCode); + return publicRecord(record); + } + + function persistSignedAuthorization(authorizationId, input) { + exactObject(input, ['authorization', 'signature', 'xPayment'], + 'SIGNED_AUTHORIZATION_SCHEMA', 'signed authorization'); + const record = get(authorizationId); + if (record.state !== 'signing') fail('SIGNED_STATE', 'signed authorization can only follow one signature claim'); + const authorization = frozenCopy(authorizationSchema(input.authorization)); + const signature = canonicalSignature(input.signature); + const envelope = paymentEnvelopeSchema(decodeCanonicalBase64Json(input.xPayment)); + if (envelope.x402Version !== 1 || envelope.scheme !== 'exact' + || envelope.network !== BASE_SEPOLIA_NETWORK + || canonicalJson(envelope.payload.authorization) !== canonicalJson(authorization) + || envelope.payload.signature !== signature) { + fail('PAYMENT_MISMATCH', 'X-PAYMENT does not exactly contain the persisted authorization'); + } + if (authorization.to !== record.offer.payTo + || authorization.value !== record.amountAtomic + || authorization.validAfter !== record.validAfter + || authorization.validBefore !== record.validBefore) { + fail('AUTHORIZATION_MISMATCH', 'signed authorization differs from the frozen offer or validity window'); + } + const nonceOwner = authorizationNonces.get(authorization.nonce); + if (nonceOwner && nonceOwner !== record.authorizationId) { + fail('NONCE_REUSE', 'EIP-3009 nonce is already bound to another authorization'); + } + authorizationNonces.set(authorization.nonce, record.authorizationId); + record.authorization = authorization; + record.signature = signature; + record.xPayment = input.xPayment; + record.state = 'signed'; + record.reasonCode = null; + return publicRecord(record); + } + + function requestIdentity({ requestUrl, method, bodyBytes }) { + const target = resourceUrl(requestUrl).href; + const verb = canonicalMethod(method); + return { + requestUrl: target, + method: verb, + requestHash: canonicalRequestHash({ method: verb, requestUrl: target, bodyBytes }), + }; + } + + function recoverSignedAuthorization(input) { + exactObject(input, ['authorizationId', 'requestUrl', 'method', 'bodyBytes'], + 'RECOVERY_SCHEMA', 'signed authorization recovery'); + const record = get(input.authorizationId); + const identity = requestIdentity(input); + if (record.requestUrl !== identity.requestUrl || record.method !== identity.method + || record.requestHash !== identity.requestHash) { + fail('RECOVERY_REQUEST_MISMATCH', 'recovery request does not match persisted exact request bytes'); + } + if (!record.authorization || !record.signature || !record.xPayment + || !['signed', 'retrying', 'unresolved', 'settled'].includes(record.state)) { + fail('SIGNED_AUTHORIZATION_UNAVAILABLE', 'no exact persisted signed authorization is recoverable'); + } + return publicRecord(record); + } + + function beginRetry(authorizationId) { + const record = get(authorizationId); + if (record.state !== 'signed' || record.retryCount !== 0 || !record.xPayment) { + fail('RETRY_LIMIT', 'authorization permits exactly one paid retry'); + } + record.retryCount = 1; + record.state = 'retrying'; + return publicRecord(record); + } + + function assertRetryChallenge(authorizationId, secondChallenge) { + const record = get(authorizationId); + let second; + try { + second = challengeSchema(secondChallenge); + } catch { + fail('QUOTE_CHANGED', 'second payment challenge is malformed or changed'); + } + if (hashJson(second) !== record.offerFingerprint) fail('QUOTE_CHANGED', 'seller changed the frozen offer'); + return publicRecord(record); + } + + function markUnresolved(authorizationId, input) { + exactObject(input, ['reasonCode'], 'UNRESOLVED_SCHEMA', 'unresolved transition'); + const record = get(authorizationId); + if (record.state === 'unresolved') return publicRecord(record); + if (!['signing', 'signed', 'retrying'].includes(record.state)) { + fail('UNRESOLVED_STATE', 'only a potentially signed authorization can become unresolved'); + } + record.state = 'unresolved'; + record.reasonCode = canonicalReason(input.reasonCode); + return publicRecord(record); + } + + function assertSettlementMatches(record, evidence, code = 'SETTLEMENT_MISMATCH') { + if (!record.authorization + || evidence.authorizationId !== record.authorizationId + || evidence.idempotencyKey !== record.authorizationId + || evidence.network !== BASE_SEPOLIA_NETWORK + || evidence.chainId !== BASE_SEPOLIA_CHAIN_ID + || evidence.asset !== record.offer.asset + || evidence.payTo !== record.authorization.to + || evidence.payer !== record.authorization.from + || evidence.value !== record.authorization.value + || evidence.nonce !== record.authorization.nonce + || evidence.settlementReference !== record.authorization.nonce + || evidence.requestHash !== record.requestHash + || evidence.quoteId !== record.quoteId) { + fail(code, 'settlement or reconciliation evidence does not match the signed authorization'); + } + } + + function settle(record, evidence) { + if (record.state === 'settled') { + if (record.txHash !== evidence.transaction) fail('SETTLEMENT_CONFLICT', 'authorization already binds another transaction'); + return publicRecord(record); + } + const transactionOwner = settlementTransactions.get(evidence.transaction); + if (transactionOwner && transactionOwner !== record.authorizationId) { + fail('TRANSACTION_REUSE', 'settlement transaction is already bound to another authorization'); + } + settlementTransactions.set(evidence.transaction, record.authorizationId); + reservedAtomic -= BigInt(record.amountAtomic); + spentAtomic += BigInt(record.amountAtomic); + record.state = 'settled'; + record.txHash = evidence.transaction; + record.reasonCode = null; + return publicRecord(record); + } + + function acceptSettlement(authorizationId, input) { + const record = get(authorizationId); + if (!['retrying', 'settled'].includes(record.state)) { + fail('SETTLEMENT_STATE', 'only the immediate paid retry response can settle without trusted reconciliation'); + } + const evidence = settlementSchema(frozenCopy(input)); + assertSettlementMatches(record, evidence); + return settle(record, evidence); + } + + function verifierAccepted(verifier, record, proof) { + const result = verifier({ authorization: publicRecord(record), proof: frozenCopy(proof) }); + if (result && typeof result.then === 'function') fail('PROOF_ASYNC', 'proof verifier must be a synchronous trust capability'); + return result === true; + } + + function reconcileSettlement(authorizationId, proof) { + const record = get(authorizationId); + if (!['signed', 'retrying', 'unresolved', 'settled'].includes(record.state)) { + fail('RECONCILIATION_STATE', 'settlement reconciliation requires a signed authorization'); + } + const evidence = reconciliationSchema(frozenCopy(proof), 'settled'); + assertSettlementMatches(record, evidence, 'RECONCILIATION_MISMATCH'); + if (!verifierAccepted(verifySettlementProof, record, proof)) { + fail('SETTLEMENT_PROOF', 'trusted settlement proof verifier rejected evidence'); + } + return settle(record, evidence); + } + + function reconcileRejection(authorizationId, proof) { + const record = get(authorizationId); + if (!['signed', 'retrying', 'unresolved'].includes(record.state)) { + fail('RECONCILIATION_STATE', 'rejection reconciliation requires a nonterminal signed authorization'); + } + const evidence = reconciliationSchema(frozenCopy(proof), 'rejected'); + assertSettlementMatches(record, evidence, 'RECONCILIATION_MISMATCH'); + if (!verifierAccepted(verifyRejectionProof, record, proof)) { + fail('REJECTION_PROOF', 'trusted rejection proof verifier rejected evidence'); + } + reservedAtomic -= BigInt(record.amountAtomic); + record.state = 'rejected'; + record.reasonCode = proof.reasonCode; + return publicRecord(record); + } + + function snapshot() { + return frozenCopy({ + sessionBudgetAtomic: budget.toString(), + reservedAtomic: reservedAtomic.toString(), + spentAtomic: spentAtomic.toString(), + remainingAtomic: (budget - reservedAtomic - spentAtomic).toString(), + authorizations: [...records.values()] + .sort((left, right) => left.authorizationId.localeCompare(right.authorizationId)) + .map(({ authorizationId, amountAtomic, state, retryCount, txHash, reasonCode }) => ({ + authorizationId, amountAtomic, state, retryCount, txHash, reasonCode, + })), + }); + } + + return Object.freeze({ + captureReceivedAt, + validateOffer, + reserveAuthorization, + claimSignature, + releaseUnsigned, + markPotentiallySigned, + persistSignedAuthorization, + recoverSignedAuthorization, + beginRetry, + assertRetryChallenge, + markUnresolved, + acceptSettlement, + reconcileSettlement, + reconcileRejection, + snapshot, + }); +} diff --git a/spikes/pi-wielder/tests/payment-policy.test.mjs b/spikes/pi-wielder/tests/payment-policy.test.mjs new file mode 100644 index 0000000..32718a8 --- /dev/null +++ b/spikes/pi-wielder/tests/payment-policy.test.mjs @@ -0,0 +1,460 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + BASE_SEPOLIA_CHAIN_ID, + BASE_SEPOLIA_NETWORK, + BASE_SEPOLIA_USDC, + canonicalRequestHash, + createPaymentPolicy, + PaymentPolicyError, +} from '../src/payment-policy.mjs'; + +const PAYEE = '0x000000000000000000000000000000000000dead'; +const PAYER = '0x1000000000000000000000000000000000000000'; +const URL = 'https://trusted.example/invoke/skill-a'; +const BODY = '{"input":"exact bytes"}'; +const NOW = Date.UTC(2026, 6, 17, 12, 0, 10); +const TX_HASH = `0x${'2'.repeat(64)}`; +const NONCE = `0x${'3'.repeat(64)}`; +const SIGNATURE = `0x${'4'.repeat(130)}`; + +function offer(overrides = {}) { + const requestHash = canonicalRequestHash({ method: 'POST', requestUrl: URL, bodyBytes: BODY }); + const base = { + scheme: 'exact', + network: BASE_SEPOLIA_NETWORK, + maxAmountRequired: '250000', + resource: URL, + description: 'test Skill', + mimeType: 'application/json', + payTo: PAYEE, + maxTimeoutSeconds: 60, + asset: BASE_SEPOLIA_USDC, + extra: { + name: 'USDC', + version: '2', + requestHash, + quoteId: `sha256:${'b'.repeat(64)}`, + issuedAt: new Date(NOW - 1_000).toISOString(), + expiresAt: new Date(NOW + 59_000).toISOString(), + }, + }; + return { ...base, ...overrides }; +} + +function challenge(candidate = offer()) { + return { + x402Version: 1, + error: 'X-PAYMENT header is required', + accepts: [candidate], + }; +} + +function policy(overrides = {}) { + return createPaymentPolicy({ + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + sessionBudgetAtomic: '500000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + now: () => NOW, + sellers: [{ + origin: 'https://trusted.example', + pathPrefix: '/invoke/', + payTo: PAYEE, + maxPerCallAtomic: '300000', + }], + ...overrides, + }); +} + +function reserve(subject, overrides = {}) { + return subject.reserveAuthorization({ + authorizationId: 'auth-1', + requestUrl: URL, + method: 'POST', + bodyBytes: BODY, + challenge: challenge(), + receivedAt: subject.captureReceivedAt(), + ...overrides, + }); +} + +function authorization(record, overrides = {}) { + return { + from: PAYER, + to: PAYEE, + value: record.amountAtomic, + validAfter: record.validAfter, + validBefore: record.validBefore, + nonce: NONCE, + ...overrides, + }; +} + +function encodePayment(record, auth, overrides = {}) { + return Buffer.from(JSON.stringify({ + x402Version: 1, + scheme: 'exact', + network: BASE_SEPOLIA_NETWORK, + payload: { signature: SIGNATURE, authorization: auth }, + ...overrides, + })).toString('base64'); +} + +function sign(subject, record = reserve(subject)) { + assert.equal(subject.claimSignature(record.authorizationId, { + offerFingerprint: record.offerFingerprint, + }).claimed, true); + const auth = authorization(record); + const xPayment = encodePayment(record, auth); + subject.persistSignedAuthorization(record.authorizationId, { + authorization: auth, + signature: SIGNATURE, + xPayment, + }); + return { record, auth, xPayment }; +} + +function settlementEvidence(subject, id = 'auth-1', overrides = {}) { + const recovered = subject.recoverSignedAuthorization({ + authorizationId: id, + requestUrl: URL, + method: 'POST', + bodyBytes: BODY, + }); + return { + success: true, + authorizationId: id, + idempotencyKey: id, + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + payTo: PAYEE, + payer: PAYER, + value: recovered.authorization.value, + nonce: NONCE, + settlementReference: NONCE, + requestHash: recovered.requestHash, + quoteId: recovered.quoteId, + transaction: TX_HASH, + ...overrides, + }; +} + +test('policy module fixes Base Sepolia and canonical Base Sepolia USDC', () => { + assert.equal(BASE_SEPOLIA_NETWORK, 'base-sepolia'); + assert.equal(BASE_SEPOLIA_CHAIN_ID, 84532); + assert.equal(BASE_SEPOLIA_USDC, '0x036cbd53842c5426634e7929541ec2318f3dcf7e'); + assert.throws(() => policy({ network: 'base' }), (error) => error.code === 'NETWORK_CONFIG'); + assert.throws(() => policy({ chainId: 1 }), (error) => error.code === 'CHAIN_CONFIG'); + assert.throws(() => policy({ asset: `0x${'1'.repeat(40)}` }), (error) => error.code === 'ASSET_CONFIG'); +}); + +test('canonical request hash binds uppercase method, exact URL, and exact body bytes', () => { + const hash = canonicalRequestHash({ method: 'POST', requestUrl: URL, bodyBytes: BODY }); + assert.match(hash, /^sha256:[0-9a-f]{64}$/); + assert.notEqual(hash, canonicalRequestHash({ method: 'POST', requestUrl: URL, bodyBytes: `${BODY}\n` })); + assert.notEqual(hash, canonicalRequestHash({ method: 'PUT', requestUrl: URL, bodyBytes: BODY })); + assert.throws(() => canonicalRequestHash({ method: 'post', requestUrl: URL, bodyBytes: BODY }), + (error) => error.code === 'REQUEST_METHOD'); +}); + +test('only a capability returned by the trusted policy clock can mark local 402 receipt time', () => { + const subject = policy(); + assert.throws(() => reserve(subject, { receivedAt: NOW }), (error) => error.code === 'RECEIVED_AT'); + assert.throws(() => reserve(subject, { receivedAt: { receivedAtMs: NOW } }), + (error) => error.code === 'RECEIVED_AT'); + assert.equal(reserve(subject).receivedAtMs, NOW); +}); + +test('each trusted local receipt-time capability is single-use even for the same offer', () => { + const subject = policy(); + const receivedAt = subject.captureReceivedAt(); + reserve(subject, { authorizationId: 'auth-token-1', receivedAt }); + assert.throws(() => reserve(subject, { authorizationId: 'auth-token-2', receivedAt }), + (error) => error.code === 'RECEIVED_AT'); +}); + +const rejectionCases = [ + ['wrong scheme', offer({ scheme: 'upto' }), 'SCHEME'], + ['wrong network', offer({ network: 'Base-Sepolia' }), 'NETWORK'], + ['numeric amount', offer({ maxAmountRequired: 250000 }), 'AMOUNT_FORMAT'], + ['noncanonical amount', offer({ maxAmountRequired: '0250000' }), 'AMOUNT_FORMAT'], + ['zero amount', offer({ maxAmountRequired: '0' }), 'AMOUNT_ZERO'], + ['over per-call amount', offer({ maxAmountRequired: '300001' }), 'PER_CALL'], + ['wrong asset', offer({ asset: `0x${'1'.repeat(40)}` }), 'ASSET'], + ['case-ambiguous asset', offer({ asset: BASE_SEPOLIA_USDC.toUpperCase().replace('0X', '0x') }), 'ASSET'], + ['wrong payee', offer({ payTo: `0x${'2'.repeat(40)}` }), 'PAYEE'], + ['wrong resource', offer({ resource: 'https://trusted.example/invoke/skill-b' }), 'RESOURCE'], + ['excess timeout', offer({ maxTimeoutSeconds: 61 }), 'TIMEOUT'], + ['numeric timeout string', offer({ maxTimeoutSeconds: '60' }), 'TIMEOUT'], + ['wrong EIP-712 name', offer({ extra: { ...offer().extra, name: 'FakeUSDC' } }), 'EIP712'], + ['wrong EIP-712 version', offer({ extra: { ...offer().extra, version: '1' } }), 'EIP712'], + ['wrong request hash', offer({ extra: { ...offer().extra, requestHash: `sha256:${'f'.repeat(64)}` } }), 'REQUEST_HASH'], + ['malformed quote ID', offer({ extra: { ...offer().extra, quoteId: 'q-1' } }), 'QUOTE_ID'], + ['expired quote', offer({ extra: { ...offer().extra, expiresAt: new Date(NOW).toISOString() } }), 'QUOTE_EXPIRY'], + ['future-issued quote', offer({ extra: { ...offer().extra, issuedAt: new Date(NOW + 1).toISOString() } }), 'QUOTE_EXPIRY'], +]; + +for (const [name, candidate, code] of rejectionCases) { + test(`rejects ${name} before reservation`, () => { + const subject = policy(); + assert.throws(() => reserve(subject, { challenge: challenge(candidate) }), + (error) => error instanceof PaymentPolicyError && error.code.includes(code)); + assert.equal(subject.snapshot().reservedAtomic, '0'); + }); +} + +test('challenge, offer, nested extra, and seller rules are strict exact plain objects', () => { + assert.throws(() => reserve(policy(), { challenge: { ...challenge(), injected: true } }), + (error) => error.code === 'CHALLENGE_SCHEMA'); + assert.throws(() => reserve(policy(), { challenge: challenge(offer({ injected: true })) }), + (error) => error.code === 'OFFER_SCHEMA'); + assert.throws(() => reserve(policy(), { challenge: challenge(offer({ + extra: { ...offer().extra, injected: true }, + })) }), (error) => error.code === 'OFFER_EXTRA_SCHEMA'); + assert.throws(() => createPaymentPolicy({ + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + sessionBudgetAtomic: '500000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + now: () => NOW, + sellers: [{ + origin: 'https://trusted.example', pathPrefix: '/invoke/', payTo: PAYEE, + maxPerCallAtomic: '300000', injected: true, + }], + }), (error) => error.code === 'SELLER_SCHEMA'); +}); + +test('seller URL and route matching reject credentials, query, fragment, normalization, and prefix confusion', () => { + for (const origin of [ + 'ftp://trusted.example', + 'https://user:pass@trusted.example', + 'https://trusted.example/', + 'https://trusted.example?x=1', + 'https://trusted.example#x', + ]) { + assert.throws(() => policy({ sellers: [{ + origin, pathPrefix: '/invoke/', payTo: PAYEE, maxPerCallAtomic: '300000', + }] }), (error) => ['SELLER_ORIGIN', 'SELLER_SCHEMA'].includes(error.code)); + } + for (const requestUrl of [ + 'https://trusted.example/invoke-evil/skill-a', + 'https://trusted.example/invokeevil/skill-a', + 'https://trusted.example/invoke/skill-a?redirect=x', + 'https://trusted.example/invoke/skill-a#fragment', + 'https://trusted.example/invoke/%2e%2e/admin', + 'https://TRUSTED.example/invoke/skill-a', + ]) { + const subject = policy(); + assert.throws(() => reserve(subject, { + requestUrl, + challenge: challenge(offer({ resource: requestUrl })), + }), (error) => ['SELLER_UNTRUSTED', 'RESOURCE_URL'].includes(error.code)); + } +}); + +test('concurrent reservations count reserved and settled spend against one session budget', () => { + const subject = policy({ sessionBudgetAtomic: '400000' }); + reserve(subject, { authorizationId: 'auth-1' }); + assert.throws(() => reserve(subject, { authorizationId: 'auth-2' }), + (error) => error.code === 'SESSION_BUDGET'); + assert.deepEqual(subject.snapshot(), { + sessionBudgetAtomic: '400000', + reservedAtomic: '250000', + spentAtomic: '0', + remainingAtomic: '150000', + authorizations: [{ + authorizationId: 'auth-1', amountAtomic: '250000', state: 'reserved', + retryCount: 0, txHash: null, reasonCode: null, + }], + }); +}); + +test('authorization, signature, and encoded payment are exact, immutable, and copied before persistence', () => { + const subject = policy(); + const record = reserve(subject); + subject.claimSignature('auth-1', { offerFingerprint: record.offerFingerprint }); + const auth = authorization(record); + const xPayment = encodePayment(record, auth); + const persisted = subject.persistSignedAuthorization('auth-1', { + authorization: auth, signature: SIGNATURE, xPayment, + }); + auth.value = '1'; + assert.equal(persisted.authorization.value, '250000'); + assert.equal(Object.isFrozen(persisted), true); + assert.equal(Object.isFrozen(persisted.authorization), true); + assert.throws(() => subject.persistSignedAuthorization('auth-1', { + authorization: { ...authorization(record), injected: true }, signature: SIGNATURE, xPayment, + }), (error) => ['SIGNED_STATE', 'AUTHORIZATION_SCHEMA'].includes(error.code)); +}); + +test('unsigned signer rejection releases exactly once but a potentially produced signature holds budget', () => { + const unsigned = policy(); + const first = reserve(unsigned); + unsigned.claimSignature('auth-1', { offerFingerprint: first.offerFingerprint }); + unsigned.releaseUnsigned('auth-1', { reasonCode: 'SIGNER_REJECTED' }); + assert.equal(unsigned.snapshot().reservedAtomic, '0'); + assert.throws(() => unsigned.releaseUnsigned('auth-1', { reasonCode: 'SIGNER_REJECTED' }), + (error) => error.code === 'UNSIGNED_RELEASE_STATE'); + + const uncertain = policy(); + const second = reserve(uncertain); + uncertain.claimSignature('auth-1', { offerFingerprint: second.offerFingerprint }); + uncertain.markPotentiallySigned('auth-1', { reasonCode: 'SIGNATURE_PERSISTENCE_UNCERTAIN' }); + assert.equal(uncertain.snapshot().reservedAtomic, '250000'); + assert.equal(uncertain.snapshot().authorizations[0].state, 'unresolved'); + assert.throws(() => uncertain.releaseUnsigned('auth-1', { reasonCode: 'LATE_RELEASE' }), + (error) => error.code === 'UNSIGNED_RELEASE_STATE'); +}); + +test('exact persisted signed authorization is recoverable without a replacement signature', () => { + const subject = policy(); + const { xPayment } = sign(subject); + const recovered = subject.recoverSignedAuthorization({ + authorizationId: 'auth-1', requestUrl: URL, method: 'POST', bodyBytes: BODY, + }); + assert.equal(recovered.xPayment, xPayment); + assert.equal(recovered.signature, SIGNATURE); + assert.throws(() => subject.recoverSignedAuthorization({ + authorizationId: 'auth-1', requestUrl: URL, method: 'POST', bodyBytes: `${BODY} `, + }), (error) => error.code === 'RECOVERY_REQUEST_MISMATCH'); +}); + +test('one signed authorization permits exactly one paid retry and immutable amount', () => { + const subject = policy(); + sign(subject); + subject.beginRetry('auth-1'); + assert.throws(() => subject.beginRetry('auth-1'), (error) => error.code === 'RETRY_LIMIT'); + assert.throws(() => subject.assertRetryChallenge('auth-1', challenge(offer({ maxAmountRequired: '250001' }))), + (error) => error.code === 'QUOTE_CHANGED'); +}); + +test('malformed or mismatched settlement evidence remains reserved', () => { + for (const mutation of [ + { value: '250001' }, + { payer: `0x${'9'.repeat(40)}` }, + { requestHash: `sha256:${'9'.repeat(64)}` }, + { quoteId: `sha256:${'8'.repeat(64)}` }, + { network: 'base' }, + { chainId: 1 }, + { asset: `0x${'7'.repeat(40)}` }, + { payTo: `0x${'6'.repeat(40)}` }, + { settlementReference: `0x${'5'.repeat(64)}` }, + { transaction: 'not-a-hash' }, + { injected: true }, + ]) { + const subject = policy(); + sign(subject); + subject.beginRetry('auth-1'); + assert.throws(() => subject.acceptSettlement('auth-1', settlementEvidence(subject, 'auth-1', mutation)), + (error) => ['SETTLEMENT_MISMATCH', 'SETTLEMENT_SCHEMA'].includes(error.code)); + assert.equal(subject.snapshot().reservedAtomic, '250000'); + } +}); + +test('settled spend moves reservation exactly once and settled HTTP status is not a policy concern', () => { + const subject = policy(); + sign(subject); + subject.beginRetry('auth-1'); + subject.acceptSettlement('auth-1', settlementEvidence(subject)); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().spentAtomic, '250000'); + assert.equal(subject.snapshot().remainingAtomic, '250000'); +}); + +test('unresolved authorizations retain budget until trusted field-bound settlement reconciliation', () => { + const trusted = []; + const subject = policy({ + verifySettlementProof: ({ authorization, proof }) => { + trusted.push({ authorization, proof }); + return proof.trustToken === 'trusted-settlement'; + }, + }); + sign(subject); + subject.beginRetry('auth-1'); + subject.markUnresolved('auth-1', { reasonCode: 'RETRY_RESPONSE_LOST' }); + const evidence = settlementEvidence(subject); + assert.throws(() => subject.acceptSettlement('auth-1', evidence), + (error) => error.code === 'SETTLEMENT_STATE'); + const proof = { ...evidence, outcome: 'settled', trustToken: 'trusted-settlement' }; + subject.reconcileSettlement('auth-1', proof); + assert.equal(trusted.length, 1); + assert.equal(subject.snapshot().spentAtomic, '250000'); + assert.equal(subject.snapshot().reservedAtomic, '0'); +}); + +test('trusted proof capability cannot authorize mismatched request data', () => { + let verifierCalls = 0; + const subject = policy({ + verifyRejectionProof: () => { verifierCalls += 1; return true; }, + }); + sign(subject); + subject.beginRetry('auth-1'); + subject.markUnresolved('auth-1', { reasonCode: 'SETTLEMENT_EVIDENCE_INVALID' }); + const evidence = settlementEvidence(subject); + const baseProof = { + ...evidence, + success: false, + transaction: null, + outcome: 'rejected', + reasonCode: 'CHAIN_REJECTED', + trustToken: 'trusted-rejection', + }; + assert.throws(() => subject.reconcileRejection('auth-1', { + ...baseProof, success: true, + }), (error) => error.code === 'RECONCILIATION_SCHEMA'); + assert.throws(() => subject.reconcileRejection('auth-1', { ...baseProof, value: '1' }), + (error) => error.code === 'RECONCILIATION_MISMATCH'); + assert.equal(verifierCalls, 0); + assert.equal(subject.snapshot().reservedAtomic, '250000'); + subject.reconcileRejection('auth-1', baseProof); + assert.equal(verifierCalls, 1); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().spentAtomic, '0'); +}); + +test('one EIP-3009 nonce cannot be persisted under two authorizations', () => { + const subject = policy(); + sign(subject, reserve(subject, { authorizationId: 'auth-nonce-1' })); + const second = reserve(subject, { authorizationId: 'auth-nonce-2' }); + subject.claimSignature('auth-nonce-2', { offerFingerprint: second.offerFingerprint }); + const duplicate = authorization(second); + assert.throws(() => subject.persistSignedAuthorization('auth-nonce-2', { + authorization: duplicate, + signature: SIGNATURE, + xPayment: encodePayment(second, duplicate), + }), (error) => error.code === 'NONCE_REUSE'); + assert.equal(subject.snapshot().reservedAtomic, '500000'); + assert.deepEqual(subject.snapshot().authorizations.map(({ state }) => state), ['signed', 'signing']); +}); + +test('one settlement transaction cannot settle two different authorizations', () => { + const subject = policy(); + sign(subject, reserve(subject, { authorizationId: 'auth-tx-1' })); + subject.beginRetry('auth-tx-1'); + subject.acceptSettlement('auth-tx-1', settlementEvidence(subject, 'auth-tx-1')); + + const second = reserve(subject, { authorizationId: 'auth-tx-2' }); + subject.claimSignature('auth-tx-2', { offerFingerprint: second.offerFingerprint }); + const secondAuthorization = authorization(second, { nonce: `0x${'6'.repeat(64)}` }); + subject.persistSignedAuthorization('auth-tx-2', { + authorization: secondAuthorization, + signature: SIGNATURE, + xPayment: encodePayment(second, secondAuthorization), + }); + subject.beginRetry('auth-tx-2'); + assert.throws(() => subject.acceptSettlement('auth-tx-2', { + ...settlementEvidence(subject, 'auth-tx-2'), + nonce: secondAuthorization.nonce, + settlementReference: secondAuthorization.nonce, + transaction: TX_HASH, + }), (error) => error.code === 'TRANSACTION_REUSE'); + assert.equal(subject.snapshot().spentAtomic, '250000'); + assert.equal(subject.snapshot().reservedAtomic, '250000'); +}); From b0c07ed79f9179719777e4a035802c97b2ef15c0 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:57:28 -0400 Subject: [PATCH 100/165] feat: gate x402 signatures and settlement --- spikes/pi-wielder/e2e.mjs | 45 +- spikes/pi-wielder/src/collar.mjs | 2 +- spikes/pi-wielder/src/proxy.mjs | 366 +++++++++++++--- spikes/pi-wielder/src/x402-seller.mjs | 83 +++- .../pi-wielder/tests/collar-failure.test.mjs | 50 ++- .../tests/gateway-transport.test.mjs | 8 +- spikes/pi-wielder/tests/paying-fetch.test.mjs | 399 ++++++++++++++++++ .../tests/payment-policy-fixture.mjs | 29 ++ .../tests/seller-payment-response.test.mjs | 66 +++ .../pi-wielder/tests/x402-lifecycle.test.mjs | 149 +++++-- 10 files changed, 1073 insertions(+), 124 deletions(-) create mode 100644 spikes/pi-wielder/tests/paying-fetch.test.mjs create mode 100644 spikes/pi-wielder/tests/payment-policy-fixture.mjs create mode 100644 spikes/pi-wielder/tests/seller-payment-response.test.mjs diff --git a/spikes/pi-wielder/e2e.mjs b/spikes/pi-wielder/e2e.mjs index 804bcbb..17a089b 100644 --- a/spikes/pi-wielder/e2e.mjs +++ b/spikes/pi-wielder/e2e.mjs @@ -11,7 +11,7 @@ import { createCollar, SKILL_ID } from './src/collar.mjs'; import { createMockFacilitator } from './src/facilitator-mock.mjs'; import { createGateway, MODEL_PRICES_USDC } from './src/gateway.mjs'; import { verifySignedReceipt } from './src/invocation-journal.mjs'; -import { payingFetch, createProxy } from './src/proxy.mjs'; +import { createDefaultPaymentPolicy, payingFetch, createProxy } from './src/proxy.mjs'; import { throwawayAccount } from './src/wallet.mjs'; import { createMockFacilitatorTransport, usdcToAtomic } from './src/x402-seller.mjs'; @@ -133,6 +133,12 @@ eq(entries.map((entry) => entry.amountAtomic), [ usdcToAtomic('0.25'), ], 'quoted amounts remain canonical atomic strings'); ok(entries.every((entry) => /^0x[0-9a-f]{64}$/.test(entry.txHash)), 'every settled view carries a transaction hash'); +const policySnapshot = proxy.paymentPolicy.snapshot(); +eq(policySnapshot.spentAtomic, usdcToAtomic('0.378'), 'policy records exact session spend'); +eq(policySnapshot.reservedAtomic, '0', 'no successful authorization remains reserved'); +eq(policySnapshot.authorizations.length, 3, 'one authorization exists per paid call'); +ok(policySnapshot.authorizations.every((authorization) => authorization.retryCount === 1), 'every authorization retried exactly once'); +ok(policySnapshot.authorizations.every((authorization) => authorization.state === 'settled'), 'every e2e authorization settled'); ok( JSON.stringify(entries[2].receipt) === JSON.stringify(skill.json.receipt), 'response and Wielder cache contain the identical signed receipt', @@ -196,15 +202,42 @@ const unresolvedCollar = createCollar({ signingKeyFile: null, }); const unresolvedKey = 'e2e-unresolved-payment'; -const unresolved = await payingFetch(account, `http://unresolved.test/invoke/${SKILL_ID}`, { +const unresolvedUrl = `http://unresolved.test/invoke/${SKILL_ID}`; +const unresolvedPolicy = createDefaultPaymentPolicy({ + gatewayUrl: 'http://unresolved.test', + collarUrl: 'http://unresolved.test', + env: {}, +}); +let unresolvedError = null; +try { + await payingFetch(account, unresolvedUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: skillRequestBody, -}, { - idempotencyKey: unresolvedKey, - fetchImpl: (url, init) => unresolvedCollar.app.request(url, init), + }, { + idempotencyKey: unresolvedKey, + fetchImpl: (url, init) => unresolvedCollar.app.request(url, init), + paymentPolicy: unresolvedPolicy, + }); +} catch (error) { + unresolvedError = error; +} +ok( + unresolvedError?.code === 'SETTLEMENT_EVIDENCE' + && !('res' in unresolvedError), + 'lost settlement response withholds output and is explicitly unresolved', +); +const unresolved = unresolvedPolicy.recoverSignedAuthorization({ + authorizationId: unresolvedKey, + requestUrl: unresolvedUrl, + method: 'POST', + bodyBytes: skillRequestBody, }); -ok(unresolved.res.status === 503, 'lost settlement response is explicitly unresolved'); +ok( + unresolvedPolicy.snapshot().authorizations[0].state === 'unresolved' + && unresolvedPolicy.snapshot().reservedAtomic === '250000', + 'unknown settlement keeps the exact session budget reservation', +); const unresolvedRetry = await unresolvedCollar.app.request(`http://unresolved.test/invoke/${SKILL_ID}`, { method: 'POST', headers: { diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index b319536..52fe0ee 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -113,7 +113,7 @@ export async function chooseFacilitator({ export function createCollar({ facilitatorTransport, - payTo = process.env.PAY_TO_ADDRESS || `0x${'d'.repeat(40)}`, + payTo = process.env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dead', priceUsdc = process.env.SKILL_PRICE_USDC || DEFAULT_PRICE_USDC, mockLlm = process.env.MOCK_LLM === '1', journal = null, diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index 3814789..ef0bbdf 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -5,8 +5,8 @@ // ║ ║ // ║ It also verifies pinned Collar receipts and maintains a payer-local ║ // ║ receipt view. It is not the complete protocol, accounting authority, ║ -// ║ custody design, or proof that ADR-0008 is production-ready. Plan 6 ║ -// ║ payment policy is not implemented here. ║ +// ║ custody design, durable cross-process budget enforcement, or proof that ║ +// ║ ADR-0008 is production-ready. ║ // ╚══════════════════════════════════════════════════════════════════════════╝ import crypto from 'node:crypto'; @@ -15,15 +15,22 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { Hono } from 'hono'; import { serve } from '@hono/node-server'; -import { formatUsdc } from '../../../prototype/atomic-money.mjs'; +import { formatUsdc, parseUsdc } from '../../../prototype/atomic-money.mjs'; import { loadAccount } from './wallet.mjs'; import { createLedger, renderLedger } from './ledger.mjs'; import { receiptKeyId, verifySignedReceipt } from './invocation-journal.mjs'; +import { + BASE_SEPOLIA_CHAIN_ID, + BASE_SEPOLIA_NETWORK, + BASE_SEPOLIA_USDC, + createPaymentPolicy, + PaymentPolicyError, +} from './payment-policy.mjs'; // EIP-712 typed data for EIP-3009 transferWithAuthorization — the single // signature that IS the payment. (Constants restated here on purpose: the // Wielder must be self-contained, importing nothing from the seller side.) -const CHAIN_ID = 84532; // Base Sepolia +const CHAIN_ID = BASE_SEPOLIA_CHAIN_ID; const EIP3009_TYPES = { TransferWithAuthorization: [ { name: 'from', type: 'address' }, { name: 'to', type: 'address' }, @@ -32,73 +39,323 @@ const EIP3009_TYPES = { ], }; const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64'); -const unb64 = (s) => JSON.parse(Buffer.from(s, 'base64').toString('utf8')); + +const payingFetchOptionKeys = new Set([ + 'fetchImpl', 'idempotencyKey', 'paymentPolicy', 'onSignedAuthorizationPersisted', 'nonceFactory', +]); + +function paymentError(code, message) { + return new PaymentPolicyError(code, message); +} + +function validatePayingFetchOptions(options) { + if (!options || typeof options !== 'object' || Array.isArray(options) + || Object.getPrototypeOf(options) !== Object.prototype + || Object.keys(options).some((key) => !payingFetchOptionKeys.has(key))) { + throw paymentError('PAYING_FETCH_OPTIONS', 'payingFetch options contain an unknown or invalid field'); + } +} + +function ownedRequestHeaders(input) { + const forbidden = new Set(['x-payment', 'idempotency-key']); + const names = []; + if (input == null) { + // no caller headers + } else if (input instanceof Headers) { + for (const [name] of input.entries()) names.push(name); + } else if (Array.isArray(input)) { + for (const entry of input) { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string') { + throw paymentError('REQUEST_HEADERS', 'request header tuples must be exact name/value pairs'); + } + names.push(entry[0]); + } + } else if (typeof input === 'object' && Object.getPrototypeOf(input) === Object.prototype) { + names.push(...Object.keys(input)); + } else { + throw paymentError('REQUEST_HEADERS', 'request headers must use a standard HeadersInit shape'); + } + if (names.some((name) => forbidden.has(name.toLowerCase()))) { + throw paymentError( + 'CALLER_PAYMENT_HEADER', + 'the Wielder exclusively owns Idempotency-Key and X-PAYMENT headers', + ); + } + let normalized; + try { + normalized = new Headers(input ?? undefined); + } catch { + throw paymentError('REQUEST_HEADERS', 'request headers are malformed'); + } + return Object.fromEntries(normalized.entries()); +} + +function decodeSettlementHeader(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + throw paymentError('SETTLEMENT_EVIDENCE', 'settlement evidence is missing or malformed'); + } + const bytes = Buffer.from(value, 'base64'); + if (bytes.toString('base64') !== value) { + throw paymentError('SETTLEMENT_EVIDENCE', 'settlement evidence is missing or malformed'); + } + try { + const decoded = JSON.parse(bytes.toString('utf8')); + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) throw new Error('shape'); + return decoded; + } catch { + throw paymentError('SETTLEMENT_EVIDENCE', 'settlement evidence is missing or malformed'); + } +} // Buyer transport loop used by this spike: request -> 402 -> sign EIP-3009 -> retry once. // Returns the accepted quote/authorization identity with the response. Timings are the spike's // payment-overhead measurement (402 roundtrip + sign + facilitator). -export async function payingFetch(account, url, init, { - fetchImpl = fetch, - idempotencyKey = crypto.randomUUID(), -} = {}) { - const requestHeaders = { ...init.headers, 'Idempotency-Key': idempotencyKey }; +export async function payingFetch(account, url, init, options = {}) { + validatePayingFetchOptions(options); + const { + fetchImpl = fetch, + idempotencyKey = crypto.randomUUID(), + paymentPolicy, + onSignedAuthorizationPersisted = null, + nonceFactory = () => `0x${crypto.randomBytes(32).toString('hex')}`, + } = options; + if (typeof fetchImpl !== 'function') throw paymentError('FETCH_CAPABILITY', 'fetchImpl must be a function'); + if (!paymentPolicy) throw paymentError('PAYMENT_POLICY_REQUIRED', 'paymentPolicy is required before any x402 signature'); + if (onSignedAuthorizationPersisted !== null + && typeof onSignedAuthorizationPersisted !== 'function') { + throw paymentError('PERSISTENCE_HOOK', 'onSignedAuthorizationPersisted must be a function'); + } + if (typeof nonceFactory !== 'function') { + throw paymentError('NONCE_CAPABILITY', 'nonceFactory must be a synchronous function'); + } + if (typeof idempotencyKey !== 'string' || !/^[A-Za-z0-9._:-]{1,200}$/.test(idempotencyKey)) { + throw paymentError('AUTHORIZATION_ID', 'idempotencyKey must be a bounded canonical token'); + } + const method = init?.method ?? 'GET'; + if (typeof method !== 'string' || method !== method.toUpperCase()) { + throw paymentError('REQUEST_METHOD', 'request method must be uppercase'); + } + const requestHeaders = { + ...ownedRequestHeaders(init?.headers), + 'Idempotency-Key': idempotencyKey, + }; const t0 = performance.now(); const first = await fetchImpl(url, { ...init, headers: requestHeaders }); if (first.status !== 402) return { res: first, paid: false, idempotencyKey }; + const receivedAt = paymentPolicy.captureReceivedAt(); const ms402 = performance.now() - t0; - // The 402 body carries PaymentRequirements; we accept the first offer. - const { accepts } = await first.json(); - const req = accepts?.[0]; - if (!req || req.scheme !== 'exact') throw new Error('402 without a usable "exact" payment offer'); - - // Sign the USDC transfer authorization. Pure local cryptography — this is - // the only "wallet" action the Wielder ever performs. - const tSign = performance.now(); - const now = Math.floor(Date.now() / 1000); - const authorization = { - from: account.address, - to: req.payTo, - value: req.maxAmountRequired, // atomic USDC - validAfter: String(now - 60), // clock-skew slack - validBefore: String(now + (req.maxTimeoutSeconds ?? 60)), - nonce: `0x${crypto.randomBytes(32).toString('hex')}`, // random EIP-3009 nonce = replay protection - }; - const signature = await account.signTypedData({ - domain: { name: req.extra?.name, version: req.extra?.version, chainId: CHAIN_ID, verifyingContract: req.asset }, - types: EIP3009_TYPES, - primaryType: 'TransferWithAuthorization', - message: { ...authorization, value: BigInt(authorization.value), validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore) }, + let firstChallenge; + try { + firstChallenge = await first.json(); + } catch { + throw paymentError('CHALLENGE_SCHEMA', '402 response does not contain strict x402 JSON'); + } + const authorizationRecord = paymentPolicy.reserveAuthorization({ + authorizationId: idempotencyKey, + requestUrl: url, + method, + bodyBytes: init?.body ?? null, + challenge: firstChallenge, + receivedAt, }); - const msSign = performance.now() - tSign; + const req = authorizationRecord.offer; - // Retry with X-PAYMENT. The seller verifies + settles via its facilitator. - const xPayment = b64({ x402Version: 1, scheme: 'exact', network: req.network, payload: { signature, authorization } }); + let signedRecord; + let msSign = 0; + if (authorizationRecord.state === 'reserved') { + const claim = paymentPolicy.claimSignature(idempotencyKey, { + offerFingerprint: authorizationRecord.offerFingerprint, + }); + if (!claim.claimed) { + throw paymentError('AUTHORIZATION_ALREADY_USED', 'idempotency key already has a signature claim'); + } + const payer = typeof account?.address === 'string' ? account.address.toLowerCase() : ''; + if (!/^0x[0-9a-f]{40}$/.test(payer) || typeof account?.signTypedData !== 'function') { + paymentPolicy.releaseUnsigned(idempotencyKey, { reasonCode: 'INVALID_WALLET_CAPABILITY' }); + throw paymentError('WALLET_CAPABILITY', 'wallet must expose a canonical address and signTypedData capability'); + } + let authorization; + try { + const nonce = nonceFactory(); + if (nonce && typeof nonce.then === 'function') { + throw paymentError('NONCE_CAPABILITY', 'nonceFactory must be synchronous'); + } + if (typeof nonce !== 'string' || !/^0x[0-9a-f]{64}$/.test(nonce)) { + throw paymentError('NONCE_FORMAT', 'nonceFactory must return one canonical lowercase bytes32'); + } + authorization = { + from: payer, + to: req.payTo, + value: authorizationRecord.amountAtomic, + validAfter: authorizationRecord.validAfter, + validBefore: authorizationRecord.validBefore, + nonce, + }; + } catch (error) { + paymentPolicy.releaseUnsigned(idempotencyKey, { + reasonCode: 'LOCAL_AUTHORIZATION_FAILURE', + }); + throw error; + } + const tSign = performance.now(); + let signatureReturned = false; + let signature; + try { + signature = await account.signTypedData({ + domain: { + name: req.extra.name, + version: req.extra.version, + chainId: CHAIN_ID, + verifyingContract: req.asset, + }, + types: EIP3009_TYPES, + primaryType: 'TransferWithAuthorization', + message: { + ...authorization, + value: BigInt(authorization.value), + validAfter: BigInt(authorization.validAfter), + validBefore: BigInt(authorization.validBefore), + }, + }); + signatureReturned = true; + msSign = performance.now() - tSign; + const xPayment = b64({ + x402Version: 1, + scheme: 'exact', + network: req.network, + payload: { signature, authorization }, + }); + // Same-process durability boundary: no await occurs between signer return + // and storing the exact authorization, signature, and X-PAYMENT bytes. + signedRecord = paymentPolicy.persistSignedAuthorization(idempotencyKey, { + authorization, + signature, + xPayment, + }); + } catch (error) { + if (!signatureReturned && error?.signatureProduced === false) { + paymentPolicy.releaseUnsigned(idempotencyKey, { reasonCode: 'SIGNER_REJECTED' }); + } else { + paymentPolicy.markPotentiallySigned(idempotencyKey, { + reasonCode: 'SIGNATURE_PERSISTENCE_UNCERTAIN', + }); + } + throw error; + } + if (onSignedAuthorizationPersisted) { + await onSignedAuthorizationPersisted({ authorization: signedRecord }); + } + } else if (authorizationRecord.state === 'signed') { + signedRecord = paymentPolicy.recoverSignedAuthorization({ + authorizationId: idempotencyKey, + requestUrl: url, + method, + bodyBytes: init?.body ?? null, + }); + } else { + throw paymentError( + 'AUTHORIZATION_ALREADY_USED', + 'idempotency key already has a signing, retrying, unresolved, or terminal authorization', + ); + } + + const { authorization, xPayment } = signedRecord; + paymentPolicy.beginRetry(idempotencyKey); const tRetry = performance.now(); - const res = await fetchImpl(url, { - ...init, - headers: { ...requestHeaders, 'X-PAYMENT': xPayment }, - }); + let res; + try { + res = await fetchImpl(url, { + ...init, + headers: { ...requestHeaders, 'X-PAYMENT': xPayment }, + }); + } catch (error) { + paymentPolicy.markUnresolved(idempotencyKey, { reasonCode: 'RETRY_RESPONSE_LOST' }); + throw error; + } const msPaidRoundtrip = performance.now() - tRetry; - const msFacilitator = Number(res.headers.get('X-402-FACILITATOR-MS') ?? NaN); // seller-reported verify+settle - const paymentResponse = res.headers.get('X-PAYMENT-RESPONSE'); - const settlement = paymentResponse ? unb64(paymentResponse) : null; + + if (res.status === 402) { + let secondChallenge = null; + try { secondChallenge = await res.clone().json(); } catch { /* stable changed-quote error below */ } + let secondError; + try { + paymentPolicy.assertRetryChallenge(idempotencyKey, secondChallenge); + secondError = paymentError( + 'SECOND_PAYMENT_REQUIRED', + 'seller requested a second payment after the only permitted retry', + ); + } catch (error) { + secondError = error; + } + paymentPolicy.markUnresolved(idempotencyKey, { reasonCode: 'SECOND_PAYMENT_REQUIRED' }); + throw secondError; + } + + let settlement; + try { + settlement = decodeSettlementHeader(res.headers.get('X-PAYMENT-RESPONSE')); + paymentPolicy.acceptSettlement(idempotencyKey, settlement); + } catch { + paymentPolicy.markUnresolved(idempotencyKey, { reasonCode: 'SETTLEMENT_EVIDENCE_INVALID' }); + throw paymentError( + 'SETTLEMENT_EVIDENCE', + 'retry settlement evidence is missing, malformed, or mismatched; upstream output withheld', + ); + } + const reportedFacilitatorMs = Number(res.headers.get('X-402-FACILITATOR-MS')); + const msFacilitator = Number.isFinite(reportedFacilitatorMs) && reportedFacilitatorMs >= 0 + ? reportedFacilitatorMs + : null; return { res, paid: true, xPayment, idempotencyKey, - settlementReference: authorization.nonce.toLowerCase(), - txHash: settlement?.transaction ?? null, - payer: account.address.toLowerCase(), - requestHash: req.extra.requestHash, - quoteId: req.extra.quoteId, - amountAtomic: String(req.maxAmountRequired), - amountDisplay: formatUsdc(BigInt(req.maxAmountRequired)), - timings: { ms402, msSign, msFacilitator, msPaidRoundtrip, msOverhead: ms402 + msSign + (msFacilitator || 0) }, + settlementReference: authorization.nonce, + txHash: settlement.transaction, + payer: authorization.from, + requestHash: signedRecord.requestHash, + quoteId: signedRecord.quoteId, + amountAtomic: signedRecord.amountAtomic, + amountDisplay: formatUsdc(BigInt(signedRecord.amountAtomic)), + timings: { + ms402, + msSign, + msFacilitator, + msPaidRoundtrip, + msOverhead: ms402 + msSign + (msFacilitator ?? 0), + }, }; } +export function createDefaultPaymentPolicy({ gatewayUrl, collarUrl, env = process.env, now } = {}) { + const payTo = env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dead'; + return createPaymentPolicy({ + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + sessionBudgetAtomic: parseUsdc(env.WIELDER_SESSION_BUDGET_USDC || '1.00').toString(), + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + ...(now ? { now } : {}), + sellers: [ + { + origin: new URL(gatewayUrl).origin, + pathPrefix: '/v1/', + payTo, + maxPerCallAtomic: parseUsdc(env.WIELDER_MODEL_MAX_USDC || '0.10').toString(), + }, + { + origin: new URL(collarUrl).origin, + pathPrefix: '/invoke/', + payTo, + maxPerCallAtomic: parseUsdc(env.WIELDER_SKILL_MAX_USDC || '0.50').toString(), + }, + ], + }); +} + export function loadPinnedCollarTrust(env = process.env) { const publicKeyFile = env.COLLAR_PUBLIC_KEY_FILE || null; const expectedKeyId = env.COLLAR_KEY_ID || null; @@ -177,6 +434,7 @@ export function createProxy({ collarFetch = fetch, trustedCollarPublicKeyPem = null, trustedCollarKeyId = null, + paymentPolicy = null, } = {}) { if (!trustedCollarPublicKeyPem || !trustedCollarKeyId) { throw new Error('Skill routes require a pinned Collar public key and key ID'); @@ -185,6 +443,7 @@ export function createProxy({ throw new Error('pinned Collar public key and key ID do not match'); } const ledger = createLedger(ledgerFile); + const sessionPaymentPolicy = paymentPolicy ?? createDefaultPaymentPolicy({ gatewayUrl, collarUrl }); const app = new Hono(); // One handler for both asset classes: /v1/* -> inference gateway (leg: @@ -197,7 +456,7 @@ export function createProxy({ requestHash, quoteId, settlementReference, timings, } = await payingFetch(account, `${upstreamBase}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: bodyText, - }, { fetchImpl }); + }, { fetchImpl, paymentPolicy: sessionPaymentPolicy }); const resBody = await res.text(); if (paid && txHash) { @@ -275,12 +534,12 @@ export function createProxy({ app.get('/ledger', (c) => c.req.query('format') === 'json' ? c.json(ledger.entries) : c.text(renderLedger(ledger.entries))); - return { app, ledger, account }; + return { app, ledger, account, paymentPolicy: sessionPaymentPolicy }; } /** Boot helper shared by the standalone script and e2e.mjs. */ export function startProxy({ port = 0, ...opts } = {}) { - const { app, ledger, account } = createProxy(opts); + const { app, ledger, account, paymentPolicy } = createProxy(opts); return new Promise((resolve) => { const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) => { let closePromise = null; @@ -296,6 +555,7 @@ export function startProxy({ port = 0, ...opts } = {}) { address: info.address, ledger, account, + paymentPolicy, close, }); }); diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index a7d511e..574b7db 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -19,7 +19,14 @@ export const APPROVED_LIVE_FACILITATOR_BASE = 'https://x402.org/facilitator'; export const usdcToAtomic = (display) => parseUsdc(display).toString(); export const atomicToUsdc = (atomic) => formatUsdc(BigInt(atomic)); -const b64ToJson = (value) => JSON.parse(Buffer.from(value, 'base64').toString('utf8')); +const b64ToJson = (value) => { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + throw new Error('payment header is not canonical base64'); + } + const bytes = Buffer.from(value, 'base64'); + if (bytes.toString('base64') !== value) throw new Error('payment header is not canonical base64'); + return JSON.parse(bytes.toString('utf8')); +}; const jsonToB64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64'); const authorizedTransports = new WeakSet(); @@ -72,6 +79,15 @@ function validTxHash(value) { return /^0x[0-9a-fA-F]{64}$/.test(String(value ?? '')); } +function exactPlainObject(value, keys) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length + && actual.every((key, index) => key === expected[index]); +} + function terminalReplayIsTrusted(decision, payer) { return decision?.kind === 'terminal' && ['settled', 'refunded'].includes(decision.paymentState) @@ -83,22 +99,59 @@ function terminalReplayIsTrusted(decision, payer) { && decision.httpStatus <= 599; } +function paymentResponseEvidence({ + idempotencyKey, + requirements, + payer, + settlementReference, + transaction, +}) { + return { + success: true, + authorizationId: idempotencyKey, + idempotencyKey, + network: NETWORK, + chainId: CHAIN_ID, + asset: requirements.asset, + payTo: requirements.payTo, + payer: String(payer).toLowerCase(), + value: requirements.maxAmountRequired, + nonce: settlementReference, + settlementReference, + requestHash: requirements.extra.requestHash, + quoteId: requirements.extra.quoteId, + transaction: String(transaction).toLowerCase(), + }; +} + function validateAuthorizationEnvelope(paymentPayload, requirements) { - if (paymentPayload?.x402Version !== X402_VERSION + if (!exactPlainObject(paymentPayload, ['x402Version', 'scheme', 'network', 'payload']) + || !exactPlainObject(paymentPayload.payload, ['signature', 'authorization']) + || paymentPayload?.x402Version !== X402_VERSION || paymentPayload?.scheme !== requirements.scheme || paymentPayload?.network !== requirements.network) { throw new Error('payment envelope does not exactly match the frozen x402 offer'); } const authorization = paymentPayload?.payload?.authorization; - if (!authorization || !paymentPayload?.payload?.signature) { + const signature = paymentPayload?.payload?.signature; + if (!exactPlainObject(authorization, [ + 'from', 'to', 'value', 'validAfter', 'validBefore', 'nonce', + ]) || typeof signature !== 'string' || !/^0x[0-9a-f]{130}$/.test(signature)) { throw new Error('payment authorization lacks authorization or signature'); } - if (String(authorization.to ?? '').toLowerCase() !== requirements.payTo.toLowerCase() - || String(authorization.value ?? '') !== requirements.maxAmountRequired) { + if (!/^0x[0-9a-f]{40}$/.test(authorization.from) + || !/^0x[0-9a-f]{40}$/.test(authorization.to) + || typeof authorization.value !== 'string' + || !/^(0|[1-9]\d*)$/.test(authorization.value) + || typeof authorization.validAfter !== 'string' + || !/^(0|[1-9]\d*)$/.test(authorization.validAfter) + || typeof authorization.validBefore !== 'string' + || !/^(0|[1-9]\d*)$/.test(authorization.validBefore) + || authorization.to !== requirements.payTo + || authorization.value !== requirements.maxAmountRequired) { throw new Error('payment authorization must exactly match payee and quoted amount'); } - if (!/^0x[0-9a-fA-F]{64}$/.test(String(authorization.nonce ?? '')) - || !/^0x[0-9a-fA-F]{40}$/.test(String(authorization.from ?? ''))) { + if (!/^0x[0-9a-f]{64}$/.test(authorization.nonce)) { throw new Error('payment authorization lacks a valid nonce or payer'); } return authorization; @@ -252,13 +305,13 @@ export function x402Paywall({ : {}), }; const replay = c.json(body, priorDecision.httpStatus); - replay.headers.set('X-PAYMENT-RESPONSE', jsonToB64({ - success: true, + replay.headers.set('X-PAYMENT-RESPONSE', jsonToB64(paymentResponseEvidence({ + idempotencyKey, + requirements, transaction: priorDecision.txHash, - network: NETWORK, payer: priorDecision.payer, settlementReference, - })); + }))); return replay; } if (priorDecision?.kind === 'payment_unresolved') { @@ -388,13 +441,13 @@ export function x402Paywall({ requirements, }); await next(); - c.res.headers.set('X-PAYMENT-RESPONSE', jsonToB64({ - success: true, + c.res.headers.set('X-PAYMENT-RESPONSE', jsonToB64(paymentResponseEvidence({ + idempotencyKey, + requirements, transaction: settledTxHash, - network: NETWORK, payer: settledPayer, settlementReference, - })); + }))); c.res.headers.set('X-402-FACILITATOR-MS', facilitatorMs.toFixed(1)); }; } diff --git a/spikes/pi-wielder/tests/collar-failure.test.mjs b/spikes/pi-wielder/tests/collar-failure.test.mjs index b75c3df..63aa1fe 100644 --- a/spikes/pi-wielder/tests/collar-failure.test.mjs +++ b/spikes/pi-wielder/tests/collar-failure.test.mjs @@ -8,16 +8,41 @@ import test from 'node:test'; import { chooseFacilitator, createCollar, SKILL_ID } from '../src/collar.mjs'; import { createMockFacilitator } from '../src/facilitator-mock.mjs'; import { verifySignedReceipt } from '../src/invocation-journal.mjs'; -import { payingFetch } from '../src/proxy.mjs'; +import { payingFetch as policyPayingFetch } from '../src/proxy.mjs'; import { throwawayAccount } from '../src/wallet.mjs'; import { APPROVED_LIVE_FACILITATOR_BASE, createLiveFacilitatorTransport, createMockFacilitatorTransport, } from '../src/x402-seller.mjs'; +import { paymentPolicyFor } from './payment-policy-fixture.mjs'; const invokeUrl = `http://collar.test/invoke/${SKILL_ID}`; const requestBody = JSON.stringify({ input: 'same bytes' }); +const payingFetch = (account, url, init, options = {}) => policyPayingFetch(account, url, init, { + paymentPolicy: paymentPolicyFor(url), + ...options, +}); + +async function withheldPayingFetch(account, url, init, options = {}) { + const paymentPolicy = paymentPolicyFor(url); + await assert.rejects(() => policyPayingFetch(account, url, init, { + ...options, + paymentPolicy, + }), (error) => error.code === 'SETTLEMENT_EVIDENCE'); + const persisted = paymentPolicy.recoverSignedAuthorization({ + authorizationId: options.idempotencyKey, + requestUrl: url, + method: init.method ?? 'GET', + bodyBytes: init.body ?? null, + }); + return { + ...persisted, + idempotencyKey: persisted.authorizationId, + settlementReference: persisted.authorization.nonce, + paymentPolicy, + }; +} function mockTransport(fetchImpl = null) { const facilitator = createMockFacilitator(); @@ -53,13 +78,13 @@ async function prepareReconciledRetry({ executeSkill, lifecycleFaults = {} }) { }), }); const idempotencyKey = `idem-crash-${crypto.randomUUID()}`; - const first = await payingFetch(throwawayAccount(), invokeUrl, { + const first = await withheldPayingFetch(throwawayAccount(), invokeUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, }, { idempotencyKey, fetchImpl: (url, init) => collar.app.request(url, init), }); - assert.equal(first.res.status, 503); + assert.equal(first.state, 'unresolved'); const reconcile = await collar.app.request( `http://collar.test/reconcile/by-settlement/${first.settlementReference}`, { method: 'POST' }, @@ -233,13 +258,13 @@ test('response-loss reconciliation advances once and exact retry never duplicate executeSkill: async ({ input }) => { executions += 1; return { output: `executed ${input}` }; }, }); const idempotencyKey = 'idem-response-loss'; - const first = await payingFetch(throwawayAccount(), invokeUrl, { + const first = await withheldPayingFetch(throwawayAccount(), invokeUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, }, { idempotencyKey, fetchImpl: (url, init) => collar.app.request(url, init), }); - assert.equal(first.res.status, 503); + assert.equal(first.state, 'unresolved'); assert.equal(collar.journal.getBySettlementReference(first.settlementReference).payment.state, 'unresolved'); const retryRequest = (body = requestBody) => collar.app.request(invokeUrl, { @@ -287,13 +312,13 @@ test('settlement success followed by journal fault is persisted unresolved befor }, executeSkill: async () => { throw new Error('must not execute'); }, }); - const first = await payingFetch(throwawayAccount(), invokeUrl, { + const first = await withheldPayingFetch(throwawayAccount(), invokeUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, }, { idempotencyKey: 'idem-post-settle-collar', fetchImpl: (url, init) => collar.app.request(url, init), }); - assert.equal(first.res.status, 503); + assert.equal(first.state, 'unresolved'); assert.equal(collar.journal.getBySettlementReference(first.settlementReference).payment.state, 'unresolved'); const retry = await collar.app.request(invokeUrl, { method: 'POST', @@ -692,7 +717,7 @@ test('Skill provider and settlement resolver secrets are replaced with stable pu resolveSettlement: async () => { throw new Error(resolverSecret); }, executeSkill: async () => ({ output: 'must not run' }), }); - const first = await payingFetch(throwawayAccount(), invokeUrl, { + const first = await withheldPayingFetch(throwawayAccount(), invokeUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, }, { idempotencyKey: 'idem-secret-resolver', @@ -731,16 +756,13 @@ test('facilitator verification detail is absent from the response and durable jo return { output: 'must not run' }; }, }); - const result = await payingFetch(throwawayAccount(), invokeUrl, { + await assert.rejects(() => payingFetch(throwawayAccount(), invokeUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, }, { idempotencyKey: 'idem-verifier-secret', fetchImpl: (url, init) => collar.app.request(url, init), - }); - assert.equal(result.res.status, 402); - const responseText = await result.res.text(); - assert.doesNotMatch(responseText, new RegExp(secret)); - assert.equal(JSON.parse(responseText).error, 'payment verification failed'); + }), (error) => error.code === 'SECOND_PAYMENT_REQUIRED' + && !error.message.includes(secret)); const record = collar.journal.getByIdempotencyKey('idem-verifier-secret'); assert.equal(record.payment.state, 'rejected'); assert.equal(record.payment.reason, 'payment verification failed'); diff --git a/spikes/pi-wielder/tests/gateway-transport.test.mjs b/spikes/pi-wielder/tests/gateway-transport.test.mjs index b9cfa01..cbe45cd 100644 --- a/spikes/pi-wielder/tests/gateway-transport.test.mjs +++ b/spikes/pi-wielder/tests/gateway-transport.test.mjs @@ -3,9 +3,15 @@ import test from 'node:test'; import { createMockFacilitator } from '../src/facilitator-mock.mjs'; import { createGateway, MODEL_PRICES_USDC, startGateway } from '../src/gateway.mjs'; -import { payingFetch } from '../src/proxy.mjs'; +import { payingFetch as policyPayingFetch } from '../src/proxy.mjs'; import { throwawayAccount } from '../src/wallet.mjs'; import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; +import { paymentPolicyFor } from './payment-policy-fixture.mjs'; + +const payingFetch = (account, url, init, options = {}) => policyPayingFetch(account, url, init, { + paymentPolicy: paymentPolicyFor(url), + ...options, +}); test('gateway prices are decimal strings and the injected transport stays in process', async () => { assert.ok(Object.values(MODEL_PRICES_USDC).every((price) => typeof price === 'string')); diff --git a/spikes/pi-wielder/tests/paying-fetch.test.mjs b/spikes/pi-wielder/tests/paying-fetch.test.mjs new file mode 100644 index 0000000..cd642a6 --- /dev/null +++ b/spikes/pi-wielder/tests/paying-fetch.test.mjs @@ -0,0 +1,399 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + BASE_SEPOLIA_CHAIN_ID, + BASE_SEPOLIA_NETWORK, + BASE_SEPOLIA_USDC, + canonicalRequestHash, + createPaymentPolicy, + PaymentPolicyError, +} from '../src/payment-policy.mjs'; +import { payingFetch } from '../src/proxy.mjs'; + +const PAYEE = '0x000000000000000000000000000000000000dead'; +const PAYER = '0x1000000000000000000000000000000000000000'; +const URL = 'https://trusted.example/invoke/skill-a'; +const BODY = '{}'; +const NOW = Date.UTC(2026, 6, 17, 12, 0, 10); +const TX_HASH = `0x${'2'.repeat(64)}`; +const SIGNATURE = `0x${'1'.repeat(130)}`; + +function baseOffer(overrides = {}) { + const base = { + scheme: 'exact', + network: BASE_SEPOLIA_NETWORK, + maxAmountRequired: '250000', + resource: URL, + description: 'test Skill', + mimeType: 'application/json', + payTo: PAYEE, + maxTimeoutSeconds: 60, + asset: BASE_SEPOLIA_USDC, + extra: { + name: 'USDC', + version: '2', + requestHash: canonicalRequestHash({ method: 'POST', requestUrl: URL, bodyBytes: BODY }), + quoteId: `sha256:${'b'.repeat(64)}`, + issuedAt: new Date(NOW - 1_000).toISOString(), + expiresAt: new Date(NOW + 59_000).toISOString(), + }, + }; + return { ...base, ...overrides }; +} + +function challenge(candidate = baseOffer()) { + return new Response(JSON.stringify({ + x402Version: 1, + error: 'X-PAYMENT header is required', + accepts: [candidate], + }), { status: 402, headers: { 'content-type': 'application/json' } }); +} + +function decodePayment(init) { + const headers = new Headers(init.headers); + return JSON.parse(Buffer.from(headers.get('X-PAYMENT'), 'base64').toString('utf8')); +} + +function settlementFor(init, overrides = {}) { + const payment = decodePayment(init); + const authorization = payment.payload.authorization; + return { + success: true, + authorizationId: new Headers(init.headers).get('Idempotency-Key'), + idempotencyKey: new Headers(init.headers).get('Idempotency-Key'), + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + payTo: PAYEE, + payer: authorization.from, + value: authorization.value, + nonce: authorization.nonce, + settlementReference: authorization.nonce, + requestHash: baseOffer().extra.requestHash, + quoteId: baseOffer().extra.quoteId, + transaction: TX_HASH, + ...overrides, + }; +} + +function paidResponse(init, { status = 200, body = '{"ok":true}', settlement = {} } = {}) { + const encoded = Buffer.from(JSON.stringify(settlementFor(init, settlement))).toString('base64'); + return new Response(body, { + status, + headers: { + 'content-type': 'application/json', + 'X-PAYMENT-RESPONSE': encoded, + 'X-402-FACILITATOR-MS': '1.5', + }, + }); +} + +function setup(overrides = {}) { + let clock = NOW; + let signatures = 0; + const account = { + address: PAYER, + async signTypedData() { + signatures += 1; + return SIGNATURE; + }, + }; + const paymentPolicy = createPaymentPolicy({ + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + sessionBudgetAtomic: '500000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + now: () => clock, + sellers: [{ + origin: 'https://trusted.example', pathPrefix: '/invoke/', payTo: PAYEE, + maxPerCallAtomic: '300000', + }], + ...overrides.policy, + }); + return { + account, + paymentPolicy, + signatureCount: () => signatures, + setClock: (value) => { clock = value; }, + }; +} + +test('a forbidden first offer is never signed or retried', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { fetches += 1; return challenge(baseOffer({ network: 'base' })); }, + idempotencyKey: 'idem-forbidden', + paymentPolicy, + }), (error) => error.code === 'NETWORK_MISMATCH'); + assert.equal(fetches, 1); + assert.equal(signatureCount(), 0); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); +}); + +test('freshness is captured from the injected clock immediately after the first 402', async () => { + const { account, paymentPolicy, setClock, signatureCount } = setup(); + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { + fetches += 1; + setClock(NOW + 5_001); + return challenge(); + }, + idempotencyKey: 'idem-stale-on-arrival', + paymentPolicy, + }), (error) => error.code === 'QUOTE_EXPIRY'); + assert.equal(fetches, 1); + assert.equal(signatureCount(), 0); + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => challenge(), + idempotencyKey: 'idem-caller-clock', + paymentPolicy, + receivedAtMs: NOW - 1_000, + }), (error) => error.code === 'PAYING_FETCH_OPTIONS'); +}); + +test('caller-supplied payment and idempotency headers are rejected case-insensitively before fetch', async () => { + for (const headers of [ + { 'idempotency-key': 'caller-owned' }, + { 'X-Payment': 'caller-owned' }, + [['content-type', 'application/json'], ['IDEMPOTENCY-KEY', 'one'], ['Idempotency-Key', 'two']], + new Headers([['x-payment', 'one'], ['X-PAYMENT', 'two']]), + ]) { + const { account, paymentPolicy, signatureCount } = setup(); + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY, headers }, { + fetchImpl: async () => { fetches += 1; return challenge(); }, + idempotencyKey: 'idem-owned', + paymentPolicy, + }), (error) => error.code === 'CALLER_PAYMENT_HEADER'); + assert.equal(fetches, 0); + assert.equal(signatureCount(), 0); + } +}); + +test('a changed second offer gets no second signature or third request and holds budget', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + const responses = [challenge(), challenge(baseOffer({ maxAmountRequired: '260000' }))]; + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => responses[fetches++], + idempotencyKey: 'idem-changed', + paymentPolicy, + }), (error) => error.code === 'QUOTE_CHANGED'); + assert.equal(fetches, 2); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); +}); + +test('an unchanged second 402 still gets no second signature or third request', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { fetches += 1; return challenge(); }, + idempotencyKey: 'idem-second-402', + paymentPolicy, + }), (error) => error.code === 'SECOND_PAYMENT_REQUIRED'); + assert.equal(fetches, 2); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); +}); + +test('missing, malformed, unknown-key, and mismatched settlement evidence withholds upstream output', async () => { + const cases = [ + () => new Response('{"secretOutput":"must stay withheld"}', { status: 200 }), + () => new Response('{"secretOutput":"must stay withheld"}', { + status: 200, headers: { 'X-PAYMENT-RESPONSE': 'not-base64' }, + }), + (init) => paidResponse(init, { + body: '{"secretOutput":"must stay withheld"}', settlement: { injected: true }, + }), + (init) => paidResponse(init, { + body: '{"secretOutput":"must stay withheld"}', settlement: { value: '250001' }, + }), + ]; + for (let index = 0; index < cases.length; index += 1) { + const { account, paymentPolicy } = setup(); + let fetches = 0; + let caught; + try { + await payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async (_url, init) => { + fetches += 1; + return fetches === 1 ? challenge() : cases[index](init); + }, + idempotencyKey: `idem-bad-settlement-${index}`, + paymentPolicy, + }); + } catch (error) { + caught = error; + } + assert.ok(caught instanceof PaymentPolicyError); + assert.equal(caught.code, 'SETTLEMENT_EVIDENCE'); + assert.equal('res' in caught, false); + assert.doesNotMatch(caught.message, /secretOutput/); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); + } +}); + +test('a settled HTTP 500 consumes spend and returns exactly the documented paid result', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let fetches = 0; + const result = await payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async (_url, init) => { + fetches += 1; + return fetches === 1 ? challenge() : paidResponse(init, { status: 500, body: '{"error":"execution failed"}' }); + }, + idempotencyKey: 'idem-settled-500', + paymentPolicy, + }); + assert.deepEqual(Object.keys(result).sort(), [ + 'amountAtomic', 'amountDisplay', 'idempotencyKey', 'paid', 'payer', 'quoteId', 'requestHash', + 'res', 'settlementReference', 'timings', 'txHash', 'xPayment', + ].sort()); + assert.deepEqual(Object.keys(result.timings).sort(), [ + 'ms402', 'msFacilitator', 'msOverhead', 'msPaidRoundtrip', 'msSign', + ].sort()); + assert.equal(result.res.status, 500); + assert.equal(result.paid, true); + assert.equal(result.txHash, TX_HASH); + assert.equal(result.amountAtomic, '250000'); + assert.equal(result.amountDisplay, '0.250000'); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().spentAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); +}); + +test('a non-402 response preserves no-pay semantics without settlement fields', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + const result = await payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => new Response('{"public":true}', { status: 200 }), + idempotencyKey: 'idem-no-pay', + paymentPolicy, + }); + assert.deepEqual(Object.keys(result).sort(), ['idempotencyKey', 'paid', 'res']); + assert.equal(result.paid, false); + assert.equal(signatureCount(), 0); + assert.equal(paymentPolicy.snapshot().authorizations.length, 0); +}); + +test('concurrent copies of one idempotency key produce one signature and one paid retry', async () => { + const { account, paymentPolicy } = setup(); + let signatures = 0; + let releaseSignature; + let announceSignature; + const started = new Promise((resolve) => { announceSignature = resolve; }); + const gate = new Promise((resolve) => { releaseSignature = resolve; }); + account.signTypedData = async () => { + signatures += 1; + announceSignature(); + await gate; + return SIGNATURE; + }; + let paidRetries = 0; + const fetchImpl = async (_url, init) => { + if (!new Headers(init.headers).has('X-PAYMENT')) return challenge(); + paidRetries += 1; + return paidResponse(init); + }; + const options = { fetchImpl, idempotencyKey: 'idem-concurrent', paymentPolicy }; + const first = payingFetch(account, URL, { method: 'POST', body: BODY }, options); + await started; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, options), + (error) => error.code === 'AUTHORIZATION_ALREADY_USED'); + releaseSignature(); + assert.equal((await first).res.status, 200); + assert.equal(signatures, 1); + assert.equal(paidRetries, 1); +}); + +test('signer rejection before any signature releases reservation exactly once', async () => { + const { account, paymentPolicy } = setup(); + account.signTypedData = async () => { + const error = new Error('wallet declined before signing'); + error.signatureProduced = false; + throw error; + }; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => challenge(), idempotencyKey: 'idem-declined', paymentPolicy, + }), /wallet declined/); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'released'); +}); + +test('local nonce construction failure before signer invocation releases the reservation', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => challenge(), + idempotencyKey: 'idem-local-nonce-failure', + paymentPolicy, + nonceFactory: () => { throw new Error('local entropy unavailable'); }, + }), /local entropy unavailable/); + assert.equal(signatureCount(), 0); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'released'); +}); + +test('an invalid signer return is potentially signed and never releases budget', async () => { + const { account, paymentPolicy } = setup(); + account.signTypedData = async () => 'not-a-signature'; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => challenge(), idempotencyKey: 'idem-invalid-signature', paymentPolicy, + }), (error) => error.code === 'SIGNATURE_FORMAT'); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); +}); + +test('a fault after synchronous signature persistence recovers exact X-PAYMENT without signing again', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let firstFetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { firstFetches += 1; return challenge(); }, + idempotencyKey: 'idem-persisted-fault', + paymentPolicy, + onSignedAuthorizationPersisted: () => { throw new Error('synthetic process interruption'); }, + }), /synthetic process interruption/); + assert.equal(firstFetches, 1); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'signed'); + + let retryPayment = null; + let recoveryFetches = 0; + const result = await payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async (_url, init) => { + recoveryFetches += 1; + if (recoveryFetches === 1) return challenge(); + retryPayment = new Headers(init.headers).get('X-PAYMENT'); + return paidResponse(init); + }, + idempotencyKey: 'idem-persisted-fault', + paymentPolicy, + }); + assert.equal(result.res.status, 200); + assert.equal(signatureCount(), 1); + assert.equal(recoveryFetches, 2); + assert.equal(retryPayment, result.xPayment); +}); + +test('retry transport loss leaves the signed amount unresolved and never retries internally', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { + fetches += 1; + if (fetches === 1) return challenge(); + throw new Error('transport response lost'); + }, + idempotencyKey: 'idem-transport-loss', + paymentPolicy, + }), /transport response lost/); + assert.equal(fetches, 2); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); +}); diff --git a/spikes/pi-wielder/tests/payment-policy-fixture.mjs b/spikes/pi-wielder/tests/payment-policy-fixture.mjs new file mode 100644 index 0000000..a37deb5 --- /dev/null +++ b/spikes/pi-wielder/tests/payment-policy-fixture.mjs @@ -0,0 +1,29 @@ +import { + BASE_SEPOLIA_CHAIN_ID, + BASE_SEPOLIA_NETWORK, + BASE_SEPOLIA_USDC, + createPaymentPolicy, +} from '../src/payment-policy.mjs'; + +export const DEFAULT_TEST_PAYEE = '0x000000000000000000000000000000000000dead'; + +export function paymentPolicyFor(requestUrl, payTo = DEFAULT_TEST_PAYEE) { + const parsed = new URL(requestUrl); + const pathPrefix = parsed.pathname.startsWith('/invoke/') ? '/invoke/' + : parsed.pathname.startsWith('/v1/') ? '/v1/' + : parsed.pathname; + return createPaymentPolicy({ + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + sessionBudgetAtomic: '1000000000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + sellers: [{ + origin: parsed.origin, + pathPrefix, + payTo, + maxPerCallAtomic: '1000000000', + }], + }); +} diff --git a/spikes/pi-wielder/tests/seller-payment-response.test.mjs b/spikes/pi-wielder/tests/seller-payment-response.test.mjs new file mode 100644 index 0000000..8870391 --- /dev/null +++ b/spikes/pi-wielder/tests/seller-payment-response.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { Hono } from 'hono'; +import { createMockFacilitator } from '../src/facilitator-mock.mjs'; +import { + BASE_SEPOLIA_CHAIN_ID, + BASE_SEPOLIA_NETWORK, + BASE_SEPOLIA_USDC, + createPaymentPolicy, +} from '../src/payment-policy.mjs'; +import { payingFetch } from '../src/proxy.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; +import { createMockFacilitatorTransport, x402Paywall } from '../src/x402-seller.mjs'; + +const URL = 'http://seller.test/resource'; +const PAYEE = '0x000000000000000000000000000000000000dead'; + +test('seller settlement response binds the complete signed authorization and quote identity', async () => { + const facilitator = createMockFacilitator(); + const app = new Hono(); + app.post('/resource', x402Paywall({ + price: '0.25', + payTo: PAYEE, + facilitatorTransport: createMockFacilitatorTransport( + (url, init) => facilitator.request(url, init), + ), + }), (c) => c.json({ output: 'released only after exact settlement validation' })); + const paymentPolicy = createPaymentPolicy({ + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + sessionBudgetAtomic: '250000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + sellers: [{ + origin: 'http://seller.test', + pathPrefix: '/resource', + payTo: PAYEE, + maxPerCallAtomic: '250000', + }], + }); + const result = await payingFetch(throwawayAccount(), URL, { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'idem-complete-settlement', + paymentPolicy, + fetchImpl: (url, init) => app.request(url, init), + }); + const evidence = JSON.parse(Buffer.from( + result.res.headers.get('X-PAYMENT-RESPONSE'), 'base64', + ).toString('utf8')); + assert.deepEqual(Object.keys(evidence).sort(), [ + 'asset', 'authorizationId', 'chainId', 'idempotencyKey', 'network', 'nonce', 'payTo', + 'payer', 'quoteId', 'requestHash', 'settlementReference', 'success', 'transaction', 'value', + ].sort()); + assert.equal(evidence.authorizationId, 'idem-complete-settlement'); + assert.equal(evidence.idempotencyKey, 'idem-complete-settlement'); + assert.equal(evidence.chainId, 84532); + assert.equal(evidence.asset, BASE_SEPOLIA_USDC); + assert.equal(evidence.payTo, PAYEE); + assert.equal(evidence.value, '250000'); + assert.equal(evidence.requestHash, result.requestHash); + assert.equal(evidence.quoteId, result.quoteId); + assert.equal(evidence.nonce, result.settlementReference); +}); diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs index 021f4e6..9f972a6 100644 --- a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import test from 'node:test'; import { Hono } from 'hono'; import { createMockFacilitator } from '../src/facilitator-mock.mjs'; -import { payingFetch } from '../src/proxy.mjs'; +import { payingFetch as policyPayingFetch } from '../src/proxy.mjs'; import { throwawayAccount } from '../src/wallet.mjs'; +import { paymentPolicyFor } from './payment-policy-fixture.mjs'; import { APPROVED_LIVE_FACILITATOR_BASE, createLiveFacilitatorTransport, @@ -14,6 +16,31 @@ import { const payTo = `0x${'d'.repeat(40)}`; +const payingFetch = (account, url, init, options = {}) => policyPayingFetch(account, url, init, { + paymentPolicy: paymentPolicyFor(url, payTo), + ...options, +}); + +async function withheldAttempt(account, url, init, options = {}) { + const paymentPolicy = paymentPolicyFor(url, payTo); + await assert.rejects(() => policyPayingFetch(account, url, init, { + ...options, + paymentPolicy, + }), (error) => error.code === 'SETTLEMENT_EVIDENCE'); + const persisted = paymentPolicy.recoverSignedAuthorization({ + authorizationId: options.idempotencyKey, + requestUrl: url, + method: init.method ?? 'GET', + bodyBytes: init.body ?? null, + }); + return { + ...persisted, + idempotencyKey: persisted.authorizationId, + settlementReference: persisted.authorization.nonce, + paymentPolicy, + }; +} + function resourceApp({ facilitatorTransport, lifecycle = {}, price = '0.25', handler } = {}) { const app = new Hono(); app.post('/resource', x402Paywall({ @@ -97,7 +124,7 @@ test('restart rejects different request bytes under the frozen idempotency key b }, }); let fetchCount = 0; - const first = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + const first = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { method: 'POST', body: '{"input":"original bytes"}', }, { idempotencyKey: 'idem-restart-conflict', @@ -110,7 +137,7 @@ test('restart rejects different request bytes under the frozen idempotency key b }); }, }); - assert.equal(first.res.status, 503); + assert.equal(first.state, 'unresolved'); const afterRestart = resourceApp({ facilitatorTransport: transport, lifecycle: { @@ -157,7 +184,7 @@ test('authorization amount must equal the frozen quote exactly before facilitato }); const app = resourceApp({ facilitatorTransport: transport }); let requestCount = 0; - const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + await assert.rejects(() => payingFetch(throwawayAccount(), 'http://seller.test/resource', { method: 'POST', body: '{}', }, { idempotencyKey: 'idem-overpay', @@ -173,12 +200,48 @@ test('authorization amount must equal the frozen quote exactly before facilitato } return app.request(url, init); }, - }); - assert.equal(result.res.status, 402); - assert.match((await result.res.json()).error, /exactly match/); + }), (error) => error.code === 'SECOND_PAYMENT_REQUIRED'); assert.equal(facilitatorCalls, 0); }); +test('seller rejects numeric and unknown authorization fields before facilitator submission', async () => { + for (const mutate of [ + (authorization) => { authorization.value = 250000; }, + (authorization) => { authorization.injected = true; }, + ]) { + let facilitatorCalls = 0; + const facilitator = createMockFacilitator(); + const app = resourceApp({ + facilitatorTransport: createMockFacilitatorTransport(async (url, init) => { + facilitatorCalls += 1; + return facilitator.request(url, init); + }), + }); + let requestCount = 0; + await assert.rejects(() => payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: `idem-strict-auth-${crypto.randomUUID()}`, + fetchImpl: (url, init) => { + requestCount += 1; + if (requestCount === 2) { + const payment = JSON.parse(Buffer.from(init.headers['X-PAYMENT'], 'base64').toString('utf8')); + mutate(payment.payload.authorization); + return app.request(url, { + ...init, + headers: { + ...init.headers, + 'X-PAYMENT': Buffer.from(JSON.stringify(payment)).toString('base64'), + }, + }); + } + return app.request(url, init); + }, + }), (error) => error.code === 'SECOND_PAYMENT_REQUIRED'); + assert.equal(facilitatorCalls, 0); + } +}); + test('unresolved payment retries return 503 without re-verification or settlement', async () => { let facilitatorCalls = 0; const transport = createMockFacilitatorTransport(async () => { @@ -191,14 +254,13 @@ test('unresolved payment retries return 503 without re-verification or settlemen async onSigned() { return { kind: 'payment_unresolved', settlementReference: `0x${'1'.repeat(64)}` }; }, }, }); - const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + const result = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { method: 'POST', body: '{}', }, { idempotencyKey: 'idem-unresolved', fetchImpl: (url, init) => app.request(url, init), }); - assert.equal(result.res.status, 503); - assert.match((await result.res.json()).error, /settlement unresolved/); + assert.equal(result.state, 'unresolved'); assert.equal(facilitatorCalls, 0); }); @@ -221,15 +283,23 @@ test('terminal replay requires settled or refunded payment with a transaction an facilitatorTransport: transport, lifecycle: { async onSigned() { return decision; } }, }); - const result = await payingFetch(account, 'http://seller.test/resource', { - method: 'POST', body: '{}', - }, { - idempotencyKey: `idem-terminal-${expectedStatus}`, - fetchImpl: (url, init) => app.request(url, init), - }); - assert.equal(result.res.status, expectedStatus); - const body = await result.res.json(); - if (expectedStatus === 500) { + if (expectedStatus === 503) { + const withheld = await withheldAttempt(account, 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: `idem-terminal-${expectedStatus}`, + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(withheld.state, 'unresolved'); + } else { + const result = await payingFetch(account, 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: `idem-terminal-${expectedStatus}`, + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(result.res.status, expectedStatus); + const body = await result.res.json(); assert.equal(body.replayed, true); assert.equal(body.error, 'terminal execution failed'); assert.equal(result.txHash, decision.txHash); @@ -273,13 +343,13 @@ test('verify and settle disable redirects and never follow a signed authorizatio }); }); const app = resourceApp({ facilitatorTransport: transport }); - const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + const result = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { method: 'POST', body: '{}', }, { fetchImpl: (url, init) => app.request(url, init), idempotencyKey: `idem-redirect-${redirectOperation}`, }); - assert.equal(result.res.status, 503); + assert.equal(result.state, 'unresolved'); assert.equal(destinations.at(-1)[0], `http://facilitator.invalid/${redirectOperation}`); assert.ok(destinations.every(([, redirect]) => redirect === 'error')); assert.ok(destinations.every(([url]) => !url.startsWith('https://evil.test'))); @@ -307,13 +377,13 @@ test('post-settle journal failure becomes durable unresolved and exact retry nev }, handler: (c) => { executions += 1; return c.json({ ok: true }); }, }); - const first = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + const first = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { method: 'POST', body: '{}', }, { idempotencyKey: 'idem-post-settle-gap', fetchImpl: (url, init) => app.request(url, init), }); - assert.equal(first.res.status, 503); + assert.equal(first.state, 'unresolved'); assert.equal(unresolved, true); const retry = await app.request('http://seller.test/resource', { method: 'POST', @@ -348,18 +418,19 @@ test('malformed facilitator success evidence is unresolved and never authorizes lifecycle: { async onUnresolved() { unresolvedCalls += 1; } }, handler: (c) => { executions += 1; return c.json({ ok: true }); }, }); - const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + const result = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { method: 'POST', body: '{}', }, { idempotencyKey: 'idem-malformed-settlement', fetchImpl: (url, init) => app.request(url, init), }); - assert.equal(result.res.status, 503); + assert.equal(result.state, 'unresolved'); assert.equal(unresolvedCalls, 1); assert.equal(executions, 0); }); test('missing or malformed settle result is ambiguous unresolved, while explicit failure is rejected', async () => { + let caseIndex = 0; for (const [settleBody, expectedStatus] of [[{}, 503], [{ success: 'false' }, 503], [{ success: false, errorReason: 'declined' }, 402]]) { let unresolvedCalls = 0; let rejectedCalls = 0; @@ -377,13 +448,23 @@ test('missing or malformed settle result is ambiguous unresolved, while explicit async onRejected() { rejectedCalls += 1; }, }, }); - const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { - method: 'POST', body: '{}', - }, { - idempotencyKey: `idem-settle-shape-${expectedStatus}-${JSON.stringify(settleBody)}`, - fetchImpl: (url, init) => app.request(url, init), - }); - assert.equal(result.res.status, expectedStatus); + const idempotencyKey = `idem-settle-shape-${expectedStatus}-${caseIndex++}`; + if (expectedStatus === 503) { + const result = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey, + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(result.state, 'unresolved'); + } else { + await assert.rejects(() => payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey, + fetchImpl: (url, init) => app.request(url, init), + }), (error) => error.code === 'SECOND_PAYMENT_REQUIRED'); + } assert.equal(unresolvedCalls, expectedStatus === 503 ? 1 : 0); assert.equal(rejectedCalls, expectedStatus === 402 ? 1 : 0); } @@ -414,13 +495,13 @@ test('onUnresolved may observe an already-settled append without turning the res }, handler: (c) => { executions += 1; return c.json({ ok: true }); }, }); - const first = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + const first = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { method: 'POST', body: '{}', }, { idempotencyKey: 'idem-settled-append-then-error', fetchImpl: (url, init) => app.request(url, init), }); - assert.equal(first.res.status, 503); + assert.equal(first.state, 'unresolved'); const retry = await app.request('http://seller.test/resource', { method: 'POST', headers: { 'Idempotency-Key': first.idempotencyKey, 'X-PAYMENT': first.xPayment }, From c2c5c61aa95d03ddffe284d27cace0b32cd2a9c3 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 04:57:35 -0400 Subject: [PATCH 101/165] docs: define one-process Wielder limits --- spikes/pi-wielder/.env.example | 5 +++++ spikes/pi-wielder/README.md | 38 +++++++++++++++++++++++++++++----- spikes/pi-wielder/package.json | 2 ++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/spikes/pi-wielder/.env.example b/spikes/pi-wielder/.env.example index 6dd77db..65d7564 100644 --- a/spikes/pi-wielder/.env.example +++ b/spikes/pi-wielder/.env.example @@ -10,6 +10,11 @@ PRIVATE_KEY= # Where sellers receive USDC (collar + gateway payTo). Any address you control. PAY_TO_ADDRESS= +# Wielder-side payment policy. Base Sepolia only; never point this spike at mainnet. +WIELDER_SESSION_BUDGET_USDC=1.00 +WIELDER_MODEL_MAX_USDC=0.10 +WIELDER_SKILL_MAX_USDC=0.50 + # --- upstream model keys (sellers' side; only needed without MOCK_LLM=1) ----- ANTHROPIC_API_KEY= OPENAI_API_KEY= diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index c1b41e9..be5956a 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -28,6 +28,29 @@ Mock transaction hashes and timings are synthetic protocol evidence. They are no evidence of live funds, mainnet readiness, production custody, distributed locking, or durable production key management. +## Wielder payment policy + +The Wielder does not accept the first x402 offer blindly. Before signing, it requires +the exact Base Sepolia network (`84532`) and Base Sepolia USDC contract, a canonical +trusted seller route and payee, an exact resource and request-byte match, a fresh +bounded-time quote, a per-call cap, and remaining session budget. The policy rejects +numeric or coerced atomic amounts, unknown protocol fields, caller-supplied payment or +idempotency headers, ambiguous URL forms, and path-prefix confusion. + +Budget is synchronously reserved before signing. The exact authorization, signature, +and encoded `X-PAYMENT` value are stored before the one paid retry begins. A recovery +path can reuse those exact stored bytes after a local interruption; it never creates a +replacement signature. A changed second offer, another `402`, a lost retry response, +or missing/mismatched settlement evidence aborts without exposing the upstream body +and retains the amount as `unresolved`. Only exact response evidence or an injected +trusted reconciliation capability may advance that state. + +This policy is an in-memory, one-process session control. Restarting the proxy loses +its policy snapshot, so this is not production spend enforcement and provides no +cross-restart budget guarantee. A durable deployment must persist and replay signed +authorizations, reject nonce and transaction reuse across workers, and reconcile every +unresolved reservation before it can advertise such a guarantee. + ## What the offline proof demonstrates `npm run e2e` exercises one wallet across two paid asset classes without opening a @@ -36,14 +59,15 @@ socket: 1. Model inference and a hosted Skill both return an x402 `exact` challenge before execution. 2. The Wielder signs one EIP-3009 authorization per challenge and retries with the same - client idempotency key. + Wielder-owned idempotency key after the local payment policy reserves budget. 3. The Collar records one authoritative external Invocation and returns derived output plus a signed receipt. The hosted `SKILL.md` bytes are read server-side and are not directly returned. 4. An exact terminal retry returns the same receipt without another settlement or Skill execution. Different request bytes under the same key return `409`. 5. A lost settlement response becomes `unresolved`; exact retries return `503` and do - not verify, settle, or execute again until a trusted resolver advances it. + not verify, settle, or execute again until a trusted resolver advances it. The + Wielder withholds the response body and retains the exact budget reservation. 6. The Wielder view contains canonical atomic-USDC strings. Finalized Skill claims are projected from the signed receipt; a failed full-gross hold produces no invented creator or treasury claim. @@ -101,8 +125,9 @@ Offline tests inject src/facilitator-mock.mjs; no arbitrary URL is accepted. ``` The proxy demonstrates the wallet-bound HTTP 402 transport shape contemplated by -ADR-0008, but it contains no Story SDK, token custody, Royalty calculator, or Plan 6 -payment policy. It is not proof of the complete protocol or production readiness. +ADR-0008 plus a conservative one-process payment policy, but it contains no Story SDK, +token custody, or Royalty calculator. It is not proof of the complete protocol, +cross-process spend enforcement, or production readiness. ## Run the verified path @@ -112,7 +137,7 @@ npm test npm run e2e ``` -Expected current results are 62 offline unit/integration tests and 24 offline e2e +Expected current results are 113 offline unit/integration tests and 30 offline e2e checks. Counts can increase as regressions are added; zero failures is the contract. The e2e labels all timing output synthetic and uses in-process Hono requests only. @@ -122,6 +147,8 @@ Focused commands: npm run test:journal npm run test:collar npm run test:proxy +npm run test:policy +npm run test:payment ``` For standalone mock processes, persistent trust bootstrapping, and the intentionally @@ -135,6 +162,7 @@ blocked live boundary, see [RUNBOOK.md](./RUNBOOK.md). | `src/collar.mjs` | Hosted Skill boundary, execution outcomes, receipts, settlement/refund operator routes | | `src/x402-seller.mjs` | Seller x402 v1 `exact` middleware and approved transport constructors | | `src/proxy.mjs` | Wielder wallet, paying fetch, pinned receipt verification, and local receipt view | +| `src/payment-policy.mjs` | Strict Base Sepolia offer validation, one-process reservation state, exact signed authorization recovery, and trusted reconciliation boundary | | `src/ledger.mjs` | JSONL-capable Wielder receipt-view storage and rendering | | `src/gateway.mjs` | Simulated x402 model reseller | | `src/facilitator-mock.mjs` | Offline signature verification plus synthetic settlement | diff --git a/spikes/pi-wielder/package.json b/spikes/pi-wielder/package.json index 24aa2e9..da56ebd 100644 --- a/spikes/pi-wielder/package.json +++ b/spikes/pi-wielder/package.json @@ -9,6 +9,8 @@ "test:journal": "node --test tests/invocation-journal.test.mjs", "test:collar": "node --test tests/collar-failure.test.mjs tests/x402-lifecycle.test.mjs", "test:proxy": "node --test tests/proxy-trust.test.mjs", + "test:policy": "node --test tests/payment-policy.test.mjs", + "test:payment": "node --test tests/payment-policy.test.mjs tests/paying-fetch.test.mjs tests/seller-payment-response.test.mjs", "e2e": "MOCK_LLM=1 node e2e.mjs", "collar": "node src/collar.mjs", "gateway": "node src/gateway.mjs", From 0c979657abcb6c1fcafb294dd95ae21b1156a6e6 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 05:12:56 -0400 Subject: [PATCH 102/165] fix: enforce forge keys and expose conflicts --- phase0/README.md | 5 ++- phase0/src/attestation-cli.ts | 15 ++++++-- phase0/src/attestation-git.ts | 27 +++++++++++--- phase0/tests/attestation-cli.test.ts | 51 ++++++++++++++++++++++++++ phase0/tests/attestation-git.test.ts | 54 +++++++++++++++++++++++++++- phase0/tests/attestations.test.ts | 26 ++++++++++++++ 6 files changed, 169 insertions(+), 9 deletions(-) diff --git a/phase0/README.md b/phase0/README.md index d742a1d..7d9b74d 100644 --- a/phase0/README.md +++ b/phase0/README.md @@ -111,7 +111,10 @@ Repository bundles contain the wallet-signed challenge and the forge observation. They do not accept a repository path, trusted ref, public key, or trust-root override. `repository-trust.json` fixes the repository URL, checkout key, trusted ref, and allowed forge signer IDs. Public forge keys live in -`forge-signers.json`; both trust-root files are empty by default. +`forge-signers.json`; each value must be exactly one canonical Ed25519 SPKI +public-key PEM with no private, concatenated, or trailing material. Forge +observation signatures are canonical base64 encodings of exactly 64 bytes. +Both trust-root files are empty by default. The verifier resolves each checkout key through the machine-local file `phase0/.attestation-checkouts.local.json`: diff --git a/phase0/src/attestation-cli.ts b/phase0/src/attestation-cli.ts index 7891da7..42a8a73 100644 --- a/phase0/src/attestation-cli.ts +++ b/phase0/src/attestation-cli.ts @@ -20,6 +20,7 @@ import { import { canonicalChallengeFileBytes, ExecGitReader, + normalizeForgePublicKey, verifyRepositoryControl, type GitReader, type SignedRepositoryChallengeFileV1, @@ -137,8 +138,8 @@ async function forgeSigners(path: string): Promise = {}; for (const [id, publicKey] of Object.entries(signers)) { - if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(id) || typeof publicKey !== "string" || !publicKey.includes("PUBLIC KEY")) throw new Error("forge signer trust is malformed"); - result[id] = publicKey; + if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(id)) throw new Error("forge signer trust is malformed"); + result[id] = normalizeForgePublicKey(publicKey); } validateNoPrivateMaterial(value, "forge signer trust"); return Object.freeze(result); @@ -247,12 +248,22 @@ export function renderAttestationStatus(index: AttestationIndex, options: Attest for (const itemValue of payload.registrations) { const item = itemValue as ReturnType & { registrationId: string }; lines.push(`registration: ${item.registrationId}`); + lines.push(`status: ${item.status}`); lines.push(`attestation: ${item.level}`); lines.push(`claim: ${item.claim}`); lines.push(`safety review: ${item.safetyReviewStatus}`); for (const warning of item.warnings) lines.push(`warning: ${warning}`); } lines.push(`conflicts: ${payload.conflicts.length}`); + for (const conflictValue of payload.conflicts) { + const conflict = conflictValue as AttestationIndex["conflicts"][number]; + lines.push(`conflict: ${conflict.conflictId}`); + lines.push(`conflict status: ${conflict.status}`); + lines.push(`conflict reason: ${conflict.reason}`); + lines.push(`conflict outcome: ${conflict.outcome ?? "(none)"}`); + lines.push(`conflict registrations: ${[...conflict.registrationIds].sort().join(", ")}`); + lines.push(`conflict events: ${conflict.eventIds.length > 0 ? conflict.eventIds.join(", ") : "(none)"}`); + } return lines; } diff --git a/phase0/src/attestation-git.ts b/phase0/src/attestation-git.ts index 4310f78..78a0a37 100644 --- a/phase0/src/attestation-git.ts +++ b/phase0/src/attestation-git.ts @@ -1,4 +1,4 @@ -import { createHash, verify as verifySignature } from "node:crypto"; +import { createHash, createPublicKey, verify as verifySignature } from "node:crypto"; import { execFile } from "node:child_process"; import { constants } from "node:fs"; import { open } from "node:fs/promises"; @@ -241,6 +241,23 @@ export function canonicalForgeObservationBytes( return Buffer.from(`${JSON.stringify(canonical)}\n`, "utf8"); } +export function normalizeForgePublicKey(value: unknown): string { + const message = "forge signer public key must be exactly one canonical Ed25519 SPKI public key"; + if (typeof value !== "string") throw new Error(message); + let publicKey; + try { + publicKey = createPublicKey(value); + } catch (error) { + throw new Error(message, { cause: error }); + } + if (publicKey.type !== "public" || publicKey.asymmetricKeyType !== "ed25519") { + throw new Error(message); + } + const canonical = publicKey.export({ type: "spki", format: "pem" }).toString(); + if (value !== canonical) throw new Error(message); + return canonical; +} + export function verifyForgeObservation( observationValue: ForgeObservationV1, trusted: TrustedRepository, @@ -257,18 +274,18 @@ export function verifyForgeObservation( } const publicKey = forgeSigners[observation.forgeSignerId]; if (!publicKey) throw new Error("forge signer is unknown"); - if (/PRIVATE KEY/.test(publicKey)) throw new Error("forge signer configuration must contain only a public key"); + const canonicalPublicKey = normalizeForgePublicKey(publicKey); let signatureBytes: Buffer; try { signatureBytes = Buffer.from(observation.signature, "base64"); } catch (error) { throw new Error("forge observation signature must be base64", { cause: error }); } - if (signatureBytes.length === 0 || signatureBytes.toString("base64") !== observation.signature) { - throw new Error("forge observation signature must be canonical base64"); + if (signatureBytes.length !== 64 || signatureBytes.toString("base64") !== observation.signature) { + throw new Error("forge observation signature must be canonical base64 encoding of exactly 64 bytes"); } const { signature: _signature, ...unsigned } = observation; - if (!verifySignature(null, canonicalForgeObservationBytes(unsigned), publicKey, signatureBytes)) { + if (!verifySignature(null, canonicalForgeObservationBytes(unsigned), canonicalPublicKey, signatureBytes)) { throw new Error("forge observation signature is invalid"); } } diff --git a/phase0/tests/attestation-cli.test.ts b/phase0/tests/attestation-cli.test.ts index ddb25b2..184975a 100644 --- a/phase0/tests/attestation-cli.test.ts +++ b/phase0/tests/attestation-cli.test.ts @@ -172,6 +172,57 @@ test("production repository command fails before Git for missing/insecure mappin assert.equal(gitCalls, 0); await chmod(mappingPath, 0o600); + const canonicalForgePublicKey = forge.publicKey.export({ type: "spki", format: "pem" }).toString(); + const forgePrivateKey = forge.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const secondEd25519 = generateKeyPairSync("ed25519"); + const rsa512 = generateKeyPairSync("rsa", { modulusLength: 512 }); + const rsa2048 = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const ed448 = generateKeyPairSync("ed448"); + const observationBytes = canonicalForgeObservationBytes(unsignedObservation); + const invalidForgeTrust = [ + { name: "weak RSA", key: rsa512.publicKey.export({ type: "spki", format: "pem" }).toString(), signature: signBytes(null, observationBytes, rsa512.privateKey).toString("base64") }, + { name: "RSA", key: rsa2048.publicKey.export({ type: "spki", format: "pem" }).toString(), signature: signBytes(null, observationBytes, rsa2048.privateKey).toString("base64") }, + { name: "Ed448", key: ed448.publicKey.export({ type: "spki", format: "pem" }).toString(), signature: signBytes(null, observationBytes, ed448.privateKey).toString("base64") }, + { name: "private PEM", key: forgePrivateKey, signature: forgeObservation.signature }, + { name: "trailing whitespace", key: `${canonicalForgePublicKey}\n`, signature: forgeObservation.signature }, + { name: "trailing text", key: `${canonicalForgePublicKey}trailing`, signature: forgeObservation.signature }, + { name: "concatenated public PEM", key: canonicalForgePublicKey + secondEd25519.publicKey.export({ type: "spki", format: "pem" }).toString(), signature: forgeObservation.signature }, + { name: "appended private PEM", key: canonicalForgePublicKey + forgePrivateKey, signature: forgeObservation.signature }, + ]; + for (const variant of invalidForgeTrust) { + await writeFile(paths.forgeSigners, `${JSON.stringify({ + schemaVersion: 1, + forgeSigners: { "forge-1": variant.key }, + })}\n`); + await writeFile(bundlePath, `${JSON.stringify({ + schemaVersion: 1, + eventId: "repository-cli-1", + sequence: 1, + occurredAt: "2026-07-18T02:00:00.000Z", + challengeFile, + forgeObservation: { ...forgeObservation, signature: variant.signature }, + })}\n`); + await assert.rejects(executeAttestationCommand( + "attestation-verify-repository", + { bundle: bundlePath }, + () => undefined, + { phase0Root: root, paths, env: {}, git: failIfCalled, now: () => NOW }, + ), /canonical Ed25519 SPKI public key/, variant.name); + } + assert.equal(gitCalls, 0); + + await writeFile(paths.forgeSigners, `${JSON.stringify({ + schemaVersion: 1, + forgeSigners: { "forge-1": canonicalForgePublicKey }, + })}\n`); + await writeFile(bundlePath, `${JSON.stringify({ + schemaVersion: 1, + eventId: "repository-cli-1", + sequence: 1, + occurredAt: "2026-07-18T02:00:00.000Z", + challengeFile, + forgeObservation, + })}\n`); const output: string[] = []; await executeAttestationCommand( "attestation-verify-repository", diff --git a/phase0/tests/attestation-git.test.ts b/phase0/tests/attestation-git.test.ts index 87038d2..a97cd49 100644 --- a/phase0/tests/attestation-git.test.ts +++ b/phase0/tests/attestation-git.test.ts @@ -22,6 +22,7 @@ import { canonicalChallengeFileBytes, canonicalForgeObservationBytes, ExecGitReader, + verifyForgeObservation, verifyRepositoryControl, type SignedRepositoryChallengeFileV1, } from "../src/attestation-git"; @@ -112,7 +113,7 @@ async function fixture(t: test.TestContext) { checkoutPaths: { "demo-checkout": { repositoryPath: root, device: rootMetadata.dev, inode: rootMetadata.ino } }, }); const forgeSigners = { "forge-1": forge.publicKey.export({ type: "spki", format: "pem" }).toString() }; - return { root, artifactCommit, proofCommit, subject, challenge, challengeFile, forgeObservation, repositories, forgeSigners, trustConfig }; + return { root, artifactCommit, proofCommit, subject, challenge, challengeFile, forge, forgeObservation, repositories, forgeSigners, trustConfig }; } test("repository control verifies offline against exact artifact and challenge bytes", async (t) => { @@ -132,6 +133,57 @@ test("repository control verifies offline against exact artifact and challenge b assert.equal(event.subject.artifactHash, f.subject.artifactHash); }); +test("forge observation trust accepts only one canonical Ed25519 SPKI public key", async (t) => { + const f = await fixture(t); + const trusted = f.repositories.resolve("demo", REPOSITORY_URL); + const { signature: _signature, ...unsignedObservation } = f.forgeObservation; + const observationBytes = canonicalForgeObservationBytes(unsignedObservation); + const canonicalPublicKey = f.forge.publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKey = f.forge.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const secondEd25519 = generateKeyPairSync("ed25519"); + const rsa512 = generateKeyPairSync("rsa", { modulusLength: 512 }); + const rsa2048 = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const ed448 = generateKeyPairSync("ed448"); + const signedBy = (signer: typeof f.forge.privateKey): ForgeObservationV1 => ({ + ...unsignedObservation, + signature: signBytes(null, observationBytes, signer).toString("base64"), + }); + const invalid = [ + { name: "weak RSA", key: rsa512.publicKey.export({ type: "spki", format: "pem" }).toString(), observation: signedBy(rsa512.privateKey) }, + { name: "RSA", key: rsa2048.publicKey.export({ type: "spki", format: "pem" }).toString(), observation: signedBy(rsa2048.privateKey) }, + { name: "Ed448", key: ed448.publicKey.export({ type: "spki", format: "pem" }).toString(), observation: signedBy(ed448.privateKey) }, + { name: "private PEM", key: privateKey, observation: f.forgeObservation }, + { name: "trailing whitespace", key: `${canonicalPublicKey}\n`, observation: f.forgeObservation }, + { name: "trailing text", key: `${canonicalPublicKey}trailing`, observation: f.forgeObservation }, + { name: "concatenated public PEM", key: canonicalPublicKey + secondEd25519.publicKey.export({ type: "spki", format: "pem" }).toString(), observation: f.forgeObservation }, + { name: "appended private PEM", key: canonicalPublicKey + privateKey, observation: f.forgeObservation }, + ]; + + assert.doesNotThrow(() => verifyForgeObservation(f.forgeObservation, trusted, { "forge-1": canonicalPublicKey })); + for (const variant of invalid) { + assert.throws( + () => verifyForgeObservation(variant.observation, trusted, { "forge-1": variant.key }), + /canonical Ed25519 SPKI public key/, + variant.name, + ); + } +}); + +test("forge observation signatures must be canonical base64 encoding of exactly 64 bytes", async (t) => { + const f = await fixture(t); + const trusted = f.repositories.resolve("demo", REPOSITORY_URL); + for (const length of [63, 65]) { + assert.throws( + () => verifyForgeObservation( + { ...f.forgeObservation, signature: Buffer.alloc(length, 1).toString("base64") }, + trusted, + f.forgeSigners, + ), + /exactly 64 bytes/, + ); + } +}); + test("repository verification rejects signed-binding and snapshot tampering", async (t) => { const f = await fixture(t); const base = { diff --git a/phase0/tests/attestations.test.ts b/phase0/tests/attestations.test.ts index b22a8f5..617d8d3 100644 --- a/phase0/tests/attestations.test.ts +++ b/phase0/tests/attestations.test.ts @@ -287,3 +287,29 @@ test("human status output uses explicit evidence-level language", async () => { assert.match(output, /safety review: not_reviewed/); assert.match(output, /warning: registration does not prove authorship, originality, legal ownership, or safety/); }); + +test("human status output lists challenged registrations and every matching conflict deterministically", async () => { + const first = privateKeyToAccount(generatePrivateKey()); + const second = privateKeyToAccount(generatePrivateKey()); + const a = subject(IP_A, first.address); + const b = subject(IP_B, second.address); + const index = await reduceAttestationEvents([], { baseSubjects: [b, a] }); + const conflictId = deterministicConflictId(a, b); + const lines = renderAttestationStatus(index, { artifactHash: a.artifactHash }); + + assert.equal(lines.filter((line) => line === "status: challenged").length, 2); + assert.deepEqual(lines.slice(-7), [ + "conflicts: 1", + `conflict: ${conflictId}`, + "conflict status: open", + "conflict reason: duplicate_bytes", + "conflict outcome: (none)", + `conflict registrations: ${[a.registrationId, b.registrationId].sort().join(", ")}`, + "conflict events: (none)", + ]); + + const json = JSON.parse(renderAttestationStatus(index, { artifactHash: a.artifactHash, json: true })[0]); + assert.equal(json.registrations.length, 2); + assert.ok(json.registrations.every((registration: { status: string }) => registration.status === "challenged")); + assert.deepEqual(json.conflicts, index.conflicts); +}); From 8b25d617eb4571dd2f1f8220d8e48ea518b9b518 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 05:15:28 -0400 Subject: [PATCH 103/165] fix: reject inherited forge signer trust --- phase0/src/attestation-git.ts | 15 ++++++++++++++- phase0/tests/attestation-git.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/phase0/src/attestation-git.ts b/phase0/src/attestation-git.ts index 78a0a37..193a6b5 100644 --- a/phase0/src/attestation-git.ts +++ b/phase0/src/attestation-git.ts @@ -258,6 +258,16 @@ export function normalizeForgePublicKey(value: unknown): string { return canonical; } +function assertForgeSignerMap(value: unknown): asserts value is Readonly> { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("forge signer trust must be a plain or null-prototype record"); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error("forge signer trust must be a plain or null-prototype record"); + } +} + export function verifyForgeObservation( observationValue: ForgeObservationV1, trusted: TrustedRepository, @@ -272,8 +282,11 @@ export function verifyForgeObservation( if (!trusted.permittedForgeSignerIds.includes(observation.forgeSignerId)) { throw new Error("forge signer is not permitted for this repository"); } + assertForgeSignerMap(forgeSigners); + if (!Object.hasOwn(forgeSigners, observation.forgeSignerId)) { + throw new Error("forge signer is unknown; signer ID must be an own property of the trust map"); + } const publicKey = forgeSigners[observation.forgeSignerId]; - if (!publicKey) throw new Error("forge signer is unknown"); const canonicalPublicKey = normalizeForgePublicKey(publicKey); let signatureBytes: Buffer; try { diff --git a/phase0/tests/attestation-git.test.ts b/phase0/tests/attestation-git.test.ts index a97cd49..e433c0f 100644 --- a/phase0/tests/attestation-git.test.ts +++ b/phase0/tests/attestation-git.test.ts @@ -184,6 +184,31 @@ test("forge observation signatures must be canonical base64 encoding of exactly } }); +test("forge observation trust rejects inherited signer entries and exotic map prototypes", async (t) => { + const f = await fixture(t); + const trusted = f.repositories.resolve("demo", REPOSITORY_URL); + const canonicalPublicKey = f.forge.publicKey.export({ type: "spki", format: "pem" }).toString(); + Object.defineProperty(Object.prototype, "forge-1", { + configurable: true, + enumerable: false, + value: canonicalPublicKey, + }); + try { + assert.throws( + () => verifyForgeObservation(f.forgeObservation, trusted, {}), + /own property/, + ); + } finally { + delete (Object.prototype as Record)["forge-1"]; + } + + const inherited = Object.create({ "forge-1": canonicalPublicKey }) as Record; + assert.throws( + () => verifyForgeObservation(f.forgeObservation, trusted, inherited), + /plain or null-prototype record/, + ); +}); + test("repository verification rejects signed-binding and snapshot tampering", async (t) => { const f = await fixture(t); const base = { From 4c5ef7748adc51d1864c3e08c72d88d85b44eca5 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 05:16:59 -0400 Subject: [PATCH 104/165] fix: enforce internal award period trust --- spikes/internal-invocation-awards/README.md | 28 +++-- .../internal-invocation-awards/src/budget.mjs | 15 +++ .../internal-invocation-awards/src/engine.mjs | 13 ++- .../internal-invocation-awards/src/schema.mjs | 11 ++ .../src/statements.mjs | 11 +- .../test/budget.test.mjs | 13 +++ .../test/engine.test.mjs | 35 ++++++ .../test/statements.test.mjs | 107 +++++++++++++++++- 8 files changed, 217 insertions(+), 16 deletions(-) diff --git a/spikes/internal-invocation-awards/README.md b/spikes/internal-invocation-awards/README.md index 519d9f3..ea1414a 100644 --- a/spikes/internal-invocation-awards/README.md +++ b/spikes/internal-invocation-awards/README.md @@ -15,8 +15,10 @@ protected ubiquitous language, PRD, or ADR corpus. The example employer signs an immutable `EmployerBudgetAuthorizationV1` for a denomination and period. Mutable reservation, consumption, and release counters live in a separate `BudgetStateV1`; changing a signed authorization field invalidates its -signature. Effective and expiry times are checked at both reservation and Execution -start. +signature. The signed budget's effective time must fall within its declared month and +its expiry may reach, but never pass, the next-month boundary. Execution credentials +are capped at that boundary, and both reservation and Execution start require the +trusted clock to remain in the declared budget period. The accounting flow is: @@ -76,7 +78,9 @@ Version 1 accepts only `paymentSchedule: monthly_in_arrears`. A current-period a remains earned and non-payable for that period. A payable advance must reference the same authenticated receipt through a later statement's historical-receipt set and prior signed statement chain; a current-period receipt cannot be advanced or paid -early. +early. Receipt creation and verification also require `occurredAt` to fall within the +receipt's declared period, so a late Execution cannot be backdated into an earlier +month and immediately treated as historical. The engine is provisioned with an immutable `(skillId, skillVersionHash)` registration map that binds the canonical Creator and employer. Missing, expired, revoked, @@ -149,14 +153,16 @@ totals, kernel journal entries, and absence of an external settlement. Every verifier accepts only a canonical Ed25519 SPKI public key. RSA, private-key PEM, noncanonical PEM, and a public PEM with appended material fail closed; only canonical -public PEM is retained in engine state. Engine provisioning signs a random, -domain-separated challenge and verifies it with the configured receipt public key -before any Invocation can start. Each receipt is still independently verified, its -caller-supplied hash is recomputed over the exact strict signed-receipt schema, and -hash reuse is rejected across receipt IDs. If a correctly provisioned signer later -fails or misbehaves after the executor starts, the terminal transaction commits no -award or receipt and leaves the Invocation in `executing` for operator reconciliation; -the executor is not run again automatically. +public PEM is retained in engine state. Receipt and statement verifier trust maps must +be ordinary plain objects, and a signer ID resolves only through an own map entry; +inherited or prototype-polluted keys are never trusted. Engine provisioning signs a +random, domain-separated challenge and verifies it with the configured receipt public +key before any Invocation can start. Each receipt is still independently verified, +its caller-supplied hash is recomputed over the exact strict signed-receipt schema, +and hash reuse is rejected across receipt IDs. If a correctly provisioned signer +later fails or misbehaves after the executor starts, the terminal transaction commits +no award or receipt and leaves the Invocation in `executing` for operator +reconciliation; the executor is not run again automatically. Employer and employee verify the same signed receipt bytes. They also verify a separate whole-statement signature that binds: diff --git a/spikes/internal-invocation-awards/src/budget.mjs b/spikes/internal-invocation-awards/src/budget.mjs index 18d825c..2f5790f 100644 --- a/spikes/internal-invocation-awards/src/budget.mjs +++ b/spikes/internal-invocation-awards/src/budget.mjs @@ -9,6 +9,7 @@ import { deepFreeze, fromAtomic, parseUtc, + periodEndExclusive, policyHash, requireExactKeys, sumAtomic, @@ -58,6 +59,13 @@ function decodeSignature(value) { return bytes; } +function periodBounds(period) { + return { + start: parseUtc(`${period}-01T00:00:00.000Z`, 'budget period start'), + end: parseUtc(periodEndExclusive(period, 'budget period'), 'budget period end'), + }; +} + function validateUnsignedAuthorization(input) { requireExactKeys(input, BUDGET_AUTHORIZATION_KEYS, 'budget authorization'); if (input.schemaVersion !== 1) throw new Error('budget authorization schemaVersion must equal 1'); @@ -77,6 +85,13 @@ function validateUnsignedAuthorization(input) { const effectiveAt = parseUtc(input.effectiveAt, 'budget effectiveAt'); const expiresAt = parseUtc(input.expiresAt, 'budget expiresAt'); if (expiresAt <= effectiveAt) throw new Error('budget expiresAt must follow effectiveAt'); + const bounds = periodBounds(input.period); + if (effectiveAt < bounds.start || effectiveAt >= bounds.end) { + throw new Error(`budget effectiveAt must fall within period ${input.period}`); + } + if (expiresAt > bounds.end) { + throw new Error(`budget expiresAt must not exceed period ${input.period}`); + } return cloneFrozen(input); } diff --git a/spikes/internal-invocation-awards/src/engine.mjs b/spikes/internal-invocation-awards/src/engine.mjs index 05f404d..13eafb0 100644 --- a/spikes/internal-invocation-awards/src/engine.mjs +++ b/spikes/internal-invocation-awards/src/engine.mjs @@ -28,6 +28,7 @@ import { fromAtomic, parseExecutorOutcome, parseUtc, + periodEndExclusive, requireExactKeys, skillRegistrationKey, toAtomic, @@ -193,12 +194,19 @@ function invocationEvent(type, invocationId, occurredAt, details = {}) { }); } +function requireActiveBudgetPeriod(budget, now) { + if (budget.period !== now.slice(0, 7)) { + throw new Error('Invocation is outside the active employer budget period'); + } +} + function effectiveCredentialExpiry(requested, quote, policy, budget) { const candidates = [ requested, quote.expiresAt, policy.expiresAt, budget.authorization.expiresAt, + periodEndExclusive(budget.period), ]; for (const [index, candidate] of candidates.entries()) { parseUtc(candidate, `credential bound ${index}`); @@ -488,10 +496,10 @@ export async function authorizeInternalInvocation(input) { const validatedPolicy = validatePolicy(policy, now); const quote = validateQuote(input.quote, validatedPolicy, now); const registration = resolveActiveRegistration(current, quote, validatedPolicy, now); - if (current.budget.policyHash !== quote.policyHash - || current.budget.period !== now.slice(0, 7)) { + if (current.budget.policyHash !== quote.policyHash) { throw new Error('quote is outside the active employer budget'); } + requireActiveBudgetPeriod(current.budget, now); if (current.budget.revision !== input.expectedBudgetRevision) { throw new Error( `stale budget revision: expected ${input.expectedBudgetRevision}, received ${current.budget.revision}`, @@ -774,6 +782,7 @@ export async function executeAuthorizedInvocation(input) { const started = await input.store.transact(initial.revision, (current) => { const now = engineNow(current); + requireActiveBudgetPeriod(current.budget, now); const policy = assertTrustedState(current, now); const quote = validateQuote(input.quote, policy, now); const invocation = current.invocations[quote.invocationId]; diff --git a/spikes/internal-invocation-awards/src/schema.mjs b/spikes/internal-invocation-awards/src/schema.mjs index 75fb530..77c1a65 100644 --- a/spikes/internal-invocation-awards/src/schema.mjs +++ b/spikes/internal-invocation-awards/src/schema.mjs @@ -84,6 +84,17 @@ export function sumAtomic(values) { return values.reduce((sum, value) => sum + toAtomic(value), 0n); } +export function periodEndExclusive(period, label = 'period') { + if (typeof period !== 'string' || !/^\d{4}-(0[1-9]|1[0-2])$/.test(period)) { + throw new Error(`${label} must be YYYY-MM`); + } + const [year, month] = period.split('-').map(Number); + const end = new Date(0); + end.setUTCFullYear(year, month, 1); + end.setUTCHours(0, 0, 0, 0); + return end.toISOString(); +} + export function receiptSequenceScope({ employerId, creatorId, currency, atomicScale }) { for (const [value, label] of [ [employerId, 'receipt employerId'], diff --git a/spikes/internal-invocation-awards/src/statements.mjs b/spikes/internal-invocation-awards/src/statements.mjs index bec75b3..eeebeed 100644 --- a/spikes/internal-invocation-awards/src/statements.mjs +++ b/spikes/internal-invocation-awards/src/statements.mjs @@ -90,8 +90,12 @@ function decodeSignature(value, label) { } function trustedKey(map, keyId, label) { - if (map === null || typeof map !== 'object' || Array.isArray(map)) { - throw new Error(`${label} trust map must be an object`); + if (map === null || typeof map !== 'object' || Array.isArray(map) + || Object.getPrototypeOf(map) !== Object.prototype) { + throw new Error(`${label} trust map must be a plain object`); + } + if (!Object.hasOwn(map, keyId)) { + throw new Error(`${label} key ID ${keyId} is not trusted`); } const key = map[keyId]; if (typeof key !== 'string' || key.length === 0) { @@ -154,6 +158,9 @@ function validateReceiptPayload(input) { atomicScale: input.atomicScale, })) throw new Error('receipt sequence scope does not match receipt identity'); parseUtc(input.occurredAt, 'receipt occurredAt'); + if (input.occurredAt.slice(0, 7) !== input.period) { + throw new Error(`receipt occurredAt must fall within receipt period ${input.period}`); + } for (const key of [ 'reservedAtomic', 'consumedAtomic', 'releasedAtomic', 'heldReservationAtomic', 'protocolFeeAtomic', 'refundReserveAtomic', 'invocationAwardAtomic', diff --git a/spikes/internal-invocation-awards/test/budget.test.mjs b/spikes/internal-invocation-awards/test/budget.test.mjs index 30fcca9..af4a724 100644 --- a/spikes/internal-invocation-awards/test/budget.test.mjs +++ b/spikes/internal-invocation-awards/test/budget.test.mjs @@ -279,6 +279,19 @@ test('budget authorization validates its own effective window and policy signer assert.throws(() => verifiedBudget(signer), /finance signer is not permitted/); }); +test('budget authorization window stays within its declared monthly period', () => { + assert.throws(() => financeFixture({ + effectiveAt: '2026-06-30T23:59:59.999Z', + }), /budget effectiveAt must fall within period 2026-07/); + assert.throws(() => financeFixture({ + expiresAt: '2026-08-01T00:00:00.001Z', + }), /budget expiresAt must not exceed period 2026-07/); + assert.doesNotThrow(() => financeFixture({ + effectiveAt: '2026-07-01T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + })); +}); + test('reservation uses exact budget and reservation revisions then kernel finalizes', () => { const budget = verifiedBudget(); const reserved = reserveBudget(budget, QUOTE, { diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs index 854488a..d0bf5f0 100644 --- a/spikes/internal-invocation-awards/test/engine.test.mjs +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -645,6 +645,41 @@ test('active Skill registration and signed budget trust are re-established befor assert.equal(calls, 0); }); +test('July authorization is capped at month end and cannot execute in August', async () => { + const augustStart = '2026-08-01T00:00:00.000Z'; + const septemberStart = '2026-09-01T00:00:00.000Z'; + const fx = fixture({ + policyOverrides: { expiresAt: septemberStart }, + registrations: [registration({ expiresAt: septemberStart })], + }); + const q = makeQuote(fx.activePolicy, 'cross-period', { + expiresAt: '2026-08-02T00:00:00.000Z', + }); + const authorized = await authorize(fx, q, { + credentialExpiresAt: q.expiresAt, + principalAttestation: principalAttestation(fx, q, { expiresAt: q.expiresAt }), + }); + assert.equal(authorized.credentialPayload.expiresAt, augustStart); + + fx.clock.now = '2026-08-01T00:01:00.000Z'; + let executorCalls = 0; + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), + executor: async () => { + executorCalls += 1; + return { kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH }; + }, + }), /outside the active employer budget period/); + assert.equal(executorCalls, 0); + const snapshot = fx.store.snapshot(); + assert.equal(snapshot.invocations[q.invocationId].state, 'authorized'); + assert.equal(snapshot.reservations[authorized.reservation.reservationId].state, 'reserved'); + assert.equal(Object.keys(snapshot.receipts).length, 0); + assert.equal(Object.keys(snapshot.awards).length, 0); +}); + test('canonical policy bytes are bound through budget, quote, credential, Invocation, award, and receipt', async () => { const fx = fixture(); const mutated = { diff --git a/spikes/internal-invocation-awards/test/statements.test.mjs b/spikes/internal-invocation-awards/test/statements.test.mjs index 03ebaf1..d31df3a 100644 --- a/spikes/internal-invocation-awards/test/statements.test.mjs +++ b/spikes/internal-invocation-awards/test/statements.test.mjs @@ -1,5 +1,9 @@ import assert from 'node:assert/strict'; -import { createHash, generateKeyPairSync } from 'node:crypto'; +import { + createHash, + generateKeyPairSync, + sign as cryptoSign, +} from 'node:crypto'; import test from 'node:test'; import { receiptSequenceScope } from '../src/receipt-ledger.mjs'; @@ -436,6 +440,70 @@ test('monthly-in-arrears rejects advancing or paying a current-period award', () }), /payable advance must reference an authenticated historical receipt/); }); +test('receipt occurrence month blocks the July-to-August payable bypass', () => { + const signers = signerFixture(); + const occurredAt = '2026-08-01T00:01:00.000Z'; + const records = successRecords(1, 'late-july'); + assert.throws(() => { + const receipt = signReceipt(buildInvocationReceipt({ + invocation: { + ...records.invocation, + finalizedAt: occurredAt, + }, + reservation: { + ...records.reservation, + finalizedAt: occurredAt, + }, + award: { + ...records.award, + measuredAt: occurredAt, + earnedAt: occurredAt, + }, + employerId: 'megacorp', + receiptSignerId: 'collar-receipt-key-2026-07', + }), signers.receipt.privateKey); + const july = signedJulyStatement(signers, [receipt], 'late-july'); + const hash = receiptHash(receipt); + return buildStatement({ + statementId: 'statement-immediate-august-payment', + employerId: 'megacorp', creatorId: 'sam', period: '2026-08', + currency: 'USD', atomicScale: 6, openingPayableAtomic: july.closingPayableAtomic, + receipts: [], historicalReceipts: [receipt], priorStatement: july, + payableAdvances: [{ + advanceId: 'advance-immediate', receiptHash: hash, + amountAtomic: '2000000', advancedAt: occurredAt, + }], + reversals: [], + payments: [{ + paymentId: 'payment-immediate', amountAtomic: '2000000', + paidAt: occurredAt, railReference: 'simulated-immediate-payment', + }], + statementSignerId: 'collar-statement-key-2026-07', + }); + }, /receipt occurredAt must fall within receipt period 2026-07/); +}); + +test('receipt verification rejects a correctly signed occurrence outside its period', () => { + const signers = signerFixture(); + const valid = signedSuccess(signers, 1, 'verification-period'); + const { signature: _signature, ...unsigned } = valid; + const mismatched = { + ...unsigned, + occurredAt: '2026-08-01T00:01:00.000Z', + }; + const mismatchedSigned = { + ...mismatched, + signature: cryptoSign( + null, + new TextEncoder().encode(JSON.stringify(mismatched)), + signers.receipt.privateKey, + ).toString('base64'), + }; + assert.throws(() => verifyReceipt(mismatchedSigned, { + trustedReceiptSigners: signers.receiptTrust, + }), /receipt occurredAt must fall within receipt period 2026-07/); +}); + test('receipt and statement verification reject RSA-512 and private-key trust material', () => { const signers = signerFixture(); const rsa = generateKeyPairSync('rsa', { modulusLength: 512 }); @@ -471,6 +539,43 @@ test('receipt and statement verification reject RSA-512 and private-key trust ma }), /public SPKI PEM/); }); +test('receipt verification rejects inherited signer-map entries', () => { + const signers = signerFixture(); + const receipt = signedSuccess(signers, 1, 'inherited-receipt-key'); + const inheritedTrust = Object.create({ + 'collar-receipt-key-2026-07': signers.receipt.publicKey.export({ + type: 'spki', format: 'pem', + }), + }); + assert.throws(() => verifyReceipt(receipt, { + trustedReceiptSigners: inheritedTrust, + }), /receipt signer trust map must be a plain object/); +}); + +test('statement verification does not trust a prototype-polluted empty signer map', () => { + const signers = signerFixture(); + const receipt = signedSuccess(signers, 1, 'inherited-statement-key'); + const unsigned = buildStatement({ + statementId: 'statement-inherited-key', employerId: 'megacorp', creatorId: 'sam', + period: '2026-07', currency: 'USD', atomicScale: 6, openingPayableAtomic: '0', + receipts: [receipt], payableAdvances: [], reversals: [], payments: [], + statementSignerId: 'collar-statement-key-2026-07', + }); + const signed = signStatement(unsigned, signers.statement.privateKey); + Object.prototype['collar-statement-key-2026-07'] = signers.statement.publicKey.export({ + type: 'spki', format: 'pem', + }); + try { + assert.throws(() => verifyStatement(signed, { + signedReceipts: [receipt], + trustedReceiptSigners: signers.receiptTrust, + trustedStatementSigners: {}, + }), /statement signer key ID collar-statement-key-2026-07 is not trusted/); + } finally { + delete Object.prototype['collar-statement-key-2026-07']; + } +}); + test('August can authenticate a July award without recounting July economics', () => { const signers = signerFixture(); const julyReceipt = signedSuccess(signers, 1, 'july'); From 32294f16aa308214098f7d3e70d4a3ef5c104790 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 05:24:24 -0400 Subject: [PATCH 105/165] fix: reject inherited credential signer keys --- spikes/internal-invocation-awards/README.md | 7 ++-- .../src/credentials.mjs | 33 ++++++++++--------- .../test/engine.test.mjs | 23 +++++++++++++ 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/spikes/internal-invocation-awards/README.md b/spikes/internal-invocation-awards/README.md index ea1414a..b41c871 100644 --- a/spikes/internal-invocation-awards/README.md +++ b/spikes/internal-invocation-awards/README.md @@ -153,9 +153,10 @@ totals, kernel journal entries, and absence of an external settlement. Every verifier accepts only a canonical Ed25519 SPKI public key. RSA, private-key PEM, noncanonical PEM, and a public PEM with appended material fail closed; only canonical -public PEM is retained in engine state. Receipt and statement verifier trust maps must -be ordinary plain objects, and a signer ID resolves only through an own map entry; -inherited or prototype-polluted keys are never trusted. Engine provisioning signs a +public PEM is retained in engine state. Finance, identity, manager, receipt, and +statement verifier trust maps must be ordinary plain objects, and a signer ID resolves +only through an own map entry; inherited or prototype-polluted keys are never trusted. +Engine provisioning signs a random, domain-separated challenge and verifies it with the configured receipt public key before any Invocation can start. Each receipt is still independently verified, its caller-supplied hash is recomputed over the exact strict signed-receipt schema, diff --git a/spikes/internal-invocation-awards/src/credentials.mjs b/spikes/internal-invocation-awards/src/credentials.mjs index c186ae8..2b642d2 100644 --- a/spikes/internal-invocation-awards/src/credentials.mjs +++ b/spikes/internal-invocation-awards/src/credentials.mjs @@ -41,6 +41,21 @@ function requireString(value, label) { if (typeof value !== 'string' || value.length === 0) throw new Error(`${label} must be non-empty`); } +function trustedSignerKey(map, signerId, label) { + if (map === null || typeof map !== 'object' || Array.isArray(map) + || Object.getPrototypeOf(map) !== Object.prototype) { + throw new Error(`${label} trust map must be a plain object`); + } + if (!Object.hasOwn(map, signerId)) { + throw new Error(`${label} is not provisioned`); + } + const configuredKey = map[signerId]; + if (typeof configuredKey !== 'string' || configuredKey.length === 0) { + throw new Error(`${label} is not provisioned`); + } + return normalizeEd25519PublicKey(configuredKey, `${label} ${signerId}`); +} + function decodeSignature(value, label) { if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { throw new Error(`${label} signature must be canonical base64`); @@ -191,14 +206,7 @@ export function verifyPrincipalAttestation(signed, { if (!policy.permittedIdentitySignerIds.includes(payload.identitySignerId)) { throw new Error('identity signer is not permitted by policy'); } - const configuredKey = identitySigners[payload.identitySignerId]; - if (typeof configuredKey !== 'string' || configuredKey.length === 0) { - throw new Error('identity signer is not provisioned'); - } - const key = normalizeEd25519PublicKey( - configuredKey, - `identity signer ${payload.identitySignerId}`, - ); + const key = trustedSignerKey(identitySigners, payload.identitySignerId, 'identity signer'); if (!policy.permittedInitiatingPrincipalIds.includes(payload.principalId)) { throw new Error('initiating principal is not permitted by policy'); } @@ -285,14 +293,7 @@ export function verifyManagerApproval(approval, { if (!policy.permittedManagerSignerIds.includes(payload.managerSignerId)) { throw new Error('manager signer is not permitted by policy'); } - const configuredKey = managerSigners[payload.managerSignerId]; - if (typeof configuredKey !== 'string' || configuredKey.length === 0) { - throw new Error('manager signer is not provisioned'); - } - const trustedKey = normalizeEd25519PublicKey( - configuredKey, - `manager signer ${payload.managerSignerId}`, - ); + const trustedKey = trustedSignerKey(managerSigners, payload.managerSignerId, 'manager signer'); if (payload.invocationId !== quote.invocationId || payload.creatorId !== quote.creatorId || payload.policyId !== policy.policyId diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs index d0bf5f0..b9caea6 100644 --- a/spikes/internal-invocation-awards/test/engine.test.mjs +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -12,6 +12,8 @@ import { signManagerApproval, signPrincipalAttestation, verifyCredential, + verifyManagerApproval, + verifyPrincipalAttestation, } from '../src/credentials.mjs'; import { authorizeInternalInvocation, @@ -411,6 +413,27 @@ test('principal attestation trust, binding, and nonce replay fail closed', async assert.equal(Object.keys(fx.store.snapshot().reservations).length, 1); }); +test('direct signer-map verifiers reject inherited identity and manager entries', () => { + const fx = fixture(); + const q = makeQuote(fx.activePolicy, 'inherited-verifier-keys'); + assert.throws(() => verifyPrincipalAttestation(principalAttestation(fx, q), { + policy: fx.activePolicy, + quote: q, + identitySigners: Object.create({ 'megacorp-identity': publicPem(fx.identity) }), + now: NOW, + }), /identity signer trust map must be a plain object/); + + const selfQuote = makeQuote(fx.activePolicy, 'inherited-manager-key', { + initiatingPrincipalId: 'sam', + }); + assert.throws(() => verifyManagerApproval(managerApproval(fx, selfQuote), { + policy: fx.activePolicy, + quote: selfQuote, + managerSigners: Object.create({ 'manager-alex': publicPem(fx.manager) }), + now: NOW, + }), /manager signer trust map must be a plain object/); +}); + test('successful execution atomically commits policy-bound signed receipt and exact gross', async () => { const fx = fixture(); const q = makeQuote(fx.activePolicy); From a5824f22a1c51b34557c1650cbd4d8ac291b580e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 05:29:44 -0400 Subject: [PATCH 106/165] fix: harden attestation trust and status --- phase0/src/attestation-cli.ts | 10 ++- phase0/src/attestations.ts | 20 ++++- phase0/tests/attestations.test.ts | 130 ++++++++++++++++++++++++++++-- 3 files changed, 151 insertions(+), 9 deletions(-) diff --git a/phase0/src/attestation-cli.ts b/phase0/src/attestation-cli.ts index 42a8a73..de9e51f 100644 --- a/phase0/src/attestation-cli.ts +++ b/phase0/src/attestation-cli.ts @@ -227,7 +227,8 @@ function statusPayload(index: AttestationIndex, options: AttestationCommandOptio if (options.artifactHash !== undefined && options.registrationId !== undefined) throw new Error("choose only one of --artifact-hash or --registration-id"); const selected = Object.entries(index.registrations).filter(([id, registration]) => (options.artifactHash === undefined || registration.subject.artifactHash === options.artifactHash) - && (options.registrationId === undefined || id === options.registrationId)); + && (options.registrationId === undefined || id === options.registrationId)) + .sort(([a], [b]) => a.localeCompare(b)); const ids = new Set(selected.map(([id]) => id)); return { registrations: selected.map(([registrationId, registration]) => ({ @@ -236,7 +237,9 @@ function statusPayload(index: AttestationIndex, options: AttestationCommandOptio ...displayAttestation(index, registrationId), revocations: registration.revocations, })), - conflicts: index.conflicts.filter((conflict) => conflict.registrationIds.some((id) => ids.has(id))), + conflicts: index.conflicts + .filter((conflict) => conflict.registrationIds.some((id) => ids.has(id))) + .sort((a, b) => a.conflictId.localeCompare(b.conflictId)), }; } @@ -258,11 +261,12 @@ export function renderAttestationStatus(index: AttestationIndex, options: Attest for (const conflictValue of payload.conflicts) { const conflict = conflictValue as AttestationIndex["conflicts"][number]; lines.push(`conflict: ${conflict.conflictId}`); + lines.push(`conflict artifact hash: ${conflict.artifactHash ?? "(none)"}`); lines.push(`conflict status: ${conflict.status}`); lines.push(`conflict reason: ${conflict.reason}`); lines.push(`conflict outcome: ${conflict.outcome ?? "(none)"}`); lines.push(`conflict registrations: ${[...conflict.registrationIds].sort().join(", ")}`); - lines.push(`conflict events: ${conflict.eventIds.length > 0 ? conflict.eventIds.join(", ") : "(none)"}`); + lines.push(`conflict events: ${conflict.eventIds.length > 0 ? [...conflict.eventIds].sort().join(", ") : "(none)"}`); } return lines; } diff --git a/phase0/src/attestations.ts b/phase0/src/attestations.ts index 22f4200..b9779ff 100644 --- a/phase0/src/attestations.ts +++ b/phase0/src/attestations.ts @@ -218,6 +218,16 @@ function exactKeys(value: Record, expected: readonly string[], } } +function assertTrustMap(value: unknown, label: string): asserts value is Readonly> { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a plain or null-prototype record`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${label} must be a plain or null-prototype record`); + } +} + function nonempty(value: unknown, label: string): asserts value is string { if (typeof value !== "string" || value.trim() !== value || value.length === 0) { throw new Error(`${label} must be a nonempty canonical string`); @@ -536,7 +546,11 @@ export async function verifyOrganizationApproval( approvedAt: approval.approvedAt, }; if (approval.statementHash !== organizationStatementHash(unsigned)) throw new Error("organization statement hash mismatch"); - const trusted = organizationSigners[approval.organizationId] ?? []; + assertTrustMap(organizationSigners, "organization signer trust"); + if (!Object.hasOwn(organizationSigners, approval.organizationId)) { + throw new Error("organization approver is not allow-listed; organization ID must be an own property of the trust map"); + } + const trusted = organizationSigners[approval.organizationId]; if (!trusted.includes(approval.approverWallet)) throw new Error("organization approver is not allow-listed"); if (!await verifyMessage({ address: approval.approverWallet, message: canonicalOrganizationStatement(unsigned), signature: approval.signature })) { throw new Error("organization signature does not recover the approver wallet"); @@ -623,6 +637,10 @@ export async function verifyAdminEventSignature( ): Promise { const event = parseAttestationEvent(eventValue); if (event.type !== "challenge_resolved" && event.type !== "attestation_revoked") throw new Error("admin event required"); + assertTrustMap(adminSigners, "attestation admin trust"); + if (!Object.hasOwn(adminSigners, event.adminSignerId)) { + throw new Error("admin signer is not provisioned; signer ID must be an own property of the trust map"); + } const signer = adminSigners[event.adminSignerId]; if (!signer) throw new Error("admin signer is not provisioned"); address(signer, "admin signer address"); diff --git a/phase0/tests/attestations.test.ts b/phase0/tests/attestations.test.ts index 617d8d3..37baebf 100644 --- a/phase0/tests/attestations.test.ts +++ b/phase0/tests/attestations.test.ts @@ -17,6 +17,8 @@ import { registrationSubjectsFromManifest, reduceAttestationEvents, repositoryStatementHash, + verifyAdminEventSignature, + verifyOrganizationApproval, type AttestationRevokedEvent, type ChallengeOpenedEvent, type ChallengeResolvedEvent, @@ -175,6 +177,51 @@ test("organization approval requires repository evidence and an allow-listed sig assert.equal(state.registrations[base.registrationId].level, "organization_approved"); }); +test("organization approval rejects inherited signer entries and exotic trust-map prototypes", async () => { + const creator = privateKeyToAccount(generatePrivateKey()); + const approver = privateKeyToAccount(generatePrivateKey()); + const unsigned = { + schemaVersion: 1 as const, + subject: subject(IP_A, creator.address), + organizationId: "example-org", + approverWallet: approver.address.toLowerCase() as `0x${string}`, + role: "ip_admin" as const, + approvedAt: NOW, + }; + const approval = { + ...unsigned, + statementHash: organizationStatementHash(unsigned), + signature: await approver.signMessage({ message: canonicalOrganizationStatement(unsigned) }), + }; + const trustedWallets = [unsigned.approverWallet] as const; + + Object.defineProperty(Object.prototype, "example-org", { + configurable: true, + enumerable: false, + value: trustedWallets, + }); + try { + await assert.rejects( + verifyOrganizationApproval(approval, {}), + /own property/, + ); + } finally { + delete (Object.prototype as Record)["example-org"]; + } + + const inherited = Object.create({ "example-org": trustedWallets }) as Record; + await assert.rejects( + verifyOrganizationApproval(approval, inherited), + /plain or null-prototype record/, + ); + + const exotic = Object.assign(Object.create({ unrelated: true }), { "example-org": trustedWallets }) as Record; + await assert.rejects( + verifyOrganizationApproval(approval, exotic), + /plain or null-prototype record/, + ); +}); + test("duplicate bytes under different wallets create a deterministic visible conflict", async () => { const first = privateKeyToAccount(generatePrivateKey()); const second = privateKeyToAccount(generatePrivateKey()); @@ -261,6 +308,57 @@ test("signed challenges, resolutions, and revocations preserve history", async ( assert.equal(state.events.length, 4); }); +test("admin events reject inherited signer entries and exotic trust-map prototypes", async () => { + const admin = privateKeyToAccount(generatePrivateKey()); + const eventBase = { + type: "challenge_resolved" as const, + eventId: "resolution-trust-map", + sequence: 1, + occurredAt: NOW, + conflictId: "conflict-trust-map", + outcome: "rejected" as const, + rationale: "The signed resolution is valid; only verifier trust is under test.", + adminSignerId: "admin-1", + statementHash: HASH_A, + signature: "0x00" as `0x${string}`, + }; + const statementHash = adminEventStatementHash(eventBase); + const event: ChallengeResolvedEvent = { + ...eventBase, + statementHash, + signature: await admin.signMessage({ + message: canonicalAdminEventStatement({ ...eventBase, statementHash }), + }), + }; + const trustedWallet = admin.address.toLowerCase() as `0x${string}`; + + Object.defineProperty(Object.prototype, "admin-1", { + configurable: true, + enumerable: false, + value: trustedWallet, + }); + try { + await assert.rejects( + verifyAdminEventSignature(event, {}), + /own property/, + ); + } finally { + delete (Object.prototype as Record)["admin-1"]; + } + + const inherited = Object.create({ "admin-1": trustedWallet }) as Record; + await assert.rejects( + verifyAdminEventSignature(event, inherited), + /plain or null-prototype record/, + ); + + const exotic = Object.assign(Object.create({ unrelated: true }), { "admin-1": trustedWallet }) as Record; + await assert.rejects( + verifyAdminEventSignature(event, exotic), + /plain or null-prototype record/, + ); +}); + test("sequence gaps, duplicate IDs, malformed normalized inputs, and overclaim text fail", async () => { const account = privateKeyToAccount(generatePrivateKey()); const base = subject(IP_A, account.address); @@ -293,14 +391,22 @@ test("human status output lists challenged registrations and every matching conf const second = privateKeyToAccount(generatePrivateKey()); const a = subject(IP_A, first.address); const b = subject(IP_B, second.address); - const index = await reduceAttestationEvents([], { baseSubjects: [b, a] }); + const forward = await reduceAttestationEvents([], { baseSubjects: [a, b] }); + const reverse = await reduceAttestationEvents([], { baseSubjects: [b, a] }); const conflictId = deterministicConflictId(a, b); - const lines = renderAttestationStatus(index, { artifactHash: a.artifactHash }); + const lines = renderAttestationStatus(forward, { artifactHash: a.artifactHash }); + const reverseLines = renderAttestationStatus(reverse, { artifactHash: a.artifactHash }); + assert.deepEqual(lines, reverseLines); + assert.deepEqual( + lines.filter((line) => line.startsWith("registration: ")), + [a.registrationId, b.registrationId].sort().map((id) => `registration: ${id}`), + ); assert.equal(lines.filter((line) => line === "status: challenged").length, 2); - assert.deepEqual(lines.slice(-7), [ + assert.deepEqual(lines.slice(-8), [ "conflicts: 1", `conflict: ${conflictId}`, + `conflict artifact hash: ${a.artifactHash}`, "conflict status: open", "conflict reason: duplicate_bytes", "conflict outcome: (none)", @@ -308,8 +414,22 @@ test("human status output lists challenged registrations and every matching conf "conflict events: (none)", ]); - const json = JSON.parse(renderAttestationStatus(index, { artifactHash: a.artifactHash, json: true })[0]); + const unsortedArrayIndex = { + ...forward, + conflicts: forward.conflicts.map((conflict) => ({ + ...conflict, + registrationIds: [...conflict.registrationIds].reverse(), + eventIds: ["event-z", "event-a"], + })), + }; + const unsortedArrayLines = renderAttestationStatus(unsortedArrayIndex, { artifactHash: a.artifactHash }); + assert.ok(unsortedArrayLines.includes( + `conflict registrations: ${[a.registrationId, b.registrationId].sort().join(", ")}`, + )); + assert.ok(unsortedArrayLines.includes("conflict events: event-a, event-z")); + + const json = JSON.parse(renderAttestationStatus(forward, { artifactHash: a.artifactHash, json: true })[0]); assert.equal(json.registrations.length, 2); assert.ok(json.registrations.every((registration: { status: string }) => registration.status === "challenged")); - assert.deepEqual(json.conflicts, index.conflicts); + assert.deepEqual(json.conflicts, forward.conflicts); }); From 80a608daf06da163c3ec2c9a0cc3ec18bce99443 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 05:33:40 -0400 Subject: [PATCH 107/165] fix: harden Wielder payment policy correctness --- spikes/pi-wielder/src/payment-policy.mjs | 67 +++-- spikes/pi-wielder/src/proxy.mjs | 11 +- spikes/pi-wielder/tests/paying-fetch.test.mjs | 246 +++++++++++++++++- .../pi-wielder/tests/payment-policy.test.mjs | 96 +++++++ 4 files changed, 398 insertions(+), 22 deletions(-) diff --git a/spikes/pi-wielder/src/payment-policy.mjs b/spikes/pi-wielder/src/payment-policy.mjs index 25c42f9..115f4bd 100644 --- a/spikes/pi-wielder/src/payment-policy.mjs +++ b/spikes/pi-wielder/src/payment-policy.mjs @@ -405,8 +405,10 @@ export function createPaymentPolicy(config) { const issuedAtMs = canonicalTimestamp(candidate.extra.issuedAt, 'issuedAt'); const expiresAtMs = canonicalTimestamp(candidate.extra.expiresAt, 'expiresAt'); const receivedAtMs = receivedAt.receivedAtMs; + const validationTimeMs = trustedNow(); if (issuedAtMs > receivedAtMs || receivedAtMs - issuedAtMs > config.maxQuoteAgeMs - || expiresAtMs <= receivedAtMs || issuedAtMs >= expiresAtMs) { + || expiresAtMs <= receivedAtMs || expiresAtMs <= validationTimeMs + || issuedAtMs >= expiresAtMs) { fail('QUOTE_EXPIRY', 'x402 quote is stale, future-issued, expired, or inverted'); } const amount = canonicalAtomic(candidate.maxAmountRequired, 'maxAmountRequired'); @@ -502,34 +504,44 @@ export function createPaymentPolicy(config) { } function releaseUnsigned(authorizationId, input) { - exactObject(input, ['reasonCode'], 'UNSIGNED_RELEASE_SCHEMA', 'unsigned release'); + const transition = frozenCopy(exactObject( + input, ['reasonCode'], 'UNSIGNED_RELEASE_SCHEMA', 'unsigned release', + )); + const reasonCode = canonicalReason(transition.reasonCode); const record = get(authorizationId); if (!['reserved', 'signing'].includes(record.state)) { fail('UNSIGNED_RELEASE_STATE', 'only an authorization that cannot have produced a signature may be released'); } reservedAtomic -= BigInt(record.amountAtomic); record.state = 'released'; - record.reasonCode = canonicalReason(input.reasonCode); + record.reasonCode = reasonCode; return publicRecord(record); } function markPotentiallySigned(authorizationId, input) { - exactObject(input, ['reasonCode'], 'UNRESOLVED_SCHEMA', 'potential signature result'); + const transition = frozenCopy(exactObject( + input, ['reasonCode'], 'UNRESOLVED_SCHEMA', 'potential signature result', + )); + const reasonCode = canonicalReason(transition.reasonCode); const record = get(authorizationId); if (record.state !== 'signing') fail('POTENTIAL_SIGNATURE_STATE', 'potential signature requires a signing claim'); record.state = 'unresolved'; - record.reasonCode = canonicalReason(input.reasonCode); + record.reasonCode = reasonCode; return publicRecord(record); } function persistSignedAuthorization(authorizationId, input) { - exactObject(input, ['authorization', 'signature', 'xPayment'], - 'SIGNED_AUTHORIZATION_SCHEMA', 'signed authorization'); + const transition = frozenCopy(exactObject( + input, + ['authorization', 'signature', 'xPayment'], + 'SIGNED_AUTHORIZATION_SCHEMA', + 'signed authorization', + )); const record = get(authorizationId); if (record.state !== 'signing') fail('SIGNED_STATE', 'signed authorization can only follow one signature claim'); - const authorization = frozenCopy(authorizationSchema(input.authorization)); - const signature = canonicalSignature(input.signature); - const envelope = paymentEnvelopeSchema(decodeCanonicalBase64Json(input.xPayment)); + const authorization = authorizationSchema(transition.authorization); + const signature = canonicalSignature(transition.signature); + const envelope = paymentEnvelopeSchema(decodeCanonicalBase64Json(transition.xPayment)); if (envelope.x402Version !== 1 || envelope.scheme !== 'exact' || envelope.network !== BASE_SEPOLIA_NETWORK || canonicalJson(envelope.payload.authorization) !== canonicalJson(authorization) @@ -549,7 +561,7 @@ export function createPaymentPolicy(config) { authorizationNonces.set(authorization.nonce, record.authorizationId); record.authorization = authorization; record.signature = signature; - record.xPayment = input.xPayment; + record.xPayment = transition.xPayment; record.state = 'signed'; record.reasonCode = null; return publicRecord(record); @@ -591,6 +603,17 @@ export function createPaymentPolicy(config) { return publicRecord(record); } + function assertAuthorizationFresh(authorizationId) { + const record = get(authorizationId); + const currentTimeMs = trustedNow(); + const expiresAtMs = canonicalTimestamp(record.offer.extra.expiresAt, 'expiresAt'); + const currentTimeSeconds = BigInt(Math.floor(currentTimeMs / 1_000)); + if (currentTimeMs >= expiresAtMs || currentTimeSeconds >= BigInt(record.validBefore)) { + fail('QUOTE_EXPIRY', 'x402 quote or signed authorization expired before the paid retry'); + } + return publicRecord(record); + } + function assertRetryChallenge(authorizationId, secondChallenge) { const record = get(authorizationId); let second; @@ -604,14 +627,17 @@ export function createPaymentPolicy(config) { } function markUnresolved(authorizationId, input) { - exactObject(input, ['reasonCode'], 'UNRESOLVED_SCHEMA', 'unresolved transition'); + const transition = frozenCopy(exactObject( + input, ['reasonCode'], 'UNRESOLVED_SCHEMA', 'unresolved transition', + )); + const reasonCode = canonicalReason(transition.reasonCode); const record = get(authorizationId); if (record.state === 'unresolved') return publicRecord(record); if (!['signing', 'signed', 'retrying'].includes(record.state)) { fail('UNRESOLVED_STATE', 'only a potentially signed authorization can become unresolved'); } record.state = 'unresolved'; - record.reasonCode = canonicalReason(input.reasonCode); + record.reasonCode = reasonCode; return publicRecord(record); } @@ -662,37 +688,39 @@ export function createPaymentPolicy(config) { } function verifierAccepted(verifier, record, proof) { - const result = verifier({ authorization: publicRecord(record), proof: frozenCopy(proof) }); + const result = verifier({ authorization: publicRecord(record), proof }); if (result && typeof result.then === 'function') fail('PROOF_ASYNC', 'proof verifier must be a synchronous trust capability'); return result === true; } function reconcileSettlement(authorizationId, proof) { + const capturedProof = frozenCopy(proof); + const evidence = reconciliationSchema(capturedProof, 'settled'); const record = get(authorizationId); if (!['signed', 'retrying', 'unresolved', 'settled'].includes(record.state)) { fail('RECONCILIATION_STATE', 'settlement reconciliation requires a signed authorization'); } - const evidence = reconciliationSchema(frozenCopy(proof), 'settled'); assertSettlementMatches(record, evidence, 'RECONCILIATION_MISMATCH'); - if (!verifierAccepted(verifySettlementProof, record, proof)) { + if (!verifierAccepted(verifySettlementProof, record, capturedProof)) { fail('SETTLEMENT_PROOF', 'trusted settlement proof verifier rejected evidence'); } return settle(record, evidence); } function reconcileRejection(authorizationId, proof) { + const capturedProof = frozenCopy(proof); + const evidence = reconciliationSchema(capturedProof, 'rejected'); const record = get(authorizationId); if (!['signed', 'retrying', 'unresolved'].includes(record.state)) { fail('RECONCILIATION_STATE', 'rejection reconciliation requires a nonterminal signed authorization'); } - const evidence = reconciliationSchema(frozenCopy(proof), 'rejected'); assertSettlementMatches(record, evidence, 'RECONCILIATION_MISMATCH'); - if (!verifierAccepted(verifyRejectionProof, record, proof)) { + if (!verifierAccepted(verifyRejectionProof, record, capturedProof)) { fail('REJECTION_PROOF', 'trusted rejection proof verifier rejected evidence'); } reservedAtomic -= BigInt(record.amountAtomic); record.state = 'rejected'; - record.reasonCode = proof.reasonCode; + record.reasonCode = capturedProof.reasonCode; return publicRecord(record); } @@ -719,6 +747,7 @@ export function createPaymentPolicy(config) { markPotentiallySigned, persistSignedAuthorization, recoverSignedAuthorization, + assertAuthorizationFresh, beginRetry, assertRetryChallenge, markUnresolved, diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index ef0bbdf..0069329 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -140,7 +140,7 @@ export async function payingFetch(account, url, init, options = {}) { 'Idempotency-Key': idempotencyKey, }; const t0 = performance.now(); - const first = await fetchImpl(url, { ...init, headers: requestHeaders }); + const first = await fetchImpl(url, { ...init, redirect: 'error', headers: requestHeaders }); if (first.status !== 402) return { res: first, paid: false, idempotencyKey }; const receivedAt = paymentPolicy.captureReceivedAt(); const ms402 = performance.now() - t0; @@ -260,6 +260,14 @@ export async function payingFetch(account, url, init, options = {}) { ); } + try { + signedRecord = paymentPolicy.assertAuthorizationFresh(idempotencyKey); + } catch (error) { + paymentPolicy.markUnresolved(idempotencyKey, { + reasonCode: 'AUTHORIZATION_EXPIRED_BEFORE_RETRY', + }); + throw error; + } const { authorization, xPayment } = signedRecord; paymentPolicy.beginRetry(idempotencyKey); const tRetry = performance.now(); @@ -267,6 +275,7 @@ export async function payingFetch(account, url, init, options = {}) { try { res = await fetchImpl(url, { ...init, + redirect: 'error', headers: { ...requestHeaders, 'X-PAYMENT': xPayment }, }); } catch (error) { diff --git a/spikes/pi-wielder/tests/paying-fetch.test.mjs b/spikes/pi-wielder/tests/paying-fetch.test.mjs index cd642a6..72ed5be 100644 --- a/spikes/pi-wielder/tests/paying-fetch.test.mjs +++ b/spikes/pi-wielder/tests/paying-fetch.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import http from 'node:http'; import test from 'node:test'; import { @@ -43,11 +44,38 @@ function baseOffer(overrides = {}) { } function challenge(candidate = baseOffer()) { - return new Response(JSON.stringify({ + return new Response(JSON.stringify(challengePayload(candidate)), { + status: 402, headers: { 'content-type': 'application/json' }, + }); +} + +function challengePayload(candidate = baseOffer()) { + return { x402Version: 1, error: 'X-PAYMENT header is required', accepts: [candidate], - }), { status: 402, headers: { 'content-type': 'application/json' } }); + }; +} + +function listenLoopback(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); +} + +function closeServer(server) { + if (!server.listening) return Promise.resolve(); + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +async function readNodeRequest(req) { + let body = ''; + req.setEncoding('utf8'); + for await (const chunk of req) body += chunk; + return body; } function decodePayment(init) { @@ -156,6 +184,30 @@ test('freshness is captured from the injected clock immediately after the first }), (error) => error.code === 'PAYING_FETCH_OPTIONS'); }); +test('a quote expiring while the first 402 JSON is parsed is rejected before signing', async () => { + const { account, paymentPolicy, setClock, signatureCount } = setup(); + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { + fetches += 1; + if (fetches > 1) throw new Error('paid retry must not start for an expired quote'); + return { + status: 402, + async json() { + setClock(NOW + 59_000); + return challengePayload(); + }, + }; + }, + idempotencyKey: 'idem-expired-during-parse', + paymentPolicy, + }), (error) => error.code === 'QUOTE_EXPIRY'); + assert.equal(fetches, 1); + assert.equal(signatureCount(), 0); + assert.deepEqual(paymentPolicy.snapshot().authorizations, []); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); +}); + test('caller-supplied payment and idempotency headers are rejected case-insensitively before fetch', async () => { for (const headers of [ { 'idempotency-key': 'caller-owned' }, @@ -175,6 +227,140 @@ test('caller-supplied payment and idempotency headers are rejected case-insensit } }); +test('native unpaid fetch refuses redirects even when caller asks to follow', async (t) => { + const redirectedRequests = []; + const redirectTarget = http.createServer(async (req, res) => { + redirectedRequests.push({ + body: await readNodeRequest(req), + payment: req.headers['x-payment'] ?? null, + }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"redirected":true}'); + }); + await listenLoopback(redirectTarget); + const redirectTargetUrl = `http://127.0.0.1:${redirectTarget.address().port}/capture`; + const seller = http.createServer(async (req, res) => { + await readNodeRequest(req); + res.writeHead(307, { location: redirectTargetUrl }); + res.end(); + }); + await listenLoopback(seller); + t.after(async () => { + await closeServer(seller); + await closeServer(redirectTarget); + }); + + const requestUrl = `http://127.0.0.1:${seller.address().port}/invoke/skill-a`; + const now = Date.now(); + let signatures = 0; + const account = { + address: PAYER, + async signTypedData() { signatures += 1; return SIGNATURE; }, + }; + const paymentPolicy = createPaymentPolicy({ + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + sessionBudgetAtomic: '500000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + now: () => now, + sellers: [{ + origin: new globalThis.URL(requestUrl).origin, + pathPrefix: '/invoke/', + payTo: PAYEE, + maxPerCallAtomic: '300000', + }], + }); + + await assert.rejects(() => payingFetch(account, requestUrl, { + method: 'POST', body: BODY, redirect: 'follow', + }, { + idempotencyKey: 'idem-unpaid-redirect', paymentPolicy, + })); + assert.equal(redirectedRequests.length, 0); + assert.equal(signatures, 0); + assert.equal(paymentPolicy.snapshot().authorizations.length, 0); +}); + +test('native paid retry refuses redirects without forwarding payment header or body', async (t) => { + const redirectedRequests = []; + const redirectTarget = http.createServer(async (req, res) => { + redirectedRequests.push({ + body: await readNodeRequest(req), + payment: req.headers['x-payment'] ?? null, + }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"redirected":true}'); + }); + await listenLoopback(redirectTarget); + const redirectTargetUrl = `http://127.0.0.1:${redirectTarget.address().port}/capture`; + let requestUrl; + let firstChallenge; + let sellerRequests = 0; + const seller = http.createServer(async (req, res) => { + await readNodeRequest(req); + sellerRequests += 1; + if (sellerRequests === 1) { + res.writeHead(402, { 'content-type': 'application/json' }); + res.end(JSON.stringify(firstChallenge)); + return; + } + res.writeHead(307, { location: redirectTargetUrl }); + res.end(); + }); + await listenLoopback(seller); + t.after(async () => { + await closeServer(seller); + await closeServer(redirectTarget); + }); + + requestUrl = `http://127.0.0.1:${seller.address().port}/invoke/skill-a`; + const now = Date.now(); + const localOffer = { + ...baseOffer(), + resource: requestUrl, + extra: { + ...baseOffer().extra, + requestHash: canonicalRequestHash({ method: 'POST', requestUrl, bodyBytes: BODY }), + issuedAt: new Date(now - 1_000).toISOString(), + expiresAt: new Date(now + 59_000).toISOString(), + }, + }; + firstChallenge = challengePayload(localOffer); + let signatures = 0; + const account = { + address: PAYER, + async signTypedData() { signatures += 1; return SIGNATURE; }, + }; + const paymentPolicy = createPaymentPolicy({ + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + sessionBudgetAtomic: '500000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + now: () => now, + sellers: [{ + origin: new globalThis.URL(requestUrl).origin, + pathPrefix: '/invoke/', + payTo: PAYEE, + maxPerCallAtomic: '300000', + }], + }); + + await assert.rejects(() => payingFetch(account, requestUrl, { + method: 'POST', body: BODY, redirect: 'follow', + }, { + idempotencyKey: 'idem-paid-redirect', paymentPolicy, + })); + assert.equal(sellerRequests, 2); + assert.equal(redirectedRequests.length, 0); + assert.equal(signatures, 1); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); +}); + test('a changed second offer gets no second signature or third request and holds budget', async () => { const { account, paymentPolicy, signatureCount } = setup(); const responses = [challenge(), challenge(baseOffer({ maxAmountRequired: '260000' }))]; @@ -349,6 +535,28 @@ test('an invalid signer return is potentially signed and never releases budget', assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); }); +test('a quote expiring after signature persistence never starts the paid retry', async () => { + const { account, paymentPolicy, setClock, signatureCount } = setup(); + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { + fetches += 1; + if (fetches === 1) return challenge(); + throw new Error('paid retry must not start after authorization expiry'); + }, + idempotencyKey: 'idem-expired-after-signing', + paymentPolicy, + onSignedAuthorizationPersisted: ({ authorization: persisted }) => { + assert.equal(persisted.state, 'signed'); + setClock(NOW + 59_000); + }, + }), (error) => error.code === 'QUOTE_EXPIRY'); + assert.equal(fetches, 1); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); +}); + test('a fault after synchronous signature persistence recovers exact X-PAYMENT without signing again', async () => { const { account, paymentPolicy, signatureCount } = setup(); let firstFetches = 0; @@ -380,6 +588,40 @@ test('a fault after synchronous signature persistence recovers exact X-PAYMENT w assert.equal(retryPayment, result.xPayment); }); +test('signed recovery rechecks expiry and holds the reservation without a retry', async () => { + let clockReads = 0; + const { account, paymentPolicy, signatureCount } = setup({ + policy: { + now: () => { + clockReads += 1; + return clockReads >= 5 ? NOW + 59_000 : NOW; + }, + }, + }); + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => challenge(), + idempotencyKey: 'idem-expired-recovery', + paymentPolicy, + onSignedAuthorizationPersisted: () => { throw new Error('synthetic process interruption'); }, + }), /synthetic process interruption/); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'signed'); + + let recoveryFetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { + recoveryFetches += 1; + if (recoveryFetches > 1) throw new Error('expired recovery must not start a paid retry'); + return challenge(); + }, + idempotencyKey: 'idem-expired-recovery', + paymentPolicy, + }), (error) => error.code === 'QUOTE_EXPIRY'); + assert.equal(recoveryFetches, 1); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); +}); + test('retry transport loss leaves the signed amount unresolved and never retries internally', async () => { const { account, paymentPolicy, signatureCount } = setup(); let fetches = 0; diff --git a/spikes/pi-wielder/tests/payment-policy.test.mjs b/spikes/pi-wielder/tests/payment-policy.test.mjs index 32718a8..ab48ab9 100644 --- a/spikes/pi-wielder/tests/payment-policy.test.mjs +++ b/spikes/pi-wielder/tests/payment-policy.test.mjs @@ -294,6 +294,32 @@ test('authorization, signature, and encoded payment are exact, immutable, and co }), (error) => ['SIGNED_STATE', 'AUTHORIZATION_SCHEMA'].includes(error.code)); }); +test('signed authorization transition captures accessor-backed fields exactly once', () => { + const subject = policy(); + const record = reserve(subject); + subject.claimSignature('auth-1', { offerFingerprint: record.offerFingerprint }); + const auth = authorization(record); + const encoded = encodePayment(record, auth); + let paymentReads = 0; + const input = { + authorization: auth, + signature: SIGNATURE, + }; + Object.defineProperty(input, 'xPayment', { + enumerable: true, + get() { + paymentReads += 1; + if (paymentReads > 1) throw new Error('raw xPayment was reread'); + return encoded; + }, + }); + + const persisted = subject.persistSignedAuthorization('auth-1', input); + assert.equal(paymentReads, 1); + assert.equal(persisted.xPayment, encoded); + assert.equal(subject.snapshot().authorizations[0].state, 'signed'); +}); + test('unsigned signer rejection releases exactly once but a potentially produced signature holds budget', () => { const unsigned = policy(); const first = reserve(unsigned); @@ -313,6 +339,40 @@ test('unsigned signer rejection releases exactly once but a potentially produced (error) => error.code === 'UNSIGNED_RELEASE_STATE'); }); +test('invalid transition inputs and throwing accessors leave policy snapshots unchanged', () => { + const transitions = [ + ['releaseUnsigned', (subject) => { + const record = reserve(subject); + subject.claimSignature('auth-1', { offerFingerprint: record.offerFingerprint }); + }], + ['markPotentiallySigned', (subject) => { + const record = reserve(subject); + subject.claimSignature('auth-1', { offerFingerprint: record.offerFingerprint }); + }], + ['markUnresolved', (subject) => { + sign(subject); + subject.beginRetry('auth-1'); + }], + ]; + const inputs = [ + () => ({ reasonCode: 'not-canonical' }), + () => Object.defineProperty({}, 'reasonCode', { + enumerable: true, + get() { throw new Error('synthetic reason accessor failure'); }, + }), + ]; + + for (const [method, arrange] of transitions) { + for (const makeInput of inputs) { + const subject = policy(); + arrange(subject); + const before = subject.snapshot(); + assert.throws(() => subject[method]('auth-1', makeInput())); + assert.deepEqual(subject.snapshot(), before); + } + } +}); + test('exact persisted signed authorization is recoverable without a replacement signature', () => { const subject = policy(); const { xPayment } = sign(subject); @@ -419,6 +479,42 @@ test('trusted proof capability cannot authorize mismatched request data', () => assert.equal(subject.snapshot().spentAtomic, '0'); }); +test('trusted reconciliation captures accessor-backed proof fields exactly once', () => { + let reasonReads = 0; + let verifiedProof = null; + const subject = policy({ + verifyRejectionProof: ({ proof }) => { + verifiedProof = proof; + return proof.trustToken === 'trusted-rejection'; + }, + }); + sign(subject); + subject.beginRetry('auth-1'); + subject.markUnresolved('auth-1', { reasonCode: 'SETTLEMENT_EVIDENCE_INVALID' }); + const proof = { + ...settlementEvidence(subject), + success: false, + transaction: null, + outcome: 'rejected', + trustToken: 'trusted-rejection', + }; + Object.defineProperty(proof, 'reasonCode', { + enumerable: true, + get() { + reasonReads += 1; + if (reasonReads > 1) throw new Error('raw reconciliation proof was reread'); + return 'CHAIN_REJECTED'; + }, + }); + + subject.reconcileRejection('auth-1', proof); + assert.equal(reasonReads, 1); + assert.equal(Object.isFrozen(verifiedProof), true); + assert.equal(verifiedProof.reasonCode, 'CHAIN_REJECTED'); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().authorizations[0].reasonCode, 'CHAIN_REJECTED'); +}); + test('one EIP-3009 nonce cannot be persisted under two authorizations', () => { const subject = policy(); sign(subject, reserve(subject, { authorizationId: 'auth-nonce-1' })); From 868026c50a2e7549d0d18ff185a72854b87d1596 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 05:52:27 -0400 Subject: [PATCH 108/165] fix: harden attestation output and commit OIDs --- phase0/src/attestation-cli.ts | 38 ++++++++++++- phase0/src/attestation-git.ts | 31 +++++++++-- phase0/src/attestations.ts | 2 +- phase0/tests/attestation-git.test.ts | 50 ++++++++++++++++- phase0/tests/attestations.test.ts | 83 +++++++++++++++++++++++++++- 5 files changed, 192 insertions(+), 12 deletions(-) diff --git a/phase0/src/attestation-cli.ts b/phase0/src/attestation-cli.ts index de9e51f..3075175 100644 --- a/phase0/src/attestation-cli.ts +++ b/phase0/src/attestation-cli.ts @@ -243,6 +243,40 @@ function statusPayload(index: AttestationIndex, options: AttestationCommandOptio }; } +function quoteHumanIdentifier(value: string): string { + let quoted = '"'; + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit === 0x22) { + quoted += '\\"'; + } else if (codeUnit === 0x5c) { + quoted += "\\\\"; + } else if ( + codeUnit <= 0x1f + || (codeUnit >= 0x7f && codeUnit <= 0x9f) + || codeUnit === 0x061c + || codeUnit === 0x200e + || codeUnit === 0x200f + || (codeUnit >= 0x2028 && codeUnit <= 0x202e) + || (codeUnit >= 0x2066 && codeUnit <= 0x2069) + || (codeUnit >= 0xd800 && codeUnit <= 0xdfff + && !(codeUnit <= 0xdbff + && index + 1 < value.length + && value.charCodeAt(index + 1) >= 0xdc00 + && value.charCodeAt(index + 1) <= 0xdfff)) + ) { + quoted += `\\u${codeUnit.toString(16).padStart(4, "0")}`; + } else { + quoted += value[index]; + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + index += 1; + quoted += value[index]; + } + } + } + return `${quoted}"`; +} + export function renderAttestationStatus(index: AttestationIndex, options: AttestationCommandOptions = {}): string[] { const payload = statusPayload(index, options); if (options.json) return [JSON.stringify(payload, null, 2)]; @@ -260,13 +294,13 @@ export function renderAttestationStatus(index: AttestationIndex, options: Attest lines.push(`conflicts: ${payload.conflicts.length}`); for (const conflictValue of payload.conflicts) { const conflict = conflictValue as AttestationIndex["conflicts"][number]; - lines.push(`conflict: ${conflict.conflictId}`); + lines.push(`conflict: ${quoteHumanIdentifier(conflict.conflictId)}`); lines.push(`conflict artifact hash: ${conflict.artifactHash ?? "(none)"}`); lines.push(`conflict status: ${conflict.status}`); lines.push(`conflict reason: ${conflict.reason}`); lines.push(`conflict outcome: ${conflict.outcome ?? "(none)"}`); lines.push(`conflict registrations: ${[...conflict.registrationIds].sort().join(", ")}`); - lines.push(`conflict events: ${conflict.eventIds.length > 0 ? [...conflict.eventIds].sort().join(", ") : "(none)"}`); + lines.push(`conflict events: ${conflict.eventIds.length > 0 ? [...conflict.eventIds].sort().map(quoteHumanIdentifier).join(", ") : "(none)"}`); } return lines; } diff --git a/phase0/src/attestation-git.ts b/phase0/src/attestation-git.ts index 193a6b5..36415c0 100644 --- a/phase0/src/attestation-git.ts +++ b/phase0/src/attestation-git.ts @@ -88,10 +88,12 @@ function validRepositoryPath(value: string): void { } } -function validObjectName(value: string, label: string): void { - if (!/^(?:[0-9a-f]{40,64}|refs\/(?:heads|remotes)\/[A-Za-z0-9._\/-]+)$/.test(value) || value.includes("..")) { +function validObjectName(value: string, label: string): "commit" | "ref" { + if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value)) return "commit"; + if (!/^refs\/(?:heads|remotes)\/[A-Za-z0-9._\/-]+$/.test(value) || value.includes("..")) { throw new Error(`${label} is not a full commit OID or configured trusted ref`); } + return "ref"; } function validRelativePath(value: string): void { @@ -115,6 +117,17 @@ export class ExecGitReader implements GitReader { return runGit(this.gitExecutable, args); } + private async resolvesToExactCommitOid(repositoryPath: string, commitOid: string): Promise { + try { + const resolved = await this.run(["-C", repositoryPath, "rev-parse", "--verify", `${commitOid}^{commit}`]); + return Buffer.from(resolved).toString("ascii").trim() === commitOid; + } catch (error) { + const cause = (error as Error & { cause?: { code?: string | number } }).cause; + if (cause && typeof cause.code === "number") return false; + throw error; + } + } + async repositoryIdentity(repositoryPath: string): Promise<{ device: number; inode: number }> { validRepositoryPath(repositoryPath); const handle = await open(repositoryPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); @@ -131,7 +144,8 @@ export class ExecGitReader implements GitReader { async commitExists(repositoryPath: string, commitSha: string): Promise { validRepositoryPath(repositoryPath); - validObjectName(commitSha, "Git commit"); + const kind = validObjectName(commitSha, "Git commit"); + if (kind === "commit") return this.resolvesToExactCommitOid(repositoryPath, commitSha); try { await this.run(["-C", repositoryPath, "cat-file", "-e", `${commitSha}^{commit}`]); return true; @@ -144,15 +158,20 @@ export class ExecGitReader implements GitReader { async readBlob(repositoryPath: string, commitSha: string, relativePath: string): Promise { validRepositoryPath(repositoryPath); - validObjectName(commitSha, "Git commit"); + const kind = validObjectName(commitSha, "Git commit"); + if (kind !== "commit" || !await this.resolvesToExactCommitOid(repositoryPath, commitSha)) { + throw new Error("Git blob commit is absent or not an exact full commit OID for this repository"); + } validRelativePath(relativePath); return this.run(["-C", repositoryPath, "show", `${commitSha}:${relativePath}`]); } async isAncestor(repositoryPath: string, ancestor: string, descendant: string): Promise { validRepositoryPath(repositoryPath); - validObjectName(ancestor, "Git ancestor"); - validObjectName(descendant, "Git descendant"); + const ancestorKind = validObjectName(ancestor, "Git ancestor"); + const descendantKind = validObjectName(descendant, "Git descendant"); + if (ancestorKind === "commit" && !await this.resolvesToExactCommitOid(repositoryPath, ancestor)) return false; + if (descendantKind === "commit" && !await this.resolvesToExactCommitOid(repositoryPath, descendant)) return false; try { await this.run(["-C", repositoryPath, "merge-base", "--is-ancestor", ancestor, descendant]); return true; diff --git a/phase0/src/attestations.ts b/phase0/src/attestations.ts index b9779ff..cbb9b02 100644 --- a/phase0/src/attestations.ts +++ b/phase0/src/attestations.ts @@ -156,7 +156,7 @@ export interface AttestationIndex { const ADDRESS = /^0x[0-9a-f]{40}$/; const HASH = /^0x[0-9a-f]{64}$/; -const COMMIT = /^[0-9a-f]{40,64}$/; +const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const NONCE = /^0x[0-9a-f]{64}$/; const HEX_SIGNATURE = /^0x(?:[0-9a-fA-F]{2})+$/; const REGISTRATION_ID = /^eip155:1315:0x[0-9a-f]{40}$/; diff --git a/phase0/tests/attestation-git.test.ts b/phase0/tests/attestation-git.test.ts index e433c0f..362d516 100644 --- a/phase0/tests/attestation-git.test.ts +++ b/phase0/tests/attestation-git.test.ts @@ -3,7 +3,7 @@ import { createHash, generateKeyPairSync, sign as signBytes } from "node:crypto" import { chmod, mkdir, mkdtemp, realpath, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import test from "node:test"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; @@ -345,6 +345,54 @@ test("ExecGitReader requires an absolute verifier-controlled executable", () => assert.throws(() => new ExecGitReader({ gitExecutable: "git" }), /absolute Git executable/); }); +test("ExecGitReader rejects 41- and 63-character hexadecimal object names on every commit path", async (t) => { + const f = await fixture(t); + const reader = new ExecGitReader(); + for (const length of [41, 63]) { + const invalid = "a".repeat(length); + await assert.rejects(reader.commitExists(f.root, invalid), /not a full commit OID/); + await assert.rejects(reader.readBlob(f.root, invalid, "skills/demo/SKILL.md"), /not a full commit OID/); + await assert.rejects(reader.isAncestor(f.root, invalid, f.proofCommit), /not a full commit OID/); + await assert.rejects(reader.isAncestor(f.root, f.artifactCommit, invalid), /not a full commit OID/); + } +}); + +test("ExecGitReader requires the exact fully resolved OID in a SHA-256 repository", async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), "phase0-attestation-git-sha256-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const initialized = spawnSync("/usr/bin/git", ["-C", root, "init", "--object-format=sha256", "-b", "main"], { + encoding: "utf8", + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" }, + }); + if (initialized.status !== 0) { + const detail = `${initialized.stdout}\n${initialized.stderr}`; + if (/unknown option.*object-format|unknown hash algorithm.*sha256|unsupported.*sha256|sha256.*not supported/i.test(detail)) { + t.skip("installed Git does not support SHA-256 repositories"); + return; + } + assert.fail(`SHA-256 repository initialization failed unexpectedly: ${detail}`); + } + + git(root, "config", "user.name", "SHA-256 Test"); + git(root, "config", "user.email", "sha256@example.invalid"); + await writeFile(join(root, "artifact.txt"), "sha256 repository artifact\n"); + git(root, "add", "artifact.txt"); + git(root, "commit", "-m", "add SHA-256 artifact"); + const fullOid = git(root, "rev-parse", "HEAD"); + assert.equal(fullOid.length, 64); + const prefix40 = fullOid.slice(0, 40); + const prefix41 = fullOid.slice(0, 41); + const reader = new ExecGitReader(); + + assert.equal(await reader.commitExists(root, fullOid), true); + assert.equal(await reader.commitExists(root, prefix40), false); + await assert.rejects(reader.commitExists(root, prefix41), /not a full commit OID/); + await assert.rejects(reader.readBlob(root, prefix40, "artifact.txt"), /exact full commit OID/); + await assert.rejects(reader.readBlob(root, prefix41, "artifact.txt"), /not a full commit OID/); + assert.equal(await reader.isAncestor(root, prefix40, fullOid), false); + await assert.rejects(reader.isAncestor(root, prefix41, fullOid), /not a full commit OID/); +}); + test("missing partial-clone objects fail without invoking a remote helper", async (t) => { const f = await fixture(t); const blobOid = git(f.root, "rev-parse", `${f.artifactCommit}:skills/demo/SKILL.md`); diff --git a/phase0/tests/attestations.test.ts b/phase0/tests/attestations.test.ts index 37baebf..df1c1cf 100644 --- a/phase0/tests/attestations.test.ts +++ b/phase0/tests/attestations.test.ts @@ -14,6 +14,8 @@ import { displayAttestation, organizationStatementHash, parseAttestationEvent, + parseForgeObservation, + parseRepositoryChallenge, registrationSubjectsFromManifest, reduceAttestationEvents, repositoryStatementHash, @@ -375,6 +377,35 @@ test("sequence gaps, duplicate IDs, malformed normalized inputs, and overclaim t assert.doesNotMatch(rendered, /authored by|safe skill|proves originality|proves safety/); }); +test("repository attestations accept only exact 40- or 64-character lowercase commit OIDs", () => { + const account = privateKeyToAccount(generatePrivateKey()); + const challenge = challengeFor(subject(IP_A, account.address)); + const observation = { + schemaVersion: 1 as const, + repositoryId: "demo", + repositoryUrl: challenge.repositoryUrl, + trustedRef: "refs/heads/main" as const, + proofCommitSha: "2".repeat(40), + challengeNonce: challenge.nonce, + observedAt: "2026-07-18T11:00:00.000Z", + forgeSignerId: "forge-1", + signature: "test-signature", + }; + + for (const length of [41, 63]) { + assert.throws( + () => parseRepositoryChallenge({ ...challenge, artifactCommitSha: "a".repeat(length) }), + /lowercase full commit SHA/, + ); + assert.throws( + () => parseForgeObservation({ ...observation, proofCommitSha: "b".repeat(length) }), + /lowercase full commit SHA/, + ); + } + assert.equal(parseRepositoryChallenge({ ...challenge, artifactCommitSha: "a".repeat(64) }).artifactCommitSha.length, 64); + assert.equal(parseForgeObservation({ ...observation, proofCommitSha: "b".repeat(64) }).proofCommitSha.length, 64); +}); + test("human status output uses explicit evidence-level language", async () => { const account = privateKeyToAccount(generatePrivateKey()); const base = subject(IP_A, account.address); @@ -386,6 +417,54 @@ test("human status output uses explicit evidence-level language", async () => { assert.match(output, /warning: registration does not prove authorship, originality, legal ownership, or safety/); }); +test("valid signed challenge identifiers cannot forge human status lines or terminal controls", async () => { + const challenged = privateKeyToAccount(generatePrivateKey()); + const challenger = privateKeyToAccount(generatePrivateKey()); + const a = subject(IP_A, challenged.address, HASH_A); + const b = subject(IP_B, challenger.address, HASH_B); + const conflictId = "conflict\"\\\rstatus: forged\u0000\u001b\u007f\u0085\u009f\u061c\u200e\u200f\u2028\u2029\u202a\u202e\u2066\u2069"; + const eventId = "event\"\\\nwarning: forged\u0001\u001b\u0080\u009b\u2028\u2029\u202d\u2067"; + const unsigned = { + type: "challenge_opened" as const, + eventId, + sequence: 1, + occurredAt: NOW, + conflictId, + challengedRegistrationId: a.registrationId, + challengerRegistrationId: b.registrationId, + challengerWallet: b.wallet, + evidenceUris: ["https://example.com/signed-evidence"], + reason: "misattributed_creator" as const, + statementHash: HASH_A, + signature: "0x00" as `0x${string}`, + }; + const statementHash = challengeEventStatementHash(unsigned); + const event: ChallengeOpenedEvent = { + ...unsigned, + statementHash, + signature: await challenger.signMessage({ + message: canonicalChallengeEventStatement({ ...unsigned, statementHash }), + }), + }; + const index = await reduceAttestationEvents([event], { baseSubjects: [a, b] }); + + const lines = renderAttestationStatus(index, { registrationId: a.registrationId }); + const renderedConflict = lines.find((line) => line.startsWith("conflict: ")); + const renderedEvents = lines.find((line) => line.startsWith("conflict events: ")); + assert.ok(renderedConflict?.startsWith('conflict: "')); + assert.ok(renderedConflict?.endsWith('"')); + assert.ok(renderedEvents?.startsWith('conflict events: "')); + for (const escaped of ["\\\"", "\\\\", "\\u0000", "\\u001b", "\\u007f", "\\u0085", "\\u009f", "\\u061c", "\\u200e", "\\u200f", "\\u2028", "\\u2029", "\\u202a", "\\u202e", "\\u2066", "\\u2069"]) { + assert.match(renderedConflict ?? "", new RegExp(escaped.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"))); + } + assert.ok(lines.every((line) => !/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/u.test(line))); + assert.equal(lines.filter((line) => line.startsWith("status: forged") || line.startsWith("warning: forged")).length, 0); + + const json = JSON.parse(renderAttestationStatus(index, { registrationId: a.registrationId, json: true })[0]); + assert.equal(json.conflicts[0].conflictId, conflictId); + assert.deepEqual(json.conflicts[0].eventIds, [eventId]); +}); + test("human status output lists challenged registrations and every matching conflict deterministically", async () => { const first = privateKeyToAccount(generatePrivateKey()); const second = privateKeyToAccount(generatePrivateKey()); @@ -405,7 +484,7 @@ test("human status output lists challenged registrations and every matching conf assert.equal(lines.filter((line) => line === "status: challenged").length, 2); assert.deepEqual(lines.slice(-8), [ "conflicts: 1", - `conflict: ${conflictId}`, + `conflict: "${conflictId}"`, `conflict artifact hash: ${a.artifactHash}`, "conflict status: open", "conflict reason: duplicate_bytes", @@ -426,7 +505,7 @@ test("human status output lists challenged registrations and every matching conf assert.ok(unsortedArrayLines.includes( `conflict registrations: ${[a.registrationId, b.registrationId].sort().join(", ")}`, )); - assert.ok(unsortedArrayLines.includes("conflict events: event-a, event-z")); + assert.ok(unsortedArrayLines.includes('conflict events: "event-a", "event-z"')); const json = JSON.parse(renderAttestationStatus(forward, { artifactHash: a.artifactHash, json: true })[0]); assert.equal(json.registrations.length, 2); From ff5050b6bdcdfb35380088267fcb40393f2a0ea0 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 06:10:44 -0400 Subject: [PATCH 109/165] fix: complete internal award acceptance contract --- spikes/internal-invocation-awards/README.md | 36 +- .../internal-invocation-awards/src/budget.mjs | 18 +- .../internal-invocation-awards/src/engine.mjs | 240 +++++++++- .../src/statements.mjs | 39 +- .../test/engine.test.mjs | 416 +++++++++++++++++- .../test/statements.test.mjs | 48 +- 6 files changed, 752 insertions(+), 45 deletions(-) diff --git a/spikes/internal-invocation-awards/README.md b/spikes/internal-invocation-awards/README.md index b41c871..27d5292 100644 --- a/spikes/internal-invocation-awards/README.md +++ b/spikes/internal-invocation-awards/README.md @@ -79,8 +79,14 @@ remains earned and non-payable for that period. A payable advance must reference same authenticated receipt through a later statement's historical-receipt set and prior signed statement chain; a current-period receipt cannot be advanced or paid early. Receipt creation and verification also require `occurredAt` to fall within the -receipt's declared period, so a late Execution cannot be backdated into an earlier -month and immediately treated as historical. +receipt's explicit recognition `period`. Each receipt separately binds the original +`budgetPeriod`. A success or known failure can finalize normally only when those +periods match, so a late Execution cannot be backdated into an earlier month and +immediately treated as historical. A reserved authorization may still be cancelled +after policy/budget expiry: the engine re-verifies the immutable signed historical +authorization, releases only that existing reservation, and signs the cancellation +receipt at the real later clock time. No new authorization or Execution may start +after expiry. The engine is provisioned with an immutable `(skillId, skillVersionHash)` registration map that binds the canonical Creator and employer. Missing, expired, revoked, @@ -93,10 +99,13 @@ Invocation bindings prevent replay even when many employees share one agent Wiel The store is a serialized, single-process CAS demonstration. It is not a distributed database lock. The engine uses exact global, budget, Invocation, reservation, and execution-attempt revisions so one stale authorization or duplicate completion wins -at most once. It conservatively counts every earned award plus the maximum award of +at most once. It conservatively counts every earned, non-reversed award plus the maximum award of every reserved, executing, or unresolved-held authorization against the period cap. -There is no automated award-reversal lifecycle in this v1 engine, so it never reduces -that exposure based on an unsupported reversal claim. +An earned-award correction reduces that cap exposure only after a provisioned, +policy-permitted finance signer authenticates an exact append-only reversal bound to +the award, Invocation, receipt hash, policy hash, amount, reason, and issuance time. +Caller-supplied statement rows or unsigned reductions never change authorization +capacity. Cumulative authenticated reversals cannot exceed the original earned award. The tested pre-execution rejection set includes: @@ -138,14 +147,23 @@ cost, malformed cost/hash, or over-cap cost transitions to `unresolved` and substitutes zero COGS, releases that hold, or creates an award automatically. Operator reconciliation of an unresolved hold is a human-only future gate. +If an Execution starts inside its authorized budget month but completes after that +month closes, it also terminally holds the full reservation and creates no late award. +A valid reported COGS amount remains signed as `known` evidence (with the success hash +or failure class) but is not silently booked or released; unknown COGS stays +`unresolved`. The receipt records the real later recognition period alongside the +original budget period. This avoids both an indefinitely `executing` record and a +July-to-August payable-acceleration path. + ## Receipts and statements Every terminal success, known failure, unresolved Execution, or pre-execution cancellation receives one monotonic receipt sequence scoped by employer, Creator, denomination, and atomic scale. Terminal state, signed receipt, receipt hash, and scoped sequence advancement commit in one serialized transaction. A terminal retry -returns the same persisted receipt without calling the executor again; a signing or -commit failure leaves no terminal state or receipt. A trusted receipt key ID selects +with a cancelled, released, held, failed, or consumed credential is rejected without +calling the executor again; callers read the already committed receipt from state. A +signing or commit failure leaves no terminal state or receipt. A trusted receipt key ID selects the provisioned verification key; receipts and lifecycle requests cannot inject key material. Receipt canonical bytes bind the Invocation, reservation, Skill registration, initiating-principal attestation, Skill hash, canonical policy hash, outcome, atomic @@ -186,7 +204,9 @@ changes only earned accounting or an already-advanced payable balance. Payments cannot exceed the authenticated payable balance. Advance, reversal, and payment timestamps must fall within the signed statement period. A later statement may cite an authenticated historical receipt for an advance, reversal, or payment without -recounting that receipt's prior-period economics. +recounting that receipt's prior-period economics. For each award, cumulative payable +advances plus cumulative earned-only reversals can never exceed the original earned +amount, regardless of which later statement recorded the reversal first. The receipt inclusion root uses domain-separated binary SHA-256 leaves and internal nodes. Odd levels duplicate the last node. The empty set has a fixed diff --git a/spikes/internal-invocation-awards/src/budget.mjs b/spikes/internal-invocation-awards/src/budget.mjs index 2f5790f..190db74 100644 --- a/spikes/internal-invocation-awards/src/budget.mjs +++ b/spikes/internal-invocation-awards/src/budget.mjs @@ -453,9 +453,20 @@ export function holdUnresolvedReservation(budgetInput, reservationInput, options const reservation = validateReservation(reservationInput); requireExecuting(budget, reservation, options); parseUtc(options.now, 'now'); - if (!['executor_threw', 'malformed_outcome', 'cost_unknown'].includes(options.reason)) { + if (![ + 'executor_threw', 'malformed_outcome', 'cost_unknown', 'period_closed_after_start', + ].includes(options.reason)) { throw new Error('unsupported unresolved reason'); } + const knownCost = options.reason === 'period_closed_after_start'; + if (knownCost) { + const cost = toAtomic(options.executionCostAtomic); + if (cost > toAtomic(reservation.quote.maxExecutionCostAtomic)) { + throw new Error('execution cost exceeds quote maximum'); + } + } else if (options.executionCostAtomic !== undefined && options.executionCostAtomic !== null) { + throw new Error('unknown-cost hold cannot record an execution cost'); + } const nextBudget = replaceBudget(budget, { revision: budget.revision + 1 }); const nextReservation = replaceReservation(reservation, { state: 'held_unresolved', @@ -465,10 +476,11 @@ export function holdUnresolvedReservation(budgetInput, reservationInput, options return deepFreeze({ budget: nextBudget, reservation: nextReservation, - event: event('execution_cost_unresolved', nextReservation, nextBudget, options.now, { + event: event(knownCost ? 'execution_period_closed' : 'execution_cost_unresolved', nextReservation, nextBudget, options.now, { reason: options.reason, heldAtomic: reservation.reservedAtomic, - executionCostStatus: 'unresolved', + executionCostStatus: knownCost ? 'known' : 'unresolved', + executionCostAtomic: knownCost ? options.executionCostAtomic : null, }), }); } diff --git a/spikes/internal-invocation-awards/src/engine.mjs b/spikes/internal-invocation-awards/src/engine.mjs index 13eafb0..c6717ed 100644 --- a/spikes/internal-invocation-awards/src/engine.mjs +++ b/spikes/internal-invocation-awards/src/engine.mjs @@ -1,4 +1,8 @@ -import { randomBytes, verify as cryptoVerify } from 'node:crypto'; +import { + randomBytes, + sign as cryptoSign, + verify as cryptoVerify, +} from 'node:crypto'; import { createBudget, @@ -59,14 +63,22 @@ const AUTHORIZE_KEYS = [ ]; const CANCEL_KEYS = ['store', 'expectedRevision', 'reservationId', 'reason']; const EXECUTE_KEYS = ['store', 'quote', 'credential', 'executor']; +const RECORD_AWARD_REVERSAL_KEYS = ['store', 'expectedRevision', 'signedReversal']; +const AWARD_REVERSAL_KEYS = [ + 'schemaVersion', 'reversalId', 'awardId', 'invocationId', 'receiptHash', + 'policyId', 'policyVersion', 'policyHash', 'amountAtomic', 'reason', + 'issuedAt', 'signerId', +]; +const SIGNED_AWARD_REVERSAL_KEYS = [...AWARD_REVERSAL_KEYS, 'signature']; const ENGINE_STATE_KEYS = [ 'revision', 'budget', 'policies', 'skillRegistrations', 'financeSigners', 'managerSigners', 'credentialAuthorizers', 'identitySigners', 'receiptSigners', 'invocations', 'reservations', 'awards', 'consumedNonces', 'issuedNonces', - 'consumedPrincipalNonces', 'idempotency', 'events', 'receipts', + 'consumedPrincipalNonces', 'idempotency', 'awardReversals', 'events', 'receipts', 'receiptHashes', 'receiptSequenceIndex', 'nextReceiptSequences', ]; const TERMINAL_STATES = new Set(['succeeded', 'failed', 'unresolved', 'cancelled']); +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; function requirePlainMap(value, label) { if (value === null || typeof value !== 'object' || Array.isArray(value) @@ -172,6 +184,29 @@ function assertTrustedState(state, now) { return policy; } +function assertHistoricallyTrustedState(state) { + capabilitiesFor(state); + requireExactKeys(state, ENGINE_STATE_KEYS, 'engine state'); + const policyKey = `${state.budget.policyId}@${state.budget.policyVersion}`; + const policy = state.policies[policyKey]; + if (!policy) throw new Error('budget policy is not provisioned'); + const historicalNow = state.budget.authorization.effectiveAt; + const validatedPolicy = validatePolicy(policy, historicalNow); + const verified = createBudget(state.budget.authorization, { + trustedFinanceSigners: state.financeSigners, + policy: validatedPolicy, + now: historicalNow, + }); + for (const key of [ + 'budgetId', 'policyId', 'policyVersion', 'policyHash', 'period', 'currency', + 'atomicScale', 'allocatedAtomic', + ]) { + if (state.budget[key] !== verified[key]) throw new Error(`budget state changed signed ${key}`); + } + remainingAtomic(state.budget); + return validatedPolicy; +} + function nextState(state, changes) { return markTrusted( { ...state, ...changes, revision: state.revision + 1 }, @@ -242,10 +277,20 @@ function awardExposureAtomic(state, policy, period) { for (const award of Object.values(state.awards)) { if (award.policyId === policy.policyId && award.policyVersion === policy.version - && award.period === period) { + && award.period === period + && ['earned', 'payable', 'paid'].includes(award.state)) { exposure += toAtomic(award.amountAtomic); } } + for (const reversal of Object.values(state.awardReversals)) { + if (reversal.policyId === policy.policyId + && reversal.policyVersion === policy.version) { + const award = state.awards[reversal.awardId]; + if (award?.period === period && ['earned', 'payable', 'paid'].includes(award.state)) { + exposure -= toAtomic(reversal.amountAtomic); + } + } + } for (const invocation of Object.values(state.invocations)) { if (invocation.policyId === policy.policyId && invocation.policyVersion === policy.version @@ -257,6 +302,66 @@ function awardExposureAtomic(state, policy, period) { return exposure; } +function ordered(source, keys) { + return Object.fromEntries(keys.map((key) => [key, source[key]])); +} + +function validateAwardReversalPayload(input) { + requireExactKeys(input, AWARD_REVERSAL_KEYS, 'award reversal authorization'); + if (input.schemaVersion !== 1) throw new Error('award reversal schemaVersion must equal 1'); + for (const key of ['reversalId', 'awardId', 'invocationId', 'policyId', 'reason', 'signerId']) { + if (typeof input[key] !== 'string' || input[key].length === 0) { + throw new Error(`award reversal ${key} must be non-empty`); + } + } + if (!SHA256_PATTERN.test(input.receiptHash)) throw new Error('award reversal receiptHash is invalid'); + if (!SHA256_PATTERN.test(input.policyHash)) throw new Error('award reversal policyHash is invalid'); + if (!Number.isSafeInteger(input.policyVersion) || input.policyVersion < 1) { + throw new Error('award reversal policyVersion must be a positive integer'); + } + if (toAtomic(input.amountAtomic) === 0n) throw new Error('award reversal amount must be positive'); + parseUtc(input.issuedAt, 'award reversal issuedAt'); + return cloneFrozen(input); +} + +export function canonicalAwardReversalBytes(unsignedReversal) { + const validated = validateAwardReversalPayload(unsignedReversal); + return new TextEncoder().encode(JSON.stringify(ordered(validated, AWARD_REVERSAL_KEYS))); +} + +export function signAwardReversal(unsignedReversal, privateKey) { + const validated = validateAwardReversalPayload(unsignedReversal); + return cloneFrozen({ + ...validated, + signature: cryptoSign(null, canonicalAwardReversalBytes(validated), privateKey).toString('base64'), + }); +} + +function decodeAwardReversalSignature(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + throw new Error('award reversal signature must be canonical base64'); + } + const bytes = Buffer.from(value, 'base64'); + if (bytes.length !== 64 || bytes.toString('base64') !== value) { + throw new Error('award reversal signature must be a 64-byte Ed25519 signature'); + } + return bytes; +} + +function snapshotSignedAwardReversal(value) { + requireExactKeys(value, SIGNED_AWARD_REVERSAL_KEYS, 'signed award reversal authorization'); + const descriptors = Object.getOwnPropertyDescriptors(value); + const captured = {}; + for (const key of SIGNED_AWARD_REVERSAL_KEYS) { + const descriptor = descriptors[key]; + if (!descriptor || !Object.hasOwn(descriptor, 'value') || descriptor.enumerable !== true) { + throw new Error(`signed award reversal ${key} must be an enumerable data property`); + } + captured[key] = descriptor.value; + } + return cloneFrozen(captured); +} + function resolveActiveRegistration(state, quote, policy, now) { const key = skillRegistrationKey(quote.skillId, quote.skillVersionHash); const registration = state.skillRegistrations[key]; @@ -477,6 +582,7 @@ export function createEngineState(input) { issuedNonces: deepFreeze({}), consumedPrincipalNonces: deepFreeze({}), idempotency: deepFreeze({}), + awardReversals: deepFreeze({}), events: deepFreeze([]), ...receiptLedger, }, capabilities); @@ -712,7 +818,7 @@ export async function cancelInternalAuthorization(input) { } const state = await input.store.transact(input.expectedRevision, (current) => { const now = engineNow(current); - assertTrustedState(current, now); + assertHistoricallyTrustedState(current); const reservation = current.reservations[input.reservationId]; if (!reservation) throw new Error('reservation does not exist'); const invocation = current.invocations[reservation.quote.invocationId]; @@ -772,12 +878,103 @@ export async function cancelInternalAuthorization(input) { return terminalResult(state, reservation.quote.invocationId); } +export async function recordAwardReversal(input) { + requireExactKeys(input, RECORD_AWARD_REVERSAL_KEYS, 'award reversal input'); + const signedReversal = snapshotSignedAwardReversal(input.signedReversal); + const state = await input.store.transact(input.expectedRevision, (current) => { + const now = engineNow(current); + const policy = assertTrustedState(current, now); + requireExactKeys( + signedReversal, + SIGNED_AWARD_REVERSAL_KEYS, + 'signed award reversal authorization', + ); + const reversal = validateAwardReversalPayload( + ordered(signedReversal, AWARD_REVERSAL_KEYS), + ); + if (Object.hasOwn(current.awardReversals, reversal.reversalId)) { + throw new Error('award reversal identifier already exists'); + } + if (reversal.policyId !== policy.policyId + || reversal.policyVersion !== policy.version + || reversal.policyHash !== current.budget.policyHash) { + throw new Error('award reversal policy binding does not match active budget'); + } + if (!policy.permittedFinanceSignerIds.includes(reversal.signerId)) { + throw new Error('award reversal finance signer is not permitted by policy'); + } + if (!Object.hasOwn(current.financeSigners, reversal.signerId)) { + throw new Error('award reversal finance signer is not provisioned'); + } + if (!cryptoVerify( + null, + canonicalAwardReversalBytes(reversal), + current.financeSigners[reversal.signerId], + decodeAwardReversalSignature(signedReversal.signature), + )) throw new Error('award reversal signature verification failed'); + const issuedAt = parseUtc(reversal.issuedAt, 'award reversal issuedAt'); + if (issuedAt > parseUtc(now, 'engine clock')) { + throw new Error('award reversal issuedAt cannot be in the future'); + } + const award = current.awards[reversal.awardId]; + if (!award || !['earned', 'payable', 'paid'].includes(award.state)) { + throw new Error('award reversal requires an earned award'); + } + const invocation = current.invocations[reversal.invocationId]; + if (!invocation || invocation.awardId !== award.awardId + || invocation.receiptHash !== reversal.receiptHash + || award.invocationId !== reversal.invocationId + || award.policyId !== reversal.policyId + || award.policyVersion !== reversal.policyVersion + || award.policyHash !== reversal.policyHash) { + throw new Error('award reversal does not match the authenticated award receipt'); + } + if (issuedAt < parseUtc(award.earnedAt, 'award earnedAt')) { + throw new Error('award reversal cannot precede award earning'); + } + const prior = Object.values(current.awardReversals) + .filter((row) => row.awardId === award.awardId) + .reduce((sum, row) => sum + toAtomic(row.amountAtomic), 0n); + if (prior + toAtomic(reversal.amountAtomic) > toAtomic(award.amountAtomic)) { + throw new Error('cumulative award reversal exceeds earned award'); + } + const stored = signedReversal; + const event = deepFreeze({ + schemaVersion: 1, + eventId: `${invocation.invocationId}:invocation_award_reversed:${reversal.reversalId}`, + type: 'invocation_award_reversed', + invocationId: invocation.invocationId, + occurredAt: now, + reversalId: reversal.reversalId, + awardId: award.awardId, + amountAtomic: reversal.amountAtomic, + financeSignerId: reversal.signerId, + }); + return nextState(current, { + awardReversals: mapWith(current.awardReversals, reversal.reversalId, stored), + events: deepFreeze([...current.events, event]), + }); + }); + const reversal = state.awardReversals[signedReversal.reversalId]; + return deepFreeze({ + state, + reversal, + event: state.events.find((row) => row.type === 'invocation_award_reversed' + && row.reversalId === reversal.reversalId), + }); +} + export async function executeAuthorizedInvocation(input) { requireExactKeys(input, EXECUTE_KEYS, 'execution input'); const initial = input.store.snapshot(); capabilitiesFor(initial); const replay = verifyTerminalReplay(initial, input.quote, input.credential); - if (replay) return replay; + if (replay) { + if (replay.invocation.state === 'cancelled') { + throw new Error('credential authorization was cancelled and reservation released'); + } + throw new Error('credential already consumed by terminal Invocation'); + } if (typeof input.executor !== 'function') throw new Error('executor must be an injected function'); const started = await input.store.transact(initial.revision, (current) => { @@ -898,7 +1095,38 @@ export async function executeAuthorizedInvocation(input) { }); const receiptSequence = current.nextReceiptSequences[scope] ?? 1; const receiptId = `receipt-${invocation.invocationId}`; - if (outcome.kind === 'succeeded') { + const crossedBudgetPeriod = now.slice(0, 7) !== invocation.period; + if (crossedBudgetPeriod) { + const executionCostKnown = outcome.kind === 'succeeded' + || outcome.kind === 'failed_after_start'; + const unresolvedReason = executionCostKnown + ? 'period_closed_after_start' + : outcome.reason; + money = holdUnresolvedReservation(current.budget, reservation, { + expectedBudgetRevision: current.budget.revision, + expectedReservationRevision: reservation.revision, + executionAttemptId: invocation.executionAttemptId, + reason: unresolvedReason, + executionCostAtomic: executionCostKnown ? outcome.executionCostAtomic : null, + now, + }); + preReceiptInvocation = deepFreeze({ + ...invocation, + state: 'unresolved', + revision: invocation.revision + 1, + finalizedAt: now, + executionCostStatus: executionCostKnown ? 'known' : 'unresolved', + executionCostAtomic: executionCostKnown ? outcome.executionCostAtomic : null, + heldReservationAtomic: reservation.reservedAtomic, + outputHash: outcome.kind === 'succeeded' ? outcome.outputHash : null, + failureClass: outcome.kind === 'failed_after_start' ? outcome.failureClass : null, + unresolvedReason, + journalEntries: deepFreeze([]), + receiptSequenceScope: scope, + receiptSequence, + receiptId, + }); + } else if (outcome.kind === 'succeeded') { const gross = toAtomic(outcome.executionCostAtomic) + toAtomic(reservation.quote.protocolFeeAtomic) + toAtomic(reservation.quote.refundReserveAtomic) diff --git a/spikes/internal-invocation-awards/src/statements.mjs b/spikes/internal-invocation-awards/src/statements.mjs index eeebeed..06290c0 100644 --- a/spikes/internal-invocation-awards/src/statements.mjs +++ b/spikes/internal-invocation-awards/src/statements.mjs @@ -25,7 +25,7 @@ const RECEIPT_KEYS = [ 'reservationId', 'employerId', 'creatorId', 'skillId', 'skillVersionHash', 'skillRegistrationId', 'initiatingPrincipalId', 'principalAttestationId', 'principalAttestationHash', 'policyId', 'policyVersion', 'policyHash', - 'period', 'currency', 'atomicScale', + 'budgetPeriod', 'period', 'currency', 'atomicScale', 'invocationState', 'reservationState', 'executionAttemptId', 'reservedAtomic', 'consumedAtomic', 'releasedAtomic', 'heldReservationAtomic', 'executionCostStatus', 'executionCostAtomic', 'outputHash', 'failureClass', @@ -133,7 +133,7 @@ function validateReceiptPayload(input) { for (const key of [ 'receiptId', 'receiptType', 'invocationId', 'reservationId', 'employerId', 'creatorId', 'skillId', 'skillRegistrationId', 'initiatingPrincipalId', - 'principalAttestationId', 'policyId', 'period', 'currency', 'invocationState', + 'principalAttestationId', 'policyId', 'budgetPeriod', 'period', 'currency', 'invocationState', 'reservationState', 'receiptSignerId', 'receiptSequenceScope', ]) requireString(input[key], key); if (!Number.isSafeInteger(input.sequence) || input.sequence < 1) { @@ -146,6 +146,12 @@ function validateReceiptPayload(input) { throw new Error('receipt atomicScale must be an integer from 0 through 18'); } if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(input.period)) throw new Error('receipt period must be YYYY-MM'); + if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(input.budgetPeriod)) { + throw new Error('receipt budgetPeriod must be YYYY-MM'); + } + if (input.budgetPeriod > input.period) { + throw new Error('receipt recognition period cannot precede its budget period'); + } if (!SHA256_PATTERN.test(input.skillVersionHash)) throw new Error('receipt Skill hash is invalid'); if (!SHA256_PATTERN.test(input.policyHash)) throw new Error('receipt policyHash is invalid'); if (!SHA256_PATTERN.test(input.principalAttestationHash)) { @@ -184,6 +190,7 @@ function validateReceiptPayload(input) { if (input.invocationState === 'succeeded') { if (input.receiptType !== 'internal_invocation_finalized' + || input.budgetPeriod !== input.period || input.reservationState !== 'consumed' || input.executionCostStatus !== 'known' || input.executionCostAtomic === null @@ -216,6 +223,7 @@ function validateReceiptPayload(input) { }); } else if (input.invocationState === 'failed') { if (input.receiptType !== 'internal_invocation_finalized' + || input.budgetPeriod !== input.period || input.reservationState !== 'released' || input.executionCostStatus !== 'known' || input.executionCostAtomic === null @@ -239,13 +247,23 @@ function validateReceiptPayload(input) { })), }); } else if (input.invocationState === 'unresolved') { + const knownAfterClose = input.unresolvedReason === 'period_closed_after_start' + && input.budgetPeriod < input.period + && input.executionCostStatus === 'known' + && input.executionCostAtomic !== null + && toAtomic(input.executionCostAtomic) <= held + && ((SHA256_PATTERN.test(input.outputHash) && input.failureClass === null) + || (input.outputHash === null + && ['provider_error', 'skill_error', 'invalid_output'].includes(input.failureClass))); + const unknownCost = ['executor_threw', 'malformed_outcome', 'cost_unknown'] + .includes(input.unresolvedReason) + && input.executionCostStatus === 'unresolved' + && input.executionCostAtomic === null + && input.outputHash === null + && input.failureClass === null; if (input.receiptType !== 'internal_invocation_finalized' || input.reservationState !== 'held_unresolved' - || input.executionCostStatus !== 'unresolved' - || input.executionCostAtomic !== null - || input.outputHash !== null - || input.failureClass !== null - || !['executor_threw', 'malformed_outcome', 'cost_unknown'].includes(input.unresolvedReason) + || (!knownAfterClose && !unknownCost) || input.protocolFeeAtomic !== '0' || input.refundReserveAtomic !== '0' || input.invocationAwardAtomic !== '0' @@ -335,7 +353,8 @@ export function buildInvocationReceipt({ policyId: invocation.policyId, policyVersion: invocation.policyVersion, policyHash: invocation.policyHash, - period: invocation.period, + budgetPeriod: invocation.period, + period: invocation.finalizedAt.slice(0, 7), currency: invocation.currency, atomicScale: invocation.atomicScale, invocationState: invocation.state, @@ -682,7 +701,9 @@ export function buildStatement({ throw new Error('payable advance must reference an authenticated historical receipt'); } award.advance += toAtomic(advance.amountAtomic); - if (award.advance > award.amount) throw new Error('payable advance exceeds earned award'); + if (award.advance + award.earnedReversal > award.amount) { + throw new Error('payable advance plus cumulative earned-only reversal exceeds earned award'); + } } const reversalRows = uniqueSorted(reversals, 'reversalId', 'reversals', validateReversal); for (const reversal of reversalRows) { diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs index b9caea6..551ad9d 100644 --- a/spikes/internal-invocation-awards/test/engine.test.mjs +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -17,9 +17,12 @@ import { } from '../src/credentials.mjs'; import { authorizeInternalInvocation, + canonicalAwardReversalBytes, cancelInternalAuthorization, createEngineState, executeAuthorizedInvocation, + recordAwardReversal, + signAwardReversal, } from '../src/engine.mjs'; import { policyHash, skillRegistrationKey } from '../src/schema.mjs'; import { receiptHash, verifyReceipt } from '../src/statements.mjs'; @@ -460,7 +463,7 @@ test('successful execution atomically commits policy-bound signed receipt and ex assert.doesNotThrow(() => JSON.stringify(result)); }); -test('terminal retry returns the identical committed receipt and never invokes executor again', async () => { +test('consumed terminal credentials reject and never invoke executor again', async () => { const fx = fixture(); const q = makeQuote(fx.activePolicy, 'retry'); const authorized = await authorize(fx, q); @@ -475,7 +478,7 @@ test('terminal retry returns the identical committed receipt and never invokes e return { kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH }; }, }); - const retry = await executeAuthorizedInvocation({ + await assert.rejects(() => executeAuthorizedInvocation({ store: fx.store, quote: q, credential, @@ -483,9 +486,9 @@ test('terminal retry returns the identical committed receipt and never invokes e calls += 1; throw new Error('must not execute'); }, - }); + }), /credential already consumed|terminal|reservation.*consumed/i); assert.equal(calls, 1); - assert.deepEqual(retry, first); + assert.equal(first.invocation.state, 'succeeded'); assert.equal(Object.keys(fx.store.snapshot().receipts).length, 1); }); @@ -493,15 +496,20 @@ test('validated known failure records exactly one shared-kernel execution COGS r const fx = fixture(); const q = makeQuote(fx.activePolicy, 'failure'); const authorized = await authorize(fx, q); + const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); + let calls = 0; const result = await executeAuthorizedInvocation({ store: fx.store, quote: q, - credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), - executor: async () => ({ - kind: 'failed_after_start', - executionCostAtomic: '700000', - failureClass: 'provider_error', - }), + credential, + executor: async () => { + calls += 1; + return { + kind: 'failed_after_start', + executionCostAtomic: '700000', + failureClass: 'provider_error', + }; + }, }); assert.equal(result.invocation.state, 'failed'); assert.equal(result.budget.consumedAtomic, '700000'); @@ -514,32 +522,62 @@ test('validated known failure records exactly one shared-kernel execution COGS r amountAtomic: '700000', }]); assert.deepEqual(result.receipt.journalEntries, result.invocation.journalEntries); + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, quote: q, credential, + executor: async () => { calls += 1; return {}; }, + }), /credential already consumed|terminal/i); + assert.equal(calls, 1); }); test('malformed and unknown-cost outcomes hold the full reservation without journals or award', async (t) => { const outcomes = [ - async () => { throw new Error('provider vanished'); }, - async () => ({ kind: 'unresolved_after_start', reason: 'cost_unknown' }), - async () => ({ kind: 'failed_after_start', executionCostAtomic: '1.5', failureClass: 'provider_error' }), - async () => ({ kind: 'succeeded', executionCostAtomic: '1', outputHash: 'bad' }), + ['throw', async () => { throw new Error('provider vanished'); }], + ['explicit executor_threw', async () => ({ kind: 'unresolved_after_start', reason: 'executor_threw' })], + ['explicit malformed_outcome', async () => ({ kind: 'unresolved_after_start', reason: 'malformed_outcome' })], + ['explicit cost_unknown', async () => ({ kind: 'unresolved_after_start', reason: 'cost_unknown' })], + ['unknown kind', async () => ({ kind: 'mystery' })], + ['missing success cost', async () => ({ kind: 'succeeded', outputHash: OUTPUT_HASH })], + ['negative cost', async () => ({ kind: 'failed_after_start', executionCostAtomic: '-1', failureClass: 'provider_error' })], + ['decimal cost', async () => ({ kind: 'failed_after_start', executionCostAtomic: '1.5', failureClass: 'provider_error' })], + ['non-string cost', async () => ({ kind: 'failed_after_start', executionCostAtomic: 1, failureClass: 'provider_error' })], + ['over-cap cost', async () => ({ kind: 'failed_after_start', executionCostAtomic: '1000001', failureClass: 'provider_error' })], + ['extra outcome key', async () => ({ kind: 'succeeded', executionCostAtomic: '1', outputHash: OUTPUT_HASH, surprise: true })], + ['malformed success hash', async () => ({ kind: 'succeeded', executionCostAtomic: '1', outputHash: 'bad' })], + ['missing failure cost', async () => ({ kind: 'failed_after_start', failureClass: 'provider_error' })], + ['invalid failure class', async () => ({ kind: 'failed_after_start', executionCostAtomic: '1', failureClass: 'timeout' })], ]; - for (const [index, executor] of outcomes.entries()) { - await t.test(`unresolved case ${index + 1}`, async () => { + for (const [index, [label, executor]] of outcomes.entries()) { + await t.test(label, async () => { const fx = fixture(); const q = makeQuote(fx.activePolicy, `unresolved-${index}`); const authorized = await authorize(fx, q); + let calls = 0; + const credential = signCredential(authorized.credentialPayload, fx.authorizer.privateKey); const result = await executeAuthorizedInvocation({ store: fx.store, quote: q, - credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), - executor, + credential, + executor: async (...args) => { calls += 1; return executor(...args); }, }); assert.equal(result.invocation.state, 'unresolved'); assert.equal(result.reservation.state, 'held_unresolved'); assert.equal(result.budget.reservedAtomic, '3050000'); assert.equal(result.budget.consumedAtomic, '0'); + assert.equal(result.budget.releasedAtomic, '0'); assert.equal(result.award, null); + assert.equal(result.invocation.executionCostAtomic, null); assert.deepEqual(result.receipt.journalEntries, []); + assert.ok(Object.hasOwn(result.state.consumedNonces, authorized.credentialPayload.nonce)); + assert.equal(result.events.filter((event) => event.type === 'execution_cost_unresolved').length, 1); + assert.equal(result.events.some((event) => event.type === 'budget_consumed'), false); + assert.equal(result.events.some((event) => event.type === 'budget_released'), false); + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential, + executor: async () => { calls += 1; return {}; }, + }), /credential already consumed|terminal|held_unresolved/i); + assert.equal(calls, 1); }); } }); @@ -559,6 +597,14 @@ test('cancellation atomically signs one journal-free terminal receipt', async () assert.deepEqual(cancelled.receipt.journalEntries, []); assert.equal(cancelled.state.nextReceiptSequences[cancelled.receipt.receiptSequenceScope], 2); assert.equal(Object.keys(cancelled.state.receipts).length, 1); + let executorCalls = 0; + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), + executor: async () => { executorCalls += 1; return {}; }, + }), /cancelled|released|terminal/i); + assert.equal(executorCalls, 0); }); test('receipt sequence is independent per employer, Creator, currency, and scale', async () => { @@ -771,6 +817,340 @@ test('period cap counts every open maximum exposure', async () => { assert.equal(Object.keys(fx.store.snapshot().reservations).length, 1); }); +test('authorization Promise race, idempotency, and reservation bindings fail closed', async () => { + const fx = fixture(); + const q1 = makeQuote(fx.activePolicy, 'authorize-race-1'); + const q2 = makeQuote(fx.activePolicy, 'authorize-race-2'); + const raced = await Promise.allSettled([authorize(fx, q1), authorize(fx, q2)]); + assert.equal(raced.filter((row) => row.status === 'fulfilled').length, 1); + assert.equal(raced.filter((row) => row.status === 'rejected').length, 1); + assert.match(raced.find((row) => row.status === 'rejected').reason.message, /stale engine revision/); + assert.equal(Object.keys(fx.store.snapshot().reservations).length, 1); + assert.equal(Object.keys(fx.store.snapshot().idempotency).length, 1); + + const winner = raced.find((row) => row.status === 'fulfilled').value; + const winnerQuote = winner.invocation.invocationId === q1.invocationId ? q1 : q2; + await assert.rejects(() => authorize(fx, makeQuote(fx.activePolicy, 'idempotency-replay', { + idempotencyKey: winnerQuote.idempotencyKey, + })), /idempotency key already bound/); + + const other = winnerQuote === q1 ? q2 : q1; + let calls = 0; + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: other, + credential: signCredential(winner.credentialPayload, fx.authorizer.privateKey), + executor: async () => { calls += 1; return {}; }, + }), /no persisted authorization|credential.*match|quote.*match/i); + assert.equal(calls, 0); +}); + +test('credential and manager signer, authorizer, and reservation bindings reject before execution', async () => { + const fx = fixture(); + const q = makeQuote(fx.activePolicy, 'binding'); + const authorized = await authorize(fx, q); + let calls = 0; + const executor = async () => { calls += 1; return {}; }; + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, quote: q, credential: null, executor, + }), /credential authorizer|credential/i); + const attacker = generateKeyPairSync('ed25519'); + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential: signCredential(authorized.credentialPayload, attacker.privateKey), + executor, + }), /signature/); + const wrongReservationCredential = signCredential({ + ...authorized.credentialPayload, + reservationId: 'res-attacker', + }, fx.authorizer.privateKey); + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, quote: q, credential: wrongReservationCredential, executor, + }), /credential does not match persisted authorization/); + assert.equal(calls, 0); + + const selfFx = fixture(); + const selfQuote = makeQuote(selfFx.activePolicy, 'manager-binding', { + initiatingPrincipalId: 'sam', + }); + const otherQuote = makeQuote(selfFx.activePolicy, 'manager-other', { + initiatingPrincipalId: 'sam', + }); + await assert.rejects(() => authorize(selfFx, selfQuote, { + managerApproval: managerApproval(selfFx, otherQuote), + }), /manager approval.*binding|invocation/i); + await assert.rejects(() => authorize(selfFx, selfQuote, { + managerApproval: signManagerApproval({ + ...(() => { + const { signature: _signature, ...payload } = managerApproval(selfFx, selfQuote); + return payload; + })(), + managerSignerId: 'unknown-manager', + }, attacker.privateKey), + }), /manager signer is not permitted|not provisioned/); + assert.equal(selfFx.store.snapshot().revision, 0); +}); + +test('required policy, budget, signer, and lifecycle rejections happen before executor start', async (t) => { + await t.test('unauthorized Wielder, quote cap, underfunding, and expired budget', async () => { + const unauthorized = fixture(); + await assert.rejects(() => authorize( + unauthorized, + makeQuote(unauthorized.activePolicy, 'bad-wielder', { wielderId: 'outsider-agent' }), + ), /Wielder is not permitted/); + assert.equal(unauthorized.store.snapshot().revision, 0); + + const overCap = fixture(); + await assert.rejects(() => authorize(overCap, makeQuote(overCap.activePolicy, 'over-cap', { + maxExecutionCostAtomic: '2000000', + maxGrossAtomic: '4050000', + })), /maxGrossAtomic exceeds policy/); + assert.equal(overCap.store.snapshot().revision, 0); + + const underfunded = fixture({ budgetOverrides: { allocatedAtomic: '1000000' } }); + await assert.rejects( + () => authorize(underfunded, makeQuote(underfunded.activePolicy, 'underfunded')), + /insufficient remaining budget/, + ); + assert.equal(underfunded.store.snapshot().revision, 0); + + const expired = fixture({ budgetOverrides: { expiresAt: '2026-07-17T00:02:00.000Z' } }); + expired.clock.now = '2026-07-17T00:03:00.000Z'; + await assert.rejects( + () => authorize(expired, makeQuote(expired.activePolicy, 'expired-budget')), + /budget authorization expired/, + ); + assert.equal(expired.store.snapshot().revision, 0); + }); + + await t.test('manager approval is independent, bound, current, trusted, and key-injection-free', async () => { + const fx = fixture(); + const q = makeQuote(fx.activePolicy, 'manager-matrix', { initiatingPrincipalId: 'sam' }); + const attacker = generateKeyPairSync('ed25519'); + const common = { + schemaVersion: 1, + approvalId: 'approval-manager-matrix', + invocationId: q.invocationId, + creatorId: q.creatorId, + policyId: q.policyId, + policyVersion: q.policyVersion, + issuedAt: '2026-07-17T00:00:00.000Z', + expiresAt: q.expiresAt, + }; + await assert.rejects(() => authorize(fx, q, { managerApproval: signManagerApproval({ + ...common, managerSignerId: 'sam', + }, attacker.privateKey) }), /cannot self-approve/); + await assert.rejects(() => authorize(fx, q, { managerApproval: signManagerApproval({ + ...common, managerSignerId: 'unknown-manager', + }, attacker.privateKey) }), /manager signer is not permitted/); + await assert.rejects(() => authorize(fx, q, { managerApproval: signManagerApproval({ + ...common, + managerSignerId: 'manager-alex', + issuedAt: '2026-07-17T00:00:00.000Z', + expiresAt: '2026-07-17T00:00:30.000Z', + }, fx.manager.privateKey) }), /manager approval expired/); + await assert.rejects(() => authorize(fx, q, { + managerApproval: { ...managerApproval(fx, q), publicKeyPem: publicPem(fx.manager) }, + }), /unknown key publicKeyPem/); + assert.equal(fx.store.snapshot().revision, 0); + }); + + await t.test('credential requires persisted exact reservation and trusted policy authorizer', async () => { + const source = fixture(); + const q1 = makeQuote(source.activePolicy, 'credential-a'); + const q2 = makeQuote(source.activePolicy, 'credential-b'); + const a = await authorize(source, q1); + const b = await authorize(source, q2); + const credentialA = signCredential(a.credentialPayload, source.authorizer.privateKey); + let calls = 0; + const executor = async () => { calls += 1; return {}; }; + await assert.rejects(() => executeAuthorizedInvocation({ + store: source.store, quote: q2, credential: credentialA, executor, + }), /credential does not match persisted authorization/); + await assert.rejects(() => executeAuthorizedInvocation({ + store: source.store, quote: q1, + credential: { ...credentialA, publicKeyPem: publicPem(source.authorizer) }, + executor, + }), /unknown key publicKeyPem/); + await assert.rejects(() => executeAuthorizedInvocation({ + store: source.store, quote: q1, credential: credentialA, executor, + publicKey: source.authorizer.publicKey, + }), /unknown key publicKey/); + + const empty = fixture(); + await assert.rejects(() => executeAuthorizedInvocation({ + store: empty.store, quote: q1, credential: credentialA, executor, + }), /no persisted authorization/); + const disallowed = fixture(); + await assert.rejects(() => authorize(disallowed, makeQuote(disallowed.activePolicy, 'authorizer'), { + credentialAuthorizerId: 'attacker-authorizer', + }), /credential authorizer is not permitted/); + assert.equal(calls, 0); + assert.equal(Object.hasOwn(b.credentialPayload, 'signature'), false); + }); +}); + +test('finance-authenticated append-only earned reversals reduce period exposure only after verification', async () => { + const fx = fixture({ policyOverrides: { maxAwardPerPeriodAtomic: '3000000' } }); + const q1 = makeQuote(fx.activePolicy, 'reversal-cap-1'); + const firstAuthorization = await authorize(fx, q1); + const first = await executeSuccess(fx, q1, firstAuthorization); + const q2 = makeQuote(fx.activePolicy, 'reversal-cap-2'); + await assert.rejects(() => authorize(fx, q2), /period award cap/); + + const unsignedReversal = { + schemaVersion: 1, + reversalId: 'award-reversal-001', + awardId: first.award.awardId, + invocationId: first.invocation.invocationId, + receiptHash: first.receiptHash, + policyId: first.award.policyId, + policyVersion: first.award.policyVersion, + policyHash: first.award.policyHash, + amountAtomic: '1000000', + reason: 'finance_authenticated_correction', + issuedAt: fx.clock.now, + signerId: 'megacorp-finance', + }; + assert.ok(canonicalAwardReversalBytes(unsignedReversal) instanceof Uint8Array); + const attacker = generateKeyPairSync('ed25519'); + const accessorBacked = { + ...signAwardReversal(unsignedReversal, fx.finance.privateKey), + }; + const signatureValue = accessorBacked.signature; + Object.defineProperty(accessorBacked, 'signature', { + enumerable: true, + get: () => signatureValue, + }); + await assert.rejects(() => recordAwardReversal({ + store: fx.store, + expectedRevision: fx.store.snapshot().revision, + signedReversal: accessorBacked, + }), /signature must be an enumerable data property/); + assert.equal(Object.keys(fx.store.snapshot().awardReversals).length, 0); + await assert.rejects(() => recordAwardReversal({ + store: fx.store, + expectedRevision: fx.store.snapshot().revision, + signedReversal: signAwardReversal(unsignedReversal, attacker.privateKey), + }), /signature/); + assert.equal(Object.keys(fx.store.snapshot().awardReversals).length, 0); + + const recorded = await recordAwardReversal({ + store: fx.store, + expectedRevision: fx.store.snapshot().revision, + signedReversal: signAwardReversal(unsignedReversal, fx.finance.privateKey), + }); + assert.equal(recorded.reversal.amountAtomic, '1000000'); + assert.equal(recorded.event.type, 'invocation_award_reversed'); + await assert.rejects(() => recordAwardReversal({ + store: fx.store, + expectedRevision: fx.store.snapshot().revision, + signedReversal: signAwardReversal(unsignedReversal, fx.finance.privateKey), + }), /reversal identifier already exists/); + await assert.rejects(() => recordAwardReversal({ + store: fx.store, + expectedRevision: fx.store.snapshot().revision, + signedReversal: signAwardReversal({ + ...unsignedReversal, + reversalId: 'award-reversal-over-earned', + amountAtomic: '1000001', + }, fx.finance.privateKey), + }), /cumulative award reversal exceeds earned award/); + assert.equal(Object.keys(fx.store.snapshot().awardReversals).length, 1); + const second = await authorize(fx, q2); + assert.equal(second.reservation.state, 'reserved'); +}); + +test('executions crossing period close terminally hold with honest recognition time and no late award', async (t) => { + const julyStart = '2026-07-31T23:59:00.000Z'; + const august = '2026-08-01T00:01:00.000Z'; + const september = '2026-09-01T00:00:00.000Z'; + const cases = [ + ['success', async (fx) => { + fx.clock.now = august; + return { kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH }; + }, 'known', '700000'], + ['known failure', async (fx) => { + fx.clock.now = august; + return { kind: 'failed_after_start', executionCostAtomic: '600000', failureClass: 'provider_error' }; + }, 'known', '600000'], + ['unknown cost', async (fx) => { + fx.clock.now = august; + return { kind: 'unresolved_after_start', reason: 'cost_unknown' }; + }, 'unresolved', null], + ]; + for (const [label, outcome, costStatus, cost] of cases) { + await t.test(label, async () => { + const fx = fixture({ + policyOverrides: { expiresAt: september }, + registrations: [registration({ expiresAt: september })], + }); + fx.clock.now = julyStart; + const q = makeQuote(fx.activePolicy, `clock-crossing-${label.replace(' ', '-')}`, { + expiresAt: '2026-08-02T00:00:00.000Z', + }); + const authorized = await authorize(fx, q, { + credentialIssuedAt: julyStart, + credentialExpiresAt: q.expiresAt, + principalAttestation: principalAttestation(fx, q, { + issuedAt: julyStart, + expiresAt: q.expiresAt, + }), + }); + const result = await executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential: signCredential(authorized.credentialPayload, fx.authorizer.privateKey), + executor: () => outcome(fx), + }); + assert.equal(result.invocation.state, 'unresolved'); + assert.equal(result.reservation.state, 'held_unresolved'); + assert.equal(result.invocation.executionCostStatus, costStatus); + assert.equal(result.invocation.executionCostAtomic, cost); + assert.equal(result.invocation.invocationAwardAtomic, '0'); + assert.equal(result.award, null); + assert.equal(result.budget.reservedAtomic, '3050000'); + assert.equal(result.receipt.period, '2026-08'); + assert.equal(result.receipt.budgetPeriod, '2026-07'); + assert.equal(result.receipt.occurredAt, august); + assert.deepEqual(result.receipt.journalEntries, []); + assert.equal(result.events.some((event) => event.type === 'budget_consumed'), false); + assert.equal(result.events.some((event) => event.type === 'budget_released'), false); + assert.equal(result.events.some((event) => event.type === ( + costStatus === 'known' ? 'execution_period_closed' : 'execution_cost_unresolved' + )), true); + }); + } +}); + +test('historically authenticated reserved authorization can be cancelled after expiry', async () => { + const september = '2026-09-01T00:00:00.000Z'; + const fx = fixture({ + policyOverrides: { expiresAt: september }, + registrations: [registration({ expiresAt: september })], + }); + const q = makeQuote(fx.activePolicy, 'late-cancel', { expiresAt: '2026-08-02T00:00:00.000Z' }); + const authorized = await authorize(fx, q, { + credentialExpiresAt: q.expiresAt, + principalAttestation: principalAttestation(fx, q, { expiresAt: q.expiresAt }), + }); + fx.clock.now = '2026-08-02T00:01:00.000Z'; + const cancelled = await cancelInternalAuthorization({ + store: fx.store, + expectedRevision: fx.store.snapshot().revision, + reservationId: authorized.reservation.reservationId, + reason: 'expired_authorization_released_by_operator', + }); + assert.equal(cancelled.invocation.state, 'cancelled'); + assert.equal(cancelled.reservation.state, 'released'); + assert.equal(cancelled.receipt.period, '2026-08'); + assert.equal(cancelled.receipt.budgetPeriod, '2026-07'); + assert.equal(cancelled.receipt.occurredAt, fx.clock.now); + assert.equal(cancelled.budget.reservedAtomic, '0'); + assert.equal(cancelled.budget.releasedAtomic, '3050000'); +}); + test('engine configuration requires exact immutable trust roots and keeps capabilities private', () => { const fx = fixture(); const snapshot = fx.store.snapshot(); diff --git a/spikes/internal-invocation-awards/test/statements.test.mjs b/spikes/internal-invocation-awards/test/statements.test.mjs index d31df3a..d54f2ff 100644 --- a/spikes/internal-invocation-awards/test/statements.test.mjs +++ b/spikes/internal-invocation-awards/test/statements.test.mjs @@ -419,6 +419,52 @@ test('payable advances, reversal semantics, and payments determine payable closi }), /payable reversal exceeds advanced amount/); }); +test('prior earned-only reversals permanently reduce every later payable-advance ceiling', () => { + const signers = signerFixture(); + const receipt = signedSuccess(signers, 1, 'cumulative-earned-reversal'); + const hash = receiptHash(receipt); + const july = signedJulyStatement(signers, [receipt], 'cumulative-earned-reversal'); + const august = signStatement(buildStatement({ + statementId: 'statement-earned-reversal-august', + employerId: 'megacorp', creatorId: 'sam', period: '2026-08', + currency: 'USD', atomicScale: 6, openingPayableAtomic: july.closingPayableAtomic, + receipts: [], historicalReceipts: [receipt], priorStatement: july, + payableAdvances: [], + reversals: [{ + reversalId: 'earned-reversal-august', receiptHash: hash, amountAtomic: '750000', + balanceEffect: 'earned_only', reason: 'authenticated_quality_correction', + occurredAt: '2026-08-15T00:00:00.000Z', + }], + payments: [], statementSignerId: 'collar-statement-key-2026-07', + }), signers.statement.privateKey); + + assert.throws(() => buildStatement({ + statementId: 'statement-overadvance-september', + employerId: 'megacorp', creatorId: 'sam', period: '2026-09', + currency: 'USD', atomicScale: 6, openingPayableAtomic: august.closingPayableAtomic, + receipts: [], historicalReceipts: [receipt], priorStatement: august, + payableAdvances: [{ + advanceId: 'advance-over-remaining-earned', receiptHash: hash, amountAtomic: '1250001', + advancedAt: '2026-09-01T00:00:00.000Z', + }], + reversals: [], payments: [], statementSignerId: 'collar-statement-key-2026-07', + }), /advance.*earned-only reversal.*exceeds earned award/i); + + const september = buildStatement({ + statementId: 'statement-exact-remaining-september', + employerId: 'megacorp', creatorId: 'sam', period: '2026-09', + currency: 'USD', atomicScale: 6, openingPayableAtomic: august.closingPayableAtomic, + receipts: [], historicalReceipts: [receipt], priorStatement: august, + payableAdvances: [{ + advanceId: 'advance-exact-remaining-earned', receiptHash: hash, amountAtomic: '1250000', + advancedAt: '2026-09-01T00:00:00.000Z', + }], + reversals: [], payments: [], statementSignerId: 'collar-statement-key-2026-07', + }); + assert.equal(september.awardActivity[0].advancedAtomic, '1250000'); + assert.equal(september.awardActivity[0].earnedReversedAtomic, '750000'); +}); + test('monthly-in-arrears rejects advancing or paying a current-period award', () => { const signers = signerFixture(); const receipt = signedSuccess(signers); @@ -480,7 +526,7 @@ test('receipt occurrence month blocks the July-to-August payable bypass', () => }], statementSignerId: 'collar-statement-key-2026-07', }); - }, /receipt occurredAt must fall within receipt period 2026-07/); + }, /invalid successful Invocation receipt state/); }); test('receipt verification rejects a correctly signed occurrence outside its period', () => { From 1ec9a7bd96c1c7a1edcf984fe52112f5600a6b61 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 06:12:55 -0400 Subject: [PATCH 110/165] fix: close Wielder acceptance gaps --- spikes/pi-wielder/README.md | 14 +- spikes/pi-wielder/src/payment-policy.mjs | 159 ++++++++++++---- spikes/pi-wielder/src/proxy.mjs | 113 ++++++++++-- spikes/pi-wielder/tests/paying-fetch.test.mjs | 172 +++++++++++++++++- .../pi-wielder/tests/payment-policy.test.mjs | 148 +++++++++++++++ 5 files changed, 545 insertions(+), 61 deletions(-) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index be5956a..51085b7 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -37,6 +37,13 @@ bounded-time quote, a per-call cap, and remaining session budget. The policy rej numeric or coerced atomic amounts, unknown protocol fields, caller-supplied payment or idempotency headers, ambiguous URL forms, and path-prefix confusion. +The caller's method, body bytes, and headers are captured once before the unpaid +request and reused for the policy hash, signed recovery, and paid retry; redirects are +disabled on both requests. Validated policy limits and seller rules are snapshotted at +construction. The trusted clock rejects backward movement and rechecks age from both +local receipt and server issue time after challenge parsing and immediately before the +paid retry. + Budget is synchronously reserved before signing. The exact authorization, signature, and encoded `X-PAYMENT` value are stored before the one paid retry begins. A recovery path can reuse those exact stored bytes after a local interruption; it never creates a @@ -45,6 +52,11 @@ or missing/mismatched settlement evidence aborts without exposing the upstream b and retains the amount as `unresolved`. Only exact response evidence or an injected trusted reconciliation capability may advance that state. +An ordinary signer rejection before a signature is returned releases its unsigned +reservation. Any persistence failure after a signature return remains conservatively +`unresolved`. Trusted reconciliation callbacks cannot reenter a monetary transition, +and every monetary commit enforces non-negative, conserved session-budget counters. + This policy is an in-memory, one-process session control. Restarting the proxy loses its policy snapshot, so this is not production spend enforcement and provides no cross-restart budget guarantee. A durable deployment must persist and replay signed @@ -137,7 +149,7 @@ npm test npm run e2e ``` -Expected current results are 113 offline unit/integration tests and 30 offline e2e +Expected current results are 127 offline unit/integration tests and 30 offline e2e checks. Counts can increase as regressions are added; zero failures is the contract. The e2e labels all timing output synthetic and uses in-process Hono requests only. diff --git a/spikes/pi-wielder/src/payment-policy.mjs b/spikes/pi-wielder/src/payment-policy.mjs index 115f4bd..8538d97 100644 --- a/spikes/pi-wielder/src/payment-policy.mjs +++ b/spikes/pi-wielder/src/payment-policy.mjs @@ -312,40 +312,45 @@ export function canonicalRequestHash({ method, requestUrl, bodyBytes }) { } export function createPaymentPolicy(config) { - optionalExactObject(config, + const capturedConfig = Object.freeze({ ...(config ?? {}) }); + optionalExactObject(capturedConfig, ['network', 'chainId', 'asset', 'sessionBudgetAtomic', 'maxQuoteAgeMs', 'maxAuthorizationSeconds', 'sellers'], ['now', 'verifySettlementProof', 'verifyRejectionProof'], 'POLICY_SCHEMA', 'payment policy'); - if (config.network !== BASE_SEPOLIA_NETWORK) fail('NETWORK_CONFIG', 'only Base Sepolia is supported'); - if (config.chainId !== BASE_SEPOLIA_CHAIN_ID) fail('CHAIN_CONFIG', 'only Base Sepolia chain ID 84532 is supported'); - if (config.asset !== BASE_SEPOLIA_USDC) fail('ASSET_CONFIG', 'only canonical Base Sepolia USDC is supported'); - const budget = canonicalAtomic(config.sessionBudgetAtomic, 'sessionBudgetAtomic').value; - if (!Number.isSafeInteger(config.maxQuoteAgeMs) || config.maxQuoteAgeMs < 0) { + if (capturedConfig.network !== BASE_SEPOLIA_NETWORK) fail('NETWORK_CONFIG', 'only Base Sepolia is supported'); + if (capturedConfig.chainId !== BASE_SEPOLIA_CHAIN_ID) fail('CHAIN_CONFIG', 'only Base Sepolia chain ID 84532 is supported'); + if (capturedConfig.asset !== BASE_SEPOLIA_USDC) fail('ASSET_CONFIG', 'only canonical Base Sepolia USDC is supported'); + const budget = canonicalAtomic(capturedConfig.sessionBudgetAtomic, 'sessionBudgetAtomic').value; + const maxQuoteAgeMs = capturedConfig.maxQuoteAgeMs; + const maxAuthorizationSeconds = capturedConfig.maxAuthorizationSeconds; + if (!Number.isSafeInteger(maxQuoteAgeMs) || maxQuoteAgeMs < 0) { fail('FRESHNESS_CONFIG', 'maxQuoteAgeMs must be a non-negative safe integer'); } - if (!Number.isSafeInteger(config.maxAuthorizationSeconds) || config.maxAuthorizationSeconds <= 0) { + if (!Number.isSafeInteger(maxAuthorizationSeconds) || maxAuthorizationSeconds <= 0) { fail('TIMEOUT_CONFIG', 'maxAuthorizationSeconds must be a positive safe integer'); } - if (!Array.isArray(config.sellers) || config.sellers.length === 0) { + const sellerInputs = capturedConfig.sellers; + if (!Array.isArray(sellerInputs) || sellerInputs.length === 0) { fail('SELLER_CONFIG', 'at least one trusted seller is required'); } - const now = config.now ?? (() => Date.now()); + const now = capturedConfig.now ?? (() => Date.now()); if (typeof now !== 'function') fail('CLOCK_CONFIG', 'now must be an injected clock function'); - const verifySettlementProof = config.verifySettlementProof ?? (() => false); - const verifyRejectionProof = config.verifyRejectionProof ?? (() => false); + const verifySettlementProof = capturedConfig.verifySettlementProof ?? (() => false); + const verifyRejectionProof = capturedConfig.verifyRejectionProof ?? (() => false); if (typeof verifySettlementProof !== 'function' || typeof verifyRejectionProof !== 'function') { fail('PROOF_CONFIG', 'proof verifiers must be injected functions'); } - const rules = config.sellers.map((input) => { + const rules = [...sellerInputs].map((input) => { exactObject(input, ['origin', 'pathPrefix', 'payTo', 'maxPerCallAtomic'], 'SELLER_SCHEMA', 'seller rule'); + const capturedInput = frozenCopy(input); const rule = { - origin: sellerOrigin(input.origin), - pathPrefix: sellerPathPrefix(input.pathPrefix), - payTo: canonicalAddress(input.payTo, 'seller.payTo', 'SELLER_PAYEE'), - maxPerCallAtomic: canonicalAtomic(input.maxPerCallAtomic, 'maxPerCallAtomic').text, + origin: sellerOrigin(capturedInput.origin), + pathPrefix: sellerPathPrefix(capturedInput.pathPrefix), + payTo: canonicalAddress(capturedInput.payTo, 'seller.payTo', 'SELLER_PAYEE'), + maxPerCallAtomic: canonicalAtomic(capturedInput.maxPerCallAtomic, 'maxPerCallAtomic').text, }; if (BigInt(rule.maxPerCallAtomic) <= 0n) fail('SELLER_LIMIT', 'seller per-call cap must be positive'); return deepFreeze(rule); @@ -362,9 +367,52 @@ export function createPaymentPolicy(config) { const records = new Map(); const authorizationNonces = new Map(); const settlementTransactions = new Map(); + const activeMonetaryTransitions = new Map(); let reservedAtomic = 0n; let spentAtomic = 0n; + function commitBudget({ reservedDelta = 0n, spentDelta = 0n }) { + const nextReserved = reservedAtomic + reservedDelta; + const nextSpent = spentAtomic + spentDelta; + const nextRemaining = budget - nextReserved - nextSpent; + if (nextReserved < 0n || nextSpent < 0n || nextRemaining < 0n + || nextReserved + nextSpent + nextRemaining !== budget) { + fail('BUDGET_INVARIANT', 'payment transition would violate budget conservation'); + } + reservedAtomic = nextReserved; + spentAtomic = nextSpent; + } + + function beginMonetaryTransition(record) { + const active = activeMonetaryTransitions.get(record.authorizationId); + if (active) { + active.reentered = true; + fail('TRANSITION_REENTRANCY', 'reentrant monetary transition is forbidden'); + } + const transition = { + record, + expectedState: record.state, + reentered: false, + }; + activeMonetaryTransitions.set(record.authorizationId, transition); + return transition; + } + + function assertMonetaryTransition(transition) { + if (transition.reentered + || activeMonetaryTransitions.get(transition.record.authorizationId) !== transition + || records.get(transition.record.authorizationId) !== transition.record + || transition.record.state !== transition.expectedState) { + fail('TRANSITION_DRIFT', 'authorization changed during a trusted monetary callback'); + } + } + + function endMonetaryTransition(transition) { + if (activeMonetaryTransitions.get(transition.record.authorizationId) === transition) { + activeMonetaryTransitions.delete(transition.record.authorizationId); + } + } + function trustedNow() { const value = now(); if (!Number.isSafeInteger(value) || value < 0) fail('CLOCK_VALUE', 'trusted clock returned an invalid millisecond value'); @@ -406,16 +454,21 @@ export function createPaymentPolicy(config) { const expiresAtMs = canonicalTimestamp(candidate.extra.expiresAt, 'expiresAt'); const receivedAtMs = receivedAt.receivedAtMs; const validationTimeMs = trustedNow(); - if (issuedAtMs > receivedAtMs || receivedAtMs - issuedAtMs > config.maxQuoteAgeMs + if (issuedAtMs > receivedAtMs || receivedAtMs - issuedAtMs > maxQuoteAgeMs || expiresAtMs <= receivedAtMs || expiresAtMs <= validationTimeMs || issuedAtMs >= expiresAtMs) { fail('QUOTE_EXPIRY', 'x402 quote is stale, future-issued, expired, or inverted'); } + if (validationTimeMs < receivedAtMs || validationTimeMs < issuedAtMs + || validationTimeMs - receivedAtMs > maxQuoteAgeMs + || validationTimeMs - issuedAtMs > maxQuoteAgeMs) { + fail('QUOTE_FRESHNESS', 'x402 quote exceeded local receipt or issue age after parsing'); + } const amount = canonicalAtomic(candidate.maxAmountRequired, 'maxAmountRequired'); if (amount.value <= 0n) fail('AMOUNT_ZERO', 'x402 amount must be positive'); if (amount.value > BigInt(seller.maxPerCallAtomic)) fail('PER_CALL_LIMIT', 'x402 amount exceeds per-call policy'); if (!Number.isSafeInteger(candidate.maxTimeoutSeconds) || candidate.maxTimeoutSeconds <= 0 - || candidate.maxTimeoutSeconds > config.maxAuthorizationSeconds) { + || candidate.maxTimeoutSeconds > maxAuthorizationSeconds) { fail('TIMEOUT_LIMIT', 'x402 timeout exceeds local policy'); } const receivedSeconds = Math.floor(receivedAtMs / 1_000); @@ -487,8 +540,8 @@ export function createPaymentPolicy(config) { signature: null, xPayment: null, }; + commitBudget({ reservedDelta: amount }); records.set(authorizationId, record); - reservedAtomic += amount; return publicRecord(record); } @@ -512,7 +565,7 @@ export function createPaymentPolicy(config) { if (!['reserved', 'signing'].includes(record.state)) { fail('UNSIGNED_RELEASE_STATE', 'only an authorization that cannot have produced a signature may be released'); } - reservedAtomic -= BigInt(record.amountAtomic); + commitBudget({ reservedDelta: -BigInt(record.amountAtomic) }); record.state = 'released'; record.reasonCode = reasonCode; return publicRecord(record); @@ -606,11 +659,17 @@ export function createPaymentPolicy(config) { function assertAuthorizationFresh(authorizationId) { const record = get(authorizationId); const currentTimeMs = trustedNow(); + const issuedAtMs = canonicalTimestamp(record.offer.extra.issuedAt, 'issuedAt'); const expiresAtMs = canonicalTimestamp(record.offer.extra.expiresAt, 'expiresAt'); const currentTimeSeconds = BigInt(Math.floor(currentTimeMs / 1_000)); if (currentTimeMs >= expiresAtMs || currentTimeSeconds >= BigInt(record.validBefore)) { fail('QUOTE_EXPIRY', 'x402 quote or signed authorization expired before the paid retry'); } + if (currentTimeMs < record.receivedAtMs || currentTimeMs < issuedAtMs + || currentTimeMs - record.receivedAtMs > maxQuoteAgeMs + || currentTimeMs - issuedAtMs > maxQuoteAgeMs) { + fail('QUOTE_FRESHNESS', 'x402 quote exceeded local receipt or issue age before retry'); + } return publicRecord(record); } @@ -659,7 +718,8 @@ export function createPaymentPolicy(config) { } } - function settle(record, evidence) { + function settle(record, evidence, transition) { + assertMonetaryTransition(transition); if (record.state === 'settled') { if (record.txHash !== evidence.transaction) fail('SETTLEMENT_CONFLICT', 'authorization already binds another transaction'); return publicRecord(record); @@ -668,9 +728,14 @@ export function createPaymentPolicy(config) { if (transactionOwner && transactionOwner !== record.authorizationId) { fail('TRANSACTION_REUSE', 'settlement transaction is already bound to another authorization'); } + if (!['signed', 'retrying', 'unresolved'].includes(record.state)) { + fail('SETTLEMENT_STATE', 'authorization is not in a settleable state'); + } + commitBudget({ + reservedDelta: -BigInt(record.amountAtomic), + spentDelta: BigInt(record.amountAtomic), + }); settlementTransactions.set(evidence.transaction, record.authorizationId); - reservedAtomic -= BigInt(record.amountAtomic); - spentAtomic += BigInt(record.amountAtomic); record.state = 'settled'; record.txHash = evidence.transaction; record.reasonCode = null; @@ -678,13 +743,19 @@ export function createPaymentPolicy(config) { } function acceptSettlement(authorizationId, input) { - const record = get(authorizationId); + const capturedAuthorizationId = id(authorizationId); + const evidence = settlementSchema(frozenCopy(input)); + const record = get(capturedAuthorizationId); if (!['retrying', 'settled'].includes(record.state)) { fail('SETTLEMENT_STATE', 'only the immediate paid retry response can settle without trusted reconciliation'); } - const evidence = settlementSchema(frozenCopy(input)); assertSettlementMatches(record, evidence); - return settle(record, evidence); + const transition = beginMonetaryTransition(record); + try { + return settle(record, evidence, transition); + } finally { + endMonetaryTransition(transition); + } } function verifierAccepted(verifier, record, proof) { @@ -694,34 +765,48 @@ export function createPaymentPolicy(config) { } function reconcileSettlement(authorizationId, proof) { + const capturedAuthorizationId = id(authorizationId); const capturedProof = frozenCopy(proof); const evidence = reconciliationSchema(capturedProof, 'settled'); - const record = get(authorizationId); + const record = get(capturedAuthorizationId); if (!['signed', 'retrying', 'unresolved', 'settled'].includes(record.state)) { fail('RECONCILIATION_STATE', 'settlement reconciliation requires a signed authorization'); } assertSettlementMatches(record, evidence, 'RECONCILIATION_MISMATCH'); - if (!verifierAccepted(verifySettlementProof, record, capturedProof)) { - fail('SETTLEMENT_PROOF', 'trusted settlement proof verifier rejected evidence'); + const transition = beginMonetaryTransition(record); + try { + if (!verifierAccepted(verifySettlementProof, record, capturedProof)) { + fail('SETTLEMENT_PROOF', 'trusted settlement proof verifier rejected evidence'); + } + assertMonetaryTransition(transition); + return settle(record, evidence, transition); + } finally { + endMonetaryTransition(transition); } - return settle(record, evidence); } function reconcileRejection(authorizationId, proof) { + const capturedAuthorizationId = id(authorizationId); const capturedProof = frozenCopy(proof); const evidence = reconciliationSchema(capturedProof, 'rejected'); - const record = get(authorizationId); + const record = get(capturedAuthorizationId); if (!['signed', 'retrying', 'unresolved'].includes(record.state)) { fail('RECONCILIATION_STATE', 'rejection reconciliation requires a nonterminal signed authorization'); } assertSettlementMatches(record, evidence, 'RECONCILIATION_MISMATCH'); - if (!verifierAccepted(verifyRejectionProof, record, capturedProof)) { - fail('REJECTION_PROOF', 'trusted rejection proof verifier rejected evidence'); + const transition = beginMonetaryTransition(record); + try { + if (!verifierAccepted(verifyRejectionProof, record, capturedProof)) { + fail('REJECTION_PROOF', 'trusted rejection proof verifier rejected evidence'); + } + assertMonetaryTransition(transition); + commitBudget({ reservedDelta: -BigInt(record.amountAtomic) }); + record.state = 'rejected'; + record.reasonCode = capturedProof.reasonCode; + return publicRecord(record); + } finally { + endMonetaryTransition(transition); } - reservedAtomic -= BigInt(record.amountAtomic); - record.state = 'rejected'; - record.reasonCode = capturedProof.reasonCode; - return publicRecord(record); } function snapshot() { diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index 0069329..314193d 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -43,6 +43,11 @@ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64'); const payingFetchOptionKeys = new Set([ 'fetchImpl', 'idempotencyKey', 'paymentPolicy', 'onSignedAuthorizationPersisted', 'nonceFactory', ]); +const requestInitKeys = Object.freeze([ + 'body', 'cache', 'credentials', 'dispatcher', 'duplex', 'headers', 'integrity', 'keepalive', + 'method', 'mode', 'priority', 'redirect', 'referrer', 'referrerPolicy', 'signal', 'window', +]); +const requestInitKeySet = new Set(requestInitKeys); function paymentError(code, message) { return new PaymentPolicyError(code, message); @@ -90,6 +95,90 @@ function ownedRequestHeaders(input) { return Object.fromEntries(normalized.entries()); } +function captureRequestInitDictionary(input) { + if (input == null) return {}; + const source = Object(input); + const captured = {}; + for (const key of requestInitKeys) { + const value = source[key]; + if (value !== undefined) captured[key] = value; + } + for (const key of Reflect.ownKeys(source)) { + if (typeof key === 'string' && requestInitKeySet.has(key)) continue; + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (descriptor?.enumerable) captured[key] = source[key]; + } + return captured; +} + +function capturePayingRequestInit(init, idempotencyKey) { + // Materialize caller-owned accessors exactly once. Every transport request is + // rebuilt from this private snapshot, never by re-spreading the caller object. + const captured = captureRequestInitDictionary(init); + const method = captured.method ?? 'GET'; + if (typeof method !== 'string' || method !== method.toUpperCase()) { + throw paymentError('REQUEST_METHOD', 'request method must be uppercase'); + } + + const callerBody = captured.body; + let bodyBytes; + let transportBody; + if (callerBody == null) { + bodyBytes = null; + transportBody = callerBody; + } else if (typeof callerBody === 'string') { + bodyBytes = callerBody; + transportBody = callerBody; + } else if (callerBody instanceof Uint8Array) { + bodyBytes = Buffer.from(callerBody); + transportBody = bodyBytes; + } else { + throw paymentError( + 'REQUEST_BODY', + 'payingFetch requires a replayable string, Uint8Array, or null request body', + ); + } + + const requestHeaders = Object.freeze({ + ...ownedRequestHeaders(captured.headers), + 'Idempotency-Key': idempotencyKey, + }); + const hasBody = Object.hasOwn(captured, 'body'); + const baseInit = { ...captured, method }; + delete baseInit.headers; + delete baseInit.body; + delete baseInit.redirect; + Object.freeze(baseInit); + + function transportInit(xPayment = null) { + const request = { + ...baseInit, + method, + redirect: 'error', + headers: { + ...requestHeaders, + ...(xPayment === null ? {} : { 'X-PAYMENT': xPayment }), + }, + }; + if (hasBody) { + request.body = transportBody instanceof Uint8Array + ? Buffer.from(transportBody) + : transportBody; + } + return request; + } + + function policyBodyBytes() { + return bodyBytes instanceof Uint8Array ? Buffer.from(bodyBytes) : bodyBytes; + } + + return Object.freeze({ + method, + policyBodyBytes, + transportInit, + }); +} + function decodeSettlementHeader(value) { if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { throw paymentError('SETTLEMENT_EVIDENCE', 'settlement evidence is missing or malformed'); @@ -131,16 +220,10 @@ export async function payingFetch(account, url, init, options = {}) { if (typeof idempotencyKey !== 'string' || !/^[A-Za-z0-9._:-]{1,200}$/.test(idempotencyKey)) { throw paymentError('AUTHORIZATION_ID', 'idempotencyKey must be a bounded canonical token'); } - const method = init?.method ?? 'GET'; - if (typeof method !== 'string' || method !== method.toUpperCase()) { - throw paymentError('REQUEST_METHOD', 'request method must be uppercase'); - } - const requestHeaders = { - ...ownedRequestHeaders(init?.headers), - 'Idempotency-Key': idempotencyKey, - }; + const request = capturePayingRequestInit(init, idempotencyKey); + const { method } = request; const t0 = performance.now(); - const first = await fetchImpl(url, { ...init, redirect: 'error', headers: requestHeaders }); + const first = await fetchImpl(url, request.transportInit()); if (first.status !== 402) return { res: first, paid: false, idempotencyKey }; const receivedAt = paymentPolicy.captureReceivedAt(); const ms402 = performance.now() - t0; @@ -155,7 +238,7 @@ export async function payingFetch(account, url, init, options = {}) { authorizationId: idempotencyKey, requestUrl: url, method, - bodyBytes: init?.body ?? null, + bodyBytes: request.policyBodyBytes(), challenge: firstChallenge, receivedAt, }); @@ -234,7 +317,7 @@ export async function payingFetch(account, url, init, options = {}) { xPayment, }); } catch (error) { - if (!signatureReturned && error?.signatureProduced === false) { + if (!signatureReturned) { paymentPolicy.releaseUnsigned(idempotencyKey, { reasonCode: 'SIGNER_REJECTED' }); } else { paymentPolicy.markPotentiallySigned(idempotencyKey, { @@ -251,7 +334,7 @@ export async function payingFetch(account, url, init, options = {}) { authorizationId: idempotencyKey, requestUrl: url, method, - bodyBytes: init?.body ?? null, + bodyBytes: request.policyBodyBytes(), }); } else { throw paymentError( @@ -273,11 +356,7 @@ export async function payingFetch(account, url, init, options = {}) { const tRetry = performance.now(); let res; try { - res = await fetchImpl(url, { - ...init, - redirect: 'error', - headers: { ...requestHeaders, 'X-PAYMENT': xPayment }, - }); + res = await fetchImpl(url, request.transportInit(xPayment)); } catch (error) { paymentPolicy.markUnresolved(idempotencyKey, { reasonCode: 'RETRY_RESPONSE_LOST' }); throw error; diff --git a/spikes/pi-wielder/tests/paying-fetch.test.mjs b/spikes/pi-wielder/tests/paying-fetch.test.mjs index 72ed5be..aed97cf 100644 --- a/spikes/pi-wielder/tests/paying-fetch.test.mjs +++ b/spikes/pi-wielder/tests/paying-fetch.test.mjs @@ -208,6 +208,116 @@ test('a quote expiring while the first 402 JSON is parsed is rejected before sig assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); }); +test('trusted quote age is rechecked after JSON parse against receipt and issue times', async () => { + const cases = [ + { + name: 'receipt age', + candidate: baseOffer({ + extra: { ...baseOffer().extra, issuedAt: new Date(NOW).toISOString() }, + }), + afterParse: NOW + 5_001, + }, + { + name: 'issue age', + candidate: baseOffer({ + extra: { ...baseOffer().extra, issuedAt: new Date(NOW - 4_000).toISOString() }, + }), + afterParse: NOW + 2_000, + }, + { + name: 'backward clock', + candidate: baseOffer(), + afterParse: NOW - 1, + }, + ]; + + for (const { name, candidate, afterParse } of cases) { + const { account, paymentPolicy, setClock, signatureCount } = setup(); + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + idempotencyKey: `idem-parse-${name.replaceAll(' ', '-')}`, + paymentPolicy, + fetchImpl: async () => { + fetches += 1; + return { + status: 402, + async json() { + setClock(afterParse); + return challengePayload(candidate); + }, + }; + }, + }), (error) => error.code === 'QUOTE_FRESHNESS'); + assert.equal(fetches, 1, name); + assert.equal(signatureCount(), 0, name); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0', name); + } +}); + +test('caller RequestInit accessors and mutable body bytes are captured once for both requests', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + const originalBody = Buffer.from('{"input":"captured"}', 'utf8'); + const expectedBody = Buffer.from(originalBody); + const expectedRequestHash = canonicalRequestHash({ + method: 'POST', requestUrl: URL, bodyBytes: expectedBody, + }); + const capturedOffer = baseOffer({ + extra: { ...baseOffer().extra, requestHash: expectedRequestHash }, + }); + const reads = { method: 0, body: 0, headers: 0 }; + const requestInitPrototype = {}; + Object.defineProperties(requestInitPrototype, { + method: { + enumerable: true, + get() { + reads.method += 1; + return reads.method === 1 ? 'POST' : 'PUT'; + }, + }, + body: { + enumerable: true, + get() { + reads.body += 1; + return reads.body === 1 ? originalBody : Buffer.from('{"input":"changed"}', 'utf8'); + }, + }, + headers: { + enumerable: true, + get() { + reads.headers += 1; + return reads.headers === 1 + ? { 'content-type': 'application/json', 'x-captured': 'yes' } + : { 'content-type': 'text/plain', 'x-captured': 'no' }; + }, + }, + }); + const callerInit = Object.create(requestInitPrototype); + + let fetches = 0; + const result = await payingFetch(account, URL, callerInit, { + idempotencyKey: 'idem-captured-init', + paymentPolicy, + fetchImpl: async (_url, requestInit) => { + fetches += 1; + assert.equal(requestInit.method, 'POST'); + assert.deepEqual(Buffer.from(requestInit.body), expectedBody); + assert.equal(new Headers(requestInit.headers).get('x-captured'), 'yes'); + if (fetches === 1) { + originalBody.fill(0x78); + callerInit.injected = 'late mutation'; + return challenge(capturedOffer); + } + return paidResponse(requestInit, { settlement: { requestHash: expectedRequestHash } }); + }, + }); + + assert.equal(result.res.status, 200); + assert.equal(fetches, 2); + assert.equal(signatureCount(), 1); + assert.deepEqual(reads, { method: 1, body: 1, headers: 1 }); + assert.equal(result.requestHash, expectedRequestHash); +}); + test('caller-supplied payment and idempotency headers are rejected case-insensitively before fetch', async () => { for (const headers of [ { 'idempotency-key': 'caller-owned' }, @@ -498,13 +608,9 @@ test('concurrent copies of one idempotency key produce one signature and one pai assert.equal(paidRetries, 1); }); -test('signer rejection before any signature releases reservation exactly once', async () => { +test('ordinary signer rejection before any signature return releases reservation exactly once', async () => { const { account, paymentPolicy } = setup(); - account.signTypedData = async () => { - const error = new Error('wallet declined before signing'); - error.signatureProduced = false; - throw error; - }; + account.signTypedData = async () => { throw new Error('wallet declined before signing'); }; await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { fetchImpl: async () => challenge(), idempotencyKey: 'idem-declined', paymentPolicy, }), /wallet declined/); @@ -535,6 +641,24 @@ test('an invalid signer return is potentially signed and never releases budget', assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); }); +test('persistence failure after a signature return remains unresolved with budget held', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + const failingPersistencePolicy = Object.freeze({ + ...paymentPolicy, + persistSignedAuthorization() { + throw new Error('synthetic persistence failure after signer return'); + }, + }); + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => challenge(), + idempotencyKey: 'idem-persistence-failure', + paymentPolicy: failingPersistencePolicy, + }), /synthetic persistence failure/); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); +}); + test('a quote expiring after signature persistence never starts the paid retry', async () => { const { account, paymentPolicy, setClock, signatureCount } = setup(); let fetches = 0; @@ -557,6 +681,42 @@ test('a quote expiring after signature persistence never starts the paid retry', assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); }); +test('trusted quote age and monotonic time are rechecked after signing before retry', async () => { + const cases = [ + { + name: 'issue age', + candidate: baseOffer({ + extra: { ...baseOffer().extra, issuedAt: new Date(NOW - 4_000).toISOString() }, + }), + beforeRetry: NOW + 2_000, + }, + { + name: 'backward clock', + candidate: baseOffer(), + beforeRetry: NOW - 1, + }, + ]; + + for (const { name, candidate, beforeRetry } of cases) { + const { account, paymentPolicy, setClock, signatureCount } = setup(); + let fetches = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + idempotencyKey: `idem-retry-${name.replaceAll(' ', '-')}`, + paymentPolicy, + fetchImpl: async () => { + fetches += 1; + if (fetches > 1) throw new Error('stale authorization must not start a paid retry'); + return challenge(candidate); + }, + onSignedAuthorizationPersisted: () => { setClock(beforeRetry); }, + }), (error) => error.code === 'QUOTE_FRESHNESS'); + assert.equal(fetches, 1, name); + assert.equal(signatureCount(), 1, name); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000', name); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved', name); + } +}); + test('a fault after synchronous signature persistence recovers exact X-PAYMENT without signing again', async () => { const { account, paymentPolicy, signatureCount } = setup(); let firstFetches = 0; diff --git a/spikes/pi-wielder/tests/payment-policy.test.mjs b/spikes/pi-wielder/tests/payment-policy.test.mjs index ab48ab9..0dcb4fe 100644 --- a/spikes/pi-wielder/tests/payment-policy.test.mjs +++ b/spikes/pi-wielder/tests/payment-policy.test.mjs @@ -178,6 +178,43 @@ test('each trusted local receipt-time capability is single-use even for the same (error) => error.code === 'RECEIVED_AT'); }); +test('validated policy primitives and seller rules are snapshotted at construction', () => { + const seller = { + origin: 'https://trusted.example', + pathPrefix: '/invoke/', + payTo: PAYEE, + maxPerCallAtomic: '300000', + }; + const config = { + network: BASE_SEPOLIA_NETWORK, + chainId: BASE_SEPOLIA_CHAIN_ID, + asset: BASE_SEPOLIA_USDC, + sessionBudgetAtomic: '500000', + maxQuoteAgeMs: 5_000, + maxAuthorizationSeconds: 60, + now: () => NOW, + sellers: [seller], + }; + const subject = createPaymentPolicy(config); + + config.network = 'base'; + config.chainId = 1; + config.asset = `0x${'9'.repeat(40)}`; + config.sessionBudgetAtomic = '1'; + config.maxQuoteAgeMs = 0; + config.maxAuthorizationSeconds = 1; + seller.origin = 'https://evil.example'; + seller.pathPrefix = '/'; + seller.payTo = `0x${'8'.repeat(40)}`; + seller.maxPerCallAtomic = '1'; + config.sellers.length = 0; + + const record = reserve(subject); + assert.equal(record.amountAtomic, '250000'); + assert.equal(record.offer.maxTimeoutSeconds, 60); + assert.equal(subject.snapshot().remainingAtomic, '250000'); +}); + const rejectionCases = [ ['wrong scheme', offer({ scheme: 'upto' }), 'SCHEME'], ['wrong network', offer({ network: 'Base-Sepolia' }), 'NETWORK'], @@ -515,6 +552,117 @@ test('trusted reconciliation captures accessor-backed proof fields exactly once' assert.equal(subject.snapshot().authorizations[0].reasonCode, 'CHAIN_REJECTED'); }); +test('reentrant settlement input and proof verifiers cannot double-commit budget', () => { + { + const subject = policy({ verifyRejectionProof: () => true }); + sign(subject); + subject.beginRetry('auth-1'); + const rejection = { + ...settlementEvidence(subject), + success: false, + transaction: null, + outcome: 'rejected', + reasonCode: 'CHAIN_REJECTED', + trustToken: 'trusted-rejection', + }; + const settlement = settlementEvidence(subject); + Object.defineProperty(settlement, 'transaction', { + enumerable: true, + get() { + subject.reconcileRejection('auth-1', rejection); + return TX_HASH; + }, + }); + + assert.throws(() => subject.acceptSettlement('auth-1', settlement), + (error) => ['SETTLEMENT_STATE', 'TRANSITION_DRIFT'].includes(error.code)); + assert.deepEqual(subject.snapshot(), { + sessionBudgetAtomic: '500000', + reservedAtomic: '0', + spentAtomic: '0', + remainingAtomic: '500000', + authorizations: [{ + authorizationId: 'auth-1', amountAtomic: '250000', state: 'rejected', + retryCount: 1, txHash: null, reasonCode: 'CHAIN_REJECTED', + }], + }); + } + + { + let subject; + let rejection; + subject = policy({ + verifyRejectionProof: () => true, + verifySettlementProof: () => { + subject.reconcileRejection('auth-1', rejection); + return true; + }, + }); + sign(subject); + subject.beginRetry('auth-1'); + subject.markUnresolved('auth-1', { reasonCode: 'RETRY_RESPONSE_LOST' }); + const evidence = settlementEvidence(subject); + rejection = { + ...evidence, + success: false, + transaction: null, + outcome: 'rejected', + reasonCode: 'CHAIN_REJECTED', + trustToken: 'trusted-rejection', + }; + const settlementProof = { + ...evidence, + outcome: 'settled', + trustToken: 'trusted-settlement', + }; + + assert.throws(() => subject.reconcileSettlement('auth-1', settlementProof), + (error) => ['TRANSITION_REENTRANCY', 'TRANSITION_DRIFT'].includes(error.code)); + const snapshot = subject.snapshot(); + assert.equal(snapshot.reservedAtomic, '250000'); + assert.equal(snapshot.spentAtomic, '0'); + assert.equal(snapshot.remainingAtomic, '250000'); + assert.equal(snapshot.authorizations[0].state, 'unresolved'); + } + + { + let subject; + let settlementProof; + subject = policy({ + verifySettlementProof: () => true, + verifyRejectionProof: () => { + subject.reconcileSettlement('auth-1', settlementProof); + return true; + }, + }); + sign(subject); + subject.beginRetry('auth-1'); + subject.markUnresolved('auth-1', { reasonCode: 'RETRY_RESPONSE_LOST' }); + const evidence = settlementEvidence(subject); + settlementProof = { + ...evidence, + outcome: 'settled', + trustToken: 'trusted-settlement', + }; + const rejection = { + ...evidence, + success: false, + transaction: null, + outcome: 'rejected', + reasonCode: 'CHAIN_REJECTED', + trustToken: 'trusted-rejection', + }; + + assert.throws(() => subject.reconcileRejection('auth-1', rejection), + (error) => ['TRANSITION_REENTRANCY', 'TRANSITION_DRIFT'].includes(error.code)); + const snapshot = subject.snapshot(); + assert.equal(snapshot.reservedAtomic, '250000'); + assert.equal(snapshot.spentAtomic, '0'); + assert.equal(snapshot.remainingAtomic, '250000'); + assert.equal(snapshot.authorizations[0].state, 'unresolved'); + } +}); + test('one EIP-3009 nonce cannot be persisted under two authorizations', () => { const subject = policy(); sign(subject, reserve(subject, { authorizationId: 'auth-nonce-1' })); From 11a4cb7ee629a81a915859d15c34a17561ff74a4 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 06:18:35 -0400 Subject: [PATCH 111/165] fix: close attestation CLI and trust gaps --- phase0/src/attestation-cli.ts | 72 +++++--- phase0/src/attestations.ts | 59 ++++++- phase0/tests/attestation-cli.test.ts | 224 ++++++++++++++++++++++++- phase0/tests/attestation-store.test.ts | 38 +++++ phase0/tests/attestations.test.ts | 74 +++++++- 5 files changed, 428 insertions(+), 39 deletions(-) diff --git a/phase0/src/attestation-cli.ts b/phase0/src/attestation-cli.ts index 3075175..81fd042 100644 --- a/phase0/src/attestation-cli.ts +++ b/phase0/src/attestation-cli.ts @@ -6,6 +6,7 @@ import { parseAttestationEvent, reduceAttestationEvents, registrationSubjectsFromManifest, + type AttestationConflict, type AttestationEvent, type AttestationIndex, type ForgeObservationV1, @@ -25,7 +26,11 @@ import { type GitReader, type SignedRepositoryChallengeFileV1, } from "./attestation-git"; -import { FileAttestationStore, type AttestationRepositoryContext } from "./attestation-store"; +import { + FileAttestationStore, + type AttestationLockMetadata, + type AttestationRepositoryContext, +} from "./attestation-store"; import { FileRegistrationStore } from "./registrations"; export type AttestationCommand = @@ -220,7 +225,7 @@ async function loadIndex(runtime: AttestationRuntime): Promise function statusPayload(index: AttestationIndex, options: AttestationCommandOptions): { registrations: unknown[]; - conflicts: unknown[]; + conflicts: AttestationConflict[]; } { if (options.artifactHash !== undefined && !/^0x[0-9a-f]{64}$/.test(options.artifactHash)) throw new Error("--artifact-hash must be a lowercase 32-byte hash"); if (options.registrationId !== undefined && !/^eip155:1315:0x[0-9a-f]{40}$/.test(options.registrationId)) throw new Error("--registration-id must be an Aeneid registration ID"); @@ -277,6 +282,35 @@ function quoteHumanIdentifier(value: string): string { return `${quoted}"`; } +function renderAttestationConflicts(conflictsValue: readonly AttestationConflict[]): string[] { + const conflicts = [...conflictsValue].sort((a, b) => a.conflictId.localeCompare(b.conflictId)); + const lines = [`conflicts: ${conflicts.length}`]; + for (const conflict of conflicts) { + lines.push(`conflict: ${quoteHumanIdentifier(conflict.conflictId)}`); + lines.push(`conflict artifact hash: ${conflict.artifactHash === null ? "(none)" : quoteHumanIdentifier(conflict.artifactHash)}`); + lines.push(`conflict status: ${conflict.status}`); + lines.push(`conflict reason: ${conflict.reason}`); + lines.push(`conflict outcome: ${conflict.outcome ?? "(none)"}`); + lines.push(`conflict registrations: ${[...conflict.registrationIds].sort().map(quoteHumanIdentifier).join(", ")}`); + lines.push(`conflict events: ${conflict.eventIds.length > 0 ? [...conflict.eventIds].sort().map(quoteHumanIdentifier).join(", ") : "(none)"}`); + } + return lines; +} + +function renderAppendSuccess(event: AttestationEvent, json: boolean): string[] { + const payload = { appended: event.eventId, type: event.type }; + if (json) return [JSON.stringify(payload, null, 2)]; + return [`appended: ${quoteHumanIdentifier(event.eventId)}; type: ${quoteHumanIdentifier(event.type)}`]; +} + +function renderRecoveredLock(metadata: AttestationLockMetadata, json: boolean): string[] { + const payload = { recovered: true, lock: metadata }; + if (json) return [JSON.stringify(payload, null, 2)]; + return [ + `recovered: true; lock pid: ${metadata.pid}; token: ${quoteHumanIdentifier(metadata.token)}; target path: ${quoteHumanIdentifier(metadata.targetPath)}; acquired at: ${quoteHumanIdentifier(metadata.acquiredAt)}`, + ]; +} + export function renderAttestationStatus(index: AttestationIndex, options: AttestationCommandOptions = {}): string[] { const payload = statusPayload(index, options); if (options.json) return [JSON.stringify(payload, null, 2)]; @@ -284,24 +318,14 @@ export function renderAttestationStatus(index: AttestationIndex, options: Attest const lines: string[] = []; for (const itemValue of payload.registrations) { const item = itemValue as ReturnType & { registrationId: string }; - lines.push(`registration: ${item.registrationId}`); + lines.push(`registration: ${quoteHumanIdentifier(item.registrationId)}`); lines.push(`status: ${item.status}`); lines.push(`attestation: ${item.level}`); lines.push(`claim: ${item.claim}`); lines.push(`safety review: ${item.safetyReviewStatus}`); for (const warning of item.warnings) lines.push(`warning: ${warning}`); } - lines.push(`conflicts: ${payload.conflicts.length}`); - for (const conflictValue of payload.conflicts) { - const conflict = conflictValue as AttestationIndex["conflicts"][number]; - lines.push(`conflict: ${quoteHumanIdentifier(conflict.conflictId)}`); - lines.push(`conflict artifact hash: ${conflict.artifactHash ?? "(none)"}`); - lines.push(`conflict status: ${conflict.status}`); - lines.push(`conflict reason: ${conflict.reason}`); - lines.push(`conflict outcome: ${conflict.outcome ?? "(none)"}`); - lines.push(`conflict registrations: ${[...conflict.registrationIds].sort().join(", ")}`); - lines.push(`conflict events: ${conflict.eventIds.length > 0 ? [...conflict.eventIds].sort().map(quoteHumanIdentifier).join(", ") : "(none)"}`); - } + lines.push(...renderAttestationConflicts(payload.conflicts)); return lines; } @@ -323,10 +347,6 @@ function assertOnlyOptions(options: AttestationCommandOptions, permitted: readon } } -function outputValue(value: unknown, json: boolean): string[] { - return json ? [JSON.stringify(value, null, 2)] : [typeof value === "string" ? value : JSON.stringify(value)]; -} - export async function executeAttestationCommand( command: AttestationCommand, options: AttestationCommandOptions, @@ -341,7 +361,9 @@ export async function executeAttestationCommand( } else if (command === "attestation-conflicts") { assertOnlyOptions(options, ["json"]); const conflicts = (await loadIndex(runtime)).conflicts; - lines = outputValue({ conflicts }, Boolean(options.json)); + lines = options.json + ? [JSON.stringify({ conflicts }, null, 2)] + : renderAttestationConflicts(conflicts); } else if (command === "attestation-verify-repository") { assertOnlyOptions(options, ["bundle", "json"]); if (!options.bundle) throw new Error("attestation-verify-repository requires --bundle "); @@ -362,27 +384,27 @@ export async function executeAttestationCommand( ...context, }); await runtime.store.append(event); - lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + lines = renderAppendSuccess(event, Boolean(options.json)); } else if (command === "attestation-verify-organization") { assertOnlyOptions(options, ["bundle", "json"]); if (!options.bundle) throw new Error("attestation-verify-organization requires --bundle "); const event = await appendTypedBundle(runtime, options.bundle, "organization_approved"); - lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + lines = renderAppendSuccess(event, Boolean(options.json)); } else if (command === "attestation-append-challenge") { assertOnlyOptions(options, ["bundle", "json"]); if (!options.bundle) throw new Error("attestation-append-challenge requires --bundle "); const event = await appendTypedBundle(runtime, options.bundle, "challenge_opened"); - lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + lines = renderAppendSuccess(event, Boolean(options.json)); } else if (command === "attestation-resolve") { assertOnlyOptions(options, ["bundle", "json"]); if (!options.bundle) throw new Error("attestation-resolve requires --bundle "); const event = await appendTypedBundle(runtime, options.bundle, "challenge_resolved"); - lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + lines = renderAppendSuccess(event, Boolean(options.json)); } else if (command === "attestation-revoke") { assertOnlyOptions(options, ["bundle", "json"]); if (!options.bundle) throw new Error("attestation-revoke requires --bundle "); const event = await appendTypedBundle(runtime, options.bundle, "attestation_revoked"); - lines = outputValue({ appended: event.eventId, type: event.type }, Boolean(options.json)); + lines = renderAppendSuccess(event, Boolean(options.json)); } else { assertOnlyOptions(options, ["lockToken", "json"]); if (!options.lockToken) throw new Error("attestation-recover-lock requires --lock-token "); @@ -394,7 +416,7 @@ export async function executeAttestationCommand( } }; await runtime.store.recoverStaleLock({ expectedToken: options.lockToken, isProcessAlive }); - lines = outputValue({ recovered: true, lock: metadata }, Boolean(options.json)); + lines = renderRecoveredLock(metadata, Boolean(options.json)); } for (const line of lines) log(line); } diff --git a/phase0/src/attestations.ts b/phase0/src/attestations.ts index cbb9b02..04569fe 100644 --- a/phase0/src/attestations.ts +++ b/phase0/src/attestations.ts @@ -228,6 +228,54 @@ function assertTrustMap(value: unknown, label: string): asserts value is Readonl } } +function parseOrganizationSignerAllowList(value: unknown): readonly `0x${string}`[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + throw new Error("organization signer allow-list must be a real array"); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if (!lengthDescriptor || !("value" in lengthDescriptor) || !Number.isSafeInteger(lengthDescriptor.value)) { + throw new Error("organization signer allow-list must have own indexed entries"); + } + const expectedKeys = new Set(["length"]); + for (let index = 0; index < lengthDescriptor.value; index += 1) expectedKeys.add(String(index)); + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string" || !expectedKeys.has(key)) || ownKeys.length !== expectedKeys.size) { + throw new Error("organization signer allow-list has unexpected schema properties or inherited entries"); + } + + const wallets: `0x${string}`[] = []; + const seen = new Set(); + for (let index = 0; index < lengthDescriptor.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) { + throw new Error("organization signer allow-list must have own indexed entries"); + } + address(descriptor.value, "organization signer allow-list canonical lowercase address"); + if (seen.has(descriptor.value)) throw new Error("organization signer allow-list addresses must be unique"); + seen.add(descriptor.value); + wallets.push(descriptor.value); + } + return wallets; +} + +function parseOrganizationSignerTrust( + value: unknown, +): Readonly> { + assertTrustMap(value, "organization signer trust"); + const parsed: Record = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string" || !IDENTIFIER.test(key)) { + throw new Error("organization signer trust contains a malformed organization identifier"); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) { + throw new Error("organization signer trust entries must be own data properties"); + } + parsed[key] = parseOrganizationSignerAllowList(descriptor.value); + } + return parsed; +} + function nonempty(value: unknown, label: string): asserts value is string { if (typeof value !== "string" || value.trim() !== value || value.length === 0) { throw new Error(`${label} must be a nonempty canonical string`); @@ -537,6 +585,7 @@ export async function verifyOrganizationApproval( organizationSigners: Readonly>, ): Promise { const approval = parseApproval(approvalValue); + const trustedOrganizations = parseOrganizationSignerTrust(organizationSigners); const unsigned: UnsignedApproval = { schemaVersion: approval.schemaVersion, subject: approval.subject, @@ -546,12 +595,11 @@ export async function verifyOrganizationApproval( approvedAt: approval.approvedAt, }; if (approval.statementHash !== organizationStatementHash(unsigned)) throw new Error("organization statement hash mismatch"); - assertTrustMap(organizationSigners, "organization signer trust"); - if (!Object.hasOwn(organizationSigners, approval.organizationId)) { + if (!Object.hasOwn(trustedOrganizations, approval.organizationId)) { throw new Error("organization approver is not allow-listed; organization ID must be an own property of the trust map"); } - const trusted = organizationSigners[approval.organizationId]; - if (!trusted.includes(approval.approverWallet)) throw new Error("organization approver is not allow-listed"); + const trusted = trustedOrganizations[approval.organizationId]; + if (!trusted.some((wallet) => wallet === approval.approverWallet)) throw new Error("organization approver is not allow-listed"); if (!await verifyMessage({ address: approval.approverWallet, message: canonicalOrganizationStatement(unsigned), signature: approval.signature })) { throw new Error("organization signature does not recover the approver wallet"); } @@ -683,6 +731,7 @@ export async function reduceAttestationEvents( now?: Date; } = {}, ): Promise { + const organizationSigners = parseOrganizationSignerTrust(trust.organizationSigners ?? {}); const verifierNow = trust.now?.getTime(); if (verifierNow !== undefined && !Number.isFinite(verifierNow)) throw new Error("attestation verifier clock is invalid"); const subjects: Record = {}; @@ -815,7 +864,7 @@ export async function reduceAttestationEvents( if (registration.repositoryActivatedAt === null || approvedAt < registration.repositoryActivatedAt) { throw new Error("organization approvedAt must not precede active repository evidence"); } - await verifyOrganizationApproval(event.approval, trust.organizationSigners ?? {}); + await verifyOrganizationApproval(event.approval, organizationSigners); consumedOrganizationStatementHashes.add(event.approval.statementHash); consumedOrganizationSignatures.add(event.approval.signature); registration.organizationActive = true; diff --git a/phase0/tests/attestation-cli.test.ts b/phase0/tests/attestation-cli.test.ts index 184975a..c134b26 100644 --- a/phase0/tests/attestation-cli.test.ts +++ b/phase0/tests/attestation-cli.test.ts @@ -9,7 +9,20 @@ import test from "node:test"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { executeAttestationCommand, type AttestationRuntimePaths } from "../src/attestation-cli"; -import { canonicalRepositoryStatement, repositoryStatementHash } from "../src/attestations"; +import { + adminEventStatementHash, + canonicalAdminEventStatement, + canonicalChallengeEventStatement, + canonicalOrganizationStatement, + canonicalRepositoryStatement, + challengeEventStatementHash, + organizationStatementHash, + repositoryStatementHash, + type AttestationRevokedEvent, + type ChallengeOpenedEvent, + type ChallengeResolvedEvent, + type OrganizationApprovedEvent, +} from "../src/attestations"; import { canonicalForgeObservationBytes, type GitReader } from "../src/attestation-git"; import { createEmptyRegistrationManifest, FileRegistrationStore, type RegistrationProof } from "../src/registrations"; @@ -65,6 +78,29 @@ test("production repository command fails before Git for missing/insecure mappin artifact: { path: "skills/demo/SKILL.md", mediaHash: artifactHash, mediaType: "text/markdown" }, }, } satisfies RegistrationProof; + const challengerIpId = `0x${"f".repeat(40)}` as const; + manifest.registrations.child = { + stage: "child", + kind: "Derivative", + name: "CLI Challenger Skill", + ipId: challengerIpId, + tokenId: "2", + txHash: `0x${"4".repeat(64)}`, + licenseTermsId: "1", + licenseTemplate: `0x${"e".repeat(40)}`, + parentIpIds: [ipId], + defaultMintingFee: null, + maxMintingFee: "1", + metadata: { + ip: { uri: "https://example.invalid/challenger-ip", hash: `0x${"5".repeat(64)}` }, + nft: { uri: "https://example.invalid/challenger-nft", hash: `0x${"6".repeat(64)}` }, + artifact: { + path: "skills/challenger/SKILL.md", + mediaHash: `0x${"7".repeat(64)}`, + mediaType: "text/markdown", + }, + }, + } satisfies RegistrationProof; const paths: AttestationRuntimePaths = { registrations: join(root, "registrations.json"), @@ -135,9 +171,10 @@ test("production repository command fails before Git for missing/insecure mappin forgeSigners: { "forge-1": forge.publicKey.export({ type: "spki", format: "pem" }).toString() }, })}\n`); const bundlePath = join(root, "repository-bundle.json"); + const repositoryEventId = "repository-cli-\u0085\u202e1"; await writeFile(bundlePath, `${JSON.stringify({ schemaVersion: 1, - eventId: "repository-cli-1", + eventId: repositoryEventId, sequence: 1, occurredAt: "2026-07-18T02:00:00.000Z", challengeFile, @@ -196,7 +233,7 @@ test("production repository command fails before Git for missing/insecure mappin })}\n`); await writeFile(bundlePath, `${JSON.stringify({ schemaVersion: 1, - eventId: "repository-cli-1", + eventId: repositoryEventId, sequence: 1, occurredAt: "2026-07-18T02:00:00.000Z", challengeFile, @@ -217,7 +254,7 @@ test("production repository command fails before Git for missing/insecure mappin })}\n`); await writeFile(bundlePath, `${JSON.stringify({ schemaVersion: 1, - eventId: "repository-cli-1", + eventId: repositoryEventId, sequence: 1, occurredAt: "2026-07-18T02:00:00.000Z", challengeFile, @@ -230,9 +267,186 @@ test("production repository command fails before Git for missing/insecure mappin (line) => output.push(line), { phase0Root: root, paths, env: {}, now: () => NOW }, ); - assert.match(output.join("\n"), /repository-cli-1/); + assert.equal(output.join("\n"), JSON.stringify({ + appended: repositoryEventId, + type: "repository_control_verified", + }, null, 2)); const persisted = await readFile(paths.attestations, "utf8"); assert.equal(JSON.parse(persisted).type, "repository_control_verified"); + + const approver = privateKeyToAccount(generatePrivateKey()); + const admin = privateKeyToAccount(generatePrivateKey()); + await writeFile(paths.organizations, `${JSON.stringify({ + schemaVersion: 1, + organizations: { "example-org": [approver.address.toLowerCase()] }, + })}\n`); + await writeFile(paths.admins, `${JSON.stringify({ + schemaVersion: 1, + admins: { "admin-1": admin.address.toLowerCase() }, + })}\n`); + + const unsignedApproval = { + schemaVersion: 1 as const, + subject: challenge.subject, + organizationId: "example-org", + approverWallet: approver.address.toLowerCase() as `0x${string}`, + role: "ip_admin" as const, + approvedAt: "2026-07-18T02:30:00.000Z", + }; + const approval = { + ...unsignedApproval, + statementHash: organizationStatementHash(unsignedApproval), + signature: await approver.signMessage({ message: canonicalOrganizationStatement(unsignedApproval) }), + }; + const organizationEventId = "organization-cli-\u001b\u0085\u202e1"; + const organizationEvent: OrganizationApprovedEvent = { + type: "organization_approved", + eventId: organizationEventId, + sequence: 2, + occurredAt: "2026-07-18T03:00:00.000Z", + subject: challenge.subject, + approval, + }; + const organizationBundle = join(root, "organization-bundle.json"); + await writeFile(organizationBundle, `${JSON.stringify(organizationEvent)}\n`); + const organizationOutput: string[] = []; + await executeAttestationCommand( + "attestation-verify-organization", + { bundle: organizationBundle }, + (line) => organizationOutput.push(line), + { phase0Root: root, paths, env: {}, now: () => NOW }, + ); + assert.deepEqual(organizationOutput, [ + 'appended: "organization-cli-\\u001b\\u0085\\u202e1"; type: "organization_approved"', + ]); + + const conflictId = "conflict-cli-\u001b\u0085\u202e1"; + const challengeEventId = "challenge-cli-\n\u009b\u20671"; + const challengerRegistrationId = `eip155:1315:${challengerIpId}` as const; + const challengeBase = { + type: "challenge_opened" as const, + eventId: challengeEventId, + sequence: 3, + occurredAt: "2026-07-18T03:15:00.000Z", + conflictId, + challengedRegistrationId: challenge.subject.registrationId, + challengerRegistrationId, + challengerWallet: challenge.subject.wallet, + evidenceUris: ["https://example.invalid/evidence"], + reason: "misattributed_creator" as const, + statementHash: artifactHash, + signature: "0x00" as `0x${string}`, + }; + const challengeHash = challengeEventStatementHash(challengeBase); + const challengeEvent: ChallengeOpenedEvent = { + ...challengeBase, + statementHash: challengeHash, + signature: await wallet.signMessage({ + message: canonicalChallengeEventStatement({ ...challengeBase, statementHash: challengeHash }), + }), + }; + const challengeBundle = join(root, "challenge-bundle.json"); + await writeFile(challengeBundle, `${JSON.stringify(challengeEvent)}\n`); + const challengeOutput: string[] = []; + await executeAttestationCommand( + "attestation-append-challenge", + { bundle: challengeBundle }, + (line) => challengeOutput.push(line), + { phase0Root: root, paths, env: {}, now: () => NOW }, + ); + assert.deepEqual(challengeOutput, [ + 'appended: "challenge-cli-\\u000a\\u009b\\u20671"; type: "challenge_opened"', + ]); + + const resolutionEventId = "resolution-cli-\r\u007f\u200f1"; + const resolutionBase = { + type: "challenge_resolved" as const, + eventId: resolutionEventId, + sequence: 4, + occurredAt: "2026-07-18T03:30:00.000Z", + conflictId, + outcome: "inconclusive" as const, + rationale: "The signed evidence remains incomplete.", + adminSignerId: "admin-1", + statementHash: artifactHash, + signature: "0x00" as `0x${string}`, + }; + const resolutionHash = adminEventStatementHash(resolutionBase); + const resolutionEvent: ChallengeResolvedEvent = { + ...resolutionBase, + statementHash: resolutionHash, + signature: await admin.signMessage({ + message: canonicalAdminEventStatement({ ...resolutionBase, statementHash: resolutionHash }), + }), + }; + const resolutionBundle = join(root, "resolution-bundle.json"); + await writeFile(resolutionBundle, `${JSON.stringify(resolutionEvent)}\n`); + const resolutionOutput: string[] = []; + await executeAttestationCommand( + "attestation-resolve", + { bundle: resolutionBundle }, + (line) => resolutionOutput.push(line), + { phase0Root: root, paths, env: {}, now: () => NOW }, + ); + assert.deepEqual(resolutionOutput, [ + 'appended: "resolution-cli-\\u000d\\u007f\\u200f1"; type: "challenge_resolved"', + ]); + + const revocationEventId = "revocation-cli-\u0000\u009f\u061c1"; + const revocationBase = { + type: "attestation_revoked" as const, + eventId: revocationEventId, + sequence: 5, + occurredAt: "2026-07-18T03:45:00.000Z", + registrationId: challenge.subject.registrationId, + level: "organization_approved" as const, + reason: "Organization evidence is no longer active.", + adminSignerId: "admin-1", + statementHash: artifactHash, + signature: "0x00" as `0x${string}`, + }; + const revocationHash = adminEventStatementHash(revocationBase); + const revocationEvent: AttestationRevokedEvent = { + ...revocationBase, + statementHash: revocationHash, + signature: await admin.signMessage({ + message: canonicalAdminEventStatement({ ...revocationBase, statementHash: revocationHash }), + }), + }; + const revocationBundle = join(root, "revocation-bundle.json"); + await writeFile(revocationBundle, `${JSON.stringify(revocationEvent)}\n`); + const revocationOutput: string[] = []; + await executeAttestationCommand( + "attestation-revoke", + { bundle: revocationBundle }, + (line) => revocationOutput.push(line), + { phase0Root: root, paths, env: {}, now: () => NOW }, + ); + assert.deepEqual(revocationOutput, [ + 'appended: "revocation-cli-\\u0000\\u009f\\u061c1"; type: "attestation_revoked"', + ]); + + const conflictsOutput: string[] = []; + await executeAttestationCommand( + "attestation-conflicts", + {}, + (line) => conflictsOutput.push(line), + { phase0Root: root, paths, env: {}, now: () => NOW }, + ); + assert.ok(conflictsOutput.includes('conflict: "conflict-cli-\\u001b\\u0085\\u202e1"')); + assert.ok(conflictsOutput.includes('conflict events: "challenge-cli-\\u000a\\u009b\\u20671", "resolution-cli-\\u000d\\u007f\\u200f1"')); + assert.ok(conflictsOutput.every((line) => !/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(line))); + + const conflictJsonOutput: string[] = []; + await executeAttestationCommand( + "attestation-conflicts", + { json: true }, + (line) => conflictJsonOutput.push(line), + { phase0Root: root, paths, env: {}, now: () => NOW }, + ); + const conflictJson = JSON.parse(conflictJsonOutput.join("\n")); + assert.equal(conflictJson.conflicts[0].conflictId, conflictId); + assert.deepEqual(conflictJson.conflicts[0].eventIds, [challengeEventId, resolutionEventId]); }); for (const option of ["--repository-path", "--trusted-ref"] as const) { diff --git a/phase0/tests/attestation-store.test.ts b/phase0/tests/attestation-store.test.ts index 0afc545..6f767bf 100644 --- a/phase0/tests/attestation-store.test.ts +++ b/phase0/tests/attestation-store.test.ts @@ -527,6 +527,44 @@ test("organization approval credentials remain consumed after close and reopen", assert.deepEqual(await readFile(f.path), beforeReplay); }); +test("store replay rejects a malformed injected organization signer root before append", async (t) => { + const f = await repositoryFixture(t); + const malformedOrganizationSigners = { + "example-org": f.approver.address.toLowerCase(), + } as unknown as Record; + const validStore = new FileAttestationStore(f.path, f.options); + await validStore.append(f.repositoryEvent); + const store = new FileAttestationStore(f.path, { + ...f.options, + organizationSigners: malformedOrganizationSigners, + }); + const before = await readFile(f.path); + const unsignedApproval = { + schemaVersion: 1 as const, + subject: f.base, + organizationId: "example-org", + approverWallet: f.approver.address.toLowerCase() as `0x${string}`, + role: "ip_admin" as const, + approvedAt: "2026-07-18T12:30:00.000Z", + }; + const approval = { + ...unsignedApproval, + statementHash: organizationStatementHash(unsignedApproval), + signature: await f.approver.signMessage({ message: canonicalOrganizationStatement(unsignedApproval) }), + }; + const organization: OrganizationApprovedEvent = { + type: "organization_approved", + eventId: "organization-malformed-root", + sequence: 2, + occurredAt: "2026-07-18T13:00:00.000Z", + subject: f.base, + approval, + }; + + await assert.rejects(store.append(organization), /organization signer allow-list.*array/i); + assert.deepEqual(await readFile(f.path), before); +}); + test("store replay applies the injected verifier clock deterministically", async (t) => { const f = await fixture(t); const clocked = new FileAttestationStore(f.path, { diff --git a/phase0/tests/attestations.test.ts b/phase0/tests/attestations.test.ts index df1c1cf..d0e3fe5 100644 --- a/phase0/tests/attestations.test.ts +++ b/phase0/tests/attestations.test.ts @@ -224,6 +224,72 @@ test("organization approval rejects inherited signer entries and exotic trust-ma ); }); +test("organization approval rejects malformed own signer allow-lists before accepting a valid signature", async () => { + const creator = privateKeyToAccount(generatePrivateKey()); + const approver = privateKeyToAccount(generatePrivateKey()); + const base = subject(IP_A, creator.address); + const repo = await repositoryEvent(base, creator); + const unsigned = { + schemaVersion: 1 as const, + subject: base, + organizationId: "example-org", + approverWallet: approver.address.toLowerCase() as `0x${string}`, + role: "ip_admin" as const, + approvedAt: NOW, + }; + const approval = { + ...unsigned, + statementHash: organizationStatementHash(unsigned), + signature: await approver.signMessage({ message: canonicalOrganizationStatement(unsigned) }), + }; + const event: OrganizationApprovedEvent = { + type: "organization_approved", + eventId: "organization-malformed-root", + sequence: 2, + occurredAt: NOW, + subject: base, + approval, + }; + + const inheritedPrototype = Object.create(Array.prototype) as Record; + inheritedPrototype[0] = unsigned.approverWallet; + const inheritedEntry = [] as unknown[]; + inheritedEntry.length = 1; + Object.setPrototypeOf(inheritedEntry, inheritedPrototype); + const extraProperty = [unsigned.approverWallet] as unknown[] & { injected?: string }; + extraProperty.injected = unsigned.approverWallet; + const malformedAllowLists: readonly [string, unknown][] = [ + ["string with a matching substring", unsigned.approverWallet], + ["array-like object with includes", { 0: unsigned.approverWallet, length: 1, includes: () => true }], + ["array with an inherited matching entry", inheritedEntry], + ["array with a malformed address", [unsigned.approverWallet.toUpperCase()]], + ["array with a non-string entry", [42]], + ["array with a duplicate canonical address", [unsigned.approverWallet, unsigned.approverWallet]], + ["array with an extra schema property", extraProperty], + ]; + + for (const [name, malformed] of malformedAllowLists) { + const trust = { "example-org": malformed } as unknown as Record; + await assert.rejects( + verifyOrganizationApproval(approval, trust), + /organization signer allow-list.*(?:array|canonical lowercase address|unique|own indexed entries|schema properties)/i, + name, + ); + } + + const malformedReducerTrust = { + "example-org": unsigned.approverWallet, + } as unknown as Record; + await assert.rejects( + reduceAttestationEvents([repo, event], { + baseSubjects: [base], + repositoryVerifier: async () => undefined, + organizationSigners: malformedReducerTrust, + }), + /organization signer allow-list.*array/i, + ); +}); + test("duplicate bytes under different wallets create a deterministic visible conflict", async () => { const first = privateKeyToAccount(generatePrivateKey()); const second = privateKeyToAccount(generatePrivateKey()); @@ -479,17 +545,17 @@ test("human status output lists challenged registrations and every matching conf assert.deepEqual(lines, reverseLines); assert.deepEqual( lines.filter((line) => line.startsWith("registration: ")), - [a.registrationId, b.registrationId].sort().map((id) => `registration: ${id}`), + [a.registrationId, b.registrationId].sort().map((id) => `registration: "${id}"`), ); assert.equal(lines.filter((line) => line === "status: challenged").length, 2); assert.deepEqual(lines.slice(-8), [ "conflicts: 1", `conflict: "${conflictId}"`, - `conflict artifact hash: ${a.artifactHash}`, + `conflict artifact hash: "${a.artifactHash}"`, "conflict status: open", "conflict reason: duplicate_bytes", "conflict outcome: (none)", - `conflict registrations: ${[a.registrationId, b.registrationId].sort().join(", ")}`, + `conflict registrations: ${[a.registrationId, b.registrationId].sort().map((id) => `"${id}"`).join(", ")}`, "conflict events: (none)", ]); @@ -503,7 +569,7 @@ test("human status output lists challenged registrations and every matching conf }; const unsortedArrayLines = renderAttestationStatus(unsortedArrayIndex, { artifactHash: a.artifactHash }); assert.ok(unsortedArrayLines.includes( - `conflict registrations: ${[a.registrationId, b.registrationId].sort().join(", ")}`, + `conflict registrations: ${[a.registrationId, b.registrationId].sort().map((id) => `"${id}"`).join(", ")}`, )); assert.ok(unsortedArrayLines.includes('conflict events: "event-a", "event-z"')); From e6a48cf6382bc7d894dc8a84cb498018f16cfa84 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 06:33:05 -0400 Subject: [PATCH 112/165] fix: snapshot internal award trust inputs --- .../internal-invocation-awards/src/budget.mjs | 28 +++-- .../internal-invocation-awards/src/engine.mjs | 7 +- .../test/budget.test.mjs | 35 ++++++ .../test/engine.test.mjs | 104 +++++++++++++++++- 4 files changed, 163 insertions(+), 11 deletions(-) diff --git a/spikes/internal-invocation-awards/src/budget.mjs b/spikes/internal-invocation-awards/src/budget.mjs index 190db74..be340c0 100644 --- a/spikes/internal-invocation-awards/src/budget.mjs +++ b/spikes/internal-invocation-awards/src/budget.mjs @@ -111,12 +111,26 @@ function validateTrustedSignerMap(value) { || Object.getPrototypeOf(value) !== Object.prototype) { throw new Error('trustedFinanceSigners must be an object'); } - const normalized = {}; - for (const [signerId, key] of Object.entries(value)) { + const normalized = Object.create(null); + for (const propertyKey of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, propertyKey); + if (typeof propertyKey !== 'string' || !descriptor?.enumerable + || !Object.hasOwn(descriptor, 'value')) { + throw new Error('trustedFinanceSigners entries must be enumerable own data properties'); + } + const signerId = propertyKey; requireNonEmpty(signerId, 'finance signer ID'); - normalized[signerId] = normalizeEd25519PublicKey(key, `trusted finance signer ${signerId}`); - } - return cloneFrozen(normalized); + Object.defineProperty(normalized, signerId, { + configurable: false, + enumerable: true, + writable: false, + value: normalizeEd25519PublicKey( + descriptor.value, + `trusted finance signer ${signerId}`, + ), + }); + } + return Object.freeze(normalized); } export function createBudget(signedBudget, { trustedFinanceSigners, policy: policyInput, now }) { @@ -136,10 +150,10 @@ export function createBudget(signedBudget, { trustedFinanceSigners, policy: poli if (!policy.permittedFinanceSignerIds.includes(unsigned.signerId)) { throw new Error('finance signer is not permitted by policy'); } - const trustedKey = normalizedFinanceSigners[unsigned.signerId]; - if (typeof trustedKey !== 'string' || trustedKey.length === 0) { + if (!Object.hasOwn(normalizedFinanceSigners, unsigned.signerId)) { throw new Error('trusted finance signer is not provisioned'); } + const trustedKey = normalizedFinanceSigners[unsigned.signerId]; const at = parseUtc(now, 'now'); const effectiveAt = parseUtc(unsigned.effectiveAt, 'budget effectiveAt'); const expiresAt = parseUtc(unsigned.expiresAt, 'budget expiresAt'); diff --git a/spikes/internal-invocation-awards/src/engine.mjs b/spikes/internal-invocation-awards/src/engine.mjs index c6717ed..7ba94d2 100644 --- a/spikes/internal-invocation-awards/src/engine.mjs +++ b/spikes/internal-invocation-awards/src/engine.mjs @@ -966,9 +966,10 @@ export async function recordAwardReversal(input) { export async function executeAuthorizedInvocation(input) { requireExactKeys(input, EXECUTE_KEYS, 'execution input'); + const credential = cloneFrozen(input.credential); const initial = input.store.snapshot(); capabilitiesFor(initial); - const replay = verifyTerminalReplay(initial, input.quote, input.credential); + const replay = verifyTerminalReplay(initial, input.quote, credential); if (replay) { if (replay.invocation.state === 'cancelled') { throw new Error('credential authorization was cancelled and reservation released'); @@ -1012,7 +1013,7 @@ export async function executeAuthorizedInvocation(input) { !== invocation.principalAttestationHash) { throw new Error('persisted initiating-principal attestation hash changed'); } - const authorizerId = input.credential?.credentialAuthorizerId; + const authorizerId = credential?.credentialAuthorizerId; if (typeof authorizerId !== 'string' || !policy.permittedCredentialAuthorizerIds.includes(authorizerId)) { throw new Error('credential authorizer is not permitted by policy'); @@ -1020,7 +1021,7 @@ export async function executeAuthorizedInvocation(input) { const trustedKey = current.credentialAuthorizers[authorizerId]; if (!trustedKey) throw new Error('credential authorizer is not provisioned'); compareCredentialPayload( - verifyCredential(input.credential, trustedKey, now), + verifyCredential(credential, trustedKey, now), invocation.credentialPayload, ); const executionAttemptId = `attempt-${invocation.invocationId}-${invocation.credentialNonce}`; diff --git a/spikes/internal-invocation-awards/test/budget.test.mjs b/spikes/internal-invocation-awards/test/budget.test.mjs index af4a724..fe3b53a 100644 --- a/spikes/internal-invocation-awards/test/budget.test.mjs +++ b/spikes/internal-invocation-awards/test/budget.test.mjs @@ -240,6 +240,41 @@ test('signed budget authorization is immutable and separate from mutable counter ); }); +test('finance signer lookup ignores inherited entries and rejects accessor trust roots', () => { + const fixture = financeFixture(); + const signerId = fixture.signedBudget.signerId; + const trustedPublicKey = fixture.trustedFinanceSigners[signerId]; + Object.defineProperty(Object.prototype, signerId, { + configurable: true, + enumerable: false, + writable: true, + value: trustedPublicKey, + }); + try { + assert.throws(() => createBudget(fixture.signedBudget, { + trustedFinanceSigners: {}, + policy: ACTIVE_POLICY, + now: NOW, + }), /trusted finance signer is not provisioned/); + } finally { + Reflect.deleteProperty(Object.prototype, signerId); + } + + const accessorMap = {}; + Object.defineProperty(accessorMap, signerId, { + configurable: true, + enumerable: true, + get() { + return trustedPublicKey; + }, + }); + assert.throws(() => createBudget(fixture.signedBudget, { + trustedFinanceSigners: accessorMap, + policy: ACTIVE_POLICY, + now: NOW, + }), /trustedFinanceSigners.*data properties/); +}); + test('finance verification accepts canonical Ed25519 public SPKI only', () => { const rsa = generateKeyPairSync('rsa', { modulusLength: 512 }); const rsaBudget = signBudget(UNSIGNED_BUDGET_AUTHORIZATION, rsa.privateKey); diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs index 551ad9d..b2cfad5 100644 --- a/spikes/internal-invocation-awards/test/engine.test.mjs +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -170,6 +170,7 @@ function fixture({ budgetOverrides = {}, registrations, receiptSign = null, + additionalCredentialAuthorizers = {}, } = {}) { const finance = generateKeyPairSync('ed25519'); const authorizer = generateKeyPairSync('ed25519'); @@ -207,7 +208,10 @@ function fixture({ skillRegistrations, financeSigners: { 'megacorp-finance': publicPem(finance) }, managerSigners: { 'manager-alex': publicPem(manager) }, - credentialAuthorizers: { 'megacorp-collar-authorizer': publicPem(authorizer) }, + credentialAuthorizers: { + 'megacorp-collar-authorizer': publicPem(authorizer), + ...additionalCredentialAuthorizers, + }, identitySigners: { 'megacorp-identity': publicPem(identity) }, receiptSigners: { 'megacorp-receipts': publicPem(receipt) }, clock: () => clock.now, @@ -463,6 +467,55 @@ test('successful execution atomically commits policy-bound signed receipt and ex assert.doesNotThrow(() => JSON.stringify(result)); }); +test('execution captures an accessor-backed signed credential once before authorizer lookup', async () => { + const alternateAuthorizer = generateKeyPairSync('ed25519'); + const alternateAuthorizerId = 'megacorp-alternate-authorizer'; + const fx = fixture({ + policyOverrides: { + permittedCredentialAuthorizerIds: [ + 'megacorp-collar-authorizer', + alternateAuthorizerId, + ], + }, + additionalCredentialAuthorizers: { + [alternateAuthorizerId]: publicPem(alternateAuthorizer), + }, + }); + const q = makeQuote(fx.activePolicy, 'credential-snapshot'); + const authorized = await authorize(fx, q); + const signedByAlternate = signCredential( + authorized.credentialPayload, + alternateAuthorizer.privateKey, + ); + let authorizerReads = 0; + const switchingCredential = { ...signedByAlternate }; + Object.defineProperty(switchingCredential, 'credentialAuthorizerId', { + enumerable: true, + configurable: true, + get() { + authorizerReads += 1; + return authorizerReads === 1 + ? alternateAuthorizerId + : 'megacorp-collar-authorizer'; + }, + }); + let executorCalls = 0; + + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential: switchingCredential, + executor: async () => { + executorCalls += 1; + return { kind: 'succeeded', executionCostAtomic: '700000', outputHash: OUTPUT_HASH }; + }, + }), /credential signature|credential payload/i); + + assert.equal(authorizerReads, 1); + assert.equal(executorCalls, 0); + assert.equal(fx.store.snapshot().invocations[q.invocationId].state, 'authorized'); +}); + test('consumed terminal credentials reject and never invoke executor again', async () => { const fx = fixture(); const q = makeQuote(fx.activePolicy, 'retry'); @@ -492,6 +545,55 @@ test('consumed terminal credentials reject and never invoke executor again', asy assert.equal(Object.keys(fx.store.snapshot().receipts).length, 1); }); +test('terminal replay captures an accessor-backed signed credential once before verification', async () => { + const alternateAuthorizer = generateKeyPairSync('ed25519'); + const alternateAuthorizerId = 'megacorp-alternate-authorizer'; + const fx = fixture({ + policyOverrides: { + permittedCredentialAuthorizerIds: [ + 'megacorp-collar-authorizer', + alternateAuthorizerId, + ], + }, + additionalCredentialAuthorizers: { + [alternateAuthorizerId]: publicPem(alternateAuthorizer), + }, + }); + const q = makeQuote(fx.activePolicy, 'terminal-credential-snapshot'); + const authorized = await authorize(fx, q); + await executeSuccess(fx, q, authorized); + const signedByAlternate = signCredential( + authorized.credentialPayload, + alternateAuthorizer.privateKey, + ); + let authorizerReads = 0; + const switchingCredential = { ...signedByAlternate }; + Object.defineProperty(switchingCredential, 'credentialAuthorizerId', { + enumerable: true, + configurable: true, + get() { + authorizerReads += 1; + return authorizerReads === 1 + ? alternateAuthorizerId + : 'megacorp-collar-authorizer'; + }, + }); + let executorCalls = 0; + + await assert.rejects(() => executeAuthorizedInvocation({ + store: fx.store, + quote: q, + credential: switchingCredential, + executor: async () => { + executorCalls += 1; + throw new Error('terminal replay must not invoke executor'); + }, + }), /credential signature|credential payload/i); + + assert.equal(authorizerReads, 1); + assert.equal(executorCalls, 0); +}); + test('validated known failure records exactly one shared-kernel execution COGS row', async () => { const fx = fixture(); const q = makeQuote(fx.activePolicy, 'failure'); From 39b2e314f2039d97f1d0f2ac066bc49630ddc9fa Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 06:34:22 -0400 Subject: [PATCH 113/165] fix: preserve terminal authorization replay --- spikes/pi-wielder/README.md | 2 +- spikes/pi-wielder/src/payment-policy.mjs | 32 +++++-- spikes/pi-wielder/tests/paying-fetch.test.mjs | 39 +++++++-- .../pi-wielder/tests/payment-policy.test.mjs | 85 +++++++++++++++++++ 4 files changed, 141 insertions(+), 17 deletions(-) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 51085b7..28e0a5b 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -149,7 +149,7 @@ npm test npm run e2e ``` -Expected current results are 127 offline unit/integration tests and 30 offline e2e +Expected current results are 134 offline unit/integration tests and 30 offline e2e checks. Counts can increase as regressions are added; zero failures is the contract. The e2e labels all timing output synthetic and uses in-process Hono requests only. diff --git a/spikes/pi-wielder/src/payment-policy.mjs b/spikes/pi-wielder/src/payment-policy.mjs index 8538d97..1179e94 100644 --- a/spikes/pi-wielder/src/payment-policy.mjs +++ b/spikes/pi-wielder/src/payment-policy.mjs @@ -425,11 +425,16 @@ export function createPaymentPolicy(config) { return token; } - function validateOffer({ requestUrl, method, bodyBytes, challenge, receivedAt }) { + function consumeReceivedAt(receivedAt) { if (!receivedAt || !receiptTokens.has(receivedAt)) { fail('RECEIVED_AT', 'receivedAt must come directly from this policy trusted clock'); } receiptTokens.delete(receivedAt); + return receivedAt.receivedAtMs; + } + + function validateOffer({ requestUrl, method, bodyBytes, challenge, receivedAt }) { + const receivedAtMs = consumeReceivedAt(receivedAt); const target = resourceUrl(requestUrl); const seller = rules.find((rule) => rule.origin === target.origin && routeMatches(target.pathname, rule.pathPrefix)); @@ -452,7 +457,6 @@ export function createPaymentPolicy(config) { canonicalHash(candidate.extra.quoteId, 'quoteId', 'QUOTE_ID'); const issuedAtMs = canonicalTimestamp(candidate.extra.issuedAt, 'issuedAt'); const expiresAtMs = canonicalTimestamp(candidate.extra.expiresAt, 'expiresAt'); - const receivedAtMs = receivedAt.receivedAtMs; const validationTimeMs = trustedNow(); if (issuedAtMs > receivedAtMs || receivedAtMs - issuedAtMs > maxQuoteAgeMs || expiresAtMs <= receivedAtMs || expiresAtMs <= validationTimeMs @@ -514,17 +518,31 @@ export function createPaymentPolicy(config) { exactObject(input, ['authorizationId', 'requestUrl', 'method', 'bodyBytes', 'challenge', 'receivedAt'], 'RESERVATION_SCHEMA', 'authorization reservation'); const authorizationId = id(input.authorizationId); - const validated = validateOffer(input); const existing = records.get(authorizationId); if (existing) { - if (existing.offerFingerprint !== validated.offerFingerprint - || existing.requestUrl !== validated.requestUrl - || existing.method !== validated.method - || existing.requestHash !== validated.requestHash) { + // An existing id is an immutable binding lookup, not a new authorization. + // Compare the exact request and frozen offer without consulting current + // quote freshness or seller/budget policy, then let its stored state decide + // whether signing, recovery, reconciliation, or terminal rejection applies. + consumeReceivedAt(input.receivedAt); + const target = resourceUrl(input.requestUrl); + const method = canonicalMethod(input.method); + const requestHash = canonicalRequestHash({ + requestUrl: target.href, + method, + bodyBytes: input.bodyBytes, + }); + const candidate = frozenCopy(challengeSchema(input.challenge)); + const offerFingerprint = hashJson(candidate); + if (existing.offerFingerprint !== offerFingerprint + || existing.requestUrl !== target.href + || existing.method !== method + || existing.requestHash !== requestHash) { fail('AUTHORIZATION_CONFLICT', 'authorizationId already binds different request or offer bytes'); } return publicRecord(existing); } + const validated = validateOffer(input); const amount = BigInt(validated.amountAtomic); if (spentAtomic + reservedAtomic + amount > budget) { fail('SESSION_BUDGET', 'offer exceeds remaining one-process session budget'); diff --git a/spikes/pi-wielder/tests/paying-fetch.test.mjs b/spikes/pi-wielder/tests/paying-fetch.test.mjs index aed97cf..1407093 100644 --- a/spikes/pi-wielder/tests/paying-fetch.test.mjs +++ b/spikes/pi-wielder/tests/paying-fetch.test.mjs @@ -608,6 +608,34 @@ test('concurrent copies of one idempotency key produce one signature and one pai assert.equal(paidRetries, 1); }); +test('a settled same-id replay after quote expiry reports already used without another signature', async () => { + const { account, paymentPolicy, signatureCount, setClock } = setup(); + let initialFetches = 0; + await payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async (_url, init) => { + initialFetches += 1; + return initialFetches === 1 ? challenge() : paidResponse(init); + }, + idempotencyKey: 'idem-expired-terminal-replay', + paymentPolicy, + }); + const before = paymentPolicy.snapshot(); + setClock(NOW + 60_000); + let replayFetches = 0; + + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { + replayFetches += 1; + return challenge(); + }, + idempotencyKey: 'idem-expired-terminal-replay', + paymentPolicy, + }), (error) => error.code === 'AUTHORIZATION_ALREADY_USED'); + assert.equal(replayFetches, 1); + assert.equal(signatureCount(), 1); + assert.deepEqual(paymentPolicy.snapshot(), before); +}); + test('ordinary signer rejection before any signature return releases reservation exactly once', async () => { const { account, paymentPolicy } = setup(); account.signTypedData = async () => { throw new Error('wallet declined before signing'); }; @@ -749,15 +777,7 @@ test('a fault after synchronous signature persistence recovers exact X-PAYMENT w }); test('signed recovery rechecks expiry and holds the reservation without a retry', async () => { - let clockReads = 0; - const { account, paymentPolicy, signatureCount } = setup({ - policy: { - now: () => { - clockReads += 1; - return clockReads >= 5 ? NOW + 59_000 : NOW; - }, - }, - }); + const { account, paymentPolicy, setClock, signatureCount } = setup(); await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { fetchImpl: async () => challenge(), idempotencyKey: 'idem-expired-recovery', @@ -765,6 +785,7 @@ test('signed recovery rechecks expiry and holds the reservation without a retry' onSignedAuthorizationPersisted: () => { throw new Error('synthetic process interruption'); }, }), /synthetic process interruption/); assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'signed'); + setClock(NOW + 59_000); let recoveryFetches = 0; await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { diff --git a/spikes/pi-wielder/tests/payment-policy.test.mjs b/spikes/pi-wielder/tests/payment-policy.test.mjs index 0dcb4fe..be8a623 100644 --- a/spikes/pi-wielder/tests/payment-policy.test.mjs +++ b/spikes/pi-wielder/tests/payment-policy.test.mjs @@ -432,6 +432,91 @@ test('one signed authorization permits exactly one paid retry and immutable amou (error) => error.code === 'QUOTE_CHANGED'); }); +const existingAuthorizationReplayCases = [ + ['signing', (subject) => { + const record = reserve(subject); + subject.claimSignature('auth-1', { offerFingerprint: record.offerFingerprint }); + }], + ['settled', (subject) => { + sign(subject); + subject.beginRetry('auth-1'); + subject.acceptSettlement('auth-1', settlementEvidence(subject)); + }], + ['rejected', (subject) => { + sign(subject); + subject.beginRetry('auth-1'); + const evidence = settlementEvidence(subject); + subject.reconcileRejection('auth-1', { + ...evidence, + success: false, + transaction: null, + outcome: 'rejected', + reasonCode: 'CHAIN_REJECTED', + trustToken: 'trusted-rejection', + }); + }], + ['unresolved', (subject) => { + sign(subject); + subject.beginRetry('auth-1'); + subject.markUnresolved('auth-1', { reasonCode: 'RETRY_RESPONSE_LOST' }); + }], +]; + +for (const [state, arrange] of existingAuthorizationReplayCases) { + test(`same-id ${state} authorization replay ignores current quote freshness and cannot claim another signature`, () => { + let clock = NOW; + const subject = policy({ + now: () => clock, + verifyRejectionProof: () => true, + }); + arrange(subject); + const before = subject.snapshot(); + clock = NOW + 60_000; + + const replayReceipt = subject.captureReceivedAt(); + const existing = reserve(subject, { receivedAt: replayReceipt }); + assert.equal(existing.state, state); + assert.equal(subject.claimSignature('auth-1', { + offerFingerprint: existing.offerFingerprint, + }).claimed, false); + assert.deepEqual(subject.snapshot(), before); + + assert.throws(() => reserve(subject, { + authorizationId: 'auth-new', + receivedAt: replayReceipt, + }), (error) => error.code === 'RECEIVED_AT'); + assert.deepEqual(subject.snapshot(), before); + }); +} + +test('same-id changed request bytes conflict against the stored binding even after quote expiry', () => { + let clock = NOW; + const subject = policy({ now: () => clock }); + sign(subject); + subject.beginRetry('auth-1'); + subject.acceptSettlement('auth-1', settlementEvidence(subject)); + const before = subject.snapshot(); + clock = NOW + 60_000; + + assert.throws(() => reserve(subject, { + bodyBytes: `${BODY} changed`, + }), (error) => error.code === 'AUTHORIZATION_CONFLICT'); + assert.deepEqual(subject.snapshot(), before); +}); + +test('a new authorization id still receives full quote freshness validation', () => { + let clock = NOW; + const subject = policy({ now: () => clock }); + reserve(subject); + const before = subject.snapshot(); + clock = NOW + 60_000; + + assert.throws(() => reserve(subject, { + authorizationId: 'auth-new', + }), (error) => error.code === 'QUOTE_EXPIRY'); + assert.deepEqual(subject.snapshot(), before); +}); + test('malformed or mismatched settlement evidence remains reserved', () => { for (const mutation of [ { value: '250001' }, From ef42617f0bea5f0a4966ef56d08ba09966a537e1 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 06:47:52 -0400 Subject: [PATCH 114/165] fix: close attestation acceptance blockers --- phase0/src/attestation-cli.ts | 60 ++------- phase0/src/attestation-git.ts | 21 ++- phase0/src/attestation-store.ts | 31 ++++- phase0/src/attestations.ts | 81 +++++++---- phase0/src/index.ts | 3 +- phase0/src/terminal.ts | 54 ++++++++ phase0/tests/attestation-git.test.ts | 42 ++++++ phase0/tests/attestation-store.test.ts | 125 +++++++++++++---- phase0/tests/attestations.test.ts | 177 +++++++++++++++++++++++++ phase0/tests/index.test.ts | 142 ++++++++++++++++++++ 10 files changed, 626 insertions(+), 110 deletions(-) create mode 100644 phase0/src/terminal.ts diff --git a/phase0/src/attestation-cli.ts b/phase0/src/attestation-cli.ts index 81fd042..e726965 100644 --- a/phase0/src/attestation-cli.ts +++ b/phase0/src/attestation-cli.ts @@ -2,6 +2,7 @@ import { readFile, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { + compareOrdinalStrings, displayAttestation, parseAttestationEvent, reduceAttestationEvents, @@ -32,6 +33,7 @@ import { type AttestationRepositoryContext, } from "./attestation-store"; import { FileRegistrationStore } from "./registrations"; +import { quoteTerminalText } from "./terminal"; export type AttestationCommand = | "attestation-status" @@ -84,8 +86,8 @@ function object(value: unknown, label: string): Record { } function exactKeys(value: Record, expected: readonly string[], label: string): void { - const actual = Object.keys(value).sort(); - const wanted = [...expected].sort(); + const actual = Object.keys(value).sort(compareOrdinalStrings); + const wanted = [...expected].sort(compareOrdinalStrings); if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw new Error(`${label} has unexpected or missing fields`); } @@ -233,7 +235,7 @@ function statusPayload(index: AttestationIndex, options: AttestationCommandOptio const selected = Object.entries(index.registrations).filter(([id, registration]) => (options.artifactHash === undefined || registration.subject.artifactHash === options.artifactHash) && (options.registrationId === undefined || id === options.registrationId)) - .sort(([a], [b]) => a.localeCompare(b)); + .sort(([a], [b]) => compareOrdinalStrings(a, b)); const ids = new Set(selected.map(([id]) => id)); return { registrations: selected.map(([registrationId, registration]) => ({ @@ -244,55 +246,21 @@ function statusPayload(index: AttestationIndex, options: AttestationCommandOptio })), conflicts: index.conflicts .filter((conflict) => conflict.registrationIds.some((id) => ids.has(id))) - .sort((a, b) => a.conflictId.localeCompare(b.conflictId)), + .sort((a, b) => compareOrdinalStrings(a.conflictId, b.conflictId)), }; } -function quoteHumanIdentifier(value: string): string { - let quoted = '"'; - for (let index = 0; index < value.length; index += 1) { - const codeUnit = value.charCodeAt(index); - if (codeUnit === 0x22) { - quoted += '\\"'; - } else if (codeUnit === 0x5c) { - quoted += "\\\\"; - } else if ( - codeUnit <= 0x1f - || (codeUnit >= 0x7f && codeUnit <= 0x9f) - || codeUnit === 0x061c - || codeUnit === 0x200e - || codeUnit === 0x200f - || (codeUnit >= 0x2028 && codeUnit <= 0x202e) - || (codeUnit >= 0x2066 && codeUnit <= 0x2069) - || (codeUnit >= 0xd800 && codeUnit <= 0xdfff - && !(codeUnit <= 0xdbff - && index + 1 < value.length - && value.charCodeAt(index + 1) >= 0xdc00 - && value.charCodeAt(index + 1) <= 0xdfff)) - ) { - quoted += `\\u${codeUnit.toString(16).padStart(4, "0")}`; - } else { - quoted += value[index]; - if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - index += 1; - quoted += value[index]; - } - } - } - return `${quoted}"`; -} - function renderAttestationConflicts(conflictsValue: readonly AttestationConflict[]): string[] { - const conflicts = [...conflictsValue].sort((a, b) => a.conflictId.localeCompare(b.conflictId)); + const conflicts = [...conflictsValue].sort((a, b) => compareOrdinalStrings(a.conflictId, b.conflictId)); const lines = [`conflicts: ${conflicts.length}`]; for (const conflict of conflicts) { - lines.push(`conflict: ${quoteHumanIdentifier(conflict.conflictId)}`); - lines.push(`conflict artifact hash: ${conflict.artifactHash === null ? "(none)" : quoteHumanIdentifier(conflict.artifactHash)}`); + lines.push(`conflict: ${quoteTerminalText(conflict.conflictId)}`); + lines.push(`conflict artifact hash: ${conflict.artifactHash === null ? "(none)" : quoteTerminalText(conflict.artifactHash)}`); lines.push(`conflict status: ${conflict.status}`); lines.push(`conflict reason: ${conflict.reason}`); lines.push(`conflict outcome: ${conflict.outcome ?? "(none)"}`); - lines.push(`conflict registrations: ${[...conflict.registrationIds].sort().map(quoteHumanIdentifier).join(", ")}`); - lines.push(`conflict events: ${conflict.eventIds.length > 0 ? [...conflict.eventIds].sort().map(quoteHumanIdentifier).join(", ") : "(none)"}`); + lines.push(`conflict registrations: ${[...conflict.registrationIds].sort(compareOrdinalStrings).map(quoteTerminalText).join(", ")}`); + lines.push(`conflict events: ${conflict.eventIds.length > 0 ? [...conflict.eventIds].sort(compareOrdinalStrings).map(quoteTerminalText).join(", ") : "(none)"}`); } return lines; } @@ -300,14 +268,14 @@ function renderAttestationConflicts(conflictsValue: readonly AttestationConflict function renderAppendSuccess(event: AttestationEvent, json: boolean): string[] { const payload = { appended: event.eventId, type: event.type }; if (json) return [JSON.stringify(payload, null, 2)]; - return [`appended: ${quoteHumanIdentifier(event.eventId)}; type: ${quoteHumanIdentifier(event.type)}`]; + return [`appended: ${quoteTerminalText(event.eventId)}; type: ${quoteTerminalText(event.type)}`]; } function renderRecoveredLock(metadata: AttestationLockMetadata, json: boolean): string[] { const payload = { recovered: true, lock: metadata }; if (json) return [JSON.stringify(payload, null, 2)]; return [ - `recovered: true; lock pid: ${metadata.pid}; token: ${quoteHumanIdentifier(metadata.token)}; target path: ${quoteHumanIdentifier(metadata.targetPath)}; acquired at: ${quoteHumanIdentifier(metadata.acquiredAt)}`, + `recovered: true; lock pid: ${metadata.pid}; token: ${quoteTerminalText(metadata.token)}; target path: ${quoteTerminalText(metadata.targetPath)}; acquired at: ${quoteTerminalText(metadata.acquiredAt)}`, ]; } @@ -318,7 +286,7 @@ export function renderAttestationStatus(index: AttestationIndex, options: Attest const lines: string[] = []; for (const itemValue of payload.registrations) { const item = itemValue as ReturnType & { registrationId: string }; - lines.push(`registration: ${quoteHumanIdentifier(item.registrationId)}`); + lines.push(`registration: ${quoteTerminalText(item.registrationId)}`); lines.push(`status: ${item.status}`); lines.push(`attestation: ${item.level}`); lines.push(`claim: ${item.claim}`); diff --git a/phase0/src/attestation-git.ts b/phase0/src/attestation-git.ts index 36415c0..0069f00 100644 --- a/phase0/src/attestation-git.ts +++ b/phase0/src/attestation-git.ts @@ -277,7 +277,7 @@ export function normalizeForgePublicKey(value: unknown): string { return canonical; } -function assertForgeSignerMap(value: unknown): asserts value is Readonly> { +export function snapshotForgeSignerTrust(value: unknown): Readonly> { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error("forge signer trust must be a plain or null-prototype record"); } @@ -285,6 +285,18 @@ function assertForgeSignerMap(value: unknown): asserts value is Readonly = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string" || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(key)) { + throw new Error("forge signer trust must use only string identifier keys"); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) { + throw new Error("forge signer trust entries must be own enumerable data properties"); + } + snapshot[key] = normalizeForgePublicKey(descriptor.value); + } + return Object.freeze(snapshot); } export function verifyForgeObservation( @@ -293,6 +305,7 @@ export function verifyForgeObservation( forgeSigners: Readonly>, ): void { const observation = parseForgeObservation(observationValue); + const trustedForgeSigners = snapshotForgeSignerTrust(forgeSigners); if (observation.repositoryId !== trusted.repositoryId || observation.repositoryUrl !== trusted.repositoryUrl || observation.trustedRef !== trusted.trustedRef) { @@ -301,12 +314,10 @@ export function verifyForgeObservation( if (!trusted.permittedForgeSignerIds.includes(observation.forgeSignerId)) { throw new Error("forge signer is not permitted for this repository"); } - assertForgeSignerMap(forgeSigners); - if (!Object.hasOwn(forgeSigners, observation.forgeSignerId)) { + if (!Object.hasOwn(trustedForgeSigners, observation.forgeSignerId)) { throw new Error("forge signer is unknown; signer ID must be an own property of the trust map"); } - const publicKey = forgeSigners[observation.forgeSignerId]; - const canonicalPublicKey = normalizeForgePublicKey(publicKey); + const canonicalPublicKey = trustedForgeSigners[observation.forgeSignerId]; let signatureBytes: Buffer; try { signatureBytes = Buffer.from(observation.signature, "base64"); diff --git a/phase0/src/attestation-store.ts b/phase0/src/attestation-store.ts index 546feaf..19e47b0 100644 --- a/phase0/src/attestation-store.ts +++ b/phase0/src/attestation-store.ts @@ -7,11 +7,18 @@ import { isDeepStrictEqual } from "node:util"; import { parseAttestationEvent, reduceAttestationEvents, + snapshotAdminSignerTrust, + snapshotOrganizationSignerTrust, type AttestationEvent, type RegistrationSubject, type RepositoryControlEvent, } from "./attestations"; -import { reverifyRepositoryEvent, type GitReader, type TrustedRepositoryResolver } from "./attestation-git"; +import { + reverifyRepositoryEvent, + snapshotForgeSignerTrust, + type GitReader, + type TrustedRepositoryResolver, +} from "./attestation-git"; export interface AttestationRepositoryContext { repositories: TrustedRepositoryResolver; @@ -137,14 +144,21 @@ export class FileAttestationStore { } this.path = path; this.lockPath = `${path}.lock`; + const organizationSigners = options.organizationSigners === undefined + ? undefined + : snapshotOrganizationSignerTrust(options.organizationSigners); + const adminSigners = options.adminSigners === undefined + ? undefined + : snapshotAdminSignerTrust(options.adminSigners); + const forgeSigners = options.forgeSigners === undefined + ? undefined + : snapshotForgeSignerTrust(options.forgeSigners); this.options = { ...options, baseSubjects: deepFreeze(structuredClone(options.baseSubjects)), - organizationSigners: options.organizationSigners - ? deepFreeze(structuredClone(options.organizationSigners)) - : undefined, - adminSigners: options.adminSigners ? deepFreeze(structuredClone(options.adminSigners)) : undefined, - forgeSigners: options.forgeSigners ? deepFreeze(structuredClone(options.forgeSigners)) : undefined, + organizationSigners, + adminSigners, + forgeSigners, }; } @@ -222,7 +236,10 @@ export class FileAttestationStore { if (this.options.repositories && this.options.forgeSigners && this.options.git) { return { repositories: this.options.repositories, forgeSigners: this.options.forgeSigners, git: this.options.git }; } - if (this.options.repositoryContextLoader) return this.options.repositoryContextLoader(); + if (this.options.repositoryContextLoader) { + const context = await this.options.repositoryContextLoader(); + return { ...context, forgeSigners: snapshotForgeSignerTrust(context.forgeSigners) }; + } throw new Error("repository verifier context required"); } diff --git a/phase0/src/attestations.ts b/phase0/src/attestations.ts index 04569fe..fc5c430 100644 --- a/phase0/src/attestations.ts +++ b/phase0/src/attestations.ts @@ -163,6 +163,14 @@ const REGISTRATION_ID = /^eip155:1315:0x[0-9a-f]{40}$/; const IDENTIFIER = /^[a-z0-9][a-z0-9._-]{0,127}$/; const TRUSTED_REF = /^refs\/(?:heads|remotes)\/[A-Za-z0-9._\/-]+$/; +/** + * Locale-independent lexicographic ordering by JavaScript UTF-16 code units. + * Signed and displayed identifiers must never change order with the host locale. + */ +export function compareOrdinalStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + const SUBJECT_KEYS = [ "registrationId", "ipId", @@ -211,8 +219,8 @@ function record(value: unknown, label: string): Record { } function exactKeys(value: Record, expected: readonly string[], label: string): void { - const actual = Object.keys(value).sort(); - const wanted = [...expected].sort(); + const actual = Object.keys(value).sort(compareOrdinalStrings); + const wanted = [...expected].sort(compareOrdinalStrings); if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { throw new Error(`${label} has unexpected or missing fields`); } @@ -236,16 +244,18 @@ function parseOrganizationSignerAllowList(value: unknown): readonly `0x${string} if (!lengthDescriptor || !("value" in lengthDescriptor) || !Number.isSafeInteger(lengthDescriptor.value)) { throw new Error("organization signer allow-list must have own indexed entries"); } - const expectedKeys = new Set(["length"]); - for (let index = 0; index < lengthDescriptor.value; index += 1) expectedKeys.add(String(index)); + const length = lengthDescriptor.value as number; const ownKeys = Reflect.ownKeys(value); - if (ownKeys.some((key) => typeof key !== "string" || !expectedKeys.has(key)) || ownKeys.length !== expectedKeys.size) { + if (lengthDescriptor.enumerable + || ownKeys.length !== length + 1 + || ownKeys.some((key) => typeof key !== "string" + || (key !== "length" && (!/^(?:0|[1-9][0-9]*)$/.test(key) || Number(key) >= length)))) { throw new Error("organization signer allow-list has unexpected schema properties or inherited entries"); } const wallets: `0x${string}`[] = []; const seen = new Set(); - for (let index = 0; index < lengthDescriptor.value; index += 1) { + for (let index = 0; index < length; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) { throw new Error("organization signer allow-list must have own indexed entries"); @@ -255,10 +265,10 @@ function parseOrganizationSignerAllowList(value: unknown): readonly `0x${string} seen.add(descriptor.value); wallets.push(descriptor.value); } - return wallets; + return Object.freeze(wallets); } -function parseOrganizationSignerTrust( +export function snapshotOrganizationSignerTrust( value: unknown, ): Readonly> { assertTrustMap(value, "organization signer trust"); @@ -273,7 +283,31 @@ function parseOrganizationSignerTrust( } parsed[key] = parseOrganizationSignerAllowList(descriptor.value); } - return parsed; + return Object.freeze(parsed); +} + +export function snapshotAdminSignerTrust( + value: unknown, +): Readonly> { + assertTrustMap(value, "attestation admin trust"); + const parsed: Record = Object.create(null) as Record; + const wallets = new Set(); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string" || !IDENTIFIER.test(key)) { + throw new Error("attestation admin trust must use only string identifier keys"); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) { + throw new Error("attestation admin trust entries must be own enumerable data properties"); + } + address(descriptor.value, "attestation admin trust canonical lowercase address"); + if (wallets.has(descriptor.value)) { + throw new Error("attestation admin trust addresses must be globally unique"); + } + wallets.add(descriptor.value); + parsed[key] = descriptor.value; + } + return Object.freeze(parsed); } function nonempty(value: unknown, label: string): asserts value is string { @@ -525,7 +559,7 @@ export function canonicalRepositoryStatement(challengeValue: RepositoryControlCh ipId: challenge.subject.ipId, wallet: challenge.subject.wallet, artifactSha256: challenge.subject.artifactHash, - declaredParentIpIds: [...challenge.subject.declaredParentIpIds].sort(), + declaredParentIpIds: [...challenge.subject.declaredParentIpIds].sort(compareOrdinalStrings), repository: challenge.repositoryUrl, artifactCommit: challenge.artifactCommitSha, artifactPath: challenge.artifactPath, @@ -568,7 +602,7 @@ export function canonicalOrganizationStatement(approvalValue: UnsignedApproval): ipId: approval.subject.ipId, wallet: approval.subject.wallet, artifactSha256: approval.subject.artifactHash, - declaredParentIpIds: [...approval.subject.declaredParentIpIds].sort(), + declaredParentIpIds: [...approval.subject.declaredParentIpIds].sort(compareOrdinalStrings), organizationId: approval.organizationId, approverWallet: approval.approverWallet, role: approval.role, @@ -585,7 +619,7 @@ export async function verifyOrganizationApproval( organizationSigners: Readonly>, ): Promise { const approval = parseApproval(approvalValue); - const trustedOrganizations = parseOrganizationSignerTrust(organizationSigners); + const trustedOrganizations = snapshotOrganizationSignerTrust(organizationSigners); const unsigned: UnsignedApproval = { schemaVersion: approval.schemaVersion, subject: approval.subject, @@ -621,7 +655,7 @@ export function canonicalChallengeEventStatement(eventValue: ChallengeOpenedEven challengedRegistrationId: event.challengedRegistrationId, challengerRegistrationId: event.challengerRegistrationId, challengerWallet: event.challengerWallet, - evidenceUris: [...event.evidenceUris].sort(), + evidenceUris: [...event.evidenceUris].sort(compareOrdinalStrings), reason: event.reason, }); } @@ -685,11 +719,11 @@ export async function verifyAdminEventSignature( ): Promise { const event = parseAttestationEvent(eventValue); if (event.type !== "challenge_resolved" && event.type !== "attestation_revoked") throw new Error("admin event required"); - assertTrustMap(adminSigners, "attestation admin trust"); - if (!Object.hasOwn(adminSigners, event.adminSignerId)) { + const trustedAdmins = snapshotAdminSignerTrust(adminSigners); + if (!Object.hasOwn(trustedAdmins, event.adminSignerId)) { throw new Error("admin signer is not provisioned; signer ID must be an own property of the trust map"); } - const signer = adminSigners[event.adminSignerId]; + const signer = trustedAdmins[event.adminSignerId]; if (!signer) throw new Error("admin signer is not provisioned"); address(signer, "admin signer address"); if (event.statementHash !== adminEventStatementHash(event)) throw new Error("admin statement hash mismatch"); @@ -711,7 +745,7 @@ export function registrationSubjectsFromManifest(manifest: RegistrationManifest) } export function deterministicConflictId(a: RegistrationSubject, b: RegistrationSubject): string { - const [first, second] = [a.registrationId, b.registrationId].sort(); + const [first, second] = [a.registrationId, b.registrationId].sort(compareOrdinalStrings); return `sha256:${createHash("sha256").update(`${first}\n${second}`).digest("hex")}`; } @@ -731,7 +765,8 @@ export async function reduceAttestationEvents( now?: Date; } = {}, ): Promise { - const organizationSigners = parseOrganizationSignerTrust(trust.organizationSigners ?? {}); + const organizationSigners = snapshotOrganizationSignerTrust(trust.organizationSigners ?? {}); + const adminSigners = snapshotAdminSignerTrust(trust.adminSigners ?? {}); const verifierNow = trust.now?.getTime(); if (verifierNow !== undefined && !Number.isFinite(verifierNow)) throw new Error("attestation verifier clock is invalid"); const subjects: Record = {}; @@ -777,7 +812,7 @@ export async function reduceAttestationEvents( conflicts.set(conflictId, { conflictId, artifactHash: subject.artifactHash, - registrationIds: [prior.registrationId, subject.registrationId].sort(), + registrationIds: [prior.registrationId, subject.registrationId].sort(compareOrdinalStrings), status: "open", reason: "duplicate_bytes", outcome: null, @@ -888,7 +923,7 @@ export async function reduceAttestationEvents( conflicts.set(event.conflictId, { conflictId: event.conflictId, artifactHash: challenged.artifactHash === challenger.artifactHash ? challenged.artifactHash : null, - registrationIds: [event.challengedRegistrationId, event.challengerRegistrationId].sort(), + registrationIds: [event.challengedRegistrationId, event.challengerRegistrationId].sort(compareOrdinalStrings), status: "open", reason: event.reason, outcome: null, @@ -896,7 +931,7 @@ export async function reduceAttestationEvents( }); } } else if (event.type === "challenge_resolved") { - await verifyAdminEventSignature(event, trust.adminSigners ?? {}); + await verifyAdminEventSignature(event, adminSigners); const conflict = conflicts.get(event.conflictId); if (!conflict) throw new Error("resolution targets an unknown conflict"); const openedAt = challengeOpenedAt.get(event.conflictId); @@ -905,7 +940,7 @@ export async function reduceAttestationEvents( if (conflict.status === "resolved") throw new Error("conflict is already resolved"); conflicts.set(event.conflictId, { ...conflict, status: "resolved", outcome: event.outcome, eventIds: [...conflict.eventIds, event.eventId] }); } else { - await verifyAdminEventSignature(event, trust.adminSigners ?? {}); + await verifyAdminEventSignature(event, adminSigners); const registration = registrations[event.registrationId]; if (!registration) throw new Error("revocation targets an unknown registration"); if (event.level === "repository_control_verified") { @@ -961,7 +996,7 @@ export async function reduceAttestationEvents( })); return deepFreeze({ registrations: publicRegistrations, - conflicts: [...conflicts.values()].sort((a, b) => a.conflictId.localeCompare(b.conflictId)), + conflicts: [...conflicts.values()].sort((a, b) => compareOrdinalStrings(a.conflictId, b.conflictId)), events: parsedEvents, }); } diff --git a/phase0/src/index.ts b/phase0/src/index.ts index 3985d38..2bdbe6f 100644 --- a/phase0/src/index.ts +++ b/phase0/src/index.ts @@ -3,6 +3,7 @@ import { parseArgs } from "node:util"; import { FileOperationJournal } from "./transactions"; import type { AttestationCommand, AttestationCommandOptions } from "./attestation-cli"; +import { renderTopLevelError } from "./terminal"; const registrationsPath = fileURLToPath(new URL("../registrations.json", import.meta.url)); const pendingTransactionsPath = fileURLToPath( @@ -196,7 +197,7 @@ async function main(): Promise { const invokedPath = process.argv[1]; if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { main().catch((error) => { - console.error("\n✗ " + (error instanceof Error ? error.message : String(error))); + console.error(renderTopLevelError(error)); process.exitCode = 1; }); } diff --git a/phase0/src/terminal.ts b/phase0/src/terminal.ts new file mode 100644 index 0000000..e4a5432 --- /dev/null +++ b/phase0/src/terminal.ts @@ -0,0 +1,54 @@ +/** + * Render untrusted terminal text as one deterministic quoted line. + * + * The encoding is deliberately locale-independent. It escapes JSON-significant + * punctuation plus C0/C1 controls, ANSI controls, bidi controls, line separators, + * and unpaired UTF-16 surrogates. Valid surrogate pairs remain readable. + */ +export function quoteTerminalText(value: string): string { + let quoted = '"'; + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit === 0x22) { + quoted += '\\"'; + } else if (codeUnit === 0x5c) { + quoted += "\\\\"; + } else if ( + codeUnit <= 0x1f + || (codeUnit >= 0x7f && codeUnit <= 0x9f) + || codeUnit === 0x061c + || codeUnit === 0x200e + || codeUnit === 0x200f + || (codeUnit >= 0x2028 && codeUnit <= 0x202e) + || (codeUnit >= 0x2066 && codeUnit <= 0x206f) + || (codeUnit >= 0xd800 && codeUnit <= 0xdfff + && !(codeUnit <= 0xdbff + && index + 1 < value.length + && value.charCodeAt(index + 1) >= 0xdc00 + && value.charCodeAt(index + 1) <= 0xdfff)) + ) { + quoted += `\\u${codeUnit.toString(16).padStart(4, "0")}`; + } else { + quoted += value[index]; + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + index += 1; + quoted += value[index]; + } + } + } + return `${quoted}"`; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + try { + return String(error); + } catch { + return "Unknown command failure"; + } +} + +export function renderTopLevelError(error: unknown): string { + return `✗ ${quoteTerminalText(errorMessage(error))}`; +} diff --git a/phase0/tests/attestation-git.test.ts b/phase0/tests/attestation-git.test.ts index 362d516..541a641 100644 --- a/phase0/tests/attestation-git.test.ts +++ b/phase0/tests/attestation-git.test.ts @@ -209,6 +209,48 @@ test("forge observation trust rejects inherited signer entries and exotic map pr ); }); +test("forge observation snapshots every own signer entry without invoking accessors", async (t) => { + const f = await fixture(t); + const trusted = f.repositories.resolve("demo", REPOSITORY_URL); + const canonicalPublicKey = f.forge.publicKey.export({ type: "spki", format: "pem" }).toString(); + let getterCalls = 0; + const accessorTrust = { "forge-1": canonicalPublicKey } as Record; + Object.defineProperty(accessorTrust, "unused-forge", { + enumerable: true, + get() { + getterCalls += 1; + return canonicalPublicKey; + }, + }); + + assert.throws( + () => verifyForgeObservation(f.forgeObservation, trusted, accessorTrust), + /own enumerable data properties/, + ); + assert.equal(getterCalls, 0); + assert.throws( + () => verifyForgeObservation(f.forgeObservation, trusted, { + "forge-1": canonicalPublicKey, + "unused-forge": "not-a-public-key", + }), + /canonical Ed25519 SPKI public key/, + ); + + const symbolTrust = { "forge-1": canonicalPublicKey } as Record; + Object.defineProperty(symbolTrust, Symbol("unused-forge"), { + enumerable: true, + value: canonicalPublicKey, + }); + assert.throws( + () => verifyForgeObservation( + f.forgeObservation, + trusted, + symbolTrust as Readonly>, + ), + /string identifier keys/, + ); +}); + test("repository verification rejects signed-binding and snapshot tampering", async (t) => { const f = await fixture(t); const base = { diff --git a/phase0/tests/attestation-store.test.ts b/phase0/tests/attestation-store.test.ts index 6f767bf..10b57d5 100644 --- a/phase0/tests/attestation-store.test.ts +++ b/phase0/tests/attestation-store.test.ts @@ -527,44 +527,113 @@ test("organization approval credentials remain consumed after close and reopen", assert.deepEqual(await readFile(f.path), beforeReplay); }); -test("store replay rejects a malformed injected organization signer root before append", async (t) => { +test("store construction rejects a malformed injected organization signer root before append", async (t) => { const f = await repositoryFixture(t); const malformedOrganizationSigners = { "example-org": f.approver.address.toLowerCase(), } as unknown as Record; const validStore = new FileAttestationStore(f.path, f.options); await validStore.append(f.repositoryEvent); - const store = new FileAttestationStore(f.path, { - ...f.options, - organizationSigners: malformedOrganizationSigners, - }); const before = await readFile(f.path); - const unsignedApproval = { - schemaVersion: 1 as const, - subject: f.base, - organizationId: "example-org", - approverWallet: f.approver.address.toLowerCase() as `0x${string}`, - role: "ip_admin" as const, - approvedAt: "2026-07-18T12:30:00.000Z", - }; - const approval = { - ...unsignedApproval, - statementHash: organizationStatementHash(unsignedApproval), - signature: await f.approver.signMessage({ message: canonicalOrganizationStatement(unsignedApproval) }), - }; - const organization: OrganizationApprovedEvent = { - type: "organization_approved", - eventId: "organization-malformed-root", - sequence: 2, - occurredAt: "2026-07-18T13:00:00.000Z", - subject: f.base, - approval, - }; - - await assert.rejects(store.append(organization), /organization signer allow-list.*array/i); + assert.throws( + () => new FileAttestationStore(f.path, { + ...f.options, + organizationSigners: malformedOrganizationSigners, + }), + /organization signer allow-list.*array/i, + ); assert.deepEqual(await readFile(f.path), before); }); +test("store constructor snapshots all trust maps through descriptors before structured cloning", () => { + const admin = privateKeyToAccount(generatePrivateKey()); + const wallet = admin.address.toLowerCase() as `0x${string}`; + const forge = generateKeyPairSync("ed25519"); + const forgeKey = forge.publicKey.export({ type: "spki", format: "pem" }).toString(); + const path = "/tmp/phase0-attestation-descriptor-snapshot.jsonl"; + + let organizationRootGetterCalls = 0; + const organizationRootAccessor = {} as Record; + Object.defineProperty(organizationRootAccessor, "example-org", { + enumerable: true, + get() { + organizationRootGetterCalls += 1; + return [wallet]; + }, + }); + assert.throws( + () => new FileAttestationStore(path, { + baseSubjects: [], + organizationSigners: organizationRootAccessor, + }), + /organization signer trust entries must be own data properties/, + ); + assert.equal(organizationRootGetterCalls, 0); + + let organizationIndexGetterCalls = 0; + const organizationIndexAccessor = new Array(1) as `0x${string}`[]; + Object.defineProperty(organizationIndexAccessor, "0", { + enumerable: true, + get() { + organizationIndexGetterCalls += 1; + return wallet; + }, + }); + assert.throws( + () => new FileAttestationStore(path, { + baseSubjects: [], + organizationSigners: { "example-org": organizationIndexAccessor }, + }), + /organization signer allow-list must have own indexed entries/, + ); + assert.equal(organizationIndexGetterCalls, 0); + + let adminGetterCalls = 0; + const adminAccessor = { "admin-1": wallet } as Record; + Object.defineProperty(adminAccessor, "unused-admin", { + enumerable: true, + get() { + adminGetterCalls += 1; + return wallet; + }, + }); + assert.throws( + () => new FileAttestationStore(path, { baseSubjects: [], adminSigners: adminAccessor }), + /attestation admin trust entries must be own enumerable data properties/, + ); + assert.equal(adminGetterCalls, 0); + + let forgeGetterCalls = 0; + const forgeAccessor = { "forge-1": forgeKey } as Record; + Object.defineProperty(forgeAccessor, "unused-forge", { + enumerable: true, + get() { + forgeGetterCalls += 1; + return forgeKey; + }, + }); + assert.throws( + () => new FileAttestationStore(path, { baseSubjects: [], forgeSigners: forgeAccessor }), + /forge signer trust entries must be own enumerable data properties/, + ); + assert.equal(forgeGetterCalls, 0); + + assert.throws( + () => new FileAttestationStore(path, { + baseSubjects: [], + adminSigners: { "admin-1": wallet, "unused-admin": "invalid" as `0x${string}` }, + }), + /canonical lowercase address/, + ); + assert.throws( + () => new FileAttestationStore(path, { + baseSubjects: [], + forgeSigners: { "forge-1": forgeKey, "unused-forge": "not-a-key" }, + }), + /canonical Ed25519 SPKI public key/, + ); +}); + test("store replay applies the injected verifier clock deterministically", async (t) => { const f = await fixture(t); const clocked = new FileAttestationStore(f.path, { diff --git a/phase0/tests/attestations.test.ts b/phase0/tests/attestations.test.ts index d0e3fe5..d5fe8c8 100644 --- a/phase0/tests/attestations.test.ts +++ b/phase0/tests/attestations.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import test from "node:test"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; @@ -290,6 +291,54 @@ test("organization approval rejects malformed own signer allow-lists before acce ); }); +test("organization trust rejects root and array accessors without invoking them", async () => { + const creator = privateKeyToAccount(generatePrivateKey()); + const approver = privateKeyToAccount(generatePrivateKey()); + const unsigned = { + schemaVersion: 1 as const, + subject: subject(IP_A, creator.address), + organizationId: "example-org", + approverWallet: approver.address.toLowerCase() as `0x${string}`, + role: "ip_admin" as const, + approvedAt: NOW, + }; + const approval = { + ...unsigned, + statementHash: organizationStatementHash(unsigned), + signature: await approver.signMessage({ message: canonicalOrganizationStatement(unsigned) }), + }; + + let rootGetterCalls = 0; + const rootAccessor = {} as Record; + Object.defineProperty(rootAccessor, "example-org", { + enumerable: true, + get() { + rootGetterCalls += 1; + return [unsigned.approverWallet]; + }, + }); + await assert.rejects( + verifyOrganizationApproval(approval, rootAccessor), + /own data properties/, + ); + assert.equal(rootGetterCalls, 0); + + let indexGetterCalls = 0; + const indexedAccessor = new Array(1) as `0x${string}`[]; + Object.defineProperty(indexedAccessor, "0", { + enumerable: true, + get() { + indexGetterCalls += 1; + return unsigned.approverWallet; + }, + }); + await assert.rejects( + verifyOrganizationApproval(approval, { "example-org": indexedAccessor }), + /own indexed entries/, + ); + assert.equal(indexGetterCalls, 0); +}); + test("duplicate bytes under different wallets create a deterministic visible conflict", async () => { const first = privateKeyToAccount(generatePrivateKey()); const second = privateKeyToAccount(generatePrivateKey()); @@ -427,6 +476,65 @@ test("admin events reject inherited signer entries and exotic trust-map prototyp ); }); +test("admin trust snapshots reject accessors, malformed unused entries, and duplicate wallets", async () => { + const admin = privateKeyToAccount(generatePrivateKey()); + const trustedWallet = admin.address.toLowerCase() as `0x${string}`; + const eventBase = { + type: "challenge_resolved" as const, + eventId: "resolution-admin-descriptors", + sequence: 1, + occurredAt: NOW, + conflictId: "conflict-admin-descriptors", + outcome: "rejected" as const, + rationale: "Only verifier-provisioned admin trust is under test.", + adminSignerId: "admin-1", + statementHash: HASH_A, + signature: "0x00" as `0x${string}`, + }; + const statementHash = adminEventStatementHash(eventBase); + const event: ChallengeResolvedEvent = { + ...eventBase, + statementHash, + signature: await admin.signMessage({ + message: canonicalAdminEventStatement({ ...eventBase, statementHash }), + }), + }; + + let getterCalls = 0; + const accessorTrust = { "admin-1": trustedWallet } as Record; + Object.defineProperty(accessorTrust, "unused-admin", { + enumerable: true, + get() { + getterCalls += 1; + return trustedWallet; + }, + }); + await assert.rejects(verifyAdminEventSignature(event, accessorTrust), /own enumerable data properties/); + assert.equal(getterCalls, 0); + + await assert.rejects( + verifyAdminEventSignature(event, { + "admin-1": trustedWallet, + "unused-admin": "not-an-address" as `0x${string}`, + }), + /canonical lowercase address/, + ); + await assert.rejects( + verifyAdminEventSignature(event, { + "admin-1": trustedWallet, + "unused-admin": trustedWallet, + }), + /globally unique/, + ); + + await assert.rejects( + reduceAttestationEvents([], { + adminSigners: { "unused-admin": "not-an-address" as `0x${string}` }, + }), + /canonical lowercase address/, + ); +}); + test("sequence gaps, duplicate IDs, malformed normalized inputs, and overclaim text fail", async () => { const account = privateKeyToAccount(generatePrivateKey()); const base = subject(IP_A, account.address); @@ -578,3 +686,72 @@ test("human status output lists challenged registrations and every matching conf assert.ok(json.registrations.every((registration: { status: string }) => registration.status === "challenged")); assert.deepEqual(json.conflicts, forward.conflicts); }); + +test("signed Unicode conflicts have identical ordinal human and JSON ordering under en-US and sv-SE", async () => { + const challenged = privateKeyToAccount(generatePrivateKey()); + const challenger = privateKeyToAccount(generatePrivateKey()); + const a = subject(IP_A, challenged.address, HASH_A); + const b = subject(IP_B, challenger.address, HASH_B); + + const signedChallenge = async (sequence: number, conflictId: string): Promise => { + const unsigned = { + type: "challenge_opened" as const, + eventId: `event-${sequence}-${conflictId}`, + sequence, + occurredAt: NOW, + conflictId, + challengedRegistrationId: a.registrationId, + challengerRegistrationId: b.registrationId, + challengerWallet: b.wallet, + evidenceUris: [`https://example.com/evidence/${sequence}`], + reason: "misattributed_creator" as const, + statementHash: HASH_A, + signature: "0x00" as `0x${string}`, + }; + const statementHash = challengeEventStatementHash(unsigned); + return { + ...unsigned, + statementHash, + signature: await challenger.signMessage({ + message: canonicalChallengeEventStatement({ ...unsigned, statementHash }), + }), + }; + }; + const payload = Buffer.from(JSON.stringify({ + baseSubjects: [a, b], + events: [ + await signedChallenge(1, "conflict-z"), + await signedChallenge(2, "conflict-ä"), + ], + })).toString("base64"); + const source = ` + import { reduceAttestationEvents } from "./src/attestations.ts"; + import { renderAttestationStatus } from "./src/attestation-cli.ts"; + const input = JSON.parse(Buffer.from(process.env.ATTESTATION_ORDER_PAYLOAD, "base64").toString("utf8")); + const index = await reduceAttestationEvents(input.events, { baseSubjects: input.baseSubjects }); + process.stdout.write(JSON.stringify({ + human: renderAttestationStatus(index), + json: JSON.parse(renderAttestationStatus(index, { json: true })[0]), + })); + `; + const outputs = ["en_US.UTF-8", "sv_SE.UTF-8"].map((locale) => { + const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", source], { + cwd: new URL("..", import.meta.url), + encoding: "utf8", + env: { + ...process.env, + LANG: locale, + LC_ALL: locale, + ATTESTATION_ORDER_PAYLOAD: payload, + }, + }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); + }); + + assert.deepEqual(outputs[0], outputs[1]); + assert.deepEqual( + outputs[0].json.conflicts.map((conflict: { conflictId: string }) => conflict.conflictId), + ["conflict-z", "conflict-ä"], + ); +}); diff --git a/phase0/tests/index.test.ts b/phase0/tests/index.test.ts index a381969..0236496 100644 --- a/phase0/tests/index.test.ts +++ b/phase0/tests/index.test.ts @@ -1,7 +1,20 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import test from "node:test"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +import { loadLocalCheckoutMap, type AttestationConfigFileSystem } from "../src/attestation-config"; +import { + canonicalChallengeEventStatement, + challengeEventStatementHash, + reduceAttestationEvents, + type ChallengeOpenedEvent, + type RegistrationSubject, +} from "../src/attestations"; +import { ExecGitReader } from "../src/attestation-git"; import { runCommand, type CommandDependencies } from "../src/index"; +import { renderTopLevelError } from "../src/terminal"; const LEASE_ID = "0123456789abcdef0123456789abcdef"; @@ -72,3 +85,132 @@ for (const retired of ["create-collection", "register-skill", "register-derivati assert.deepEqual(fixture.calls, { check: 0, demo: 0, recover: [] }); }); } + +const UNSAFE_TERMINAL = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u206f]/u; + +async function rejected(operation: Promise): Promise { + try { + await operation; + } catch (error) { + return error; + } + assert.fail("expected operation to reject"); +} + +function assertSafeTopLevelError(error: unknown): string { + const rendered = renderTopLevelError(error); + assert.ok(rendered.startsWith('✗ "')); + assert.ok(rendered.endsWith('"')); + assert.doesNotMatch(rendered, UNSAFE_TERMINAL); + assert.equal(rendered.split("\n").length, 1); + return rendered; +} + +test("terminal errors deterministically escape quotes, slashes, controls, bidi, and unpaired surrogates", () => { + const message = "quote\" slash\\ c0\u0000 ansi\u001b c1\u0085\u009b bidi\u061c\u200e\u200f\u202a\u202e\u2066\u2069\u206f line\u2028\u2029 high\ud800 low\udfff"; + const rendered = assertSafeTopLevelError(new Error(message)); + for (const token of [ + '\\"', "\\\\", "\\u0000", "\\u001b", "\\u0085", "\\u009b", "\\u061c", + "\\u200e", "\\u200f", "\\u2028", "\\u2029", "\\u202a", "\\u202e", + "\\u2066", "\\u2069", "\\u206f", "\\ud800", "\\udfff", + ]) assert.ok(rendered.includes(token), token); +}); + +test("top-level CLI stderr is one escaped line with no leading blank line", () => { + const result = spawnSync(process.execPath, ["--import", "tsx", "src/index.ts", "check", "extra"], { + cwd: new URL("..", import.meta.url), + encoding: "utf8", + env: { ...process.env, HTTP_PROXY: "", HTTPS_PROXY: "", ALL_PROXY: "" }, + }); + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ""); + assert.ok(result.stderr.startsWith('✗ "')); + assert.ok(!result.stderr.startsWith("\n")); + assert.equal(result.stderr.trimEnd().split("\n").length, 1); +}); + +test("real config-path, duplicate-event, and Git-stderr failures use safe top-level rendering", async () => { + const hostilePath = "/outside/config-\"\\\n\u001b\u0085\u202e\ud800.json"; + const missing = Object.assign(new Error("missing"), { code: "ENOENT" }); + const fs: AttestationConfigFileSystem = { + realpath: async (path) => path, + openNoFollow: async () => { throw missing; }, + currentUid: () => 1, + }; + const configError = await rejected(loadLocalCheckoutMap({ + env: { PHASE0_ATTESTATION_CHECKOUTS_FILE: hostilePath }, + phase0Root: "/phase0", + referencedCheckoutKeys: [], + fs, + })); + const renderedConfigError = assertSafeTopLevelError(configError); + assert.ok(renderedConfigError.includes("repository snapshot mapping unavailable")); + assert.ok(renderedConfigError.includes('\\"')); + assert.ok(renderedConfigError.includes("\\\\")); + assert.ok(renderedConfigError.includes("\\u001b")); + assert.ok(renderedConfigError.includes("\\u0085")); + assert.ok(renderedConfigError.includes("\\u202e")); + assert.ok(renderedConfigError.includes("\\ud800")); + + const challenged = privateKeyToAccount(generatePrivateKey()); + const challenger = privateKeyToAccount(generatePrivateKey()); + const challengedIp = `0x${"a".repeat(40)}` as const; + const challengerIp = `0x${"b".repeat(40)}` as const; + const baseSubjects: RegistrationSubject[] = [ + { + registrationId: `eip155:1315:${challengedIp}`, + ipId: challengedIp, + wallet: challenged.address.toLowerCase() as `0x${string}`, + artifactHash: `0x${"1".repeat(64)}`, + declaredParentIpIds: [], + }, + { + registrationId: `eip155:1315:${challengerIp}`, + ipId: challengerIp, + wallet: challenger.address.toLowerCase() as `0x${string}`, + artifactHash: `0x${"2".repeat(64)}`, + declaredParentIpIds: [], + }, + ]; + const hostileEventId = "event-\"\\\n\u001b\u0085\u202e\ud800"; + const unsigned = { + type: "challenge_opened" as const, + eventId: hostileEventId, + sequence: 1, + occurredAt: "2026-07-18T12:00:00.000Z", + conflictId: "conflict-duplicate-event", + challengedRegistrationId: baseSubjects[0].registrationId, + challengerRegistrationId: baseSubjects[1].registrationId, + challengerWallet: baseSubjects[1].wallet, + evidenceUris: ["https://example.com/evidence"], + reason: "misattributed_creator" as const, + statementHash: `0x${"0".repeat(64)}` as `0x${string}`, + signature: "0x00" as `0x${string}`, + }; + const statementHash = challengeEventStatementHash(unsigned); + const event: ChallengeOpenedEvent = { + ...unsigned, + statementHash, + signature: await challenger.signMessage({ + message: canonicalChallengeEventStatement({ ...unsigned, statementHash }), + }), + }; + const duplicateError = await rejected(reduceAttestationEvents([ + event, + { ...event, sequence: 2 }, + ], { baseSubjects })); + const renderedDuplicateError = assertSafeTopLevelError(duplicateError); + assert.ok(renderedDuplicateError.includes("duplicate attestation event ID")); + assert.ok(renderedDuplicateError.includes("\\u001b")); + assert.ok(renderedDuplicateError.includes("\\u0085")); + assert.ok(renderedDuplicateError.includes("\\u202e")); + assert.ok(renderedDuplicateError.includes("\\ud800")); + + const gitPath = "/definitely/missing-\"\\\n\u001b\u0085\u202e"; + const gitError = await rejected(new ExecGitReader().remoteUrl(gitPath, "origin")); + const renderedGitError = assertSafeTopLevelError(gitError); + assert.ok(renderedGitError.includes("offline Git verification failed")); + assert.ok(renderedGitError.includes("\\u000a")); + assert.ok(renderedGitError.includes('\\"')); + assert.ok(renderedGitError.includes("\\\\")); +}); From 51abab0a3e9979c074df7b4413763798d4447e0e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 06:53:56 -0400 Subject: [PATCH 115/165] fix: prevent Wielder reservation reentrancy --- spikes/pi-wielder/README.md | 2 +- spikes/pi-wielder/src/payment-policy.mjs | 47 +++-- spikes/pi-wielder/src/proxy.mjs | 25 ++- spikes/pi-wielder/tests/paying-fetch.test.mjs | 64 +++++++ .../pi-wielder/tests/payment-policy.test.mjs | 178 ++++++++++++++++++ 5 files changed, 288 insertions(+), 28 deletions(-) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 28e0a5b..e888195 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -149,7 +149,7 @@ npm test npm run e2e ``` -Expected current results are 134 offline unit/integration tests and 30 offline e2e +Expected current results are 141 offline unit/integration tests and 30 offline e2e checks. Counts can increase as regressions are added; zero failures is the contract. The e2e labels all timing output synthetic and uses in-process Hono requests only. diff --git a/spikes/pi-wielder/src/payment-policy.mjs b/spikes/pi-wielder/src/payment-policy.mjs index 1179e94..9874093 100644 --- a/spikes/pi-wielder/src/payment-policy.mjs +++ b/spikes/pi-wielder/src/payment-policy.mjs @@ -367,6 +367,7 @@ export function createPaymentPolicy(config) { const records = new Map(); const authorizationNonces = new Map(); const settlementTransactions = new Map(); + const activeReservations = new Set(); const activeMonetaryTransitions = new Map(); let reservedAtomic = 0n; let spentAtomic = 0n; @@ -542,25 +543,33 @@ export function createPaymentPolicy(config) { } return publicRecord(existing); } - const validated = validateOffer(input); - const amount = BigInt(validated.amountAtomic); - if (spentAtomic + reservedAtomic + amount > budget) { - fail('SESSION_BUDGET', 'offer exceeds remaining one-process session budget'); - } - const record = { - authorizationId, - ...validated, - state: 'reserved', - retryCount: 0, - txHash: null, - reasonCode: null, - authorization: null, - signature: null, - xPayment: null, - }; - commitBudget({ reservedDelta: amount }); - records.set(authorizationId, record); - return publicRecord(record); + if (activeReservations.has(authorizationId)) { + fail('TRANSITION_REENTRANCY', 'reentrant authorization reservation is forbidden'); + } + activeReservations.add(authorizationId); + try { + const validated = validateOffer(input); + const amount = BigInt(validated.amountAtomic); + if (spentAtomic + reservedAtomic + amount > budget) { + fail('SESSION_BUDGET', 'offer exceeds remaining one-process session budget'); + } + const record = { + authorizationId, + ...validated, + state: 'reserved', + retryCount: 0, + txHash: null, + reasonCode: null, + authorization: null, + signature: null, + xPayment: null, + }; + commitBudget({ reservedDelta: amount }); + records.set(authorizationId, record); + return publicRecord(record); + } finally { + activeReservations.delete(authorizationId); + } } function claimSignature(authorizationId, input) { diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index 314193d..f3968a5 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -253,13 +253,22 @@ export async function payingFetch(account, url, init, options = {}) { if (!claim.claimed) { throw paymentError('AUTHORIZATION_ALREADY_USED', 'idempotency key already has a signature claim'); } - const payer = typeof account?.address === 'string' ? account.address.toLowerCase() : ''; - if (!/^0x[0-9a-f]{40}$/.test(payer) || typeof account?.signTypedData !== 'function') { - paymentPolicy.releaseUnsigned(idempotencyKey, { reasonCode: 'INVALID_WALLET_CAPABILITY' }); - throw paymentError('WALLET_CAPABILITY', 'wallet must expose a canonical address and signTypedData capability'); - } + let payer; + let signTypedData; let authorization; + let preSignFailureReason = 'LOCAL_AUTHORIZATION_FAILURE'; try { + const address = account?.address; + const signer = account?.signTypedData; + payer = typeof address === 'string' ? address.toLowerCase() : ''; + if (!/^0x[0-9a-f]{40}$/.test(payer) || typeof signer !== 'function') { + preSignFailureReason = 'INVALID_WALLET_CAPABILITY'; + throw paymentError( + 'WALLET_CAPABILITY', + 'wallet must expose a canonical address and signTypedData capability', + ); + } + signTypedData = signer; const nonce = nonceFactory(); if (nonce && typeof nonce.then === 'function') { throw paymentError('NONCE_CAPABILITY', 'nonceFactory must be synchronous'); @@ -277,7 +286,7 @@ export async function payingFetch(account, url, init, options = {}) { }; } catch (error) { paymentPolicy.releaseUnsigned(idempotencyKey, { - reasonCode: 'LOCAL_AUTHORIZATION_FAILURE', + reasonCode: preSignFailureReason, }); throw error; } @@ -285,7 +294,7 @@ export async function payingFetch(account, url, init, options = {}) { let signatureReturned = false; let signature; try { - signature = await account.signTypedData({ + signature = await Reflect.apply(signTypedData, account, [{ domain: { name: req.extra.name, version: req.extra.version, @@ -300,7 +309,7 @@ export async function payingFetch(account, url, init, options = {}) { validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore), }, - }); + }]); signatureReturned = true; msSign = performance.now() - tSign; const xPayment = b64({ diff --git a/spikes/pi-wielder/tests/paying-fetch.test.mjs b/spikes/pi-wielder/tests/paying-fetch.test.mjs index 1407093..27a0576 100644 --- a/spikes/pi-wielder/tests/paying-fetch.test.mjs +++ b/spikes/pi-wielder/tests/paying-fetch.test.mjs @@ -646,6 +646,70 @@ test('ordinary signer rejection before any signature return releases reservation assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'released'); }); +test('wallet address and signer access failures release before signer invocation', async () => { + for (const capability of ['address', 'signTypedData']) { + const { account, paymentPolicy, signatureCount } = setup(); + Object.defineProperty(account, capability, { + configurable: true, + enumerable: true, + get() { + throw new Error(`synthetic ${capability} access failure`); + }, + }); + + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => challenge(), + idempotencyKey: `idem-${capability}-access-failure`, + paymentPolicy, + }), new RegExp(`synthetic ${capability} access failure`)); + assert.equal(signatureCount(), 0, capability); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0', capability); + assert.equal(paymentPolicy.snapshot().remainingAtomic, '500000', capability); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'released', capability); + } +}); + +test('wallet address and signer capabilities are captured exactly once before signing', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + const signer = account.signTypedData; + let addressReads = 0; + let signerReads = 0; + Object.defineProperty(account, 'address', { + configurable: true, + enumerable: true, + get() { + addressReads += 1; + if (addressReads > 1) throw new Error('wallet address was reread'); + return PAYER; + }, + }); + Object.defineProperty(account, 'signTypedData', { + configurable: true, + enumerable: true, + get() { + signerReads += 1; + if (signerReads > 1) throw new Error('wallet signer was reread'); + return signer; + }, + }); + let fetches = 0; + + const result = await payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async (_url, init) => { + fetches += 1; + return fetches === 1 ? challenge() : paidResponse(init); + }, + idempotencyKey: 'idem-wallet-snapshot', + paymentPolicy, + }); + assert.equal(result.paid, true); + assert.equal(addressReads, 1); + assert.equal(signerReads, 1); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); + assert.equal(paymentPolicy.snapshot().spentAtomic, '250000'); +}); + test('local nonce construction failure before signer invocation releases the reservation', async () => { const { account, paymentPolicy, signatureCount } = setup(); await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { diff --git a/spikes/pi-wielder/tests/payment-policy.test.mjs b/spikes/pi-wielder/tests/payment-policy.test.mjs index be8a623..296ddb6 100644 --- a/spikes/pi-wielder/tests/payment-policy.test.mjs +++ b/spikes/pi-wielder/tests/payment-policy.test.mjs @@ -313,6 +313,184 @@ test('concurrent reservations count reserved and settled spend against one sessi }); }); +test('caught same-id reservation reentry from an input accessor commits one reservation', () => { + const subject = policy(); + let nestedErrors = 0; + const input = { + authorizationId: 'auth-accessor-caught', + requestUrl: URL, + method: 'POST', + bodyBytes: BODY, + challenge: challenge(), + receivedAt: subject.captureReceivedAt(), + }; + Object.defineProperty(input, 'requestUrl', { + enumerable: true, + get() { + try { + reserve(subject, { authorizationId: 'auth-accessor-caught' }); + } catch (error) { + assert.equal(error.code, 'TRANSITION_REENTRANCY'); + nestedErrors += 1; + } + return URL; + }, + }); + + const record = subject.reserveAuthorization(input); + assert.equal(record.authorizationId, 'auth-accessor-caught'); + assert.equal(nestedErrors, 1); + assert.deepEqual(subject.snapshot(), { + sessionBudgetAtomic: '500000', + reservedAtomic: '250000', + spentAtomic: '0', + remainingAtomic: '250000', + authorizations: [{ + authorizationId: 'auth-accessor-caught', amountAtomic: '250000', state: 'reserved', + retryCount: 0, txHash: null, reasonCode: null, + }], + }); + subject.releaseUnsigned('auth-accessor-caught', { reasonCode: 'CALL_CANCELLED' }); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().remainingAtomic, '500000'); +}); + +test('uncaught same-id reservation reentry from an input accessor clears its guard', () => { + const subject = policy(); + const input = { + authorizationId: 'auth-accessor-uncaught', + requestUrl: URL, + method: 'POST', + bodyBytes: BODY, + challenge: challenge(), + receivedAt: subject.captureReceivedAt(), + }; + Object.defineProperty(input, 'requestUrl', { + enumerable: true, + get() { + reserve(subject, { authorizationId: 'auth-accessor-uncaught' }); + return URL; + }, + }); + + assert.throws(() => subject.reserveAuthorization(input), + (error) => error.code === 'TRANSITION_REENTRANCY'); + assert.deepEqual(subject.snapshot(), { + sessionBudgetAtomic: '500000', + reservedAtomic: '0', + spentAtomic: '0', + remainingAtomic: '500000', + authorizations: [], + }); + + reserve(subject, { authorizationId: 'auth-accessor-uncaught' }); + subject.releaseUnsigned('auth-accessor-uncaught', { reasonCode: 'CALL_CANCELLED' }); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().remainingAtomic, '500000'); +}); + +test('caught same-id reservation reentry from the trusted clock commits one reservation', () => { + let subject; + let reenter = false; + let nestedErrors = 0; + subject = policy({ + now: () => { + if (reenter) { + reenter = false; + try { + reserve(subject, { authorizationId: 'auth-clock-caught' }); + } catch (error) { + assert.equal(error.code, 'TRANSITION_REENTRANCY'); + nestedErrors += 1; + } + } + return NOW; + }, + }); + const receivedAt = subject.captureReceivedAt(); + reenter = true; + + const record = subject.reserveAuthorization({ + authorizationId: 'auth-clock-caught', + requestUrl: URL, + method: 'POST', + bodyBytes: BODY, + challenge: challenge(), + receivedAt, + }); + assert.equal(record.authorizationId, 'auth-clock-caught'); + assert.equal(nestedErrors, 1); + assert.equal(subject.snapshot().authorizations.length, 1); + assert.equal(subject.snapshot().reservedAtomic, '250000'); + assert.equal(subject.snapshot().remainingAtomic, '250000'); + subject.releaseUnsigned('auth-clock-caught', { reasonCode: 'CALL_CANCELLED' }); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().remainingAtomic, '500000'); +}); + +test('uncaught same-id reservation reentry from the trusted clock clears its guard', () => { + let subject; + let reenter = false; + subject = policy({ + now: () => { + if (reenter) { + reenter = false; + reserve(subject, { authorizationId: 'auth-clock-uncaught' }); + } + return NOW; + }, + }); + const receivedAt = subject.captureReceivedAt(); + reenter = true; + + assert.throws(() => subject.reserveAuthorization({ + authorizationId: 'auth-clock-uncaught', + requestUrl: URL, + method: 'POST', + bodyBytes: BODY, + challenge: challenge(), + receivedAt, + }), (error) => error.code === 'TRANSITION_REENTRANCY'); + assert.equal(subject.snapshot().authorizations.length, 0); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().remainingAtomic, '500000'); + + reserve(subject, { authorizationId: 'auth-clock-uncaught' }); + subject.releaseUnsigned('auth-clock-uncaught', { reasonCode: 'CALL_CANCELLED' }); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().remainingAtomic, '500000'); +}); + +test('different-id callback reservations retain ordinary session-budget behavior', () => { + const subject = policy(); + let nestedRecord; + const input = { + authorizationId: 'auth-outer', + requestUrl: URL, + method: 'POST', + bodyBytes: BODY, + challenge: challenge(), + receivedAt: subject.captureReceivedAt(), + }; + Object.defineProperty(input, 'requestUrl', { + enumerable: true, + get() { + nestedRecord = reserve(subject, { authorizationId: 'auth-inner' }); + return URL; + }, + }); + + subject.reserveAuthorization(input); + assert.equal(nestedRecord.authorizationId, 'auth-inner'); + assert.equal(subject.snapshot().authorizations.length, 2); + assert.equal(subject.snapshot().reservedAtomic, '500000'); + assert.equal(subject.snapshot().remainingAtomic, '0'); + subject.releaseUnsigned('auth-inner', { reasonCode: 'CALL_CANCELLED' }); + subject.releaseUnsigned('auth-outer', { reasonCode: 'CALL_CANCELLED' }); + assert.equal(subject.snapshot().reservedAtomic, '0'); + assert.equal(subject.snapshot().remainingAtomic, '500000'); +}); + test('authorization, signature, and encoded payment are exact, immutable, and copied before persistence', () => { const subject = policy(); const record = reserve(subject); From 4811bbeb889737d1ed357da6e782a2b44c817e34 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 11:58:46 -0400 Subject: [PATCH 116/165] docs: clarify Wielder header binding --- spikes/pi-wielder/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index e888195..f948acc 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -38,11 +38,12 @@ numeric or coerced atomic amounts, unknown protocol fields, caller-supplied paym idempotency headers, ambiguous URL forms, and path-prefix confusion. The caller's method, body bytes, and headers are captured once before the unpaid -request and reused for the policy hash, signed recovery, and paid retry; redirects are -disabled on both requests. Validated policy limits and seller rules are snapshotted at -construction. The trusted clock rejects backward movement and rechecks age from both -local receipt and server issue time after challenge parsing and immediately before the -paid retry. +request. Method and body bytes bind the policy hash and signed recovery; captured +headers are reused for the unpaid and paid requests but are not signed or covered by +the request hash. Redirects are disabled on both requests. Validated policy limits and +seller rules are snapshotted at construction. The trusted clock rejects backward +movement and rechecks age from both local receipt and server issue time after challenge +parsing and immediately before the paid retry. Budget is synchronously reserved before signing. The exact authorization, signature, and encoded `X-PAYMENT` value are stored before the one paid retry begins. A recovery From bcd70ec4e3fa579effb2ad517e32861602207047 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:06:30 -0400 Subject: [PATCH 117/165] feat: quote hosted Skill execution costs --- spikes/pi-wielder/src/execution-economics.mjs | 517 ++++++++++++++++++ .../tests/execution-economics.test.mjs | 236 ++++++++ 2 files changed, 753 insertions(+) create mode 100644 spikes/pi-wielder/src/execution-economics.mjs create mode 100644 spikes/pi-wielder/tests/execution-economics.test.mjs diff --git a/spikes/pi-wielder/src/execution-economics.mjs b/spikes/pi-wielder/src/execution-economics.mjs new file mode 100644 index 0000000..db3d80e --- /dev/null +++ b/spikes/pi-wielder/src/execution-economics.mjs @@ -0,0 +1,517 @@ +import crypto from 'node:crypto'; + +import { allocateExternalGross } from '../../../prototype/atomic-money.mjs'; + +export class ExecutionEconomicsError extends Error { + constructor(code, message) { + super(message); + this.name = 'ExecutionEconomicsError'; + this.code = code; + } +} + +const fail = (code, message) => { throw new ExecutionEconomicsError(code, message); }; + +function isPlainObject(value) { + return value !== null + && typeof value === 'object' + && !Array.isArray(value) + && Object.getPrototypeOf(value) === Object.prototype; +} + +function exactPlainObject(value, required, optional, code, label) { + if (!isPlainObject(value)) fail(code, `${label} must be an exact plain object`); + const keys = Object.keys(value); + if (required.some((key) => !keys.includes(key)) + || keys.some((key) => !required.includes(key) && !optional.includes(key))) { + fail(code, `${label} has missing or unknown fields`); + } + return value; +} + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +const frozenCopy = (value) => deepFreeze(structuredClone(value)); + +export const EXECUTION_CATALOG = deepFreeze({ + schemaVersion: 2, + version: 'synthetic-anthropic-2026-07-17-v1', + evidenceLabel: 'synthetic_config', + source: null, + asOf: null, + models: { + 'claude-sonnet-4-6': { + provider: 'anthropic', + inputAtomicPerMillionTokens: '3000000', + outputAtomicPerMillionTokens: '15000000', + maxInputTokens: 16384, + maxOutputTokens: 2048, + }, + }, +}); + +function integer(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + fail('TOKEN_INTEGER', `${label} must be a non-negative safe integer`); + } + return BigInt(value); +} + +function atomic(value, label) { + if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value)) { + fail('ATOMIC_FORMAT', `${label} must be a canonical atomic string`); + } + return BigInt(value); +} + +function canonicalTimestamp(value, label) { + if (typeof value !== 'string' + || !Number.isFinite(Date.parse(value)) + || new Date(value).toISOString() !== value) { + fail('CATALOG_SCHEMA', `${label} must be one canonical ISO timestamp`); + } + return value; +} + +const ceilDiv = (numerator, denominator) => (numerator + denominator - 1n) / denominator; + +function validateCatalog(input) { + const catalog = exactPlainObject(input, + ['schemaVersion', 'version', 'evidenceLabel', 'source', 'asOf', 'models'], [], + 'CATALOG_SCHEMA', 'execution catalog'); + if (catalog.schemaVersion !== 2 + || typeof catalog.version !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(catalog.version) + || !['synthetic_config', 'human_verified'].includes(catalog.evidenceLabel) + || !isPlainObject(catalog.models) || Object.keys(catalog.models).length === 0) { + fail('CATALOG_SCHEMA', 'execution catalog metadata is invalid'); + } + if (catalog.evidenceLabel === 'synthetic_config') { + if (catalog.source !== null || catalog.asOf !== null) { + fail('CATALOG_SCHEMA', 'synthetic catalog source and as-of must remain null'); + } + } else { + if (typeof catalog.source !== 'string' || !catalog.source) { + fail('CATALOG_SCHEMA', 'human-verified catalog requires a source'); + } + canonicalTimestamp(catalog.asOf, 'catalog as-of'); + } + for (const [model, policy] of Object.entries(catalog.models)) { + if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(model)) { + fail('CATALOG_SCHEMA', 'catalog model identifier is invalid'); + } + exactPlainObject(policy, [ + 'provider', 'inputAtomicPerMillionTokens', 'outputAtomicPerMillionTokens', + 'maxInputTokens', 'maxOutputTokens', + ], [], 'CATALOG_SCHEMA', `catalog model '${model}'`); + if (typeof policy.provider !== 'string' || !policy.provider + || integer(policy.maxInputTokens, 'catalog maxInputTokens') <= 0n + || integer(policy.maxOutputTokens, 'catalog maxOutputTokens') <= 0n + || atomic(policy.inputAtomicPerMillionTokens, 'catalog input rate') <= 0n + || atomic(policy.outputAtomicPerMillionTokens, 'catalog output rate') <= 0n) { + fail('CATALOG_SCHEMA', `catalog model '${model}' has invalid limits or rates`); + } + } + return catalog; +} + +function modelPolicy(model, catalog = EXECUTION_CATALOG) { + const validated = validateCatalog(catalog); + if (typeof model !== 'string') fail('MODEL_NOT_ALLOWED', 'model must be a catalog identifier'); + const policy = validated.models[model]; + if (!policy) fail('MODEL_NOT_ALLOWED', `model '${model}' is not in pricing catalog '${validated.version}'`); + return policy; +} + +function normalizeUsage(input) { + const usage = exactPlainObject(input, + ['schemaVersion', 'model', 'inputTokens', 'outputTokens'], [], + 'USAGE_SCHEMA', 'provider usage'); + if (usage.schemaVersion !== 2 || typeof usage.model !== 'string') { + fail('USAGE_SCHEMA', 'provider usage must use strict schema version 2'); + } + integer(usage.inputTokens, 'inputTokens'); + integer(usage.outputTokens, 'outputTokens'); + return { + schemaVersion: 2, + model: usage.model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + }; +} + +export function usageCostAtomic(input, catalog = EXECUTION_CATALOG) { + const usage = normalizeUsage(input); + const policy = modelPolicy(usage.model, catalog); + const inputCost = ceilDiv( + integer(usage.inputTokens, 'inputTokens') * atomic(policy.inputAtomicPerMillionTokens, 'input rate'), + 1_000_000n, + ); + const outputCost = ceilDiv( + integer(usage.outputTokens, 'outputTokens') * atomic(policy.outputAtomicPerMillionTokens, 'output rate'), + 1_000_000n, + ); + return inputCost + outputCost; +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); + } + return value; +} + +const hash = (value) => `sha256:${crypto.createHash('sha256') + .update(JSON.stringify(canonicalize(value))) + .digest('hex')}`; + +export function artifactDigest(content) { + if (typeof content !== 'string') fail('ARTIFACT_SCHEMA', 'Skill artifact must be exact string bytes'); + return `sha256:${crypto.createHash('sha256').update(content).digest('hex')}`; +} + +export const royaltyGraphDigest = (skills) => hash(skills); + +export function catalogDigest(catalog) { + return hash(validateCatalog(catalog)); +} + +export function assertLiveCatalogApproval(input) { + const captured = exactPlainObject(input, ['catalog', 'approval', 'grossAtomic'], [], + 'LIVE_APPROVAL_SHAPE', 'live approval check'); + const catalog = validateCatalog(captured.catalog); + if (catalog.evidenceLabel !== 'human_verified') { + fail('LIVE_CATALOG_EVIDENCE', 'live catalog requires human_verified evidence, source, and as-of'); + } + const approval = exactPlainObject(captured.approval, + ['catalogDigest', 'spendCapAtomic'], [], 'LIVE_APPROVAL_SHAPE', 'live approval'); + if (typeof approval.catalogDigest !== 'string') { + fail('LIVE_APPROVAL_SHAPE', 'live catalog digest must be a string'); + } + const recomputed = catalogDigest(catalog); + if (approval.catalogDigest !== recomputed) { + fail('LIVE_CATALOG_DIGEST', 'human-approved digest does not match canonical catalog content'); + } + const cap = atomic(approval.spendCapAtomic, 'live spend cap'); + const gross = atomic(captured.grossAtomic, 'grossAtomic'); + if (gross > cap) fail('LIVE_SPEND_CAP', 'Invocation gross exceeds the separately approved spend cap'); + return deepFreeze({ catalogDigest: recomputed, spendCapAtomic: cap.toString() }); +} + +const QUOTE_FIELDS = Object.freeze([ + 'schemaVersion', 'quoteId', 'catalogVersion', 'evidenceLabel', 'skillId', 'skillVersion', + 'artifactHash', 'royaltyGraphDigest', 'catalogDigest', 'model', 'maxInputTokens', + 'maxOutputTokens', 'promptBytes', 'estimatedInputTokens', 'grossAtomic', + 'worstCaseExecutionCostAtomic', 'settlementCostAtomic', 'refundReserveAtomic', + 'protocolFeeBps', 'protocolFeeAtomic', 'worstCaseRoyaltyPoolAtomic', + 'worstCaseContributionMarginAtomic', +]); + +export function assertExecutionQuote(input) { + const quote = exactPlainObject(input, QUOTE_FIELDS, [], 'QUOTE_SCHEMA', 'execution quote'); + if (quote.schemaVersion !== 2 + || typeof quote.quoteId !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(quote.quoteId) + || typeof quote.catalogVersion !== 'string' + || !['synthetic_config', 'human_verified'].includes(quote.evidenceLabel) + || typeof quote.skillId !== 'string' || !quote.skillId + || typeof quote.skillVersion !== 'string' || !quote.skillVersion + || !/^sha256:[0-9a-f]{64}$/.test(quote.artifactHash) + || !/^sha256:[0-9a-f]{64}$/.test(quote.royaltyGraphDigest) + || !/^sha256:[0-9a-f]{64}$/.test(quote.catalogDigest) + || typeof quote.model !== 'string' + || !Number.isSafeInteger(quote.maxInputTokens) || quote.maxInputTokens < 0 + || !Number.isSafeInteger(quote.maxOutputTokens) || quote.maxOutputTokens < 1 + || !Number.isSafeInteger(quote.promptBytes) || quote.promptBytes < 0 + || !Number.isSafeInteger(quote.estimatedInputTokens) || quote.estimatedInputTokens < quote.promptBytes + || !Number.isSafeInteger(quote.protocolFeeBps) || quote.protocolFeeBps < 0 || quote.protocolFeeBps > 10_000) { + fail('QUOTE_SCHEMA', 'execution quote contains invalid versioned fields'); + } + for (const field of [ + 'grossAtomic', 'worstCaseExecutionCostAtomic', 'settlementCostAtomic', + 'refundReserveAtomic', 'protocolFeeAtomic', 'worstCaseRoyaltyPoolAtomic', + 'worstCaseContributionMarginAtomic', + ]) atomic(quote[field], field); + const { quoteId: ignored, ...body } = quote; + if (hash(body) !== quote.quoteId) fail('QUOTE_ID_MISMATCH', 'quote ID does not bind the complete execution quote'); + return quote; +} + +export function assertFrozenExecutionIdentity(input) { + const captured = exactPlainObject(input, + ['quote', 'skillId', 'skillVersion', 'artifactContent', 'skills', 'catalog'], [], + 'IDENTITY_SCHEMA', 'frozen execution identity check'); + const quote = assertExecutionQuote(captured.quote); + if (quote.skillId !== captured.skillId || quote.skillVersion !== captured.skillVersion) { + fail('SKILL_IDENTITY_DRIFT', 'current Skill identity differs from the accepted quote'); + } + if (quote.artifactHash !== artifactDigest(captured.artifactContent)) { + fail('ARTIFACT_DRIFT', 'current hosted Skill bytes differ from the accepted quote'); + } + if (quote.royaltyGraphDigest !== royaltyGraphDigest(captured.skills)) { + fail('ROYALTY_GRAPH_DRIFT', 'current Royalty graph differs from the accepted quote'); + } + if (quote.catalogDigest !== catalogDigest(captured.catalog)) { + fail('CATALOG_DIGEST_DRIFT', 'current pricing catalog differs from the accepted quote'); + } + if (quote.catalogVersion !== captured.catalog.version) { + fail('CATALOG_VERSION_DRIFT', 'current pricing catalog version differs from the accepted quote'); + } + return true; +} + +const PROVIDER_FRAMING_TOKEN_ALLOWANCE = 256; + +export function conservativeProviderPromptBound(input) { + const captured = exactPlainObject(input, [ + 'systemPrompt', 'userInput', 'requestBodyBytes', 'maxRequestBodyBytes', 'maxInputTokens', + ], [], 'PROMPT_BOUND_SCHEMA', 'provider prompt bound'); + if (typeof captured.systemPrompt !== 'string' || typeof captured.userInput !== 'string') { + fail('PROMPT_BOUND_SCHEMA', 'provider prompt inputs must be strings'); + } + for (const [label, value] of Object.entries({ + requestBodyBytes: captured.requestBodyBytes, + maxRequestBodyBytes: captured.maxRequestBodyBytes, + maxInputTokens: captured.maxInputTokens, + })) { + if (!Number.isSafeInteger(value) || value < 0) { + fail('PROMPT_BOUND_INTEGER', `${label} must be a non-negative safe integer`); + } + } + if (captured.requestBodyBytes > captured.maxRequestBodyBytes) { + fail('REQUEST_BODY_TOO_LARGE', 'request body exceeds the pre-payment byte cap'); + } + const promptBytes = Buffer.byteLength(captured.systemPrompt, 'utf8') + + Buffer.byteLength(captured.userInput, 'utf8'); + const estimatedInputTokens = promptBytes + PROVIDER_FRAMING_TOKEN_ALLOWANCE; + if (estimatedInputTokens > captured.maxInputTokens) { + fail('PROMPT_TOKEN_BOUND', 'complete provider prompt exceeds the frozen conservative input-token cap'); + } + return deepFreeze({ promptBytes, estimatedInputTokens, requestBodyBytes: captured.requestBodyBytes }); +} + +function serializeAllocation(allocation) { + return { + allocationPolicy: allocation.allocationPolicy, + grossAtomic: allocation.grossAtomic.toString(), + executionCostAtomic: allocation.executionCostAtomic.toString(), + settlementCostAtomic: allocation.settlementCostAtomic.toString(), + protocolFeeAtomic: allocation.protocolFeeAtomic.toString(), + royaltyPoolAtomic: allocation.royaltyPoolAtomic.toString(), + refundReserveAtomic: allocation.refundReserveAtomic.toString(), + holderCredits: allocation.holderCredits.map((credit) => ({ + ...credit, amountAtomic: credit.amountAtomic.toString(), + })), + ancestorCredits: allocation.ancestorCredits.map((credit) => ({ + ...credit, amountAtomic: credit.amountAtomic.toString(), + })), + journalEntries: allocation.journalEntries.map((entry) => ({ + ...entry, amountAtomic: entry.amountAtomic.toString(), + })), + }; +} + +export function createExecutionQuote(input) { + const captured = exactPlainObject(input, [ + 'schemaVersion', 'grossAtomic', 'model', 'maxInputTokens', 'maxOutputTokens', + 'promptBytes', 'estimatedInputTokens', 'settlementCostAtomic', 'refundReserveAtomic', + 'protocolFeeBps', 'leafSkillId', 'skillId', 'skillVersion', 'artifactHash', 'skills', + ], ['catalog'], 'QUOTE_SCHEMA', 'execution quote request'); + if (captured.schemaVersion !== 2) fail('QUOTE_SCHEMA', 'execution quote request must use schema version 2'); + const catalog = captured.catalog ?? EXECUTION_CATALOG; + const policy = modelPolicy(captured.model, catalog); + if (captured.skillId !== captured.leafSkillId + || typeof captured.skillId !== 'string' || !captured.skillId + || typeof captured.skillVersion !== 'string' || !captured.skillVersion + || typeof captured.artifactHash !== 'string' + || !/^sha256:[0-9a-f]{64}$/.test(captured.artifactHash)) { + fail('EXECUTION_IDENTITY', 'quote requires exact Skill id/version and lowercase artifact hash'); + } + if (!Number.isSafeInteger(captured.maxInputTokens) || captured.maxInputTokens < 0 + || !Number.isSafeInteger(captured.maxOutputTokens) || captured.maxOutputTokens < 1 + || captured.maxInputTokens > policy.maxInputTokens + || captured.maxOutputTokens > policy.maxOutputTokens) { + fail('TOKEN_LIMIT', `requested token limits exceed catalog policy for '${captured.model}'`); + } + if (!Number.isSafeInteger(captured.promptBytes) || captured.promptBytes < 0 + || !Number.isSafeInteger(captured.estimatedInputTokens) + || captured.estimatedInputTokens < captured.promptBytes + || captured.estimatedInputTokens > captured.maxInputTokens) { + fail('PROMPT_TOKEN_BOUND', 'quote prompt bounds must fit the accepted maxInputTokens'); + } + const worstCaseExecutionCost = usageCostAtomic({ + schemaVersion: 2, + model: captured.model, + inputTokens: captured.maxInputTokens, + outputTokens: captured.maxOutputTokens, + }, catalog); + let allocation; + try { + allocation = allocateExternalGross({ + grossAtomic: atomic(captured.grossAtomic, 'grossAtomic'), + executionCostAtomic: worstCaseExecutionCost, + settlementCostAtomic: atomic(captured.settlementCostAtomic, 'settlementCostAtomic'), + protocolFeeBps: captured.protocolFeeBps, + refundReserveAtomic: atomic(captured.refundReserveAtomic, 'refundReserveAtomic'), + leafSkillId: captured.leafSkillId, + skills: structuredClone(captured.skills), + }); + } catch (error) { + fail('NEGATIVE_WORST_CASE_MARGIN', `quote cannot cover worst-case costs: ${error.message}`); + } + const body = { + schemaVersion: 2, + catalogVersion: catalog.version, + evidenceLabel: catalog.evidenceLabel, + skillId: captured.skillId, + skillVersion: captured.skillVersion, + artifactHash: captured.artifactHash, + royaltyGraphDigest: royaltyGraphDigest(captured.skills), + catalogDigest: catalogDigest(catalog), + model: captured.model, + maxInputTokens: captured.maxInputTokens, + maxOutputTokens: captured.maxOutputTokens, + promptBytes: captured.promptBytes, + estimatedInputTokens: captured.estimatedInputTokens, + grossAtomic: allocation.grossAtomic.toString(), + worstCaseExecutionCostAtomic: worstCaseExecutionCost.toString(), + settlementCostAtomic: allocation.settlementCostAtomic.toString(), + refundReserveAtomic: allocation.refundReserveAtomic.toString(), + protocolFeeBps: captured.protocolFeeBps, + protocolFeeAtomic: allocation.protocolFeeAtomic.toString(), + worstCaseRoyaltyPoolAtomic: allocation.royaltyPoolAtomic.toString(), + worstCaseContributionMarginAtomic: allocation.protocolFeeAtomic.toString(), + }; + return frozenCopy({ quoteId: hash(body), ...body }); +} + +function pendingUsage(input, catalog) { + if (input == null) return { actual: null, usage: null }; + const usage = normalizeUsage(input); + try { + return { actual: usageCostAtomic(usage, catalog), usage }; + } catch { + return { actual: null, usage: null }; + } +} + +export function createPendingExecutionAccounting(input) { + const captured = exactPlainObject(input, + ['quote', 'failureClass', 'reason'], ['usage', 'catalog'], + 'ACCOUNTING_SCHEMA', 'pending execution accounting'); + const quote = assertExecutionQuote(captured.quote); + const catalog = captured.catalog ?? EXECUTION_CATALOG; + const { actual, usage } = pendingUsage(captured.usage ?? null, catalog); + const quotedWorstCase = BigInt(quote.worstCaseExecutionCostAtomic); + const overrun = actual != null && actual > quotedWorstCase ? actual - quotedWorstCase : 0n; + const result = { + schemaVersion: 2, + quoteId: quote.quoteId, + grossAtomic: quote.grossAtomic, + executionCostAtomic: '0', + settlementCostAtomic: '0', + protocolFeeAtomic: '0', + royaltyPoolAtomic: '0', + refundReserveAtomic: '0', + contributionMarginAtomic: '0', + allocationState: 'pending_cogs_reconciliation', + allocationPolicy: null, + holderCredits: [], + ancestorCredits: [], + journalEntries: [{ + category: 'unresolved-execution-accounting', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'hold:execution-accounting-reconciliation', + amountAtomic: quote.grossAtomic, + }], + executionCogs: { + schemaVersion: 2, + status: actual == null ? 'unknown' : 'known', + actualAtomic: actual?.toString() ?? null, + chargedAtomic: null, + quotedWorstCaseAtomic: quote.worstCaseExecutionCostAtomic, + accruedOverrunAtomic: overrun.toString(), + catalogVersion: quote.catalogVersion, + catalogDigest: quote.catalogDigest, + usage, + failureClass: String(captured.failureClass), + reason: String(captured.reason), + }, + }; + return frozenCopy(result); +} + +export function finalizeExecutionAccounting(input) { + const captured = exactPlainObject(input, + ['quote', 'usage', 'leafSkillId', 'skills'], ['unknownReason', 'catalog'], + 'ACCOUNTING_SCHEMA', 'final execution accounting'); + const quote = assertExecutionQuote(captured.quote); + const catalog = captured.catalog ?? EXECUTION_CATALOG; + validateCatalog(catalog); + if (quote.catalogVersion !== catalog.version || quote.catalogDigest !== catalogDigest(catalog)) { + fail('CATALOG_VERSION', 'quote pricing catalog identity is not loaded'); + } + if (quote.skillId !== captured.leafSkillId + || quote.royaltyGraphDigest !== royaltyGraphDigest(captured.skills)) { + fail('ROYALTY_GRAPH_DRIFT', 'final allocation graph differs from the accepted quote'); + } + if (captured.usage == null) { + return createPendingExecutionAccounting({ + quote, + usage: null, + failureClass: 'COGS_UNKNOWN', + reason: captured.unknownReason ?? 'provider usage unavailable', + catalog, + }); + } + const usage = normalizeUsage(captured.usage); + if (usage.model !== quote.model + || usage.inputTokens > quote.maxInputTokens + || usage.outputTokens > quote.maxOutputTokens) { + fail('USAGE_EXCEEDS_QUOTE', 'provider usage exceeds the accepted model or token limits'); + } + const actual = usageCostAtomic(usage, catalog); + if (actual > BigInt(quote.worstCaseExecutionCostAtomic)) { + fail('COGS_EXCEEDS_QUOTE', 'actual provider COGS exceeds the accepted reserve'); + } + let allocation; + try { + allocation = allocateExternalGross({ + grossAtomic: BigInt(quote.grossAtomic), + executionCostAtomic: actual, + settlementCostAtomic: BigInt(quote.settlementCostAtomic), + protocolFeeBps: quote.protocolFeeBps, + refundReserveAtomic: BigInt(quote.refundReserveAtomic), + leafSkillId: captured.leafSkillId, + skills: structuredClone(captured.skills), + }); + } catch (error) { + fail('NEGATIVE_CONTRIBUTION_MARGIN', `actual execution economics do not conserve: ${error.message}`); + } + const serialized = serializeAllocation(allocation); + return frozenCopy({ + schemaVersion: 2, + ...serialized, + quoteId: quote.quoteId, + allocationState: 'finalized', + contributionMarginAtomic: serialized.protocolFeeAtomic, + executionCogs: { + schemaVersion: 2, + status: 'known', + actualAtomic: actual.toString(), + chargedAtomic: actual.toString(), + quotedWorstCaseAtomic: quote.worstCaseExecutionCostAtomic, + accruedOverrunAtomic: '0', + catalogVersion: catalog.version, + catalogDigest: quote.catalogDigest, + usage, + failureClass: null, + reason: null, + }, + }); +} diff --git a/spikes/pi-wielder/tests/execution-economics.test.mjs b/spikes/pi-wielder/tests/execution-economics.test.mjs new file mode 100644 index 0000000..ef256d0 --- /dev/null +++ b/spikes/pi-wielder/tests/execution-economics.test.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + artifactDigest, + assertFrozenExecutionIdentity, + assertLiveCatalogApproval, + catalogDigest, + conservativeProviderPromptBound, + createPendingExecutionAccounting, + createExecutionQuote, + EXECUTION_CATALOG, + ExecutionEconomicsError, + finalizeExecutionAccounting, + usageCostAtomic, +} from '../src/execution-economics.mjs'; + +const SKILL_ID = 'skill-a'; +const SKILL_VERSION = 'skill-a/2026-07-17-v1'; +const SKILL_ARTIFACT = 'system prompt artifact v1'; +const skills = { + [SKILL_ID]: { + parentIds: [], + inheritBps: 0, + holders: [{ recipientId: 'creator', bps: 10_000 }], + }, +}; + +const quote = (overrides = {}) => createExecutionQuote({ + schemaVersion: 2, + grossAtomic: '250000', + model: 'claude-sonnet-4-6', + maxInputTokens: 16384, + maxOutputTokens: 2048, + promptBytes: 10_000, + estimatedInputTokens: 10_256, + settlementCostAtomic: '1000', + refundReserveAtomic: '5000', + protocolFeeBps: 250, + leafSkillId: SKILL_ID, + skillId: SKILL_ID, + skillVersion: SKILL_VERSION, + artifactHash: artifactDigest(SKILL_ARTIFACT), + skills, + ...overrides, +}); + +test('usageCostAtomic rounds each versioned provider charge upward', () => { + assert.equal(usageCostAtomic({ + schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42, + }), 756n); + assert.equal(usageCostAtomic({ + schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 1, outputTokens: 1, + }), 18n); +}); + +test('complete provider prompt bound is byte-conservative and rejects body/token overflow', () => { + assert.deepEqual(conservativeProviderPromptBound({ + systemPrompt: 'abc', userInput: 'é', requestBodyBytes: 100, + maxRequestBodyBytes: 4096, maxInputTokens: 300, + }), { promptBytes: 5, estimatedInputTokens: 261, requestBodyBytes: 100 }); + assert.throws(() => conservativeProviderPromptBound({ + systemPrompt: 'x', userInput: 'y', requestBodyBytes: 4097, + maxRequestBodyBytes: 4096, maxInputTokens: 300, + }), (error) => error.code === 'REQUEST_BODY_TOO_LARGE'); + assert.throws(() => conservativeProviderPromptBound({ + systemPrompt: 'x'.repeat(100), userInput: 'y', requestBodyBytes: 120, + maxRequestBodyBytes: 4096, maxInputTokens: 300, + }), (error) => error.code === 'PROMPT_TOKEN_BOUND'); +}); + +test('quote reserves worst-case COGS before fee and Royalty pool', () => { + const result = quote(); + assert.equal(result.schemaVersion, 2); + assert.equal(result.catalogVersion, EXECUTION_CATALOG.version); + assert.equal(result.evidenceLabel, 'synthetic_config'); + assert.equal(result.worstCaseExecutionCostAtomic, '79872'); + assert.equal(result.protocolFeeAtomic, '6250'); + assert.equal(result.worstCaseRoyaltyPoolAtomic, '157878'); + assert.equal(result.worstCaseContributionMarginAtomic, '6250'); + assert.match(result.quoteId, /^sha256:[0-9a-f]{64}$/); + assert.ok(Object.isFrozen(result)); +}); + +test('known usage charges actual COGS before increasing the Royalty pool', () => { + const result = finalizeExecutionAccounting({ + quote: quote(), + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, + leafSkillId: SKILL_ID, + skills, + }); + assert.equal(result.schemaVersion, 2); + assert.deepEqual(result.executionCogs, { + schemaVersion: 2, + status: 'known', + actualAtomic: '756', + chargedAtomic: '756', + quotedWorstCaseAtomic: '79872', + accruedOverrunAtomic: '0', + catalogVersion: EXECUTION_CATALOG.version, + catalogDigest: quote().catalogDigest, + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, + failureClass: null, + reason: null, + }); + assert.equal(result.royaltyPoolAtomic, '236994'); + assert.equal(result.protocolFeeAtomic, '6250'); + assert.equal(result.contributionMarginAtomic, '6250'); + assert.equal(result.journalEntries.reduce((sum, entry) => sum + BigInt(entry.amountAtomic), 0n), 250_000n); + assert.ok(result.journalEntries.every((entry) => entry.debitAccountId === 'wielder:external-gross')); +}); + +test('unknown usage fails closed into one full-gross hold and finalizes no Royalty claims', () => { + const result = finalizeExecutionAccounting({ + quote: quote(), + usage: null, + unknownReason: 'provider response omitted usage', + leafSkillId: SKILL_ID, + skills, + }); + assert.equal(result.schemaVersion, 2); + assert.equal(result.executionCogs.status, 'unknown'); + assert.equal(result.executionCogs.actualAtomic, null); + assert.equal(result.executionCogs.chargedAtomic, null); + assert.equal(result.executionCogs.quotedWorstCaseAtomic, '79872'); + assert.equal(result.executionCogs.reason, 'provider response omitted usage'); + assert.equal(result.allocationState, 'pending_cogs_reconciliation'); + assert.equal(result.royaltyPoolAtomic, '0'); + assert.deepEqual(result.holderCredits, []); + assert.deepEqual(result.ancestorCredits, []); + assert.deepEqual(result.journalEntries, [{ + category: 'unresolved-execution-accounting', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'hold:execution-accounting-reconciliation', + amountAtomic: '250000', + }]); +}); + +test('quotes fail before payment when caps or worst-case economics are invalid', () => { + assert.throws(() => quote({ model: 'unlisted-model' }), (error) => ( + error instanceof ExecutionEconomicsError && error.code === 'MODEL_NOT_ALLOWED' + )); + assert.throws(() => quote({ maxOutputTokens: 2049 }), (error) => error.code === 'TOKEN_LIMIT'); + assert.throws(() => quote({ grossAtomic: '50000' }), (error) => error.code === 'NEGATIVE_WORST_CASE_MARGIN'); +}); + +test('provider usage above the accepted quote fails product acceptance', () => { + assert.throws(() => finalizeExecutionAccounting({ + quote: quote(), + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 4096, outputTokens: 2049 }, + leafSkillId: SKILL_ID, + skills, + }), (error) => error.code === 'USAGE_EXCEEDS_QUOTE'); +}); + +test('post-provider overrun accounting records known accrued COGS but finalizes no claims', () => { + const result = createPendingExecutionAccounting({ + quote: quote(), + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 16384, outputTokens: 2049 }, + failureClass: 'USAGE_EXCEEDS_QUOTE', + reason: 'provider exceeded frozen output cap', + }); + assert.equal(result.allocationState, 'pending_cogs_reconciliation'); + assert.equal(result.executionCogs.status, 'known'); + assert.equal(result.executionCogs.actualAtomic, '79887'); + assert.equal(result.executionCogs.accruedOverrunAtomic, '15'); + assert.equal(result.royaltyPoolAtomic, '0'); + assert.deepEqual(result.holderCredits, []); + assert.deepEqual(result.ancestorCredits, []); + assert.equal(result.journalEntries[0].amountAtomic, result.grossAtomic); +}); + +test('live approval binds a separately supplied exact catalog digest and spend cap', () => { + const verifiedCatalog = structuredClone(EXECUTION_CATALOG); + verifiedCatalog.evidenceLabel = 'human_verified'; + verifiedCatalog.source = 'https://provider.example/pricing/2026-07-17'; + verifiedCatalog.asOf = '2026-07-17T00:00:00.000Z'; + const approval = { catalogDigest: catalogDigest(verifiedCatalog), spendCapAtomic: '250000' }; + assert.doesNotThrow(() => assertLiveCatalogApproval({ catalog: verifiedCatalog, approval, grossAtomic: '250000' })); + const mutated = structuredClone(verifiedCatalog); + mutated.models['claude-sonnet-4-6'].outputAtomicPerMillionTokens = '15000001'; + assert.throws(() => assertLiveCatalogApproval({ catalog: mutated, approval, grossAtomic: '250000' }), + (error) => error.code === 'LIVE_CATALOG_DIGEST'); + assert.throws(() => assertLiveCatalogApproval({ + catalog: verifiedCatalog, + approval: { ...approval, spendCapAtomic: '249999' }, + grossAtomic: '250000', + }), (error) => error.code === 'LIVE_SPEND_CAP'); +}); + +test('quote ID freezes Skill, artifact, Royalty graph, and catalog identity', () => { + const frozen = quote(); + assert.deepEqual({ + skillId: frozen.skillId, + skillVersion: frozen.skillVersion, + artifactHash: frozen.artifactHash, + }, { skillId: SKILL_ID, skillVersion: SKILL_VERSION, artifactHash: artifactDigest(SKILL_ARTIFACT) }); + assert.doesNotThrow(() => assertFrozenExecutionIdentity({ + quote: frozen, skillId: SKILL_ID, skillVersion: SKILL_VERSION, + artifactContent: SKILL_ARTIFACT, skills, catalog: EXECUTION_CATALOG, + })); + for (const [expectedCode, overrides] of [ + ['SKILL_IDENTITY_DRIFT', { skillVersion: `${SKILL_VERSION}-changed` }], + ['ARTIFACT_DRIFT', { artifactContent: `${SKILL_ARTIFACT}\nchanged` }], + ['ROYALTY_GRAPH_DRIFT', { skills: { ...skills, extra: { parentIds: [], inheritBps: 0, holders: [] } } }], + ['CATALOG_DIGEST_DRIFT', { catalog: { ...EXECUTION_CATALOG, version: 'changed' } }], + ]) { + assert.throws(() => assertFrozenExecutionIdentity({ + quote: frozen, skillId: SKILL_ID, skillVersion: SKILL_VERSION, + artifactContent: SKILL_ARTIFACT, skills, catalog: EXECUTION_CATALOG, ...overrides, + }), (error) => error.code === expectedCode); + } + const driftedCatalog = structuredClone(EXECUTION_CATALOG); + driftedCatalog.version = 'drifted-current-config'; + const pending = createPendingExecutionAccounting({ + quote: frozen, usage: null, failureClass: 'CATALOG_DIGEST_DRIFT', + reason: 'current catalog changed', catalog: driftedCatalog, + }); + assert.equal(pending.executionCogs.catalogVersion, frozen.catalogVersion); + assert.equal(pending.executionCogs.catalogDigest, frozen.catalogDigest); +}); + +test('strict v2 quote and usage schemas reject inherited, unknown, and unsafe values', () => { + assert.throws(() => createExecutionQuote(Object.assign(Object.create({ grossAtomic: '250000' }), { + model: 'claude-sonnet-4-6', maxInputTokens: 1, maxOutputTokens: 1, + })), (error) => error.code === 'QUOTE_SCHEMA'); + assert.throws(() => finalizeExecutionAccounting({ + quote: quote(), + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 1, outputTokens: 1, extra: true }, + leafSkillId: SKILL_ID, + skills, + }), (error) => error.code === 'USAGE_SCHEMA'); + assert.throws(() => usageCostAtomic({ + schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: Number.MAX_SAFE_INTEGER + 1, outputTokens: 0, + }), (error) => error.code === 'TOKEN_INTEGER'); +}); From 385eca86b6409534d31198e5594c6132ba7d29d6 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:07:20 -0400 Subject: [PATCH 118/165] feat: detect direct Skill serialization --- spikes/pi-wielder/src/artifact-boundary.mjs | 18 +++++++++ .../tests/artifact-boundary.test.mjs | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 spikes/pi-wielder/src/artifact-boundary.mjs create mode 100644 spikes/pi-wielder/tests/artifact-boundary.test.mjs diff --git a/spikes/pi-wielder/src/artifact-boundary.mjs b/spikes/pi-wielder/src/artifact-boundary.mjs new file mode 100644 index 0000000..e1587e0 --- /dev/null +++ b/spikes/pi-wielder/src/artifact-boundary.mjs @@ -0,0 +1,18 @@ +export function assertArtifactNotSerialized(input) { + if (input === null || typeof input !== 'object' || Array.isArray(input) + || Object.getPrototypeOf(input) !== Object.prototype + || Object.keys(input).sort().join(',') !== 'artifact,output') { + throw new TypeError('artifact boundary requires one exact plain object'); + } + const { output, artifact } = input; + if (typeof output !== 'string' || typeof artifact !== 'string' || artifact.length === 0) { + throw new TypeError('artifact boundary requires exact string inputs and a non-empty artifact'); + } + const fragments = artifact.length >= 400 + ? [artifact, artifact.slice(0, 200), artifact.slice(-200)] + : [artifact]; + if (fragments.some((fragment) => output.includes(fragment))) { + throw new Error('direct artifact serialization detected in model output'); + } + return true; +} diff --git a/spikes/pi-wielder/tests/artifact-boundary.test.mjs b/spikes/pi-wielder/tests/artifact-boundary.test.mjs new file mode 100644 index 0000000..e282432 --- /dev/null +++ b/spikes/pi-wielder/tests/artifact-boundary.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { assertArtifactNotSerialized } from '../src/artifact-boundary.mjs'; + +const artifact = 'A'.repeat(220) + '\nSECRET-RULE\n' + 'B'.repeat(220); + +test('rejects the full artifact and long exact boundary fragments', () => { + assert.throws( + () => assertArtifactNotSerialized({ output: artifact, artifact }), + /direct artifact serialization/, + ); + assert.throws( + () => assertArtifactNotSerialized({ output: artifact.slice(0, 220), artifact }), + /direct artifact serialization/, + ); + assert.throws( + () => assertArtifactNotSerialized({ output: artifact.slice(-220), artifact }), + /direct artifact serialization/, + ); +}); + +test('permits ordinary derived output and states the limit of the check', () => { + assert.equal(assertArtifactNotSerialized({ + output: 'A concise optimized prompt derived from the Skill behavior.', artifact, + }), true); +}); + +test('requires direct string inputs and rejects inherited boundary fields', () => { + assert.throws( + () => assertArtifactNotSerialized({ output: Buffer.from('derived'), artifact }), + /exact string inputs/, + ); + assert.throws( + () => assertArtifactNotSerialized(Object.assign(Object.create({ output: artifact }), { artifact })), + /exact plain object/, + ); +}); From 3810ef5810a92e743fddf508ab085123dae66955 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:08:12 -0400 Subject: [PATCH 119/165] spike: rank registry entries by independent settled use --- .../fixtures/settlements.json | 192 +++++++++ .../fixtures/verified-billing-registry.json | 61 +++ spikes/registry-ranking/package.json | 11 + spikes/registry-ranking/src/metrics.mjs | 395 ++++++++++++++++++ spikes/registry-ranking/test/metrics.test.mjs | 193 +++++++++ 5 files changed, 852 insertions(+) create mode 100644 spikes/registry-ranking/fixtures/settlements.json create mode 100644 spikes/registry-ranking/fixtures/verified-billing-registry.json create mode 100644 spikes/registry-ranking/package.json create mode 100644 spikes/registry-ranking/src/metrics.mjs create mode 100644 spikes/registry-ranking/test/metrics.test.mjs diff --git a/spikes/registry-ranking/fixtures/settlements.json b/spikes/registry-ranking/fixtures/settlements.json new file mode 100644 index 0000000..7361e14 --- /dev/null +++ b/spikes/registry-ranking/fixtures/settlements.json @@ -0,0 +1,192 @@ +[ + { + "schemaVersion": 1, + "settlementId": "settlement-001-self", + "invocationId": "invocation-001", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0x1111111111111111111111111111111111111111", + "untrustedPayerClaims": { + "beneficiaryId": "claimed-otherco", + "payerClusterId": "claimed-independent-self", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "0", + "recycledAtomic": "0", + "outcome": "succeeded", + "settledAt": "2026-07-17T00:00:01.000Z" + }, + { + "schemaVersion": 1, + "settlementId": "settlement-002-linked", + "invocationId": "invocation-002", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0x3333333333333333333333333333333333333333", + "untrustedPayerClaims": { + "beneficiaryId": "claimed-thirdco", + "payerClusterId": "claimed-independent-linked", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "0", + "recycledAtomic": "0", + "outcome": "succeeded", + "settledAt": "2026-07-17T00:00:02.000Z" + }, + { + "schemaVersion": 1, + "settlementId": "settlement-003-otherco-a", + "invocationId": "invocation-003", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0x4444444444444444444444444444444444444444", + "untrustedPayerClaims": { + "beneficiaryId": "otherco", + "payerClusterId": "cluster-otherco", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "0", + "recycledAtomic": "0", + "outcome": "succeeded", + "settledAt": "2026-07-17T00:00:03.000Z" + }, + { + "schemaVersion": 1, + "settlementId": "settlement-004-otherco-b", + "invocationId": "invocation-004", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0x5555555555555555555555555555555555555555", + "untrustedPayerClaims": { + "beneficiaryId": "invented-beneficiary", + "payerClusterId": "invented-cluster", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "0", + "recycledAtomic": "0", + "outcome": "succeeded", + "settledAt": "2026-07-17T00:00:04.000Z" + }, + { + "schemaVersion": 1, + "settlementId": "settlement-005-failed", + "invocationId": "invocation-005", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0x6666666666666666666666666666666666666666", + "untrustedPayerClaims": { + "beneficiaryId": "failedco", + "payerClusterId": "cluster-failedco", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "0", + "recycledAtomic": "0", + "outcome": "failed", + "settledAt": "2026-07-17T00:00:05.000Z" + }, + { + "schemaVersion": 1, + "settlementId": "settlement-006-unresolved", + "invocationId": "invocation-006", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0x7777777777777777777777777777777777777777", + "untrustedPayerClaims": { + "beneficiaryId": "unresolvedco", + "payerClusterId": "cluster-unresolvedco", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "0", + "recycledAtomic": "0", + "outcome": "unresolved", + "settledAt": "2026-07-17T00:00:06.000Z" + }, + { + "schemaVersion": 1, + "settlementId": "settlement-007-refunded", + "invocationId": "invocation-007", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0x8888888888888888888888888888888888888888", + "untrustedPayerClaims": { + "beneficiaryId": "refundco", + "payerClusterId": "cluster-refundco", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "250000", + "recycledAtomic": "0", + "outcome": "succeeded", + "settledAt": "2026-07-17T00:00:07.000Z" + }, + { + "schemaVersion": 1, + "settlementId": "settlement-008-recycled", + "invocationId": "invocation-008", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0x9999999999999999999999999999999999999999", + "untrustedPayerClaims": { + "beneficiaryId": "recycleco", + "payerClusterId": "cluster-recycleco", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "0", + "recycledAtomic": "250000", + "outcome": "succeeded", + "settledAt": "2026-07-17T00:00:08.000Z" + }, + { + "schemaVersion": 1, + "settlementId": "settlement-009-unknown", + "invocationId": "invocation-009", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "untrustedPayerClaims": { + "beneficiaryId": "spoofed-independent", + "payerClusterId": "spoofed-unique-cluster", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "0", + "recycledAtomic": "0", + "outcome": "succeeded", + "settledAt": "2026-07-17T00:00:09.000Z" + }, + { + "schemaVersion": 1, + "settlementId": "settlement-010-thirdco", + "invocationId": "invocation-010", + "skillId": "ledger-recon", + "creatorWallet": "0x1111111111111111111111111111111111111111", + "payeeWallet": "0x2222222222222222222222222222222222222222", + "payerWallet": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "untrustedPayerClaims": { + "beneficiaryId": "thirdco", + "payerClusterId": "cluster-thirdco", + "relationship": "independent" + }, + "grossAtomic": "250000", + "refundedAtomic": "0", + "recycledAtomic": "0", + "outcome": "succeeded", + "settledAt": "2026-07-17T00:00:10.000Z" + } +] diff --git a/spikes/registry-ranking/fixtures/verified-billing-registry.json b/spikes/registry-ranking/fixtures/verified-billing-registry.json new file mode 100644 index 0000000..dc8bb55 --- /dev/null +++ b/spikes/registry-ranking/fixtures/verified-billing-registry.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": 1, + "entries": { + "0x3333333333333333333333333333333333333333": { + "beneficiaryId": "creator-affiliate", + "payerClusterId": "verified-billing-owner:creator-affiliate", + "relationship": "linked", + "evidenceRef": "billing-review:creator-affiliate:2026-07-17", + "reviewedAt": "2026-07-17T00:00:00.000Z" + }, + "0x4444444444444444444444444444444444444444": { + "beneficiaryId": "otherco", + "payerClusterId": "cluster-otherco", + "relationship": "independent", + "evidenceRef": "billing-review:otherco:2026-07-17", + "reviewedAt": "2026-07-17T00:00:00.000Z" + }, + "0x5555555555555555555555555555555555555555": { + "beneficiaryId": "otherco", + "payerClusterId": "cluster-otherco", + "relationship": "independent", + "evidenceRef": "billing-review:otherco-second-wallet:2026-07-17", + "reviewedAt": "2026-07-17T00:00:00.000Z" + }, + "0x6666666666666666666666666666666666666666": { + "beneficiaryId": "failedco", + "payerClusterId": "cluster-failedco", + "relationship": "independent", + "evidenceRef": "billing-review:failedco:2026-07-17", + "reviewedAt": "2026-07-17T00:00:00.000Z" + }, + "0x7777777777777777777777777777777777777777": { + "beneficiaryId": "unresolvedco", + "payerClusterId": "cluster-unresolvedco", + "relationship": "independent", + "evidenceRef": "billing-review:unresolvedco:2026-07-17", + "reviewedAt": "2026-07-17T00:00:00.000Z" + }, + "0x8888888888888888888888888888888888888888": { + "beneficiaryId": "refundco", + "payerClusterId": "cluster-refundco", + "relationship": "independent", + "evidenceRef": "billing-review:refundco:2026-07-17", + "reviewedAt": "2026-07-17T00:00:00.000Z" + }, + "0x9999999999999999999999999999999999999999": { + "beneficiaryId": "recycleco", + "payerClusterId": "cluster-recycleco", + "relationship": "independent", + "evidenceRef": "billing-review:recycleco:2026-07-17", + "reviewedAt": "2026-07-17T00:00:00.000Z" + }, + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "beneficiaryId": "thirdco", + "payerClusterId": "cluster-thirdco", + "relationship": "independent", + "evidenceRef": "billing-review:thirdco:2026-07-17", + "reviewedAt": "2026-07-17T00:00:00.000Z" + } + } +} diff --git a/spikes/registry-ranking/package.json b/spikes/registry-ranking/package.json new file mode 100644 index 0000000..42dc504 --- /dev/null +++ b/spikes/registry-ranking/package.json @@ -0,0 +1,11 @@ +{ + "name": "registry-ranking-spike", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Offline settlement-verifiable registry ranking spike.", + "scripts": { + "test": "node --test test/*.test.mjs", + "report": "node src/report.mjs fixtures/settlements.json fixtures/verified-billing-registry.json" + } +} diff --git a/spikes/registry-ranking/src/metrics.mjs b/spikes/registry-ranking/src/metrics.mjs new file mode 100644 index 0000000..d8a66d3 --- /dev/null +++ b/spikes/registry-ranking/src/metrics.mjs @@ -0,0 +1,395 @@ +const EVENT_KEYS = Object.freeze([ + 'creatorWallet', + 'grossAtomic', + 'invocationId', + 'outcome', + 'payeeWallet', + 'payerWallet', + 'recycledAtomic', + 'refundedAtomic', + 'schemaVersion', + 'settledAt', + 'settlementId', + 'skillId', + 'untrustedPayerClaims', +].sort()); +const CLAIM_KEYS = Object.freeze([ + 'beneficiaryId', + 'payerClusterId', + 'relationship', +].sort()); +const REGISTRY_KEYS = Object.freeze(['entries', 'schemaVersion']); +const REGISTRY_ENTRY_KEYS = Object.freeze([ + 'beneficiaryId', + 'evidenceRef', + 'payerClusterId', + 'relationship', + 'reviewedAt', +].sort()); +const EXCLUSION_KEYS = Object.freeze([ + 'self_payment', + 'linked_wallet', + 'failed_invocation', + 'unresolved_settlement', + 'refunded', + 'recycled_value', + 'sybil_cluster', + 'unknown_relationship', +]); +const WALLET_PATTERN = /^0x[0-9a-f]{40}$/; +const ATOMIC_PATTERN = /^(0|[1-9][0-9]*)$/; +const IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,127}$/; +const OUTCOMES = new Set(['succeeded', 'failed', 'unresolved']); +const TRUSTED_RELATIONSHIPS = new Set(['linked', 'independent']); + +function fail(message) { + throw new TypeError(message); +} + +function isPlainRecord(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function requireRecord(value, label) { + if (!isPlainRecord(value)) fail(`${label} must be a plain object`); + return value; +} + +function requireExactKeys(value, expected, label) { + const actual = Object.keys(value).sort(); + if (actual.length !== expected.length + || actual.some((key, index) => key !== expected[index])) { + fail(`${label} has invalid keys`); + } +} + +function requireIdentifier(value, label) { + if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) { + fail(`${label} must be a canonical identifier`); + } + return value; +} + +function requireUntrustedText(value, label) { + if (typeof value !== 'string' || value.length === 0 || value.length > 256 + || /[^\x20-\x7e]/.test(value)) { + fail(`${label} must be non-empty bounded visible ASCII`); + } + return value; +} + +function requireWallet(value, label) { + if (typeof value !== 'string' || !WALLET_PATTERN.test(value)) { + fail(`${label} must be a lowercase 40-hex wallet address`); + } + return value; +} + +function requireAtomic(value, label) { + if (typeof value !== 'string' || !ATOMIC_PATTERN.test(value)) { + fail(`${label} must be a canonical non-negative decimal string`); + } + return BigInt(value); +} + +function requireUtcTimestamp(value, label) { + if (typeof value !== 'string' + || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) + || Number.isNaN(Date.parse(value)) + || new Date(value).toISOString() !== value) { + fail(`${label} must be a canonical UTC timestamp`); + } + return value; +} + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function validateClassification(value) { + requireRecord(value, 'payer classification'); + requireExactKeys( + value, + ['beneficiaryId', 'evidenceRef', 'payerClusterId', 'relationship'], + 'payer classification', + ); + if (!['self', 'linked', 'independent', 'unknown'].includes(value.relationship)) { + fail('payer classification relationship is invalid'); + } + if (value.relationship === 'linked' || value.relationship === 'independent') { + requireIdentifier(value.beneficiaryId, 'payer classification beneficiaryId'); + requireIdentifier(value.payerClusterId, 'payer classification payerClusterId'); + requireUntrustedText(value.evidenceRef, 'payer classification evidenceRef'); + } else if (value.beneficiaryId !== null || value.payerClusterId !== null) { + fail('self and unknown payer classifications must not claim an owner or cluster'); + } + if (typeof value.evidenceRef !== 'string' || value.evidenceRef.length === 0) { + fail('payer classification evidenceRef must be non-empty'); + } + return deepFreeze({ + relationship: value.relationship, + beneficiaryId: value.beneficiaryId, + payerClusterId: value.payerClusterId, + evidenceRef: value.evidenceRef, + }); +} + +export function parseSettlementMetricEvent(value) { + requireRecord(value, 'SettlementMetricEventV1'); + requireExactKeys(value, EVENT_KEYS, 'SettlementMetricEventV1'); + if (value.schemaVersion !== 1) fail('SettlementMetricEventV1 schemaVersion must be 1'); + + const claims = requireRecord(value.untrustedPayerClaims, 'untrustedPayerClaims'); + requireExactKeys(claims, CLAIM_KEYS, 'untrustedPayerClaims'); + const gross = requireAtomic(value.grossAtomic, 'grossAtomic'); + const refunded = requireAtomic(value.refundedAtomic, 'refundedAtomic'); + const recycled = requireAtomic(value.recycledAtomic, 'recycledAtomic'); + if (refunded > gross) fail('refundedAtomic must not exceed grossAtomic'); + if (recycled > gross) fail('recycledAtomic must not exceed grossAtomic'); + if (refunded + recycled > gross) { + fail('refundedAtomic plus recycledAtomic must not exceed grossAtomic'); + } + if (!OUTCOMES.has(value.outcome)) fail('outcome is invalid'); + + return deepFreeze({ + schemaVersion: 1, + settlementId: requireIdentifier(value.settlementId, 'settlementId'), + invocationId: requireIdentifier(value.invocationId, 'invocationId'), + skillId: requireIdentifier(value.skillId, 'skillId'), + creatorWallet: requireWallet(value.creatorWallet, 'creatorWallet'), + payeeWallet: requireWallet(value.payeeWallet, 'payeeWallet'), + payerWallet: requireWallet(value.payerWallet, 'payerWallet'), + untrustedPayerClaims: { + beneficiaryId: requireUntrustedText(claims.beneficiaryId, 'untrustedPayerClaims.beneficiaryId'), + payerClusterId: requireUntrustedText(claims.payerClusterId, 'untrustedPayerClaims.payerClusterId'), + relationship: requireUntrustedText(claims.relationship, 'untrustedPayerClaims.relationship'), + }, + grossAtomic: gross.toString(), + refundedAtomic: refunded.toString(), + recycledAtomic: recycled.toString(), + outcome: value.outcome, + settledAt: requireUtcTimestamp(value.settledAt, 'settledAt'), + }); +} + +export function createVerifiedBillingClassifier(value) { + const registry = requireRecord(value, 'VerifiedBillingRegistryV1'); + requireExactKeys(registry, REGISTRY_KEYS, 'VerifiedBillingRegistryV1'); + if (registry.schemaVersion !== 1) fail('VerifiedBillingRegistryV1 schemaVersion must be 1'); + const inputEntries = requireRecord(registry.entries, 'VerifiedBillingRegistryV1.entries'); + const entries = Object.create(null); + + for (const payerWallet of Object.keys(inputEntries).sort()) { + requireWallet(payerWallet, 'verified billing registry wallet key'); + const input = requireRecord( + inputEntries[payerWallet], + `verified billing registry entry ${payerWallet}`, + ); + requireExactKeys(input, REGISTRY_ENTRY_KEYS, `verified billing registry entry ${payerWallet}`); + if (!TRUSTED_RELATIONSHIPS.has(input.relationship)) { + fail(`verified billing registry entry ${payerWallet} relationship is invalid`); + } + entries[payerWallet] = { + beneficiaryId: requireIdentifier( + input.beneficiaryId, + `verified billing registry entry ${payerWallet} beneficiaryId`, + ), + payerClusterId: requireIdentifier( + input.payerClusterId, + `verified billing registry entry ${payerWallet} payerClusterId`, + ), + relationship: input.relationship, + evidenceRef: requireUntrustedText( + input.evidenceRef, + `verified billing registry entry ${payerWallet} evidenceRef`, + ), + reviewedAt: requireUtcTimestamp( + input.reviewedAt, + `verified billing registry entry ${payerWallet} reviewedAt`, + ), + }; + } + const snapshot = deepFreeze({ schemaVersion: 1, entries }); + + return Object.freeze(function classifyPayer(eventValue) { + const event = parseSettlementMetricEvent(eventValue); + if (event.payerWallet === event.creatorWallet || event.payerWallet === event.payeeWallet) { + return validateClassification({ + relationship: 'self', + beneficiaryId: null, + payerClusterId: null, + evidenceRef: 'derived:self-payment', + }); + } + if (!Object.hasOwn(snapshot.entries, event.payerWallet)) { + return validateClassification({ + relationship: 'unknown', + beneficiaryId: null, + payerClusterId: null, + evidenceRef: 'derived:no-verified-billing-record', + }); + } + const record = snapshot.entries[event.payerWallet]; + return validateClassification({ + relationship: record.relationship, + beneficiaryId: record.beneficiaryId, + payerClusterId: record.payerClusterId, + evidenceRef: record.evidenceRef, + }); + }); +} + +export function exclusionReasons( + eventValue, + classificationValue, + { seenIndependentClusters } = {}, +) { + const event = parseSettlementMetricEvent(eventValue); + const classification = validateClassification(classificationValue); + if (!(seenIndependentClusters instanceof Set)) { + fail('seenIndependentClusters must be a Set'); + } + const reasons = []; + if (classification.relationship === 'self') reasons.push('self_payment'); + if (classification.relationship === 'linked') reasons.push('linked_wallet'); + if (event.outcome === 'failed') reasons.push('failed_invocation'); + if (event.outcome === 'unresolved') reasons.push('unresolved_settlement'); + if (BigInt(event.refundedAtomic) > 0n) reasons.push('refunded'); + if (BigInt(event.recycledAtomic) > 0n) reasons.push('recycled_value'); + if (classification.relationship === 'unknown') reasons.push('unknown_relationship'); + if (classification.relationship === 'independent' + && event.outcome === 'succeeded' + && event.refundedAtomic === '0' + && event.recycledAtomic === '0' + && seenIndependentClusters.has(classification.payerClusterId)) { + reasons.push('sybil_cluster'); + } + return Object.freeze(reasons.sort()); +} + +function claimsDisagree(event, classification) { + const claims = event.untrustedPayerClaims; + return claims.relationship !== classification.relationship + || claims.beneficiaryId !== classification.beneficiaryId + || claims.payerClusterId !== classification.payerClusterId; +} + +function compareEvents(left, right) { + const time = left.settledAt.localeCompare(right.settledAt); + return time || left.settlementId.localeCompare(right.settlementId); +} + +export function computeSkillMetrics(eventValues, { classifier } = {}) { + if (!Array.isArray(eventValues) || eventValues.length === 0) { + fail('events must be a non-empty array'); + } + if (typeof classifier !== 'function') fail('classifier must be an injected trusted function'); + const events = eventValues.map(parseSettlementMetricEvent).sort(compareEvents); + const skillId = events[0].skillId; + const settlementIds = new Set(); + const successfulInvocationIds = new Set(); + for (const event of events) { + if (event.skillId !== skillId) fail('events for multiple Skills must be grouped before reduction'); + if (settlementIds.has(event.settlementId)) { + fail(`duplicate settlement ID '${event.settlementId}'`); + } + settlementIds.add(event.settlementId); + if (event.outcome === 'succeeded') { + if (successfulInvocationIds.has(event.invocationId)) { + fail(`duplicate successful Invocation '${event.invocationId}'`); + } + successfulInvocationIds.add(event.invocationId); + } + } + + const exclusionCounts = Object.fromEntries(EXCLUSION_KEYS.map((key) => [key, 0])); + const seenIndependentClusters = new Set(); + const independentBeneficiaries = new Set(); + const uniquePayers = new Set(); + const auditWarnings = []; + let refundAdjustedNet = 0n; + let independentNet = 0n; + let hasSuccessfulNetSettlement = false; + + for (const event of events) { + uniquePayers.add(event.payerWallet); + refundAdjustedNet += BigInt(event.grossAtomic) + - BigInt(event.refundedAtomic) + - BigInt(event.recycledAtomic); + if (event.outcome === 'succeeded' + && event.refundedAtomic === '0' + && event.recycledAtomic === '0') { + hasSuccessfulNetSettlement = true; + } + const classification = validateClassification(classifier(event)); + if (claimsDisagree(event, classification)) { + auditWarnings.push( + `${event.settlementId}: untrusted payer claims disagree with verified classification`, + ); + } + const reasons = exclusionReasons(event, classification, { seenIndependentClusters }); + for (const reason of reasons) exclusionCounts[reason] += 1; + if (reasons.length === 0) { + seenIndependentClusters.add(classification.payerClusterId); + independentBeneficiaries.add(classification.beneficiaryId); + independentNet += BigInt(event.grossAtomic); + } + } + + const independentClusterCount = seenIndependentClusters.size; + const registryStatus = independentClusterCount >= 2 + && independentBeneficiaries.size >= 2 + && independentNet > 0n + ? 'eligible' + : hasSuccessfulNetSettlement + ? 'allow_listed' + : 'ineligible'; + const independenceConfidence = independentClusterCount >= 2 + ? 'high' + : independentClusterCount === 1 + ? 'medium' + : 'low'; + + return deepFreeze({ + schemaVersion: 1, + skillId, + totalSettlements: events.length, + successfulInvocations: events.filter((event) => event.outcome === 'succeeded').length, + settledFailures: events.filter((event) => event.outcome === 'failed').length, + unresolvedSettlements: events.filter((event) => event.outcome === 'unresolved').length, + refundedSettlements: events.filter((event) => BigInt(event.refundedAtomic) > 0n).length, + uniquePayerWallets: uniquePayers.size, + uniqueIndependentBeneficiaries: independentBeneficiaries.size, + refundAdjustedNetAtomic: refundAdjustedNet.toString(), + independentNetAtomic: independentNet.toString(), + independenceConfidence, + registryStatus, + exclusionCounts, + auditWarnings, + }); +} + +export function rankEligibleSkills(metricValues) { + if (!Array.isArray(metricValues)) fail('metrics must be an array'); + return Object.freeze( + metricValues + .filter((metric) => metric?.registryStatus === 'eligible') + .slice() + .sort((left, right) => { + const netDifference = BigInt(right.independentNetAtomic) - BigInt(left.independentNetAtomic); + if (netDifference !== 0n) return netDifference > 0n ? 1 : -1; + const beneficiaries = right.uniqueIndependentBeneficiaries + - left.uniqueIndependentBeneficiaries; + if (beneficiaries !== 0) return beneficiaries; + const invocations = right.successfulInvocations - left.successfulInvocations; + if (invocations !== 0) return invocations; + return String(left.skillId).localeCompare(String(right.skillId)); + }), + ); +} diff --git a/spikes/registry-ranking/test/metrics.test.mjs b/spikes/registry-ranking/test/metrics.test.mjs new file mode 100644 index 0000000..52fe827 --- /dev/null +++ b/spikes/registry-ranking/test/metrics.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + computeSkillMetrics, + createVerifiedBillingClassifier, + exclusionReasons, + parseSettlementMetricEvent, + rankEligibleSkills, +} from '../src/metrics.mjs'; + +const fixtureUrl = new URL('../fixtures/settlements.json', import.meta.url); +const registryUrl = new URL('../fixtures/verified-billing-registry.json', import.meta.url); +const events = JSON.parse(await readFile(fixtureUrl, 'utf8')); +const verifiedBillingRegistry = JSON.parse(await readFile(registryUrl, 'utf8')); +const classifier = createVerifiedBillingClassifier(verifiedBillingRegistry); +const SYBIL_SETTLEMENT_IDS = new Set([ + 'settlement-003-otherco-a', + 'settlement-004-otherco-b', +]); + +function clone(value) { + return structuredClone(value); +} + +test('fixture has ten settlements and exactly two accepted independent clusters', () => { + const metrics = computeSkillMetrics(events, { classifier }); + + assert.equal(metrics.totalSettlements, 10); + assert.equal(metrics.successfulInvocations, 8); + assert.equal(metrics.settledFailures, 1); + assert.equal(metrics.unresolvedSettlements, 1); + assert.equal(metrics.refundedSettlements, 1); + assert.equal(metrics.uniquePayerWallets, 10); + assert.equal(metrics.uniqueIndependentBeneficiaries, 2); + assert.equal(metrics.independentNetAtomic, '500000'); + assert.equal(metrics.registryStatus, 'eligible'); + assert.equal(metrics.exclusionCounts.self_payment, 1); + assert.equal(metrics.exclusionCounts.linked_wallet, 1); + assert.equal(metrics.exclusionCounts.sybil_cluster, 1); + assert.equal(metrics.exclusionCounts.failed_invocation, 1); + assert.equal(metrics.exclusionCounts.unresolved_settlement, 1); + assert.equal(metrics.exclusionCounts.refunded, 1); + assert.equal(metrics.exclusionCounts.recycled_value, 1); + assert.equal(metrics.exclusionCounts.unknown_relationship, 1); + assert.equal(metrics.independenceConfidence, 'high'); + + const sybilOnly = computeSkillMetrics( + events.filter((event) => SYBIL_SETTLEMENT_IDS.has(event.settlementId)), + { classifier }, + ); + assert.equal(sybilOnly.registryStatus, 'allow_listed'); + assert.equal(sybilOnly.uniqueIndependentBeneficiaries, 1); + assert.equal(sybilOnly.independenceConfidence, 'medium'); +}); + +test('classification ignores caller-supplied payer claims', () => { + const unknown = events.find((event) => event.settlementId === 'settlement-009-unknown'); + const unknownMetrics = computeSkillMetrics([unknown], { classifier }); + assert.equal(unknownMetrics.registryStatus, 'allow_listed'); + assert.equal(unknownMetrics.uniqueIndependentBeneficiaries, 0); + assert.equal(unknownMetrics.independenceConfidence, 'low'); + assert.deepEqual(unknownMetrics.exclusionCounts, { + self_payment: 0, + linked_wallet: 0, + failed_invocation: 0, + unresolved_settlement: 0, + refunded: 0, + recycled_value: 0, + sybil_cluster: 0, + unknown_relationship: 1, + }); + + const linked = events.find((event) => event.settlementId === 'settlement-002-linked'); + const linkedMetrics = computeSkillMetrics([linked], { classifier }); + assert.equal(linkedMetrics.registryStatus, 'allow_listed'); + assert.equal(linkedMetrics.exclusionCounts.linked_wallet, 1); + + const changedClaims = events.map((event) => ({ + ...clone(event), + untrustedPayerClaims: { + beneficiaryId: `spoof-${event.settlementId}`, + payerClusterId: `spoof-cluster-${event.settlementId}`, + relationship: 'independent', + }, + })); + const original = computeSkillMetrics(events, { classifier }); + const changed = computeSkillMetrics(changedClaims, { classifier }); + assert.deepEqual( + { ...changed, auditWarnings: [] }, + { ...original, auditWarnings: [] }, + ); +}); + +test('self classification wins before trusted registry lookup', () => { + const self = events[0]; + const registry = clone(verifiedBillingRegistry); + registry.entries[self.payerWallet] = { + beneficiaryId: 'attacker-controlled-registry-row', + payerClusterId: 'cluster-attacker', + relationship: 'independent', + evidenceRef: 'billing-review:attacker:2026-07-17', + reviewedAt: '2026-07-17T00:00:00.000Z', + }; + const localClassifier = createVerifiedBillingClassifier(registry); + const classification = localClassifier(parseSettlementMetricEvent(self)); + assert.equal(classification.relationship, 'self'); + assert.deepEqual( + exclusionReasons(self, classification, { seenIndependentClusters: new Set() }), + ['self_payment'], + ); +}); + +test('cluster acceptance is independent of caller event order', () => { + const forward = computeSkillMetrics(events, { classifier }); + const reverse = computeSkillMetrics([...events].reverse(), { classifier }); + assert.deepEqual(reverse, forward); +}); + +test('metric parser and reducer fail closed on malformed events', () => { + const base = clone(events[0]); + for (const [field, value] of [ + ['grossAtomic', '-1'], + ['grossAtomic', '01'], + ['grossAtomic', 250000], + ['refundedAtomic', '250001'], + ['recycledAtomic', '250001'], + ['settledAt', '2026-07-17T00:00:00-04:00'], + ['creatorWallet', '0xABCDEF'], + ]) { + const invalid = { ...clone(base), [field]: value }; + assert.throws(() => parseSettlementMetricEvent(invalid), /invalid|must|exceed/i, field); + } + + assert.throws( + () => computeSkillMetrics([base, { ...clone(base) }], { classifier }), + /duplicate settlement/i, + ); + assert.throws( + () => computeSkillMetrics([ + base, + { + ...clone(events[1]), + settlementId: 'settlement-unique', + invocationId: base.invocationId, + }, + ], { classifier }), + /duplicate successful Invocation/i, + ); +}); + +test('trusted registry requires canonical direct evidence records', () => { + const cases = [ + (() => { + const value = clone(verifiedBillingRegistry); + value.entries['0x4444444444444444444444444444444444444444'].evidenceRef = ''; + return value; + })(), + (() => { + const value = clone(verifiedBillingRegistry); + value.entries['0x4444444444444444444444444444444444444444'].reviewedAt = '2026-07-17'; + return value; + })(), + (() => { + const value = clone(verifiedBillingRegistry); + value.entries['0x4444444444444444444444444444444444444444'].relationship = 'self'; + return value; + })(), + (() => { + const value = clone(verifiedBillingRegistry); + value.entries['0x4444444444444444444444444444444444444444'].unexpected = true; + return value; + })(), + ]; + for (const invalid of cases) { + assert.throws(() => createVerifiedBillingClassifier(invalid), /registry|evidence|timestamp|keys/i); + } +}); + +test('ranker is deterministic and leaves caller arrays untouched', () => { + const ledger = computeSkillMetrics(events, { classifier }); + const second = Object.freeze({ + ...ledger, + skillId: 'alpha-skill', + independentNetAtomic: '750000', + }); + const input = [ledger, second]; + const ranked = rankEligibleSkills(input); + assert.deepEqual(ranked.map((metric) => metric.skillId), ['alpha-skill', 'ledger-recon']); + assert.deepEqual(input.map((metric) => metric.skillId), ['ledger-recon', 'alpha-skill']); + assert.ok(Object.isFrozen(ranked)); +}); From a7f2368efca955d10a34d8be967fcbacdb9beacc Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:13:25 -0400 Subject: [PATCH 120/165] docs: define settlement-verifiable registry metrics --- .../2026-07-15-registry-not-marketplace.md | 66 +++++++++++--- spikes/registry-ranking/README.md | 57 ++++++++++++ spikes/registry-ranking/src/report.mjs | 91 +++++++++++++++++++ spikes/registry-ranking/test/metrics.test.mjs | 29 ++++++ 4 files changed, 232 insertions(+), 11 deletions(-) create mode 100644 spikes/registry-ranking/README.md create mode 100644 spikes/registry-ranking/src/report.mjs diff --git a/docs/plans/2026-07-15-registry-not-marketplace.md b/docs/plans/2026-07-15-registry-not-marketplace.md index 042ec0e..55c5911 100644 --- a/docs/plans/2026-07-15-registry-not-marketplace.md +++ b/docs/plans/2026-07-15-registry-not-marketplace.md @@ -6,6 +6,14 @@ synthesizer, adversarially cross-checked) run 2026-07-15 against the question: adoptable protocol like MCP/A2A?" All external facts are dated; re-verify before publication — this space moves week to week.* +> **2026-07-17 accounting amendment:** The dated strategy below originally +> treated gross settled volume and payer-wallet counts as sufficient ranking +> signals. The implemented spike now uses the settlement-verifiable metric +> contract in this amendment. Settlement establishes that value moved; it does +> not establish independent demand, usefulness, authorship, originality, or +> safety. Where the dated research narrative conflicts with this amendment, +> this amendment controls public registry output. + ## Verdict The question splits in two, with opposite answers. @@ -33,8 +41,9 @@ playbook applied to the Collar/ledger side. **The version of the founder's idea that survives both:** a **settlement-gated registry** — a thin index over the ledger + provenance graph Phases 0–1 build -anyway. A skill is listed the moment its first x402 payment settles; ranked by -30-day settled volume and unique payers (unfakeable); auto-delisted when idle. +anyway. A Skill becomes allow-listed after its first successful, +unrefunded, unrecycled settlement, but remains ineligible for public ranking +until classifier-verified independent use clears the amendment gate below. No submission, no curation, no hosting decisions, no tradeable instruments. This passes ADR-0007's own optionality test ("nearly free" when mechanics are shared, 0007:61-63) because it is a read API over shared mechanics — provided @@ -71,6 +80,36 @@ Timing gift: the **x402 Foundation formally launched under the Linux Foundation on 2026-07-14** (40 members; Visa, Mastercard, Amex, Stripe, Ripple premier). Building on x402 now inherits that legitimacy for free. +## 2026-07-17 public registry metric contract + +The registry consumes `SettlementMetricEventV1` records with required +settlement, Invocation, Skill, Creator/payee/payer wallet, gross/refund/recycle, +outcome, and UTC timestamp fields. Payer-supplied Beneficiary, relationship, +and cluster claims are retained only as audit warnings; they never determine a +public metric. + +An operator-controlled `VerifiedBillingRegistryV1` classifies payer ownership. +Self-payment is derived before registry lookup. Direct reviewed entries classify +a payer as Creator-linked or independent and bind it to one Beneficiary and +billing-owner cluster; absent entries are unknown. This is explicit operator +trust, not proof of ultimate beneficial ownership. + +Public output reports these fields separately: total settlements, successful +Invocations, settled failures, unresolved settlements, refunded settlements, +unique payer wallets, unique independent Beneficiaries, refund-adjusted net, +independent net, independence confidence, registry status, and counts for +`self_payment`, `linked_wallet`, `failed_invocation`, +`unresolved_settlement`, `refunded`, `recycled_value`, `sybil_cluster`, and +`unknown_relationship`. + +Only successful, unrefunded, unrecycled settlements classified as independent +contribute to independent net. Events are ordered by settlement time and ID; +only the first accepted event in a billing-owner cluster can count. The first +registry stays allow-listed until at least two classifier-verified successful +independent Beneficiaries in distinct accepted clusters have positive +independent net. Eligible Skills sort by independent net, independent +Beneficiaries, successful Invocations, then Skill identifier. + ## The adoption evidence: MCP vs A2A Measured outcome (pypistats, 2026-07-15): `mcp` ≈ 295.5M downloads/month vs @@ -114,22 +153,27 @@ co-held claim — which is KC1.** 5. **Mid-size ecosystem before giants**: gateway operators, OpenClaw/ClawHub maintainers, Smithery, Story devs — the campaign's week-of-living-in-replies already targets exactly these. Platforms ratify; they are not the ask. -6. **Listing = proof of settlement** (the Coinbase Bazaar mechanic): the +6. **Listing = settlement-verifiable movement** (the Coinbase Bazaar mechanic): the anti-skills.sh. Their telemetry listing produced ~895k mostly-noise - entries; settlement-gating produces a small index where every entry is - provably alive and paid. + entries; settlement-gating produces a smaller index while the verified + billing classifier and exclusions keep self-funded, linked, refunded, + failed, unresolved, recycled, repeated-cluster, and unknown activity out of + independent metrics. 7. **Discovery as an MCP server**: search → quote → pay (x402) → invoke in one agent tool-loop. x402 Bazaar, Nevermined, and MCP Hive all converged on MCP as the surface agents actually touch. -8. **Publish unfakeable telemetry, ranked by the hierarchy that predicted - MCP-vs-A2A**: settled tx + unique payers → active collared skills → SDK - pulls → stars. Matches the campaign's existing metric doctrine. +8. **Publish settlement-verifiable telemetry under the amendment contract**: + report movement, outcomes, refunds, payer-wallet count, classifier-verified + independent Beneficiaries, net amounts, confidence, status, and exclusions + as separate fields. Never collapse them into a demand or quality claim. 9. **Sell attribution as security simultaneously**: signed immutable skill definitions + derivation graphs answer the documented registry supply-chain wound (Unit 42: five malicious ClawHub skills incl. macOS infostealers, 2026-02..05; Trail of Bits reportedly bypassed skills.sh's - Snyk scanning via prompt injection). Buyers get supply-chain safety, authors - get the claim substrate — same primitive, two pitches. + Snyk scanning via prompt injection). Buyers get wallet-attested registration + and declared ancestry; authorship evidence and safety review are separate + statuses. Creators get the claim substrate — the same primitive supports two + distinct, explicitly bounded pitches. 10. **Donate late, like MCP and x402**: foundation paperwork before usage is pure distraction for a solo founder; revisit only on a credible fork threat. @@ -141,7 +185,7 @@ co-held claim — which is KC1.** | Week 0 (now) | Execute the slipped launch — verified 2026-07-15 via `gh`: repo still private vs Day 0 = 07-14 — with a re-anchored Day 0 and design-partner conversations as metric #1; **instantiate KC7's monthly platform review with a named owner** (mandated PRD:647, currently uninstantiated) | — | | Weeks 1–2 | Neutral GitHub org; package the adoption kit; finish pi-wielder as reference Wielder | Cheap, parallel | | Weeks 2–6 | Land the two named adopters: the KC1 LOI + one x402 gateway; first settled mainnet payment through a collared skill, provenance on Story | The KC1 LOI is the hard gate for everything downstream | -| Weeks 6–10 | Ship **the Skill Asset Protocol registry** as an MCP server with proof-of-settlement listing + public telemetry dashboard | Only after organic supply exists; named registry, never marketplace | +| Weeks 6–10 | Ship **the Skill Asset Protocol registry** as an MCP server with settlement-verifiable listing + public telemetry dashboard | Only after organic supply exists; named registry, never marketplace | | Months 3–4 | Attribution overlay on existing free registries (signed authorship + derivation records keyed to GitHub owner/repo); court ClawHub/Smithery — their malware problem is the sales wedge | — | | Months 4–6+ | Phase-1 build per PRD | KC1 LOI signed + paying closed-mode deployment | diff --git a/spikes/registry-ranking/README.md b/spikes/registry-ranking/README.md new file mode 100644 index 0000000..cdc0f3d --- /dev/null +++ b/spikes/registry-ranking/README.md @@ -0,0 +1,57 @@ +# Settlement-verifiable registry ranking spike + +SPIKE — synthetic registry-accounting evidence only. Settlement proves that value moved. It does not prove independent demand, usefulness, authorship, originality, or safety. + +This offline spike tests whether a public registry can separate successful independent use from self-payment, Creator-linked payment, repeated billing clusters, failed or unresolved Invocations, refunds, recycled value, and unknown payer relationships. The fixture is synthetic. The verified billing registry is also synthetic and represents an operator-controlled trust input, not proof of ultimate beneficial ownership. + +## Reproduce + +Requires Node.js 20 or newer. No wallet, provider, network, or live service is used. + +```bash +cd spikes/registry-ranking +npm test +npm run report +npm run report -- --json +``` + +## Event contract + +Every `SettlementMetricEventV1` requires `schemaVersion`, settlement/Invocation/Skill identifiers, Creator/payee/payer wallets, untrusted payer claims, gross/refunded/recycled atomic-unit decimal strings, an outcome (`succeeded`, `failed`, or `unresolved`), and a canonical UTC settlement timestamp. Duplicate settlement IDs and duplicate successful Invocation IDs fail closed. + +The event's `untrustedPayerClaims` are retained only for disagreement warnings. They never determine ranking or eligibility. + +`VerifiedBillingRegistryV1` is supplied separately. Every canonical lowercase payer-wallet entry names a reviewed Beneficiary, billing-owner cluster, relationship (`linked` or `independent`), non-empty evidence reference, and review timestamp. The classifier always derives `self` when the payer equals the Creator or payee, uses the trusted registry for linked/independent ownership, and otherwise returns `unknown`. + +## Reduction and exclusions + +Events are ordered by `settledAt`, then `settlementId`, before the first accepted event in a billing cluster is selected. A settlement may record one or more stable exclusion reasons: + +- `self_payment` +- `linked_wallet` +- `failed_invocation` +- `unresolved_settlement` +- `refunded` +- `recycled_value` +- `sybil_cluster` +- `unknown_relationship` + +Only a successful, unrefunded, unrecycled event classified as independent can contribute to `independentNetAtomic`. A second payer wallet in an already accepted cluster is excluded as `sybil_cluster`. The reducer still reports raw settlement/outcome/refund/payer counts separately so the public view does not collapse movement, execution outcome, and independence into one number. + +## Eligibility and sort order + +A Skill is: + +- `eligible` only after at least two classifier-verified successful independent Beneficiaries in distinct accepted clusters and positive independent net; +- `allow_listed` when any successful unrefunded/unrecycled settlement exists but the independent gate is unmet; +- `ineligible` otherwise. + +Independence confidence is `low` for zero accepted clusters, `medium` for one, and `high` for at least two. Eligible Skills sort by independent net descending, independent Beneficiaries descending, successful Invocations descending, then Skill identifier ascending. + +## Known limits + +- Settlement verifies that value moved; it does not establish why it moved. +- The billing registry is operator-reviewed evidence, not cryptographic proof of ownership or independence. +- Multiple apparently independent entities can still coordinate outside the observable billing graph. +- Settlement metrics do not establish usefulness, quality, safety, authorship, originality, compliant distribution, or production readiness. +- This spike does not authorize a registry launch, public listing, payment, or transaction. diff --git a/spikes/registry-ranking/src/report.mjs b/spikes/registry-ranking/src/report.mjs new file mode 100644 index 0000000..dbdb1c9 --- /dev/null +++ b/spikes/registry-ranking/src/report.mjs @@ -0,0 +1,91 @@ +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +import { + computeSkillMetrics, + createVerifiedBillingClassifier, + parseSettlementMetricEvent, + rankEligibleSkills, +} from './metrics.mjs'; + +function sortedMetrics(metricValues) { + if (!Array.isArray(metricValues)) throw new TypeError('metrics must be an array'); + return metricValues.slice().sort((left, right) => left.skillId.localeCompare(right.skillId)); +} + +export function renderRegistryReport(metricValues) { + const metrics = sortedMetrics(metricValues); + const lines = [ + '# Settlement-verifiable registry report', + '', + 'This settlement-verifiable report means the ledger supports that value moved. These metrics do not establish independent demand, usefulness, authorship, originality, or safety.', + ]; + for (const metric of metrics) { + lines.push( + '', + `## Skill: ${metric.skillId}`, + '', + `- Total settlements: ${metric.totalSettlements}`, + `- Successful Invocations: ${metric.successfulInvocations}`, + `- Settled failures: ${metric.settledFailures}`, + `- Unresolved settlements: ${metric.unresolvedSettlements}`, + `- Refunded settlements: ${metric.refundedSettlements}`, + `- Unique payer wallets: ${metric.uniquePayerWallets}`, + `- Unique independent Beneficiaries: ${metric.uniqueIndependentBeneficiaries}`, + `- Refund-adjusted net (after refunded and recycled value), atomic units: ${metric.refundAdjustedNetAtomic}`, + `- Independent net, atomic units: ${metric.independentNetAtomic}`, + `- Independence confidence: ${metric.independenceConfidence}`, + `- Registry eligibility: ${metric.registryStatus}`, + '- Exclusions:', + ); + for (const [reason, count] of Object.entries(metric.exclusionCounts)) { + lines.push(` - ${reason}: ${count}`); + } + if (metric.auditWarnings.length > 0) { + lines.push('- Audit warnings:'); + for (const warning of metric.auditWarnings) lines.push(` - ${warning}`); + } + } + return `${lines.join('\n')}\n`; +} + +export function buildRegistryReport(eventsValue, registryValue) { + if (!Array.isArray(eventsValue)) throw new TypeError('settlement fixture must be an array'); + const classifier = createVerifiedBillingClassifier(registryValue); + const grouped = new Map(); + for (const raw of eventsValue) { + const event = parseSettlementMetricEvent(raw); + if (!grouped.has(event.skillId)) grouped.set(event.skillId, []); + grouped.get(event.skillId).push(event); + } + const skills = [...grouped.keys()] + .sort() + .map((skillId) => computeSkillMetrics(grouped.get(skillId), { classifier })); + return Object.freeze({ + schemaVersion: 1, + evidenceStatus: 'synthetic_registry_accounting_fixture', + eligibilityRule: 'at least two classifier-verified successful independent Beneficiaries in distinct billing clusters and positive independent net', + eligibleSkills: rankEligibleSkills(skills), + skills: Object.freeze(skills), + }); +} + +export async function main(argv = process.argv.slice(2)) { + const json = argv.includes('--json'); + const paths = argv.filter((argument) => argument !== '--json'); + if (paths.length !== 2) { + throw new TypeError('usage: report.mjs SETTLEMENTS_JSON VERIFIED_BILLING_REGISTRY_JSON [--json]'); + } + const [eventsText, registryText] = await Promise.all(paths.map((path) => readFile(path, 'utf8'))); + const report = buildRegistryReport(JSON.parse(eventsText), JSON.parse(registryText)); + process.stdout.write(json + ? `${JSON.stringify(report, null, 2)}\n` + : renderRegistryReport(report.skills)); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/spikes/registry-ranking/test/metrics.test.mjs b/spikes/registry-ranking/test/metrics.test.mjs index 52fe827..288e21c 100644 --- a/spikes/registry-ranking/test/metrics.test.mjs +++ b/spikes/registry-ranking/test/metrics.test.mjs @@ -9,6 +9,7 @@ import { parseSettlementMetricEvent, rankEligibleSkills, } from '../src/metrics.mjs'; +import { renderRegistryReport } from '../src/report.mjs'; const fixtureUrl = new URL('../fixtures/settlements.json', import.meta.url); const registryUrl = new URL('../fixtures/verified-billing-registry.json', import.meta.url); @@ -191,3 +192,31 @@ test('ranker is deterministic and leaves caller arrays untouched', () => { assert.deepEqual(input.map((metric) => metric.skillId), ['ledger-recon', 'alpha-skill']); assert.ok(Object.isFrozen(ranked)); }); + +test('report separates settlement-verifiable metrics from unsupported inferences', () => { + const metrics = computeSkillMetrics(events, { classifier }); + const report = renderRegistryReport([metrics]); + for (const heading of [ + 'Total settlements', + 'Successful Invocations', + 'Settled failures', + 'Unresolved settlements', + 'Refunded settlements', + 'Unique independent Beneficiaries', + 'Refund-adjusted net', + 'Independent net', + 'Independence confidence', + 'Registry eligibility', + 'Exclusions', + ]) { + assert.match(report, new RegExp(heading)); + } + assert.match(report, /settlement-verifiable/); + const forbiddenClaims = new RegExp([ + `un${'fakeable'}`, + `proof of ${'demand'}`, + `proves ${'quality'}`, + `supply-chain ${'safety'}`, + ].join('|'), 'i'); + assert.doesNotMatch(report, forbiddenClaims); +}); From 00bb2476faa3b6b0d5202a712f59dbe26757cb28 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:14:50 -0400 Subject: [PATCH 121/165] docs: capture narrow Base Sepolia transaction evidence --- .../2026-07-12-skill-settlement/manifest.json | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json diff --git a/spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json b/spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json new file mode 100644 index 0000000..8d9ee1f --- /dev/null +++ b/spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "evidenceId": "base-sepolia-skill-settlement-2026-07-12", + "evidenceStatus": "historical_transaction_receipt_verified", + "network": { + "name": "base-sepolia", + "chainId": 84532 + }, + "transaction": { + "txHash": "0xaf1ba2fe508ee9d6bfe0823e25a05fc8b05c8dbac007b40b7d36dbbe447af522", + "status": "success", + "blockNumber": 44053992, + "blockHash": "0x7aad94c78a3c7a4eda90c70b510bd1f27a8b44d2c135d98a95473a561d48f56f", + "blockTimestamp": "2026-07-12T17:11:12.000Z", + "to": "0x036cbd53842c5426634e7929541ec2318f3dcf7e" + }, + "usdcTransfer": { + "from": "0xdddf065692ae373266a921f028ba6666a583053f", + "to": "0x25005dfac23d4bc45c801eaeb6c8b5a2bab0f189", + "amountAtomic": "250000" + }, + "verification": { + "method": "eth_getTransactionReceipt", + "rpc": "https://sepolia.base.org", + "verifiedOn": "2026-07-17", + "repositorySourceCommit": "69e7c6c17ba92792e1e0a8fee15fc90efc998c84", + "repositorySourcePath": "spikes/pi-wielder/README.md" + }, + "publication": { + "allowed": true, + "publicClaim": "One successful Base Sepolia USDC transfer transaction exists; the repository's 2026-07-12 historical run log labels it as the Skill-leg settlement.", + "doesNotProve": [ + "current endpoint behavior", + "latency", + "Royalty-claim split correctness", + "Skill execution output", + "independent demand", + "production readiness" + ] + } +} From 8103fff81296cac206c4f31bff91d6aedc427a36 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:22:20 -0400 Subject: [PATCH 122/165] fix: reject cross-Skill registry duplicates --- spikes/registry-ranking/src/report.mjs | 18 ++++++++++-- spikes/registry-ranking/test/metrics.test.mjs | 28 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/spikes/registry-ranking/src/report.mjs b/spikes/registry-ranking/src/report.mjs index dbdb1c9..27da8bb 100644 --- a/spikes/registry-ranking/src/report.mjs +++ b/spikes/registry-ranking/src/report.mjs @@ -52,9 +52,23 @@ export function renderRegistryReport(metricValues) { export function buildRegistryReport(eventsValue, registryValue) { if (!Array.isArray(eventsValue)) throw new TypeError('settlement fixture must be an array'); const classifier = createVerifiedBillingClassifier(registryValue); + const parsedEvents = eventsValue.map(parseSettlementMetricEvent); + const settlementIds = new Set(); + const successfulInvocationIds = new Set(); + for (const event of parsedEvents) { + if (settlementIds.has(event.settlementId)) { + throw new TypeError(`duplicate settlement ID '${event.settlementId}'`); + } + settlementIds.add(event.settlementId); + if (event.outcome === 'succeeded') { + if (successfulInvocationIds.has(event.invocationId)) { + throw new TypeError(`duplicate successful Invocation '${event.invocationId}'`); + } + successfulInvocationIds.add(event.invocationId); + } + } const grouped = new Map(); - for (const raw of eventsValue) { - const event = parseSettlementMetricEvent(raw); + for (const event of parsedEvents) { if (!grouped.has(event.skillId)) grouped.set(event.skillId, []); grouped.get(event.skillId).push(event); } diff --git a/spikes/registry-ranking/test/metrics.test.mjs b/spikes/registry-ranking/test/metrics.test.mjs index 288e21c..083ba46 100644 --- a/spikes/registry-ranking/test/metrics.test.mjs +++ b/spikes/registry-ranking/test/metrics.test.mjs @@ -9,7 +9,7 @@ import { parseSettlementMetricEvent, rankEligibleSkills, } from '../src/metrics.mjs'; -import { renderRegistryReport } from '../src/report.mjs'; +import { buildRegistryReport, renderRegistryReport } from '../src/report.mjs'; const fixtureUrl = new URL('../fixtures/settlements.json', import.meta.url); const registryUrl = new URL('../fixtures/verified-billing-registry.json', import.meta.url); @@ -220,3 +220,29 @@ test('report separates settlement-verifiable metrics from unsupported inferences ].join('|'), 'i'); assert.doesNotMatch(report, forbiddenClaims); }); + +test('report rejects duplicate settlement and successful Invocation IDs across Skills', () => { + const base = clone(events[9]); + assert.throws( + () => buildRegistryReport([ + base, + { + ...clone(base), + skillId: 'another-skill', + invocationId: 'another-invocation', + }, + ], verifiedBillingRegistry), + /duplicate settlement/i, + ); + assert.throws( + () => buildRegistryReport([ + base, + { + ...clone(base), + skillId: 'another-skill', + settlementId: 'another-settlement', + }, + ], verifiedBillingRegistry), + /duplicate successful Invocation/i, + ); +}); From 17ec3c3532a5ef534ae463f9a39bf6a77ddd98bc Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:37:26 -0400 Subject: [PATCH 123/165] fix: align public demo with verified accounting --- hf-space/gradio/README.md | 45 ++ hf-space/gradio/app.py | 137 +++++ hf-space/gradio/data/evidence.json | 22 + hf-space/gradio/data/fixture-integrity.json | 14 + .../gradio/data/public-demo-allocation.json | 353 +++++++++++++ hf-space/gradio/demo_logic.py | 245 +++++++++ hf-space/gradio/requirements.txt | 2 + hf-space/gradio/test_app_smoke.py | 90 ++++ hf-space/gradio/test_demo_logic.py | 144 ++++++ .../scripts/generate-accounting-fixture.mjs | 292 +++++++++++ hf-space/scripts/package-space-fixtures.mjs | 149 ++++++ .../test-generate-accounting-fixture.mjs | 132 +++++ .../scripts/test-package-space-fixtures.mjs | 125 +++++ hf-space/scripts/verify-local-scope.mjs | 115 +++++ hf-space/shared/evidence.json | 22 + hf-space/shared/public-demo-allocation.json | 353 +++++++++++++ hf-space/static/README.md | 40 ++ hf-space/static/data/evidence.json | 22 + hf-space/static/data/fixture-integrity.json | 14 + .../static/data/public-demo-allocation.json | 353 +++++++++++++ hf-space/static/demo-logic.mjs | 475 ++++++++++++++++++ hf-space/static/index.html | 69 +++ hf-space/static/package-lock.json | 216 ++++++++ hf-space/static/package.json | 12 + hf-space/static/test-demo-logic.mjs | 154 ++++++ hf-space/static/test-index-smoke.mjs | 134 +++++ 26 files changed, 3729 insertions(+) create mode 100644 hf-space/gradio/README.md create mode 100644 hf-space/gradio/app.py create mode 100644 hf-space/gradio/data/evidence.json create mode 100644 hf-space/gradio/data/fixture-integrity.json create mode 100644 hf-space/gradio/data/public-demo-allocation.json create mode 100644 hf-space/gradio/demo_logic.py create mode 100644 hf-space/gradio/requirements.txt create mode 100644 hf-space/gradio/test_app_smoke.py create mode 100644 hf-space/gradio/test_demo_logic.py create mode 100644 hf-space/scripts/generate-accounting-fixture.mjs create mode 100644 hf-space/scripts/package-space-fixtures.mjs create mode 100644 hf-space/scripts/test-generate-accounting-fixture.mjs create mode 100644 hf-space/scripts/test-package-space-fixtures.mjs create mode 100644 hf-space/scripts/verify-local-scope.mjs create mode 100644 hf-space/shared/evidence.json create mode 100644 hf-space/shared/public-demo-allocation.json create mode 100644 hf-space/static/README.md create mode 100644 hf-space/static/data/evidence.json create mode 100644 hf-space/static/data/fixture-integrity.json create mode 100644 hf-space/static/data/public-demo-allocation.json create mode 100644 hf-space/static/demo-logic.mjs create mode 100644 hf-space/static/index.html create mode 100644 hf-space/static/package-lock.json create mode 100644 hf-space/static/package.json create mode 100644 hf-space/static/test-demo-logic.mjs create mode 100644 hf-space/static/test-index-smoke.mjs diff --git a/hf-space/gradio/README.md b/hf-space/gradio/README.md new file mode 100644 index 0000000..570796a --- /dev/null +++ b/hf-space/gradio/README.md @@ -0,0 +1,45 @@ +--- +title: Skill Asset Protocol — Verified Accounting Demo +emoji: 🔏 +colorFrom: gray +colorTo: blue +sdk: gradio +sdk_version: 6.20.0 +app_file: app.py +python_version: "3.12" +short_description: Testnet-only HTTP 402 and generated accounting evidence. +license: apache-2.0 +--- + +# Skill Asset Protocol — verified accounting demo + +This standalone Gradio root is a research demo for the compensation, +attribution, and metering layer for authored AI Skills. It defaults to the +terminal-product **Intra-org** scenario. **Education** is deferred after free +re-authoring dominated the tested model; **Marketplace** remains Phase-3 +optionality. + +The allocation fixture is generated from `prototype/atomic-money.mjs` and +packaged into this Space root with a SHA-256 integrity manifest. The runtime +does not derive fees, Royalty-claim pools, Invocation awards, ancestry splits, +rounding, or account identifiers. It renders the kernel-returned journal rows. +The illustration is synthetic. A credited allocation is not a withdrawal or +on-chain settlement; neither is implemented in this demo. + +The live check makes one unpaid request only to the fixed Collar endpoint, +refuses redirects, uses a bounded timeout, and marks a response live only when +it is a valid x402 v1 `exact` offer for Base Sepolia. A failed request or JSON +200/500 response is explicitly non-live. There is no cached-offer fallback. + +Evidence shown here is deliberately narrow: + +- the historical inference-route aggregate is `historical_unreproducible` and + not publication-eligible because normalized samples were not retained; +- one successful historical Base Sepolia USDC transfer has a rechecked receipt, + and the 2026-07-12 repository log labels it as the Skill leg; +- that receipt does not establish current endpoint behavior, latency, + Royalty-claim split correctness, or Skill execution output. + +Everything is **Base Sepolia testnet play money**. This demo holds no key, +signs no payment, sends no transaction, deploys nothing, and is not an offer of +any financial product. diff --git a/hf-space/gradio/app.py b/hf-space/gradio/app.py new file mode 100644 index 0000000..3cf1c5e --- /dev/null +++ b/hf-space/gradio/app.py @@ -0,0 +1,137 @@ +"""Skill Asset Protocol public demo, backed only by packaged verified fixtures.""" + +from __future__ import annotations + +import httpx +import gradio as gr + +from demo_logic import ( + LIVE_ENDPOINT, + load_allocation_fixture, + load_evidence_fixture, + render_allocation, + validate_live_402, +) + +REQUEST_BODY = {"input": "help me tighten this prompt"} +REQUEST_TIMEOUT = httpx.Timeout(5.0, connect=3.0) + + +def check_live_402(): + """Read the fixed endpoint once; never follow redirects or use a cached offer.""" + try: + response = httpx.post( + LIVE_ENDPOINT, + json=REQUEST_BODY, + headers={"accept": "application/json"}, + timeout=REQUEST_TIMEOUT, + follow_redirects=False, + ) + body = response.json() + except (httpx.HTTPError, ValueError, TypeError): + return { + "live": False, + "status": None, + "offer": None, + "error": "live endpoint request failed; no cached response is represented as live", + "source": "live_request_failed_no_cache", + } + result = validate_live_402(response.status_code, body) + return {**result, "source": "live_http_response"} + + +def allocation_markdown(scenario_id): + model = render_allocation(ALLOCATION_FIXTURE, scenario_id) + rows = [ + "| category | debit account | credit account | atomic units | testnet USDC |", + "|---|---|---|---:|---:|", + ] + for row in model["rows"]: + rows.append( + f"| `{row['category']}` | `{row['debitAccountId']}` | " + f"`{row['creditAccountId']}` | `{row['amountAtomic']}` | `{row['amountUsdc']}` |" + ) + return "\n".join( + [ + f"### {model['label']}", + f"**Status:** `{model['status']}` · **Policy:** `{model['policy']}`", + "", + model["accountingLabel"], + "", + model["implementationNote"], + "", + f"Gross: `{model['grossAtomic']}` atomic units (`{model['grossUsdc']}` testnet USDC)", + "", + *rows, + "", + model["settlementNote"], + ] + ) + + +def evidence_markdown(evidence): + overhead = evidence["historicalOverhead"] + transaction = evidence["historicalSkillLegTransactions"][0] + boundaries = "\n".join(f"- Does not prove: {item}" for item in transaction["doesNotProve"]) + return "\n".join( + [ + "## Evidence status", + "", + f"**Suppressed historical route evidence:** `{overhead['evidenceStatus']}`; publication allowed: `{str(overhead['publicationAllowed']).lower()}`.", + "", + overhead["publicText"], + "", + f"**Narrow historical transaction evidence:** {transaction['label']}.", + "", + f"Manifest record: `{transaction['manifestPath']}`", + "", + boundaries, + ] + ) + + +def build_demo(): + choices = [(scenario["label"], scenario["id"]) for scenario in ALLOCATION_FIXTURE["scenarios"]] + with gr.Blocks(title="Skill Asset Protocol — verified accounting demo") as blocks: + gr.Markdown( + "# Skill Asset Protocol\n\n" + "A testnet-only accounting and HTTP 402 research demo. No real funds, " + "wallet signing, payment, withdrawal, deployment, or publication occurs here." + ) + with gr.Tab("Accounting illustration"): + scenario = gr.Dropdown( + choices=choices, + value=ALLOCATION_FIXTURE["defaultScenarioId"], + label="Distribution mode", + ) + allocation = gr.Markdown(value=allocation_markdown(ALLOCATION_FIXTURE["defaultScenarioId"])) + scenario.change( + allocation_markdown, + inputs=scenario, + outputs=allocation, + api_name=False, + ) + with gr.Tab("Live HTTP 402 check"): + gr.Markdown( + "This performs one unpaid POST to the fixed Collar endpoint. Redirects are refused, " + "the timeout is bounded, and only a strict x402 v1 Base Sepolia offer is marked live." + ) + live_button = gr.Button("Check fixed live endpoint") + live_result = gr.JSON(label="Validated live response") + live_button.click( + check_live_402, + outputs=live_result, + api_name="check_live_402", + ) + with gr.Tab("Evidence boundaries"): + gr.Markdown(evidence_markdown(EVIDENCE_FIXTURE)) + return blocks + + +ALLOCATION_FIXTURE = load_allocation_fixture() +EVIDENCE_FIXTURE = load_evidence_fixture() +demo = build_demo() + + +if __name__ == "__main__": + demo.launch() diff --git a/hf-space/gradio/data/evidence.json b/hf-space/gradio/data/evidence.json new file mode 100644 index 0000000..ae3f1be --- /dev/null +++ b/hf-space/gradio/data/evidence.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "historicalOverhead": { + "manifestPath": "spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json", + "evidenceStatus": "historical_unreproducible", + "publicationAllowed": false, + "publicText": "A historical 2026-07-15 inference-route run reported latency percentiles, but normalized samples were not retained. Percentiles are suppressed until a new dated reproducible run is authorized and committed." + }, + "historicalSkillLegTransactions": [ + { + "manifestPath": "spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json", + "evidenceStatus": "historical_transaction_receipt_verified", + "label": "one successful historical Base Sepolia USDC transfer; the 2026-07-12 repository log labels it as the Skill leg", + "doesNotProve": [ + "current endpoint behavior", + "latency", + "Royalty-claim split correctness", + "Skill execution output" + ] + } + ] +} diff --git a/hf-space/gradio/data/fixture-integrity.json b/hf-space/gradio/data/fixture-integrity.json new file mode 100644 index 0000000..9ff7ddb --- /dev/null +++ b/hf-space/gradio/data/fixture-integrity.json @@ -0,0 +1,14 @@ +{ + "files": { + "evidence.json": { + "bytes": 960, + "sha256": "sha256:3d809a9382ea32c9929db4cbdc7c2fd9c3fc7bc0bebd5982ab19faf151106d20" + }, + "public-demo-allocation.json": { + "bytes": 11881, + "sha256": "sha256:3505befaf75c9c87a05b4f100cc1a34ce3f8f58a639a569faab9058018e57b7e" + } + }, + "generatedBy": "hf-space/scripts/package-space-fixtures.mjs", + "schemaVersion": 1 +} diff --git a/hf-space/gradio/data/public-demo-allocation.json b/hf-space/gradio/data/public-demo-allocation.json new file mode 100644 index 0000000..441047a --- /dev/null +++ b/hf-space/gradio/data/public-demo-allocation.json @@ -0,0 +1,353 @@ +{ + "corePath": "prototype/atomic-money.mjs", + "defaultScenarioId": "intra-org", + "evidenceStatus": "synthetic_accounting_illustration", + "fixtureSha256": "sha256:5618c710e772fcfb7b8390b87a4b44482bce144343d9bed787a4b71a34767fe8", + "generatedBy": "hf-space/scripts/generate-accounting-fixture.mjs", + "inputs": { + "common": { + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "refundReserveAtomic": "0" + }, + "external": { + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "protocolFeeBps": 250, + "refundReserveAtomic": "0", + "settlementCostAtomic": "0" + }, + "externalSkills": { + "derived-skill": { + "holders": [ + { + "bps": 10000, + "recipientId": "derived-creator" + } + ], + "inheritBps": 1500, + "parentIds": [ + "source-skill" + ] + }, + "source-skill": { + "holders": [ + { + "bps": 10000, + "recipientId": "source-creator" + } + ], + "inheritBps": 0, + "parentIds": [] + } + }, + "internal": { + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0" + } + }, + "scenarios": [ + { + "accountingLabel": "generated from prototype/atomic-money.mjs; synthetic accounting illustration", + "allocation": { + "awardCredit": { + "amountAtomic": "193750", + "recipientId": "employee-creator" + }, + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "invocationAwardAtomic": "193750", + "journalEntries": [ + { + "amountAtomic": "50000", + "category": "execution-cogs", + "creditAccountId": "provider:execution", + "debitAccountId": "employer:invocation-gross" + }, + { + "amountAtomic": "6250", + "category": "protocol-fee", + "creditAccountId": "protocol:treasury", + "debitAccountId": "employer:invocation-gross" + }, + { + "amountAtomic": "0", + "category": "refund-reserve", + "creditAccountId": "reserve:refund", + "debitAccountId": "employer:invocation-gross" + }, + { + "amountAtomic": "193750", + "category": "invocation-award", + "creditAccountId": "employee:employee-creator", + "debitAccountId": "employer:invocation-gross" + } + ], + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0" + }, + "allocationKind": "internal_invocation_award", + "conservationEquation": "grossAtomic = executionCostAtomic + protocolFeeAtomic + refundReserveAtomic + invocationAwardAtomic", + "executionCostAtomic": "50000", + "executionCostUsdc": "0.050000", + "expectedGrossDebitAccountId": "employer:invocation-gross", + "grossAtomic": "250000", + "grossUsdc": "0.250000", + "id": "intra-org", + "implementationNote": "Illustrative until the internal-award amendment becomes canonical.", + "invocationAwardAtomic": "193750", + "invocationAwardUsdc": "0.193750", + "journalEntryDisplayUsdc": [ + "0.050000", + "0.006250", + "0.000000", + "0.193750" + ], + "label": "Intra-org — employer-funded internal Invocation award", + "policy": "internal_award", + "protocolFeeAtomic": "6250", + "protocolFeeUsdc": "0.006250", + "refundReserveAtomic": "0", + "refundReserveUsdc": "0.000000", + "royaltyPoolAtomic": null, + "royaltyPoolUsdc": null, + "settlementCostAtomic": null, + "settlementCostUsdc": null, + "settlementNote": "Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.", + "status": "terminal_product_spike" + }, + { + "accountingLabel": "generated from prototype/atomic-money.mjs; synthetic accounting illustration", + "allocation": { + "allocationPolicy": "lrp-per-hop-v1", + "ancestorCredits": [ + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "credits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + }, + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "holderCredits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + } + ], + "journalEntries": [ + { + "amountAtomic": "50000", + "category": "execution-cogs", + "creditAccountId": "provider:execution", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "settlement-cogs", + "creditAccountId": "provider:settlement", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "6250", + "category": "protocol-fee", + "creditAccountId": "protocol:treasury", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "refund-reserve", + "creditAccountId": "reserve:refund", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "164688", + "category": "royalty-holder", + "creditAccountId": "royalty:derived-creator", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "29062", + "category": "royalty-ancestor", + "creditAccountId": "royalty:source-creator", + "debitAccountId": "wielder:external-gross" + } + ], + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0", + "royaltyPoolAtomic": "193750", + "settlementCostAtomic": "0" + }, + "allocationKind": "external_royalty_claim", + "conservationEquation": "grossAtomic = executionCostAtomic + settlementCostAtomic + protocolFeeAtomic + refundReserveAtomic + royaltyPoolAtomic", + "executionCostAtomic": "50000", + "executionCostUsdc": "0.050000", + "expectedGrossDebitAccountId": "wielder:external-gross", + "grossAtomic": "250000", + "grossUsdc": "0.250000", + "id": "education", + "implementationNote": "External Royalty-claim allocation generated by the shared accounting kernel.", + "invocationAwardAtomic": null, + "invocationAwardUsdc": null, + "journalEntryDisplayUsdc": [ + "0.050000", + "0.000000", + "0.006250", + "0.000000", + "0.164688", + "0.029062" + ], + "label": "Education — deferred after free re-authoring dominated the tested model", + "policy": "LRP", + "protocolFeeAtomic": "6250", + "protocolFeeUsdc": "0.006250", + "refundReserveAtomic": "0", + "refundReserveUsdc": "0.000000", + "royaltyPoolAtomic": "193750", + "royaltyPoolUsdc": "0.193750", + "settlementCostAtomic": "0", + "settlementCostUsdc": "0.000000", + "settlementNote": "Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.", + "status": "deferred" + }, + { + "accountingLabel": "generated from prototype/atomic-money.mjs; synthetic accounting illustration", + "allocation": { + "allocationPolicy": "lrp-per-hop-v1", + "ancestorCredits": [ + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "credits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + }, + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "holderCredits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + } + ], + "journalEntries": [ + { + "amountAtomic": "50000", + "category": "execution-cogs", + "creditAccountId": "provider:execution", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "settlement-cogs", + "creditAccountId": "provider:settlement", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "6250", + "category": "protocol-fee", + "creditAccountId": "protocol:treasury", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "refund-reserve", + "creditAccountId": "reserve:refund", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "164688", + "category": "royalty-holder", + "creditAccountId": "royalty:derived-creator", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "29062", + "category": "royalty-ancestor", + "creditAccountId": "royalty:source-creator", + "debitAccountId": "wielder:external-gross" + } + ], + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0", + "royaltyPoolAtomic": "193750", + "settlementCostAtomic": "0" + }, + "allocationKind": "external_royalty_claim", + "conservationEquation": "grossAtomic = executionCostAtomic + settlementCostAtomic + protocolFeeAtomic + refundReserveAtomic + royaltyPoolAtomic", + "executionCostAtomic": "50000", + "executionCostUsdc": "0.050000", + "expectedGrossDebitAccountId": "wielder:external-gross", + "grossAtomic": "250000", + "grossUsdc": "0.250000", + "id": "marketplace", + "implementationNote": "External Royalty-claim allocation generated by the shared accounting kernel.", + "invocationAwardAtomic": null, + "invocationAwardUsdc": null, + "journalEntryDisplayUsdc": [ + "0.050000", + "0.000000", + "0.006250", + "0.000000", + "0.164688", + "0.029062" + ], + "label": "Marketplace — Phase-3 optionality", + "policy": "LRP", + "protocolFeeAtomic": "6250", + "protocolFeeUsdc": "0.006250", + "refundReserveAtomic": "0", + "refundReserveUsdc": "0.000000", + "royaltyPoolAtomic": "193750", + "royaltyPoolUsdc": "0.193750", + "settlementCostAtomic": "0", + "settlementCostUsdc": "0.000000", + "settlementNote": "Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.", + "status": "phase_3_optionality" + } + ], + "schemaVersion": 1 +} diff --git a/hf-space/gradio/demo_logic.py b/hf-space/gradio/demo_logic.py new file mode 100644 index 0000000..4d8ef35 --- /dev/null +++ b/hf-space/gradio/demo_logic.py @@ -0,0 +1,245 @@ +"""Fixture-only accounting and strict live-402 validation for the Gradio Space.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +from typing import Any + +DATA_DIRECTORY = Path(__file__).resolve().parent / "data" +LIVE_ENDPOINT = "https://neverhandedover.com/api/invoke/optimizing-claude-code-prompts" +EXPECTED_PAY_TO = "0x25005dfac23d4bc45c801eaeb6c8b5a2bab0f189" +EXPECTED_ASSET = "0x036cbd53842c5426634e7929541ec2318f3dcf7e" +_ATOMIC_PATTERN = re.compile(r"^(0|[1-9][0-9]*)$") +_ADDRESS_PATTERN = re.compile(r"^0x[0-9a-fA-F]{40}$") +_SHA_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") +_FIXTURE_NAMES = {"evidence.json", "public-demo-allocation.json"} + + +def _integrity_error(file_name: str) -> ValueError: + return ValueError(f"packaged fixture integrity mismatch: {file_name}") + + +def _load_integrity_manifest() -> dict[str, Any]: + try: + raw = (DATA_DIRECTORY / "fixture-integrity.json").read_bytes() + value = json.loads(raw) + except (OSError, json.JSONDecodeError, UnicodeDecodeError) as error: + raise _integrity_error("fixture-integrity.json") from error + if not isinstance(value, dict) or set(value) != {"schemaVersion", "generatedBy", "files"}: + raise _integrity_error("fixture-integrity.json") + if value["schemaVersion"] != 1 or value["generatedBy"] != "hf-space/scripts/package-space-fixtures.mjs": + raise _integrity_error("fixture-integrity.json") + files = value["files"] + if not isinstance(files, dict) or set(files) != _FIXTURE_NAMES: + raise _integrity_error("fixture-integrity.json") + for file_name, metadata in files.items(): + if not isinstance(metadata, dict) or set(metadata) != {"sha256", "bytes"}: + raise _integrity_error("fixture-integrity.json") + if ( + not isinstance(metadata["bytes"], int) + or isinstance(metadata["bytes"], bool) + or metadata["bytes"] <= 0 + or not isinstance(metadata["sha256"], str) + or not _SHA_PATTERN.fullmatch(metadata["sha256"]) + ): + raise _integrity_error("fixture-integrity.json") + return value + + +def _load_verified_json(file_name: str) -> dict[str, Any]: + if file_name not in _FIXTURE_NAMES: + raise ValueError("unsupported packaged fixture") + manifest = _load_integrity_manifest() + try: + raw = (DATA_DIRECTORY / file_name).read_bytes() + except OSError as error: + raise _integrity_error(file_name) from error + expected = manifest["files"][file_name] + actual_hash = "sha256:" + hashlib.sha256(raw).hexdigest() + if len(raw) != expected["bytes"] or actual_hash != expected["sha256"]: + raise _integrity_error(file_name) + try: + value = json.loads(raw) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise ValueError(f"invalid packaged fixture JSON: {file_name}") from error + if not isinstance(value, dict): + raise ValueError(f"invalid packaged fixture shape: {file_name}") + return value + + +def load_allocation_fixture() -> dict[str, Any]: + fixture = _load_verified_json("public-demo-allocation.json") + if ( + fixture.get("schemaVersion") != 1 + or fixture.get("evidenceStatus") != "synthetic_accounting_illustration" + or fixture.get("defaultScenarioId") != "intra-org" + or not isinstance(fixture.get("scenarios"), list) + or len(fixture["scenarios"]) != 3 + ): + raise ValueError("invalid public demo accounting fixture") + expected_modes = { + "intra-org": ("internal_invocation_award", "terminal_product_spike"), + "education": ("external_royalty_claim", "deferred"), + "marketplace": ("external_royalty_claim", "phase_3_optionality"), + } + seen = set() + for scenario in fixture["scenarios"]: + if not isinstance(scenario, dict) or scenario.get("id") not in expected_modes: + raise ValueError("invalid public demo scenario") + scenario_id = scenario["id"] + if scenario_id in seen: + raise ValueError("duplicate public demo scenario") + seen.add(scenario_id) + expected_kind, expected_status = expected_modes[scenario_id] + if scenario.get("allocationKind") != expected_kind or scenario.get("status") != expected_status: + raise ValueError("invalid public demo scenario status") + if seen != set(expected_modes): + raise ValueError("missing public demo scenario") + return fixture + + +def load_evidence_fixture() -> dict[str, Any]: + evidence = _load_verified_json("evidence.json") + overhead = evidence.get("historicalOverhead") + transactions = evidence.get("historicalSkillLegTransactions") + if ( + evidence.get("schemaVersion") != 1 + or not isinstance(overhead, dict) + or overhead.get("evidenceStatus") != "historical_unreproducible" + or overhead.get("publicationAllowed") is not False + or not isinstance(transactions, list) + or len(transactions) != 1 + or not isinstance(transactions[0], dict) + or transactions[0].get("evidenceStatus") != "historical_transaction_receipt_verified" + ): + raise ValueError("invalid public demo evidence fixture") + return evidence + + +def _invalid_402(status: Any) -> dict[str, Any]: + return { + "live": False, + "status": status if isinstance(status, int) and not isinstance(status, bool) else None, + "offer": None, + "error": "live endpoint did not return a valid 402 offer", + } + + +def validate_live_402(status: Any, body: Any) -> dict[str, Any]: + """Return a bounded view only when the fixed endpoint returns a valid x402 v1 offer.""" + if status != 402 or isinstance(status, bool) or not isinstance(body, dict): + return _invalid_402(status) + accepts = body.get("accepts") + if body.get("x402Version") != 1 or not isinstance(accepts, list) or not accepts: + return _invalid_402(status) + offer = accepts[0] + if not isinstance(offer, dict): + return _invalid_402(status) + amount = offer.get("maxAmountRequired") + pay_to = offer.get("payTo") + asset = offer.get("asset") + if ( + offer.get("scheme") != "exact" + or offer.get("network") != "base-sepolia" + or not isinstance(amount, str) + or not _ATOMIC_PATTERN.fullmatch(amount) + or int(amount) <= 0 + or offer.get("resource") != LIVE_ENDPOINT + or not isinstance(pay_to, str) + or not _ADDRESS_PATTERN.fullmatch(pay_to) + or pay_to.lower() != EXPECTED_PAY_TO + or not isinstance(asset, str) + or not _ADDRESS_PATTERN.fullmatch(asset) + or asset.lower() != EXPECTED_ASSET + ): + return _invalid_402(status) + return { + "live": True, + "status": 402, + "offer": { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": amount, + "resource": LIVE_ENDPOINT, + "payTo": pay_to, + "asset": asset, + }, + "error": None, + } + + +def scenario_by_id(fixture: dict[str, Any], scenario_id: str | None = None) -> dict[str, Any]: + selected = scenario_id if scenario_id is not None else fixture.get("defaultScenarioId") + if not isinstance(selected, str): + raise ValueError("scenario identifier must be a string") + for scenario in fixture.get("scenarios", []): + if isinstance(scenario, dict) and scenario.get("id") == selected: + return scenario + raise ValueError(f"unknown public demo scenario: {selected}") + + +def _parse_atomic(value: Any, label: str) -> int: + if not isinstance(value, str) or not _ATOMIC_PATTERN.fullmatch(value): + raise ValueError(f"invalid atomic amount: {label}") + return int(value) + + +def render_allocation(fixture: dict[str, Any], scenario_id: str | None = None) -> dict[str, Any]: + scenario = scenario_by_id(fixture, scenario_id) + allocation = scenario.get("allocation") + entries = allocation.get("journalEntries") if isinstance(allocation, dict) else None + display_amounts = scenario.get("journalEntryDisplayUsdc") + expected_debit = scenario.get("expectedGrossDebitAccountId") + if ( + not isinstance(entries, list) + or not entries + or not isinstance(display_amounts, list) + or len(display_amounts) != len(entries) + or not isinstance(expected_debit, str) + or not expected_debit + ): + raise ValueError("invalid kernel journal fixture") + rows = [] + total = 0 + expected_entry_keys = {"category", "debitAccountId", "creditAccountId", "amountAtomic"} + for index, entry in enumerate(entries): + if not isinstance(entry, dict) or set(entry) != expected_entry_keys: + raise ValueError("invalid kernel journal entry") + if ( + entry.get("debitAccountId") != expected_debit + or not isinstance(entry.get("creditAccountId"), str) + or not entry["creditAccountId"] + or not isinstance(entry.get("category"), str) + or not entry["category"] + or not isinstance(display_amounts[index], str) + ): + raise ValueError("invalid kernel journal account") + total += _parse_atomic(entry.get("amountAtomic"), f"journalEntries[{index}]") + rows.append( + { + "category": entry["category"], + "debitAccountId": entry["debitAccountId"], + "creditAccountId": entry["creditAccountId"], + "amountAtomic": entry["amountAtomic"], + "amountUsdc": display_amounts[index], + } + ) + gross = _parse_atomic(scenario.get("grossAtomic"), "grossAtomic") + if total != gross: + raise ValueError("kernel journal does not conserve gross") + return { + "scenarioId": scenario["id"], + "label": scenario["label"], + "status": scenario["status"], + "policy": scenario["policy"], + "allocationKind": scenario["allocationKind"], + "accountingLabel": scenario["accountingLabel"], + "implementationNote": scenario["implementationNote"], + "settlementNote": scenario["settlementNote"], + "grossAtomic": scenario["grossAtomic"], + "grossUsdc": scenario["grossUsdc"], + "rows": rows, + } diff --git a/hf-space/gradio/requirements.txt b/hf-space/gradio/requirements.txt new file mode 100644 index 0000000..707dd4b --- /dev/null +++ b/hf-space/gradio/requirements.txt @@ -0,0 +1,2 @@ +gradio==6.20.0 +httpx==0.28.1 diff --git a/hf-space/gradio/test_app_smoke.py b/hf-space/gradio/test_app_smoke.py new file mode 100644 index 0000000..eb5168d --- /dev/null +++ b/hf-space/gradio/test_app_smoke.py @@ -0,0 +1,90 @@ +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +import gradio as gr +import httpx + +GRADIO_ROOT = Path(__file__).resolve().parent + +VALID_402 = { + "x402Version": 1, + "accepts": [ + { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "250000", + "resource": "https://neverhandedover.com/api/invoke/optimizing-claude-code-prompts", + "payTo": "0x25005dfac23d4bc45c801eaeb6c8b5a2bab0f189", + "asset": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + } + ], +} + + +class FakeResponse: + def __init__(self, status_code, body): + self.status_code = status_code + self._body = body + + def json(self): + return self._body + + +def import_app(): + sys.path.insert(0, str(GRADIO_ROOT)) + sys.modules.pop("demo_logic", None) + spec = importlib.util.spec_from_file_location("plan10_gradio_app", GRADIO_ROOT / "app.py") + module = importlib.util.module_from_spec(spec) + with patch.object(httpx, "post", side_effect=AssertionError("network during import")), patch.object( + gr.Blocks, + "launch", + side_effect=AssertionError("server launch during import"), + ): + spec.loader.exec_module(module) + return module + + +class AppSmokeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = import_app() + + def test_import_builds_blocks_without_network_or_launch(self): + self.assertIsInstance(self.app.demo, gr.Blocks) + config = self.app.demo.get_config_file() + self.assertTrue( + any(component.get("props", {}).get("value") == "intra-org" for component in config["components"]) + ) + self.assertTrue( + any(dependency.get("api_name") == "check_live_402" for dependency in config["dependencies"]) + ) + + def test_actual_wired_handler_distinguishes_live_non_402_and_failure(self): + with patch.object(httpx, "post", return_value=FakeResponse(402, VALID_402)) as request: + result = self.app.check_live_402() + self.assertTrue(result["live"]) + self.assertEqual(result["source"], "live_http_response") + self.assertFalse(request.call_args.kwargs["follow_redirects"]) + self.assertIsInstance(request.call_args.kwargs["timeout"], httpx.Timeout) + + with patch.object(httpx, "post", return_value=FakeResponse(200, {"ok": True})): + result = self.app.check_live_402() + self.assertFalse(result["live"]) + self.assertEqual(result["status"], 200) + + with patch.object(httpx, "post", side_effect=httpx.ConnectError("offline")): + result = self.app.check_live_402() + self.assertFalse(result["live"]) + self.assertEqual(result["source"], "live_request_failed_no_cache") + self.assertNotIn("offline", json_safe(result)) + + +def json_safe(value): + return str(value).lower() + + +if __name__ == "__main__": + unittest.main() diff --git a/hf-space/gradio/test_demo_logic.py b/hf-space/gradio/test_demo_logic.py new file mode 100644 index 0000000..ffb2f94 --- /dev/null +++ b/hf-space/gradio/test_demo_logic.py @@ -0,0 +1,144 @@ +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import sys +import tempfile +import unittest + +GRADIO_ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(GRADIO_ROOT)) + +from demo_logic import ( # noqa: E402 + load_allocation_fixture, + load_evidence_fixture, + render_allocation, + scenario_by_id, + validate_live_402, +) + + +VALID_402 = { + "x402Version": 1, + "accepts": [ + { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "250000", + "resource": "https://neverhandedover.com/api/invoke/optimizing-claude-code-prompts", + "payTo": "0x25005dfac23d4bc45c801eaeb6c8b5a2bab0f189", + "asset": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + } + ], +} + + +class Live402ValidationTests(unittest.TestCase): + def test_accepts_only_the_fixed_valid_402_offer(self): + result = validate_live_402(402, VALID_402) + self.assertTrue(result["live"]) + self.assertEqual(result["status"], 402) + self.assertEqual(result["offer"]["maxAmountRequired"], "250000") + + def test_rejects_non_402_and_malformed_offers(self): + cases = [ + (200, VALID_402), + (500, VALID_402), + (402, {**VALID_402, "x402Version": 2}), + (402, {**VALID_402, "accepts": []}), + ] + for field, value in [ + ("scheme", "upto"), + ("network", "base"), + ("maxAmountRequired", "0.25"), + ("maxAmountRequired", "01"), + ("resource", "https://attacker.example/invoke"), + ("payTo", ""), + ("asset", ""), + ]: + body = json.loads(json.dumps(VALID_402)) + body["accepts"][0][field] = value + cases.append((402, body)) + for status, body in cases: + with self.subTest(status=status, body=body): + result = validate_live_402(status, body) + self.assertFalse(result["live"]) + self.assertIn("valid 402", result["error"]) + + +class FixtureTests(unittest.TestCase): + def test_default_and_mode_statuses_are_fixture_controlled(self): + fixture = load_allocation_fixture() + self.assertEqual(fixture["defaultScenarioId"], "intra-org") + self.assertEqual(scenario_by_id(fixture)["id"], "intra-org") + self.assertEqual(scenario_by_id(fixture, "education")["status"], "deferred") + self.assertEqual( + scenario_by_id(fixture, "marketplace")["status"], + "phase_3_optionality", + ) + + def test_rendered_rows_are_kernel_journal_rows_and_conserve_gross(self): + fixture = load_allocation_fixture() + for scenario in fixture["scenarios"]: + model = render_allocation(fixture, scenario["id"]) + expected_entries = scenario["allocation"]["journalEntries"] + self.assertEqual(len(model["rows"]), len(expected_entries)) + for row, entry in zip(model["rows"], expected_entries, strict=True): + self.assertEqual(row["category"], entry["category"]) + self.assertEqual(row["debitAccountId"], entry["debitAccountId"]) + self.assertEqual(row["creditAccountId"], entry["creditAccountId"]) + self.assertEqual(row["amountAtomic"], entry["amountAtomic"]) + total = sum(int(entry["amountAtomic"]) for entry in expected_entries) + self.assertEqual(total, int(scenario["grossAtomic"])) + self.assertIn( + scenario["allocationKind"], + ("internal_invocation_award", "external_royalty_claim"), + ) + + def test_evidence_suppresses_unreproducible_percentiles(self): + evidence = load_evidence_fixture() + rendered = json.dumps(evidence).lower() + for percentile in ("p" + str(50), "p" + str(95)): + self.assertNotIn(percentile, rendered) + self.assertFalse(evidence["historicalOverhead"]["publicationAllowed"]) + self.assertEqual(len(evidence["historicalSkillLegTransactions"]), 1) + + def test_root_is_standalone_and_integrity_drift_fails_closed(self): + with tempfile.TemporaryDirectory() as temporary: + copied_root = Path(temporary) / "copied-gradio" + shutil.copytree(GRADIO_ROOT, copied_root) + module_path = copied_root / "demo_logic.py" + spec = importlib.util.spec_from_file_location("copied_demo_logic", module_path) + module = importlib.util.module_from_spec(spec) + original_cwd = os.getcwd() + try: + os.chdir(tempfile.gettempdir()) + spec.loader.exec_module(module) + copied_fixture = module.load_allocation_fixture() + finally: + os.chdir(original_cwd) + self.assertEqual(copied_fixture["defaultScenarioId"], "intra-org") + self.assertNotIn("shared", str(module.DATA_DIRECTORY)) + + evidence_path = copied_root / "data" / "evidence.json" + original = evidence_path.read_bytes() + evidence_path.write_bytes(original + b" ") + with self.assertRaisesRegex( + ValueError, + "packaged fixture integrity mismatch: evidence.json", + ): + module.load_evidence_fixture() + + def test_integrity_manifest_matches_current_raw_bytes(self): + data = GRADIO_ROOT / "data" + manifest = json.loads((data / "fixture-integrity.json").read_text()) + for name, expected in manifest["files"].items(): + raw = (data / name).read_bytes() + self.assertEqual(expected["bytes"], len(raw)) + self.assertEqual(expected["sha256"], "sha256:" + hashlib.sha256(raw).hexdigest()) + + +if __name__ == "__main__": + unittest.main() diff --git a/hf-space/scripts/generate-accounting-fixture.mjs b/hf-space/scripts/generate-accounting-fixture.mjs new file mode 100644 index 0000000..1e6d256 --- /dev/null +++ b/hf-space/scripts/generate-accounting-fixture.mjs @@ -0,0 +1,292 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + mkdir, + open, + readFile, + rename, + unlink, +} from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { + allocateExternalGross, + allocateInternalGross, + formatUsdc, +} from '../../prototype/atomic-money.mjs'; + +const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join(SCRIPT_DIRECTORY, '..', 'shared', 'public-demo-allocation.json'); + +const SCENARIOS = Object.freeze([ + Object.freeze({ + id: 'intra-org', + allocationKind: 'internal_invocation_award', + status: 'terminal_product_spike', + label: 'Intra-org — employer-funded internal Invocation award', + policy: 'internal_award', + }), + Object.freeze({ + id: 'education', + allocationKind: 'external_royalty_claim', + status: 'deferred', + label: 'Education — deferred after free re-authoring dominated the tested model', + policy: 'LRP', + }), + Object.freeze({ + id: 'marketplace', + allocationKind: 'external_royalty_claim', + status: 'phase_3_optionality', + label: 'Marketplace — Phase-3 optionality', + policy: 'LRP', + }), +]); +const COMMON_INPUT = Object.freeze({ + grossAtomic: 250000n, + executionCostAtomic: 50000n, + refundReserveAtomic: 0n, +}); +const EXTERNAL_INPUT = Object.freeze({ + ...COMMON_INPUT, + settlementCostAtomic: 0n, + protocolFeeBps: 250, +}); +const INTERNAL_INPUT = Object.freeze({ + ...COMMON_INPUT, + protocolFeeAtomic: 6250n, +}); +const EXTERNAL_SKILLS = Object.freeze({ + 'derived-skill': Object.freeze({ + parentIds: Object.freeze(['source-skill']), + inheritBps: 1500, + holders: Object.freeze([Object.freeze({ recipientId: 'derived-creator', bps: 10000 })]), + }), + 'source-skill': Object.freeze({ + parentIds: Object.freeze([]), + inheritBps: 0, + holders: Object.freeze([Object.freeze({ recipientId: 'source-creator', bps: 10000 })]), + }), +}); + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function jsonSafe(value) { + if (typeof value === 'bigint') return value.toString(); + if (Array.isArray(value)) return value.map(jsonSafe); + if (value && typeof value === 'object') { + const output = {}; + for (const [key, child] of Object.entries(value)) output[key] = jsonSafe(child); + return output; + } + if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return value; + throw new TypeError(`unsupported fixture value type: ${typeof value}`); +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]), + ); + } + return value; +} + +export function canonicalFixtureBytes(fixture) { + return `${JSON.stringify(canonicalize(fixture), null, 2)}\n`; +} + +function sameJournalEntry(candidate, actual) { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return false; + const expectedKeys = ['amountAtomic', 'category', 'creditAccountId', 'debitAccountId']; + const keys = Object.keys(candidate).sort(); + if (keys.length !== expectedKeys.length + || keys.some((key, index) => key !== expectedKeys[index])) return false; + return candidate.category === actual.category + && candidate.debitAccountId === actual.debitAccountId + && candidate.creditAccountId === actual.creditAccountId + && candidate.amountAtomic === actual.amountAtomic; +} + +export function serializeKernelAllocation( + allocation, + { expectedGrossDebitAccountId, journalEntries = allocation?.journalEntries } = {}, +) { + if (!allocation || typeof allocation !== 'object' || Array.isArray(allocation)) { + throw new TypeError('kernel allocation must be an object'); + } + if (!Array.isArray(allocation.journalEntries) || !Array.isArray(journalEntries)) { + throw new TypeError('kernel allocation must contain journalEntries'); + } + if (typeof expectedGrossDebitAccountId !== 'string' || !expectedGrossDebitAccountId) { + throw new TypeError('expected gross debit account must be non-empty'); + } + if (journalEntries.length !== allocation.journalEntries.length + || journalEntries.some((entry, index) => ( + !sameJournalEntry(entry, allocation.journalEntries[index]) + ))) { + throw new TypeError('supplied entries are not the kernel-returned journal'); + } + const gross = allocation.grossAtomic; + if (typeof gross !== 'bigint') throw new TypeError('kernel grossAtomic must be bigint'); + let total = 0n; + for (const entry of journalEntries) { + if (entry.debitAccountId !== expectedGrossDebitAccountId) { + throw new TypeError('kernel journal has an unexpected gross-source debit account'); + } + if (typeof entry.creditAccountId !== 'string' || !entry.creditAccountId + || typeof entry.category !== 'string' || !entry.category + || typeof entry.amountAtomic !== 'bigint' || entry.amountAtomic < 0n) { + throw new TypeError('kernel journal entry is malformed'); + } + total += entry.amountAtomic; + } + if (total !== gross) throw new TypeError('kernel journal does not conserve gross'); + return deepFreeze(jsonSafe(allocation)); +} + +function scenarioFromAllocation(definition, allocation, expectedGrossDebitAccountId) { + const serialized = serializeKernelAllocation(allocation, { expectedGrossDebitAccountId }); + const isInternal = definition.allocationKind === 'internal_invocation_award'; + const topLevel = { + id: definition.id, + label: definition.label, + status: definition.status, + policy: definition.policy, + allocationKind: definition.allocationKind, + accountingLabel: 'generated from prototype/atomic-money.mjs; synthetic accounting illustration', + implementationNote: isInternal + ? 'Illustrative until the internal-award amendment becomes canonical.' + : 'External Royalty-claim allocation generated by the shared accounting kernel.', + settlementNote: 'Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.', + expectedGrossDebitAccountId, + grossAtomic: serialized.grossAtomic, + grossUsdc: formatUsdc(allocation.grossAtomic), + executionCostAtomic: serialized.executionCostAtomic, + executionCostUsdc: formatUsdc(allocation.executionCostAtomic), + settlementCostAtomic: isInternal ? null : serialized.settlementCostAtomic, + settlementCostUsdc: isInternal ? null : formatUsdc(allocation.settlementCostAtomic), + protocolFeeAtomic: serialized.protocolFeeAtomic, + protocolFeeUsdc: formatUsdc(allocation.protocolFeeAtomic), + refundReserveAtomic: serialized.refundReserveAtomic, + refundReserveUsdc: formatUsdc(allocation.refundReserveAtomic), + royaltyPoolAtomic: isInternal ? null : serialized.royaltyPoolAtomic, + royaltyPoolUsdc: isInternal ? null : formatUsdc(allocation.royaltyPoolAtomic), + invocationAwardAtomic: isInternal ? serialized.invocationAwardAtomic : null, + invocationAwardUsdc: isInternal ? formatUsdc(allocation.invocationAwardAtomic) : null, + journalEntryDisplayUsdc: allocation.journalEntries.map((entry) => formatUsdc(entry.amountAtomic)), + conservationEquation: isInternal + ? 'grossAtomic = executionCostAtomic + protocolFeeAtomic + refundReserveAtomic + invocationAwardAtomic' + : 'grossAtomic = executionCostAtomic + settlementCostAtomic + protocolFeeAtomic + refundReserveAtomic + royaltyPoolAtomic', + allocation: serialized, + }; + return deepFreeze(topLevel); +} + +function externalAllocation() { + return allocateExternalGross({ + ...EXTERNAL_INPUT, + leafSkillId: 'derived-skill', + skills: EXTERNAL_SKILLS, + }); +} + +export function buildFixture() { + const quotedExternal = externalAllocation(); + if (quotedExternal.protocolFeeAtomic !== 6250n) { + throw new Error('shared kernel did not derive the expected protocol fee'); + } + const internal = allocateInternalGross({ + ...INTERNAL_INPUT, + protocolFeeAtomic: quotedExternal.protocolFeeAtomic, + recipientId: 'employee-creator', + }); + const education = externalAllocation(); + const marketplace = externalAllocation(); + for (const allocation of [education, marketplace]) { + if (allocation.protocolFeeAtomic !== 6250n || allocation.royaltyPoolAtomic !== 193750n) { + throw new Error('shared external kernel returned unexpected derived amounts'); + } + } + if (internal.invocationAwardAtomic !== 193750n) { + throw new Error('shared internal kernel returned an unexpected Invocation award'); + } + + const withoutHash = { + schemaVersion: 1, + evidenceStatus: 'synthetic_accounting_illustration', + generatedBy: 'hf-space/scripts/generate-accounting-fixture.mjs', + corePath: 'prototype/atomic-money.mjs', + defaultScenarioId: 'intra-org', + inputs: jsonSafe({ + common: COMMON_INPUT, + external: EXTERNAL_INPUT, + internal: INTERNAL_INPUT, + externalSkills: EXTERNAL_SKILLS, + }), + scenarios: [ + scenarioFromAllocation(SCENARIOS[0], internal, 'employer:invocation-gross'), + scenarioFromAllocation(SCENARIOS[1], education, 'wielder:external-gross'), + scenarioFromAllocation(SCENARIOS[2], marketplace, 'wielder:external-gross'), + ], + }; + const fixtureSha256 = `sha256:${createHash('sha256') + .update(canonicalFixtureBytes(withoutHash)) + .digest('hex')}`; + return deepFreeze({ ...withoutHash, fixtureSha256 }); +} + +async function writeFully(fileHandle, bytes) { + let offset = 0; + while (offset < bytes.length) { + const { bytesWritten } = await fileHandle.write(bytes, offset, bytes.length - offset, offset); + if (bytesWritten <= 0) throw new Error('short write while generating fixture'); + offset += bytesWritten; + } +} + +async function atomicWrite(path, bytes) { + await mkdir(dirname(path), { recursive: true }); + const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`; + let handle; + try { + handle = await open(temporaryPath, 'wx', 0o600); + await writeFully(handle, bytes); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporaryPath, path); + } catch (error) { + if (handle) await handle.close().catch(() => {}); + await unlink(temporaryPath).catch(() => {}); + throw error; + } +} + +export async function main(argv = process.argv.slice(2)) { + if (argv.length !== 1 || !['--write', '--check'].includes(argv[0])) { + throw new TypeError('usage: generate-accounting-fixture.mjs --write|--check'); + } + const expected = Buffer.from(canonicalFixtureBytes(buildFixture()), 'utf8'); + if (argv[0] === '--write') { + await atomicWrite(FIXTURE_PATH, expected); + return; + } + const actual = await readFile(FIXTURE_PATH).catch(() => null); + if (!actual || !actual.equals(expected)) { + throw new Error('public demo accounting fixture drift'); + } +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/hf-space/scripts/package-space-fixtures.mjs b/hf-space/scripts/package-space-fixtures.mjs new file mode 100644 index 0000000..de20381 --- /dev/null +++ b/hf-space/scripts/package-space-fixtures.mjs @@ -0,0 +1,149 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + mkdir, + open, + readFile, + rename, + unlink, +} from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const HF_SPACE_ROOT = join(SCRIPT_DIRECTORY, '..'); +const PRODUCTION_CONFIGURATION = Object.freeze({ + canonicalRoot: join(HF_SPACE_ROOT, 'shared'), + spaceRoots: Object.freeze([ + join(HF_SPACE_ROOT, 'gradio'), + join(HF_SPACE_ROOT, 'static'), + ]), +}); +const FIXTURE_NAMES = Object.freeze(['evidence.json', 'public-demo-allocation.json']); + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]), + ); + } + return value; +} + +export function canonicalIntegrityBytes(value) { + return `${JSON.stringify(canonicalize(value), null, 2)}\n`; +} + +function requireConfiguration({ canonicalRoot, spaceRoots }) { + if (typeof canonicalRoot !== 'string' || canonicalRoot.length === 0) { + throw new TypeError('canonicalRoot must be a non-empty path'); + } + if (!Array.isArray(spaceRoots) || spaceRoots.length !== 2 + || spaceRoots.some((root) => typeof root !== 'string' || root.length === 0) + || new Set(spaceRoots).size !== spaceRoots.length) { + throw new TypeError('spaceRoots must contain two distinct paths'); + } + return { canonicalRoot, spaceRoots: [...spaceRoots] }; +} + +export async function buildPackagePlan(configuration = PRODUCTION_CONFIGURATION) { + const { canonicalRoot, spaceRoots } = requireConfiguration(configuration); + const canonicalFiles = Object.create(null); + for (const fileName of FIXTURE_NAMES) { + const bytes = await readFile(join(canonicalRoot, fileName)); + if (bytes.length === 0 || bytes[bytes.length - 1] !== 0x0a) { + throw new Error(`canonical fixture must end with one newline: ${fileName}`); + } + if (bytes.length > 1 && bytes[bytes.length - 2] === 0x0a) { + throw new Error(`canonical fixture must end with one newline: ${fileName}`); + } + canonicalFiles[fileName] = bytes; + } + const integrity = { + schemaVersion: 1, + generatedBy: 'hf-space/scripts/package-space-fixtures.mjs', + files: Object.fromEntries(FIXTURE_NAMES.map((fileName) => [ + fileName, + { + sha256: `sha256:${createHash('sha256').update(canonicalFiles[fileName]).digest('hex')}`, + bytes: canonicalFiles[fileName].length, + }, + ])), + }; + const integrityBytes = Buffer.from(canonicalIntegrityBytes(integrity), 'utf8'); + const plan = []; + for (const spaceRoot of spaceRoots) { + const rootName = basename(spaceRoot); + for (const fileName of FIXTURE_NAMES) { + plan.push(Object.freeze({ + spaceRoot, + targetPath: join(spaceRoot, 'data', fileName), + relativePath: `${rootName}/data/${fileName}`, + bytes: canonicalFiles[fileName], + })); + } + plan.push(Object.freeze({ + spaceRoot, + targetPath: join(spaceRoot, 'data', 'fixture-integrity.json'), + relativePath: `${rootName}/data/fixture-integrity.json`, + bytes: integrityBytes, + })); + } + return Object.freeze(plan); +} + +async function writeFully(handle, bytes) { + let offset = 0; + while (offset < bytes.length) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, offset); + if (bytesWritten <= 0) throw new Error('short write while packaging fixtures'); + offset += bytesWritten; + } +} + +async function writePlan(plan) { + const prepared = []; + try { + for (const item of plan) { + await mkdir(dirname(item.targetPath), { recursive: true }); + const temporaryPath = `${item.targetPath}.tmp-${process.pid}-${randomUUID()}`; + const handle = await open(temporaryPath, 'wx', 0o600); + try { + await writeFully(handle, item.bytes); + await handle.sync(); + } finally { + await handle.close(); + } + prepared.push({ temporaryPath, targetPath: item.targetPath }); + } + for (const item of prepared) await rename(item.temporaryPath, item.targetPath); + } catch (error) { + await Promise.all(prepared.map(({ temporaryPath }) => unlink(temporaryPath).catch(() => {}))); + throw error; + } +} + +async function checkPlan(plan) { + for (const item of plan) { + const actual = await readFile(item.targetPath).catch(() => null); + if (!actual || !actual.equals(item.bytes)) { + throw new Error(`standalone Space fixture drift: ${item.relativePath}`); + } + } +} + +export async function main(argv = process.argv.slice(2), configuration = PRODUCTION_CONFIGURATION) { + if (argv.length !== 1 || !['--write', '--check'].includes(argv[0])) { + throw new TypeError('usage: package-space-fixtures.mjs --write|--check'); + } + const plan = await buildPackagePlan(configuration); + if (argv[0] === '--write') await writePlan(plan); + else await checkPlan(plan); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/hf-space/scripts/test-generate-accounting-fixture.mjs b/hf-space/scripts/test-generate-accounting-fixture.mjs new file mode 100644 index 0000000..81d4f89 --- /dev/null +++ b/hf-space/scripts/test-generate-accounting-fixture.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + allocateExternalGross, + allocateInternalGross, +} from '../../prototype/atomic-money.mjs'; +import { + buildFixture, + canonicalFixtureBytes, + serializeKernelAllocation, +} from './generate-accounting-fixture.mjs'; + +const EXTERNAL_SKILLS = { + 'derived-skill': { + parentIds: ['source-skill'], + inheritBps: 1500, + holders: [{ recipientId: 'derived-creator', bps: 10000 }], + }, + 'source-skill': { + parentIds: [], + inheritBps: 0, + holders: [{ recipientId: 'source-creator', bps: 10000 }], + }, +}; + +function jsonSafe(value) { + if (typeof value === 'bigint') return value.toString(); + if (Array.isArray(value)) return value.map(jsonSafe); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, jsonSafe(child)])); + } + return value; +} + +test('fixture derives all three scenarios from the shared accounting kernel', () => { + const fixture = buildFixture(); + assert.equal(fixture.defaultScenarioId, 'intra-org'); + assert.equal(fixture.scenarios.length, 3); + assert.deepEqual(fixture.scenarios.map((scenario) => scenario.id), [ + 'intra-org', + 'education', + 'marketplace', + ]); + + const internal = fixture.scenarios[0]; + assert.equal(internal.allocationKind, 'internal_invocation_award'); + assert.equal(internal.status, 'terminal_product_spike'); + assert.equal(internal.policy, 'internal_award'); + assert.equal(internal.protocolFeeAtomic, '6250'); + assert.equal(internal.invocationAwardAtomic, '193750'); + assert.equal(internal.royaltyPoolAtomic, null); + assert.equal(internal.allocation.awardCredit.recipientId, 'employee-creator'); + assert.ok(!JSON.stringify(internal).includes('employer:self-credit')); + + for (const external of fixture.scenarios.slice(1)) { + assert.equal(external.allocationKind, 'external_royalty_claim'); + assert.equal(external.policy, 'LRP'); + assert.equal(external.protocolFeeAtomic, '6250'); + assert.equal(external.royaltyPoolAtomic, '193750'); + assert.equal(external.invocationAwardAtomic, null); + } + assert.equal(fixture.scenarios[1].status, 'deferred'); + assert.equal(fixture.scenarios[2].status, 'phase_3_optionality'); +}); + +test('serialized journal entries are exact kernel results and conserve gross', () => { + const fixture = buildFixture(); + const externalKernel = allocateExternalGross({ + grossAtomic: 250000n, + executionCostAtomic: 50000n, + settlementCostAtomic: 0n, + protocolFeeBps: 250, + refundReserveAtomic: 0n, + leafSkillId: 'derived-skill', + skills: EXTERNAL_SKILLS, + }); + const internalKernel = allocateInternalGross({ + grossAtomic: 250000n, + executionCostAtomic: 50000n, + protocolFeeAtomic: externalKernel.protocolFeeAtomic, + refundReserveAtomic: 0n, + recipientId: 'employee-creator', + }); + + assert.deepEqual(fixture.scenarios[0].allocation.journalEntries, jsonSafe(internalKernel.journalEntries)); + for (const scenario of fixture.scenarios.slice(1)) { + assert.deepEqual(scenario.allocation.journalEntries, jsonSafe(externalKernel.journalEntries)); + } + + for (const scenario of fixture.scenarios) { + assert.equal(scenario.grossAtomic, '250000'); + assert.equal(scenario.protocolFeeAtomic, '6250'); + assert.equal(scenario.journalEntryDisplayUsdc.length, scenario.allocation.journalEntries.length); + const total = scenario.allocation.journalEntries.reduce((sum, entry) => { + assert.match(entry.amountAtomic, /^(0|[1-9][0-9]*)$/); + assert.equal(entry.debitAccountId, scenario.expectedGrossDebitAccountId); + assert.equal(typeof entry.creditAccountId, 'string'); + return sum + BigInt(entry.amountAtomic); + }, 0n); + assert.equal(total, BigInt(scenario.grossAtomic)); + } +}); + +test('generator rejects a supplied journal entry not returned by the kernel', () => { + const kernel = allocateInternalGross({ + grossAtomic: 250000n, + executionCostAtomic: 50000n, + protocolFeeAtomic: 6250n, + refundReserveAtomic: 0n, + recipientId: 'employee-creator', + }); + const altered = kernel.journalEntries.map((entry) => ({ ...entry })); + altered[0].creditAccountId = 'attacker:substitute'; + assert.throws( + () => serializeKernelAllocation(kernel, { + expectedGrossDebitAccountId: 'employer:invocation-gross', + journalEntries: altered, + }), + /not the kernel-returned journal/i, + ); +}); + +test('fixture hash covers canonical fixture bytes with the hash field omitted', () => { + const fixture = buildFixture(); + const { fixtureSha256, ...withoutHash } = fixture; + const expected = `sha256:${createHash('sha256').update(canonicalFixtureBytes(withoutHash)).digest('hex')}`; + assert.equal(fixtureSha256, expected); + assert.ok(canonicalFixtureBytes(fixture).endsWith('\n')); + assert.deepEqual(buildFixture(), fixture); +}); diff --git a/hf-space/scripts/test-package-space-fixtures.mjs b/hf-space/scripts/test-package-space-fixtures.mjs new file mode 100644 index 0000000..05c9c4e --- /dev/null +++ b/hf-space/scripts/test-package-space-fixtures.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + mkdtemp, + mkdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import test from 'node:test'; + +import { + buildPackagePlan, + canonicalIntegrityBytes, + main, +} from './package-space-fixtures.mjs'; + +const productionShared = new URL('../shared/', import.meta.url); + +async function temporaryPackageRoots() { + const root = await mkdtemp(join(tmpdir(), 'space-package-test-')); + const canonicalRoot = join(root, 'shared'); + const spaceRoots = [join(root, 'gradio'), join(root, 'static')]; + await mkdir(canonicalRoot, { recursive: true }); + for (const fileName of ['public-demo-allocation.json', 'evidence.json']) { + await writeFile( + join(canonicalRoot, fileName), + await readFile(new URL(fileName, productionShared)), + ); + } + return { root, canonicalRoot, spaceRoots }; +} + +test('packager writes byte-identical fixtures and deterministic integrity manifests', async (t) => { + const temporary = await temporaryPackageRoots(); + t.after(() => rm(temporary.root, { recursive: true, force: true })); + const configuration = { + canonicalRoot: temporary.canonicalRoot, + spaceRoots: temporary.spaceRoots, + }; + const plan = await buildPackagePlan(configuration); + assert.equal(plan.length, 6); + assert.deepEqual( + plan.map((item) => item.relativePath).sort(), + [ + 'gradio/data/evidence.json', + 'gradio/data/fixture-integrity.json', + 'gradio/data/public-demo-allocation.json', + 'static/data/evidence.json', + 'static/data/fixture-integrity.json', + 'static/data/public-demo-allocation.json', + ], + ); + + await main(['--write'], configuration); + await main(['--check'], configuration); + + for (const spaceRoot of temporary.spaceRoots) { + for (const fileName of ['public-demo-allocation.json', 'evidence.json']) { + assert.deepEqual( + await readFile(join(spaceRoot, 'data', fileName)), + await readFile(join(temporary.canonicalRoot, fileName)), + ); + } + } + const gradioManifest = await readFile(join(temporary.spaceRoots[0], 'data', 'fixture-integrity.json')); + const staticManifest = await readFile(join(temporary.spaceRoots[1], 'data', 'fixture-integrity.json')); + assert.deepEqual(gradioManifest, staticManifest); + const parsed = JSON.parse(gradioManifest); + assert.deepEqual(Object.keys(parsed.files), ['evidence.json', 'public-demo-allocation.json']); + for (const [fileName, metadata] of Object.entries(parsed.files)) { + const bytes = await readFile(join(temporary.canonicalRoot, fileName)); + assert.equal(metadata.bytes, bytes.length); + assert.equal( + metadata.sha256, + `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + ); + } + assert.deepEqual(Buffer.from(canonicalIntegrityBytes(parsed)), gradioManifest); +}); + +test('check mode is cwd-independent and fails on fixture or manifest drift', async (t) => { + const temporary = await temporaryPackageRoots(); + t.after(() => rm(temporary.root, { recursive: true, force: true })); + const configuration = { + canonicalRoot: temporary.canonicalRoot, + spaceRoots: temporary.spaceRoots, + }; + await main(['--write'], configuration); + const originalCwd = process.cwd(); + try { + process.chdir(tmpdir()); + await main(['--check'], configuration); + } finally { + process.chdir(originalCwd); + } + + const allocationPath = join(temporary.spaceRoots[0], 'data', 'public-demo-allocation.json'); + const originalAllocation = await readFile(allocationPath); + await writeFile(allocationPath, Buffer.concat([originalAllocation, Buffer.from(' ')])); + await assert.rejects(() => main(['--check'], configuration), /standalone Space fixture drift/); + await writeFile(allocationPath, originalAllocation); + + const manifestPath = join(temporary.spaceRoots[1], 'data', 'fixture-integrity.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.files['evidence.json'].sha256 = `sha256:${'0'.repeat(64)}`; + await writeFile(manifestPath, canonicalIntegrityBytes(manifest)); + await assert.rejects(() => main(['--check'], configuration), /standalone Space fixture drift/); +}); + +test('packaging plan has no sibling or repository-relative runtime dependency', async (t) => { + const temporary = await temporaryPackageRoots(); + t.after(() => rm(temporary.root, { recursive: true, force: true })); + const plan = await buildPackagePlan({ + canonicalRoot: temporary.canonicalRoot, + spaceRoots: temporary.spaceRoots, + }); + for (const item of plan) { + assert.ok(['gradio', 'static'].includes(basename(item.spaceRoot))); + assert.doesNotMatch(item.relativePath, /\.\.|shared|hf-space/i); + assert.doesNotMatch(item.bytes.toString('utf8'), /\.\.\/shared|hf-space\/(gradio|static)/i); + } +}); diff --git a/hf-space/scripts/verify-local-scope.mjs b/hf-space/scripts/verify-local-scope.mjs new file mode 100644 index 0000000..f1b6bfa --- /dev/null +++ b/hf-space/scripts/verify-local-scope.mjs @@ -0,0 +1,115 @@ +import { promisify } from 'node:util'; +import { execFile } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const execFileAsync = promisify(execFile); +const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const REPOSITORY_ROOT = join(SCRIPT_DIRECTORY, '..', '..'); + +export const HF_SPACE_ALLOWED_PATHS = Object.freeze([ + 'hf-space/scripts/generate-accounting-fixture.mjs', + 'hf-space/scripts/test-generate-accounting-fixture.mjs', + 'hf-space/scripts/package-space-fixtures.mjs', + 'hf-space/scripts/test-package-space-fixtures.mjs', + 'hf-space/scripts/verify-local-scope.mjs', + 'hf-space/shared/public-demo-allocation.json', + 'hf-space/shared/evidence.json', + 'hf-space/gradio/demo_logic.py', + 'hf-space/gradio/test_demo_logic.py', + 'hf-space/gradio/test_app_smoke.py', + 'hf-space/gradio/app.py', + 'hf-space/gradio/README.md', + 'hf-space/gradio/requirements.txt', + 'hf-space/gradio/data/public-demo-allocation.json', + 'hf-space/gradio/data/evidence.json', + 'hf-space/gradio/data/fixture-integrity.json', + 'hf-space/static/demo-logic.mjs', + 'hf-space/static/test-demo-logic.mjs', + 'hf-space/static/test-index-smoke.mjs', + 'hf-space/static/index.html', + 'hf-space/static/README.md', + 'hf-space/static/package.json', + 'hf-space/static/package-lock.json', + 'hf-space/static/data/public-demo-allocation.json', + 'hf-space/static/data/evidence.json', + 'hf-space/static/data/fixture-integrity.json', +].sort()); + +function requireExactScope(paths, mode) { + const sorted = [...paths].sort(); + const duplicates = sorted.filter((path, index) => index > 0 && path === sorted[index - 1]); + if (duplicates.length > 0) throw new Error(`${mode} scope contains duplicate paths`); + const allowed = new Set(HF_SPACE_ALLOWED_PATHS); + const actual = new Set(sorted); + const extra = sorted.filter((path) => !allowed.has(path)); + const missing = HF_SPACE_ALLOWED_PATHS.filter((path) => !actual.has(path)); + if (extra.length > 0 || missing.length > 0) { + throw new Error( + `${mode} hf-space scope mismatch; extra=[${extra.join(', ')}] missing=[${missing.join(', ')}]`, + ); + } + return Object.freeze(sorted); +} + +function parseStatus(stdout) { + if (!stdout) return []; + const lines = stdout.endsWith('\n') ? stdout.slice(0, -1).split('\n') : stdout.split('\n'); + return lines.map((line) => { + if (line.length < 4 || line[2] !== ' ') throw new Error('malformed git status line'); + const status = line.slice(0, 2); + const path = line.slice(3); + if (/[RC]/.test(status) || path.includes(' -> ')) { + throw new Error('rename/copy records are not allowed in hf-space scope'); + } + if (!path.startsWith('hf-space/') || path.endsWith('/') || path.startsWith('"')) { + throw new Error(`unsupported hf-space status path: ${path}`); + } + return path; + }); +} + +async function normalPaths() { + const { stdout } = await execFileAsync( + 'git', + ['status', '--porcelain=v1', '--untracked-files=all', '--', 'hf-space'], + { cwd: REPOSITORY_ROOT, encoding: 'utf8', maxBuffer: 1_000_000 }, + ); + return parseStatus(stdout); +} + +async function cachedPaths() { + const { stdout } = await execFileAsync( + 'git', + ['diff', '--cached', '--name-only', '-z', '--', 'hf-space'], + { cwd: REPOSITORY_ROOT, encoding: 'utf8', maxBuffer: 1_000_000 }, + ); + if (!stdout) return []; + if (!stdout.endsWith('\0')) throw new Error('malformed cached path output'); + const paths = stdout.slice(0, -1).split('\0'); + for (const path of paths) { + if (!path.startsWith('hf-space/') || path.endsWith('/')) { + throw new Error(`unsupported cached hf-space path: ${path}`); + } + } + return paths; +} + +export async function main(argv = process.argv.slice(2)) { + if (argv.length > 1 || (argv.length === 1 && argv[0] !== '--cached')) { + throw new TypeError('usage: verify-local-scope.mjs [--cached]'); + } + const mode = argv[0] === '--cached' ? 'cached' : 'working-tree'; + const paths = requireExactScope( + mode === 'cached' ? await cachedPaths() : await normalPaths(), + mode, + ); + process.stdout.write(`${paths.join('\n')}\n`); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/hf-space/shared/evidence.json b/hf-space/shared/evidence.json new file mode 100644 index 0000000..ae3f1be --- /dev/null +++ b/hf-space/shared/evidence.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "historicalOverhead": { + "manifestPath": "spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json", + "evidenceStatus": "historical_unreproducible", + "publicationAllowed": false, + "publicText": "A historical 2026-07-15 inference-route run reported latency percentiles, but normalized samples were not retained. Percentiles are suppressed until a new dated reproducible run is authorized and committed." + }, + "historicalSkillLegTransactions": [ + { + "manifestPath": "spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json", + "evidenceStatus": "historical_transaction_receipt_verified", + "label": "one successful historical Base Sepolia USDC transfer; the 2026-07-12 repository log labels it as the Skill leg", + "doesNotProve": [ + "current endpoint behavior", + "latency", + "Royalty-claim split correctness", + "Skill execution output" + ] + } + ] +} diff --git a/hf-space/shared/public-demo-allocation.json b/hf-space/shared/public-demo-allocation.json new file mode 100644 index 0000000..441047a --- /dev/null +++ b/hf-space/shared/public-demo-allocation.json @@ -0,0 +1,353 @@ +{ + "corePath": "prototype/atomic-money.mjs", + "defaultScenarioId": "intra-org", + "evidenceStatus": "synthetic_accounting_illustration", + "fixtureSha256": "sha256:5618c710e772fcfb7b8390b87a4b44482bce144343d9bed787a4b71a34767fe8", + "generatedBy": "hf-space/scripts/generate-accounting-fixture.mjs", + "inputs": { + "common": { + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "refundReserveAtomic": "0" + }, + "external": { + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "protocolFeeBps": 250, + "refundReserveAtomic": "0", + "settlementCostAtomic": "0" + }, + "externalSkills": { + "derived-skill": { + "holders": [ + { + "bps": 10000, + "recipientId": "derived-creator" + } + ], + "inheritBps": 1500, + "parentIds": [ + "source-skill" + ] + }, + "source-skill": { + "holders": [ + { + "bps": 10000, + "recipientId": "source-creator" + } + ], + "inheritBps": 0, + "parentIds": [] + } + }, + "internal": { + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0" + } + }, + "scenarios": [ + { + "accountingLabel": "generated from prototype/atomic-money.mjs; synthetic accounting illustration", + "allocation": { + "awardCredit": { + "amountAtomic": "193750", + "recipientId": "employee-creator" + }, + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "invocationAwardAtomic": "193750", + "journalEntries": [ + { + "amountAtomic": "50000", + "category": "execution-cogs", + "creditAccountId": "provider:execution", + "debitAccountId": "employer:invocation-gross" + }, + { + "amountAtomic": "6250", + "category": "protocol-fee", + "creditAccountId": "protocol:treasury", + "debitAccountId": "employer:invocation-gross" + }, + { + "amountAtomic": "0", + "category": "refund-reserve", + "creditAccountId": "reserve:refund", + "debitAccountId": "employer:invocation-gross" + }, + { + "amountAtomic": "193750", + "category": "invocation-award", + "creditAccountId": "employee:employee-creator", + "debitAccountId": "employer:invocation-gross" + } + ], + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0" + }, + "allocationKind": "internal_invocation_award", + "conservationEquation": "grossAtomic = executionCostAtomic + protocolFeeAtomic + refundReserveAtomic + invocationAwardAtomic", + "executionCostAtomic": "50000", + "executionCostUsdc": "0.050000", + "expectedGrossDebitAccountId": "employer:invocation-gross", + "grossAtomic": "250000", + "grossUsdc": "0.250000", + "id": "intra-org", + "implementationNote": "Illustrative until the internal-award amendment becomes canonical.", + "invocationAwardAtomic": "193750", + "invocationAwardUsdc": "0.193750", + "journalEntryDisplayUsdc": [ + "0.050000", + "0.006250", + "0.000000", + "0.193750" + ], + "label": "Intra-org — employer-funded internal Invocation award", + "policy": "internal_award", + "protocolFeeAtomic": "6250", + "protocolFeeUsdc": "0.006250", + "refundReserveAtomic": "0", + "refundReserveUsdc": "0.000000", + "royaltyPoolAtomic": null, + "royaltyPoolUsdc": null, + "settlementCostAtomic": null, + "settlementCostUsdc": null, + "settlementNote": "Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.", + "status": "terminal_product_spike" + }, + { + "accountingLabel": "generated from prototype/atomic-money.mjs; synthetic accounting illustration", + "allocation": { + "allocationPolicy": "lrp-per-hop-v1", + "ancestorCredits": [ + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "credits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + }, + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "holderCredits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + } + ], + "journalEntries": [ + { + "amountAtomic": "50000", + "category": "execution-cogs", + "creditAccountId": "provider:execution", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "settlement-cogs", + "creditAccountId": "provider:settlement", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "6250", + "category": "protocol-fee", + "creditAccountId": "protocol:treasury", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "refund-reserve", + "creditAccountId": "reserve:refund", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "164688", + "category": "royalty-holder", + "creditAccountId": "royalty:derived-creator", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "29062", + "category": "royalty-ancestor", + "creditAccountId": "royalty:source-creator", + "debitAccountId": "wielder:external-gross" + } + ], + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0", + "royaltyPoolAtomic": "193750", + "settlementCostAtomic": "0" + }, + "allocationKind": "external_royalty_claim", + "conservationEquation": "grossAtomic = executionCostAtomic + settlementCostAtomic + protocolFeeAtomic + refundReserveAtomic + royaltyPoolAtomic", + "executionCostAtomic": "50000", + "executionCostUsdc": "0.050000", + "expectedGrossDebitAccountId": "wielder:external-gross", + "grossAtomic": "250000", + "grossUsdc": "0.250000", + "id": "education", + "implementationNote": "External Royalty-claim allocation generated by the shared accounting kernel.", + "invocationAwardAtomic": null, + "invocationAwardUsdc": null, + "journalEntryDisplayUsdc": [ + "0.050000", + "0.000000", + "0.006250", + "0.000000", + "0.164688", + "0.029062" + ], + "label": "Education — deferred after free re-authoring dominated the tested model", + "policy": "LRP", + "protocolFeeAtomic": "6250", + "protocolFeeUsdc": "0.006250", + "refundReserveAtomic": "0", + "refundReserveUsdc": "0.000000", + "royaltyPoolAtomic": "193750", + "royaltyPoolUsdc": "0.193750", + "settlementCostAtomic": "0", + "settlementCostUsdc": "0.000000", + "settlementNote": "Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.", + "status": "deferred" + }, + { + "accountingLabel": "generated from prototype/atomic-money.mjs; synthetic accounting illustration", + "allocation": { + "allocationPolicy": "lrp-per-hop-v1", + "ancestorCredits": [ + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "credits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + }, + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "holderCredits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + } + ], + "journalEntries": [ + { + "amountAtomic": "50000", + "category": "execution-cogs", + "creditAccountId": "provider:execution", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "settlement-cogs", + "creditAccountId": "provider:settlement", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "6250", + "category": "protocol-fee", + "creditAccountId": "protocol:treasury", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "refund-reserve", + "creditAccountId": "reserve:refund", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "164688", + "category": "royalty-holder", + "creditAccountId": "royalty:derived-creator", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "29062", + "category": "royalty-ancestor", + "creditAccountId": "royalty:source-creator", + "debitAccountId": "wielder:external-gross" + } + ], + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0", + "royaltyPoolAtomic": "193750", + "settlementCostAtomic": "0" + }, + "allocationKind": "external_royalty_claim", + "conservationEquation": "grossAtomic = executionCostAtomic + settlementCostAtomic + protocolFeeAtomic + refundReserveAtomic + royaltyPoolAtomic", + "executionCostAtomic": "50000", + "executionCostUsdc": "0.050000", + "expectedGrossDebitAccountId": "wielder:external-gross", + "grossAtomic": "250000", + "grossUsdc": "0.250000", + "id": "marketplace", + "implementationNote": "External Royalty-claim allocation generated by the shared accounting kernel.", + "invocationAwardAtomic": null, + "invocationAwardUsdc": null, + "journalEntryDisplayUsdc": [ + "0.050000", + "0.000000", + "0.006250", + "0.000000", + "0.164688", + "0.029062" + ], + "label": "Marketplace — Phase-3 optionality", + "policy": "LRP", + "protocolFeeAtomic": "6250", + "protocolFeeUsdc": "0.006250", + "refundReserveAtomic": "0", + "refundReserveUsdc": "0.000000", + "royaltyPoolAtomic": "193750", + "royaltyPoolUsdc": "0.193750", + "settlementCostAtomic": "0", + "settlementCostUsdc": "0.000000", + "settlementNote": "Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.", + "status": "phase_3_optionality" + } + ], + "schemaVersion": 1 +} diff --git a/hf-space/static/README.md b/hf-space/static/README.md new file mode 100644 index 0000000..b93c815 --- /dev/null +++ b/hf-space/static/README.md @@ -0,0 +1,40 @@ +--- +title: Skill Asset Protocol — Verified Accounting Demo +emoji: 🔏 +colorFrom: gray +colorTo: blue +sdk: static +app_file: index.html +short_description: Testnet-only HTTP 402 and generated accounting evidence. +license: apache-2.0 +--- + +# Skill Asset Protocol — verified accounting demo + +This standalone static root renders the same deterministic accounting fixture +as the Gradio root. The fixture is generated from +`prototype/atomic-money.mjs`, copied byte-for-byte into this root, and checked +against a local SHA-256 integrity manifest before parsing. + +The default mode is **Intra-org**, the terminal-product spike. **Education** is +deferred after free re-authoring dominated the tested model. **Marketplace** is +Phase-3 optionality. The browser does not calculate fees, COGS, ancestry, +percentages, rounding, account identifiers, Royalty-claim pools, or Invocation +awards. It displays the kernel-returned journal entries and only sums them to +verify conservation. + +The live button makes one unpaid request to a fixed Collar endpoint. Redirects +are refused and the response is size-bounded. Only a valid x402 v1 `exact` +offer for Base Sepolia is marked live; JSON 200/500 and malformed responses are +non-live. No cached response is presented as current. + +Evidence is deliberately narrow. The historical inference-route aggregate is +`historical_unreproducible` and not publication-eligible because normalized +samples were not retained. One successful historical Base Sepolia USDC +transfer has a rechecked receipt, and the 2026-07-12 repository log labels it +as the Skill leg; that does not establish current endpoint behavior, latency, +Royalty-claim split correctness, or Skill execution output. + +Everything uses **Base Sepolia testnet play money**. This demo holds no key, +signs no payment, sends no transaction, deploys nothing, and is not an offer of +any financial product. diff --git a/hf-space/static/data/evidence.json b/hf-space/static/data/evidence.json new file mode 100644 index 0000000..ae3f1be --- /dev/null +++ b/hf-space/static/data/evidence.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "historicalOverhead": { + "manifestPath": "spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json", + "evidenceStatus": "historical_unreproducible", + "publicationAllowed": false, + "publicText": "A historical 2026-07-15 inference-route run reported latency percentiles, but normalized samples were not retained. Percentiles are suppressed until a new dated reproducible run is authorized and committed." + }, + "historicalSkillLegTransactions": [ + { + "manifestPath": "spikes/pi-wielder/evidence/2026-07-12-skill-settlement/manifest.json", + "evidenceStatus": "historical_transaction_receipt_verified", + "label": "one successful historical Base Sepolia USDC transfer; the 2026-07-12 repository log labels it as the Skill leg", + "doesNotProve": [ + "current endpoint behavior", + "latency", + "Royalty-claim split correctness", + "Skill execution output" + ] + } + ] +} diff --git a/hf-space/static/data/fixture-integrity.json b/hf-space/static/data/fixture-integrity.json new file mode 100644 index 0000000..9ff7ddb --- /dev/null +++ b/hf-space/static/data/fixture-integrity.json @@ -0,0 +1,14 @@ +{ + "files": { + "evidence.json": { + "bytes": 960, + "sha256": "sha256:3d809a9382ea32c9929db4cbdc7c2fd9c3fc7bc0bebd5982ab19faf151106d20" + }, + "public-demo-allocation.json": { + "bytes": 11881, + "sha256": "sha256:3505befaf75c9c87a05b4f100cc1a34ce3f8f58a639a569faab9058018e57b7e" + } + }, + "generatedBy": "hf-space/scripts/package-space-fixtures.mjs", + "schemaVersion": 1 +} diff --git a/hf-space/static/data/public-demo-allocation.json b/hf-space/static/data/public-demo-allocation.json new file mode 100644 index 0000000..441047a --- /dev/null +++ b/hf-space/static/data/public-demo-allocation.json @@ -0,0 +1,353 @@ +{ + "corePath": "prototype/atomic-money.mjs", + "defaultScenarioId": "intra-org", + "evidenceStatus": "synthetic_accounting_illustration", + "fixtureSha256": "sha256:5618c710e772fcfb7b8390b87a4b44482bce144343d9bed787a4b71a34767fe8", + "generatedBy": "hf-space/scripts/generate-accounting-fixture.mjs", + "inputs": { + "common": { + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "refundReserveAtomic": "0" + }, + "external": { + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "protocolFeeBps": 250, + "refundReserveAtomic": "0", + "settlementCostAtomic": "0" + }, + "externalSkills": { + "derived-skill": { + "holders": [ + { + "bps": 10000, + "recipientId": "derived-creator" + } + ], + "inheritBps": 1500, + "parentIds": [ + "source-skill" + ] + }, + "source-skill": { + "holders": [ + { + "bps": 10000, + "recipientId": "source-creator" + } + ], + "inheritBps": 0, + "parentIds": [] + } + }, + "internal": { + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0" + } + }, + "scenarios": [ + { + "accountingLabel": "generated from prototype/atomic-money.mjs; synthetic accounting illustration", + "allocation": { + "awardCredit": { + "amountAtomic": "193750", + "recipientId": "employee-creator" + }, + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "invocationAwardAtomic": "193750", + "journalEntries": [ + { + "amountAtomic": "50000", + "category": "execution-cogs", + "creditAccountId": "provider:execution", + "debitAccountId": "employer:invocation-gross" + }, + { + "amountAtomic": "6250", + "category": "protocol-fee", + "creditAccountId": "protocol:treasury", + "debitAccountId": "employer:invocation-gross" + }, + { + "amountAtomic": "0", + "category": "refund-reserve", + "creditAccountId": "reserve:refund", + "debitAccountId": "employer:invocation-gross" + }, + { + "amountAtomic": "193750", + "category": "invocation-award", + "creditAccountId": "employee:employee-creator", + "debitAccountId": "employer:invocation-gross" + } + ], + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0" + }, + "allocationKind": "internal_invocation_award", + "conservationEquation": "grossAtomic = executionCostAtomic + protocolFeeAtomic + refundReserveAtomic + invocationAwardAtomic", + "executionCostAtomic": "50000", + "executionCostUsdc": "0.050000", + "expectedGrossDebitAccountId": "employer:invocation-gross", + "grossAtomic": "250000", + "grossUsdc": "0.250000", + "id": "intra-org", + "implementationNote": "Illustrative until the internal-award amendment becomes canonical.", + "invocationAwardAtomic": "193750", + "invocationAwardUsdc": "0.193750", + "journalEntryDisplayUsdc": [ + "0.050000", + "0.006250", + "0.000000", + "0.193750" + ], + "label": "Intra-org — employer-funded internal Invocation award", + "policy": "internal_award", + "protocolFeeAtomic": "6250", + "protocolFeeUsdc": "0.006250", + "refundReserveAtomic": "0", + "refundReserveUsdc": "0.000000", + "royaltyPoolAtomic": null, + "royaltyPoolUsdc": null, + "settlementCostAtomic": null, + "settlementCostUsdc": null, + "settlementNote": "Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.", + "status": "terminal_product_spike" + }, + { + "accountingLabel": "generated from prototype/atomic-money.mjs; synthetic accounting illustration", + "allocation": { + "allocationPolicy": "lrp-per-hop-v1", + "ancestorCredits": [ + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "credits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + }, + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "holderCredits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + } + ], + "journalEntries": [ + { + "amountAtomic": "50000", + "category": "execution-cogs", + "creditAccountId": "provider:execution", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "settlement-cogs", + "creditAccountId": "provider:settlement", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "6250", + "category": "protocol-fee", + "creditAccountId": "protocol:treasury", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "refund-reserve", + "creditAccountId": "reserve:refund", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "164688", + "category": "royalty-holder", + "creditAccountId": "royalty:derived-creator", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "29062", + "category": "royalty-ancestor", + "creditAccountId": "royalty:source-creator", + "debitAccountId": "wielder:external-gross" + } + ], + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0", + "royaltyPoolAtomic": "193750", + "settlementCostAtomic": "0" + }, + "allocationKind": "external_royalty_claim", + "conservationEquation": "grossAtomic = executionCostAtomic + settlementCostAtomic + protocolFeeAtomic + refundReserveAtomic + royaltyPoolAtomic", + "executionCostAtomic": "50000", + "executionCostUsdc": "0.050000", + "expectedGrossDebitAccountId": "wielder:external-gross", + "grossAtomic": "250000", + "grossUsdc": "0.250000", + "id": "education", + "implementationNote": "External Royalty-claim allocation generated by the shared accounting kernel.", + "invocationAwardAtomic": null, + "invocationAwardUsdc": null, + "journalEntryDisplayUsdc": [ + "0.050000", + "0.000000", + "0.006250", + "0.000000", + "0.164688", + "0.029062" + ], + "label": "Education — deferred after free re-authoring dominated the tested model", + "policy": "LRP", + "protocolFeeAtomic": "6250", + "protocolFeeUsdc": "0.006250", + "refundReserveAtomic": "0", + "refundReserveUsdc": "0.000000", + "royaltyPoolAtomic": "193750", + "royaltyPoolUsdc": "0.193750", + "settlementCostAtomic": "0", + "settlementCostUsdc": "0.000000", + "settlementNote": "Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.", + "status": "deferred" + }, + { + "accountingLabel": "generated from prototype/atomic-money.mjs; synthetic accounting illustration", + "allocation": { + "allocationPolicy": "lrp-per-hop-v1", + "ancestorCredits": [ + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "credits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + }, + { + "amountAtomic": "29062", + "depth": 1, + "kind": "ancestor", + "recipientId": "source-creator", + "viaSkillId": "source-skill" + } + ], + "executionCostAtomic": "50000", + "grossAtomic": "250000", + "holderCredits": [ + { + "amountAtomic": "164688", + "depth": 0, + "kind": "holder", + "recipientId": "derived-creator", + "viaSkillId": "derived-skill" + } + ], + "journalEntries": [ + { + "amountAtomic": "50000", + "category": "execution-cogs", + "creditAccountId": "provider:execution", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "settlement-cogs", + "creditAccountId": "provider:settlement", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "6250", + "category": "protocol-fee", + "creditAccountId": "protocol:treasury", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "0", + "category": "refund-reserve", + "creditAccountId": "reserve:refund", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "164688", + "category": "royalty-holder", + "creditAccountId": "royalty:derived-creator", + "debitAccountId": "wielder:external-gross" + }, + { + "amountAtomic": "29062", + "category": "royalty-ancestor", + "creditAccountId": "royalty:source-creator", + "debitAccountId": "wielder:external-gross" + } + ], + "protocolFeeAtomic": "6250", + "refundReserveAtomic": "0", + "royaltyPoolAtomic": "193750", + "settlementCostAtomic": "0" + }, + "allocationKind": "external_royalty_claim", + "conservationEquation": "grossAtomic = executionCostAtomic + settlementCostAtomic + protocolFeeAtomic + refundReserveAtomic + royaltyPoolAtomic", + "executionCostAtomic": "50000", + "executionCostUsdc": "0.050000", + "expectedGrossDebitAccountId": "wielder:external-gross", + "grossAtomic": "250000", + "grossUsdc": "0.250000", + "id": "marketplace", + "implementationNote": "External Royalty-claim allocation generated by the shared accounting kernel.", + "invocationAwardAtomic": null, + "invocationAwardUsdc": null, + "journalEntryDisplayUsdc": [ + "0.050000", + "0.000000", + "0.006250", + "0.000000", + "0.164688", + "0.029062" + ], + "label": "Marketplace — Phase-3 optionality", + "policy": "LRP", + "protocolFeeAtomic": "6250", + "protocolFeeUsdc": "0.006250", + "refundReserveAtomic": "0", + "refundReserveUsdc": "0.000000", + "royaltyPoolAtomic": "193750", + "royaltyPoolUsdc": "0.193750", + "settlementCostAtomic": "0", + "settlementCostUsdc": "0.000000", + "settlementNote": "Credited allocation shown; withdrawal and on-chain settlement are not implemented in this demo.", + "status": "phase_3_optionality" + } + ], + "schemaVersion": 1 +} diff --git a/hf-space/static/demo-logic.mjs b/hf-space/static/demo-logic.mjs new file mode 100644 index 0000000..04b44ee --- /dev/null +++ b/hf-space/static/demo-logic.mjs @@ -0,0 +1,475 @@ +const LIVE_ENDPOINT = 'https://neverhandedover.com/api/invoke/optimizing-claude-code-prompts'; +const EXPECTED_PAY_TO = '0x25005dfac23d4bc45c801eaeb6c8b5a2bab0f189'; +const EXPECTED_ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const LOCAL_FIXTURE_URLS = Object.freeze([ + './data/fixture-integrity.json', + './data/public-demo-allocation.json', + './data/evidence.json', +]); +const FIXTURE_NAMES = Object.freeze(['evidence.json', 'public-demo-allocation.json']); +const ATOMIC_PATTERN = /^(0|[1-9][0-9]*)$/; +const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; +const SHA_PATTERN = /^sha256:[0-9a-f]{64}$/; +const MAX_FIXTURE_BYTES = 1_000_000; +const MAX_LIVE_RESPONSE_BYTES = 65_536; + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasExactKeys(value, expected) { + if (!isRecord(value)) return false; + const actual = Object.keys(value).sort(); + const sorted = [...expected].sort(); + return actual.length === sorted.length + && actual.every((key, index) => key === sorted[index]); +} + +function decodeJson(bytes, label) { + let text; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + return JSON.parse(text); + } catch (error) { + throw new TypeError(`invalid JSON for ${label}`, { cause: error }); + } +} + +async function responseBytes(response, label, maximumBytes, { requireOk = true } = {}) { + if (!response || typeof response.arrayBuffer !== 'function') { + throw new TypeError(`${label} response is invalid`); + } + if (requireOk && response.ok !== true) throw new TypeError(`${label} request failed`); + const contentLength = response.headers?.get?.('content-length'); + if (contentLength != null && contentLength !== '') { + if (!ATOMIC_PATTERN.test(contentLength) || Number(contentLength) > maximumBytes) { + throw new TypeError(`${label} response is too large`); + } + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength === 0 || bytes.byteLength > maximumBytes) { + throw new TypeError(`${label} response is too large`); + } + return bytes; +} + +async function sha256(bytes, cryptoImpl) { + if (!cryptoImpl?.subtle || typeof cryptoImpl.subtle.digest !== 'function') { + throw new TypeError('Web Crypto SHA-256 is unavailable'); + } + const digest = new Uint8Array(await cryptoImpl.subtle.digest('SHA-256', bytes)); + return `sha256:${[...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`; +} + +function validateIntegrityManifest(value) { + if (!hasExactKeys(value, ['schemaVersion', 'generatedBy', 'files']) + || value.schemaVersion !== 1 + || value.generatedBy !== 'hf-space/scripts/package-space-fixtures.mjs' + || !hasExactKeys(value.files, FIXTURE_NAMES)) { + throw new TypeError('packaged fixture integrity mismatch: fixture-integrity.json'); + } + for (const fileName of FIXTURE_NAMES) { + const metadata = value.files[fileName]; + if (!hasExactKeys(metadata, ['sha256', 'bytes']) + || !Number.isSafeInteger(metadata.bytes) + || metadata.bytes <= 0 + || typeof metadata.sha256 !== 'string' + || !SHA_PATTERN.test(metadata.sha256)) { + throw new TypeError('packaged fixture integrity mismatch: fixture-integrity.json'); + } + } + return value; +} + +function validateAllocationFixture(value) { + if (!isRecord(value) + || value.schemaVersion !== 1 + || value.evidenceStatus !== 'synthetic_accounting_illustration' + || value.defaultScenarioId !== 'intra-org' + || !Array.isArray(value.scenarios) + || value.scenarios.length !== 3) { + throw new TypeError('invalid public demo accounting fixture'); + } + const expected = new Map([ + ['intra-org', ['internal_invocation_award', 'terminal_product_spike']], + ['education', ['external_royalty_claim', 'deferred']], + ['marketplace', ['external_royalty_claim', 'phase_3_optionality']], + ]); + const seen = new Set(); + for (const scenario of value.scenarios) { + if (!isRecord(scenario) || !expected.has(scenario.id) || seen.has(scenario.id)) { + throw new TypeError('invalid public demo scenario'); + } + seen.add(scenario.id); + const [kind, status] = expected.get(scenario.id); + if (scenario.allocationKind !== kind || scenario.status !== status) { + throw new TypeError('invalid public demo scenario status'); + } + } + if (seen.size !== expected.size) throw new TypeError('missing public demo scenario'); + return value; +} + +function validateEvidenceFixture(value) { + if (!isRecord(value) + || value.schemaVersion !== 1 + || !isRecord(value.historicalOverhead) + || value.historicalOverhead.evidenceStatus !== 'historical_unreproducible' + || value.historicalOverhead.publicationAllowed !== false + || !Array.isArray(value.historicalSkillLegTransactions) + || value.historicalSkillLegTransactions.length !== 1 + || !isRecord(value.historicalSkillLegTransactions[0]) + || value.historicalSkillLegTransactions[0].evidenceStatus + !== 'historical_transaction_receipt_verified') { + throw new TypeError('invalid public demo evidence fixture'); + } + return value; +} + +export async function loadPackagedFixtures({ fetchImpl, cryptoImpl }) { + if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function'); + const fetchOptions = Object.freeze({ + method: 'GET', + redirect: 'error', + cache: 'no-store', + credentials: 'omit', + }); + const integrityResponse = await fetchImpl(LOCAL_FIXTURE_URLS[0], fetchOptions); + const integrityBytes = await responseBytes( + integrityResponse, + 'fixture-integrity.json', + MAX_FIXTURE_BYTES, + ); + const integrity = validateIntegrityManifest(decodeJson(integrityBytes, 'fixture-integrity.json')); + const values = Object.create(null); + const fixtureRequests = [ + ['public-demo-allocation.json', LOCAL_FIXTURE_URLS[1]], + ['evidence.json', LOCAL_FIXTURE_URLS[2]], + ]; + for (const [fileName, url] of fixtureRequests) { + const response = await fetchImpl(url, fetchOptions); + const bytes = await responseBytes(response, fileName, MAX_FIXTURE_BYTES); + const expected = integrity.files[fileName]; + if (bytes.byteLength !== expected.bytes || await sha256(bytes, cryptoImpl) !== expected.sha256) { + throw new TypeError(`packaged fixture integrity mismatch: ${fileName}`); + } + values[fileName] = decodeJson(bytes, fileName); + } + return deepFreeze({ + allocation: validateAllocationFixture(values['public-demo-allocation.json']), + evidence: validateEvidenceFixture(values['evidence.json']), + }); +} + +function invalid402(status) { + return deepFreeze({ + live: false, + status: Number.isInteger(status) ? status : null, + offer: null, + error: 'live endpoint did not return a valid 402 offer', + }); +} + +export function validateLive402(status, body) { + if (status !== 402 || !isRecord(body) || body.x402Version !== 1 + || !Array.isArray(body.accepts) || body.accepts.length === 0) { + return invalid402(status); + } + const offer = body.accepts[0]; + const amount = offer?.maxAmountRequired; + const payTo = offer?.payTo; + const asset = offer?.asset; + if (!isRecord(offer) + || offer.scheme !== 'exact' + || offer.network !== 'base-sepolia' + || typeof amount !== 'string' + || !ATOMIC_PATTERN.test(amount) + || BigInt(amount) <= 0n + || offer.resource !== LIVE_ENDPOINT + || typeof payTo !== 'string' + || !ADDRESS_PATTERN.test(payTo) + || payTo.toLowerCase() !== EXPECTED_PAY_TO + || typeof asset !== 'string' + || !ADDRESS_PATTERN.test(asset) + || asset.toLowerCase() !== EXPECTED_ASSET) { + return invalid402(status); + } + return deepFreeze({ + live: true, + status: 402, + offer: { + scheme: 'exact', + network: 'base-sepolia', + maxAmountRequired: amount, + resource: LIVE_ENDPOINT, + payTo, + asset, + }, + error: null, + }); +} + +export function loadScenario(fixture, scenarioId = fixture?.defaultScenarioId) { + validateAllocationFixture(fixture); + if (typeof scenarioId !== 'string') throw new TypeError('scenario identifier must be a string'); + const scenario = fixture.scenarios.find((candidate) => candidate.id === scenarioId); + if (!scenario) throw new TypeError(`unknown public demo scenario: ${scenarioId}`); + return scenario; +} + +function atomic(value, label) { + if (typeof value !== 'string' || !ATOMIC_PATTERN.test(value)) { + throw new TypeError(`invalid atomic amount: ${label}`); + } + return BigInt(value); +} + +export function renderScenarioModel(fixture, scenarioId = fixture?.defaultScenarioId) { + const scenario = loadScenario(fixture, scenarioId); + const entries = scenario.allocation?.journalEntries; + const displayAmounts = scenario.journalEntryDisplayUsdc; + const expectedDebit = scenario.expectedGrossDebitAccountId; + if (!Array.isArray(entries) || entries.length === 0 + || !Array.isArray(displayAmounts) || displayAmounts.length !== entries.length + || typeof expectedDebit !== 'string' || !expectedDebit) { + throw new TypeError('invalid kernel journal fixture'); + } + let total = 0n; + const rows = entries.map((entry, index) => { + if (!hasExactKeys(entry, ['category', 'debitAccountId', 'creditAccountId', 'amountAtomic']) + || entry.debitAccountId !== expectedDebit + || typeof entry.creditAccountId !== 'string' || !entry.creditAccountId + || typeof entry.category !== 'string' || !entry.category + || typeof displayAmounts[index] !== 'string') { + throw new TypeError('invalid kernel journal entry'); + } + total += atomic(entry.amountAtomic, `journalEntries[${index}]`); + return deepFreeze({ + category: entry.category, + debitAccountId: entry.debitAccountId, + creditAccountId: entry.creditAccountId, + amountAtomic: entry.amountAtomic, + amountUsdc: displayAmounts[index], + }); + }); + if (total !== atomic(scenario.grossAtomic, 'grossAtomic')) { + throw new TypeError('kernel journal does not conserve gross'); + } + return deepFreeze({ + scenarioId: scenario.id, + label: scenario.label, + status: scenario.status, + policy: scenario.policy, + allocationKind: scenario.allocationKind, + accountingLabel: scenario.accountingLabel, + implementationNote: scenario.implementationNote, + settlementNote: scenario.settlementNote, + grossAtomic: scenario.grossAtomic, + grossUsdc: scenario.grossUsdc, + rows, + }); +} + +function clear(element) { + while (element.firstChild) element.removeChild(element.firstChild); +} + +function textElement(documentObject, tagName, text, className) { + const element = documentObject.createElement(tagName); + element.textContent = text; + if (className) element.className = className; + return element; +} + +function renderAllocation(documentObject, output, model) { + clear(output); + output.append( + textElement(documentObject, 'h3', model.label), + textElement(documentObject, 'p', `Status: ${model.status} · Policy: ${model.policy}`, 'status-line'), + textElement(documentObject, 'p', model.accountingLabel), + textElement(documentObject, 'p', model.implementationNote), + textElement( + documentObject, + 'p', + `Gross: ${model.grossAtomic} atomic units (${model.grossUsdc} testnet USDC)`, + 'mono', + ), + ); + const table = documentObject.createElement('table'); + const header = documentObject.createElement('tr'); + for (const label of ['category', 'debit account', 'credit account', 'atomic units', 'testnet USDC']) { + header.append(textElement(documentObject, 'th', label)); + } + table.append(header); + for (const row of model.rows) { + const tr = documentObject.createElement('tr'); + for (const value of [ + row.category, + row.debitAccountId, + row.creditAccountId, + row.amountAtomic, + row.amountUsdc, + ]) { + tr.append(textElement(documentObject, 'td', value)); + } + table.append(tr); + } + output.append(table, textElement(documentObject, 'p', model.settlementNote, 'boundary')); +} + +function renderEvidence(documentObject, output, evidence) { + clear(output); + const overhead = evidence.historicalOverhead; + const transaction = evidence.historicalSkillLegTransactions[0]; + output.append( + textElement(documentObject, 'h3', 'Evidence status'), + textElement( + documentObject, + 'p', + `Suppressed route evidence: ${overhead.evidenceStatus}; publication allowed: ${overhead.publicationAllowed}.`, + ), + textElement(documentObject, 'p', overhead.publicText), + textElement(documentObject, 'p', `Narrow historical transaction evidence: ${transaction.label}.`), + textElement(documentObject, 'p', `Manifest record: ${transaction.manifestPath}`, 'mono'), + ); + const list = documentObject.createElement('ul'); + for (const boundary of transaction.doesNotProve) { + list.append(textElement(documentObject, 'li', `Does not prove: ${boundary}`)); + } + output.append(list); +} + +function renderLiveResult(documentObject, output, result) { + clear(output); + output.dataset.live = String(result.live); + output.append(textElement( + documentObject, + 'strong', + result.live + ? 'Valid live HTTP 402 offer from the fixed endpoint.' + : 'Live endpoint did not return a valid 402 offer.', + )); + if (result.live) { + output.append(textElement( + documentObject, + 'p', + `HTTP ${result.status}; ${result.offer.network}; ${result.offer.maxAmountRequired} atomic units.`, + 'mono', + )); + } +} + +async function fetchLiveOffer(fetchImpl) { + try { + const response = await fetchImpl(LIVE_ENDPOINT, { + method: 'POST', + redirect: 'error', + cache: 'no-store', + credentials: 'omit', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ input: 'help me tighten this prompt' }), + }); + const bytes = await responseBytes( + response, + 'live endpoint', + MAX_LIVE_RESPONSE_BYTES, + { requireOk: false }, + ); + const body = decodeJson(bytes, 'live endpoint'); + return validateLive402(response.status, body); + } catch { + return invalid402(null); + } +} + +export async function mountDemo({ + document: documentObject, + fetchImpl = fetch, + cryptoImpl = crypto, +}) { + if (!documentObject?.documentElement) throw new TypeError('document is required'); + const mountState = documentObject.documentElement.dataset.skillAssetMounted; + if (mountState === 'true') return Object.freeze({ alreadyMounted: true }); + if (mountState === 'mounting') throw new Error('demo mount already in progress'); + documentObject.documentElement.dataset.skillAssetMounted = 'mounting'; + try { + const fixtures = await loadPackagedFixtures({ fetchImpl, cryptoImpl }); + const select = documentObject.querySelector('#scenario-select'); + const allocationOutput = documentObject.querySelector('#allocation-output'); + const evidenceOutput = documentObject.querySelector('#evidence-output'); + const liveButton = documentObject.querySelector('#check-live-402'); + const liveOutput = documentObject.querySelector('#live-result'); + if (!select || !allocationOutput || !evidenceOutput || !liveButton || !liveOutput) { + throw new Error('required demo controls are missing'); + } + clear(select); + for (const scenario of fixtures.allocation.scenarios) { + const option = documentObject.createElement('option'); + option.value = scenario.id; + option.textContent = `${scenario.label} [${scenario.status}]`; + if (scenario.id === fixtures.allocation.defaultScenarioId) option.setAttribute('selected', ''); + select.append(option); + } + renderAllocation( + documentObject, + allocationOutput, + renderScenarioModel(fixtures.allocation, fixtures.allocation.defaultScenarioId), + ); + renderEvidence(documentObject, evidenceOutput, fixtures.evidence); + select.addEventListener('change', () => { + renderAllocation( + documentObject, + allocationOutput, + renderScenarioModel(fixtures.allocation, select.value), + ); + }); + liveButton.addEventListener('click', async () => { + liveButton.disabled = true; + try { + renderLiveResult(documentObject, liveOutput, await fetchLiveOffer(fetchImpl)); + } finally { + liveButton.disabled = false; + } + }); + documentObject.documentElement.dataset.skillAssetMounted = 'true'; + documentObject.documentElement.dataset.skillAssetMountCount = '1'; + return Object.freeze({ alreadyMounted: false }); + } catch (error) { + delete documentObject.documentElement.dataset.skillAssetMounted; + throw error; + } +} + +function renderFatalState(documentObject) { + const fatal = documentObject.querySelector('#fatal-state'); + if (fatal) { + fatal.hidden = false; + fatal.textContent = 'Demo unavailable: packaged fixture validation failed.'; + } +} + +function browserReady(documentObject) { + if (documentObject.readyState !== 'loading') return Promise.resolve(); + return new Promise((resolve) => { + documentObject.addEventListener('DOMContentLoaded', resolve, { once: true }); + }); +} + +const hasBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; + +export const browserBootstrapPromise = hasBrowser + ? browserReady(document) + .then(() => mountDemo({ document, fetchImpl: fetch, cryptoImpl: crypto })) + .catch((error) => { + renderFatalState(document); + throw error; + }) + : null; diff --git a/hf-space/static/index.html b/hf-space/static/index.html new file mode 100644 index 0000000..7f59033 --- /dev/null +++ b/hf-space/static/index.html @@ -0,0 +1,69 @@ + + + + + + + + Skill Asset Protocol — verified accounting demo + + + + +
+
+

BASE SEPOLIA TESTNET · PLAY MONEY · NO REAL FUNDS

+

Generated accounting, bounded evidence.

+

A compensation, attribution, and metering research demo for authored AI Skills. It signs no payment, sends no transaction, and performs no deployment or publication action.

+
+ + + +
+

Accounting illustration

+

Intra-org is the terminal-product spike. Education is deferred. Marketplace is Phase-3 optionality. All rows below come from a generated, hash-verified fixture.

+ + +
+
+ +
+

Strict live HTTP 402 check

+

One unpaid POST may be sent only to the fixed Collar endpoint after you click. Redirects are refused. JSON 200/500 responses and malformed offers stay non-live.

+ +
+
+ +
+

Evidence boundaries

+
+
+ + +
+ + + diff --git a/hf-space/static/package-lock.json b/hf-space/static/package-lock.json new file mode 100644 index 0000000..d9a9492 --- /dev/null +++ b/hf-space/static/package-lock.json @@ -0,0 +1,216 @@ +{ + "name": "skill-asset-protocol-static-space", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skill-asset-protocol-static-space", + "version": "0.1.0", + "devDependencies": { + "linkedom": "0.18.12" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/hf-space/static/package.json b/hf-space/static/package.json new file mode 100644 index 0000000..5988a20 --- /dev/null +++ b/hf-space/static/package.json @@ -0,0 +1,12 @@ +{ + "name": "skill-asset-protocol-static-space", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test-demo-logic.mjs test-index-smoke.mjs" + }, + "devDependencies": { + "linkedom": "0.18.12" + } +} diff --git a/hf-space/static/test-demo-logic.mjs b/hf-space/static/test-demo-logic.mjs new file mode 100644 index 0000000..c258734 --- /dev/null +++ b/hf-space/static/test-demo-logic.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { cp, readFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { webcrypto } from 'node:crypto'; +import test from 'node:test'; + +import { + loadPackagedFixtures, + loadScenario, + renderScenarioModel, + validateLive402, +} from './demo-logic.mjs'; + +const STATIC_ROOT = new URL('./', import.meta.url); +const DATA_URLS = Object.freeze([ + './data/fixture-integrity.json', + './data/public-demo-allocation.json', + './data/evidence.json', +]); +const VALID_402 = Object.freeze({ + x402Version: 1, + accepts: Object.freeze([Object.freeze({ + scheme: 'exact', + network: 'base-sepolia', + maxAmountRequired: '250000', + resource: 'https://neverhandedover.com/api/invoke/optimizing-claude-code-prompts', + payTo: '0x25005dfac23d4bc45c801eaeb6c8b5a2bab0f189', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + })]), +}); + +function response(bytes, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => String(bytes.length) }, + arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + }; +} + +async function packagedBytes(root = STATIC_ROOT) { + return new Map(await Promise.all(DATA_URLS.map(async (url) => [ + url, + await readFile(new URL(url.slice(2), root)), + ]))); +} + +test('strict live validation accepts only the fixed valid 402 offer', () => { + assert.equal(validateLive402(402, VALID_402).live, true); + for (const [status, body] of [ + [200, VALID_402], + [500, VALID_402], + [402, { ...VALID_402, x402Version: 2 }], + [402, { ...VALID_402, accepts: [] }], + ]) { + assert.equal(validateLive402(status, body).live, false); + } + for (const [field, value] of [ + ['scheme', 'upto'], + ['network', 'base'], + ['maxAmountRequired', '0.25'], + ['maxAmountRequired', '01'], + ['resource', 'https://attacker.example/invoke'], + ['payTo', ''], + ['asset', ''], + ]) { + const offer = { ...VALID_402.accepts[0], [field]: value }; + assert.equal(validateLive402(402, { x402Version: 1, accepts: [offer] }).live, false); + } +}); + +test('browser model renders exact kernel journal rows and conserves gross', async () => { + const allocation = JSON.parse(await readFile(new URL('data/public-demo-allocation.json', STATIC_ROOT))); + assert.equal(allocation.defaultScenarioId, 'intra-org'); + assert.equal(loadScenario(allocation).id, 'intra-org'); + assert.equal(loadScenario(allocation, 'education').status, 'deferred'); + assert.equal(loadScenario(allocation, 'marketplace').status, 'phase_3_optionality'); + + for (const scenario of allocation.scenarios) { + const model = renderScenarioModel(allocation, scenario.id); + const entries = scenario.allocation.journalEntries; + assert.equal(model.rows.length, entries.length); + for (let index = 0; index < entries.length; index += 1) { + assert.equal(model.rows[index].category, entries[index].category); + assert.equal(model.rows[index].debitAccountId, entries[index].debitAccountId); + assert.equal(model.rows[index].creditAccountId, entries[index].creditAccountId); + assert.equal(model.rows[index].amountAtomic, entries[index].amountAtomic); + } + assert.equal( + entries.reduce((sum, entry) => sum + BigInt(entry.amountAtomic), 0n), + BigInt(scenario.grossAtomic), + ); + } + const rendered = JSON.stringify(allocation).toLowerCase(); + for (const percentile of [`p${50}`, `p${95}`]) assert.equal(rendered.includes(percentile), false); +}); + +test('fixture loader fetches only local packaged files and verifies raw hashes', async () => { + const bytes = await packagedBytes(); + const seen = []; + const fetchImpl = async (url) => { + seen.push(url); + if (!bytes.has(url)) throw new Error(`unstubbed URL: ${url}`); + return response(bytes.get(url)); + }; + const fixtures = await loadPackagedFixtures({ fetchImpl, cryptoImpl: webcrypto }); + assert.deepEqual(seen, DATA_URLS); + assert.equal(fixtures.allocation.defaultScenarioId, 'intra-org'); + assert.equal(fixtures.evidence.historicalOverhead.publicationAllowed, false); + + const drifted = new Map(bytes); + drifted.set('./data/evidence.json', Buffer.concat([bytes.get('./data/evidence.json'), Buffer.from(' ')])); + await assert.rejects( + () => loadPackagedFixtures({ + fetchImpl: async (url) => response(drifted.get(url)), + cryptoImpl: webcrypto, + }), + /packaged fixture integrity mismatch: evidence.json/, + ); +}); + +test('static root remains standalone when copied away from the repository', async (t) => { + const temporary = await mkdtemp(join(tmpdir(), 'static-root-test-')); + t.after(() => rm(temporary, { recursive: true, force: true })); + const copiedRoot = join(temporary, 'copied-static'); + await cp(fileURLToPath(STATIC_ROOT), copiedRoot, { + recursive: true, + filter: (sourcePath) => !sourcePath.includes(`${join('static', 'node_modules')}`), + }); + const source = await readFile(join(copiedRoot, 'demo-logic.mjs'), 'utf8'); + assert.doesNotMatch(source, /\.\.\/|hf-space\/(gradio|static)/); + const bytes = new Map(await Promise.all(DATA_URLS.map(async (url) => [ + url, + await readFile(join(copiedRoot, url.slice(2))), + ]))); + const moduleUrl = `${pathToFileURL(join(copiedRoot, 'demo-logic.mjs')).href}?standalone=${Date.now()}`; + const originalCwd = process.cwd(); + try { + process.chdir(tmpdir()); + const module = await import(moduleUrl); + const fixtures = await module.loadPackagedFixtures({ + fetchImpl: async (url) => { + if (!bytes.has(url)) throw new Error(`unstubbed URL: ${url}`); + return response(bytes.get(url)); + }, + cryptoImpl: webcrypto, + }); + assert.equal(fixtures.allocation.defaultScenarioId, 'intra-org'); + } finally { + process.chdir(originalCwd); + } +}); diff --git a/hf-space/static/test-index-smoke.mjs b/hf-space/static/test-index-smoke.mjs new file mode 100644 index 0000000..76ab071 --- /dev/null +++ b/hf-space/static/test-index-smoke.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { webcrypto } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { parseHTML } from 'linkedom'; + +const STATIC_ROOT = new URL('./', import.meta.url); +const LIVE_ENDPOINT = 'https://neverhandedover.com/api/invoke/optimizing-claude-code-prompts'; +const LOCAL_URLS = [ + './data/fixture-integrity.json', + './data/public-demo-allocation.json', + './data/evidence.json', +]; +const VALID_402 = { + x402Version: 1, + accepts: [{ + scheme: 'exact', + network: 'base-sepolia', + maxAmountRequired: '250000', + resource: LIVE_ENDPOINT, + payTo: '0x25005dfac23d4bc45c801eaeb6c8b5a2bab0f189', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + }], +}; + +function response(value, status = 200) { + const bytes = Buffer.isBuffer(value) ? value : Buffer.from(JSON.stringify(value)); + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => String(bytes.length) }, + arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + }; +} + +async function waitForClick(button) { + button.click(); + const deadline = Date.now() + 1_000; + while (button.disabled && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.equal(button.disabled, false); +} + +test('actual HTML auto-mounts once and distinguishes valid 402 from JSON 200/500', async (t) => { + const html = await readFile(new URL('index.html', STATIC_ROOT), 'utf8'); + const { window, document } = parseHTML(html); + Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); + const scripts = [...document.querySelectorAll('script[type="module"]')]; + assert.equal(scripts.length, 1); + assert.equal(scripts[0].getAttribute('src'), './demo-logic.mjs'); + assert.ok(document.querySelector('#scenario-select')); + assert.ok(document.querySelector('#check-live-402')); + + const local = new Map(await Promise.all(LOCAL_URLS.map(async (url) => [ + url, + await readFile(new URL(url.slice(2), STATIC_ROOT)), + ]))); + let liveStatus = 402; + const seen = []; + const fetchStub = async (url) => { + seen.push(url); + if (local.has(url)) return response(local.get(url)); + if (url === LIVE_ENDPOINT) { + if (liveStatus === 402) return response(VALID_402, 402); + return response({ status: `synthetic-${liveStatus}` }, liveStatus); + } + throw new Error(`unstubbed URL: ${url}`); + }; + + const prior = { + window: globalThis.window, + document: globalThis.document, + fetch: globalThis.fetch, + crypto: globalThis.crypto, + }; + Object.defineProperties(globalThis, { + window: { value: window, configurable: true, writable: true }, + document: { value: document, configurable: true, writable: true }, + fetch: { value: fetchStub, configurable: true, writable: true }, + crypto: { value: webcrypto, configurable: true, writable: true }, + }); + t.after(() => { + for (const [key, value] of Object.entries(prior)) { + Object.defineProperty(globalThis, key, { value, configurable: true, writable: true }); + } + }); + + const module = await import(new URL(`demo-logic.mjs?browser=${Date.now()}`, STATIC_ROOT)); + await module.browserBootstrapPromise; + assert.equal(document.documentElement.dataset.skillAssetMounted, 'true'); + assert.equal(document.documentElement.dataset.skillAssetMountCount, '1'); + assert.match(document.querySelector('#allocation-output').textContent, /Intra-org/); + assert.deepEqual(seen.slice(0, 3), LOCAL_URLS); + + const button = document.querySelector('#check-live-402'); + const result = document.querySelector('#live-result'); + await waitForClick(button); + assert.equal(result.dataset.live, 'true'); + assert.match(result.textContent, /valid live HTTP 402 offer/i); + + liveStatus = 200; + await waitForClick(button); + assert.equal(result.dataset.live, 'false'); + assert.match(result.textContent, /did not return a valid 402 offer/i); + + liveStatus = 500; + await waitForClick(button); + assert.equal(result.dataset.live, 'false'); + assert.match(result.textContent, /did not return a valid 402 offer/i); + assert.equal(document.documentElement.dataset.skillAssetMountCount, '1'); +}); + +test('module import outside a browser performs no fetch and exposes null bootstrap', async () => { + const descriptors = Object.fromEntries( + ['window', 'document', 'fetch'].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + let fetches = 0; + try { + Object.defineProperties(globalThis, { + window: { value: undefined, configurable: true, writable: true }, + document: { value: undefined, configurable: true, writable: true }, + fetch: { value: () => { fetches += 1; }, configurable: true, writable: true }, + }); + const module = await import(new URL(`demo-logic.mjs?server=${Date.now()}`, STATIC_ROOT)); + assert.equal(module.browserBootstrapPromise, null); + assert.equal(fetches, 0); + } finally { + for (const [key, descriptor] of Object.entries(descriptors)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + } +}); From dfce384f1beb674dd1d74e82de0df835f1f5a06f Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:43:19 -0400 Subject: [PATCH 124/165] feat: bind execution quote to x402 payment --- spikes/pi-wielder/src/invocation-journal.mjs | 314 +++++++++++++++++- spikes/pi-wielder/src/proxy.mjs | 12 +- spikes/pi-wielder/src/x402-seller.mjs | 69 +++- .../tests/invocation-journal.test.mjs | 279 +++++++++++++++- spikes/pi-wielder/tests/proxy-trust.test.mjs | 21 +- .../pi-wielder/tests/x402-lifecycle.test.mjs | 56 +++- 6 files changed, 704 insertions(+), 47 deletions(-) diff --git a/spikes/pi-wielder/src/invocation-journal.mjs b/spikes/pi-wielder/src/invocation-journal.mjs index be7489e..bbfdfdf 100644 --- a/spikes/pi-wielder/src/invocation-journal.mjs +++ b/spikes/pi-wielder/src/invocation-journal.mjs @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { assertExecutionQuote } from './execution-economics.mjs'; + const TERMINAL_EXECUTION = new Set(['succeeded', 'failed', 'cancelled']); const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../', import.meta.url))); const LEASE_ID = /^[0-9a-f]{32}$/; @@ -418,7 +420,7 @@ function receiptPayload(record) { const payment = copy(record.payment); delete payment.refundExecution; return { - schemaVersion: 1, + schemaVersion: record.schemaVersion, revision: record.receiptHistory.length + 1, supersedesReceiptHash: record.receiptHistory.at(-1)?.receiptHash ?? null, sequence: record.lastSequence, @@ -491,11 +493,24 @@ export function createInvocationJournal({ let nextSequence = 1; let headHash = null; - function validateQuote(quote) { - exactKeys(quote, [ + function validateQuote(quote, schemaVersion) { + const legacyFields = [ 'quoteId', 'amountAtomic', 'currency', 'network', 'asset', 'payTo', 'resource', 'requestHash', 'requirementsHash', 'expiresAt', 'requirements', - ], 'payment quote'); + ]; + if (schemaVersion === 1) { + exactKeys(quote, legacyFields, 'legacy payment quote'); + } else if (schemaVersion === 2) { + exactKeys(quote, ['schemaVersion', ...legacyFields, 'executionQuote'], 'payment quote'); + if (quote.schemaVersion !== 2) throw new Error('payment quote must use schema version 2'); + assertExecutionQuote(quote.executionQuote); + if (quote.executionQuote.quoteId !== quote.quoteId + || quote.executionQuote.grossAtomic !== quote.amountAtomic) { + throw new Error('execution quote does not match x402 quote identity and amount'); + } + } else { + throw new Error('payment quote schema version is unsupported'); + } requireText(quote.quoteId, 'quoteId'); requireAtomicString(quote.amountAtomic, 'amountAtomic'); if (quote.currency !== 'USDC') throw new Error("currency must be 'USDC'"); @@ -525,12 +540,266 @@ export function createInvocationJournal({ } } + const V2_ACCOUNTING_FIELDS = [ + 'schemaVersion', 'quoteId', 'allocationPolicy', 'grossAtomic', 'executionCostAtomic', + 'settlementCostAtomic', 'protocolFeeAtomic', 'royaltyPoolAtomic', 'refundReserveAtomic', + 'contributionMarginAtomic', 'allocationState', 'holderCredits', 'ancestorCredits', + 'journalEntries', 'executionCogs', + ]; + + function validateV2Usage(usage) { + exactKeys(usage, ['schemaVersion', 'model', 'inputTokens', 'outputTokens'], 'provider usage'); + if (usage.schemaVersion !== 2 || typeof usage.model !== 'string' || !usage.model + || !Number.isSafeInteger(usage.inputTokens) || usage.inputTokens < 0 + || !Number.isSafeInteger(usage.outputTokens) || usage.outputTokens < 0) { + throw new Error('provider usage must use strict schema version 2'); + } + } + + function validateV2Credits(credits, kind) { + if (!Array.isArray(credits)) throw new Error(`${kind} Royalty credits must be an array`); + for (const credit of credits) { + exactKeys(credit, ['recipientId', 'viaSkillId', 'depth', 'kind', 'amountAtomic'], `${kind} Royalty credit`); + requireText(credit.recipientId, 'Royalty recipientId'); + requireText(credit.viaSkillId, 'Royalty viaSkillId'); + if (!Number.isSafeInteger(credit.depth) || credit.depth < 0 || credit.kind !== kind) { + throw new Error(`${kind} Royalty credit identity is invalid`); + } + requireAtomicString(credit.amountAtomic, 'Royalty credit amountAtomic'); + } + } + + function validateV2JournalEntries(entries) { + if (!Array.isArray(entries)) throw new Error('accounting journalEntries must be an array'); + for (const entry of entries) { + exactKeys(entry, ['category', 'debitAccountId', 'creditAccountId', 'amountAtomic'], 'accounting journal entry'); + requireText(entry.category, 'accounting category'); + requireText(entry.debitAccountId, 'accounting debitAccountId'); + requireText(entry.creditAccountId, 'accounting creditAccountId'); + requireAtomicString(entry.amountAtomic, 'accounting amountAtomic'); + } + } + + function validateV2Accounting(record, accounting) { + exactKeys(accounting, V2_ACCOUNTING_FIELDS, 'v2 execution accounting'); + if (accounting.schemaVersion !== 2 + || accounting.quoteId !== record.quote.quoteId + || accounting.grossAtomic !== record.quote.amountAtomic) { + throw new Error('v2 accounting does not match the frozen quote and gross'); + } + for (const field of [ + 'grossAtomic', 'executionCostAtomic', 'settlementCostAtomic', 'protocolFeeAtomic', + 'royaltyPoolAtomic', 'refundReserveAtomic', 'contributionMarginAtomic', + ]) requireAtomicString(accounting[field], `accounting.${field}`); + validateV2Credits(accounting.holderCredits, 'holder'); + validateV2Credits(accounting.ancestorCredits, 'ancestor'); + validateV2JournalEntries(accounting.journalEntries); + + const cogs = accounting.executionCogs; + exactKeys(cogs, [ + 'schemaVersion', 'status', 'actualAtomic', 'chargedAtomic', 'quotedWorstCaseAtomic', + 'accruedOverrunAtomic', 'catalogVersion', 'catalogDigest', 'usage', 'failureClass', 'reason', + ], 'execution COGS'); + const executionQuote = record.quote.executionQuote; + if (cogs.schemaVersion !== 2 + || cogs.quotedWorstCaseAtomic !== executionQuote.worstCaseExecutionCostAtomic + || cogs.catalogVersion !== executionQuote.catalogVersion + || cogs.catalogDigest !== executionQuote.catalogDigest) { + throw new Error('execution COGS does not match the frozen quote catalog'); + } + requireAtomicString(cogs.quotedWorstCaseAtomic, 'executionCogs.quotedWorstCaseAtomic'); + requireAtomicString(cogs.accruedOverrunAtomic, 'executionCogs.accruedOverrunAtomic'); + + const gross = BigInt(accounting.grossAtomic); + if (accounting.allocationState === 'finalized') { + if (accounting.allocationPolicy !== 'lrp-per-hop-v1' + || cogs.status !== 'known' || cogs.actualAtomic === null + || cogs.chargedAtomic !== cogs.actualAtomic || cogs.usage === null + || cogs.failureClass !== null || cogs.reason !== null + || cogs.accruedOverrunAtomic !== '0') { + throw new Error('finalized accounting requires known charged COGS and no failure'); + } + requireAtomicString(cogs.actualAtomic, 'executionCogs.actualAtomic'); + validateV2Usage(cogs.usage); + if (cogs.usage.model !== executionQuote.model + || cogs.usage.inputTokens > executionQuote.maxInputTokens + || cogs.usage.outputTokens > executionQuote.maxOutputTokens) { + throw new Error('finalized accounting usage exceeds the frozen execution quote'); + } + if (BigInt(cogs.actualAtomic) > BigInt(cogs.quotedWorstCaseAtomic)) { + throw new Error('finalized accounting COGS exceeds the frozen quote reserve'); + } + if (accounting.executionCostAtomic !== cogs.actualAtomic + || accounting.settlementCostAtomic !== executionQuote.settlementCostAtomic + || accounting.protocolFeeAtomic !== executionQuote.protocolFeeAtomic + || accounting.refundReserveAtomic !== executionQuote.refundReserveAtomic + || accounting.contributionMarginAtomic !== accounting.protocolFeeAtomic) { + throw new Error('finalized accounting costs or contribution margin do not match the frozen quote'); + } + const components = [ + 'executionCostAtomic', 'settlementCostAtomic', 'protocolFeeAtomic', + 'royaltyPoolAtomic', 'refundReserveAtomic', + ].reduce((sum, field) => sum + BigInt(accounting[field]), 0n); + const journalTotal = accounting.journalEntries + .reduce((sum, entry) => sum + BigInt(entry.amountAtomic), 0n); + const holderTotal = accounting.holderCredits + .reduce((sum, credit) => sum + BigInt(credit.amountAtomic), 0n); + const ancestorTotal = accounting.ancestorCredits + .reduce((sum, credit) => sum + BigInt(credit.amountAtomic), 0n); + if (components !== gross || journalTotal !== gross + || holderTotal + ancestorTotal !== BigInt(accounting.royaltyPoolAtomic)) { + throw new Error('finalized accounting must conserve gross and the Royalty pool exactly'); + } + const categoryRows = new Map(); + for (const entry of accounting.journalEntries) { + if (entry.debitAccountId !== 'wielder:external-gross') { + throw new Error('finalized accounting journal must debit external gross'); + } + const rows = categoryRows.get(entry.category) ?? []; + rows.push(entry); + categoryRows.set(entry.category, rows); + } + const expectedCategories = new Map([ + ['execution-cogs', { + amountAtomic: accounting.executionCostAtomic, + creditAccountId: 'provider:execution', + }], + ['settlement-cogs', { + amountAtomic: accounting.settlementCostAtomic, + creditAccountId: 'provider:settlement', + }], + ['protocol-fee', { + amountAtomic: accounting.protocolFeeAtomic, + creditAccountId: 'protocol:treasury', + }], + ['refund-reserve', { + amountAtomic: accounting.refundReserveAtomic, + creditAccountId: 'reserve:refund', + }], + ]); + for (const [category, expected] of expectedCategories) { + const rows = categoryRows.get(category) ?? []; + if (rows.length !== 1 + || rows[0].amountAtomic !== expected.amountAtomic + || rows[0].creditAccountId !== expected.creditAccountId) { + throw new Error(`finalized accounting category '${category}' does not match its quoted destination`); + } + } + if ([...categoryRows.keys()].some((category) => ( + !expectedCategories.has(category) + && !['royalty-holder', 'royalty-ancestor'].includes(category) + ))) { + throw new Error('finalized accounting journal has invalid Royalty categories'); + } + const expectedRoyaltyRows = [ + ...accounting.holderCredits.map((credit) => ({ + category: 'royalty-holder', + creditAccountId: `royalty:${credit.recipientId}`, + amountAtomic: credit.amountAtomic, + })), + ...accounting.ancestorCredits.map((credit) => ({ + category: 'royalty-ancestor', + creditAccountId: `royalty:${credit.recipientId}`, + amountAtomic: credit.amountAtomic, + })), + ]; + const actualRoyaltyRows = [ + ...(categoryRows.get('royalty-holder') ?? []), + ...(categoryRows.get('royalty-ancestor') ?? []), + ]; + const rowKey = (row) => JSON.stringify([ + row.category, row.creditAccountId, row.amountAtomic, + ]); + const rowCounts = (rows) => rows.reduce((counts, row) => { + const key = rowKey(row); + counts.set(key, (counts.get(key) ?? 0) + 1); + return counts; + }, new Map()); + const expectedRows = rowCounts(expectedRoyaltyRows); + const actualRows = rowCounts(actualRoyaltyRows); + if (expectedRoyaltyRows.length !== actualRoyaltyRows.length + || expectedRows.size !== actualRows.size + || [...expectedRows].some(([key, count]) => actualRows.get(key) !== count)) { + throw new Error('finalized accounting Royalty journal does not match signed credits'); + } + return; + } + + if (accounting.allocationState !== 'pending_cogs_reconciliation' + || accounting.allocationPolicy !== null + || ['executionCostAtomic', 'settlementCostAtomic', 'protocolFeeAtomic', + 'royaltyPoolAtomic', 'refundReserveAtomic', 'contributionMarginAtomic'] + .some((field) => accounting[field] !== '0') + || accounting.holderCredits.length !== 0 || accounting.ancestorCredits.length !== 0 + || accounting.journalEntries.length !== 1) { + throw new Error('pending accounting must hold full gross with no finalized Royalty claims'); + } + const [hold] = accounting.journalEntries; + if (hold.category !== 'unresolved-execution-accounting' + || hold.debitAccountId !== 'wielder:external-gross' + || hold.creditAccountId !== 'hold:execution-accounting-reconciliation' + || hold.amountAtomic !== accounting.grossAtomic) { + throw new Error('pending accounting requires one exact full-gross hold'); + } + if (typeof cogs.failureClass !== 'string' + || !/^[A-Z][A-Z0-9_]{1,63}$/.test(cogs.failureClass) + || typeof cogs.reason !== 'string' || !cogs.reason) { + throw new Error('pending execution COGS requires a stable failure class and reason'); + } + if (cogs.status === 'unknown') { + if (cogs.actualAtomic !== null || cogs.chargedAtomic !== null + || cogs.usage !== null || cogs.accruedOverrunAtomic !== '0') { + throw new Error('unknown COGS must remain null rather than zero'); + } + return; + } + if (cogs.status !== 'known' || cogs.actualAtomic === null + || cogs.chargedAtomic !== null || cogs.usage === null) { + throw new Error('pending known COGS has invalid charged or usage fields'); + } + requireAtomicString(cogs.actualAtomic, 'executionCogs.actualAtomic'); + validateV2Usage(cogs.usage); + const expectedOverrun = BigInt(cogs.actualAtomic) > BigInt(cogs.quotedWorstCaseAtomic) + ? BigInt(cogs.actualAtomic) - BigInt(cogs.quotedWorstCaseAtomic) + : 0n; + if (cogs.accruedOverrunAtomic !== expectedOverrun.toString()) { + throw new Error('pending known COGS accrued overrun is inconsistent'); + } + } + + function validateV2ExecutionFinished(record, data) { + if (data.outcome === 'succeeded') { + if (!/^sha256:[0-9a-f]{64}$/.test(String(data.outcomeHash ?? '')) + || data.failureClass !== null || data.message !== null + || data.httpStatus < 200 || data.httpStatus >= 400 + || data.accounting?.allocationState !== 'finalized') { + throw new Error('v2 success requires a hash, success status, finalized accounting, and no failure message'); + } + } else if (data.outcome === 'failed') { + if (data.outcomeHash !== null + || typeof data.failureClass !== 'string' || !/^[A-Z][A-Z0-9_]{1,63}$/.test(data.failureClass) + || typeof data.message !== 'string' || !data.message + || data.httpStatus < 400 || data.httpStatus > 599 + || data.accounting?.allocationState !== 'pending_cogs_reconciliation') { + throw new Error('v2 failure requires a stable failure, failure status, and pending accounting'); + } + } else { + throw new Error('v2 execution.finished supports succeeded or failed outcomes only'); + } + if (data.outcome === 'failed' + && data.accounting?.executionCogs?.failureClass !== data.failureClass) { + throw new Error('v2 failure class must match its pending COGS accounting'); + } + validateV2Accounting(record, data.accounting); + } + function validateEventForApply(event) { exactKeys(event, [ 'schemaVersion', 'eventId', 'sequence', 'previousHash', 'type', 'idempotencyKey', 'at', 'data', 'keyId', 'eventHash', 'eventSignature', ], 'journal event'); - if (event.schemaVersion !== 1 || event.eventId !== `event-${String(event.sequence).padStart(8, '0')}`) { + if (![1, 2].includes(event.schemaVersion) + || event.eventId !== `event-${String(event.sequence).padStart(8, '0')}`) { throw new Error('journal event schema or identifier is invalid'); } if (!Number.isSafeInteger(event.sequence) || event.sequence < 1 @@ -541,6 +810,10 @@ export function createInvocationJournal({ if (!dataKeys) throw new Error(`unknown journal event '${event.type}'`); exactKeys(event.data, dataKeys, `${event.type}.data`); const record = records.get(event.idempotencyKey); + if (event.type !== 'invocation.requested' && record + && event.schemaVersion !== record.schemaVersion) { + throw new Error('journal event schema version cannot change within one Invocation'); + } switch (event.type) { case 'invocation.requested': if (record) throw new Error(`duplicate request event for '${event.idempotencyKey}'`); @@ -551,7 +824,7 @@ export function createInvocationJournal({ if (!record || record.execution.state !== 'requested' || record.payment.state !== null) { throw new Error('payment.offered requires one unquoted requested Invocation'); } - validateQuote(event.data.quote); + validateQuote(event.data.quote, event.schemaVersion); break; case 'payment.signed': if (!record || record.payment.state !== 'offered') throw new Error('payment.signed requires offered payment'); @@ -612,6 +885,7 @@ export function createInvocationJournal({ || event.data.httpStatus < 100 || event.data.httpStatus > 599) { throw new Error('execution HTTP status is invalid'); } + if (event.schemaVersion === 2) validateV2ExecutionFinished(record, event.data); break; case 'receipt.issued': if (!record || !TERMINAL_EXECUTION.has(record.execution.state) || record.receipt) { @@ -636,7 +910,7 @@ export function createInvocationJournal({ switch (event.type) { case 'invocation.requested': record = { - schemaVersion: 1, + schemaVersion: event.schemaVersion, invocationId: event.data.invocationId, idempotencyKey: event.idempotencyKey, mode: event.data.mode, @@ -811,7 +1085,9 @@ export function createInvocationJournal({ throw error; } const unsigned = { - schemaVersion: 1, + schemaVersion: type === 'invocation.requested' + ? 2 + : (records.get(idempotencyKey)?.schemaVersion ?? 2), eventId: `event-${String(nextSequence).padStart(8, '0')}`, sequence: nextSequence, previousHash: headHash, @@ -896,6 +1172,7 @@ export function createInvocationJournal({ const record = requireRecord(records, key); const requirements = copy(input.requirements); const frozenQuote = { + schemaVersion: 2, quoteId: requireText(input.quoteId, 'quoteId'), amountAtomic: requireAtomicString(input.amountAtomic, 'amountAtomic'), currency: input.currency === 'USDC' ? 'USDC' : (() => { throw new Error("currency must be 'USDC'"); })(), @@ -907,8 +1184,12 @@ export function createInvocationJournal({ requirementsHash: requireText(input.requirementsHash, 'requirementsHash'), expiresAt: requireText(input.expiresAt, 'expiresAt'), requirements, + executionQuote: copy(input.executionQuote), }; - validateQuote(frozenQuote); + if (record.schemaVersion !== 2) { + throw new Error('legacy nonterminal Invocation cannot be upgraded to execution quote schema v2'); + } + validateQuote(frozenQuote, record.schemaVersion); if (record.quote) { if (!same(record.quote, frozenQuote)) throw new Error('idempotency key already binds a different quote'); return copy(record); @@ -1126,16 +1407,17 @@ export function createInvocationJournal({ function finishExecution(key, input) { refreshFromAuthority(); const record = requireRecord(records, key); - const outcome = requireText(input.outcome, 'outcome'); + const captured = copy(input); + const outcome = requireText(captured.outcome, 'outcome'); if (!TERMINAL_EXECUTION.has(outcome)) throw new Error(`unsupported execution outcome '${outcome}'`); const data = { - executionAttemptId: requireText(input.executionAttemptId ?? record.execution.executionAttemptId, 'executionAttemptId'), + executionAttemptId: requireText(captured.executionAttemptId ?? record.execution.executionAttemptId, 'executionAttemptId'), outcome, - outcomeHash: input.outcomeHash ?? null, - failureClass: input.failureClass ?? null, - message: input.message ?? null, - httpStatus: input.httpStatus, - accounting: input.accounting ?? null, + outcomeHash: captured.outcomeHash ?? null, + failureClass: captured.failureClass ?? null, + message: captured.message ?? null, + httpStatus: captured.httpStatus, + accounting: captured.accounting ?? null, }; if (TERMINAL_EXECUTION.has(record.execution.state)) { const terminal = { ...record.execution, accounting: record.accounting }; diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index f3968a5..d7de33c 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -494,8 +494,18 @@ export function assertReceiptMatchesPayment(bundle, expected) { const statusSemanticsMatch = executionState === 'succeeded' ? httpStatus >= 200 && httpStatus < 400 : Number.isSafeInteger(httpStatus) && httpStatus >= 400 && httpStatus <= 599; + const legacyReceipt = receipt?.schemaVersion === 1 + && receipt.quote?.schemaVersion === undefined + && receipt.quote?.executionQuote === undefined; + const cogsAwareReceipt = receipt?.schemaVersion === 2 + && receipt.quote?.schemaVersion === 2 + && receipt.quote?.executionQuote?.schemaVersion === 2 + && receipt.quote.executionQuote.quoteId === expected.quoteId + && receipt.quote.executionQuote.grossAtomic === expected.amountAtomic + && receipt.accounting?.schemaVersion === 2 + && receipt.accounting?.quoteId === expected.quoteId; if (!receipt - || receipt.schemaVersion !== 1 + || !(legacyReceipt || cogsAwareReceipt) || receipt.mode !== 'external' || receipt.idempotencyKey !== expected.idempotencyKey || receipt.requestHash !== expected.requestHash diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index 574b7db..3dbf0da 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -163,9 +163,13 @@ export function x402Paywall({ facilitatorTransport, description = '', lifecycle = {}, + quote = null, }) { const transport = requireFacilitatorTransport(facilitatorTransport); const canonicalPayTo = canonicalAddress(payTo); + if (quote !== null && typeof quote !== 'function') { + throw new TypeError('quote must be an injected function or null'); + } const frozenOffers = new Map(); const locallyUnresolvedSettlements = new Set(); @@ -187,27 +191,66 @@ export function x402Paywall({ .update(`${c.req.method}\n${c.req.url}\n${requestBody}`) .digest('hex')}`; - let requirements = frozenOffers.get(idempotencyKey) ?? null; - if (!requirements) { - const recovered = await lifecycle.loadFrozenOffer?.({ idempotencyKey }); + let frozen = frozenOffers.get(idempotencyKey) ?? null; + if (!frozen) { + let recovered = null; + try { + recovered = await lifecycle.loadFrozenOffer?.({ + idempotencyKey, + paymentHeaderPresent: Boolean(paymentHeader), + }); + } catch { + return c.json({ error: 'frozen offer recovery conflicts with authoritative state' }, 409); + } if (recovered) { - requirements = structuredClone(recovered); - frozenOffers.set(idempotencyKey, requirements); + if (!exactPlainObject(recovered, ['requirements', 'executionQuote'])) { + return c.json({ error: 'persisted frozen offer has an unsupported schema' }, 409); + } + frozen = structuredClone(recovered); + frozenOffers.set(idempotencyKey, frozen); } } + let requirements = frozen?.requirements ?? null; + let executionQuote = frozen?.executionQuote ?? null; if (requirements) { if (requirements.extra?.requestHash !== requestHash) { return c.json({ error: 'Idempotency-Key already binds a different request' }, 409); } } else { if (paymentHeader) return c.json({ error: 'paid retry has no prior frozen x402 offer' }, 409); - const priceUsdc = typeof price === 'function' ? await price(c) : price; + try { + executionQuote = quote ? structuredClone(await quote(c)) : null; + } catch (error) { + const code = typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{1,63}$/.test(error.code) + ? error.code + : 'QUOTE_REJECTED'; + const status = code === 'REQUEST_BODY_TOO_LARGE' ? 413 : 400; + const message = error?.name === 'ExecutionEconomicsError' + ? error.message + : 'execution quote rejected'; + return c.json({ error: message, code }, status); + } + if (executionQuote !== null + && (!exactPlainObject(executionQuote, Object.keys(executionQuote)) + || executionQuote.schemaVersion !== 2 + || typeof executionQuote.quoteId !== 'string' + || !/^sha256:[0-9a-f]{64}$/.test(executionQuote.quoteId) + || typeof executionQuote.grossAtomic !== 'string' + || !/^[1-9]\d*$/.test(executionQuote.grossAtomic))) { + return c.json({ error: 'execution quote rejected', code: 'QUOTE_SCHEMA' }, 400); + } + const priceUsdc = executionQuote == null + ? (typeof price === 'function' ? await price(c) : price) + : null; + const amountAtomic = executionQuote == null + ? usdcToAtomic(priceUsdc) + : executionQuote.grossAtomic; const issuedAt = new Date().toISOString(); const expiresAt = new Date(Date.now() + 60_000).toISOString(); const base = { scheme: 'exact', network: NETWORK, - maxAmountRequired: usdcToAtomic(priceUsdc), + maxAmountRequired: amountAtomic, resource: c.req.url, description, mimeType: 'application/json', @@ -215,7 +258,7 @@ export function x402Paywall({ maxTimeoutSeconds: 60, asset: USDC_ADDRESS, }; - const quoteId = `sha256:${crypto.createHash('sha256') + const quoteId = executionQuote?.quoteId ?? `sha256:${crypto.createHash('sha256') .update(JSON.stringify({ ...base, requestHash, issuedAt, expiresAt })) .digest('hex')}`; requirements = { @@ -229,7 +272,8 @@ export function x402Paywall({ expiresAt, }, }; - frozenOffers.set(idempotencyKey, requirements); + frozen = { requirements, executionQuote }; + frozenOffers.set(idempotencyKey, structuredClone(frozen)); } if (!paymentHeader) { @@ -238,6 +282,7 @@ export function x402Paywall({ idempotencyKey, requirements: structuredClone(requirements), expiresAt: requirements.extra.expiresAt, + executionQuote: structuredClone(executionQuote), }); } catch { return c.json({ error: 'Invocation offer conflicts with authoritative state' }, 409); @@ -280,6 +325,7 @@ export function x402Paywall({ settlementReference, payer, requirements: structuredClone(requirements), + executionQuote: structuredClone(executionQuote), }); } catch { return c.json({ error: 'paid retry conflicts with authoritative Invocation state' }, 409); @@ -416,6 +462,7 @@ export function x402Paywall({ payer: settledPayer, amountAtomic: requirements.maxAmountRequired, requirements: structuredClone(requirements), + executionQuote: structuredClone(executionQuote), }); } catch { locallyUnresolvedSettlements.add(idempotencyKey); @@ -438,7 +485,9 @@ export function x402Paywall({ txHash: settledTxHash, payer: settledPayer, amountAtomic: requirements.maxAmountRequired, - requirements, + requirements: structuredClone(requirements), + executionQuote: structuredClone(executionQuote), + legacySchemaVersion: priorDecision?.legacySchemaVersion ?? null, }); await next(); c.res.headers.set('X-PAYMENT-RESPONSE', jsonToB64(paymentResponseEvidence({ diff --git a/spikes/pi-wielder/tests/invocation-journal.test.mjs b/spikes/pi-wielder/tests/invocation-journal.test.mjs index 46d33f3..30beaa4 100644 --- a/spikes/pi-wielder/tests/invocation-journal.test.mjs +++ b/spikes/pi-wielder/tests/invocation-journal.test.mjs @@ -15,6 +15,12 @@ import { receiptKeyId, verifySignedReceipt, } from '../src/invocation-journal.mjs'; +import { + artifactDigest, + createPendingExecutionAccounting, + createExecutionQuote, + finalizeExecutionAccounting, +} from '../src/execution-economics.mjs'; const payer = `0x${'1'.repeat(40)}`; const payTo = `0x${'d'.repeat(40)}`; @@ -31,6 +37,31 @@ const declaration = Object.freeze({ beneficiaryId: null, }); +const royaltyGraph = Object.freeze({ + 'skill-a': Object.freeze({ + parentIds: Object.freeze([]), + inheritBps: 0, + holders: Object.freeze([{ recipientId: 'creator-a', bps: 10_000 }]), + }), +}); +const executionQuote = createExecutionQuote({ + schemaVersion: 2, + grossAtomic: '250000', + model: 'claude-sonnet-4-6', + maxInputTokens: 16384, + maxOutputTokens: 2048, + promptBytes: 100, + estimatedInputTokens: 356, + settlementCostAtomic: '1000', + refundReserveAtomic: '5000', + protocolFeeBps: 250, + leafSkillId: 'skill-a', + skillId: 'skill-a', + skillVersion: 'skill-a/2026-07-17-v1', + artifactHash: artifactDigest('test Skill artifact'), + skills: royaltyGraph, +}); + const requirements = Object.freeze({ scheme: 'exact', network: 'base-sepolia', @@ -45,13 +76,14 @@ const requirements = Object.freeze({ name: 'USDC', version: '2', requestHash: declaration.requestHash, - quoteId: `sha256:${'c'.repeat(64)}`, + quoteId: executionQuote.quoteId, issuedAt: '2026-07-17T12:00:00.000Z', expiresAt: '2026-07-17T12:01:00.000Z', }, }); const quote = Object.freeze({ + schemaVersion: 2, quoteId: requirements.extra.quoteId, amountAtomic: requirements.maxAmountRequired, currency: 'USDC', @@ -63,8 +95,13 @@ const quote = Object.freeze({ requirementsHash: `sha256:${'e'.repeat(64)}`, expiresAt: requirements.extra.expiresAt, requirements, + executionQuote, }); +const legacyQuote = Object.freeze(Object.fromEntries( + Object.entries(quote).filter(([key]) => !['schemaVersion', 'executionQuote'].includes(key)), +)); + function fixture(overrides = {}) { let tick = 0; return createInvocationJournal({ @@ -107,7 +144,16 @@ function settle(journal, input = declaration) { }); } -function pendingFailureAccounting() { +function pendingFailureAccounting(failureClass = 'UPSTREAM_PROVIDER_ERROR') { + return structuredClone(createPendingExecutionAccounting({ + quote: executionQuote, + usage: null, + failureClass, + reason: 'provider execution failed', + })); +} + +function legacyPendingFailureAccounting() { return { grossAtomic: '250000', allocationState: 'pending_cogs_reconciliation', @@ -122,6 +168,20 @@ function pendingFailureAccounting() { }; } +function finalizedAccounting() { + return structuredClone(finalizeExecutionAccounting({ + quote: executionQuote, + usage: { + schemaVersion: 2, + model: executionQuote.model, + inputTokens: 42, + outputTokens: 42, + }, + leafSkillId: 'skill-a', + skills: royaltyGraph, + })); +} + test('exact retries are no-ops and conflicting idempotency reuse fails closed', () => { const journal = fixture(); const first = journal.requestInvocation(declaration); @@ -156,17 +216,137 @@ test('a settled execution failure keeps its transaction, full-gross hold, and HT message: 'provider returned HTTP 500', outcomeHash: null, httpStatus: 500, - accounting: pendingFailureAccounting(), + accounting: pendingFailureAccounting('UPSTREAM_500'), }); const bundle = journal.issueReceipt(declaration.idempotencyKey); + assert.equal(bundle.receipt.schemaVersion, 2); + assert.equal(bundle.receipt.quote.executionQuote.quoteId, requirements.extra.quoteId); assert.equal(bundle.receipt.payment.state, 'settled'); assert.equal(bundle.receipt.payment.txHash, txHash); assert.equal(bundle.receipt.execution.state, 'failed'); assert.equal(bundle.receipt.execution.httpStatus, 500); - assert.deepEqual(bundle.receipt.accounting, pendingFailureAccounting()); + assert.deepEqual(bundle.receipt.accounting, pendingFailureAccounting('UPSTREAM_500')); assert.equal(verifySignedReceipt(bundle, trustFor(journal)), true); }); +test('v2 terminal accounting rejects nonconservation, false unknown zero, and quote mismatch before append', () => { + const invalidAccounting = [ + (value) => { value.royaltyPoolAtomic = '1'; }, + (value) => { value.executionCogs.actualAtomic = '0'; }, + (value) => { value.quoteId = `sha256:${'9'.repeat(64)}`; }, + (value) => { value.journalEntries[0].amountAtomic = '249999'; }, + (value) => { value.holderCredits.push({ + recipientId: 'attacker', viaSkillId: 'skill-a', depth: 0, kind: 'holder', amountAtomic: '1', + }); }, + ]; + for (const mutate of invalidAccounting) { + const journal = fixture(); + settle(journal); + const claim = journal.startExecution(declaration.idempotencyKey); + const accounting = pendingFailureAccounting(); + mutate(accounting); + const before = journal.events.length; + assert.throws(() => journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: claim.record.execution.executionAttemptId, + outcome: 'failed', + failureClass: 'UPSTREAM_PROVIDER_ERROR', + message: 'safe provider failure', + outcomeHash: null, + httpStatus: 500, + accounting, + }), /accounting|COGS|Royalty|quote|hold|conserve/i); + assert.equal(journal.events.length, before); + assert.equal(journal.getByIdempotencyKey(declaration.idempotencyKey).execution.state, 'executing'); + } +}); + +test('v2 success requires known finalized accounting and exact terminal hash/status semantics', () => { + for (const mutation of [ + { accounting: pendingFailureAccounting() }, + { outcomeHash: null }, + { failureClass: 'FALSE_SUCCESS' }, + { message: 'false success' }, + { httpStatus: 500 }, + ]) { + const journal = fixture(); + settle(journal); + const claim = journal.startExecution(declaration.idempotencyKey); + assert.throws(() => journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: claim.record.execution.executionAttemptId, + outcome: 'succeeded', + failureClass: null, + message: null, + outcomeHash: `sha256:${'7'.repeat(64)}`, + httpStatus: 200, + accounting: finalizedAccounting(), + ...mutation, + }), /success|finalized|hash|status|failure|message/i); + assert.equal(journal.getByIdempotencyKey(declaration.idempotencyKey).execution.state, 'executing'); + } +}); + +test('v2 finalized accounting binds quote-fixed costs, quoted usage, and exact credit destinations', () => { + const invalidAccounting = [ + (value) => { + value.settlementCostAtomic = '999'; + value.royaltyPoolAtomic = '236995'; + value.journalEntries.find((entry) => entry.category === 'settlement-cogs').amountAtomic = '999'; + value.holderCredits[0].amountAtomic = '236995'; + value.journalEntries.find((entry) => entry.category === 'royalty-holder').amountAtomic = '236995'; + }, + (value) => { + value.executionCogs.actualAtomic = '79873'; + value.executionCogs.chargedAtomic = '79873'; + value.executionCostAtomic = '79873'; + value.royaltyPoolAtomic = '157877'; + value.journalEntries.find((entry) => entry.category === 'execution-cogs').amountAtomic = '79873'; + value.holderCredits[0].amountAtomic = '157877'; + value.journalEntries.find((entry) => entry.category === 'royalty-holder').amountAtomic = '157877'; + }, + (value) => { value.executionCogs.usage.model = 'unquoted-model'; }, + (value) => { + value.journalEntries.find((entry) => entry.category === 'protocol-fee').creditAccountId = 'attacker:fee'; + }, + (value) => { + value.journalEntries.find((entry) => entry.category === 'royalty-holder').creditAccountId = 'royalty:attacker'; + }, + ]; + for (const mutate of invalidAccounting) { + const journal = fixture(); + settle(journal); + const claim = journal.startExecution(declaration.idempotencyKey); + const accounting = finalizedAccounting(); + mutate(accounting); + const before = journal.events.length; + assert.throws(() => journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: claim.record.execution.executionAttemptId, + outcome: 'succeeded', + failureClass: null, + message: null, + outcomeHash: `sha256:${'7'.repeat(64)}`, + httpStatus: 200, + accounting, + }), /accounting|COGS|quote|usage|journal|credit|category/i); + assert.equal(journal.events.length, before); + } +}); + +test('v2 failure class matches its pending COGS hold before signing a receipt', () => { + const journal = fixture(); + settle(journal); + const claim = journal.startExecution(declaration.idempotencyKey); + assert.throws(() => journal.finishExecution(declaration.idempotencyKey, { + executionAttemptId: claim.record.execution.executionAttemptId, + outcome: 'failed', + failureClass: 'COGS_UNKNOWN', + message: 'provider usage unavailable', + outcomeHash: null, + httpStatus: 500, + accounting: pendingFailureAccounting('UPSTREAM_PROVIDER_ERROR'), + }), /failure class/i); + assert.equal(journal.getByIdempotencyKey(declaration.idempotencyKey).execution.state, 'executing'); +}); + test('an unresolved settlement reconciles once by its payment reference', () => { const journal = fixture(); offer(journal); @@ -216,6 +396,84 @@ function temporaryAuthority(prefix = 'collar-journal-') { }; } +function writeLegacyV1Journal({ terminal }) { + const authority = temporaryAuthority('collar-legacy-v1-'); + const keys = crypto.generateKeyPairSync('ed25519'); + fs.writeFileSync( + authority.signingKeyPath, + keys.privateKey.export({ type: 'pkcs8', format: 'pem' }), + { mode: 0o600 }, + ); + const signer = createReceiptSigner(keys, { persistent: true }); + const events = []; + let previousHash = null; + const append = (type, data) => { + const sequence = events.length + 1; + const unsigned = { + schemaVersion: 1, + eventId: `event-${String(sequence).padStart(8, '0')}`, + sequence, + previousHash, + type, + idempotencyKey: declaration.idempotencyKey, + at: new Date(Date.UTC(2026, 6, 16, 12, 0, sequence)).toISOString(), + data, + keyId: signer.keyId, + }; + const eventHash = crypto.createHash('sha256').update(canonicalJson(unsigned)).digest('hex'); + const event = { ...unsigned, eventHash, eventSignature: signer.signHash(eventHash) }; + events.push(event); + previousHash = eventHash; + }; + append('invocation.requested', { + invocationId: 'inv-legacy-v1', + mode: declaration.mode, + skill: { id: declaration.skillId, versionHash: declaration.skillVersionHash }, + requestHash: declaration.requestHash, + creatorId: declaration.creatorId, + beneficiaryId: declaration.beneficiaryId, + }); + append('payment.offered', { quote: legacyQuote }); + if (terminal) { + append('payment.signed', { settlementReference, payer }); + append('payment.settled', { settlementReference, txHash, payer }); + append('execution.started', { executionAttemptId: 'attempt:legacy-v1' }); + append('execution.finished', { + executionAttemptId: 'attempt:legacy-v1', + outcome: 'failed', + outcomeHash: null, + failureClass: 'LEGACY_FAILURE', + message: 'legacy terminal failure', + httpStatus: 500, + accounting: legacyPendingFailureAccounting(), + }); + } + fs.writeFileSync( + authority.filePath, + `${events.map((event) => JSON.stringify(event)).join('\n')}\n`, + { mode: 0o600 }, + ); + return { ...authority, signer }; +} + +test('legacy v1 terminal history replays without relabeling while nonterminal v1 cannot upgrade', () => { + const terminalAuthority = writeLegacyV1Journal({ terminal: true }); + const terminal = createInvocationJournal(terminalAuthority); + const receipt = terminal.issueReceipt(declaration.idempotencyKey); + assert.equal(receipt.receipt.schemaVersion, 1); + assert.equal(receipt.receipt.quote.executionQuote, undefined); + assert.ok(terminal.events.every((event) => event.schemaVersion === 1)); + assert.equal(verifySignedReceipt(receipt, trustFor(terminal)), true); + + const outstandingAuthority = writeLegacyV1Journal({ terminal: false }); + const outstanding = createInvocationJournal(outstandingAuthority); + assert.throws( + () => outstanding.offerExternalPayment(declaration.idempotencyKey, quote), + /legacy nonterminal Invocation cannot be upgraded/, + ); + assert.equal(outstanding.events.length, 2); +}); + test('JSONL replay reconstructs a terminal record and refuses rewritten signed history', () => { const { filePath, signingKeyPath } = temporaryAuthority(); const journal = createInvocationJournal({ filePath, signingKeyPath, createId: () => 'inv-persistent' }); @@ -228,9 +486,10 @@ test('JSONL replay reconstructs a terminal record and refuses rewritten signed h message: null, outcomeHash: `sha256:${'7'.repeat(64)}`, httpStatus: 200, - accounting: { grossAtomic: '250000', allocationState: 'finalized', holderCredits: [], ancestorCredits: [], journalEntries: [] }, + accounting: finalizedAccounting(), }); const original = journal.issueReceipt(declaration.idempotencyKey); + assert.ok(journal.events.every((event) => event.schemaVersion === 2)); const reopened = createInvocationJournal({ filePath, signingKeyPath }); assert.deepEqual(reopened.getByIdempotencyKey(declaration.idempotencyKey), journal.getByIdempotencyKey(declaration.idempotencyKey)); assert.deepEqual(reopened.issueReceipt(declaration.idempotencyKey), original); @@ -320,7 +579,7 @@ test('a receipt cannot authenticate itself and tampering invalidates it', () => journal.finishExecution(declaration.idempotencyKey, { executionAttemptId: claim.record.execution.executionAttemptId, outcome: 'failed', failureClass: 'FAULT', message: 'fault', outcomeHash: null, - httpStatus: 500, accounting: pendingFailureAccounting(), + httpStatus: 500, accounting: pendingFailureAccounting('FAULT'), }); const bundle = journal.issueReceipt(declaration.idempotencyKey); const tampered = structuredClone(bundle); @@ -332,7 +591,7 @@ test('a receipt cannot authenticate itself and tampering invalidates it', () => attacker.finishExecution(declaration.idempotencyKey, { executionAttemptId: attackerClaim.record.execution.executionAttemptId, outcome: 'failed', failureClass: 'FAULT', message: 'fault', outcomeHash: null, - httpStatus: 500, accounting: pendingFailureAccounting(), + httpStatus: 500, accounting: pendingFailureAccounting('FAULT'), }); assert.equal(verifySignedReceipt(attacker.issueReceipt(declaration.idempotencyKey), trustFor(journal)), false); }); @@ -344,7 +603,7 @@ test('refund reverses only a terminal failed full-gross hold and issues a signed journal.finishExecution(declaration.idempotencyKey, { executionAttemptId: claim.record.execution.executionAttemptId, outcome: 'failed', failureClass: 'COGS_UNKNOWN', message: 'fault', outcomeHash: null, - httpStatus: 500, accounting: pendingFailureAccounting(), + httpStatus: 500, accounting: pendingFailureAccounting('COGS_UNKNOWN'), }); const original = journal.issueReceipt(declaration.idempotencyKey); const request = { @@ -377,7 +636,7 @@ test('refund execution is durably claimed once and ambiguous outcomes remain unr journal.finishExecution(declaration.idempotencyKey, { executionAttemptId: execution.record.execution.executionAttemptId, outcome: 'failed', failureClass: 'COGS_UNKNOWN', message: 'safe failure', outcomeHash: null, - httpStatus: 500, accounting: pendingFailureAccounting(), + httpStatus: 500, accounting: pendingFailureAccounting('COGS_UNKNOWN'), }); journal.issueReceipt(declaration.idempotencyKey); @@ -425,7 +684,7 @@ test('separate journal instances observe one durable refund claim', () => { first.finishExecution(declaration.idempotencyKey, { executionAttemptId: execution.record.execution.executionAttemptId, outcome: 'failed', failureClass: 'COGS_UNKNOWN', message: 'safe failure', outcomeHash: null, - httpStatus: 500, accounting: pendingFailureAccounting(), + httpStatus: 500, accounting: pendingFailureAccounting('COGS_UNKNOWN'), }); first.issueReceipt(declaration.idempotencyKey); const second = createInvocationJournal({ filePath, signingKeyPath }); diff --git a/spikes/pi-wielder/tests/proxy-trust.test.mjs b/spikes/pi-wielder/tests/proxy-trust.test.mjs index 567abac..bfe6fef 100644 --- a/spikes/pi-wielder/tests/proxy-trust.test.mjs +++ b/spikes/pi-wielder/tests/proxy-trust.test.mjs @@ -43,7 +43,7 @@ const expected = Object.freeze({ function receiptFor(overrides = {}) { return { - schemaVersion: 1, + schemaVersion: 2, revision: 1, supersedesReceiptHash: null, invocationId: 'inv-current', @@ -53,12 +53,14 @@ function receiptFor(overrides = {}) { requestHash: expected.requestHash, wielderId: expected.payer, quote: { + schemaVersion: 2, requestHash: expected.requestHash, quoteId: expected.quoteId, amountAtomic: expected.amountAtomic, currency: 'USDC', network: 'base-sepolia', resource: expected.resource, + executionQuote: { schemaVersion: 2, quoteId: expected.quoteId, grossAtomic: expected.amountAtomic }, }, payment: { state: 'settled', @@ -68,7 +70,12 @@ function receiptFor(overrides = {}) { refundAmountAtomic: null, }, execution: { state: 'succeeded', httpStatus: expected.httpStatus }, - accounting: { grossAtomic: expected.amountAtomic, allocationState: 'finalized' }, + accounting: { + schemaVersion: 2, + quoteId: expected.quoteId, + grossAtomic: expected.amountAtomic, + allocationState: 'finalized', + }, ...overrides, }; } @@ -116,6 +123,14 @@ test('a valid signature is insufficient when receipt semantics do not match the }), true); assert.equal(assertReceiptMatchesPayment(valid, expected).invocationId, 'inv-current'); + const legacyReceipt = receiptFor({ + schemaVersion: 1, + quote: Object.fromEntries(Object.entries(receiptFor().quote) + .filter(([key]) => !['schemaVersion', 'executionQuote'].includes(key))), + accounting: { grossAtomic: expected.amountAtomic, allocationState: 'finalized' }, + }); + assert.equal(assertReceiptMatchesPayment(signReceipt(signer, legacyReceipt), expected).schemaVersion, 1); + const stale = signReceipt(signer, receiptFor({ idempotencyKey: 'idem-previous' })); assert.equal(verifySignedReceipt(stale, { publicKeyPem: signer.publicKeyPem, @@ -129,6 +144,8 @@ test('a valid signature is insufficient when receipt semantics do not match the { execution: { state: 'succeeded', httpStatus: 500 } }, { accounting: { grossAtomic: '249999', allocationState: 'finalized' } }, { quote: { ...receiptFor().quote, resource: 'http://evil.test/invoke/skill-current' } }, + { quote: { ...receiptFor().quote, executionQuote: { ...receiptFor().quote.executionQuote, quoteId: `sha256:${'9'.repeat(64)}` } } }, + { quote: { ...receiptFor().quote, executionQuote: undefined } }, ]) { const bundle = signReceipt(signer, receiptFor(mutation)); assert.throws(() => assertReceiptMatchesPayment(bundle, expected), /does not semantically match/); diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs index 9f972a6..3d30523 100644 --- a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -15,6 +15,14 @@ import { } from '../src/x402-seller.mjs'; const payTo = `0x${'d'.repeat(40)}`; +const executionQuote = Object.freeze({ + schemaVersion: 2, + quoteId: `sha256:${'7'.repeat(64)}`, + grossAtomic: '250000', + model: 'claude-sonnet-4-6', + maxInputTokens: 16384, + maxOutputTokens: 2048, +}); const payingFetch = (account, url, init, options = {}) => policyPayingFetch(account, url, init, { paymentPolicy: paymentPolicyFor(url, payTo), @@ -41,10 +49,11 @@ async function withheldAttempt(account, url, init, options = {}) { }; } -function resourceApp({ facilitatorTransport, lifecycle = {}, price = '0.25', handler } = {}) { +function resourceApp({ facilitatorTransport, lifecycle = {}, price = '0.25', quote = null, handler } = {}) { const app = new Hono(); app.post('/resource', x402Paywall({ price, + quote, payTo, facilitatorTransport, lifecycle, @@ -59,7 +68,17 @@ test('challenge and retry emit one ordered lifecycle under one idempotency key', const lifecycle = Object.fromEntries([ 'onOffered', 'onSigned', 'onSettled', 'onUnresolved', 'onRejected', ].map((name) => [name, async (payload) => calls.push([name, payload])])); - const app = resourceApp({ facilitatorTransport: transport, lifecycle }); + let quoteCalls = 0; + let handlerQuote = null; + const app = resourceApp({ + facilitatorTransport: transport, + lifecycle, + quote: async () => { quoteCalls += 1; return structuredClone(executionQuote); }, + handler: (c) => { + handlerQuote = structuredClone(c.get('x402').executionQuote); + return c.json({ ok: true }); + }, + }); const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { method: 'POST', body: '{}', }, { @@ -70,6 +89,13 @@ test('challenge and retry emit one ordered lifecycle under one idempotency key', assert.deepEqual(calls.map(([name]) => name), ['onOffered', 'onSigned', 'onSettled']); assert.ok(calls.every(([, payload]) => payload.idempotencyKey === 'idem-lifecycle')); assert.deepEqual(calls[0][1].requirements, calls[1][1].requirements); + assert.equal(calls[0][1].requirements.extra.quoteId, executionQuote.quoteId); + assert.deepEqual(calls[0][1].executionQuote, executionQuote); + assert.deepEqual(calls[1][1].executionQuote, executionQuote); + assert.deepEqual(calls[2][1].executionQuote, executionQuote); + assert.deepEqual(handlerQuote, executionQuote); + assert.equal(quoteCalls, 1); + assert.equal(result.quoteId, executionQuote.quoteId); assert.match(calls[0][1].requirements.extra.requestHash, /^sha256:[0-9a-f]{64}$/); assert.equal(calls[2][1].settlementReference, result.settlementReference); assert.equal(calls[2][1].txHash, result.txHash); @@ -80,20 +106,30 @@ test('challenge and retry emit one ordered lifecycle under one idempotency key', test('a restarted paywall accepts only the complete persisted frozen offer', async () => { const facilitator = createMockFacilitator(); const transport = createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); - let persistedRequirements = null; + let persistedOffer = null; + let quoteCalls = 0; let fetchCount = 0; const beforeRestart = resourceApp({ facilitatorTransport: transport, + quote: async () => { quoteCalls += 1; return structuredClone(executionQuote); }, lifecycle: { - async onOffered({ requirements }) { persistedRequirements = structuredClone(requirements); }, + async onOffered({ requirements, executionQuote: offeredQuote }) { + persistedOffer = structuredClone({ requirements, executionQuote: offeredQuote }); + offeredQuote.model = 'mutated-by-untrusted-hook'; + }, }, handler: (c) => c.json({ shouldNotExecute: true }), }); const afterRestart = resourceApp({ facilitatorTransport: transport, price: '9.99', + quote: async () => { quoteCalls += 1; throw new Error('must not recompute after restart'); }, lifecycle: { - async loadFrozenOffer() { return structuredClone(persistedRequirements); }, + async loadFrozenOffer() { return structuredClone(persistedOffer); }, + }, + handler: (c) => { + assert.deepEqual(c.get('x402').executionQuote, executionQuote); + return c.json({ ok: true }); }, }); const result = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { @@ -104,8 +140,10 @@ test('a restarted paywall accepts only the complete persisted frozen offer', asy }); assert.equal(result.res.status, 200); assert.equal(fetchCount, 2); - assert.equal(persistedRequirements.maxAmountRequired, '250000'); - assert.equal(persistedRequirements.payTo, payTo); + assert.equal(persistedOffer.requirements.maxAmountRequired, '250000'); + assert.equal(persistedOffer.requirements.payTo, payTo); + assert.deepEqual(persistedOffer.executionQuote, executionQuote); + assert.equal(quoteCalls, 1); }); test('restart rejects different request bytes under the frozen idempotency key before facilitator or execution', async () => { @@ -141,7 +179,9 @@ test('restart rejects different request bytes under the frozen idempotency key b const afterRestart = resourceApp({ facilitatorTransport: transport, lifecycle: { - async loadFrozenOffer() { return structuredClone(persistedRequirements); }, + async loadFrozenOffer() { + return { requirements: structuredClone(persistedRequirements), executionQuote: null }; + }, }, handler: (c) => { executions += 1; return c.json({ ok: true }); }, }); From d2feed7c3295cfedb9ab82a2e743a37065b3ea25 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:43:33 -0400 Subject: [PATCH 125/165] feat: settle hosted Skill COGS before royalties --- spikes/pi-wielder/src/collar.mjs | 509 +++++++++++++++--- spikes/pi-wielder/src/execution-economics.mjs | 9 +- spikes/pi-wielder/tests/collar-cogs.test.mjs | 367 +++++++++++++ .../pi-wielder/tests/collar-failure.test.mjs | 72 +-- .../tests/execution-economics.test.mjs | 9 + 5 files changed, 843 insertions(+), 123 deletions(-) create mode 100644 spikes/pi-wielder/tests/collar-cogs.test.mjs diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index 52fe0ee..8f53b59 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -13,7 +13,18 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { serve } from '@hono/node-server'; import { Hono } from 'hono'; -import { allocateExternalGross } from '../../../prototype/atomic-money.mjs'; +import { assertArtifactNotSerialized } from './artifact-boundary.mjs'; +import { + artifactDigest, + assertFrozenExecutionIdentity, + assertLiveCatalogApproval, + conservativeProviderPromptBound, + createPendingExecutionAccounting, + createExecutionQuote, + EXECUTION_CATALOG, + ExecutionEconomicsError, + finalizeExecutionAccounting, +} from './execution-economics.mjs'; import { APPROVED_LIVE_FACILITATOR_BASE, createLiveFacilitatorTransport, @@ -31,6 +42,15 @@ const SKILL_PATH = fileURLToPath( new URL(`../../../.claude/skills/${SKILL_ID}/SKILL.md`, import.meta.url), ); const DEFAULT_PRICE_USDC = '0.25'; +const DEFAULT_EXECUTION = Object.freeze({ + model: 'claude-sonnet-4-6', + maxInputTokens: 16384, + maxOutputTokens: 2048, +}); +const SETTLEMENT_COST_ATOMIC = '1000'; +const REFUND_RESERVE_ATOMIC = '5000'; +const MAX_REQUEST_BODY_BYTES = 4096; +const SKILL_VERSION = 'optimizing-claude-code-prompts/2026-07-17-v1'; const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']); const hash = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; @@ -43,31 +63,7 @@ const royaltyGraph = Object.freeze({ }), }); -function serializeEntry(entry) { - return { ...entry, amountAtomic: entry.amountAtomic.toString() }; -} - -function serializeCredit(credit) { - return { ...credit, amountAtomic: credit.amountAtomic.toString() }; -} - -function serializeAccounting(result) { - return { - allocationState: 'finalized', - allocationPolicy: result.allocationPolicy, - grossAtomic: result.grossAtomic.toString(), - executionCostAtomic: result.executionCostAtomic.toString(), - settlementCostAtomic: result.settlementCostAtomic.toString(), - protocolFeeAtomic: result.protocolFeeAtomic.toString(), - royaltyPoolAtomic: result.royaltyPoolAtomic.toString(), - refundReserveAtomic: result.refundReserveAtomic.toString(), - holderCredits: result.holderCredits.map(serializeCredit), - ancestorCredits: result.ancestorCredits.map(serializeCredit), - journalEntries: result.journalEntries.map(serializeEntry), - }; -} - -function pendingFailureAccounting(amountAtomic) { +function legacyPendingFailureAccounting(amountAtomic) { return { grossAtomic: String(amountAtomic), allocationState: 'pending_cogs_reconciliation', @@ -82,6 +78,23 @@ function pendingFailureAccounting(amountAtomic) { }; } +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function isExactPlainObject(value, keys) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length + && actual.every((key, index) => key === expected[index]); +} + function validTxHash(value) { return /^0x[0-9a-fA-F]{64}$/.test(String(value ?? '')); } @@ -115,7 +128,16 @@ export function createCollar({ facilitatorTransport, payTo = process.env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dead', priceUsdc = process.env.SKILL_PRICE_USDC || DEFAULT_PRICE_USDC, - mockLlm = process.env.MOCK_LLM === '1', + mockLlm = process.env.MOCK_LLM !== '0', + allowLiveProvider = process.env.ALLOW_LIVE_PROVIDER === '1', + executionCatalog = EXECUTION_CATALOG, + liveApproval = process.env.LIVE_CATALOG_DIGEST && process.env.LIVE_SPEND_CAP_ATOMIC + ? { + catalogDigest: process.env.LIVE_CATALOG_DIGEST, + spendCapAtomic: process.env.LIVE_SPEND_CAP_ATOMIC, + } + : null, + liveExecutorFactory = () => createAnthropicExecutor(), journal = null, journalFile = process.env.COLLAR_JOURNAL_FILE || null, signingKeyFile = process.env.COLLAR_SIGNING_KEY_FILE || null, @@ -149,12 +171,107 @@ export function createCollar({ const skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); const skillVersionHash = hash(skillContent); const priceAtomic = usdcToAtomic(priceUsdc); - const executor = executeSkill ?? (mockLlm - ? async ({ input }) => ({ output: mockSkillOutput(input) }) - : async ({ input }) => ({ output: await runSkillViaAnthropic(skillContent, input) })); + const frozenExecutionCatalog = deepFreeze(structuredClone(executionCatalog)); + let executor = executeSkill; + if (!executor && mockLlm) { + executor = async ({ input }) => ({ + output: mockSkillOutput(input), + usage: { + schemaVersion: 2, + model: 'claude-sonnet-4-6', + inputTokens: 42, + outputTokens: 42, + }, + }); + } + if (!executor) { + if (!allowLiveProvider) { + throw new ExecutionEconomicsError( + 'LIVE_PRICING_UNAPPROVED', + 'live provider execution requires an explicit gate', + ); + } + assertLiveCatalogApproval({ + catalog: frozenExecutionCatalog, + approval: liveApproval, + grossAtomic: priceAtomic, + }); + executor = liveExecutorFactory(); + } + if (typeof executor !== 'function') { + throw new TypeError('Skill executor must be a function'); + } + + const readInvocationBody = async (c) => { + const cached = c.get('invocationBody'); + if (cached) return cached; + const raw = await c.req.text(); + const requestBodyBytes = Buffer.byteLength(raw, 'utf8'); + if (requestBodyBytes > MAX_REQUEST_BODY_BYTES) { + throw new ExecutionEconomicsError( + 'REQUEST_BODY_TOO_LARGE', + 'request body exceeds the pre-payment byte cap', + ); + } + let body; + try { + body = JSON.parse(raw); + } catch { + throw new ExecutionEconomicsError('INVALID_REQUEST', 'body must be JSON'); + } + if (!body || typeof body !== 'object' || Array.isArray(body) + || Object.getPrototypeOf(body) !== Object.prototype + || typeof body.input !== 'string' || !body.input) { + throw new ExecutionEconomicsError( + 'INVALID_REQUEST', + 'body must contain a non-empty string input', + ); + } + const execution = body.execution ?? {}; + if (!isExactPlainObject(execution, Object.keys(execution)) + || Object.keys(execution).some((key) => !Object.hasOwn(DEFAULT_EXECUTION, key))) { + throw new ExecutionEconomicsError( + 'EXECUTION_REQUEST_SCHEMA', + 'execution options contain unknown fields', + ); + } + const parsed = { body, requestBodyBytes }; + c.set('invocationBody', parsed); + return parsed; + }; + + const buildQuote = async (c) => { + const { body, requestBodyBytes } = await readInvocationBody(c); + const requested = { ...DEFAULT_EXECUTION, ...(body.execution ?? {}) }; + const promptBound = conservativeProviderPromptBound({ + systemPrompt: skillContent, + userInput: body.input, + requestBodyBytes, + maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + maxInputTokens: requested.maxInputTokens, + }); + return createExecutionQuote({ + schemaVersion: 2, + grossAtomic: priceAtomic, + model: requested.model, + maxInputTokens: requested.maxInputTokens, + maxOutputTokens: requested.maxOutputTokens, + promptBytes: promptBound.promptBytes, + estimatedInputTokens: promptBound.estimatedInputTokens, + settlementCostAtomic: SETTLEMENT_COST_ATOMIC, + refundReserveAtomic: REFUND_RESERVE_ATOMIC, + protocolFeeBps: 250, + leafSkillId: SKILL_ID, + skillId: SKILL_ID, + skillVersion: SKILL_VERSION, + artifactHash: artifactDigest(skillContent), + skills: royaltyGraph, + catalog: frozenExecutionCatalog, + }); + }; const lifecycle = { - async onOffered({ idempotencyKey, requirements, expiresAt }) { + async onOffered({ idempotencyKey, requirements, expiresAt, executionQuote }) { journal.requestInvocation({ idempotencyKey, mode: 'external', @@ -176,40 +293,85 @@ export function createCollar({ requirementsHash: hash(canonicalJson(requirements)), expiresAt, requirements, + executionQuote, }); }, - async loadFrozenOffer({ idempotencyKey }) { - return journal.getByIdempotencyKey(idempotencyKey)?.quote?.requirements ?? null; + async loadFrozenOffer({ idempotencyKey, paymentHeaderPresent = false }) { + const record = journal.getByIdempotencyKey(idempotencyKey); + const persistedQuote = record?.quote; + if (!persistedQuote) return null; + if (record.schemaVersion === 1 && !paymentHeaderPresent) { + throw new Error('legacy v1 frozen offers cannot authorize a new payment'); + } + return { + requirements: persistedQuote.requirements, + executionQuote: persistedQuote.executionQuote ?? null, + }; }, - async onSigned({ idempotencyKey, settlementReference, payer, requirements }) { + async onSigned({ + idempotencyKey, settlementReference, payer, requirements, executionQuote, + }) { const existing = journal.getByIdempotencyKey(idempotencyKey); if (!existing?.quote) throw new Error('paid retry has no prior quoted Invocation'); if (existing.quote.requirementsHash !== hash(canonicalJson(requirements)) || existing.quote.requestHash !== requirements.extra.requestHash) { throw new Error('paid retry does not match the frozen x402 requirements'); } - const claim = journal.claimExternalPaymentSigned(idempotencyKey, { settlementReference, payer }); - const record = claim.record; - if (TERMINAL.has(record.execution.state)) { - if (!['settled', 'refunded'].includes(record.payment.state) || !record.payment.txHash) { + if (existing.schemaVersion === 2 + && canonicalJson(existing.quote.executionQuote) !== canonicalJson(executionQuote)) { + throw new Error('paid retry does not match the frozen execution quote'); + } + // A terminal record is immutable historical authority. Replay it before + // consulting current catalog or artifact configuration. + if (TERMINAL.has(existing.execution.state)) { + if (!['settled', 'refunded'].includes(existing.payment.state) || !existing.payment.txHash) { throw new Error('terminal Invocation does not carry a replayable settled payment'); } return { kind: 'terminal', - paymentState: record.payment.state, - receipt: record.receipt ?? journal.issueReceipt(idempotencyKey), - txHash: record.payment.txHash, - payer: record.payment.payer, - httpStatus: record.execution.httpStatus, + paymentState: existing.payment.state, + receipt: existing.receipt ?? journal.issueReceipt(idempotencyKey), + txHash: existing.payment.txHash, + payer: existing.payment.payer, + httpStatus: existing.execution.httpStatus, }; } - if (record.execution.state === 'executing') { - return { kind: 'execution_unresolved', executionAttemptId: record.execution.executionAttemptId }; + if (existing.execution.state === 'executing') { + return { + kind: 'execution_unresolved', + executionAttemptId: existing.execution.executionAttemptId, + }; + } + if (existing.schemaVersion === 1) { + if (existing.payment.state === 'settled' && existing.execution.state === 'authorized') { + return { + kind: 'settled', + txHash: existing.payment.txHash, + payer: existing.payment.payer, + legacySchemaVersion: 1, + }; + } + throw new Error('legacy v1 nonterminal payment cannot continue'); } + // Before the seller records payment.signed or calls the facilitator, the + // current execution identity must still match the persisted full quote. + assertFrozenExecutionIdentity({ + quote: executionQuote, + skillId: SKILL_ID, + skillVersion: SKILL_VERSION, + artifactContent: skillContent, + skills: royaltyGraph, + catalog: frozenExecutionCatalog, + }); + const claim = journal.claimExternalPaymentSigned(idempotencyKey, { settlementReference, payer }); + const record = claim.record; if (record.payment.state === 'unresolved' || (!claim.claimed && record.payment.state === 'signed')) { - return { kind: 'payment_unresolved', settlementReference: record.payment.settlementReference }; + return { + kind: 'payment_unresolved', + settlementReference: record.payment.settlementReference, + }; } if (record.payment.state === 'settled' && record.execution.state === 'authorized') { return { @@ -221,7 +383,14 @@ export function createCollar({ return null; }, - async onSettled({ idempotencyKey, settlementReference, txHash, payer }) { + async onSettled({ + idempotencyKey, settlementReference, txHash, payer, executionQuote, + }) { + const persisted = journal.getByIdempotencyKey(idempotencyKey); + if (persisted?.schemaVersion === 2 + && canonicalJson(persisted.quote.executionQuote) !== canonicalJson(executionQuote)) { + throw new Error('settlement does not match the frozen execution quote'); + } await lifecycleFaults.beforeSettlementRecorded?.({ idempotencyKey, settlementReference, txHash, payer }); const record = journal.markExternalPaymentSettled(idempotencyKey, { settlementReference, @@ -463,6 +632,7 @@ export function createCollar({ }, x402Paywall({ price: priceUsdc, + quote: buildQuote, payTo, facilitatorTransport, description: `hosted-skill Invocation: ${SKILL_ID}`, @@ -479,7 +649,7 @@ export function createCollar({ }, 503); } const executionAttemptId = claim.record.execution.executionAttemptId; - const finishFailure = (failureClass, message, status) => { + const finishFailure = (failureClass, message, status, accounting) => { journal.finishExecution(key, { executionAttemptId, outcome: 'failed', @@ -487,14 +657,48 @@ export function createCollar({ message, outcomeHash: null, httpStatus: status, - accounting: pendingFailureAccounting(payment.amountAtomic), + accounting, }); return c.json({ error: message, receipt: journal.issueReceipt(key) }, status); }; - const body = await c.req.json().catch(() => null); - if (typeof body?.input !== 'string' || !body.input) { - return finishFailure('INVALID_REQUEST', 'body must be JSON: { "input": "..." }', 400); + if (payment.legacySchemaVersion === 1) { + return finishFailure( + 'LEGACY_ACCOUNTING_UNSUPPORTED', + 'legacy settled Invocation cannot execute without a frozen COGS quote', + 500, + legacyPendingFailureAccounting(payment.amountAtomic), + ); + } + + const { body } = await readInvocationBody(c); + const frozenQuote = payment.executionQuote; + try { + assertFrozenExecutionIdentity({ + quote: frozenQuote, + skillId: SKILL_ID, + skillVersion: SKILL_VERSION, + artifactContent: skillContent, + skills: royaltyGraph, + catalog: frozenExecutionCatalog, + }); + } catch (error) { + const failureClass = error instanceof ExecutionEconomicsError + ? error.code + : 'EXECUTION_IDENTITY_DRIFT'; + const accounting = createPendingExecutionAccounting({ + quote: frozenQuote, + usage: null, + failureClass, + reason: 'frozen execution identity changed after settlement', + catalog: frozenExecutionCatalog, + }); + return finishFailure( + failureClass, + 'frozen execution identity changed after settlement', + 500, + accounting, + ); } let execution; @@ -505,35 +709,114 @@ export function createCollar({ skillContent, input: body.input, executionAttemptId, + model: frozenQuote.model, + maxInputTokens: frozenQuote.maxInputTokens, + maxOutputTokens: frozenQuote.maxOutputTokens, + promptBytes: frozenQuote.promptBytes, + estimatedInputTokens: frozenQuote.estimatedInputTokens, }); } catch (error) { - return finishFailure('UPSTREAM_500', 'Skill execution failed after settlement', 500); - } - if (!execution || typeof execution.output !== 'string') { - return finishFailure('INVALID_EXECUTOR_RESULT', 'executor must return { output: string }', 500); + let retainedUsage = null; + try { retainedUsage = error?.usage ?? null; } catch { retainedUsage = null; } + const accounting = createPendingExecutionAccounting({ + quote: frozenQuote, + usage: retainedUsage, + failureClass: 'UPSTREAM_PROVIDER_ERROR', + reason: 'provider execution failed after settlement', + catalog: frozenExecutionCatalog, + }); + return finishFailure( + 'UPSTREAM_PROVIDER_ERROR', + 'Skill execution failed after settlement', + 500, + accounting, + ); } await lifecycleFaults.afterExecutorReturned?.({ idempotencyKey: key, executionAttemptId }); - const allocation = allocateExternalGross({ - grossAtomic: BigInt(payment.amountAtomic), - executionCostAtomic: 0n, - settlementCostAtomic: 0n, - protocolFeeBps: 250, - refundReserveAtomic: 0n, - leafSkillId: SKILL_ID, - skills: royaltyGraph, - }); + let capturedExecution = null; + try { capturedExecution = structuredClone(execution); } catch { /* invalid result below */ } + if (!isExactPlainObject(capturedExecution, ['output', 'usage']) + || typeof capturedExecution.output !== 'string' + || !(capturedExecution.usage === null + || (capturedExecution.usage && typeof capturedExecution.usage === 'object'))) { + const accounting = createPendingExecutionAccounting({ + quote: frozenQuote, + usage: capturedExecution?.usage ?? null, + failureClass: 'INVALID_EXECUTOR_RESULT', + reason: 'executor returned an invalid result', + catalog: frozenExecutionCatalog, + }); + return finishFailure( + 'INVALID_EXECUTOR_RESULT', + 'executor must return exactly output:string and usage:object|null', + 500, + accounting, + ); + } + + let accounting; + try { + accounting = finalizeExecutionAccounting({ + quote: frozenQuote, + usage: capturedExecution.usage, + unknownReason: 'provider response omitted usage', + leafSkillId: SKILL_ID, + skills: royaltyGraph, + catalog: frozenExecutionCatalog, + }); + } catch (error) { + if (!(error instanceof ExecutionEconomicsError)) throw error; + const pending = createPendingExecutionAccounting({ + quote: frozenQuote, + usage: capturedExecution.usage, + failureClass: error.code, + reason: 'provider usage violated the frozen execution quote', + catalog: frozenExecutionCatalog, + }); + return finishFailure( + error.code, + 'provider usage violated the frozen execution quote', + 500, + pending, + ); + } + if (accounting.executionCogs.status === 'unknown') { + return finishFailure( + 'COGS_UNKNOWN', + 'provider usage is unavailable; full gross held pending trusted COGS reconciliation or refund', + 500, + accounting, + ); + } + try { + assertArtifactNotSerialized({ output: capturedExecution.output, artifact: skillContent }); + } catch { + const pending = createPendingExecutionAccounting({ + quote: frozenQuote, + usage: capturedExecution.usage, + failureClass: 'ARTIFACT_SERIALIZATION', + reason: 'direct artifact serialization detected', + catalog: frozenExecutionCatalog, + }); + return finishFailure( + 'ARTIFACT_SERIALIZATION', + 'Skill output violated the direct artifact serialization boundary', + 500, + pending, + ); + } journal.finishExecution(key, { executionAttemptId, outcome: 'succeeded', failureClass: null, message: null, - outcomeHash: hash(execution.output), + outcomeHash: hash(capturedExecution.output), httpStatus: 200, - accounting: serializeAccounting(allocation), + accounting, }); await lifecycleFaults.afterExecutionFinished?.({ idempotencyKey: key, executionAttemptId }); - return c.json({ output: execution.output, receipt: journal.issueReceipt(key) }); + return c.json({ output: capturedExecution.output, receipt: journal.issueReceipt(key) }); }, ); @@ -551,26 +834,76 @@ function mockSkillOutput(input) { ].join('\n'); } -async function runSkillViaAnthropic(skillContent, input) { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) throw new Error('ANTHROPIC_API_KEY required unless MOCK_LLM=1'); - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - model: 'claude-sonnet-4-6', - max_tokens: 2048, - system: skillContent, - messages: [{ role: 'user', content: String(input) }], - }), - }); - if (!response.ok) throw new Error(`Anthropic API returned HTTP ${response.status}`); - const data = await response.json(); - return data.content?.map((block) => block.text ?? '').join('') ?? ''; +export function createAnthropicExecutor({ + apiKey = process.env.ANTHROPIC_API_KEY, + fetchImpl = fetch, +} = {}) { + if (typeof apiKey !== 'string' || !apiKey) { + throw new Error('Anthropic API key is required for the live executor'); + } + if (typeof fetchImpl !== 'function') throw new TypeError('Anthropic executor requires fetch'); + return async ({ + skillContent, + input, + model, + maxInputTokens, + maxOutputTokens, + promptBytes, + estimatedInputTokens, + }) => { + const rebound = conservativeProviderPromptBound({ + systemPrompt: skillContent, + userInput: input, + requestBodyBytes: 0, + maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + maxInputTokens, + }); + if (rebound.promptBytes !== promptBytes + || rebound.estimatedInputTokens !== estimatedInputTokens) { + throw new ExecutionEconomicsError( + 'FROZEN_PROMPT_MISMATCH', + 'provider prompt differs from the accepted quote', + ); + } + let response; + try { + response = await fetchImpl('https://api.anthropic.com/v1/messages', { + method: 'POST', + redirect: 'error', + headers: { + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + max_tokens: maxOutputTokens, + system: skillContent, + messages: [{ role: 'user', content: input }], + }), + }); + } catch { + throw new ExecutionEconomicsError('UPSTREAM_PROVIDER_ERROR', 'provider request failed'); + } + if (!response?.ok) { + throw new ExecutionEconomicsError('UPSTREAM_PROVIDER_ERROR', 'provider request failed'); + } + let data; + try { data = await response.json(); } catch { + throw new ExecutionEconomicsError('UPSTREAM_PROVIDER_ERROR', 'provider response was not JSON'); + } + return { + output: Array.isArray(data?.content) + ? data.content.map((block) => (typeof block?.text === 'string' ? block.text : '')).join('') + : '', + usage: data?.usage ? { + schemaVersion: 2, + model, + inputTokens: data.usage.input_tokens, + outputTokens: data.usage.output_tokens, + } : null, + }; + }; } export function startCollar({ port = 0, ...options } = {}) { diff --git a/spikes/pi-wielder/src/execution-economics.mjs b/spikes/pi-wielder/src/execution-economics.mjs index db3d80e..a95406d 100644 --- a/spikes/pi-wielder/src/execution-economics.mjs +++ b/spikes/pi-wielder/src/execution-economics.mjs @@ -390,10 +390,13 @@ export function createExecutionQuote(input) { return frozenCopy({ quoteId: hash(body), ...body }); } -function pendingUsage(input, catalog) { +function pendingUsage(input, catalog, quote) { if (input == null) return { actual: null, usage: null }; - const usage = normalizeUsage(input); try { + const usage = normalizeUsage(input); + if (catalog.version !== quote.catalogVersion || catalogDigest(catalog) !== quote.catalogDigest) { + return { actual: null, usage: null }; + } return { actual: usageCostAtomic(usage, catalog), usage }; } catch { return { actual: null, usage: null }; @@ -406,7 +409,7 @@ export function createPendingExecutionAccounting(input) { 'ACCOUNTING_SCHEMA', 'pending execution accounting'); const quote = assertExecutionQuote(captured.quote); const catalog = captured.catalog ?? EXECUTION_CATALOG; - const { actual, usage } = pendingUsage(captured.usage ?? null, catalog); + const { actual, usage } = pendingUsage(captured.usage ?? null, catalog, quote); const quotedWorstCase = BigInt(quote.worstCaseExecutionCostAtomic); const overrun = actual != null && actual > quotedWorstCase ? actual - quotedWorstCase : 0n; const result = { diff --git a/spikes/pi-wielder/tests/collar-cogs.test.mjs b/spikes/pi-wielder/tests/collar-cogs.test.mjs new file mode 100644 index 0000000..131ca5b --- /dev/null +++ b/spikes/pi-wielder/tests/collar-cogs.test.mjs @@ -0,0 +1,367 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import test from 'node:test'; + +import { + createAnthropicExecutor, + createCollar, + SKILL_ID, +} from '../src/collar.mjs'; +import { + catalogDigest, + EXECUTION_CATALOG, +} from '../src/execution-economics.mjs'; +import { createMockFacilitator } from '../src/facilitator-mock.mjs'; +import { + createInvocationJournal, + createReceiptSigner, +} from '../src/invocation-journal.mjs'; +import { + createProxy, + payingFetch, +} from '../src/proxy.mjs'; +import { throwawayAccount } from '../src/wallet.mjs'; +import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; +import { paymentPolicyFor } from './payment-policy-fixture.mjs'; + +const PAY_TO = '0x000000000000000000000000000000000000dead'; + +function stack(collarOptions = {}) { + const facilitator = createMockFacilitator(); + const transport = createMockFacilitatorTransport( + (url, init) => facilitator.request(url, init), + ); + const collar = createCollar({ facilitatorTransport: transport, ...collarOptions }); + const proxy = createProxy({ + account: throwawayAccount(), + collarUrl: 'http://collar.test', + collarFetch: (url, init) => collar.app.request(url, init), + gatewayFetch: async () => { throw new Error('model gateway must not run'); }, + trustedCollarPublicKeyPem: collar.journal.signingPublicKeyPem, + trustedCollarKeyId: collar.journal.signingKeyId, + }); + return { facilitator, collar, proxy }; +} + +async function invoke(proxy, execution = undefined) { + const body = execution === undefined + ? { input: 'optimize this prompt' } + : { input: 'optimize this prompt', execution }; + const res = await proxy.app.request(`http://proxy.test/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { res, body: await res.json() }; +} + +test('known provider usage is charged before the Royalty pool and binds exact output bytes', async () => { + const services = stack(); + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 200); + assert.equal(typeof body.output, 'string'); + const receipt = body.receipt.receipt; + const accounting = receipt.accounting; + assert.equal(receipt.schemaVersion, 2); + assert.equal(accounting.schemaVersion, 2); + assert.equal(accounting.executionCogs.status, 'known'); + assert.equal(accounting.executionCogs.actualAtomic, '756'); + assert.equal(accounting.executionCostAtomic, '756'); + assert.equal(accounting.settlementCostAtomic, '1000'); + assert.equal(accounting.royaltyPoolAtomic, '236994'); + assert.equal(accounting.protocolFeeAtomic, '6250'); + assert.equal(accounting.refundReserveAtomic, '5000'); + assert.equal(accounting.contributionMarginAtomic, '6250'); + assert.equal(receipt.quote.executionQuote.quoteId, accounting.quoteId); + assert.equal(receipt.execution.outcomeHash, `sha256:${crypto.createHash('sha256').update(body.output).digest('hex')}`); + assert.equal( + BigInt(accounting.executionCostAtomic) + + BigInt(accounting.settlementCostAtomic) + + BigInt(accounting.protocolFeeAtomic) + + BigInt(accounting.royaltyPoolAtomic) + + BigInt(accounting.refundReserveAtomic), + 250_000n, + ); + assert.equal( + accounting.journalEntries.reduce((sum, entry) => sum + BigInt(entry.amountAtomic), 0n), + 250_000n, + ); +}); + +test('missing usage fails settled execution, emits no output, and holds the full gross', async () => { + const services = stack({ + executeSkill: async () => ({ output: 'safe output', usage: null }), + }); + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 500); + assert.equal(body.output, undefined); + assert.equal(body.receipt.receipt.execution.failureClass, 'COGS_UNKNOWN'); + const accounting = body.receipt.receipt.accounting; + assert.equal(accounting.executionCogs.status, 'unknown'); + assert.equal(accounting.executionCogs.actualAtomic, null); + assert.equal(accounting.executionCogs.chargedAtomic, null); + assert.equal(accounting.executionCogs.quotedWorstCaseAtomic, '79872'); + assert.equal(accounting.royaltyPoolAtomic, '0'); + assert.deepEqual(accounting.holderCredits, []); + assert.deepEqual(accounting.ancestorCredits, []); + assert.equal(accounting.journalEntries[0].amountAtomic, accounting.grossAtomic); +}); + +test('synthetic pricing blocks live adapter construction even when live mode is requested', () => { + let constructions = 0; + assert.throws(() => createCollar({ + facilitatorTransport: createMockFacilitatorTransport(async () => { throw new Error('must not fetch'); }), + mockLlm: false, + allowLiveProvider: true, + liveExecutorFactory: () => { constructions += 1; return async () => ({ output: '', usage: null }); }, + }), (error) => error.code === 'LIVE_CATALOG_EVIDENCE'); + assert.equal(constructions, 0); +}); + +test('live approval is rechecked against canonical catalog bytes before adapter construction', () => { + const catalog = structuredClone(EXECUTION_CATALOG); + Object.assign(catalog, { + evidenceLabel: 'human_verified', + source: 'https://provider.example/pricing/2026-07-17', + asOf: '2026-07-17T00:00:00.000Z', + }); + const liveApproval = { catalogDigest: catalogDigest(catalog), spendCapAtomic: '250000' }; + catalog.models['claude-sonnet-4-6'].outputAtomicPerMillionTokens = '15000001'; + let constructions = 0; + assert.throws(() => createCollar({ + facilitatorTransport: createMockFacilitatorTransport(async () => { throw new Error('must not fetch'); }), + mockLlm: false, + allowLiveProvider: true, + executionCatalog: catalog, + liveApproval, + liveExecutorFactory: () => { constructions += 1; return async () => {}; }, + }), (error) => error.code === 'LIVE_CATALOG_DIGEST'); + assert.equal(constructions, 0); +}); + +test('a restarted Collar rejects persisted nonterminal quote drift before facilitator or provider calls', async () => { + const facilitator = createMockFacilitator(); + let facilitatorCalls = 0; + let providerCalls = 0; + const transport = createMockFacilitatorTransport((url, init) => { + facilitatorCalls += 1; + return facilitator.request(url, init); + }); + const journal = createInvocationJournal({ signer: createReceiptSigner() }); + const beforeRestart = createCollar({ facilitatorTransport: transport, journal }); + const changedCatalog = structuredClone(EXECUTION_CATALOG); + changedCatalog.models['claude-sonnet-4-6'].outputAtomicPerMillionTokens = '15000001'; + const afterRestart = createCollar({ + facilitatorTransport: transport, + journal, + executionCatalog: changedCatalog, + executeSkill: async () => { providerCalls += 1; return { output: 'must not run', usage: null }; }, + }); + let sellerRequests = 0; + const sellerUrl = `http://seller.test/invoke/${SKILL_ID}`; + await assert.rejects(() => payingFetch(throwawayAccount(), sellerUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'same frozen request' }), + }, { + idempotencyKey: 'restart-catalog-drift', + paymentPolicy: paymentPolicyFor(sellerUrl, PAY_TO), + fetchImpl: (url, init) => (++sellerRequests === 1 ? beforeRestart.app : afterRestart.app).request(url, init), + }), (error) => error.code === 'SETTLEMENT_EVIDENCE'); + assert.equal(facilitatorCalls, 0); + assert.equal(providerCalls, 0); + assert.equal(journal.events.some((event) => event.type === 'payment.signed'), false); +}); + +test('terminal replay precedes current catalog drift checks and never re-executes', async () => { + const facilitator = createMockFacilitator(); + let facilitatorCalls = 0; + let providerCalls = 0; + const transport = createMockFacilitatorTransport((url, init) => { + facilitatorCalls += 1; + return facilitator.request(url, init); + }); + const journal = createInvocationJournal({ signer: createReceiptSigner() }); + const beforeRestart = createCollar({ + facilitatorTransport: transport, + journal, + executeSkill: async () => { + providerCalls += 1; + return { + output: 'terminal output', + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, + }; + }, + }); + const sellerUrl = `http://seller.test/invoke/${SKILL_ID}`; + const idempotencyKey = 'terminal-replay-before-drift'; + const requestBody = JSON.stringify({ input: 'same frozen request' }); + const first = await payingFetch(throwawayAccount(), sellerUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey, + paymentPolicy: paymentPolicyFor(sellerUrl, PAY_TO), + fetchImpl: (url, init) => beforeRestart.app.request(url, init), + }); + assert.equal(first.res.status, 200); + const changedCatalog = structuredClone(EXECUTION_CATALOG); + changedCatalog.models['claude-sonnet-4-6'].outputAtomicPerMillionTokens = '15000001'; + const afterRestart = createCollar({ + facilitatorTransport: transport, + journal, + executionCatalog: changedCatalog, + executeSkill: async () => { providerCalls += 1; throw new Error('must not run'); }, + }); + const callsAfterFirst = facilitatorCalls; + const replay = await afterRestart.app.request(sellerUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': first.xPayment, + }, + body: requestBody, + }); + assert.equal(replay.status, 200); + assert.equal((await replay.json()).replayed, true); + assert.equal(providerCalls, 1); + assert.equal(facilitatorCalls, callsAfterFirst); +}); + +test('Anthropic adapter sends frozen model/cap, emits strict v2 usage, and rejects before fetch', async () => { + const requests = []; + const executor = createAnthropicExecutor({ + apiKey: 'test-only', + fetchImpl: async (_url, init) => { + requests.push(JSON.parse(init.body)); + return { + ok: true, + json: async () => ({ + content: [{ text: 'ok' }], + usage: { input_tokens: 11, output_tokens: 2 }, + }), + }; + }, + }); + const frozen = { model: 'claude-sonnet-4-6', maxInputTokens: 300, maxOutputTokens: 17 }; + assert.deepEqual(await executor({ + skillContent: 'system', input: 'hello', ...frozen, promptBytes: 11, estimatedInputTokens: 267, + }), { + output: 'ok', + usage: { schemaVersion: 2, model: frozen.model, inputTokens: 11, outputTokens: 2 }, + }); + assert.equal(requests[0].model, frozen.model); + assert.equal(requests[0].max_tokens, frozen.maxOutputTokens); + await assert.rejects(executor({ + skillContent: 'x'.repeat(45), input: '', ...frozen, promptBytes: 45, estimatedInputTokens: 301, + }), (error) => error.code === 'PROMPT_TOKEN_BOUND'); + assert.equal(requests.length, 1); + assert.throws(() => createAnthropicExecutor({ apiKey: '', fetchImpl: async () => {} }), /API key/); +}); + +test('body, complete-prompt, model, and token caps reject before a 402 offer', async () => { + for (const [body, status, code] of [ + [{ input: 'x'.repeat(4097) }, 413, 'REQUEST_BODY_TOO_LARGE'], + [{ input: 'x', execution: { maxInputTokens: 300 } }, 400, 'PROMPT_TOKEN_BOUND'], + [{ input: 'x', execution: { model: 'unlisted-model' } }, 400, 'MODEL_NOT_ALLOWED'], + [{ input: 'x', execution: { maxOutputTokens: 2049 } }, 400, 'TOKEN_LIMIT'], + ]) { + const facilitator = createMockFacilitator(); + const collar = createCollar({ + facilitatorTransport: createMockFacilitatorTransport((url, init) => facilitator.request(url, init)), + }); + const res = await collar.app.request(`http://collar.test/invoke/${SKILL_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, + body: JSON.stringify(body), + }); + assert.equal(res.status, status); + assert.equal((await res.json()).code, code); + assert.equal(collar.journal.events.length, 0); + } +}); + +test('above-cap known usage fails with retained COGS, no output, and no Royalty credits', async () => { + const services = stack({ + executeSkill: async () => ({ + output: 'must not escape', + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 16384, outputTokens: 2049 }, + }), + }); + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 500); + assert.equal(body.output, undefined); + assert.equal(body.receipt.receipt.payment.state, 'settled'); + assert.equal(body.receipt.receipt.execution.failureClass, 'USAGE_EXCEEDS_QUOTE'); + const accounting = body.receipt.receipt.accounting; + assert.equal(accounting.allocationState, 'pending_cogs_reconciliation'); + assert.equal(accounting.executionCogs.status, 'known'); + assert.equal(accounting.executionCogs.actualAtomic, '79887'); + assert.equal(accounting.executionCogs.accruedOverrunAtomic, '15'); + assert.equal(accounting.royaltyPoolAtomic, '0'); + assert.deepEqual(accounting.holderCredits, []); + assert.equal(accounting.journalEntries[0].amountAtomic, accounting.grossAtomic); +}); + +test('provider failures are sanitized and malformed known usage remains held without output', async () => { + const secret = 'sk-provider-secret-do-not-leak'; + for (const [executeSkill, failureClass, expectedCogs] of [[ + async () => { throw Object.assign(new Error(`upstream failed ${secret}`), { code: 'ARBITRARY_SECRET_CODE' }); }, + 'UPSTREAM_PROVIDER_ERROR', + 'unknown', + ], [ + async () => ({ + output: 42, + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, + }), + 'INVALID_EXECUTOR_RESULT', + 'known', + ]]) { + const services = stack({ executeSkill }); + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 500); + assert.equal(body.output, undefined); + assert.equal(body.receipt.receipt.execution.failureClass, failureClass); + assert.equal(body.receipt.receipt.accounting.executionCogs.status, expectedCogs); + assert.equal(body.receipt.receipt.accounting.royaltyPoolAtomic, '0'); + assert.equal(JSON.stringify(body).includes(secret), false); + assert.equal(JSON.stringify(services.collar.journal.events).includes(secret), false); + } +}); + +test('direct artifact serialization fails with known held COGS and emits no artifact bytes', async () => { + let artifact = null; + const services = stack({ + executeSkill: async ({ skillContent }) => { + artifact = skillContent; + return { + output: skillContent, + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, + }; + }, + }); + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 500); + assert.equal(body.output, undefined); + assert.equal(body.receipt.receipt.execution.failureClass, 'ARTIFACT_SERIALIZATION'); + assert.equal(body.receipt.receipt.accounting.executionCogs.status, 'known'); + assert.equal(body.receipt.receipt.accounting.executionCogs.actualAtomic, '756'); + assert.equal(JSON.stringify(body).includes(artifact.slice(0, 200)), false); +}); + +test('Collar snapshots the exact catalog before an executor can mutate caller-owned rates', async () => { + const catalog = structuredClone(EXECUTION_CATALOG); + const services = stack({ + executionCatalog: catalog, + executeSkill: async () => { + catalog.models['claude-sonnet-4-6'].outputAtomicPerMillionTokens = '999999999'; + return { + output: 'safe output', + usage: { schemaVersion: 2, model: 'claude-sonnet-4-6', inputTokens: 42, outputTokens: 42 }, + }; + }, + }); + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 200); + assert.equal(body.receipt.receipt.accounting.executionCogs.actualAtomic, '756'); + assert.equal(body.receipt.receipt.quote.executionQuote.catalogDigest, catalogDigest(EXECUTION_CATALOG)); +}); diff --git a/spikes/pi-wielder/tests/collar-failure.test.mjs b/spikes/pi-wielder/tests/collar-failure.test.mjs index 63aa1fe..b4a1140 100644 --- a/spikes/pi-wielder/tests/collar-failure.test.mjs +++ b/spikes/pi-wielder/tests/collar-failure.test.mjs @@ -5,7 +5,12 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { chooseFacilitator, createCollar, SKILL_ID } from '../src/collar.mjs'; +import { + chooseFacilitator, + createAnthropicExecutor, + createCollar, + SKILL_ID, +} from '../src/collar.mjs'; import { createMockFacilitator } from '../src/facilitator-mock.mjs'; import { verifySignedReceipt } from '../src/invocation-journal.mjs'; import { payingFetch as policyPayingFetch } from '../src/proxy.mjs'; @@ -19,6 +24,12 @@ import { paymentPolicyFor } from './payment-policy-fixture.mjs'; const invokeUrl = `http://collar.test/invoke/${SKILL_ID}`; const requestBody = JSON.stringify({ input: 'same bytes' }); +const KNOWN_USAGE = Object.freeze({ + schemaVersion: 2, + model: 'claude-sonnet-4-6', + inputTokens: 42, + outputTokens: 42, +}); const payingFetch = (account, url, init, options = {}) => policyPayingFetch(account, url, init, { paymentPolicy: paymentPolicyFor(url), ...options, @@ -255,7 +266,10 @@ test('response-loss reconciliation advances once and exact retry never duplicate txHash: lostSettlement.transaction, payer, }), - executeSkill: async ({ input }) => { executions += 1; return { output: `executed ${input}` }; }, + executeSkill: async ({ input }) => { + executions += 1; + return { output: `executed ${input}`, usage: KNOWN_USAGE }; + }, }); const idempotencyKey = 'idem-response-loss'; const first = await withheldPayingFetch(throwawayAccount(), invokeUrl, { @@ -334,7 +348,7 @@ test('crash after the provider returns leaves one unresolved attempt and never c const prepared = await prepareReconciledRetry({ executeSkill: async () => { executions += 1; - return { output: 'completed but not journaled' }; + return { output: 'completed but not journaled', usage: KNOWN_USAGE }; }, lifecycleFaults: { afterExecutorReturned: async () => { throw new Error('crash after provider return'); }, @@ -357,7 +371,7 @@ test('crash after finish but before receipt issuance replays without another pro const prepared = await prepareReconciledRetry({ executeSkill: async () => { executions += 1; - return { output: 'journaled output' }; + return { output: 'journaled output', usage: KNOWN_USAGE }; }, lifecycleFaults: { afterExecutionFinished: async () => { @@ -394,7 +408,7 @@ test('overlapping paid retries atomically claim one execution attempt', async () assert.match(executionAttemptId, /^attempt:/); announceStarted(); await gate; - return { output: 'one output' }; + return { output: 'one output', usage: KNOWN_USAGE }; }, }); const winner = prepared.retry(); @@ -775,31 +789,25 @@ test('facilitator verification detail is absent from the response and durable jo test('Anthropic error response bodies are never copied into the failed receipt', async () => { const responseSecret = 'sk-ant-secret-inside-upstream-body'; - const previousFetch = globalThis.fetch; - const previousKey = process.env.ANTHROPIC_API_KEY; - process.env.ANTHROPIC_API_KEY = 'test-only-key'; - globalThis.fetch = async (url) => { - assert.equal(url, 'https://api.anthropic.com/v1/messages'); - return new Response(JSON.stringify({ error: responseSecret }), { - status: 500, - headers: { 'content-type': 'application/json' }, - }); - }; - try { - const collar = createCollar({ facilitatorTransport: mockTransport(), mockLlm: false }); - const result = await payingFetch(throwawayAccount(), invokeUrl, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, - }, { - idempotencyKey: 'idem-anthropic-secret-body', - fetchImpl: (url, init) => collar.app.request(url, init), - }); - assert.equal(result.res.status, 500); - const text = await result.res.text(); - assert.doesNotMatch(text, new RegExp(responseSecret)); - assert.match(text, /Skill execution failed after settlement/); - } finally { - globalThis.fetch = previousFetch; - if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY; - else process.env.ANTHROPIC_API_KEY = previousKey; - } + const executeSkill = createAnthropicExecutor({ + apiKey: 'test-only-key', + fetchImpl: async (url) => { + assert.equal(url, 'https://api.anthropic.com/v1/messages'); + return new Response(JSON.stringify({ error: responseSecret }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + const collar = createCollar({ facilitatorTransport: mockTransport(), executeSkill }); + const result = await payingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey: 'idem-anthropic-secret-body', + fetchImpl: (url, init) => collar.app.request(url, init), + }); + assert.equal(result.res.status, 500); + const text = await result.res.text(); + assert.doesNotMatch(text, new RegExp(responseSecret)); + assert.match(text, /Skill execution failed after settlement/); }); diff --git a/spikes/pi-wielder/tests/execution-economics.test.mjs b/spikes/pi-wielder/tests/execution-economics.test.mjs index ef256d0..453a0ab 100644 --- a/spikes/pi-wielder/tests/execution-economics.test.mjs +++ b/spikes/pi-wielder/tests/execution-economics.test.mjs @@ -218,6 +218,15 @@ test('quote ID freezes Skill, artifact, Royalty graph, and catalog identity', () }); assert.equal(pending.executionCogs.catalogVersion, frozen.catalogVersion); assert.equal(pending.executionCogs.catalogDigest, frozen.catalogDigest); + const driftedKnownUsage = createPendingExecutionAccounting({ + quote: frozen, + usage: { schemaVersion: 2, model: frozen.model, inputTokens: 42, outputTokens: 42 }, + failureClass: 'CATALOG_DIGEST_DRIFT', + reason: 'current catalog changed', + catalog: driftedCatalog, + }); + assert.equal(driftedKnownUsage.executionCogs.status, 'unknown'); + assert.equal(driftedKnownUsage.executionCogs.actualAtomic, null); }); test('strict v2 quote and usage schemas reject inherited, unknown, and unsafe values', () => { From ede1d0e0962557738eb99be6ee815f02e666ca8a Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 12:43:46 -0400 Subject: [PATCH 126/165] docs: label hosted Skill execution economics --- spikes/pi-wielder/README.md | 34 +++++++++++++++++++++------------- spikes/pi-wielder/RUNBOOK.md | 13 +++++++++++++ spikes/pi-wielder/e2e.mjs | 32 ++++++++++++++++++++++++++++---- spikes/pi-wielder/package.json | 1 + 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index f948acc..1f061b6 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -74,8 +74,10 @@ socket: 2. The Wielder signs one EIP-3009 authorization per challenge and retries with the same Wielder-owned idempotency key after the local payment policy reserves budget. 3. The Collar records one authoritative external Invocation and returns derived output - plus a signed receipt. The hosted `SKILL.md` bytes are read server-side and are not - directly returned. + plus a signed receipt. The Collar does not directly return or serialize the hosted + Skill artifact. A narrow runtime guard rejects full or long exact artifact fragments. + Model-output extraction can never be ruled out categorically; prompt-injection + resistance remains adversarial test evidence, not a secrecy guarantee. 4. An exact terminal retry returns the same receipt without another settlement or Skill execution. Different request bytes under the same key return `409`. 5. A lost settlement response becomes `unresolved`; exact retries return `503` and do @@ -85,15 +87,19 @@ socket: projected from the signed receipt; a failed full-gross hold produces no invented creator or treasury claim. -The displayed successful mock session currently looks like: +For the deterministic mock fixture, a 250,000-atomic-USDC gross Invocation allocates +756 to synthetic-config execution COGS, 1,000 to settlement cost, 6,250 to protocol +fee, 5,000 to refund reserve, and 236,994 to the Royalty pool. These are mock accounting +values, not observed live provider economics. If provider usage is missing, the settled +Invocation fails `COGS_UNKNOWN`, emits no output or Royalty credits, and holds full gross +in `pending_cogs_reconciliation` until trusted reconciliation or refund. -```text -claude/plan $0.041 [succeeded] · gpt/implement $0.087 [succeeded] · skill/optimizing-claude-code-prompts $0.25 [succeeded] → creator $0.24375 / treasury $0.00625 - session receipt total $0.378 across 3 settled calls, one wallet -``` +The complete payer-side mock session totals 378,000 atomic USDC across two model calls +and one hosted-Skill Invocation. It is deliberately not described as a unified +authoritative ledger. -This is a payer-side view across independent sellers. It is deliberately not described -as a unified authoritative ledger. +The quoted execution catalog is immutable and versioned. Its initial rates are labeled +`synthetic_config`; they are proof fixtures rather than current provider pricing. ## Failure and refund semantics @@ -150,7 +156,7 @@ npm test npm run e2e ``` -Expected current results are 141 offline unit/integration tests and 30 offline e2e +Expected current results are 172 offline unit/integration tests and 41 offline e2e checks. Counts can increase as regressions are added; zero failures is the contract. The e2e labels all timing output synthetic and uses in-process Hono requests only. @@ -162,6 +168,7 @@ npm run test:collar npm run test:proxy npm run test:policy npm run test:payment +npm run test:economics ``` For standalone mock processes, persistent trust bootstrapping, and the intentionally @@ -198,9 +205,10 @@ blocked live boundary, see [RUNBOOK.md](./RUNBOOK.md). DER. A key ID or key embedded in a receipt cannot authenticate that receipt. - The Pi extension is a manual demo adapter and is not compiled by this spike's test suite. -- Successful mock accounting currently passes zero execution and settlement COGS into - the atomic allocator. That is explicit spike behavior, not a validated production - margin model. +- Successful mock accounting records synthetic-config provider usage and allocates + execution COGS and settlement cost before the Royalty pool. It is executable evidence + of ordering and conservation, not a validated production margin model or current + provider-price claim. ## Protocol implementation note diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md index 9b596d8..67a4ea8 100644 --- a/spikes/pi-wielder/RUNBOOK.md +++ b/spikes/pi-wielder/RUNBOOK.md @@ -146,6 +146,19 @@ reconciliation design exists. Do not manually rewrite the journal. ## 5. Live Base Sepolia boundary — intentionally blocked in the CLI +Before a live provider run, verify the current provider price sheet, add a new immutable +catalog version with `evidenceLabel: human_verified`, source, and as-of timestamp. Compute +its exact canonical `catalogDigest`, set that separately as `LIVE_CATALOG_DIGEST`, set an +atomic `LIVE_SPEND_CAP_ATOMIC`, then set `ALLOW_LIVE_PROVIDER=1` and `MOCK_LLM=0`. Supply +the provider credential only through the operator's secret injection. Do not embed approval +or spend authorization in the catalog itself. Never relabel +`synthetic-anthropic-2026-07-17-v1` as measured. Automated verification stays on the +mock facilitator and mock model and uses no real funds. + +Provider approval is separate from the x402 settlement gate below. Both gates must be +satisfied by a future integration; enabling either one does not implicitly authorize +the other. + Live mode is opt-in only: ```dotenv diff --git a/spikes/pi-wielder/e2e.mjs b/spikes/pi-wielder/e2e.mjs index 17a089b..bc67aa6 100644 --- a/spikes/pi-wielder/e2e.mjs +++ b/spikes/pi-wielder/e2e.mjs @@ -145,17 +145,41 @@ ok( ); const authoritative = collar.journal.getByTxHash(entries[2].txHash); eq(entries[2].receipt.receipt.invocationId, authoritative.invocationId, 'receipt view points to the authoritative Collar Invocation'); -const accounting = entries[2].receipt.receipt.accounting; +const skillReceipt = entries[2].receipt.receipt; +const skillAccounting = skillReceipt.accounting; +eq(skillReceipt.quote.schemaVersion, 2, 'Skill receipt uses the strict quote schema'); +eq( + skillReceipt.quote.executionQuote.quoteId, + skillAccounting.quoteId, + 'signed receipt accounting is bound to the accepted execution quote', +); +eq(skillAccounting.executionCogs.status, 'known', 'mock provider usage is explicitly known'); +eq(skillAccounting.executionCogs.actualAtomic, '756', 'mock provider COGS uses the versioned catalog'); +eq(skillAccounting.executionCostAtomic, '756', 'final allocation charges the known execution COGS'); +eq(skillAccounting.settlementCostAtomic, '1000', 'settlement cost is allocated before royalties'); +eq(skillAccounting.protocolFeeAtomic, '6250', 'protocol fee remains exact'); +eq(skillAccounting.refundReserveAtomic, '5000', 'refund reserve remains explicit'); +eq(skillAccounting.royaltyPoolAtomic, '236994', 'Royalty pool is the exact post-cost residual'); +eq(skillAccounting.contributionMarginAtomic, '6250', 'contribution margin remains the retained protocol fee'); +ok( + BigInt(skillAccounting.executionCostAtomic) + + BigInt(skillAccounting.settlementCostAtomic) + + BigInt(skillAccounting.protocolFeeAtomic) + + BigInt(skillAccounting.royaltyPoolAtomic) + + BigInt(skillAccounting.refundReserveAtomic) + === BigInt(skillAccounting.grossAtomic), + 'receipt accounting conserves 250,000 atomic USDC exactly', +); eq(entries[2].splits, [ - ...accounting.holderCredits.map((credit) => ({ + ...skillAccounting.holderCredits.map((credit) => ({ party: credit.recipientId, amountAtomic: credit.amountAtomic, })), - ...accounting.ancestorCredits.map((credit) => ({ + ...skillAccounting.ancestorCredits.map((credit) => ({ party: credit.recipientId, amountAtomic: credit.amountAtomic, })), - { party: 'treasury', amountAtomic: accounting.protocolFeeAtomic }, + { party: 'treasury', amountAtomic: skillAccounting.protocolFeeAtomic }, ], 'displayed claims are projected only from finalized signed accounting'); console.log('\nidempotency and replay:'); diff --git a/spikes/pi-wielder/package.json b/spikes/pi-wielder/package.json index da56ebd..34f90b5 100644 --- a/spikes/pi-wielder/package.json +++ b/spikes/pi-wielder/package.json @@ -11,6 +11,7 @@ "test:proxy": "node --test tests/proxy-trust.test.mjs", "test:policy": "node --test tests/payment-policy.test.mjs", "test:payment": "node --test tests/payment-policy.test.mjs tests/paying-fetch.test.mjs tests/seller-payment-response.test.mjs", + "test:economics": "node --test tests/execution-economics.test.mjs tests/artifact-boundary.test.mjs tests/collar-cogs.test.mjs", "e2e": "MOCK_LLM=1 node e2e.mjs", "collar": "node src/collar.mjs", "gateway": "node src/gateway.mjs", From 913ce0cabd5e2b389af60210274542e89e3efcab Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 13:00:59 -0400 Subject: [PATCH 127/165] fix: harden public demo validation boundaries --- hf-space/gradio/app.py | 2 +- hf-space/gradio/demo_logic.py | 21 +++++++---- hf-space/gradio/test_app_smoke.py | 29 +++++++++++++++ hf-space/gradio/test_demo_logic.py | 23 ++++++++++++ hf-space/static/index.html | 2 +- hf-space/static/test-index-smoke.mjs | 54 ++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 8 deletions(-) diff --git a/hf-space/gradio/app.py b/hf-space/gradio/app.py index 3cf1c5e..1a3a678 100644 --- a/hf-space/gradio/app.py +++ b/hf-space/gradio/app.py @@ -28,6 +28,7 @@ def check_live_402(): follow_redirects=False, ) body = response.json() + result = validate_live_402(response.status_code, body) except (httpx.HTTPError, ValueError, TypeError): return { "live": False, @@ -36,7 +37,6 @@ def check_live_402(): "error": "live endpoint request failed; no cached response is represented as live", "source": "live_request_failed_no_cache", } - result = validate_live_402(response.status_code, body) return {**result, "source": "live_http_response"} diff --git a/hf-space/gradio/demo_logic.py b/hf-space/gradio/demo_logic.py index 4d8ef35..807ca9f 100644 --- a/hf-space/gradio/demo_logic.py +++ b/hf-space/gradio/demo_logic.py @@ -12,7 +12,8 @@ LIVE_ENDPOINT = "https://neverhandedover.com/api/invoke/optimizing-claude-code-prompts" EXPECTED_PAY_TO = "0x25005dfac23d4bc45c801eaeb6c8b5a2bab0f189" EXPECTED_ASSET = "0x036cbd53842c5426634e7929541ec2318f3dcf7e" -_ATOMIC_PATTERN = re.compile(r"^(0|[1-9][0-9]*)$") +_ATOMIC_PATTERN = re.compile(r"^(0|[1-9][0-9]{0,77})$") +_MAX_ATOMIC_AMOUNT = (1 << 256) - 1 _ADDRESS_PATTERN = re.compile(r"^0x[0-9a-fA-F]{40}$") _SHA_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") _FIXTURE_NAMES = {"evidence.json", "public-demo-allocation.json"} @@ -128,6 +129,13 @@ def _invalid_402(status: Any) -> dict[str, Any]: } +def _bounded_atomic(value: Any) -> int | None: + if not isinstance(value, str) or not _ATOMIC_PATTERN.fullmatch(value): + return None + parsed = int(value) + return parsed if parsed <= _MAX_ATOMIC_AMOUNT else None + + def validate_live_402(status: Any, body: Any) -> dict[str, Any]: """Return a bounded view only when the fixed endpoint returns a valid x402 v1 offer.""" if status != 402 or isinstance(status, bool) or not isinstance(body, dict): @@ -139,14 +147,14 @@ def validate_live_402(status: Any, body: Any) -> dict[str, Any]: if not isinstance(offer, dict): return _invalid_402(status) amount = offer.get("maxAmountRequired") + amount_atomic = _bounded_atomic(amount) pay_to = offer.get("payTo") asset = offer.get("asset") if ( offer.get("scheme") != "exact" or offer.get("network") != "base-sepolia" - or not isinstance(amount, str) - or not _ATOMIC_PATTERN.fullmatch(amount) - or int(amount) <= 0 + or amount_atomic is None + or amount_atomic <= 0 or offer.get("resource") != LIVE_ENDPOINT or not isinstance(pay_to, str) or not _ADDRESS_PATTERN.fullmatch(pay_to) @@ -182,9 +190,10 @@ def scenario_by_id(fixture: dict[str, Any], scenario_id: str | None = None) -> d def _parse_atomic(value: Any, label: str) -> int: - if not isinstance(value, str) or not _ATOMIC_PATTERN.fullmatch(value): + parsed = _bounded_atomic(value) + if parsed is None: raise ValueError(f"invalid atomic amount: {label}") - return int(value) + return parsed def render_allocation(fixture: dict[str, Any], scenario_id: str | None = None) -> dict[str, Any]: diff --git a/hf-space/gradio/test_app_smoke.py b/hf-space/gradio/test_app_smoke.py index eb5168d..42b6537 100644 --- a/hf-space/gradio/test_app_smoke.py +++ b/hf-space/gradio/test_app_smoke.py @@ -81,6 +81,35 @@ def test_actual_wired_handler_distinguishes_live_non_402_and_failure(self): self.assertEqual(result["source"], "live_request_failed_no_cache") self.assertNotIn("offline", json_safe(result)) + def test_actual_wired_handler_returns_non_live_for_five_thousand_digit_amount(self): + invalid = { + **VALID_402, + "accepts": [{**VALID_402["accepts"][0], "maxAmountRequired": "9" * 5_000}], + } + with patch.object(httpx, "post", return_value=FakeResponse(402, invalid)): + result = self.app.check_live_402() + self.assertEqual( + result, + { + "live": False, + "status": 402, + "offer": None, + "error": "live endpoint did not return a valid 402 offer", + "source": "live_http_response", + }, + ) + + def test_actual_wired_handler_contains_validation_exceptions(self): + with patch.object(httpx, "post", return_value=FakeResponse(402, VALID_402)), patch.object( + self.app, + "validate_live_402", + side_effect=ValueError("invalid response boundary"), + ): + result = self.app.check_live_402() + self.assertFalse(result["live"]) + self.assertEqual(result["source"], "live_request_failed_no_cache") + self.assertNotIn("invalid response boundary", json_safe(result)) + def json_safe(value): return str(value).lower() diff --git a/hf-space/gradio/test_demo_logic.py b/hf-space/gradio/test_demo_logic.py index ffb2f94..81a78f0 100644 --- a/hf-space/gradio/test_demo_logic.py +++ b/hf-space/gradio/test_demo_logic.py @@ -35,6 +35,12 @@ } +def offer_with_amount(amount): + body = json.loads(json.dumps(VALID_402)) + body["accepts"][0]["maxAmountRequired"] = amount + return body + + class Live402ValidationTests(unittest.TestCase): def test_accepts_only_the_fixed_valid_402_offer(self): result = validate_live_402(402, VALID_402) @@ -67,6 +73,23 @@ def test_rejects_non_402_and_malformed_offers(self): self.assertFalse(result["live"]) self.assertIn("valid 402", result["error"]) + def test_rejects_five_thousand_digit_amount_without_raising(self): + result = validate_live_402(402, offer_with_amount("9" * 5_000)) + self.assertEqual( + result, + { + "live": False, + "status": 402, + "offer": None, + "error": "live endpoint did not return a valid 402 offer", + }, + ) + + def test_enforces_uint256_atomic_amount_boundary(self): + maximum = str((1 << 256) - 1) + self.assertTrue(validate_live_402(402, offer_with_amount(maximum))["live"]) + self.assertFalse(validate_live_402(402, offer_with_amount(str(1 << 256)))["live"]) + class FixtureTests(unittest.TestCase): def test_default_and_mode_statuses_are_fixture_controlled(self): diff --git a/hf-space/static/index.html b/hf-space/static/index.html index 7f59033..2b794d9 100644 --- a/hf-space/static/index.html +++ b/hf-space/static/index.html @@ -3,7 +3,7 @@ - + Skill Asset Protocol — verified accounting demo diff --git a/hf-space/static/test-index-smoke.mjs b/hf-space/static/test-index-smoke.mjs index 76ab071..743dec1 100644 --- a/hf-space/static/test-index-smoke.mjs +++ b/hf-space/static/test-index-smoke.mjs @@ -23,6 +23,31 @@ const VALID_402 = { }], }; +function parseCsp(document) { + const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]'); + assert.ok(meta, 'index.html must declare a Content Security Policy'); + const directives = new Map(); + for (const clause of meta.getAttribute('content').split(';')) { + const tokens = clause.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) continue; + const [name, ...sources] = tokens; + assert.equal(directives.has(name), false, `duplicate CSP directive: ${name}`); + directives.set(name, sources); + } + return directives; +} + +function cspAllows(policy, directive, rawUrl, documentOrigin) { + const sources = policy.get(directive) ?? policy.get('default-src') ?? []; + if (sources.includes("'none'")) return false; + const url = new URL(rawUrl, documentOrigin); + return sources.some((source) => { + if (source === "'self'") return url.origin === documentOrigin; + if (/^https:\/\//.test(source)) return url.origin === new URL(source).origin; + return false; + }); +} + function response(value, status = 200) { const bytes = Buffer.isBuffer(value) ? value : Buffer.from(JSON.stringify(value)); return { @@ -42,9 +67,33 @@ async function waitForClick(button) { assert.equal(button.disabled, false); } +test('static CSP permits only same-origin fixtures and the fixed live endpoint', async () => { + const html = await readFile(new URL('index.html', STATIC_ROOT), 'utf8'); + const { document } = parseHTML(html); + const policy = parseCsp(document); + const documentOrigin = 'https://skill-asset-protocol.example'; + + assert.deepEqual(policy.get('default-src'), ["'none'"]); + assert.deepEqual(policy.get('script-src'), ["'self'"]); + assert.deepEqual(policy.get('font-src'), ["'none'"]); + assert.deepEqual(policy.get('connect-src'), ["'self'", 'https://neverhandedover.com']); + + assert.equal(cspAllows(policy, 'script-src', './demo-logic.mjs', documentOrigin), true); + assert.equal(cspAllows(policy, 'script-src', 'https://attacker.example/code.js', documentOrigin), false); + assert.equal(cspAllows(policy, 'font-src', './font.woff2', documentOrigin), false); + assert.equal(cspAllows(policy, 'font-src', 'https://fonts.example/font.woff2', documentOrigin), false); + for (const url of LOCAL_URLS) { + assert.equal(cspAllows(policy, 'connect-src', url, documentOrigin), true, url); + } + assert.equal(cspAllows(policy, 'connect-src', LIVE_ENDPOINT, documentOrigin), true); + assert.equal(cspAllows(policy, 'connect-src', 'https://attacker.example/api', documentOrigin), false); +}); + test('actual HTML auto-mounts once and distinguishes valid 402 from JSON 200/500', async (t) => { const html = await readFile(new URL('index.html', STATIC_ROOT), 'utf8'); const { window, document } = parseHTML(html); + const policy = parseCsp(document); + const documentOrigin = 'https://skill-asset-protocol.example'; Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true }); const scripts = [...document.querySelectorAll('script[type="module"]')]; assert.equal(scripts.length, 1); @@ -59,6 +108,11 @@ test('actual HTML auto-mounts once and distinguishes valid 402 from JSON 200/500 let liveStatus = 402; const seen = []; const fetchStub = async (url) => { + assert.equal( + cspAllows(policy, 'connect-src', url, documentOrigin), + true, + `CSP blocked production fetch: ${url}`, + ); seen.push(url); if (local.has(url)) return response(local.get(url)); if (url === LIVE_ENDPOINT) { From 6ba76e08f1487dc3777c377e132df6d630b72ecf Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 13:12:50 -0400 Subject: [PATCH 128/165] fix: recover settled execution across catalog drift --- spikes/pi-wielder/src/collar.mjs | 15 ++ spikes/pi-wielder/tests/collar-cogs.test.mjs | 175 ++++++++++++++++++- 2 files changed, 189 insertions(+), 1 deletion(-) diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index 8f53b59..c7ddb25 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -323,6 +323,11 @@ export function createCollar({ && canonicalJson(existing.quote.executionQuote) !== canonicalJson(executionQuote)) { throw new Error('paid retry does not match the frozen execution quote'); } + if (existing.payment.settlementReference !== null + && (existing.payment.settlementReference !== settlementReference + || existing.payment.payer !== payer)) { + throw new Error('paid retry does not match the persisted signed payment'); + } // A terminal record is immutable historical authority. Replay it before // consulting current catalog or artifact configuration. if (TERMINAL.has(existing.execution.state)) { @@ -355,6 +360,16 @@ export function createCollar({ } throw new Error('legacy v1 nonterminal payment cannot continue'); } + // Once settlement is authoritative, recovery must reach the + // post-settlement identity check so any current-config drift is journaled + // as one terminal full-gross hold instead of stranding settled value. + if (existing.payment.state === 'settled' && existing.execution.state === 'authorized') { + return { + kind: 'settled', + txHash: existing.payment.txHash, + payer: existing.payment.payer, + }; + } // Before the seller records payment.signed or calls the facilitator, the // current execution identity must still match the persisted full quote. assertFrozenExecutionIdentity({ diff --git a/spikes/pi-wielder/tests/collar-cogs.test.mjs b/spikes/pi-wielder/tests/collar-cogs.test.mjs index 131ca5b..35f4a3d 100644 --- a/spikes/pi-wielder/tests/collar-cogs.test.mjs +++ b/spikes/pi-wielder/tests/collar-cogs.test.mjs @@ -15,6 +15,7 @@ import { createMockFacilitator } from '../src/facilitator-mock.mjs'; import { createInvocationJournal, createReceiptSigner, + verifySignedReceipt, } from '../src/invocation-journal.mjs'; import { createProxy, @@ -55,6 +56,78 @@ async function invoke(proxy, execution = undefined) { return { res, body: await res.json() }; } +async function settlementCrash({ executionCatalog = EXECUTION_CATALOG, executeSkill }) { + const facilitator = createMockFacilitator(); + let facilitatorCalls = 0; + let providerCalls = 0; + const countedExecuteSkill = async (...args) => { + providerCalls += 1; + return executeSkill(...args); + }; + const transport = createMockFacilitatorTransport((url, init) => { + facilitatorCalls += 1; + return facilitator.request(url, init); + }); + const journal = createInvocationJournal({ signer: createReceiptSigner() }); + const beforeRestart = createCollar({ + facilitatorTransport: transport, + journal, + lifecycleFaults: { + afterSettlementRecorded: async () => { throw new Error('injected crash after authoritative settlement'); }, + }, + executeSkill: countedExecuteSkill, + }); + const account = throwawayAccount(); + const sellerUrl = `http://seller.test/invoke/${SKILL_ID}`; + const idempotencyKey = `settlement-crash-${crypto.randomUUID()}`; + const requestBody = JSON.stringify({ input: 'same frozen request' }); + const paymentPolicy = paymentPolicyFor(sellerUrl, PAY_TO); + await assert.rejects(() => payingFetch(account, sellerUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: requestBody, + }, { + idempotencyKey, + paymentPolicy, + fetchImpl: (url, init) => beforeRestart.app.request(url, init), + }), (error) => error.code === 'SETTLEMENT_EVIDENCE'); + const persistedPayment = paymentPolicy.recoverSignedAuthorization({ + authorizationId: idempotencyKey, + requestUrl: sellerUrl, + method: 'POST', + bodyBytes: requestBody, + }); + const crashedRecord = journal.getByIdempotencyKey(idempotencyKey); + assert.equal(crashedRecord.payment.state, 'settled'); + assert.equal(crashedRecord.execution.state, 'authorized'); + assert.equal(crashedRecord.accounting, null); + assert.equal(crashedRecord.receipt, null); + + const afterRestart = createCollar({ + facilitatorTransport: transport, + journal, + executionCatalog, + executeSkill: countedExecuteSkill, + }); + const retry = (xPayment = persistedPayment.xPayment) => afterRestart.app.request(sellerUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': xPayment, + }, + body: requestBody, + }); + return { + afterRestart, + facilitatorCallCount: () => facilitatorCalls, + journal, + providerCallCount: () => providerCalls, + retry, + xPayment: persistedPayment.xPayment, + }; +} + test('known provider usage is charged before the Royalty pool and binds exact output bytes', async () => { const services = stack(); const { res, body } = await invoke(services.proxy); @@ -139,7 +212,7 @@ test('live approval is rechecked against canonical catalog bytes before adapter assert.equal(constructions, 0); }); -test('a restarted Collar rejects persisted nonterminal quote drift before facilitator or provider calls', async () => { +test('a restarted Collar rejects pre-settlement quote drift before facilitator or payment.signed', async () => { const facilitator = createMockFacilitator(); let facilitatorCalls = 0; let providerCalls = 0; @@ -172,6 +245,106 @@ test('a restarted Collar rejects persisted nonterminal quote drift before facili assert.equal(journal.events.some((event) => event.type === 'payment.signed'), false); }); +test('settled crash recovery converts catalog drift into one terminal full-gross hold', async () => { + const changedCatalog = structuredClone(EXECUTION_CATALOG); + changedCatalog.models['claude-sonnet-4-6'].outputAtomicPerMillionTokens = '15000001'; + const prepared = await settlementCrash({ + executionCatalog: changedCatalog, + executeSkill: async () => ({ output: 'must not escape', usage: null }), + }); + const callsAfterCrash = prepared.facilitatorCallCount(); + const eventsAfterCrash = prepared.journal.events.length; + const changedPayment = JSON.parse(Buffer.from(prepared.xPayment, 'base64').toString('utf8')); + const persistedNonce = changedPayment.payload.authorization.nonce; + changedPayment.payload.authorization.nonce = `${persistedNonce.slice(0, -1)}${ + persistedNonce.endsWith('0') ? '1' : '0' + }`; + + // A settled recovery must still require the exact persisted signed payment, + // not merely the same payer under the frozen offer. + const rejected = await prepared.retry(Buffer.from(JSON.stringify(changedPayment)).toString('base64')); + assert.equal(rejected.status, 409); + assert.equal(prepared.providerCallCount(), 0); + assert.equal(prepared.facilitatorCallCount(), callsAfterCrash); + assert.equal(prepared.journal.events.length, eventsAfterCrash); + + const recovered = await prepared.retry(); + assert.equal(recovered.status, 500); + const recoveredBody = await recovered.json(); + assert.equal(recoveredBody.output, undefined); + assert.equal(verifySignedReceipt(recoveredBody.receipt, { + publicKeyPem: prepared.afterRestart.journal.signingPublicKeyPem, + keyId: prepared.afterRestart.journal.signingKeyId, + }), true); + const receipt = recoveredBody.receipt.receipt; + assert.equal(receipt.payment.state, 'settled'); + assert.equal(receipt.execution.state, 'failed'); + assert.equal(receipt.execution.failureClass, 'CATALOG_DIGEST_DRIFT'); + assert.equal(receipt.accounting.grossAtomic, '250000'); + assert.equal(receipt.accounting.executionCogs.status, 'unknown'); + assert.equal(receipt.accounting.executionCogs.actualAtomic, null); + assert.equal(receipt.accounting.royaltyPoolAtomic, '0'); + assert.deepEqual(receipt.accounting.holderCredits, []); + assert.deepEqual(receipt.accounting.ancestorCredits, []); + assert.deepEqual(receipt.accounting.journalEntries, [{ + category: 'unresolved-execution-accounting', + debitAccountId: 'wielder:external-gross', + creditAccountId: 'hold:execution-accounting-reconciliation', + amountAtomic: '250000', + }]); + assert.equal(prepared.providerCallCount(), 0); + assert.equal(prepared.facilitatorCallCount(), callsAfterCrash); + + const eventCount = prepared.journal.events.length; + for (let attempt = 0; attempt < 2; attempt += 1) { + const replay = await prepared.retry(); + assert.equal(replay.status, 500); + const replayBody = await replay.json(); + assert.equal(replayBody.replayed, true); + assert.deepEqual(replayBody.receipt, recoveredBody.receipt); + } + assert.equal(prepared.providerCallCount(), 0); + assert.equal(prepared.facilitatorCallCount(), callsAfterCrash); + assert.equal(prepared.journal.events.length, eventCount); + assert.equal(prepared.journal.events.filter((event) => event.type === 'execution.finished').length, 1); + assert.equal(prepared.journal.events.filter((event) => event.type === 'receipt.issued').length, 1); +}); + +test('settled crash recovery succeeds under unchanged config and replays idempotently', async () => { + const prepared = await settlementCrash({ + executeSkill: async () => { + return { + output: 'recovered output', + usage: { + schemaVersion: 2, + model: 'claude-sonnet-4-6', + inputTokens: 42, + outputTokens: 42, + }, + }; + }, + }); + const callsAfterCrash = prepared.facilitatorCallCount(); + + const recovered = await prepared.retry(); + assert.equal(recovered.status, 200); + const recoveredBody = await recovered.json(); + assert.equal(recoveredBody.output, 'recovered output'); + assert.equal(recoveredBody.receipt.receipt.execution.state, 'succeeded'); + assert.equal(prepared.providerCallCount(), 1); + assert.equal(prepared.facilitatorCallCount(), callsAfterCrash); + + const eventCount = prepared.journal.events.length; + const replay = await prepared.retry(); + assert.equal(replay.status, 200); + const replayBody = await replay.json(); + assert.equal(replayBody.replayed, true); + assert.deepEqual(replayBody.receipt, recoveredBody.receipt); + assert.equal(prepared.providerCallCount(), 1); + assert.equal(prepared.facilitatorCallCount(), callsAfterCrash); + assert.equal(prepared.journal.events.length, eventCount); +}); + test('terminal replay precedes current catalog drift checks and never re-executes', async () => { const facilitator = createMockFacilitator(); let facilitatorCalls = 0; From f651f7d83a5269ee8f77f7a157d84d005baadf69 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 13:33:03 -0400 Subject: [PATCH 129/165] docs: satisfy clone claim quarantine gate --- docs/marketing/hn-and-demo.md | 6 +++--- docs/marketing/linkedin.md | 2 +- docs/marketing/x.md | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/marketing/hn-and-demo.md b/docs/marketing/hn-and-demo.md index c734d89..a34b2c2 100644 --- a/docs/marketing/hn-and-demo.md +++ b/docs/marketing/hn-and-demo.md @@ -11,7 +11,7 @@ language anywhere.* ## 1. Show HN draft > **PUBLICATION BLOCKED — INVALID BENCHMARK.** The 2026-07-12 target scored -> 0.400 and failed its own critical gates, so clone-quality, fidelity-defense, and +> 0.400 and failed its own critical gates, so clone quality, resistance to output imitation, and > break-even conclusions are suppressed. Acquisition was modeled at $1.50; no > x402 acquisition payments settled. Unblock only after > `spikes/clone-economics` produces a valid N=100 result with committed normalized @@ -58,7 +58,7 @@ above is satisfied and a human approves revised copy. > > The historical N=6 run used a modeled $1.50 acquisition cost and measured about > $0.03 of distillation-provider cost; no acquisition payment settled. Its target -> failed the benchmark, so clone quality, fidelity defense, and break-even are +> failed the benchmark, so clone quality, resistance to output imitation, and break-even are > unknown. Publication remains blocked pending a valid preregistered N=100 run. > > We also documented two live failure modes: 10 calls that settled then 500'd ($0.87 of @@ -130,7 +130,7 @@ Post these as replies, verbatim or trimmed. Never argue tone; concede fast and l > The historical N=6 run used a modeled $1.50 acquisition cost and measured about > $0.03 of distillation-provider cost; no acquisition payment settled. Its target -> failed the benchmark, so clone quality, fidelity defense, and break-even are +> failed the benchmark, so clone quality, resistance to output imitation, and break-even are > unknown. Publication remains blocked pending a valid preregistered N=100 run. **Q5. "Who would actually pay for this?"** diff --git a/docs/marketing/linkedin.md b/docs/marketing/linkedin.md index 057654a..492dafb 100644 --- a/docs/marketing/linkedin.md +++ b/docs/marketing/linkedin.md @@ -52,7 +52,7 @@ Launch day, Tuesday–Thursday ~8:30am ET; pin to profile and leave it pinned th ## Post 2 — Clone-economics benchmark: publication blocked > **PUBLICATION BLOCKED — INVALID BENCHMARK.** The 2026-07-12 target scored -> 0.400 and failed its own critical gates, so clone-quality, fidelity-defense, and +> 0.400 and failed its own critical gates, so clone quality, resistance to output imitation, and > break-even conclusions are suppressed. Acquisition was modeled at $1.50; no > x402 acquisition payments settled. Unblock only after > `spikes/clone-economics` produces a valid N=100 result with committed normalized diff --git a/docs/marketing/x.md b/docs/marketing/x.md index c0fcb31..6c3601b 100644 --- a/docs/marketing/x.md +++ b/docs/marketing/x.md @@ -67,7 +67,7 @@ measurement. **6/** The historical N=6 run used a modeled $1.50 acquisition cost and measured about $0.03 of distillation-provider cost; no acquisition payment settled. Its target -failed the benchmark, so clone quality, fidelity defense, and break-even are +failed the benchmark, so clone quality, resistance to output imitation, and break-even are unknown. Publication remains blocked pending a valid preregistered N=100 run. **7/** @@ -103,7 +103,7 @@ If you author skills, this is about who gets credited and compensated for them. ## 2. Clone-attack thread — publication blocked > **PUBLICATION BLOCKED — INVALID BENCHMARK.** The 2026-07-12 target scored -> 0.400 and failed its own critical gates, so clone-quality, fidelity-defense, and +> 0.400 and failed its own critical gates, so clone quality, resistance to output imitation, and > break-even conclusions are suppressed. Acquisition was modeled at $1.50; no > x402 acquisition payments settled. Unblock only after > `spikes/clone-economics` produces a valid N=100 result with committed normalized @@ -111,7 +111,7 @@ If you author skills, this is about who gets credited and compensated for them. The historical N=6 run used a modeled $1.50 acquisition cost and measured about $0.03 of distillation-provider cost; no acquisition payment settled. Its target -failed the benchmark, so clone quality, fidelity defense, and break-even are +failed the benchmark, so clone quality, resistance to output imitation, and break-even are unknown. Publication remains blocked pending a valid preregistered N=100 run. --- @@ -227,7 +227,7 @@ Good (someone asks whether x402 latency is workable): Good (someone claims per-call pricing stops people cloning your agent): > The historical N=6 run used a modeled $1.50 acquisition cost and measured about > $0.03 of distillation-provider cost; no acquisition payment settled. Its target -> failed the benchmark, so clone quality, fidelity defense, and break-even are +> failed the benchmark, so clone quality, resistance to output imitation, and break-even are > unknown. Publication remains blocked pending a valid preregistered N=100 run. Good (Claude Code author asks who owns the skills they write at work): @@ -311,7 +311,7 @@ https://neverhandedover.com **C.** The historical N=6 run used a modeled $1.50 acquisition cost and measured about $0.03 of distillation-provider cost; no acquisition payment settled. Its target -failed the benchmark, so clone quality, fidelity defense, and break-even are +failed the benchmark, so clone quality, resistance to output imitation, and break-even are unknown. Publication remains blocked pending a valid preregistered N=100 run. **D.** From d669c6d41d85ef065dc52397cee83d11ab08d4b0 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 14:01:52 -0400 Subject: [PATCH 130/165] fix: count vesting-pending awards toward period cap --- .../internal-invocation-awards/src/engine.mjs | 2 +- .../test/engine.test.mjs | 33 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/spikes/internal-invocation-awards/src/engine.mjs b/spikes/internal-invocation-awards/src/engine.mjs index 7ba94d2..026ff2f 100644 --- a/spikes/internal-invocation-awards/src/engine.mjs +++ b/spikes/internal-invocation-awards/src/engine.mjs @@ -278,7 +278,7 @@ function awardExposureAtomic(state, policy, period) { if (award.policyId === policy.policyId && award.policyVersion === policy.version && award.period === period - && ['earned', 'payable', 'paid'].includes(award.state)) { + && ['vesting_pending', 'earned', 'payable', 'paid'].includes(award.state)) { exposure += toAtomic(award.amountAtomic); } } diff --git a/spikes/internal-invocation-awards/test/engine.test.mjs b/spikes/internal-invocation-awards/test/engine.test.mjs index b2cfad5..bb70823 100644 --- a/spikes/internal-invocation-awards/test/engine.test.mjs +++ b/spikes/internal-invocation-awards/test/engine.test.mjs @@ -919,6 +919,28 @@ test('period cap counts every open maximum exposure', async () => { assert.equal(Object.keys(fx.store.snapshot().reservations).length, 1); }); +test('period cap retains committed future-policy awards after successful Invocations', async () => { + const fx = fixture({ + policyOverrides: { + maxAwardPerPeriodAtomic: '3000000', + vestingRule: 'future_policy_controlled', + }, + }); + const firstQuote = makeQuote(fx.activePolicy, 'vesting-cap-1'); + const firstAuthorization = await authorize(fx, firstQuote); + const first = await executeSuccess(fx, firstQuote, firstAuthorization); + + assert.equal(first.invocation.state, 'succeeded'); + assert.equal(first.award.state, 'vesting_pending'); + assert.equal(first.award.amountAtomic, '2000000'); + + const secondQuote = makeQuote(fx.activePolicy, 'vesting-cap-2'); + await assert.rejects(() => authorize(fx, secondQuote), /period award cap/); + const snapshot = fx.store.snapshot(); + assert.equal(Object.keys(snapshot.awards).length, 1); + assert.equal(Object.hasOwn(snapshot.invocations, secondQuote.invocationId), false); +}); + test('authorization Promise race, idempotency, and reservation bindings fail closed', async () => { const fx = fixture(); const q1 = makeQuote(fx.activePolicy, 'authorize-race-1'); @@ -1093,7 +1115,7 @@ test('required policy, budget, signer, and lifecycle rejections happen before ex }); }); -test('finance-authenticated append-only earned reversals reduce period exposure only after verification', async () => { +test('finance-authenticated append-only earned reversals reduce period exposure exactly once after verification', async () => { const fx = fixture({ policyOverrides: { maxAwardPerPeriodAtomic: '3000000' } }); const q1 = makeQuote(fx.activePolicy, 'reversal-cap-1'); const firstAuthorization = await authorize(fx, q1); @@ -1162,6 +1184,15 @@ test('finance-authenticated append-only earned reversals reduce period exposure assert.equal(Object.keys(fx.store.snapshot().awardReversals).length, 1); const second = await authorize(fx, q2); assert.equal(second.reservation.state, 'reserved'); + const secondResult = await executeSuccess(fx, q2, second); + assert.equal(secondResult.award.state, 'earned'); + + const q3 = makeQuote(fx.activePolicy, 'reversal-cap-3', { + maxInvocationAwardAtomic: '1000000', + maxGrossAtomic: '2050000', + }); + await assert.rejects(() => authorize(fx, q3), /period award cap/); + assert.equal(Object.keys(fx.store.snapshot().awardReversals).length, 1); }); test('executions crossing period close terminally hold with honest recognition time and no late award', async (t) => { From 0b9ff1707857ac8517ab6e037cb4663792089656 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 14:17:47 -0400 Subject: [PATCH 131/165] fix: bound Phase 0 metadata responses --- phase0/README.md | 10 ++ phase0/src/metadata.ts | 222 +++++++++++++++++++++---- phase0/tests/metadata.test.ts | 304 +++++++++++++++++++++++++++++++++- 3 files changed, 507 insertions(+), 29 deletions(-) diff --git a/phase0/README.md b/phase0/README.md index 7d9b74d..b62554f 100644 --- a/phase0/README.md +++ b/phase0/README.md @@ -209,6 +209,16 @@ gateway hosts are `gateway.pinata.cloud` and HTTPS subdomains of document is serialized once, hashed, fetched, and byte-compared before a new transaction is prepared. +Each upload and each gateway verification has a fixed 15-second total +wall-clock deadline covering both response headers and streamed body reads. An +optional caller `AbortSignal` is composed with that deadline without replacing +the caller's abort reason. Pinata upload acknowledgements are streamed into a +fixed 16 KiB maximum before JSON parsing. Gateway bodies are streamed against +the exact canonical metadata byte length, with only one additional sentinel +byte permitted to detect chunked overflow. Oversized declared `Content-Length` +values fail before body reads; chunked overflow is cancelled on its first +observed excess byte. These runtime bounds are intentionally not configurable. + ## Native gas and WIP are separate prerequisites Run `npm run check` before each human testnet step. It reports pending recovery, diff --git a/phase0/src/metadata.ts b/phase0/src/metadata.ts index df4c5f3..1b2b547 100644 --- a/phase0/src/metadata.ts +++ b/phase0/src/metadata.ts @@ -19,12 +19,136 @@ export interface HttpMetadataProviderOptions { stageUris?: StageMetadataUris; pinataJwt?: string; publicGatewayBaseUrl?: string; + signal?: AbortSignal; } const PINATA_PUBLIC_UPLOAD_URL = "https://uploads.pinata.cloud/v3/files"; const DEFAULT_PUBLIC_GATEWAY_BASE_URL = "https://gateway.pinata.cloud/ipfs/"; const STAGES: readonly DemoStage[] = ["root", "child", "grandchild"]; +/** Total wall-clock budget for one upload or gateway verification, including its body. */ +export const METADATA_HTTP_TIMEOUT_MS = 15_000; +/** Pinata's upload acknowledgement is JSON metadata, never an artifact body. */ +export const PINATA_UPLOAD_RESPONSE_MAX_BYTES = 16 * 1024; +/** One sentinel byte distinguishes an exact gateway match from a chunked overflow. */ +export const METADATA_VERIFICATION_OVERFLOW_PROBE_BYTES = 1; + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); +} + +async function withHttpDeadline( + label: string, + callerSignal: AbortSignal | undefined, + operation: (signal: AbortSignal) => Promise, +): Promise { + const deadline = new AbortController(); + const timeoutError = new Error(`${label} timed out after ${METADATA_HTTP_TIMEOUT_MS} ms`); + timeoutError.name = "TimeoutError"; + const timeout = setTimeout(() => deadline.abort(timeoutError), METADATA_HTTP_TIMEOUT_MS); + const signal = callerSignal + ? AbortSignal.any([callerSignal, deadline.signal]) + : deadline.signal; + let onAbort: (() => void) | undefined; + + try { + if (signal.aborted) throw abortReason(signal); + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(abortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + }); + return await Promise.race([operation(signal), aborted]); + } finally { + clearTimeout(timeout); + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +async function cancelReader( + reader: ReadableStreamDefaultReader, + reason?: unknown, +): Promise { + try { + await reader.cancel(reason); + } catch { + // Cancellation is best-effort; retain the bounded operation's sanitized error. + } +} + +async function readBoundedResponseBody(input: { + response: Response; + maxBytes: number; + overflowProbeBytes?: number; + label: string; + signal: AbortSignal; +}): Promise { + const overflowProbeBytes = input.overflowProbeBytes ?? 0; + if ( + !Number.isSafeInteger(input.maxBytes) + || input.maxBytes < 0 + || !Number.isSafeInteger(overflowProbeBytes) + || overflowProbeBytes < 0 + || input.maxBytes > Number.MAX_SAFE_INTEGER - overflowProbeBytes + ) { + throw new Error("Metadata response byte limit is invalid"); + } + if (input.signal.aborted) throw abortReason(input.signal); + const readLimitBytes = input.maxBytes + overflowProbeBytes; + const limitError = () => new Error(`${input.label} exceeds ${input.maxBytes}-byte limit`); + const contentLength = input.response.headers.get("content-length"); + if (contentLength !== null) { + if (!/^\d+$/.test(contentLength)) { + throw new Error(`${input.label} has an invalid Content-Length`); + } + if (BigInt(contentLength) > BigInt(input.maxBytes)) throw limitError(); + } + if (!input.response.body) return new Uint8Array(); + + const reader = input.response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + const onAbort = () => { + void cancelReader(reader, abortReason(input.signal)); + }; + input.signal.addEventListener("abort", onAbort, { once: true }); + + try { + while (true) { + if (input.signal.aborted) throw abortReason(input.signal); + let result: ReadableStreamReadResult; + try { + result = await reader.read(); + } catch { + if (input.signal.aborted) throw abortReason(input.signal); + throw new Error(`${input.label} could not be read`); + } + if (result.done) break; + const chunk = result.value; + if (chunk.byteLength > readLimitBytes - totalBytes) { + await cancelReader(reader); + throw limitError(); + } + if (chunk.byteLength === 0) continue; + chunks.push(chunk); + totalBytes += chunk.byteLength; + if (totalBytes > input.maxBytes) { + await cancelReader(reader); + throw limitError(); + } + } + } finally { + input.signal.removeEventListener("abort", onAbort); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + function sha256Hex(bytes: Uint8Array): `0x${string}` { return `0x${createHash("sha256").update(bytes).digest("hex")}`; } @@ -88,27 +212,49 @@ async function pinPublicJson(input: { gatewayBaseUrl: string; name: string; bytes: Uint8Array; + signal?: AbortSignal; }): Promise { const form = new FormData(); form.set("network", "public"); form.set("name", input.name); form.set("file", new Blob([Buffer.from(input.bytes)], { type: "application/json" }), input.name); - const response = await input.fetcher(PINATA_PUBLIC_UPLOAD_URL, { - method: "POST", - headers: { Authorization: `Bearer ${input.jwt}` }, - body: form, - redirect: "error", + return withHttpDeadline("Metadata pin request", input.signal, async (signal) => { + let response: Response; + try { + response = await input.fetcher(PINATA_PUBLIC_UPLOAD_URL, { + method: "POST", + headers: { Authorization: `Bearer ${input.jwt}` }, + body: form, + redirect: "error", + signal, + }); + } catch { + if (signal.aborted) throw abortReason(signal); + throw new Error("Metadata pin request failed"); + } + if (signal.aborted) throw abortReason(signal); + if (!response.ok) throw new Error(`Metadata pin failed (${response.status})`); + const responseBytes = await readBoundedResponseBody({ + response, + maxBytes: PINATA_UPLOAD_RESPONSE_MAX_BYTES, + label: "Metadata pin response", + signal, + }); + let body: { data?: { cid?: string } }; + try { + body = JSON.parse(Buffer.from(responseBytes).toString("utf8")) as typeof body; + } catch { + throw new Error("Metadata pin response is not valid JSON"); + } + const cid = body?.data?.cid; + if (!cid || !/^b[a-z0-9]+$/.test(cid)) { + throw new Error("Pinata response is missing a public CID"); + } + return new URL( + cid, + input.gatewayBaseUrl.endsWith("/") ? input.gatewayBaseUrl : `${input.gatewayBaseUrl}/`, + ).toString(); }); - if (!response.ok) throw new Error(`Metadata pin failed (${response.status})`); - const body = await response.json() as { data?: { cid?: string } }; - const cid = body.data?.cid; - if (!cid || !/^b[a-z0-9]+$/.test(cid)) { - throw new Error("Pinata response is missing a public CID"); - } - return new URL( - cid, - input.gatewayBaseUrl.endsWith("/") ? input.gatewayBaseUrl : `${input.gatewayBaseUrl}/`, - ).toString(); } async function verifyExactBytes( @@ -116,18 +262,34 @@ async function verifyExactBytes( uri: string, expectedBytes: Uint8Array, expectedHash: `0x${string}`, + callerSignal?: AbortSignal, ): Promise { - const response = await fetcher(uri, { redirect: "error" }); - if (!response.ok) { - throw new Error(`Metadata fetch failed (${response.status}) for ${uri}`); - } - const fetched = new Uint8Array(await response.arrayBuffer()); - if (!Buffer.from(fetched).equals(Buffer.from(expectedBytes))) { - throw new Error(`Fetched metadata bytes do not match the serialized metadata for ${uri}`); - } - if (sha256Hex(fetched) !== expectedHash) { - throw new Error(`Fetched metadata SHA-256 does not match the expected hash for ${uri}`); - } + await withHttpDeadline("Metadata fetch request", callerSignal, async (signal) => { + let response: Response; + try { + response = await fetcher(uri, { redirect: "error", signal }); + } catch { + if (signal.aborted) throw abortReason(signal); + throw new Error(`Metadata fetch request failed for ${uri}`); + } + if (signal.aborted) throw abortReason(signal); + if (!response.ok) { + throw new Error(`Metadata fetch failed (${response.status}) for ${uri}`); + } + const fetched = await readBoundedResponseBody({ + response, + maxBytes: expectedBytes.byteLength, + overflowProbeBytes: METADATA_VERIFICATION_OVERFLOW_PROBE_BYTES, + label: "Fetched metadata", + signal, + }); + if (!Buffer.from(fetched).equals(Buffer.from(expectedBytes))) { + throw new Error(`Fetched metadata bytes do not match the serialized metadata for ${uri}`); + } + if (sha256Hex(fetched) !== expectedHash) { + throw new Error(`Fetched metadata SHA-256 does not match the expected hash for ${uri}`); + } + }); } function envStageUris(): StageMetadataUris { @@ -146,6 +308,7 @@ export class HttpMetadataProvider implements DemoMetadataProvider { private readonly rawStageUris: StageMetadataUris; private readonly pinataJwt?: string; private readonly rawPublicGatewayBaseUrl: string; + private readonly signal?: AbortSignal; private validated?: { gatewayBaseUrl: string; stageUris: StageMetadataUris }; constructor(options: HttpMetadataProviderOptions = {}) { @@ -157,6 +320,7 @@ export class HttpMetadataProvider implements DemoMetadataProvider { this.rawPublicGatewayBaseUrl = options.publicGatewayBaseUrl ?? process.env.IPFS_PUBLIC_GATEWAY_BASE_URL?.trim() ?? DEFAULT_PUBLIC_GATEWAY_BASE_URL; + this.signal = options.signal; } private configuration(): { gatewayBaseUrl: string; stageUris: StageMetadataUris } { @@ -212,6 +376,7 @@ export class HttpMetadataProvider implements DemoMetadataProvider { gatewayBaseUrl: configuration.gatewayBaseUrl, name: `${input.stage}-ip-metadata.json`, bytes: ipBytes, + signal: this.signal, }); const nftMetadataURI = override?.nft ?? await pinPublicJson({ fetcher: this.fetcher, @@ -219,10 +384,11 @@ export class HttpMetadataProvider implements DemoMetadataProvider { gatewayBaseUrl: configuration.gatewayBaseUrl, name: `${input.stage}-nft-metadata.json`, bytes: nftBytes, + signal: this.signal, }); - await verifyExactBytes(this.fetcher, ipMetadataURI, ipBytes, ipMetadataHash); - await verifyExactBytes(this.fetcher, nftMetadataURI, nftBytes, nftMetadataHash); + await verifyExactBytes(this.fetcher, ipMetadataURI, ipBytes, ipMetadataHash, this.signal); + await verifyExactBytes(this.fetcher, nftMetadataURI, nftBytes, nftMetadataHash, this.signal); const artifactPath = isAbsolute(input.artifactPath) ? relative(process.cwd(), input.artifactPath) diff --git a/phase0/tests/metadata.test.ts b/phase0/tests/metadata.test.ts index a5453af..58c693e 100644 --- a/phase0/tests/metadata.test.ts +++ b/phase0/tests/metadata.test.ts @@ -5,7 +5,12 @@ import { join } from "node:path"; import test from "node:test"; import type { DemoStage } from "../src/registrations"; -import { HttpMetadataProvider } from "../src/metadata"; +import { + HttpMetadataProvider, + METADATA_HTTP_TIMEOUT_MS, + METADATA_VERIFICATION_OVERFLOW_PROBE_BYTES, + PINATA_UPLOAD_RESPONSE_MAX_BYTES, +} from "../src/metadata"; const WALLET = "0x00000000000000000000000000000000000000aa" as const; const ARTIFACT = "# Fixture Skill\n\nReturn one concise answer.\n"; @@ -14,6 +19,16 @@ const IP_HASH = "0x6c42a18b50e58fbe307da28995b381d6c5690f7815070733946c20344b58b const NFT_HASH = "0xdb24ac9487196af7c830b213c8127f08b3b2c12eb2baeb1fcc6625281ab16098"; const UPLOAD_URL = "https://uploads.pinata.cloud/v3/files"; const DEFAULT_GATEWAY = "https://gateway.pinata.cloud/ipfs/"; +const IP_METADATA_JSON = JSON.stringify({ + title: "Fixture Skill", + description: "fixture", + createdAt: "0", + ipType: "skill", + creators: [{ name: "creator", address: WALLET, contributionPercent: 100 }], + mediaHash: ARTIFACT_HASH, + mediaType: "text/markdown", +}); +const NFT_METADATA_JSON = JSON.stringify({ name: "Fixture Skill", description: "fixture" }); const INVALID_STAGE_URIS = [ "http://gateway.pinata.cloud/ipfs/bafyvalidcid123", @@ -60,6 +75,16 @@ function headerValue(headers: HeadersInit | undefined, name: string): string | n return new Headers(headers).get(name); } +function pinataJsonAtSize(cid: string, byteLength: number): string { + const prefix = `{"data":{"cid":"${cid}"},"padding":"`; + const suffix = '"}'; + const paddingLength = byteLength - Buffer.byteLength(prefix) - Buffer.byteLength(suffix); + assert.ok(paddingLength >= 0); + const body = `${prefix}${"x".repeat(paddingLength)}${suffix}`; + assert.equal(Buffer.byteLength(body), byteLength); + return body; +} + test("default publication pins two exact byte documents and verifies them without credentials", async (t) => { const artifactPath = await withArtifact(t); const calls: Array<{ url: string; init?: RequestInit }> = []; @@ -288,3 +313,280 @@ test("altered fetched metadata bytes are rejected", async (t) => { await assert.rejects(provider.prepare(input(artifactPath)), /fetched metadata bytes do not match/i); }); + +test("Pinata upload has a hard wall-clock deadline even when fetch ignores abort", async (t) => { + const artifactPath = await withArtifact(t); + t.mock.timers.enable({ apis: ["setTimeout"] }); + let requestSignal: AbortSignal | null | undefined; + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const provider = new HttpMetadataProvider({ + pinataJwt: "fixture-token", + fetcher: async (_request, init) => { + requestSignal = init?.signal; + markStarted(); + return new Promise(() => undefined); + }, + }); + + const pending = provider.prepare(input(artifactPath)); + await started; + assert.ok(requestSignal instanceof AbortSignal); + t.mock.timers.tick(METADATA_HTTP_TIMEOUT_MS); + + await assert.rejects( + pending, + new RegExp(`Metadata pin request timed out after ${METADATA_HTTP_TIMEOUT_MS} ms`, "i"), + ); + assert.equal(requestSignal.aborted, true); +}); + +test("Pinata upload accepts a valid JSON response exactly at its fixed byte ceiling", async (t) => { + const artifactPath = await withArtifact(t); + const pinned = new Map(); + const cids = ["bafyboundaryip123", "bafyboundarynft456"]; + const provider = new HttpMetadataProvider({ + pinataJwt: "fixture-token", + fetcher: async (request, init) => { + const url = String(request); + if (init?.method === "POST") { + assert.ok(init.body instanceof FormData); + const file = init.body.get("file"); + assert.ok(file instanceof Blob); + const cid = cids[pinned.size]; + assert.ok(cid); + pinned.set(`${DEFAULT_GATEWAY}${cid}`, new Uint8Array(await file.arrayBuffer())); + const body = pinataJsonAtSize(cid, PINATA_UPLOAD_RESPONSE_MAX_BYTES); + return new Response(body, { + headers: { "content-length": String(PINATA_UPLOAD_RESPONSE_MAX_BYTES) }, + }); + } + const bytes = pinned.get(url); + assert.ok(bytes); + return new Response(Buffer.from(bytes)); + }, + }); + + const prepared = await provider.prepare(input(artifactPath)); + assert.equal(prepared.onchain.ipMetadataURI, `${DEFAULT_GATEWAY}${cids[0]}`); + assert.equal(prepared.onchain.nftMetadataURI, `${DEFAULT_GATEWAY}${cids[1]}`); +}); + +test("Pinata upload rejects an oversized Content-Length before reading the body", async (t) => { + const artifactPath = await withArtifact(t); + let readers = 0; + const provider = new HttpMetadataProvider({ + pinataJwt: "fixture-token", + fetcher: async () => ({ + ok: true, + status: 200, + headers: new Headers({ + "content-length": String(PINATA_UPLOAD_RESPONSE_MAX_BYTES + 1), + }), + body: { + getReader() { + readers += 1; + throw new Error("body must not be read"); + }, + }, + }) as unknown as Response, + }); + + await assert.rejects( + provider.prepare(input(artifactPath)), + new RegExp(`Metadata pin response exceeds ${PINATA_UPLOAD_RESPONSE_MAX_BYTES}-byte limit`, "i"), + ); + assert.equal(readers, 0); +}); + +test("Pinata upload rejects a chunked response on the first byte over its ceiling", async (t) => { + const artifactPath = await withArtifact(t); + let cancelled = false; + const provider = new HttpMetadataProvider({ + pinataJwt: "fixture-token", + fetcher: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(PINATA_UPLOAD_RESPONSE_MAX_BYTES)); + controller.enqueue(Uint8Array.of(1)); + }, + cancel() { + cancelled = true; + }, + })), + }); + + await assert.rejects( + provider.prepare(input(artifactPath)), + new RegExp(`Metadata pin response exceeds ${PINATA_UPLOAD_RESPONSE_MAX_BYTES}-byte limit`, "i"), + ); + assert.equal(cancelled, true); +}); + +test("gateway verification accepts exactly the canonical byte length", async (t) => { + const artifactPath = await withArtifact(t); + const ip = `${DEFAULT_GATEWAY}bafyrootip123`; + const nft = `${DEFAULT_GATEWAY}bafyrootnft456`; + const expected = new Map([ + [ip, IP_METADATA_JSON], + [nft, NFT_METADATA_JSON], + ]); + const provider = new HttpMetadataProvider({ + stageUris: { root: { ip, nft } }, + fetcher: async (request) => { + const body = expected.get(String(request)); + assert.ok(body); + return new Response(body, { + headers: { "content-length": String(Buffer.byteLength(body)) }, + }); + }, + }); + + const prepared = await provider.prepare(input(artifactPath)); + assert.equal(prepared.onchain.ipMetadataHash, IP_HASH); + assert.equal(prepared.onchain.nftMetadataHash, NFT_HASH); +}); + +test("gateway verification rejects Content-Length one byte over canonical before reading", async (t) => { + const artifactPath = await withArtifact(t); + const ip = `${DEFAULT_GATEWAY}bafyrootip123`; + const nft = `${DEFAULT_GATEWAY}bafyrootnft456`; + let readers = 0; + const provider = new HttpMetadataProvider({ + stageUris: { root: { ip, nft } }, + fetcher: async () => ({ + ok: true, + status: 200, + headers: new Headers({ + "content-length": String(Buffer.byteLength(IP_METADATA_JSON) + 1), + }), + body: { + getReader() { + readers += 1; + throw new Error("body must not be read"); + }, + }, + }) as unknown as Response, + }); + + await assert.rejects( + provider.prepare(input(artifactPath)), + new RegExp(`Fetched metadata exceeds ${Buffer.byteLength(IP_METADATA_JSON)}-byte limit`, "i"), + ); + assert.equal(readers, 0); +}); + +test("gateway verification reads only one overflow byte from a chunked response", async (t) => { + const artifactPath = await withArtifact(t); + const ip = `${DEFAULT_GATEWAY}bafyrootip123`; + const nft = `${DEFAULT_GATEWAY}bafyrootnft456`; + let cancelled = false; + assert.equal(METADATA_VERIFICATION_OVERFLOW_PROBE_BYTES, 1); + const provider = new HttpMetadataProvider({ + stageUris: { root: { ip, nft } }, + fetcher: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from(IP_METADATA_JSON)); + controller.enqueue(new Uint8Array(METADATA_VERIFICATION_OVERFLOW_PROBE_BYTES)); + }, + cancel() { + cancelled = true; + }, + })), + }); + + await assert.rejects( + provider.prepare(input(artifactPath)), + new RegExp(`Fetched metadata exceeds ${Buffer.byteLength(IP_METADATA_JSON)}-byte limit`, "i"), + ); + assert.equal(cancelled, true); +}); + +test("gateway verification body consumption shares the hard wall-clock deadline", async (t) => { + const artifactPath = await withArtifact(t); + t.mock.timers.enable({ apis: ["setTimeout"] }); + const ip = `${DEFAULT_GATEWAY}bafyrootip123`; + const nft = `${DEFAULT_GATEWAY}bafyrootnft456`; + let requestSignal: AbortSignal | null | undefined; + let markPullStarted!: () => void; + const pullStarted = new Promise((resolve) => { + markPullStarted = resolve; + }); + const provider = new HttpMetadataProvider({ + stageUris: { root: { ip, nft } }, + fetcher: async (_request, init) => { + requestSignal = init?.signal; + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from(IP_METADATA_JSON).subarray(0, 1)); + }, + pull() { + markPullStarted(); + return new Promise(() => undefined); + }, + })); + }, + }); + + const pending = provider.prepare(input(artifactPath)); + await pullStarted; + assert.ok(requestSignal instanceof AbortSignal); + t.mock.timers.tick(METADATA_HTTP_TIMEOUT_MS); + + await assert.rejects( + pending, + new RegExp(`Metadata fetch request timed out after ${METADATA_HTTP_TIMEOUT_MS} ms`, "i"), + ); + assert.equal(requestSignal.aborted, true); +}); + +test("caller abort is composed with the deadline and preserves the caller reason", async (t) => { + const artifactPath = await withArtifact(t); + const caller = new AbortController(); + const reason = new Error("operator cancelled metadata publication"); + let requestSignal: AbortSignal | null | undefined; + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const provider = new HttpMetadataProvider({ + pinataJwt: "fixture-token", + signal: caller.signal, + fetcher: async (_request, init) => { + requestSignal = init?.signal; + markStarted(); + return new Promise(() => undefined); + }, + }); + + const pending = provider.prepare(input(artifactPath)); + await started; + caller.abort(reason); + + await assert.rejects(pending, (error) => { + assert.equal(error, reason); + return true; + }); + assert.ok(requestSignal instanceof AbortSignal); + assert.equal(requestSignal.aborted, true); + assert.equal(requestSignal.reason, reason); +}); + +test("Pinata transport errors are sanitized and do not expose the JWT", async (t) => { + const artifactPath = await withArtifact(t); + const secret = "fixture-token"; + const provider = new HttpMetadataProvider({ + pinataJwt: secret, + fetcher: async () => { + throw new Error(`transport echoed ${secret}`); + }, + }); + + await assert.rejects(provider.prepare(input(artifactPath)), (error) => { + assert.ok(error instanceof Error); + assert.match(error.message, /Metadata pin request failed/); + assert.doesNotMatch(error.message, new RegExp(secret)); + return true; + }); +}); From 0779fa1a0bd39f28ca8fd18104d7bb21eb676006 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 14:22:14 -0400 Subject: [PATCH 132/165] fix: harden registry and public demo boundaries --- hf-space/gradio/README.md | 2 + hf-space/gradio/app.py | 55 +++++++- hf-space/gradio/test_app_smoke.py | 121 +++++++++++++++-- hf-space/gradio/test_demo_logic.py | 6 + hf-space/static/README.md | 7 + hf-space/static/demo-logic.mjs | 123 ++++++++++++++--- hf-space/static/index.html | 3 +- hf-space/static/test-demo-logic.mjs | 70 +++++++++- hf-space/static/test-index-smoke.mjs | 41 +++++- spikes/registry-ranking/src/metrics.mjs | 58 +++++--- spikes/registry-ranking/src/report.mjs | 50 ++++++- spikes/registry-ranking/test/metrics.test.mjs | 126 +++++++++++++++++- 12 files changed, 599 insertions(+), 63 deletions(-) diff --git a/hf-space/gradio/README.md b/hf-space/gradio/README.md index 570796a..1e2938b 100644 --- a/hf-space/gradio/README.md +++ b/hf-space/gradio/README.md @@ -13,6 +13,8 @@ license: apache-2.0 # Skill Asset Protocol — verified accounting demo +> **PROPOSED / NONCANONICAL:** The employer-funded internal Invocation-award model is pending explicit approval. + This standalone Gradio root is a research demo for the compensation, attribution, and metering layer for authored AI Skills. It defaults to the terminal-product **Intra-org** scenario. **Education** is deferred after free diff --git a/hf-space/gradio/app.py b/hf-space/gradio/app.py index 1a3a678..79eaefb 100644 --- a/hf-space/gradio/app.py +++ b/hf-space/gradio/app.py @@ -2,6 +2,10 @@ from __future__ import annotations +import json +import re +import time + import httpx import gradio as gr @@ -15,21 +19,51 @@ REQUEST_BODY = {"input": "help me tighten this prompt"} REQUEST_TIMEOUT = httpx.Timeout(5.0, connect=3.0) +MAX_LIVE_RESPONSE_BYTES = 65_536 +LIVE_RESPONSE_DEADLINE_SECONDS = 5.0 +_CONTENT_LENGTH_PATTERN = re.compile(r"^(0|[1-9][0-9]*)$") + + +def _read_streamed_json(response, deadline): + content_length = response.headers.get("content-length") + if content_length not in (None, ""): + if ( + not isinstance(content_length, str) + or not _CONTENT_LENGTH_PATTERN.fullmatch(content_length) + or int(content_length) > MAX_LIVE_RESPONSE_BYTES + ): + raise ValueError("live endpoint response is too large") + body = bytearray() + for chunk in response.iter_bytes(chunk_size=8_192): + if time.monotonic() >= deadline: + raise TimeoutError("live endpoint response deadline exceeded") + if not isinstance(chunk, (bytes, bytearray, memoryview)): + raise TypeError("live endpoint response chunk is invalid") + if len(chunk) > MAX_LIVE_RESPONSE_BYTES - len(body): + raise ValueError("live endpoint response is too large") + body.extend(chunk) + if time.monotonic() >= deadline: + raise TimeoutError("live endpoint response deadline exceeded") + if not body: + raise ValueError("live endpoint response is empty") + return json.loads(bytes(body).decode("utf-8")) def check_live_402(): """Read the fixed endpoint once; never follow redirects or use a cached offer.""" try: - response = httpx.post( + deadline = time.monotonic() + LIVE_RESPONSE_DEADLINE_SECONDS + with httpx.stream( + "POST", LIVE_ENDPOINT, json=REQUEST_BODY, headers={"accept": "application/json"}, timeout=REQUEST_TIMEOUT, follow_redirects=False, - ) - body = response.json() - result = validate_live_402(response.status_code, body) - except (httpx.HTTPError, ValueError, TypeError): + ) as response: + body = _read_streamed_json(response, deadline) + result = validate_live_402(response.status_code, body) + except (httpx.HTTPError, UnicodeError, ValueError, TypeError, TimeoutError): return { "live": False, "status": None, @@ -95,6 +129,8 @@ def build_demo(): with gr.Blocks(title="Skill Asset Protocol — verified accounting demo") as blocks: gr.Markdown( "# Skill Asset Protocol\n\n" + "**PROPOSED / NONCANONICAL** — The employer-funded internal Invocation-award " + "model is pending explicit approval.\n\n" "A testnet-only accounting and HTTP 402 research demo. No real funds, " "wallet signing, payment, withdrawal, deployment, or publication occurs here." ) @@ -121,11 +157,16 @@ def build_demo(): live_button.click( check_live_402, outputs=live_result, - api_name="check_live_402", + api_name=False, + api_visibility="private", + concurrency_limit=1, + concurrency_id="fixed-live-402-check", + queue=True, + trigger_mode="once", ) with gr.Tab("Evidence boundaries"): gr.Markdown(evidence_markdown(EVIDENCE_FIXTURE)) - return blocks + return blocks.queue(api_open=False, max_size=1, default_concurrency_limit=1) ALLOCATION_FIXTURE = load_allocation_fixture() diff --git a/hf-space/gradio/test_app_smoke.py b/hf-space/gradio/test_app_smoke.py index 42b6537..ac94200 100644 --- a/hf-space/gradio/test_app_smoke.py +++ b/hf-space/gradio/test_app_smoke.py @@ -1,4 +1,5 @@ import importlib.util +import json from pathlib import Path import sys import unittest @@ -25,12 +26,34 @@ class FakeResponse: - def __init__(self, status_code, body): + def __init__(self, status_code, body=None, *, chunks=None, content_length=None, before_chunk=None): self.status_code = status_code - self._body = body + encoded = json.dumps(body, separators=(",", ":")).encode() if chunks is None else None + self._chunks = list(chunks) if chunks is not None else [encoded] + self.headers = { + "content-length": str(len(encoded)) if content_length is None and encoded is not None + else content_length + } + self.before_chunk = before_chunk + self.iteration_count = 0 + + def iter_bytes(self, chunk_size=None): + for chunk in self._chunks: + if self.before_chunk is not None: + self.before_chunk(self.iteration_count) + self.iteration_count += 1 + yield chunk + + +class FakeStream: + def __init__(self, response): + self.response = response - def json(self): - return self._body + def __enter__(self): + return self.response + + def __exit__(self, exc_type, exc_value, traceback): + return False def import_app(): @@ -39,6 +62,10 @@ def import_app(): spec = importlib.util.spec_from_file_location("plan10_gradio_app", GRADIO_ROOT / "app.py") module = importlib.util.module_from_spec(spec) with patch.object(httpx, "post", side_effect=AssertionError("network during import")), patch.object( + httpx, + "stream", + side_effect=AssertionError("network during import"), + ), patch.object( gr.Blocks, "launch", side_effect=AssertionError("server launch during import"), @@ -52,6 +79,16 @@ class AppSmokeTests(unittest.TestCase): def setUpClass(cls): cls.app = import_app() + def setUp(self): + for method_name in ("post", "stream"): + network = patch.object( + httpx, + method_name, + side_effect=AssertionError(f"unstubbed HTTP request through {method_name}"), + ) + network.start() + self.addCleanup(network.stop) + def test_import_builds_blocks_without_network_or_launch(self): self.assertIsInstance(self.app.demo, gr.Blocks) config = self.app.demo.get_config_file() @@ -59,23 +96,41 @@ def test_import_builds_blocks_without_network_or_launch(self): any(component.get("props", {}).get("value") == "intra-org" for component in config["components"]) ) self.assertTrue( - any(dependency.get("api_name") == "check_live_402" for dependency in config["dependencies"]) + any("PROPOSED / NONCANONICAL" in component.get("props", {}).get("value", "") + for component in config["components"]) + ) + live_function_id, live_function = next( + (function_id, fn) + for function_id, fn in self.app.demo.fns.items() + if fn.name == "check_live_402" ) + live_dependency = next( + dependency for dependency in config["dependencies"] + if dependency.get("id") == live_function_id + ) + self.assertEqual(live_dependency["api_visibility"], "private") + self.assertNotEqual(live_dependency["api_name"], "check_live_402") + self.assertTrue(live_dependency["queue"]) + self.assertEqual(live_function.concurrency_limit, 1) + self.assertEqual(live_function.concurrency_id, "fixed-live-402-check") + self.assertFalse(self.app.demo.api_open) + self.assertEqual(self.app.demo._queue.max_size, 1) def test_actual_wired_handler_distinguishes_live_non_402_and_failure(self): - with patch.object(httpx, "post", return_value=FakeResponse(402, VALID_402)) as request: + with patch.object(httpx, "stream", return_value=FakeStream(FakeResponse(402, VALID_402))) as request: result = self.app.check_live_402() self.assertTrue(result["live"]) self.assertEqual(result["source"], "live_http_response") + self.assertEqual(request.call_args.args, ("POST", self.app.LIVE_ENDPOINT)) self.assertFalse(request.call_args.kwargs["follow_redirects"]) self.assertIsInstance(request.call_args.kwargs["timeout"], httpx.Timeout) - with patch.object(httpx, "post", return_value=FakeResponse(200, {"ok": True})): + with patch.object(httpx, "stream", return_value=FakeStream(FakeResponse(200, {"ok": True}))): result = self.app.check_live_402() self.assertFalse(result["live"]) self.assertEqual(result["status"], 200) - with patch.object(httpx, "post", side_effect=httpx.ConnectError("offline")): + with patch.object(httpx, "stream", side_effect=httpx.ConnectError("offline")): result = self.app.check_live_402() self.assertFalse(result["live"]) self.assertEqual(result["source"], "live_request_failed_no_cache") @@ -86,7 +141,7 @@ def test_actual_wired_handler_returns_non_live_for_five_thousand_digit_amount(se **VALID_402, "accepts": [{**VALID_402["accepts"][0], "maxAmountRequired": "9" * 5_000}], } - with patch.object(httpx, "post", return_value=FakeResponse(402, invalid)): + with patch.object(httpx, "stream", return_value=FakeStream(FakeResponse(402, invalid))): result = self.app.check_live_402() self.assertEqual( result, @@ -100,7 +155,7 @@ def test_actual_wired_handler_returns_non_live_for_five_thousand_digit_amount(se ) def test_actual_wired_handler_contains_validation_exceptions(self): - with patch.object(httpx, "post", return_value=FakeResponse(402, VALID_402)), patch.object( + with patch.object(httpx, "stream", return_value=FakeStream(FakeResponse(402, VALID_402))), patch.object( self.app, "validate_live_402", side_effect=ValueError("invalid response boundary"), @@ -110,6 +165,52 @@ def test_actual_wired_handler_contains_validation_exceptions(self): self.assertEqual(result["source"], "live_request_failed_no_cache") self.assertNotIn("invalid response boundary", json_safe(result)) + def test_actual_wired_handler_prechecks_length_and_caps_chunked_bodies(self): + declared_oversize = FakeResponse( + 402, + VALID_402, + content_length=str(self.app.MAX_LIVE_RESPONSE_BYTES + 1), + ) + with patch.object(httpx, "stream", return_value=FakeStream(declared_oversize)): + result = self.app.check_live_402() + self.assertFalse(result["live"]) + self.assertEqual(result["source"], "live_request_failed_no_cache") + self.assertEqual(declared_oversize.iteration_count, 0) + + chunked_oversize = FakeResponse( + 402, + chunks=[b"x" * self.app.MAX_LIVE_RESPONSE_BYTES, b"x"], + content_length="", + ) + with patch.object(httpx, "stream", return_value=FakeStream(chunked_oversize)): + result = self.app.check_live_402() + self.assertFalse(result["live"]) + self.assertEqual(result["source"], "live_request_failed_no_cache") + self.assertEqual(chunked_oversize.iteration_count, 2) + + def test_actual_wired_handler_contains_malformed_and_deadline_failures(self): + malformed = FakeResponse(402, chunks=[b"{"], content_length="") + with patch.object(httpx, "stream", return_value=FakeStream(malformed)): + result = self.app.check_live_402() + self.assertFalse(result["live"]) + self.assertEqual(result["source"], "live_request_failed_no_cache") + + now = [0.0] + slow = FakeResponse( + 402, + VALID_402, + content_length="", + before_chunk=lambda _index: now.__setitem__(0, self.app.LIVE_RESPONSE_DEADLINE_SECONDS + 1), + ) + with patch.object(httpx, "stream", return_value=FakeStream(slow)), patch.object( + self.app.time, + "monotonic", + side_effect=lambda: now[0], + ): + result = self.app.check_live_402() + self.assertFalse(result["live"]) + self.assertEqual(result["source"], "live_request_failed_no_cache") + def json_safe(value): return str(value).lower() diff --git a/hf-space/gradio/test_demo_logic.py b/hf-space/gradio/test_demo_logic.py index 81a78f0..82e5a01 100644 --- a/hf-space/gradio/test_demo_logic.py +++ b/hf-space/gradio/test_demo_logic.py @@ -106,6 +106,7 @@ def test_rendered_rows_are_kernel_journal_rows_and_conserve_gross(self): fixture = load_allocation_fixture() for scenario in fixture["scenarios"]: model = render_allocation(fixture, scenario["id"]) + self.assertEqual(model["implementationNote"], scenario["implementationNote"]) expected_entries = scenario["allocation"]["journalEntries"] self.assertEqual(len(model["rows"]), len(expected_entries)) for row, entry in zip(model["rows"], expected_entries, strict=True): @@ -162,6 +163,11 @@ def test_integrity_manifest_matches_current_raw_bytes(self): self.assertEqual(expected["bytes"], len(raw)) self.assertEqual(expected["sha256"], "sha256:" + hashlib.sha256(raw).hexdigest()) + def test_readme_marks_internal_award_model_proposed_and_noncanonical(self): + readme = (GRADIO_ROOT / "README.md").read_text() + self.assertIn("PROPOSED / NONCANONICAL", readme) + self.assertRegex(readme, r"(?i)employer-funded internal Invocation-award.*pending explicit approval") + if __name__ == "__main__": unittest.main() diff --git a/hf-space/static/README.md b/hf-space/static/README.md index b93c815..95c3699 100644 --- a/hf-space/static/README.md +++ b/hf-space/static/README.md @@ -11,6 +11,8 @@ license: apache-2.0 # Skill Asset Protocol — verified accounting demo +> **PROPOSED / NONCANONICAL:** The employer-funded internal Invocation-award model is pending explicit approval. + This standalone static root renders the same deterministic accounting fixture as the Gradio root. The fixture is generated from `prototype/atomic-money.mjs`, copied byte-for-byte into this root, and checked @@ -28,6 +30,11 @@ are refused and the response is size-bounded. Only a valid x402 v1 `exact` offer for Base Sepolia is marked live; JSON 200/500 and malformed responses are non-live. No cached response is presented as current. +Anti-framing must be supplied by the host as a `Content-Security-Policy` HTTP +response header containing `frame-ancestors 'none'`; that directive is not +enforceable from this page's meta policy. All enforceable meta CSP directives +remain in `index.html`. + Evidence is deliberately narrow. The historical inference-route aggregate is `historical_unreproducible` and not publication-eligible because normalized samples were not retained. One successful historical Base Sepolia USDC diff --git a/hf-space/static/demo-logic.mjs b/hf-space/static/demo-logic.mjs index 04b44ee..9b86707 100644 --- a/hf-space/static/demo-logic.mjs +++ b/hf-space/static/demo-logic.mjs @@ -10,8 +10,8 @@ const FIXTURE_NAMES = Object.freeze(['evidence.json', 'public-demo-allocation.js const ATOMIC_PATTERN = /^(0|[1-9][0-9]*)$/; const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; const SHA_PATTERN = /^sha256:[0-9a-f]{64}$/; -const MAX_FIXTURE_BYTES = 1_000_000; -const MAX_LIVE_RESPONSE_BYTES = 65_536; +const MAX_RESPONSE_BYTES = 65_536; +const RESPONSE_DEADLINE_MILLISECONDS = 5_000; function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { @@ -43,24 +43,100 @@ function decodeJson(bytes, label) { } } -async function responseBytes(response, label, maximumBytes, { requireOk = true } = {}) { - if (!response || typeof response.arrayBuffer !== 'function') { +function abortable(operation, signal) { + if (signal.aborted) return Promise.reject(signal.reason ?? new Error('response deadline exceeded')); + return new Promise((resolve, reject) => { + const aborted = () => reject(signal.reason ?? new Error('response deadline exceeded')); + signal.addEventListener('abort', aborted, { once: true }); + Promise.resolve(operation).then( + (value) => { + signal.removeEventListener('abort', aborted); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', aborted); + reject(error); + }, + ); + }); +} + +async function responseBytes(response, label, { requireOk = true, signal } = {}) { + if (!response?.body || typeof response.body.getReader !== 'function') { throw new TypeError(`${label} response is invalid`); } if (requireOk && response.ok !== true) throw new TypeError(`${label} request failed`); const contentLength = response.headers?.get?.('content-length'); if (contentLength != null && contentLength !== '') { - if (!ATOMIC_PATTERN.test(contentLength) || Number(contentLength) > maximumBytes) { + if (!ATOMIC_PATTERN.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES) { throw new TypeError(`${label} response is too large`); } } - const bytes = new Uint8Array(await response.arrayBuffer()); - if (bytes.byteLength === 0 || bytes.byteLength > maximumBytes) { - throw new TypeError(`${label} response is too large`); + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await abortable(reader.read(), signal); + if (done) break; + if (!(value instanceof Uint8Array)) throw new TypeError(`${label} response chunk is invalid`); + if (value.byteLength > MAX_RESPONSE_BYTES - totalBytes) { + throw new TypeError(`${label} response is too large`); + } + chunks.push(Uint8Array.from(value)); + totalBytes += value.byteLength; + } + } catch (error) { + try { + Promise.resolve(reader.cancel?.(error)).catch(() => {}); + } catch { + // The original bounded-read error is authoritative. + } + throw error; + } finally { + reader.releaseLock?.(); + } + if (totalBytes === 0) { + throw new TypeError(`${label} response is empty`); + } + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; } return bytes; } +async function fetchResponseBytes( + fetchImpl, + url, + fetchOptions, + label, + { requireOk = true, deadlineMilliseconds = RESPONSE_DEADLINE_MILLISECONDS } = {}, +) { + if (!Number.isInteger(deadlineMilliseconds) || deadlineMilliseconds <= 0) { + throw new TypeError('response deadline must be a positive integer'); + } + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(new Error(`${label} response deadline exceeded`)); + }, deadlineMilliseconds); + try { + const response = await abortable(fetchImpl(url, { + ...fetchOptions, + signal: controller.signal, + }), controller.signal); + const bytes = await responseBytes(response, label, { requireOk, signal: controller.signal }); + return Object.freeze({ bytes, status: response.status }); + } catch (error) { + if (!controller.signal.aborted) controller.abort(error); + throw error; + } finally { + clearTimeout(timeout); + } +} + async function sha256(bytes, cryptoImpl) { if (!cryptoImpl?.subtle || typeof cryptoImpl.subtle.digest !== 'function') { throw new TypeError('Web Crypto SHA-256 is unavailable'); @@ -142,11 +218,11 @@ export async function loadPackagedFixtures({ fetchImpl, cryptoImpl }) { cache: 'no-store', credentials: 'omit', }); - const integrityResponse = await fetchImpl(LOCAL_FIXTURE_URLS[0], fetchOptions); - const integrityBytes = await responseBytes( - integrityResponse, + const { bytes: integrityBytes } = await fetchResponseBytes( + fetchImpl, + LOCAL_FIXTURE_URLS[0], + fetchOptions, 'fixture-integrity.json', - MAX_FIXTURE_BYTES, ); const integrity = validateIntegrityManifest(decodeJson(integrityBytes, 'fixture-integrity.json')); const values = Object.create(null); @@ -155,8 +231,7 @@ export async function loadPackagedFixtures({ fetchImpl, cryptoImpl }) { ['evidence.json', LOCAL_FIXTURE_URLS[2]], ]; for (const [fileName, url] of fixtureRequests) { - const response = await fetchImpl(url, fetchOptions); - const bytes = await responseBytes(response, fileName, MAX_FIXTURE_BYTES); + const { bytes } = await fetchResponseBytes(fetchImpl, url, fetchOptions, fileName); const expected = integrity.files[fileName]; if (bytes.byteLength !== expected.bytes || await sha256(bytes, cryptoImpl) !== expected.sha256) { throw new TypeError(`packaged fixture integrity mismatch: ${fileName}`); @@ -367,24 +442,28 @@ function renderLiveResult(documentObject, output, result) { } } -async function fetchLiveOffer(fetchImpl) { +export async function fetchLiveOffer( + fetchImpl, + { deadlineMilliseconds = RESPONSE_DEADLINE_MILLISECONDS } = {}, +) { try { - const response = await fetchImpl(LIVE_ENDPOINT, { + const fetchOptions = { method: 'POST', redirect: 'error', cache: 'no-store', credentials: 'omit', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ input: 'help me tighten this prompt' }), - }); - const bytes = await responseBytes( - response, + }; + const { bytes, status } = await fetchResponseBytes( + fetchImpl, + LIVE_ENDPOINT, + fetchOptions, 'live endpoint', - MAX_LIVE_RESPONSE_BYTES, - { requireOk: false }, + { requireOk: false, deadlineMilliseconds }, ); const body = decodeJson(bytes, 'live endpoint'); - return validateLive402(response.status, body); + return validateLive402(status, body); } catch { return invalid402(null); } diff --git a/hf-space/static/index.html b/hf-space/static/index.html index 2b794d9..fae8b41 100644 --- a/hf-space/static/index.html +++ b/hf-space/static/index.html @@ -3,7 +3,7 @@ - + Skill Asset Protocol — verified accounting demo @@ -36,6 +36,7 @@

BASE SEPOLIA TESTNET · PLAY MONEY · NO REAL FUNDS

+

PROPOSED / NONCANONICAL — The employer-funded internal Invocation-award model is pending explicit approval.

Generated accounting, bounded evidence.

A compensation, attribution, and metering research demo for authored AI Skills. It signs no payment, sends no transaction, and performs no deployment or publication action.

diff --git a/hf-space/static/test-demo-logic.mjs b/hf-space/static/test-demo-logic.mjs index c258734..fb56959 100644 --- a/hf-space/static/test-demo-logic.mjs +++ b/hf-space/static/test-demo-logic.mjs @@ -7,6 +7,7 @@ import { webcrypto } from 'node:crypto'; import test from 'node:test'; import { + fetchLiveOffer, loadPackagedFixtures, loadScenario, renderScenarioModel, @@ -32,11 +33,32 @@ const VALID_402 = Object.freeze({ }); function response(bytes, status = 200) { + return streamedResponse([bytes], status); +} + +function streamedResponse(chunks, status = 200, { + contentLength = String(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)), + onRead = () => {}, +} = {}) { + let index = 0; return { ok: status >= 200 && status < 300, status, - headers: { get: () => String(bytes.length) }, - arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name) => name.toLowerCase() === 'content-length' ? contentLength : null }, + body: { + getReader() { + return { + async read() { + onRead(index); + if (index >= chunks.length) return { done: true, value: undefined }; + const value = chunks[index]; + index += 1; + return { done: false, value }; + }, + releaseLock() {}, + }; + }, + }, }; } @@ -80,6 +102,7 @@ test('browser model renders exact kernel journal rows and conserves gross', asyn for (const scenario of allocation.scenarios) { const model = renderScenarioModel(allocation, scenario.id); + assert.equal(model.implementationNote, scenario.implementationNote); const entries = scenario.allocation.journalEntries; assert.equal(model.rows.length, entries.length); for (let index = 0; index < entries.length; index += 1) { @@ -97,6 +120,49 @@ test('browser model renders exact kernel journal rows and conserves gross', asyn for (const percentile of [`p${50}`, `p${95}`]) assert.equal(rendered.includes(percentile), false); }); +test('live reader caps chunked bodies and aborts a slow total response deadline', async () => { + let reads = 0; + const oversized = streamedResponse( + [Buffer.alloc(65_536), Buffer.from('x')], + 402, + { contentLength: '', onRead: () => { reads += 1; } }, + ); + const oversizedResult = await fetchLiveOffer(async (_url, options) => { + assert.ok(options.signal instanceof AbortSignal); + return oversized; + }); + assert.equal(oversizedResult.live, false); + assert.equal(reads, 2); + + let deadlineSignal; + const slowResult = await fetchLiveOffer(async (_url, options) => { + deadlineSignal = options.signal; + return { + ok: false, + status: 402, + headers: { get: () => '' }, + body: { + getReader() { + return { + read() { + return new Promise((resolve, reject) => { + if (deadlineSignal.aborted) reject(deadlineSignal.reason); + deadlineSignal.addEventListener('abort', () => reject(deadlineSignal.reason), { + once: true, + }); + }); + }, + releaseLock() {}, + }; + }, + }, + }; + }, { deadlineMilliseconds: 5 }); + assert.ok(deadlineSignal instanceof AbortSignal); + assert.equal(deadlineSignal.aborted, true); + assert.equal(slowResult.live, false); +}); + test('fixture loader fetches only local packaged files and verifies raw hashes', async () => { const bytes = await packagedBytes(); const seen = []; diff --git a/hf-space/static/test-index-smoke.mjs b/hf-space/static/test-index-smoke.mjs index 743dec1..279cae5 100644 --- a/hf-space/static/test-index-smoke.mjs +++ b/hf-space/static/test-index-smoke.mjs @@ -50,11 +50,23 @@ function cspAllows(policy, directive, rawUrl, documentOrigin) { function response(value, status = 200) { const bytes = Buffer.isBuffer(value) ? value : Buffer.from(JSON.stringify(value)); + let read = false; return { ok: status >= 200 && status < 300, status, - headers: { get: () => String(bytes.length) }, - arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + headers: { get: (name) => name.toLowerCase() === 'content-length' ? String(bytes.length) : null }, + body: { + getReader() { + return { + async read() { + if (read) return { done: true, value: undefined }; + read = true; + return { done: false, value: bytes }; + }, + releaseLock() {}, + }; + }, + }, }; } @@ -75,8 +87,16 @@ test('static CSP permits only same-origin fixtures and the fixed live endpoint', assert.deepEqual(policy.get('default-src'), ["'none'"]); assert.deepEqual(policy.get('script-src'), ["'self'"]); + assert.deepEqual(policy.get('style-src'), ["'unsafe-inline'"]); + assert.deepEqual(policy.get('img-src'), ["'self'", 'data:']); assert.deepEqual(policy.get('font-src'), ["'none'"]); assert.deepEqual(policy.get('connect-src'), ["'self'", 'https://neverhandedover.com']); + assert.deepEqual(policy.get('base-uri'), ["'none'"]); + assert.deepEqual(policy.get('form-action'), ["'none'"]); + assert.deepEqual(policy.get('object-src'), ["'none'"]); + assert.deepEqual(policy.get('upgrade-insecure-requests'), []); + assert.equal(policy.size, 10); + assert.equal(policy.has('frame-ancestors'), false); assert.equal(cspAllows(policy, 'script-src', './demo-logic.mjs', documentOrigin), true); assert.equal(cspAllows(policy, 'script-src', 'https://attacker.example/code.js', documentOrigin), false); @@ -89,6 +109,23 @@ test('static CSP permits only same-origin fixtures and the fixed live endpoint', assert.equal(cspAllows(policy, 'connect-src', 'https://attacker.example/api', documentOrigin), false); }); +test('static framing labels the proposed model and delegates anti-framing to the host', async () => { + const html = await readFile(new URL('index.html', STATIC_ROOT), 'utf8'); + const { document } = parseHTML(html); + assert.match(document.querySelector('header').textContent, /PROPOSED \/ NONCANONICAL/); + assert.match( + document.querySelector('header').textContent, + /employer-funded internal Invocation-award model is pending explicit approval/i, + ); + + const readme = await readFile(new URL('README.md', STATIC_ROOT), 'utf8'); + assert.match(readme, /PROPOSED \/ NONCANONICAL/); + assert.match(readme, /employer-funded internal Invocation-award model is pending explicit approval/i); + assert.match(readme, /anti-framing must be supplied by the host/is); + assert.match(readme, /Content-Security-Policy.*HTTP\s+response header/is); + assert.match(readme, /frame-ancestors 'none'/); +}); + test('actual HTML auto-mounts once and distinguishes valid 402 from JSON 200/500', async (t) => { const html = await readFile(new URL('index.html', STATIC_ROOT), 'utf8'); const { window, document } = parseHTML(html); diff --git a/spikes/registry-ranking/src/metrics.mjs b/spikes/registry-ranking/src/metrics.mjs index d8a66d3..8621455 100644 --- a/spikes/registry-ranking/src/metrics.mjs +++ b/spikes/registry-ranking/src/metrics.mjs @@ -65,6 +65,28 @@ function requireExactKeys(value, expected, label) { } } +function snapshotExactDataRecord(value, expected, label) { + requireRecord(value, label); + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== 'string')) { + fail(`${label} must use only string keys`); + } + const actual = [...ownKeys].sort(); + if (actual.length !== expected.length + || actual.some((key, index) => key !== expected[index])) { + fail(`${label} has invalid keys`); + } + const snapshot = Object.create(null); + for (const key of ownKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail(`${label} fields must be own enumerable data properties`); + } + snapshot[key] = descriptor.value; + } + return snapshot; +} + function requireIdentifier(value, label) { if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) { fail(`${label} must be a canonical identifier`); @@ -141,30 +163,32 @@ function validateClassification(value) { } export function parseSettlementMetricEvent(value) { - requireRecord(value, 'SettlementMetricEventV1'); - requireExactKeys(value, EVENT_KEYS, 'SettlementMetricEventV1'); - if (value.schemaVersion !== 1) fail('SettlementMetricEventV1 schemaVersion must be 1'); + const event = snapshotExactDataRecord(value, EVENT_KEYS, 'SettlementMetricEventV1'); + if (event.schemaVersion !== 1) fail('SettlementMetricEventV1 schemaVersion must be 1'); - const claims = requireRecord(value.untrustedPayerClaims, 'untrustedPayerClaims'); - requireExactKeys(claims, CLAIM_KEYS, 'untrustedPayerClaims'); - const gross = requireAtomic(value.grossAtomic, 'grossAtomic'); - const refunded = requireAtomic(value.refundedAtomic, 'refundedAtomic'); - const recycled = requireAtomic(value.recycledAtomic, 'recycledAtomic'); + const claims = snapshotExactDataRecord( + event.untrustedPayerClaims, + CLAIM_KEYS, + 'untrustedPayerClaims', + ); + const gross = requireAtomic(event.grossAtomic, 'grossAtomic'); + const refunded = requireAtomic(event.refundedAtomic, 'refundedAtomic'); + const recycled = requireAtomic(event.recycledAtomic, 'recycledAtomic'); if (refunded > gross) fail('refundedAtomic must not exceed grossAtomic'); if (recycled > gross) fail('recycledAtomic must not exceed grossAtomic'); if (refunded + recycled > gross) { fail('refundedAtomic plus recycledAtomic must not exceed grossAtomic'); } - if (!OUTCOMES.has(value.outcome)) fail('outcome is invalid'); + if (!OUTCOMES.has(event.outcome)) fail('outcome is invalid'); return deepFreeze({ schemaVersion: 1, - settlementId: requireIdentifier(value.settlementId, 'settlementId'), - invocationId: requireIdentifier(value.invocationId, 'invocationId'), - skillId: requireIdentifier(value.skillId, 'skillId'), - creatorWallet: requireWallet(value.creatorWallet, 'creatorWallet'), - payeeWallet: requireWallet(value.payeeWallet, 'payeeWallet'), - payerWallet: requireWallet(value.payerWallet, 'payerWallet'), + settlementId: requireIdentifier(event.settlementId, 'settlementId'), + invocationId: requireIdentifier(event.invocationId, 'invocationId'), + skillId: requireIdentifier(event.skillId, 'skillId'), + creatorWallet: requireWallet(event.creatorWallet, 'creatorWallet'), + payeeWallet: requireWallet(event.payeeWallet, 'payeeWallet'), + payerWallet: requireWallet(event.payerWallet, 'payerWallet'), untrustedPayerClaims: { beneficiaryId: requireUntrustedText(claims.beneficiaryId, 'untrustedPayerClaims.beneficiaryId'), payerClusterId: requireUntrustedText(claims.payerClusterId, 'untrustedPayerClaims.payerClusterId'), @@ -173,8 +197,8 @@ export function parseSettlementMetricEvent(value) { grossAtomic: gross.toString(), refundedAtomic: refunded.toString(), recycledAtomic: recycled.toString(), - outcome: value.outcome, - settledAt: requireUtcTimestamp(value.settledAt, 'settledAt'), + outcome: event.outcome, + settledAt: requireUtcTimestamp(event.settledAt, 'settledAt'), }); } diff --git a/spikes/registry-ranking/src/report.mjs b/spikes/registry-ranking/src/report.mjs index 27da8bb..09683fb 100644 --- a/spikes/registry-ranking/src/report.mjs +++ b/spikes/registry-ranking/src/report.mjs @@ -8,6 +8,54 @@ import { rankEligibleSkills, } from './metrics.mjs'; +function errorMessage(error) { + try { + if (error instanceof Error) return String(error.message); + if (typeof error === 'string') return error; + return String(error); + } catch { + return 'Unknown registry report failure'; + } +} + +function quoteTerminalText(value) { + let quoted = '"'; + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit === 0x22) { + quoted += '\\"'; + } else if (codeUnit === 0x5c) { + quoted += '\\\\'; + } else if ( + codeUnit <= 0x1f + || (codeUnit >= 0x7f && codeUnit <= 0x9f) + || codeUnit === 0x061c + || codeUnit === 0x200e + || codeUnit === 0x200f + || (codeUnit >= 0x2028 && codeUnit <= 0x202e) + || (codeUnit >= 0x2066 && codeUnit <= 0x206f) + || (codeUnit >= 0xd800 && codeUnit <= 0xdfff + && !(codeUnit <= 0xdbff + && index + 1 < value.length + && value.charCodeAt(index + 1) >= 0xdc00 + && value.charCodeAt(index + 1) <= 0xdfff)) + ) { + quoted += `\\u${codeUnit.toString(16).padStart(4, '0')}`; + } else { + quoted += value[index]; + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + index += 1; + quoted += value[index]; + } + } + } + return `${quoted}"`; +} + +export function renderRegistryError(error) { + return `error: ${quoteTerminalText(errorMessage(error))}`; +} + function sortedMetrics(metricValues) { if (!Array.isArray(metricValues)) throw new TypeError('metrics must be an array'); return metricValues.slice().sort((left, right) => left.skillId.localeCompare(right.skillId)); @@ -99,7 +147,7 @@ export async function main(argv = process.argv.slice(2)) { if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { main().catch((error) => { - process.stderr.write(`${error.message}\n`); + process.stderr.write(`${renderRegistryError(error)}\n`); process.exitCode = 1; }); } diff --git a/spikes/registry-ranking/test/metrics.test.mjs b/spikes/registry-ranking/test/metrics.test.mjs index 083ba46..2a193e9 100644 --- a/spikes/registry-ranking/test/metrics.test.mjs +++ b/spikes/registry-ranking/test/metrics.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import test from 'node:test'; import { @@ -9,7 +11,11 @@ import { parseSettlementMetricEvent, rankEligibleSkills, } from '../src/metrics.mjs'; -import { buildRegistryReport, renderRegistryReport } from '../src/report.mjs'; +import { + buildRegistryReport, + renderRegistryError, + renderRegistryReport, +} from '../src/report.mjs'; const fixtureUrl = new URL('../fixtures/settlements.json', import.meta.url); const registryUrl = new URL('../fixtures/verified-billing-registry.json', import.meta.url); @@ -151,6 +157,75 @@ test('metric parser and reducer fail closed on malformed events', () => { ); }); +test('settlement parser snapshots every own enumerable data field exactly once', () => { + const eventTarget = clone(events[0]); + const claimsTarget = eventTarget.untrustedPayerClaims; + const eventDescriptors = new Map(); + const claimDescriptors = new Map(); + let eventReads = 0; + let claimReads = 0; + const claims = new Proxy(claimsTarget, { + getOwnPropertyDescriptor(target, key) { + claimDescriptors.set(key, (claimDescriptors.get(key) ?? 0) + 1); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + get(target, key, receiver) { + claimReads += 1; + return Reflect.get(target, key, receiver); + }, + }); + eventTarget.untrustedPayerClaims = claims; + const event = new Proxy(eventTarget, { + getOwnPropertyDescriptor(target, key) { + eventDescriptors.set(key, (eventDescriptors.get(key) ?? 0) + 1); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + get(target, key, receiver) { + eventReads += 1; + return Reflect.get(target, key, receiver); + }, + }); + + assert.equal(parseSettlementMetricEvent(event).settlementId, eventTarget.settlementId); + assert.equal(eventReads, 0); + assert.equal(claimReads, 0); + assert.equal(eventDescriptors.size, 13); + assert.equal(claimDescriptors.size, 3); + assert.deepEqual([...eventDescriptors.values()], Array(eventDescriptors.size).fill(1)); + assert.deepEqual([...claimDescriptors.values()], Array(claimDescriptors.size).fill(1)); + + let getterCalls = 0; + const accessor = clone(events[0]); + Object.defineProperty(accessor, 'settledAt', { + enumerable: true, + get() { + getterCalls += 1; + return events[0].settledAt; + }, + }); + assert.throws(() => parseSettlementMetricEvent(accessor), /own enumerable data properties/i); + assert.equal(getterCalls, 0); + + const nestedAccessor = clone(events[0]); + Object.defineProperty(nestedAccessor.untrustedPayerClaims, 'relationship', { + enumerable: true, + get() { + getterCalls += 1; + return 'independent'; + }, + }); + assert.throws(() => parseSettlementMetricEvent(nestedAccessor), /own enumerable data properties/i); + assert.equal(getterCalls, 0); + + const symbolField = clone(events[0]); + symbolField[Symbol('unused')] = 'ignored-by-Object.keys'; + assert.throws(() => parseSettlementMetricEvent(symbolField), /string keys/i); + + const malformedUnusedClaim = clone(events[0]); + malformedUnusedClaim.untrustedPayerClaims.beneficiaryId = ''; + assert.throws(() => parseSettlementMetricEvent(malformedUnusedClaim), /beneficiaryId/i); +}); + test('trusted registry requires canonical direct evidence records', () => { const cases = [ (() => { @@ -246,3 +321,52 @@ test('report rejects duplicate settlement and successful Invocation IDs across S /duplicate successful Invocation/i, ); }); + +test('report errors are deterministic quoted terminal-safe single lines', () => { + const unsafe = 'quote" slash\\ c0\u0000 ansi\u001b c1\u0085\u009b bidi\u061c\u200e\u200f' + + '\u202a\u202e\u2066\u2069\u206f line\u2028\u2029 high\ud800 low\udfff'; + const rendered = renderRegistryError(new Error(unsafe)); + assert.ok(rendered.startsWith('error: "')); + assert.ok(rendered.endsWith('"')); + assert.equal(rendered.split('\n').length, 1); + assert.doesNotMatch( + rendered, + /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u206f]/u, + ); + for (const token of [ + '\\"', '\\\\', '\\u0000', '\\u001b', '\\u0085', '\\u009b', '\\u061c', + '\\u200e', '\\u200f', '\\u2028', '\\u2029', '\\u202a', '\\u202e', + '\\u2066', '\\u2069', '\\u206f', '\\ud800', '\\udfff', + ]) assert.ok(rendered.includes(token), token); +}); + +test('report CLI safely renders a malicious argv filename and preserves JSON output', () => { + const cliPath = fileURLToPath(new URL('../src/report.mjs', import.meta.url)); + const rootUrl = new URL('../', import.meta.url); + const hostilePath = 'missing-"\\\n\u001b\u0085\u202e.json'; + const failed = spawnSync(process.execPath, [cliPath, hostilePath, fileURLToPath(registryUrl)], { + cwd: rootUrl, + encoding: 'utf8', + }); + assert.notEqual(failed.status, 0); + assert.equal(failed.stdout, ''); + assert.ok(failed.stderr.startsWith('error: "')); + assert.equal(failed.stderr.trimEnd().split('\n').length, 1); + assert.doesNotMatch( + failed.stderr, + /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u206f]/u, + ); + for (const token of ['\\"', '\\\\', '\\u000a', '\\u001b', '\\u0085', '\\u202e']) { + assert.ok(failed.stderr.includes(token), token); + } + + const succeeded = spawnSync( + process.execPath, + [cliPath, fileURLToPath(fixtureUrl), fileURLToPath(registryUrl), '--json'], + { cwd: rootUrl, encoding: 'utf8' }, + ); + assert.equal(succeeded.status, 0, succeeded.stderr); + assert.equal(succeeded.stderr, ''); + const expectedJson = `${JSON.stringify(buildRegistryReport(events, verifiedBillingRegistry), null, 2)}\n`; + assert.equal(succeeded.stdout, expectedJson); +}); From 25c4af3f13748a0f7ffea5787858c5a4493f9217 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 14:25:13 -0400 Subject: [PATCH 133/165] fix: bound Pi payment runtime I/O --- spikes/pi-wielder/README.md | 29 ++- spikes/pi-wielder/RUNBOOK.md | 16 ++ spikes/pi-wielder/pi-extension/x402.ts | 14 +- spikes/pi-wielder/src/collar.mjs | 119 ++++++++--- spikes/pi-wielder/src/gateway.mjs | 20 +- spikes/pi-wielder/src/proxy.mjs | 195 ++++++++++++++++-- spikes/pi-wielder/src/runtime-boundaries.mjs | 156 ++++++++++++++ spikes/pi-wielder/src/x402-seller.mjs | 129 ++++++++++-- spikes/pi-wielder/tests/collar-cogs.test.mjs | 94 +++++++++ spikes/pi-wielder/tests/paying-fetch.test.mjs | 96 +++++++++ .../tests/pi-extension-contract.test.mjs | 21 ++ spikes/pi-wielder/tests/proxy-trust.test.mjs | 97 +++++++++ .../tests/runtime-boundaries.test.mjs | 127 ++++++++++++ .../pi-wielder/tests/x402-lifecycle.test.mjs | 140 ++++++++++++- 14 files changed, 1174 insertions(+), 79 deletions(-) create mode 100644 spikes/pi-wielder/src/runtime-boundaries.mjs create mode 100644 spikes/pi-wielder/tests/pi-extension-contract.test.mjs create mode 100644 spikes/pi-wielder/tests/runtime-boundaries.test.mjs diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 1f061b6..7ee6661 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -156,7 +156,7 @@ npm test npm run e2e ``` -Expected current results are 172 offline unit/integration tests and 41 offline e2e +Expected current results are 193 offline unit/integration tests and 41 offline e2e checks. Counts can increase as regressions are added; zero failures is the contract. The e2e labels all timing output synthetic and uses in-process Hono requests only. @@ -183,6 +183,7 @@ blocked live boundary, see [RUNBOOK.md](./RUNBOOK.md). | `src/x402-seller.mjs` | Seller x402 v1 `exact` middleware and approved transport constructors | | `src/proxy.mjs` | Wielder wallet, paying fetch, pinned receipt verification, and local receipt view | | `src/payment-policy.mjs` | Strict Base Sepolia offer validation, one-process reservation state, exact signed authorization recovery, and trusted reconciliation boundary | +| `src/runtime-boundaries.mjs` | Composed wall-clock deadlines plus streaming byte-limited body and JSON readers | | `src/ledger.mjs` | JSONL-capable Wielder receipt-view storage and rendering | | `src/gateway.mjs` | Simulated x402 model reseller | | `src/facilitator-mock.mjs` | Offline signature verification plus synthetic settlement | @@ -191,6 +192,28 @@ blocked live boundary, see [RUNBOOK.md](./RUNBOOK.md). ## Security and operational boundaries +Every body limit is enforced while chunks arrive, including when `Content-Length` is +absent. A declared oversize is rejected before the body is pulled. The Collar and its +default x402 middleware accept at most exactly 4,096 request bytes; 4,096 is accepted +and 4,097 is rejected before an offer. The model gateway and proxy model route accept +at most 1 MiB, while the proxy Skill route keeps the 4,096-byte contract. Buyer x402 +challenges and facilitator JSON responses are capped at 64 KiB. Anthropic JSON and +proxy upstream responses are capped at 1 MiB. + +Default wall-clock bounds are 15 seconds for the unpaid buyer fetch, 30 seconds for +the signed paid retry, 5 seconds for x402/proxy request-body reads, 10 seconds for each +facilitator verify and settle operation, and 30 seconds for provider execution and +proxy upstream-response reads. Caller abort signals are composed into child signals; +an internal timeout never aborts the caller's controller. Redirects remain disabled. + +Timeout state follows the durable money boundary: an unpaid timeout creates no +reservation; a signed retry or facilitator ambiguity stays `unresolved` with budget +held; and a provider timeout after settlement finalizes a sanitized failed receipt, +unknown COGS, one full-gross reconciliation hold, no output, and no Royalty credits. +Raw transport and provider errors are not returned or journaled. The manual Pi tool +has no caller-selected Skill route: it invokes only the fixed, encoded +`optimizing-claude-code-prompts` path. + - Base Sepolia only; no mainnet and no real funds in automated verification. - Live facilitator construction accepts only the byte-exact approved HTTPS base and disables redirects for `/verify` and `/settle`. @@ -203,8 +226,8 @@ blocked live boundary, see [RUNBOOK.md](./RUNBOOK.md). not a distributed consensus mechanism. - The proxy trusts an operator-pinned public key file and one SHA-256 key ID of its SPKI DER. A key ID or key embedded in a receipt cannot authenticate that receipt. -- The Pi extension is a manual demo adapter and is not compiled by this spike's test - suite. +- The Pi extension is a manual demo adapter and is not type-compiled by this spike; + an offline source-contract test pins its fixed Skill route and tool schema. - Successful mock accounting records synthetic-config provider usage and allocates execution COGS and settlement cost before the Royalty pool. It is executable evidence of ordering and conservation, not a validated production margin model or current diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md index 67a4ea8..486cc7d 100644 --- a/spikes/pi-wielder/RUNBOOK.md +++ b/spikes/pi-wielder/RUNBOOK.md @@ -90,6 +90,22 @@ routes without a separately pinned public key and expected key ID. `/ledger` is a payer-side receipt view. It is not the Collar journal and is not a cross-seller accounting authority. +### Runtime limits + +The mock and standalone paths use the same bounded runtime contract as the tests. +Collar/Skill request bodies stop at exactly 4,096 bytes; model requests stop at 1 MiB. +x402 challenges and facilitator JSON stop at 64 KiB; Anthropic JSON and proxy upstream +responses stop at 1 MiB. Streaming and chunked bodies are counted as they arrive, so +omitting `Content-Length` does not bypass a limit. + +The default deadlines are 15 seconds for an unpaid buyer fetch, 30 seconds for its +single paid retry, 5 seconds for request-body reads, 10 seconds each for facilitator +verify and settle, and 30 seconds for provider execution/upstream response reads. An +unpaid timeout remains unreserved. Any timeout after the signature keeps the payment +unresolved/held until trusted reconciliation. A provider timeout after settlement +returns no output and finalizes a signed failed receipt with unknown COGS and the full +gross held for reconciliation or refund. + ## 3. Persistent authority contract `COLLAR_JOURNAL_FILE` and `COLLAR_SIGNING_KEY_FILE` are one authority pair: diff --git a/spikes/pi-wielder/pi-extension/x402.ts b/spikes/pi-wielder/pi-extension/x402.ts index c96c815..cc0727d 100644 --- a/spikes/pi-wielder/pi-extension/x402.ts +++ b/spikes/pi-wielder/pi-extension/x402.ts @@ -18,6 +18,8 @@ // on this file compiling. const PROXY = process.env.PI_WIELDER_PROXY ?? "http://localhost:8402"; +const HOSTED_SKILL_ID = "optimizing-claude-code-prompts"; +const HOSTED_SKILL_PATH = `/invoke/${encodeURIComponent(HOSTED_SKILL_ID)}`; const displayUsdc = (amountAtomic: string) => { const padded = BigInt(amountAtomic).toString().padStart(7, "0"); @@ -98,23 +100,17 @@ export default function activate(pi: Pi) { parameters: { type: "object", properties: { - skillId: { - type: "string", - description: "Hosted skill id", - default: "optimizing-claude-code-prompts", - }, input: { type: "string", description: "The rough request to optimize" }, }, required: ["input"], }, - async execute(args: { skillId?: string; input: string }) { - const skillId = args.skillId ?? "optimizing-claude-code-prompts"; - const res = await fetch(`${PROXY}/invoke/${skillId}`, { + async execute(args: { input: string }) { + const res = await fetch(`${PROXY}${HOSTED_SKILL_PATH}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ input: args.input }), }); - if (!res.ok) return `invoke_skill failed (HTTP ${res.status}): ${await res.text()}`; + if (!res.ok) return `invoke_skill failed (HTTP ${res.status})`; const { output, receipt } = (await res.json()) as { output: string; receipt: SignedInvocationReceipt; diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index c7ddb25..f2b8b20 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -31,11 +31,18 @@ import { createMockFacilitatorTransport, usdcToAtomic, x402Paywall, + x402RequestBodyBytes, + x402RequestBodyText, } from './x402-seller.mjs'; import { canonicalJson, createInvocationJournal, } from './invocation-journal.mjs'; +import { + readJsonBody, + RuntimeBoundaryError, + withWallClockDeadline, +} from './runtime-boundaries.mjs'; export const SKILL_ID = 'optimizing-claude-code-prompts'; const SKILL_PATH = fileURLToPath( @@ -50,6 +57,8 @@ const DEFAULT_EXECUTION = Object.freeze({ const SETTLEMENT_COST_ATOMIC = '1000'; const REFUND_RESERVE_ATOMIC = '5000'; const MAX_REQUEST_BODY_BYTES = 4096; +export const DEFAULT_PROVIDER_TIMEOUT_MS = 30_000; +export const DEFAULT_PROVIDER_RESPONSE_BYTES = 1024 * 1024; const SKILL_VERSION = 'optimizing-claude-code-prompts/2026-07-17-v1'; const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']); @@ -143,11 +152,15 @@ export function createCollar({ signingKeyFile = process.env.COLLAR_SIGNING_KEY_FILE || null, receiptSigner = null, executeSkill = null, + providerTimeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS, lifecycleFaults = {}, resolveSettlement = null, executeRefund = null, resolveRefund = null, } = {}) { + if (!Number.isSafeInteger(providerTimeoutMs) || providerTimeoutMs <= 0) { + throw new TypeError('providerTimeoutMs must be a positive safe integer'); + } if (journal && (journalFile || signingKeyFile || receiptSigner)) { throw new Error('injected journal cannot be combined with journal/key paths or signer'); } @@ -205,8 +218,8 @@ export function createCollar({ const readInvocationBody = async (c) => { const cached = c.get('invocationBody'); if (cached) return cached; - const raw = await c.req.text(); - const requestBodyBytes = Buffer.byteLength(raw, 'utf8'); + const raw = x402RequestBodyText(c); + const requestBodyBytes = x402RequestBodyBytes(c).byteLength; if (requestBodyBytes > MAX_REQUEST_BODY_BYTES) { throw new ExecutionEconomicsError( 'REQUEST_BODY_TOO_LARGE', @@ -718,7 +731,14 @@ export function createCollar({ let execution; try { - execution = await executor({ + execution = await withWallClockDeadline({ + signal: c.req.raw.signal, + timeoutMs: providerTimeoutMs, + timeoutCode: 'UPSTREAM_PROVIDER_TIMEOUT', + timeoutMessage: 'provider execution timed out after settlement', + abortedCode: 'UPSTREAM_PROVIDER_ABORTED', + abortedMessage: 'provider execution was aborted after settlement', + }, (signal) => executor({ skillId: SKILL_ID, skillVersionHash, skillContent, @@ -729,19 +749,28 @@ export function createCollar({ maxOutputTokens: frozenQuote.maxOutputTokens, promptBytes: frozenQuote.promptBytes, estimatedInputTokens: frozenQuote.estimatedInputTokens, - }); + signal, + })); } catch (error) { let retainedUsage = null; try { retainedUsage = error?.usage ?? null; } catch { retainedUsage = null; } + const safeProviderFailureClasses = new Set([ + 'UPSTREAM_PROVIDER_TIMEOUT', + 'UPSTREAM_PROVIDER_ABORTED', + 'UPSTREAM_PROVIDER_RESPONSE_TOO_LARGE', + ]); + const failureClass = safeProviderFailureClasses.has(error?.code) + ? error.code + : 'UPSTREAM_PROVIDER_ERROR'; const accounting = createPendingExecutionAccounting({ quote: frozenQuote, usage: retainedUsage, - failureClass: 'UPSTREAM_PROVIDER_ERROR', + failureClass, reason: 'provider execution failed after settlement', catalog: frozenExecutionCatalog, }); return finishFailure( - 'UPSTREAM_PROVIDER_ERROR', + failureClass, 'Skill execution failed after settlement', 500, accounting, @@ -852,11 +881,19 @@ function mockSkillOutput(input) { export function createAnthropicExecutor({ apiKey = process.env.ANTHROPIC_API_KEY, fetchImpl = fetch, + timeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS, + maxResponseBytes = DEFAULT_PROVIDER_RESPONSE_BYTES, } = {}) { if (typeof apiKey !== 'string' || !apiKey) { throw new Error('Anthropic API key is required for the live executor'); } if (typeof fetchImpl !== 'function') throw new TypeError('Anthropic executor requires fetch'); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new TypeError('Anthropic timeoutMs must be a positive safe integer'); + } + if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0) { + throw new TypeError('Anthropic maxResponseBytes must be a positive safe integer'); + } return async ({ skillContent, input, @@ -865,6 +902,7 @@ export function createAnthropicExecutor({ maxOutputTokens, promptBytes, estimatedInputTokens, + signal = null, }) => { const rebound = conservativeProviderPromptBound({ systemPrompt: skillContent, @@ -880,33 +918,56 @@ export function createAnthropicExecutor({ 'provider prompt differs from the accepted quote', ); } - let response; + let data; try { - response = await fetchImpl('https://api.anthropic.com/v1/messages', { - method: 'POST', - redirect: 'error', - headers: { - 'content-type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model, - max_tokens: maxOutputTokens, - system: skillContent, - messages: [{ role: 'user', content: input }], - }), + data = await withWallClockDeadline({ + signal, + timeoutMs, + timeoutCode: 'UPSTREAM_PROVIDER_TIMEOUT', + timeoutMessage: 'provider request timed out', + abortedCode: 'UPSTREAM_PROVIDER_ABORTED', + abortedMessage: 'provider request was aborted', + }, async (composedSignal) => { + const response = await fetchImpl('https://api.anthropic.com/v1/messages', { + method: 'POST', + redirect: 'error', + signal: composedSignal, + headers: { + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + max_tokens: maxOutputTokens, + system: skillContent, + messages: [{ role: 'user', content: input }], + }), + }); + if (!response?.ok) { + throw new RuntimeBoundaryError('UPSTREAM_PROVIDER_ERROR', 'provider request failed'); + } + if (response?.body !== undefined && response?.headers?.get) { + return readJsonBody(response, { + maxBytes: maxResponseBytes, + tooLargeCode: 'UPSTREAM_PROVIDER_RESPONSE_TOO_LARGE', + tooLargeMessage: 'provider response exceeds the JSON byte limit', + readErrorCode: 'UPSTREAM_PROVIDER_RESPONSE_READ_FAILED', + readErrorMessage: 'provider response could not be read', + jsonErrorCode: 'UPSTREAM_PROVIDER_RESPONSE_JSON', + jsonErrorMessage: 'provider response was not JSON', + signal: composedSignal, + }); + } + return response.json(); }); - } catch { - throw new ExecutionEconomicsError('UPSTREAM_PROVIDER_ERROR', 'provider request failed'); - } - if (!response?.ok) { + } catch (error) { + if (error instanceof RuntimeBoundaryError) { + throw new ExecutionEconomicsError(error.code, error.message); + } + if (error instanceof ExecutionEconomicsError) throw error; throw new ExecutionEconomicsError('UPSTREAM_PROVIDER_ERROR', 'provider request failed'); } - let data; - try { data = await response.json(); } catch { - throw new ExecutionEconomicsError('UPSTREAM_PROVIDER_ERROR', 'provider response was not JSON'); - } return { output: Array.isArray(data?.content) ? data.content.map((block) => (typeof block?.text === 'string' ? block.text : '')).join('') diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index 8f26820..cd6775e 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -18,16 +18,27 @@ import { createLiveFacilitatorTransport, createMockFacilitatorTransport, x402Paywall, + x402RequestBodyText, } from './x402-seller.mjs'; // Flat per-call testnet prices by model family (real resellers price per // token; per-call keeps the 402 requirements computable before inference). export const MODEL_PRICES_USDC = Object.freeze({ claude: '0.041', gpt: '0.087', default: '0.05' }); +export const MAX_GATEWAY_REQUEST_BODY_BYTES = 1024 * 1024; const priceFor = (model = '') => model.startsWith('claude') ? MODEL_PRICES_USDC.claude : model.startsWith('gpt') ? MODEL_PRICES_USDC.gpt : MODEL_PRICES_USDC.default; +function gatewayRequestBody(c) { + const cached = c.get('gatewayRequestBody'); + if (cached) return cached; + let body; + try { body = JSON.parse(x402RequestBodyText(c)); } catch { body = {}; } + c.set('gatewayRequestBody', body); + return body; +} + export function createGateway({ facilitatorTransport, payTo = process.env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dEaD', @@ -39,15 +50,16 @@ export function createGateway({ app.post( '/v1/chat/completions', x402Paywall({ - // Per-request pricing: Hono caches the parsed body, so reading it here - // and again in the handler is safe. - price: async (c) => priceFor((await c.req.json().catch(() => ({}))).model), + // The x402 boundary owns one bounded stream read. Pricing and execution + // parse its cached text instead of consuming the request twice. + price: async (c) => priceFor(gatewayRequestBody(c).model), payTo, facilitatorTransport, description: 'per-call model inference (x402 reseller, testnet)', + maxRequestBodyBytes: MAX_GATEWAY_REQUEST_BODY_BYTES, }), async (c) => { - const body = await c.req.json(); + const body = gatewayRequestBody(c); const model = body.model ?? ''; const completion = mockLlm ? mockCompletion(body) : model.startsWith('claude') ? await viaAnthropic(body) diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index d7de33c..9253480 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -19,6 +19,12 @@ import { formatUsdc, parseUsdc } from '../../../prototype/atomic-money.mjs'; import { loadAccount } from './wallet.mjs'; import { createLedger, renderLedger } from './ledger.mjs'; import { receiptKeyId, verifySignedReceipt } from './invocation-journal.mjs'; +import { + readBodyBytes, + readJsonBody, + RuntimeBoundaryError, + withWallClockDeadline, +} from './runtime-boundaries.mjs'; import { BASE_SEPOLIA_CHAIN_ID, BASE_SEPOLIA_NETWORK, @@ -31,6 +37,14 @@ import { // signature that IS the payment. (Constants restated here on purpose: the // Wielder must be self-contained, importing nothing from the seller side.) const CHAIN_ID = BASE_SEPOLIA_CHAIN_ID; +export const DEFAULT_UNPAID_FETCH_TIMEOUT_MS = 15_000; +export const DEFAULT_PAID_RETRY_TIMEOUT_MS = 30_000; +export const MAX_X402_CHALLENGE_BYTES = 64 * 1024; +export const DEFAULT_PROXY_SKILL_REQUEST_BYTES = 4096; +export const DEFAULT_PROXY_MODEL_REQUEST_BYTES = 1024 * 1024; +export const DEFAULT_PROXY_UPSTREAM_RESPONSE_BYTES = 1024 * 1024; +export const DEFAULT_PROXY_REQUEST_TIMEOUT_MS = 5_000; +export const DEFAULT_PROXY_RESPONSE_TIMEOUT_MS = 30_000; const EIP3009_TYPES = { TransferWithAuthorization: [ { name: 'from', type: 'address' }, { name: 'to', type: 'address' }, @@ -42,6 +56,7 @@ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64'); const payingFetchOptionKeys = new Set([ 'fetchImpl', 'idempotencyKey', 'paymentPolicy', 'onSignedAuthorizationPersisted', 'nonceFactory', + 'unpaidTimeoutMs', 'paidTimeoutMs', ]); const requestInitKeys = Object.freeze([ 'body', 'cache', 'credentials', 'dispatcher', 'duplex', 'headers', 'integrity', 'keepalive', @@ -53,6 +68,47 @@ function paymentError(code, message) { return new PaymentPolicyError(code, message); } +function positiveLimit(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${label} must be a positive safe integer`); + } + return value; +} + +function proxyBoundaryResponse(c, error) { + const code = error instanceof RuntimeBoundaryError ? error.code : 'UPSTREAM_FAILURE'; + const statuses = { + PROXY_REQUEST_TOO_LARGE: 413, + PROXY_REQUEST_TIMEOUT: 408, + PROXY_REQUEST_ABORTED: 408, + UPSTREAM_RESPONSE_TOO_LARGE: 502, + UPSTREAM_RESPONSE_TIMEOUT: 504, + UPSTREAM_RESPONSE_ABORTED: 504, + UNPAID_FETCH_TIMEOUT: 504, + PAID_RETRY_TIMEOUT: 504, + UNPAID_FETCH_ABORTED: 408, + PAID_RETRY_ABORTED: 504, + X402_CHALLENGE_TOO_LARGE: 502, + }; + const messages = { + PROXY_REQUEST_TOO_LARGE: error.message, + PROXY_REQUEST_TIMEOUT: 'proxy request body timed out', + PROXY_REQUEST_ABORTED: 'proxy request body was aborted', + UPSTREAM_RESPONSE_TOO_LARGE: 'upstream response exceeds the proxy byte limit', + UPSTREAM_RESPONSE_TIMEOUT: 'upstream response timed out', + UPSTREAM_RESPONSE_ABORTED: 'upstream response was aborted', + UNPAID_FETCH_TIMEOUT: 'upstream unpaid request timed out', + PAID_RETRY_TIMEOUT: 'upstream paid retry timed out with settlement unresolved', + UNPAID_FETCH_ABORTED: 'upstream unpaid request was aborted', + PAID_RETRY_ABORTED: 'upstream paid retry was aborted with settlement unresolved', + X402_CHALLENGE_TOO_LARGE: 'upstream x402 challenge exceeds the byte limit', + }; + return c.json({ + error: messages[code] ?? 'Wielder proxy request failed', + code, + }, statuses[code] ?? 502); +} + function validatePayingFetchOptions(options) { if (!options || typeof options !== 'object' || Array.isArray(options) || Object.getPrototypeOf(options) !== Object.prototype @@ -121,6 +177,7 @@ function capturePayingRequestInit(init, idempotencyKey) { } const callerBody = captured.body; + const callerSignal = captured.signal ?? null; let bodyBytes; let transportBody; if (callerBody == null) { @@ -148,13 +205,15 @@ function capturePayingRequestInit(init, idempotencyKey) { delete baseInit.headers; delete baseInit.body; delete baseInit.redirect; + delete baseInit.signal; Object.freeze(baseInit); - function transportInit(xPayment = null) { + function transportInit(xPayment = null, signal = null) { const request = { ...baseInit, method, redirect: 'error', + ...(signal === null ? {} : { signal }), headers: { ...requestHeaders, ...(xPayment === null ? {} : { 'X-PAYMENT': xPayment }), @@ -174,11 +233,33 @@ function capturePayingRequestInit(init, idempotencyKey) { return Object.freeze({ method, + callerSignal, policyBodyBytes, transportInit, }); } +const challengeReadOptions = Object.freeze({ + maxBytes: MAX_X402_CHALLENGE_BYTES, + tooLargeCode: 'X402_CHALLENGE_TOO_LARGE', + tooLargeMessage: 'x402 challenge exceeds the response byte limit', + readErrorCode: 'CHALLENGE_READ_FAILED', + readErrorMessage: 'x402 challenge body could not be read', + jsonErrorCode: 'CHALLENGE_SCHEMA', + jsonErrorMessage: '402 response does not contain strict x402 JSON', +}); + +async function readChallenge(response, signal) { + if (response?.body !== undefined && response?.headers?.get) { + return readJsonBody(response, { ...challengeReadOptions, signal }); + } + try { + return await response.json(); + } catch { + throw paymentError('CHALLENGE_SCHEMA', challengeReadOptions.jsonErrorMessage); + } +} + function decodeSettlementHeader(value) { if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { throw paymentError('SETTLEMENT_EVIDENCE', 'settlement evidence is missing or malformed'); @@ -207,6 +288,8 @@ export async function payingFetch(account, url, init, options = {}) { paymentPolicy, onSignedAuthorizationPersisted = null, nonceFactory = () => `0x${crypto.randomBytes(32).toString('hex')}`, + unpaidTimeoutMs = DEFAULT_UNPAID_FETCH_TIMEOUT_MS, + paidTimeoutMs = DEFAULT_PAID_RETRY_TIMEOUT_MS, } = options; if (typeof fetchImpl !== 'function') throw paymentError('FETCH_CAPABILITY', 'fetchImpl must be a function'); if (!paymentPolicy) throw paymentError('PAYMENT_POLICY_REQUIRED', 'paymentPolicy is required before any x402 signature'); @@ -221,19 +304,25 @@ export async function payingFetch(account, url, init, options = {}) { throw paymentError('AUTHORIZATION_ID', 'idempotencyKey must be a bounded canonical token'); } const request = capturePayingRequestInit(init, idempotencyKey); - const { method } = request; + const { method, callerSignal } = request; const t0 = performance.now(); - const first = await fetchImpl(url, request.transportInit()); + const unpaid = await withWallClockDeadline({ + signal: callerSignal, + timeoutMs: unpaidTimeoutMs, + timeoutCode: 'UNPAID_FETCH_TIMEOUT', + timeoutMessage: 'unpaid x402 request timed out before authorization', + abortedCode: 'UNPAID_FETCH_ABORTED', + abortedMessage: 'unpaid x402 request was aborted before authorization', + }, async (signal) => { + const first = await fetchImpl(url, request.transportInit(null, signal)); + if (first.status !== 402) return { first, receivedAt: null, firstChallenge: null }; + const receivedAt = paymentPolicy.captureReceivedAt(); + const firstChallenge = await readChallenge(first, signal); + return { first, receivedAt, firstChallenge }; + }); + const { first, receivedAt, firstChallenge } = unpaid; if (first.status !== 402) return { res: first, paid: false, idempotencyKey }; - const receivedAt = paymentPolicy.captureReceivedAt(); const ms402 = performance.now() - t0; - - let firstChallenge; - try { - firstChallenge = await first.json(); - } catch { - throw paymentError('CHALLENGE_SCHEMA', '402 response does not contain strict x402 JSON'); - } const authorizationRecord = paymentPolicy.reserveAuthorization({ authorizationId: idempotencyKey, requestUrl: url, @@ -364,17 +453,30 @@ export async function payingFetch(account, url, init, options = {}) { paymentPolicy.beginRetry(idempotencyKey); const tRetry = performance.now(); let res; + let secondChallenge = null; try { - res = await fetchImpl(url, request.transportInit(xPayment)); + ({ res, secondChallenge } = await withWallClockDeadline({ + signal: callerSignal, + timeoutMs: paidTimeoutMs, + timeoutCode: 'PAID_RETRY_TIMEOUT', + timeoutMessage: 'paid x402 retry timed out with settlement unresolved', + abortedCode: 'PAID_RETRY_ABORTED', + abortedMessage: 'paid x402 retry was aborted with settlement unresolved', + }, async (signal) => { + const response = await fetchImpl(url, request.transportInit(xPayment, signal)); + const challengeBody = response.status === 402 ? await readChallenge(response, signal) : null; + return { res: response, secondChallenge: challengeBody }; + })); } catch (error) { - paymentPolicy.markUnresolved(idempotencyKey, { reasonCode: 'RETRY_RESPONSE_LOST' }); + const reasonCode = error instanceof RuntimeBoundaryError + ? error.code + : 'RETRY_RESPONSE_LOST'; + paymentPolicy.markUnresolved(idempotencyKey, { reasonCode }); throw error; } const msPaidRoundtrip = performance.now() - tRetry; if (res.status === 402) { - let secondChallenge = null; - try { secondChallenge = await res.clone().json(); } catch { /* stable changed-quote error below */ } let secondError; try { paymentPolicy.assertRetryChallenge(idempotencyKey, secondChallenge); @@ -542,6 +644,11 @@ export function createProxy({ trustedCollarPublicKeyPem = null, trustedCollarKeyId = null, paymentPolicy = null, + maxSkillRequestBytes = DEFAULT_PROXY_SKILL_REQUEST_BYTES, + maxModelRequestBytes = DEFAULT_PROXY_MODEL_REQUEST_BYTES, + maxUpstreamResponseBytes = DEFAULT_PROXY_UPSTREAM_RESPONSE_BYTES, + proxyRequestTimeoutMs = DEFAULT_PROXY_REQUEST_TIMEOUT_MS, + proxyResponseTimeoutMs = DEFAULT_PROXY_RESPONSE_TIMEOUT_MS, } = {}) { if (!trustedCollarPublicKeyPem || !trustedCollarKeyId) { throw new Error('Skill routes require a pinned Collar public key and key ID'); @@ -549,22 +656,72 @@ export function createProxy({ if (receiptKeyId(trustedCollarPublicKeyPem) !== trustedCollarKeyId) { throw new Error('pinned Collar public key and key ID do not match'); } + positiveLimit(maxSkillRequestBytes, 'maxSkillRequestBytes'); + positiveLimit(maxModelRequestBytes, 'maxModelRequestBytes'); + positiveLimit(maxUpstreamResponseBytes, 'maxUpstreamResponseBytes'); + positiveLimit(proxyRequestTimeoutMs, 'proxyRequestTimeoutMs'); + positiveLimit(proxyResponseTimeoutMs, 'proxyResponseTimeoutMs'); const ledger = createLedger(ledgerFile); const sessionPaymentPolicy = paymentPolicy ?? createDefaultPaymentPolicy({ gatewayUrl, collarUrl }); const app = new Hono(); + app.onError((error, c) => proxyBoundaryResponse(c, error)); // One handler for both asset classes: /v1/* -> inference gateway (leg: // "model"), /invoke/* -> collar (leg: "skill"). Same wallet, one local view. const forward = (upstreamBase, leg, fetchImpl) => async (c) => { const path = c.req.path; - const bodyText = await c.req.text(); + const requestLimit = leg === 'skill' ? maxSkillRequestBytes : maxModelRequestBytes; + let bodyBytes; + try { + bodyBytes = await withWallClockDeadline({ + signal: c.req.raw.signal, + timeoutMs: proxyRequestTimeoutMs, + timeoutCode: 'PROXY_REQUEST_TIMEOUT', + timeoutMessage: 'proxy request body timed out', + abortedCode: 'PROXY_REQUEST_ABORTED', + abortedMessage: 'proxy request body was aborted', + }, (signal) => readBodyBytes(c.req.raw, { + maxBytes: requestLimit, + tooLargeCode: 'PROXY_REQUEST_TOO_LARGE', + tooLargeMessage: leg === 'skill' + ? `proxy Skill request exceeds the ${requestLimit}-byte limit` + : `proxy model request exceeds the ${requestLimit}-byte limit`, + readErrorCode: 'PROXY_REQUEST_READ_FAILED', + readErrorMessage: 'proxy request body could not be read', + signal, + })); + } catch (error) { + return proxyBoundaryResponse(c, error); + } + const bodyText = bodyBytes.toString('utf8'); const { res, paid, xPayment, idempotencyKey, amountAtomic, txHash, payer, requestHash, quoteId, settlementReference, timings, } = await payingFetch(account, `${upstreamBase}${path}`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: bodyText, + method: 'POST', headers: { 'content-type': 'application/json' }, body: bodyBytes, + signal: c.req.raw.signal, }, { fetchImpl, paymentPolicy: sessionPaymentPolicy }); - const resBody = await res.text(); + let resBodyBytes; + try { + resBodyBytes = await withWallClockDeadline({ + signal: c.req.raw.signal, + timeoutMs: proxyResponseTimeoutMs, + timeoutCode: 'UPSTREAM_RESPONSE_TIMEOUT', + timeoutMessage: 'upstream response timed out', + abortedCode: 'UPSTREAM_RESPONSE_ABORTED', + abortedMessage: 'upstream response was aborted', + }, (signal) => readBodyBytes(res, { + maxBytes: maxUpstreamResponseBytes, + tooLargeCode: 'UPSTREAM_RESPONSE_TOO_LARGE', + tooLargeMessage: 'upstream response exceeds the proxy byte limit', + readErrorCode: 'UPSTREAM_RESPONSE_READ_FAILED', + readErrorMessage: 'upstream response could not be read', + signal, + })); + } catch (error) { + return proxyBoundaryResponse(c, error); + } + const resBody = resBodyBytes.toString('utf8'); if (paid && txHash) { let parsed = {}; @@ -631,7 +788,7 @@ export function createProxy({ headers['x-wielder-overhead'] = JSON.stringify(timings); headers['x-wielder-payment'] = xPayment; // testnet-only; never expose a mainnet authorization like this } - return c.newResponse(resBody, res.status, headers); + return c.newResponse(resBodyBytes, res.status, headers); }; app.post('/v1/*', forward(gatewayUrl, 'model', gatewayFetch)); diff --git a/spikes/pi-wielder/src/runtime-boundaries.mjs b/spikes/pi-wielder/src/runtime-boundaries.mjs new file mode 100644 index 0000000..e731d3c --- /dev/null +++ b/spikes/pi-wielder/src/runtime-boundaries.mjs @@ -0,0 +1,156 @@ +export class RuntimeBoundaryError extends Error { + constructor(code, message) { + super(message); + this.name = 'RuntimeBoundaryError'; + this.code = code; + } +} + +const fail = (code, message) => { throw new RuntimeBoundaryError(code, message); }; + +function assertPositiveLimit(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${label} must be a positive safe integer`); + } +} + +function assertSignal(signal) { + if (signal === null || signal === undefined) return null; + if (typeof signal !== 'object' + || typeof signal.aborted !== 'boolean' + || typeof signal.addEventListener !== 'function' + || typeof signal.removeEventListener !== 'function') { + throw new TypeError('signal must be an AbortSignal or null'); + } + return signal; +} + +export async function withWallClockDeadline({ + signal = null, + timeoutMs, + timeoutCode, + timeoutMessage, + abortedCode, + abortedMessage, +}, operation) { + const callerSignal = assertSignal(signal); + assertPositiveLimit(timeoutMs, 'timeoutMs'); + if (typeof operation !== 'function') throw new TypeError('deadline operation must be a function'); + for (const [label, value] of Object.entries({ + timeoutCode, timeoutMessage, abortedCode, abortedMessage, + })) { + if (typeof value !== 'string' || !value) throw new TypeError(`${label} must be non-empty`); + } + if (callerSignal?.aborted) fail(abortedCode, abortedMessage); + + const controller = new AbortController(); + let timer = null; + let onCallerAbort = null; + let rejectBoundary; + let finished = false; + const boundary = new Promise((resolve, reject) => { + void resolve; + rejectBoundary = reject; + }); + const stop = (error) => { + if (finished) return; + if (!controller.signal.aborted) controller.abort(error); + rejectBoundary(error); + }; + + if (callerSignal) { + onCallerAbort = () => stop(new RuntimeBoundaryError(abortedCode, abortedMessage)); + callerSignal.addEventListener('abort', onCallerAbort, { once: true }); + } + timer = setTimeout(() => { + stop(new RuntimeBoundaryError(timeoutCode, timeoutMessage)); + }, timeoutMs); + + let operationPromise; + try { + operationPromise = Promise.resolve(operation(controller.signal)); + } catch (error) { + operationPromise = Promise.reject(error); + } + try { + return await Promise.race([operationPromise, boundary]); + } finally { + finished = true; + clearTimeout(timer); + if (callerSignal && onCallerAbort) { + callerSignal.removeEventListener('abort', onCallerAbort); + } + } +} + +function contentLength(source) { + const value = source?.headers?.get?.('content-length'); + if (value === null || value === undefined) return null; + if (!/^(0|[1-9]\d*)$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +async function cancelQuietly(readerOrBody, reason) { + try { await readerOrBody?.cancel?.(reason); } catch { /* bounded failure already owns the result */ } +} + +export async function readBodyBytes(source, { + maxBytes, + tooLargeCode, + tooLargeMessage, + readErrorCode, + readErrorMessage, + signal = null, +}) { + assertPositiveLimit(maxBytes, 'maxBytes'); + const bodySignal = assertSignal(signal); + const declared = contentLength(source); + if (declared !== null && declared > maxBytes) { + await cancelQuietly(source?.body, new RuntimeBoundaryError(tooLargeCode, tooLargeMessage)); + fail(tooLargeCode, tooLargeMessage); + } + if (source?.body == null) return Buffer.alloc(0); + if (typeof source.body.getReader !== 'function') { + fail(readErrorCode, readErrorMessage); + } + + const reader = source.body.getReader(); + const onAbort = bodySignal + ? () => { void cancelQuietly(reader, bodySignal.reason); } + : null; + if (bodySignal && onAbort) bodySignal.addEventListener('abort', onAbort, { once: true }); + const chunks = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) fail(readErrorCode, readErrorMessage); + total += value.byteLength; + if (total > maxBytes) { + const error = new RuntimeBoundaryError(tooLargeCode, tooLargeMessage); + await cancelQuietly(reader, error); + throw error; + } + chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength)); + } + } catch (error) { + if (error instanceof RuntimeBoundaryError) throw error; + await cancelQuietly(reader, error); + fail(readErrorCode, readErrorMessage); + } finally { + if (bodySignal && onAbort) bodySignal.removeEventListener('abort', onAbort); + try { reader.releaseLock(); } catch { /* reader may already be released by its source */ } + } + return Buffer.concat(chunks, total); +} + +export async function readJsonBody(source, options) { + const bytes = await readBodyBytes(source, options); + try { + return JSON.parse(bytes.toString('utf8')); + } catch { + fail(options.jsonErrorCode, options.jsonErrorMessage); + } +} diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index 3dbf0da..84255f2 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -8,6 +8,12 @@ import crypto from 'node:crypto'; import { formatUsdc, parseUsdc } from '../../../prototype/atomic-money.mjs'; +import { + readBodyBytes, + readJsonBody, + RuntimeBoundaryError, + withWallClockDeadline, +} from './runtime-boundaries.mjs'; export const X402_VERSION = 1; export const NETWORK = 'base-sepolia'; @@ -15,6 +21,10 @@ export const CHAIN_ID = 84532; export const USDC_ADDRESS = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; export const USDC_EIP712 = Object.freeze({ name: 'USDC', version: '2' }); export const APPROVED_LIVE_FACILITATOR_BASE = 'https://x402.org/facilitator'; +export const DEFAULT_X402_REQUEST_BODY_BYTES = 4096; +export const DEFAULT_X402_REQUEST_BODY_TIMEOUT_MS = 5_000; +export const DEFAULT_FACILITATOR_TIMEOUT_MS = 10_000; +export const DEFAULT_FACILITATOR_RESPONSE_BYTES = 64 * 1024; export const usdcToAtomic = (display) => parseUsdc(display).toString(); export const atomicToUsdc = (atomic) => formatUsdc(BigInt(atomic)); @@ -88,6 +98,28 @@ function exactPlainObject(value, keys) { && actual.every((key, index) => key === expected[index]); } +function positiveLimit(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${label} must be a positive safe integer`); + } + return value; +} + +const X402_BODY_TEXT_KEY = 'x402RequestBodyText'; +const X402_BODY_BYTES_KEY = 'x402RequestBodyBytes'; + +export function x402RequestBodyText(c) { + const value = c.get(X402_BODY_TEXT_KEY); + if (typeof value !== 'string') throw new Error('x402 request body was not captured'); + return value; +} + +export function x402RequestBodyBytes(c) { + const value = c.get(X402_BODY_BYTES_KEY); + if (!(value instanceof Uint8Array)) throw new Error('x402 request body was not captured'); + return Buffer.from(value); +} + function terminalReplayIsTrusted(decision, payer) { return decision?.kind === 'terminal' && ['settled', 'refunded'].includes(decision.paymentState) @@ -164,12 +196,20 @@ export function x402Paywall({ description = '', lifecycle = {}, quote = null, + maxRequestBodyBytes = DEFAULT_X402_REQUEST_BODY_BYTES, + requestBodyTimeoutMs = DEFAULT_X402_REQUEST_BODY_TIMEOUT_MS, + facilitatorTimeoutMs = DEFAULT_FACILITATOR_TIMEOUT_MS, + facilitatorResponseMaxBytes = DEFAULT_FACILITATOR_RESPONSE_BYTES, }) { const transport = requireFacilitatorTransport(facilitatorTransport); const canonicalPayTo = canonicalAddress(payTo); if (quote !== null && typeof quote !== 'function') { throw new TypeError('quote must be an injected function or null'); } + positiveLimit(maxRequestBodyBytes, 'maxRequestBodyBytes'); + positiveLimit(requestBodyTimeoutMs, 'requestBodyTimeoutMs'); + positiveLimit(facilitatorTimeoutMs, 'facilitatorTimeoutMs'); + positiveLimit(facilitatorResponseMaxBytes, 'facilitatorResponseMaxBytes'); const frozenOffers = new Map(); const locallyUnresolvedSettlements = new Set(); @@ -186,9 +226,39 @@ export function x402Paywall({ const idempotencyKey = c.req.header('Idempotency-Key')?.trim(); if (!idempotencyKey) return c.json({ error: 'Idempotency-Key header is required' }, 400); const paymentHeader = c.req.header('X-PAYMENT'); - const requestBody = await c.req.text(); + let requestBodyBytes; + try { + requestBodyBytes = await withWallClockDeadline({ + signal: c.req.raw.signal, + timeoutMs: requestBodyTimeoutMs, + timeoutCode: 'REQUEST_BODY_TIMEOUT', + timeoutMessage: 'x402 request body timed out', + abortedCode: 'REQUEST_BODY_ABORTED', + abortedMessage: 'x402 request body was aborted', + }, (signal) => readBodyBytes(c.req.raw, { + maxBytes: maxRequestBodyBytes, + tooLargeCode: 'REQUEST_BODY_TOO_LARGE', + tooLargeMessage: `request body exceeds the ${maxRequestBodyBytes}-byte x402 limit`, + readErrorCode: 'REQUEST_BODY_READ_FAILED', + readErrorMessage: 'x402 request body could not be read', + signal, + })); + } catch (error) { + const code = error instanceof RuntimeBoundaryError ? error.code : 'REQUEST_BODY_READ_FAILED'; + const status = code === 'REQUEST_BODY_TOO_LARGE' ? 413 + : code === 'REQUEST_BODY_TIMEOUT' ? 408 + : 400; + const message = error instanceof RuntimeBoundaryError + ? error.message + : 'x402 request body could not be read'; + return c.json({ error: message, code }, status); + } + const requestBody = requestBodyBytes.toString('utf8'); + c.set(X402_BODY_BYTES_KEY, Buffer.from(requestBodyBytes)); + c.set(X402_BODY_TEXT_KEY, requestBody); const requestHash = `sha256:${crypto.createHash('sha256') - .update(`${c.req.method}\n${c.req.url}\n${requestBody}`) + .update(Buffer.from(`${c.req.method}\n${c.req.url}\n`, 'utf8')) + .update(requestBodyBytes) .digest('hex')}`; let frozen = frozenOffers.get(idempotencyKey) ?? null; @@ -394,7 +464,11 @@ export function x402Paywall({ } else { const started = performance.now(); try { - const verify = await postJson(transport, 'verify', facilitatorBody); + const verify = await postJson(transport, 'verify', facilitatorBody, { + signal: c.req.raw.signal, + timeoutMs: facilitatorTimeoutMs, + maxResponseBytes: facilitatorResponseMaxBytes, + }); if (!verify?.isValid) { const reason = 'payment verification failed'; await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); @@ -404,7 +478,11 @@ export function x402Paywall({ accepts: [requirements], }, 402); } - settle = await postJson(transport, 'settle', facilitatorBody); + settle = await postJson(transport, 'settle', facilitatorBody, { + signal: c.req.raw.signal, + timeoutMs: facilitatorTimeoutMs, + maxResponseBytes: facilitatorResponseMaxBytes, + }); } catch { locallyUnresolvedSettlements.add(idempotencyKey); await notifyUnresolved({ @@ -501,16 +579,39 @@ export function x402Paywall({ }; } -async function postJson(transport, operation, body) { +async function postJson(transport, operation, body, { + signal, + timeoutMs, + maxResponseBytes, +}) { if (!['verify', 'settle'].includes(operation)) throw new Error('invalid facilitator operation'); - const response = await transport.fetchImpl(`${transport.baseUrl}/${operation}`, { - method: 'POST', - redirect: 'error', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), + return withWallClockDeadline({ + signal, + timeoutMs, + timeoutCode: 'FACILITATOR_TIMEOUT', + timeoutMessage: `facilitator ${operation} timed out`, + abortedCode: 'FACILITATOR_ABORTED', + abortedMessage: `facilitator ${operation} was aborted`, + }, async (composedSignal) => { + const response = await transport.fetchImpl(`${transport.baseUrl}/${operation}`, { + method: 'POST', + redirect: 'error', + signal: composedSignal, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response?.ok) { + throw new RuntimeBoundaryError('FACILITATOR_HTTP', 'facilitator returned an unsuccessful status'); + } + return readJsonBody(response, { + maxBytes: maxResponseBytes, + tooLargeCode: 'FACILITATOR_RESPONSE_TOO_LARGE', + tooLargeMessage: 'facilitator response exceeds the JSON byte limit', + readErrorCode: 'FACILITATOR_RESPONSE_READ_FAILED', + readErrorMessage: 'facilitator response could not be read', + jsonErrorCode: 'FACILITATOR_RESPONSE_JSON', + jsonErrorMessage: 'facilitator response was not JSON', + signal: composedSignal, + }); }); - if (!response.ok) throw new Error(`facilitator HTTP ${response.status}`); - const json = await response.json().catch(() => null); - if (!json) throw new Error('facilitator returned no JSON result'); - return json; } diff --git a/spikes/pi-wielder/tests/collar-cogs.test.mjs b/spikes/pi-wielder/tests/collar-cogs.test.mjs index 35f4a3d..9a0f58e 100644 --- a/spikes/pi-wielder/tests/collar-cogs.test.mjs +++ b/spikes/pi-wielder/tests/collar-cogs.test.mjs @@ -538,3 +538,97 @@ test('Collar snapshots the exact catalog before an executor can mutate caller-ow assert.equal(body.receipt.receipt.accounting.executionCogs.actualAtomic, '756'); assert.equal(body.receipt.receipt.quote.executionQuote.catalogDigest, catalogDigest(EXECUTION_CATALOG)); }); + +test('post-settlement provider deadline finalizes a sanitized unknown-COGS hold', { + timeout: 1_000, +}, async () => { + const secret = 'provider-timeout-secret-must-not-leak'; + let providerSignal = null; + const services = stack({ + providerTimeoutMs: 20, + executeSkill: async ({ signal }) => { + providerSignal = signal; + void secret; + return new Promise(() => {}); + }, + }); + const { res, body } = await invoke(services.proxy); + assert.equal(res.status, 500); + assert.equal(body.output, undefined); + const receipt = body.receipt.receipt; + assert.equal(receipt.payment.state, 'settled'); + assert.equal(receipt.execution.state, 'failed'); + assert.equal(receipt.execution.failureClass, 'UPSTREAM_PROVIDER_TIMEOUT'); + assert.equal(receipt.accounting.allocationState, 'pending_cogs_reconciliation'); + assert.equal(receipt.accounting.executionCogs.status, 'unknown'); + assert.equal(receipt.accounting.executionCogs.actualAtomic, null); + assert.equal(receipt.accounting.royaltyPoolAtomic, '0'); + assert.deepEqual(receipt.accounting.holderCredits, []); + assert.deepEqual(receipt.accounting.ancestorCredits, []); + assert.equal(receipt.accounting.journalEntries[0].amountAtomic, receipt.accounting.grossAtomic); + assert.equal(providerSignal.aborted, true); + assert.equal(JSON.stringify(body).includes(secret), false); + assert.equal(JSON.stringify(services.collar.journal.events).includes(secret), false); +}); + +test('Anthropic executor cancels oversized chunked JSON before buffering provider output', { + timeout: 1_000, +}, async () => { + let cancelled = false; + const executor = createAnthropicExecutor({ + apiKey: 'test-only', + timeoutMs: 200, + maxResponseBytes: 64, + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"content":[{"text":"')); + controller.enqueue(new TextEncoder().encode('provider-secret-'.repeat(8))); + }, + cancel() { cancelled = true; }, + }), { status: 200, headers: { 'content-type': 'application/json' } }), + }); + await assert.rejects(() => executor({ + skillContent: 'system', + input: 'hello', + model: 'claude-sonnet-4-6', + maxInputTokens: 300, + maxOutputTokens: 17, + promptBytes: 11, + estimatedInputTokens: 267, + }), (error) => ( + error.code === 'UPSTREAM_PROVIDER_RESPONSE_TOO_LARGE' + && error.message === 'provider response exceeds the JSON byte limit' + && !error.message.includes('secret') + )); + assert.equal(cancelled, true); +}); + +test('Anthropic executor deadline aborts a never-resolving fetch with a stable error', { + timeout: 1_000, +}, async () => { + let providerSignal = null; + const caller = new AbortController(); + const executor = createAnthropicExecutor({ + apiKey: 'test-only', + timeoutMs: 20, + fetchImpl: async (_url, init) => { + providerSignal = init.signal; + return new Promise(() => {}); + }, + }); + await assert.rejects(() => executor({ + skillContent: 'system', + input: 'hello', + model: 'claude-sonnet-4-6', + maxInputTokens: 300, + maxOutputTokens: 17, + promptBytes: 11, + estimatedInputTokens: 267, + signal: caller.signal, + }), (error) => ( + error.code === 'UPSTREAM_PROVIDER_TIMEOUT' + && error.message === 'provider request timed out' + )); + assert.equal(providerSignal.aborted, true); + assert.equal(caller.signal.aborted, false); +}); diff --git a/spikes/pi-wielder/tests/paying-fetch.test.mjs b/spikes/pi-wielder/tests/paying-fetch.test.mjs index 27a0576..5446631 100644 --- a/spikes/pi-wielder/tests/paying-fetch.test.mjs +++ b/spikes/pi-wielder/tests/paying-fetch.test.mjs @@ -884,3 +884,99 @@ test('retry transport loss leaves the signed amount unresolved and never retries assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); }); + +test('unpaid fetch deadline stops an ignoring transport before any reservation or signature', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + const caller = new AbortController(); + let transportSignal = null; + await assert.rejects(() => payingFetch(account, URL, { + method: 'POST', body: BODY, signal: caller.signal, + }, { + fetchImpl: async (_url, init) => { + transportSignal = init.signal; + return new Promise(() => {}); + }, + idempotencyKey: 'idem-unpaid-timeout', + paymentPolicy, + unpaidTimeoutMs: 20, + paidTimeoutMs: 20, + }), (error) => error.code === 'UNPAID_FETCH_TIMEOUT'); + assert.equal(transportSignal.aborted, true); + assert.notEqual(transportSignal, caller.signal); + assert.equal(caller.signal.aborted, false); + assert.equal(signatureCount(), 0); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); + assert.deepEqual(paymentPolicy.snapshot().authorizations, []); +}); + +test('paid retry deadline leaves the signed authorization unresolved with budget held', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let fetches = 0; + let retrySignal = null; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async (_url, init) => { + fetches += 1; + if (fetches === 1) return challenge(); + retrySignal = init.signal; + return new Promise(() => {}); + }, + idempotencyKey: 'idem-paid-timeout', + paymentPolicy, + unpaidTimeoutMs: 200, + paidTimeoutMs: 20, + }), (error) => error.code === 'PAID_RETRY_TIMEOUT'); + assert.equal(fetches, 2); + assert.equal(retrySignal.aborted, true); + assert.equal(signatureCount(), 1); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000'); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved'); +}); + +test('chunked oversized 402 challenge is cancelled before signing or reservation', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(40_000)); + controller.enqueue(new Uint8Array(30_000)); + }, + cancel() { cancelled = true; }, + }); + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => new Response(stream, { + status: 402, + headers: { 'content-type': 'application/json' }, + }), + idempotencyKey: 'idem-oversized-challenge', + paymentPolicy, + unpaidTimeoutMs: 200, + paidTimeoutMs: 200, + }), (error) => error.code === 'X402_CHALLENGE_TOO_LARGE'); + assert.equal(cancelled, true); + assert.equal(signatureCount(), 0); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); +}); + +test('stalled 402 challenge body is cancelled at the unpaid deadline before signing', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"x402Version":1')); + }, + cancel() { cancelled = true; }, + }); + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => new Response(stream, { + status: 402, + headers: { 'content-type': 'application/json' }, + }), + idempotencyKey: 'idem-stalled-challenge', + paymentPolicy, + unpaidTimeoutMs: 20, + paidTimeoutMs: 200, + }), (error) => error.code === 'UNPAID_FETCH_TIMEOUT'); + assert.equal(cancelled, true); + assert.equal(signatureCount(), 0); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); +}); diff --git a/spikes/pi-wielder/tests/pi-extension-contract.test.mjs b/spikes/pi-wielder/tests/pi-extension-contract.test.mjs new file mode 100644 index 0000000..fbd25e8 --- /dev/null +++ b/spikes/pi-wielder/tests/pi-extension-contract.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const extensionUrl = new URL('../pi-extension/x402.ts', import.meta.url); + +test('Pi invoke_skill exposes only input and uses one fixed encoded Collar route', () => { + const source = fs.readFileSync(extensionUrl, 'utf8'); + assert.equal(/\bskillId\b/.test(source), false); + assert.match( + source, + /const HOSTED_SKILL_ID = "optimizing-claude-code-prompts";/, + ); + assert.match( + source, + /const HOSTED_SKILL_PATH = `\/invoke\/\$\{encodeURIComponent\(HOSTED_SKILL_ID\)\}`;/, + ); + assert.match(source, /async execute\(args: \{ input: string \}\)/); + assert.match(source, /fetch\(`\$\{PROXY\}\$\{HOSTED_SKILL_PATH\}`/); + assert.doesNotMatch(source, /properties:\s*\{[^}]*skill/i); +}); diff --git a/spikes/pi-wielder/tests/proxy-trust.test.mjs b/spikes/pi-wielder/tests/proxy-trust.test.mjs index bfe6fef..5dbc4d5 100644 --- a/spikes/pi-wielder/tests/proxy-trust.test.mjs +++ b/spikes/pi-wielder/tests/proxy-trust.test.mjs @@ -252,3 +252,100 @@ test('unknown Skill fails before payment through both Collar and proxy', async ( assert.equal(collar.journal.events.length, 0); assert.equal(proxy.ledger.entries.length, 0); }); + +test('proxy rejects a chunked 4097-byte Skill request before any upstream fetch', { + timeout: 1_000, +}, async () => { + const signer = createReceiptSigner(); + let upstreamCalls = 0; + let pulls = 0; + let cancelled = false; + const proxy = createProxy({ + account: throwawayAccount(), + collarFetch: async () => { upstreamCalls += 1; throw new Error('must not fetch'); }, + gatewayFetch: async () => { upstreamCalls += 1; throw new Error('must not fetch'); }, + trustedCollarPublicKeyPem: signer.publicKeyPem, + trustedCollarKeyId: signer.keyId, + maxSkillRequestBytes: 4096, + }); + const body = new ReadableStream({ + pull(controller) { + pulls += 1; + if (pulls === 1) controller.enqueue(new Uint8Array(4096)); + else controller.enqueue(new Uint8Array([1])); + }, + cancel() { cancelled = true; }, + }); + const response = await proxy.app.request(`http://proxy.test/invoke/${SKILL_ID}`, { + method: 'POST', body, duplex: 'half', + }); + assert.equal(response.status, 413); + assert.deepEqual(await response.json(), { + error: 'proxy Skill request exceeds the 4096-byte limit', + code: 'PROXY_REQUEST_TOO_LARGE', + }); + assert.equal(cancelled, true); + assert.equal(upstreamCalls, 0); + assert.equal(proxy.ledger.entries.length, 0); +}); + +test('proxy cancels a chunked oversized upstream response before buffering it', { + timeout: 1_000, +}, async () => { + const signer = createReceiptSigner(); + let cancelled = false; + const proxy = createProxy({ + account: throwawayAccount(), + gatewayFetch: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(600_000)); + controller.enqueue(new Uint8Array(500_000)); + }, + cancel() { cancelled = true; }, + }), { status: 200, headers: { 'content-type': 'application/json' } }), + collarFetch: async () => { throw new Error('Collar must not run'); }, + trustedCollarPublicKeyPem: signer.publicKeyPem, + trustedCollarKeyId: signer.keyId, + maxUpstreamResponseBytes: 1024 * 1024, + }); + const response = await proxy.app.request('http://proxy.test/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] }), + }); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { + error: 'upstream response exceeds the proxy byte limit', + code: 'UPSTREAM_RESPONSE_TOO_LARGE', + }); + assert.equal(cancelled, true); + assert.equal(proxy.ledger.entries.length, 0); +}); + +test('proxy response deadline stops a slow chunked upstream without leaking its error', { + timeout: 1_000, +}, async () => { + const signer = createReceiptSigner(); + let cancelled = false; + const proxy = createProxy({ + account: throwawayAccount(), + gatewayFetch: async () => new Response(new ReadableStream({ + pull() { return new Promise(() => {}); }, + cancel() { cancelled = true; }, + }), { status: 200, headers: { 'content-type': 'application/json' } }), + collarFetch: async () => { throw new Error('Collar must not run'); }, + trustedCollarPublicKeyPem: signer.publicKeyPem, + trustedCollarKeyId: signer.keyId, + proxyResponseTimeoutMs: 20, + }); + const response = await proxy.app.request('http://proxy.test/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] }), + }); + assert.equal(response.status, 504); + assert.deepEqual(await response.json(), { + error: 'upstream response timed out', + code: 'UPSTREAM_RESPONSE_TIMEOUT', + }); + assert.equal(cancelled, true); + assert.equal(proxy.ledger.entries.length, 0); +}); diff --git a/spikes/pi-wielder/tests/runtime-boundaries.test.mjs b/spikes/pi-wielder/tests/runtime-boundaries.test.mjs new file mode 100644 index 0000000..a191ede --- /dev/null +++ b/spikes/pi-wielder/tests/runtime-boundaries.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + readBodyBytes, + readJsonBody, + RuntimeBoundaryError, + withWallClockDeadline, +} from '../src/runtime-boundaries.mjs'; + +test('wall-clock deadline aborts an ignoring operation without aborting the caller signal', async () => { + const caller = new AbortController(); + let operationSignal = null; + const started = performance.now(); + + await assert.rejects(() => withWallClockDeadline({ + signal: caller.signal, + timeoutMs: 20, + timeoutCode: 'TEST_TIMEOUT', + timeoutMessage: 'test operation timed out', + abortedCode: 'TEST_ABORTED', + abortedMessage: 'test operation aborted', + }, async (signal) => { + operationSignal = signal; + return new Promise(() => {}); + }), (error) => ( + error instanceof RuntimeBoundaryError + && error.code === 'TEST_TIMEOUT' + && error.message === 'test operation timed out' + )); + + assert.equal(operationSignal.aborted, true); + assert.equal(caller.signal.aborted, false); + assert.ok(performance.now() - started < 500); +}); + +test('caller abort is composed into the operation without exposing its reason', async () => { + const caller = new AbortController(); + let operationSignal = null; + const pending = withWallClockDeadline({ + signal: caller.signal, + timeoutMs: 1_000, + timeoutCode: 'TEST_TIMEOUT', + timeoutMessage: 'test operation timed out', + abortedCode: 'TEST_ABORTED', + abortedMessage: 'test operation aborted', + }, async (signal) => { + operationSignal = signal; + return new Promise(() => {}); + }); + + caller.abort(new Error('caller secret must not escape')); + await assert.rejects(() => pending, (error) => ( + error instanceof RuntimeBoundaryError + && error.code === 'TEST_ABORTED' + && error.message === 'test operation aborted' + && !error.message.includes('secret') + )); + assert.equal(operationSignal.aborted, true); +}); + +test('streaming byte ceiling accepts exactly the limit and cancels on the first excess chunk', async () => { + const exact = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])); + controller.enqueue(new Uint8Array([3, 4])); + controller.close(); + }, + })); + assert.deepEqual(await readBodyBytes(exact, { + maxBytes: 4, + tooLargeCode: 'TEST_TOO_LARGE', + tooLargeMessage: 'test body too large', + readErrorCode: 'TEST_READ_ERROR', + readErrorMessage: 'test body read failed', + }), Buffer.from([1, 2, 3, 4])); + + let cancelled = false; + const oversized = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.enqueue(new Uint8Array([4, 5])); + }, + cancel() { cancelled = true; }, + })); + await assert.rejects(() => readBodyBytes(oversized, { + maxBytes: 4, + tooLargeCode: 'TEST_TOO_LARGE', + tooLargeMessage: 'test body too large', + readErrorCode: 'TEST_READ_ERROR', + readErrorMessage: 'test body read failed', + }), (error) => error.code === 'TEST_TOO_LARGE' && error.message === 'test body too large'); + assert.equal(cancelled, true); +}); + +test('declared oversize is rejected before pulling and malformed JSON stays sanitized', async () => { + let pulls = 0; + const declaredOversize = new Response(new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(new TextEncoder().encode('{}')); + controller.close(); + }, + }), { headers: { 'content-length': '5' } }); + await assert.rejects(() => readBodyBytes(declaredOversize, { + maxBytes: 4, + tooLargeCode: 'TEST_TOO_LARGE', + tooLargeMessage: 'test body too large', + readErrorCode: 'TEST_READ_ERROR', + readErrorMessage: 'test body read failed', + }), (error) => error.code === 'TEST_TOO_LARGE'); + assert.equal(pulls, 0); + + await assert.rejects(() => readJsonBody(new Response('{secret-invalid-json'), { + maxBytes: 64, + tooLargeCode: 'TEST_TOO_LARGE', + tooLargeMessage: 'test body too large', + readErrorCode: 'TEST_READ_ERROR', + readErrorMessage: 'test body read failed', + jsonErrorCode: 'TEST_JSON', + jsonErrorMessage: 'test response was not JSON', + }), (error) => ( + error.code === 'TEST_JSON' + && error.message === 'test response was not JSON' + && !error.message.includes('secret') + )); +}); diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs index 3d30523..07b8c25 100644 --- a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -49,7 +49,17 @@ async function withheldAttempt(account, url, init, options = {}) { }; } -function resourceApp({ facilitatorTransport, lifecycle = {}, price = '0.25', quote = null, handler } = {}) { +function resourceApp({ + facilitatorTransport, + lifecycle = {}, + price = '0.25', + quote = null, + handler, + maxRequestBodyBytes, + requestBodyTimeoutMs, + facilitatorTimeoutMs, + facilitatorResponseMaxBytes, +} = {}) { const app = new Hono(); app.post('/resource', x402Paywall({ price, @@ -57,6 +67,10 @@ function resourceApp({ facilitatorTransport, lifecycle = {}, price = '0.25', quo payTo, facilitatorTransport, lifecycle, + ...(maxRequestBodyBytes === undefined ? {} : { maxRequestBodyBytes }), + ...(requestBodyTimeoutMs === undefined ? {} : { requestBodyTimeoutMs }), + ...(facilitatorTimeoutMs === undefined ? {} : { facilitatorTimeoutMs }), + ...(facilitatorResponseMaxBytes === undefined ? {} : { facilitatorResponseMaxBytes }), }), handler ?? ((c) => c.json({ ok: true }))); return app; } @@ -551,3 +565,127 @@ test('onUnresolved may observe an already-settled append without turning the res assert.equal(settleCalls, 1); assert.equal(executions, 1); }); + +test('chunked x402 request body is rejected at 4097 bytes before quote or lifecycle state', { + timeout: 1_000, +}, async () => { + let quoteCalls = 0; + let lifecycleCalls = 0; + let pulls = 0; + let cancelled = false; + const transport = createMockFacilitatorTransport(async () => { + throw new Error('facilitator must not run for an oversized unpaid request'); + }); + const app = resourceApp({ + facilitatorTransport: transport, + maxRequestBodyBytes: 4096, + quote: async () => { quoteCalls += 1; return structuredClone(executionQuote); }, + lifecycle: { async onOffered() { lifecycleCalls += 1; } }, + }); + const body = new ReadableStream({ + pull(controller) { + pulls += 1; + if (pulls === 1) controller.enqueue(new Uint8Array(4096)); + else controller.enqueue(new Uint8Array([1])); + }, + cancel() { cancelled = true; }, + }); + const response = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': 'idem-chunked-oversize' }, + body, + duplex: 'half', + }); + assert.equal(response.status, 413); + assert.deepEqual(await response.json(), { + error: 'request body exceeds the 4096-byte x402 limit', + code: 'REQUEST_BODY_TOO_LARGE', + }); + assert.equal(cancelled, true); + assert.equal(quoteCalls, 0); + assert.equal(lifecycleCalls, 0); +}); + +test('x402 request body accepts exactly 4096 bytes', async () => { + const transport = createMockFacilitatorTransport(async () => { + throw new Error('facilitator must not run for an unpaid request'); + }); + const app = resourceApp({ facilitatorTransport: transport, maxRequestBodyBytes: 4096 }); + const response = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': 'idem-exact-body-limit' }, + body: 'x'.repeat(4096), + }); + assert.equal(response.status, 402); + assert.equal((await response.json()).x402Version, 1); +}); + +test('facilitator verify and settle deadlines abort ignoring transports and remain unresolved', { + timeout: 1_000, +}, async () => { + for (const timedOutOperation of ['verify', 'settle']) { + let facilitatorSignal = null; + let unresolvedReason = null; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname.slice(1); + if (operation === timedOutOperation) { + facilitatorSignal = init.signal; + return new Promise(() => {}); + } + return new Response(JSON.stringify({ isValid: true }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + }); + const app = resourceApp({ + facilitatorTransport: transport, + facilitatorTimeoutMs: 20, + lifecycle: { + async onUnresolved({ reason }) { unresolvedReason = reason; }, + }, + handler: (c) => { executions += 1; return c.json({ ok: true }); }, + }); + const held = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: `idem-facilitator-${timedOutOperation}-timeout`, + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(facilitatorSignal.aborted, true, timedOutOperation); + assert.equal(unresolvedReason, 'facilitator response unresolved', timedOutOperation); + assert.equal(held.state, 'unresolved', timedOutOperation); + assert.equal(held.paymentPolicy.snapshot().reservedAtomic, '250000', timedOutOperation); + assert.equal(executions, 0, timedOutOperation); + } +}); + +test('oversized chunked facilitator JSON is cancelled and treated as ambiguous settlement', async () => { + let cancelled = false; + let unresolvedReason = null; + let executions = 0; + const transport = createMockFacilitatorTransport(async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(40_000)); + controller.enqueue(new Uint8Array(30_000)); + }, + cancel() { cancelled = true; }, + }), { status: 200, headers: { 'content-type': 'application/json' } })); + const app = resourceApp({ + facilitatorTransport: transport, + facilitatorResponseMaxBytes: 64 * 1024, + lifecycle: { + async onUnresolved({ reason }) { unresolvedReason = reason; }, + }, + handler: (c) => { executions += 1; return c.json({ ok: true }); }, + }); + const held = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'idem-facilitator-oversize', + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(cancelled, true); + assert.equal(unresolvedReason, 'facilitator response unresolved'); + assert.equal(held.state, 'unresolved'); + assert.equal(executions, 0); +}); From eb227dc70c7b512e4416eb2a4be511a05deb21bf Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 14:31:41 -0400 Subject: [PATCH 134/165] docs: fix authorship overclaim verification --- docs/superpowers/plans/2026-07-17-authorship-attestation.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-authorship-attestation.md b/docs/superpowers/plans/2026-07-17-authorship-attestation.md index 3535025..37009c4 100644 --- a/docs/superpowers/plans/2026-07-17-authorship-attestation.md +++ b/docs/superpowers/plans/2026-07-17-authorship-attestation.md @@ -787,9 +787,11 @@ Expected: tests/typecheck PASS; status command returns JSON with an empty `regis - [ ] **Step 6: Confirm no chain write or protected-corpus edit entered the slice** -Run: `git diff --exit-code -- CONTEXT.md docs/PRD.md docs/adr && ! rg -n 'authored by|proves? (originality|safety)|safe Skill' phase0/src phase0/README.md` +Run: `git diff --exit-code -- CONTEXT.md docs/PRD.md docs/adr && ! rg -n -P '\bauthored by\b|(? Date: Sat, 18 Jul 2026 14:37:28 -0400 Subject: [PATCH 135/165] docs: make Collar secret scan fail closed --- .../plans/2026-07-17-collar-invocation-journal.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md b/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md index c17532a..90abddd 100644 --- a/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md +++ b/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md @@ -3093,9 +3093,20 @@ and after the receipt lookup. - [ ] **Step 5: Confirm secrets and mainnet did not enter tracked files** -Run: `! git ls-files -z | xargs -0 rg -n --pcre2 '-----BEGIN (?:PRIVATE|ENCRYPTED PRIVATE) KEY-----|PRIVATE_KEY\s*=\s*(?:0x)?[0-9a-fA-F]{64}'` +Run: -Expected: no output. The existing ignored local `.env` remains untouched. +```bash +if git grep -I -q -E -e '-----BEGIN (PRIVATE|ENCRYPTED PRIVATE) KEY-----|PRIVATE_KEY[[:space:]]*=[[:space:]]*(0x)?[0-9a-fA-F]{64}' -- .; then + false +else + secret_scan_status=$? + test "$secret_scan_status" -eq 1 +fi +``` + +Expected: exit 0 and no output. Exit 1 from `git grep` means no match; a match or scan +error fails the gate without printing possible secret material. The existing ignored +local `.env` remains untouched. - [ ] **Step 6: Commit the authority documentation** From 5ca44b8db32fcf9b6db4ba1bbcbfeec8d36c4c4d Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 14:39:47 -0400 Subject: [PATCH 136/165] docs: distinguish complete private keys in scan --- .../plans/2026-07-17-collar-invocation-journal.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md b/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md index 90abddd..4cdcfb2 100644 --- a/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md +++ b/docs/superpowers/plans/2026-07-17-collar-invocation-journal.md @@ -3096,7 +3096,7 @@ and after the receipt lookup. Run: ```bash -if git grep -I -q -E -e '-----BEGIN (PRIVATE|ENCRYPTED PRIVATE) KEY-----|PRIVATE_KEY[[:space:]]*=[[:space:]]*(0x)?[0-9a-fA-F]{64}' -- .; then +if git grep -I -q -E -e '-----END ([A-Z]+ )*PRIVATE KEY-----|PRIVATE_KEY[[:space:]]*=[[:space:]]*(0x)?[0-9a-fA-F]{64}' -- .; then false else secret_scan_status=$? @@ -3104,9 +3104,11 @@ else fi ``` -Expected: exit 0 and no output. Exit 1 from `git grep` means no match; a match or scan -error fails the gate without printing possible secret material. The existing ignored -local `.env` remains untouched. +Expected: exit 0 and no output. A complete PEM private key has an end marker; this +deliberately ignores header-only negative-test sentinels. Exit 1 from `git grep` means +no match; a complete PEM marker, a full private-key assignment, or a scan error fails +the gate without printing possible secret material. The existing ignored local `.env` +remains untouched. - [ ] **Step 6: Commit the authority documentation** From 412f012825145aebf9d8c0fd0c1e30fb0e3076b1 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 15:07:38 -0400 Subject: [PATCH 137/165] fix: bound clone provider transport --- spikes/clone-economics/src/adapters.mjs | 145 ++++++++++++-- .../tests/adapters-budget.test.mjs | 185 +++++++++++++++++- 2 files changed, 310 insertions(+), 20 deletions(-) diff --git a/spikes/clone-economics/src/adapters.mjs b/spikes/clone-economics/src/adapters.mjs index f8b4874..fb9f4ed 100644 --- a/spikes/clone-economics/src/adapters.mjs +++ b/spikes/clone-economics/src/adapters.mjs @@ -8,6 +8,95 @@ import { const clone = (value) => structuredClone(value); const rounded = (value) => Number(value.toFixed(12)); +const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_LIVE_RESPONSE_BYTES = 1_048_576; + +async function withWallClockDeadline(timeoutMs, operation) { + const controller = new AbortController(); + const timeoutError = new Error('Anthropic request timed out'); + let timeoutId; + const deadline = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); + }); + try { + return await Promise.race([ + Promise.resolve().then(() => operation(controller.signal)), + deadline, + ]); + } finally { + clearTimeout(timeoutId); + } +} + +function cancelReader(reader, reason) { + try { + const cancellation = reader.cancel(reason); + void Promise.resolve(cancellation).catch(() => {}); + } catch { + // The boundary error remains authoritative even if cancellation itself fails. + } +} + +async function readBoundedJson(response, { maxBytes, signal }) { + let body; + try { + body = response?.body; + } catch { + throw new Error('Anthropic response body is unavailable'); + } + if (!body || typeof body.getReader !== 'function') { + throw new Error('Anthropic response body is unavailable'); + } + let reader; + try { + reader = body.getReader(); + } catch { + throw new Error('Anthropic response body is unavailable'); + } + const cancelOnAbort = () => cancelReader(reader, signal.reason); + if (signal.aborted) { + cancelOnAbort(); + if (signal.reason instanceof Error) throw signal.reason; + throw new Error('Anthropic request timed out'); + } + signal.addEventListener('abort', cancelOnAbort, { once: true }); + try { + const chunks = []; + let totalBytes = 0; + while (true) { + let result; + try { + result = await reader.read(); + } catch { + if (signal.aborted && signal.reason instanceof Error) throw signal.reason; + throw new Error('Anthropic response could not be read'); + } + if (result.done) break; + if (!(result.value instanceof Uint8Array)) { + const error = new Error('Anthropic response could not be read'); + cancelReader(reader, error); + throw error; + } + if (result.value.byteLength > maxBytes - totalBytes) { + const error = new Error('Anthropic response exceeded byte limit'); + cancelReader(reader, error); + throw error; + } + chunks.push(result.value); + totalBytes += result.value.byteLength; + } + try { + return JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')); + } catch { + throw new Error('Anthropic response was not valid JSON'); + } + } finally { + signal.removeEventListener('abort', cancelOnAbort); + } +} const LIVE_KIND_INSTRUCTIONS = { 'target-train': 'Apply the supplied target Skill and reference to the supplied request and synthetic repository context. Return only the resulting response.', @@ -207,6 +296,14 @@ export class LiveAnthropicAdapter { }); this.fetchImpl = config.fetchImpl ?? globalThis.fetch; if (typeof this.fetchImpl !== 'function') throw new Error('A fetch implementation is required'); + this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_LIVE_REQUEST_TIMEOUT_MS; + if (!Number.isSafeInteger(this.requestTimeoutMs) || this.requestTimeoutMs <= 0) { + throw new Error('requestTimeoutMs must be a positive safe integer'); + } + this.maxResponseBytes = config.maxResponseBytes ?? DEFAULT_LIVE_RESPONSE_BYTES; + if (!Number.isSafeInteger(this.maxResponseBytes) || this.maxResponseBytes <= 0) { + throw new Error('maxResponseBytes must be a positive safe integer'); + } this.capturedRequests = []; this.records = []; this.attempts = []; @@ -232,25 +329,37 @@ export class LiveAnthropicAdapter { let capError = null; try { reservationId = this.budget.reserveNextAttempt({ kind: request.kind, caseId: request.caseId ?? null }); - const response = await this.fetchImpl('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'anthropic-version': '2023-06-01', - 'x-api-key': this.apiKey, + const { response, json } = await withWallClockDeadline( + this.requestTimeoutMs, + async (signal) => { + let providerResponse; + try { + providerResponse = await this.fetchImpl('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': this.apiKey, + }, + body: JSON.stringify({ + model: this.model, + max_tokens: this.maxTokens, + messages: [{ role: 'user', content: prompt }], + }), + redirect: 'error', + signal, + }); + } catch { + if (signal.aborted && signal.reason instanceof Error) throw signal.reason; + throw new Error('Anthropic request failed'); + } + const providerJson = await readBoundedJson(providerResponse, { + maxBytes: this.maxResponseBytes, + signal, + }); + return { response: providerResponse, json: providerJson }; }, - body: JSON.stringify({ - model: this.model, - max_tokens: this.maxTokens, - messages: [{ role: 'user', content: prompt }], - }), - }); - let json; - try { - json = await response.json(); - } catch (error) { - throw new Error(`Anthropic response JSON failed: ${error instanceof Error ? error.message : 'unknown parse failure'}`); - } + ); providerRequestId = typeof json.id === 'string' ? json.id : null; const inputTokens = json.usage?.input_tokens; const outputTokens = json.usage?.output_tokens; diff --git a/spikes/clone-economics/tests/adapters-budget.test.mjs b/spikes/clone-economics/tests/adapters-budget.test.mjs index 0536eb6..5266578 100644 --- a/spikes/clone-economics/tests/adapters-budget.test.mjs +++ b/spikes/clone-economics/tests/adapters-budget.test.mjs @@ -23,10 +23,30 @@ const snapshot = (overrides = {}) => ({ }); function response(json, { ok = true, status = 200 } = {}) { - return { ok, status, async json() { return structuredClone(json); } }; + const bytes = new TextEncoder().encode(JSON.stringify(json)); + let delivered = false; + return { + ok, + status, + headers: { get: () => null }, + body: { + getReader() { + return { + async read() { + if (delivered) return { done: true, value: undefined }; + delivered = true; + return { done: false, value: bytes }; + }, + async cancel() {}, + }; + }, + }, + async json() { throw new Error('unbounded json reader must not be called'); }, + async text() { throw new Error('unbounded text reader must not be called'); }, + }; } -function live({ budget, fetchImpl, contract = snapshot() }) { +function live({ budget, fetchImpl, contract = snapshot(), runtime = {} }) { return new LiveAnthropicAdapter({ mode: 'live', apiKey: 'synthetic-never-sent-to-network', @@ -34,6 +54,7 @@ function live({ budget, fetchImpl, contract = snapshot() }) { budget, fetchImpl, testOnlyNoNetwork: true, + ...runtime, }); } @@ -70,6 +91,166 @@ test('mock seed evidence is synthetic and output callback receives no payload by assert.equal(adapter.attempts[0].budgetAttemptId, null); }); +test('live provider I/O has a hard wall-clock deadline and redirect refusal', async () => { + const budget = createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }); + let fetchOptions; + const adapter = live({ + budget, + runtime: { requestTimeoutMs: 20 }, + fetchImpl: async (_url, options) => { + fetchOptions = options; + return new Promise(() => {}); + }, + }); + + const guard = new Promise((_, reject) => { + setTimeout(() => reject(new Error('test guard expired before adapter deadline')), 200); + }); + await assert.rejects( + Promise.race([ + adapter.invoke({ kind: 'target-heldout', caseId: 'deadline', payload: { input: 'small' } }), + guard, + ]), + /Anthropic request timed out/, + ); + assert.equal(fetchOptions.redirect, 'error'); + assert.ok(fetchOptions.signal instanceof AbortSignal); + assert.equal(fetchOptions.signal.aborted, true); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 0n, + outstandingReservedMicroUsd: 100n, + lock: { kind: 'unknown_cost', attemptId: 'attempt-000001' }, + }); +}); + +test('live provider response cancels on the first byte beyond the cap without unbounded readers', async () => { + const budget = createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }); + let reads = 0; + let cancellations = 0; + const chunks = [ + new TextEncoder().encode('12345678'), + new TextEncoder().encode('9'), + new TextEncoder().encode('never-read'), + ]; + const adapter = live({ + budget, + runtime: { maxResponseBytes: 8 }, + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + body: { + getReader() { + return { + async read() { + const value = chunks[reads]; + reads += 1; + return value === undefined + ? { done: true, value: undefined } + : { done: false, value }; + }, + async cancel() { + cancellations += 1; + }, + }; + }, + }, + async json() { + throw new Error('unbounded json reader must not be called'); + }, + async text() { + throw new Error('unbounded text reader must not be called'); + }, + }), + }); + + await assert.rejects( + adapter.invoke({ kind: 'target-heldout', caseId: 'overflow', payload: { input: 'small' } }), + /Anthropic response exceeded byte limit/, + ); + assert.equal(reads, 2); + assert.equal(cancellations, 1); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 0n, + outstandingReservedMicroUsd: 100n, + lock: { kind: 'unknown_cost', attemptId: 'attempt-000001' }, + }); +}); + +test('live provider transport errors are sanitized without releasing unknown spend', async () => { + const budget = createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }); + const adapter = live({ + budget, + fetchImpl: async () => { + throw new Error('private-provider-detail apiKey=should-never-cross-boundary'); + }, + }); + + const error = await adapter + .invoke({ kind: 'target-heldout', caseId: 'transport-error', payload: { input: 'small' } }) + .then( + () => null, + (caught) => caught, + ); + assert.ok(error instanceof Error); + assert.match(error.message, /Anthropic request failed/); + assert.doesNotMatch(error.message, /private-provider-detail|apiKey|should-never-cross-boundary/); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 0n, + outstandingReservedMicroUsd: 100n, + lock: { kind: 'unknown_cost', attemptId: 'attempt-000001' }, + }); + assert.equal(adapter.attempts[0].providerCostMicroUsd, null); +}); + +test('live provider body deadline cancels a stalled stream reader', async () => { + const budget = createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }); + let fetchSignal; + let cancellations = 0; + const adapter = live({ + budget, + runtime: { requestTimeoutMs: 20 }, + fetchImpl: async (_url, options) => { + fetchSignal = options.signal; + return { + ok: true, + status: 200, + headers: { get: () => null }, + body: { + getReader() { + return { + async read() { return new Promise(() => {}); }, + async cancel() { cancellations += 1; }, + }; + }, + }, + }; + }, + }); + + const guard = new Promise((_, reject) => { + setTimeout(() => reject(new Error('test guard expired before body deadline')), 200); + }); + await assert.rejects( + Promise.race([ + adapter.invoke({ kind: 'target-heldout', caseId: 'body-deadline', payload: { input: 'small' } }), + guard, + ]), + /Anthropic request timed out/, + ); + assert.equal(fetchSignal.aborted, true); + assert.equal(cancellations, 1); + assert.deepEqual(budget.state(), { + attemptedCalls: 1, + knownAccruedMicroUsd: 0n, + outstandingReservedMicroUsd: 100n, + lock: { kind: 'unknown_cost', attemptId: 'attempt-000001' }, + }); +}); + test('missing usage retains a reservation, locks unknown_cost, and permits no later fetch', async () => { const budget = createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }); let fetches = 0; From cfac5b4f3879aae6ea0ba4e13de76340612e7ce3 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 15:13:37 -0400 Subject: [PATCH 138/165] fix: require bounded x402 challenge streams --- spikes/pi-wielder/src/proxy.mjs | 13 ++--- spikes/pi-wielder/tests/paying-fetch.test.mjs | 55 ++++++++++++++----- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index 9253480..1805f23 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -250,14 +250,13 @@ const challengeReadOptions = Object.freeze({ }); async function readChallenge(response, signal) { - if (response?.body !== undefined && response?.headers?.get) { - return readJsonBody(response, { ...challengeReadOptions, signal }); - } - try { - return await response.json(); - } catch { - throw paymentError('CHALLENGE_SCHEMA', challengeReadOptions.jsonErrorMessage); + if (!(response instanceof Response)) { + throw paymentError( + 'CHALLENGE_RESPONSE_SHAPE', + 'x402 challenge response must expose a bounded byte stream', + ); } + return readJsonBody(response, { ...challengeReadOptions, signal }); } function decodeSettlementHeader(value) { diff --git a/spikes/pi-wielder/tests/paying-fetch.test.mjs b/spikes/pi-wielder/tests/paying-fetch.test.mjs index 5446631..788aad6 100644 --- a/spikes/pi-wielder/tests/paying-fetch.test.mjs +++ b/spikes/pi-wielder/tests/paying-fetch.test.mjs @@ -49,6 +49,23 @@ function challenge(candidate = baseOffer()) { }); } +function challengeWithReadHook(candidate, onRead) { + const bytes = new TextEncoder().encode(JSON.stringify(challengePayload(candidate))); + let emitted = false; + return new Response(new ReadableStream({ + pull(controller) { + if (emitted) return; + emitted = true; + onRead(); + controller.enqueue(bytes); + controller.close(); + }, + }, { highWaterMark: 0 }), { + status: 402, + headers: { 'content-type': 'application/json' }, + }); +} + function challengePayload(candidate = baseOffer()) { return { x402Version: 1, @@ -191,13 +208,7 @@ test('a quote expiring while the first 402 JSON is parsed is rejected before sig fetchImpl: async () => { fetches += 1; if (fetches > 1) throw new Error('paid retry must not start for an expired quote'); - return { - status: 402, - async json() { - setClock(NOW + 59_000); - return challengePayload(); - }, - }; + return challengeWithReadHook(baseOffer(), () => setClock(NOW + 59_000)); }, idempotencyKey: 'idem-expired-during-parse', paymentPolicy, @@ -208,6 +219,28 @@ test('a quote expiring while the first 402 JSON is parsed is rejected before sig assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); }); +test('a JSON-only injected challenge is rejected without calling an unbounded parser', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let jsonCalls = 0; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => ({ + status: 402, + async json() { + jsonCalls += 1; + return challengePayload(); + }, + }), + idempotencyKey: 'idem-json-only-challenge', + paymentPolicy, + }), (error) => ( + error.code === 'CHALLENGE_RESPONSE_SHAPE' + && error.message === 'x402 challenge response must expose a bounded byte stream' + )); + assert.equal(jsonCalls, 0); + assert.equal(signatureCount(), 0); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); +}); + test('trusted quote age is rechecked after JSON parse against receipt and issue times', async () => { const cases = [ { @@ -239,13 +272,7 @@ test('trusted quote age is rechecked after JSON parse against receipt and issue paymentPolicy, fetchImpl: async () => { fetches += 1; - return { - status: 402, - async json() { - setClock(afterParse); - return challengePayload(candidate); - }, - }; + return challengeWithReadHook(candidate, () => setClock(afterParse)); }, }), (error) => error.code === 'QUOTE_FRESHNESS'); assert.equal(fetches, 1, name); From e2c92acb7fad483c56f53f898b3114380be8a47e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 15:14:48 -0400 Subject: [PATCH 139/165] fix: lock clone provider security ceilings --- spikes/clone-economics/src/adapters.mjs | 6 ++++++ .../tests/adapters-budget.test.mjs | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/spikes/clone-economics/src/adapters.mjs b/spikes/clone-economics/src/adapters.mjs index fb9f4ed..76099fe 100644 --- a/spikes/clone-economics/src/adapters.mjs +++ b/spikes/clone-economics/src/adapters.mjs @@ -300,10 +300,16 @@ export class LiveAnthropicAdapter { if (!Number.isSafeInteger(this.requestTimeoutMs) || this.requestTimeoutMs <= 0) { throw new Error('requestTimeoutMs must be a positive safe integer'); } + if (this.requestTimeoutMs > DEFAULT_LIVE_REQUEST_TIMEOUT_MS) { + throw new Error(`requestTimeoutMs cannot exceed ${DEFAULT_LIVE_REQUEST_TIMEOUT_MS}`); + } this.maxResponseBytes = config.maxResponseBytes ?? DEFAULT_LIVE_RESPONSE_BYTES; if (!Number.isSafeInteger(this.maxResponseBytes) || this.maxResponseBytes <= 0) { throw new Error('maxResponseBytes must be a positive safe integer'); } + if (this.maxResponseBytes > DEFAULT_LIVE_RESPONSE_BYTES) { + throw new Error(`maxResponseBytes cannot exceed ${DEFAULT_LIVE_RESPONSE_BYTES}`); + } this.capturedRequests = []; this.records = []; this.attempts = []; diff --git a/spikes/clone-economics/tests/adapters-budget.test.mjs b/spikes/clone-economics/tests/adapters-budget.test.mjs index 5266578..97c6293 100644 --- a/spikes/clone-economics/tests/adapters-budget.test.mjs +++ b/spikes/clone-economics/tests/adapters-budget.test.mjs @@ -251,6 +251,22 @@ test('live provider body deadline cancels a stalled stream reader', async () => }); }); +test('live provider security ceilings cannot be raised by runtime configuration', () => { + const make = (runtime) => live({ + budget: createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }), + fetchImpl: async () => { throw new Error('must not fetch during construction'); }, + runtime, + }); + assert.throws( + () => make({ requestTimeoutMs: 30_001 }), + /requestTimeoutMs cannot exceed 30000/, + ); + assert.throws( + () => make({ maxResponseBytes: 1_048_577 }), + /maxResponseBytes cannot exceed 1048576/, + ); +}); + test('missing usage retains a reservation, locks unknown_cost, and permits no later fetch', async () => { const budget = createAttemptBudget({ capMicroUsd: 200n, worstCaseCallMicroUsd: 100n }); let fetches = 0; From ee19624851df696a5bf52ae6099814556ce37da6 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 15:54:34 -0400 Subject: [PATCH 140/165] fix: close final Pi runtime trust boundaries --- spikes/pi-wielder/.env.example | 17 +- spikes/pi-wielder/README.md | 57 +- spikes/pi-wielder/RUNBOOK.md | 73 ++- spikes/pi-wielder/src/collar.mjs | 71 ++- spikes/pi-wielder/src/gateway.mjs | 585 ++++++++++++++++-- spikes/pi-wielder/src/invocation-journal.mjs | 54 ++ spikes/pi-wielder/src/proxy.mjs | 34 +- spikes/pi-wielder/src/runtime-boundaries.mjs | 5 + spikes/pi-wielder/src/x402-seller.mjs | 234 +++++-- spikes/pi-wielder/tests/collar-cogs.test.mjs | 66 +- .../pi-wielder/tests/collar-failure.test.mjs | 167 ++++- .../tests/gateway-transport.test.mjs | 419 +++++++++++++ .../tests/invocation-journal.test.mjs | 19 + spikes/pi-wielder/tests/paying-fetch.test.mjs | 65 +- spikes/pi-wielder/tests/proxy-trust.test.mjs | 25 + .../tests/runtime-boundaries.test.mjs | 54 ++ .../pi-wielder/tests/x402-lifecycle.test.mjs | 339 +++++++++- 17 files changed, 2129 insertions(+), 155 deletions(-) diff --git a/spikes/pi-wielder/.env.example b/spikes/pi-wielder/.env.example index 65d7564..0b8122b 100644 --- a/spikes/pi-wielder/.env.example +++ b/spikes/pi-wielder/.env.example @@ -15,7 +15,7 @@ WIELDER_SESSION_BUDGET_USDC=1.00 WIELDER_MODEL_MAX_USDC=0.10 WIELDER_SKILL_MAX_USDC=0.50 -# --- upstream model keys (sellers' side; only needed without MOCK_LLM=1) ----- +# --- upstream model keys (sellers' side; only after every live gate passes) -- ANTHROPIC_API_KEY= OPENAI_API_KEY= @@ -24,9 +24,22 @@ OPENAI_API_KEY= # explicitly approved Base Sepolia integration; arbitrary URLs are rejected. ALLOW_LIVE_X402=0 FACILITATOR_URL= -# 1 = canned completions/Skill output, no model keys needed. +# Mock execution is fail-closed and is also the default when MOCK_LLM is unset. +# Only the exact value 0 requests a live provider; 1 uses canned output. MOCK_LLM=1 +# Live model execution additionally requires all three fields below. The +# committed gateway catalog is synthetic_config, so it intentionally cannot be +# approved for live use. A human must first review current provider pricing, +# replace it with an immutable human_verified catalog (source + as-of), compute +# that catalog's exact digest, and set a cumulative process-run cap large enough +# to reserve its maximum worst-case request. Unknown/error outcomes consume the +# full reservation. This in-memory cap resets on restart and is not a durable +# cross-process budget. Provider credentials alone never enable a live call. +ALLOW_LIVE_PROVIDER=0 +GATEWAY_LIVE_CATALOG_DIGEST= +GATEWAY_LIVE_SPEND_CAP_ATOMIC= + # --- Collar authority (paired, absolute, outside this checkout) ------------- # Live settlement refuses ephemeral authority. Set both or neither. COLLAR_JOURNAL_FILE= diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 7ee6661..ede62b0 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -5,15 +5,31 @@ Design context: [Pi-Wielder design](../../docs/plans/2026-07-11-reframe-and-pi-w This is an executable design spike, not a production payment service. Its automated proof is fully offline: an unfunded throwaway wallet signs x402 authorizations, an injected mock verifies the signatures and synthesizes settlement, and canned model -responses avoid external APIs. +responses avoid external APIs. Mock execution is fail-closed and remains the default +when `MOCK_LLM` is unset. ## Accounting authority The Collar's append-only Invocation journal is authoritative for hosted Skill Invocations. Payment and execution are independent state machines, so a settled Invocation remains attached to its transaction when execution fails or a response is -lost. The journal freezes the complete x402 offer, atomically claims execution and -refund attempts, records accounting, and issues an Ed25519-signed terminal receipt. +lost. An unpaid x402 offer is not authority: the paywall holds at most 128 such offers +in process memory for at most 60 seconds. Only after the facilitator verifies the exact +signed authorization does the Collar append the Invocation, frozen offer, verified +payment-header digest, and signed payment claim. Durable replay accepts only that exact +authorization. Legacy journal entries without the digest are reverified instead of +being trusted implicitly. The journal then owns all signed, unresolved, settled, +execution, refund, and terminal state; pending-offer expiry never prunes that authority. +It atomically claims execution and refund attempts, records accounting, and issues an +Ed25519-signed terminal receipt. + +This boundary deliberately fails closed across one restart window. If the Collar +restarts after returning `402` but before a verified retry is journaled, the old paid +retry receives `409` because no authoritative frozen offer exists. It is not submitted +to the facilitator and it cannot execute. The Wielder keeps that signed authorization +unresolved until an operator reconciles its nonce, then uses a new idempotency key. +Once verified state reaches the journal, the durable reconciliation and exact replay +rules apply. The Wielder's `/ledger` endpoint is a session-local **receipt view**, not an authoritative protocol ledger. For Skill legs it caches a receipt only after verifying @@ -37,6 +53,12 @@ bounded-time quote, a per-call cap, and remaining session budget. The policy rej numeric or coerced atomic amounts, unknown protocol fields, caller-supplied payment or idempotency headers, ambiguous URL forms, and path-prefix confusion. +The seller accepts only canonical `Idempotency-Key` values: 1-128 ASCII letters, digits, +periods, underscores, colons, or hyphens, beginning with a letter or digit. A new unpaid +key receives `503 PENDING_OFFER_CAPACITY` when all 128 pending slots are active; an +existing active key can still retrieve its exact frozen challenge. Expired unpaid slots +are the only offer state reclaimed by this admission control. + The caller's method, body bytes, and headers are captured once before the unpaid request. Method and body bytes bind the policy hash and signed recovery; captured headers are reused for the unpaid and paid requests but are not signed or covered by @@ -156,7 +178,7 @@ npm test npm run e2e ``` -Expected current results are 193 offline unit/integration tests and 41 offline e2e +Expected current results are 222 offline unit/integration tests and 41 offline e2e checks. Counts can increase as regressions are added; zero failures is the contract. The e2e labels all timing output synthetic and uses in-process Hono requests only. @@ -185,7 +207,7 @@ blocked live boundary, see [RUNBOOK.md](./RUNBOOK.md). | `src/payment-policy.mjs` | Strict Base Sepolia offer validation, one-process reservation state, exact signed authorization recovery, and trusted reconciliation boundary | | `src/runtime-boundaries.mjs` | Composed wall-clock deadlines plus streaming byte-limited body and JSON readers | | `src/ledger.mjs` | JSONL-capable Wielder receipt-view storage and rendering | -| `src/gateway.mjs` | Simulated x402 model reseller | +| `src/gateway.mjs` | Fail-closed x402 model reseller with catalog, spend, and provider runtime bounds | | `src/facilitator-mock.mjs` | Offline signature verification plus synthetic settlement | | `pi-extension/x402.ts` | Manual Pi adapter for provider, Skill tool, and `/ledger` view | | `e2e.mjs` | Fully in-process offline proof | @@ -197,14 +219,16 @@ absent. A declared oversize is rejected before the body is pulled. The Collar an default x402 middleware accept at most exactly 4,096 request bytes; 4,096 is accepted and 4,097 is rejected before an offer. The model gateway and proxy model route accept at most 1 MiB, while the proxy Skill route keeps the 4,096-byte contract. Buyer x402 -challenges and facilitator JSON responses are capped at 64 KiB. Anthropic JSON and -proxy upstream responses are capped at 1 MiB. +challenges and facilitator JSON responses are capped at 64 KiB. Gateway provider JSON +and proxy upstream responses are capped at 1 MiB. Default wall-clock bounds are 15 seconds for the unpaid buyer fetch, 30 seconds for the signed paid retry, 5 seconds for x402/proxy request-body reads, 10 seconds for each facilitator verify and settle operation, and 30 seconds for provider execution and -proxy upstream-response reads. Caller abort signals are composed into child signals; -an internal timeout never aborts the caller's controller. Redirects remain disabled. +proxy upstream-response reads. The provider deadline includes both fetch and streamed +body consumption and cannot be configured above 30 seconds. Caller abort signals are +composed into child signals; an internal timeout never aborts the caller's controller. +Redirects remain disabled. Timeout state follows the durable money boundary: an unpaid timeout creates no reservation; a signed retry or facilitator ambiguity stays `unresolved` with budget @@ -214,6 +238,21 @@ Raw transport and provider errors are not returned or journaled. The manual Pi t has no caller-selected Skill route: it invokes only the fixed, encoded `optimizing-claude-code-prompts` path. +Live model execution requires the exact combination of `MOCK_LLM=0`, +`ALLOW_LIVE_PROVIDER=1`, a `human_verified` immutable catalog, its exact operator-approved +digest, a cumulative process-run spend cap covering at least the maximum worst-case +request across all allowed models, and the relevant provider key. Each live call reserves +its request's worst-case catalog cost after payment verification and before facilitator +settlement. Success commits actual provider usage, while an ambiguous or failed provider +outcome consumes the full reservation. Exhaustion therefore blocks settlement and +provider fetch for another paid retry. This in-memory cap resets on restart and is not +durable or cross-process. The committed catalog is `synthetic_config`, so default and +standalone gateway startup remain mock/fail-closed. Model allowlisting plus strict +input/output bounds run before an x402 offer; the conservative input bound adds a +1,024-token provider-framing reserve to the raw request-byte upper bound. Non-success +provider bodies are never consumed, and stable sanitized errors replace all provider +detail. + - Base Sepolia only; no mainnet and no real funds in automated verification. - Live facilitator construction accepts only the byte-exact approved HTTPS base and disables redirects for `/verify` and `/settle`. diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md index 486cc7d..ea132f0 100644 --- a/spikes/pi-wielder/RUNBOOK.md +++ b/spikes/pi-wielder/RUNBOOK.md @@ -94,18 +94,38 @@ cross-seller accounting authority. The mock and standalone paths use the same bounded runtime contract as the tests. Collar/Skill request bodies stop at exactly 4,096 bytes; model requests stop at 1 MiB. -x402 challenges and facilitator JSON stop at 64 KiB; Anthropic JSON and proxy upstream -responses stop at 1 MiB. Streaming and chunked bodies are counted as they arrive, so -omitting `Content-Length` does not bypass a limit. +x402 challenges and facilitator JSON stop at 64 KiB; gateway provider JSON and proxy +upstream responses stop at 1 MiB. Streaming and chunked bodies are counted as they +arrive, so omitting `Content-Length` does not bypass a limit. The default deadlines are 15 seconds for an unpaid buyer fetch, 30 seconds for its single paid retry, 5 seconds for request-body reads, 10 seconds each for facilitator -verify and settle, and 30 seconds for provider execution/upstream response reads. An -unpaid timeout remains unreserved. Any timeout after the signature keeps the payment +verify and settle, and 30 seconds for provider execution/upstream response reads. The +gateway's provider deadline covers both the fetch and the streamed response read, +cannot be configured above 30 seconds, composes the request abort signal, and refuses +redirects. Provider HTTP failures never consume or expose the raw response body; all +public failures are stable and sanitized. +An unpaid timeout remains unreserved. Any timeout after the signature keeps the payment unresolved/held until trusted reconciliation. A provider timeout after settlement returns no output and finalizes a signed failed receipt with unknown COGS and the full gross held for reconciliation or refund. +The Collar keeps no durable state for an unpaid challenge. Its paywall admits at most +128 process-local pending offers, each with a 60-second TTL. New keys beyond the cap get +`503 PENDING_OFFER_CAPACITY` with `Retry-After: 1`; active keys still get their frozen +offer. `Idempotency-Key` is restricted to 1-128 canonical ASCII characters. Only expired +unpaid entries are reclaimed. After facilitator verification, the exact payment-header +digest and all signed, unresolved, settled, refunded, execution, and terminal state +remain in the append-only journal and are never capacity-pruned. A replay must match that +digest; legacy journal entries without one are sent back through facilitator verification. + +A restart after `402` but before successful verification intentionally loses that +non-authoritative offer. A paid retry carrying the old key then gets `409` before any +facilitator or provider call. Keep the Wielder reservation unresolved, reconcile the +signed nonce through a trusted operator path, and issue a fresh key only after that +check. A retry whose verified state was journaled continues through the normal durable +reconciliation and replay paths. + ## 3. Persistent authority contract `COLLAR_JOURNAL_FILE` and `COLLAR_SIGNING_KEY_FILE` are one authority pair: @@ -162,14 +182,41 @@ reconciliation design exists. Do not manually rewrite the journal. ## 5. Live Base Sepolia boundary — intentionally blocked in the CLI -Before a live provider run, verify the current provider price sheet, add a new immutable -catalog version with `evidenceLabel: human_verified`, source, and as-of timestamp. Compute -its exact canonical `catalogDigest`, set that separately as `LIVE_CATALOG_DIGEST`, set an -atomic `LIVE_SPEND_CAP_ATOMIC`, then set `ALLOW_LIVE_PROVIDER=1` and `MOCK_LLM=0`. Supply -the provider credential only through the operator's secret injection. Do not embed approval -or spend authorization in the catalog itself. Never relabel -`synthetic-anthropic-2026-07-17-v1` as measured. Automated verification stays on the -mock facilitator and mock model and uses no real funds. +Before a live provider run, verify the current provider price sheet and construct a new +immutable catalog version with `evidenceLabel: human_verified`, source, and as-of +timestamp. Compute its exact canonical `catalogDigest`; the spend cap must cover the +maximum worst-case provider cost across every allowed model in that catalog. Do not +embed approval or spend authorization in the catalog itself, and never relabel a +`synthetic_config` catalog as measured. + +The Collar and gateway use separate operator approvals: + +- Collar construction receives `LIVE_CATALOG_DIGEST` and `LIVE_SPEND_CAP_ATOMIC`. +- The standalone gateway reads `GATEWAY_LIVE_CATALOG_DIGEST` and + `GATEWAY_LIVE_SPEND_CAP_ATOMIC`. +- Both require `ALLOW_LIVE_PROVIDER=1` and `MOCK_LLM=0`; the provider credential is + supplied only through operator secret injection. + +The Collar approval checks the gross ceiling for one Invocation. The gateway approval +instead funds one cumulative in-memory process-run budget. After facilitator verification +and before settlement, the gateway synchronously reserves that request's catalog +worst-case input/output cost. A valid provider response commits actual catalog-rated +usage, while a timeout, HTTP failure, invalid usage, or other ambiguous outcome consumes +the full reservation. A new paid retry is refused before settlement when its worst case +no longer fits. The cap resets on process restart and is not durable or shared across +workers, so a production integration still needs an independent persistent aggregate +budget. + +The committed gateway catalog is deliberately `synthetic_config`, so `npm run gateway` +remains blocked from live execution even if the flags, digest, cap, and provider key are +set. A reviewed integration must inject a `human_verified` catalog and its exact digest. +The gateway enforces its catalog's exact model allowlist, output bound, and conservative +input bound before offering payment. That input bound treats each raw request byte as at +most one provider token and reserves another 1,024 tokens for provider-side chat +framing. Provider requests refuse redirects, use one absolute fetch-plus-body deadline, +whose configurable value cannot exceed 30 seconds, and stream responses through a hard +1 MiB cap. Automated verification stays on the mock facilitator and mock model and uses +no real funds. Provider approval is separate from the x402 settlement gate below. Both gates must be satisfied by a future integration; enabling either one does not implicitly authorize diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index f2b8b20..e0047b7 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -161,6 +161,9 @@ export function createCollar({ if (!Number.isSafeInteger(providerTimeoutMs) || providerTimeoutMs <= 0) { throw new TypeError('providerTimeoutMs must be a positive safe integer'); } + if (providerTimeoutMs > DEFAULT_PROVIDER_TIMEOUT_MS) { + throw new TypeError(`providerTimeoutMs cannot exceed ${DEFAULT_PROVIDER_TIMEOUT_MS}`); + } if (journal && (journalFile || signingKeyFile || receiptSigner)) { throw new Error('injected journal cannot be combined with journal/key paths or signer'); } @@ -283,8 +286,9 @@ export function createCollar({ }); }; - const lifecycle = { - async onOffered({ idempotencyKey, requirements, expiresAt, executionQuote }) { + const persistVerifiedOffer = ({ idempotencyKey, requirements, executionQuote }) => { + const existing = journal.getByIdempotencyKey(idempotencyKey); + if (!existing) { journal.requestInvocation({ idempotencyKey, mode: 'external', @@ -294,6 +298,8 @@ export function createCollar({ creatorId: 'creator', beneficiaryId: null, }); + } + if (!journal.getByIdempotencyKey(idempotencyKey)?.quote) { journal.offerExternalPayment(idempotencyKey, { quoteId: requirements.extra.quoteId, amountAtomic: requirements.maxAmountRequired, @@ -304,11 +310,17 @@ export function createCollar({ resource: requirements.resource, requestHash: requirements.extra.requestHash, requirementsHash: hash(canonicalJson(requirements)), - expiresAt, + expiresAt: requirements.extra.expiresAt, requirements, executionQuote, }); - }, + } + }; + + const lifecycle = { + // Unpaid challenges are bounded, expiring process-local state in the + // paywall. Authority begins only after facilitator verification succeeds. + async onOffered() {}, async loadFrozenOffer({ idempotencyKey, paymentHeaderPresent = false }) { const record = journal.getByIdempotencyKey(idempotencyKey); @@ -317,17 +329,22 @@ export function createCollar({ if (record.schemaVersion === 1 && !paymentHeaderPresent) { throw new Error('legacy v1 frozen offers cannot authorize a new payment'); } + const verifiedPaymentHash = journal.getVerifiedPaymentHash(idempotencyKey); return { requirements: persistedQuote.requirements, executionQuote: persistedQuote.executionQuote ?? null, + verificationRequired: verifiedPaymentHash === null, + verifiedPaymentHash, }; }, async onSigned({ idempotencyKey, settlementReference, payer, requirements, executionQuote, + verifiedPaymentHash, }) { + persistVerifiedOffer({ idempotencyKey, requirements, executionQuote }); const existing = journal.getByIdempotencyKey(idempotencyKey); - if (!existing?.quote) throw new Error('paid retry has no prior quoted Invocation'); + if (!existing?.quote) throw new Error('verified paid retry could not persist its frozen offer'); if (existing.quote.requirementsHash !== hash(canonicalJson(requirements)) || existing.quote.requestHash !== requirements.extra.requestHash) { throw new Error('paid retry does not match the frozen x402 requirements'); @@ -336,6 +353,13 @@ export function createCollar({ && canonicalJson(existing.quote.executionQuote) !== canonicalJson(executionQuote)) { throw new Error('paid retry does not match the frozen execution quote'); } + const recordedPaymentHash = journal.getVerifiedPaymentHash(idempotencyKey); + if (recordedPaymentHash !== null && recordedPaymentHash !== verifiedPaymentHash) { + throw new Error('paid retry does not match the facilitator-verified payment authorization'); + } + if (recordedPaymentHash === null && existing.payment.state === 'offered') { + journal.recordExternalPaymentVerification(idempotencyKey, { verifiedPaymentHash }); + } if (existing.payment.settlementReference !== null && (existing.payment.settlementReference !== settlementReference || existing.payment.payer !== payer)) { @@ -408,7 +432,7 @@ export function createCollar({ payer: record.payment.payer, }; } - return null; + return { kind: 'signed' }; }, async onSettled({ @@ -891,9 +915,17 @@ export function createAnthropicExecutor({ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { throw new TypeError('Anthropic timeoutMs must be a positive safe integer'); } + if (timeoutMs > DEFAULT_PROVIDER_TIMEOUT_MS) { + throw new TypeError(`Anthropic timeoutMs cannot exceed ${DEFAULT_PROVIDER_TIMEOUT_MS}`); + } if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0) { throw new TypeError('Anthropic maxResponseBytes must be a positive safe integer'); } + if (maxResponseBytes > DEFAULT_PROVIDER_RESPONSE_BYTES) { + throw new TypeError( + `Anthropic maxResponseBytes cannot exceed ${DEFAULT_PROVIDER_RESPONSE_BYTES}`, + ); + } return async ({ skillContent, input, @@ -947,19 +979,22 @@ export function createAnthropicExecutor({ if (!response?.ok) { throw new RuntimeBoundaryError('UPSTREAM_PROVIDER_ERROR', 'provider request failed'); } - if (response?.body !== undefined && response?.headers?.get) { - return readJsonBody(response, { - maxBytes: maxResponseBytes, - tooLargeCode: 'UPSTREAM_PROVIDER_RESPONSE_TOO_LARGE', - tooLargeMessage: 'provider response exceeds the JSON byte limit', - readErrorCode: 'UPSTREAM_PROVIDER_RESPONSE_READ_FAILED', - readErrorMessage: 'provider response could not be read', - jsonErrorCode: 'UPSTREAM_PROVIDER_RESPONSE_JSON', - jsonErrorMessage: 'provider response was not JSON', - signal: composedSignal, - }); + if (!(response instanceof Response)) { + throw new RuntimeBoundaryError( + 'UPSTREAM_PROVIDER_RESPONSE_SHAPE', + 'provider response must expose a bounded byte stream', + ); } - return response.json(); + return readJsonBody(response, { + maxBytes: maxResponseBytes, + tooLargeCode: 'UPSTREAM_PROVIDER_RESPONSE_TOO_LARGE', + tooLargeMessage: 'provider response exceeds the JSON byte limit', + readErrorCode: 'UPSTREAM_PROVIDER_RESPONSE_READ_FAILED', + readErrorMessage: 'provider response could not be read', + jsonErrorCode: 'UPSTREAM_PROVIDER_RESPONSE_JSON', + jsonErrorMessage: 'provider response was not JSON', + signal: composedSignal, + }); }); } catch (error) { if (error instanceof RuntimeBoundaryError) { diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index cd6775e..ed230f9 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -14,10 +14,21 @@ import { pathToFileURL } from 'node:url'; import { Hono } from 'hono'; import { serve } from '@hono/node-server'; +import { + assertLiveCatalogApproval, + catalogDigest, + usageCostAtomic, +} from './execution-economics.mjs'; +import { + readJsonBody, + RuntimeBoundaryError, + withWallClockDeadline, +} from './runtime-boundaries.mjs'; import { createLiveFacilitatorTransport, createMockFacilitatorTransport, x402Paywall, + x402RequestBodyBytes, x402RequestBodyText, } from './x402-seller.mjs'; @@ -25,10 +36,238 @@ import { // token; per-call keeps the 402 requirements computable before inference). export const MODEL_PRICES_USDC = Object.freeze({ claude: '0.041', gpt: '0.087', default: '0.05' }); export const MAX_GATEWAY_REQUEST_BODY_BYTES = 1024 * 1024; -const priceFor = (model = '') => - model.startsWith('claude') ? MODEL_PRICES_USDC.claude - : model.startsWith('gpt') ? MODEL_PRICES_USDC.gpt - : MODEL_PRICES_USDC.default; +export const DEFAULT_GATEWAY_PROVIDER_TIMEOUT_MS = 30_000; +export const MAX_GATEWAY_PROVIDER_RESPONSE_BYTES = 1024 * 1024; +export const GATEWAY_PROVIDER_FRAMING_TOKEN_ALLOWANCE = 1_024; +export const GATEWAY_EXECUTION_CATALOG = deepFreeze({ + schemaVersion: 2, + version: 'synthetic-gateway-2026-07-18-v1', + evidenceLabel: 'synthetic_config', + source: null, + asOf: null, + models: { + 'claude-sonnet-4-6': { + provider: 'anthropic', + inputAtomicPerMillionTokens: '3000000', + outputAtomicPerMillionTokens: '15000000', + maxInputTokens: 200_000, + maxOutputTokens: 8_192, + }, + 'gpt-5.2': { + provider: 'openai', + inputAtomicPerMillionTokens: '1000000', + outputAtomicPerMillionTokens: '1000000', + maxInputTokens: 128_000, + maxOutputTokens: 8_192, + }, + // Retained only for the offline protocol e2e's generic mock challenge. + 'gpt-x': { + provider: 'openai', + inputAtomicPerMillionTokens: '1000000', + outputAtomicPerMillionTokens: '1000000', + maxInputTokens: 128_000, + maxOutputTokens: 8_192, + }, + }, +}); + +const LIVE_PROVIDERS = new Set(['anthropic', 'openai']); + +class GatewayBoundaryError extends Error { + constructor(code, message, status) { + super(message); + this.name = 'GatewayBoundaryError'; + this.code = code; + this.status = status; + } +} + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function positiveLimit(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${label} must be a positive safe integer`); + } + return value; +} + +function gatewayFailure(code, message, status) { + throw new GatewayBoundaryError(code, message, status); +} + +function priceForProvider(provider) { + if (provider === 'anthropic') return MODEL_PRICES_USDC.claude; + if (provider === 'openai') return MODEL_PRICES_USDC.gpt; + gatewayFailure('MODEL_NOT_ALLOWED', 'gateway model is not allowed', 400); +} + +function frozenCatalog(value) { + const snapshot = structuredClone(value); + catalogDigest(snapshot); + for (const policy of Object.values(snapshot.models)) { + if (!LIVE_PROVIDERS.has(policy.provider)) { + throw new TypeError('gateway catalog provider must be anthropic or openai'); + } + } + return deepFreeze(snapshot); +} + +function configuredLiveApproval(value) { + if (value !== undefined) return value; + return process.env.GATEWAY_LIVE_CATALOG_DIGEST && process.env.GATEWAY_LIVE_SPEND_CAP_ATOMIC + ? { + catalogDigest: process.env.GATEWAY_LIVE_CATALOG_DIGEST, + spendCapAtomic: process.env.GATEWAY_LIVE_SPEND_CAP_ATOMIC, + } + : null; +} + +function configuredProviderKeys(value) { + if (value !== undefined) return structuredClone(value); + return { + anthropic: process.env.ANTHROPIC_API_KEY ?? null, + openai: process.env.OPENAI_API_KEY ?? null, + }; +} + +function assertLiveGatewayApproval({ catalog, approval }) { + let maximumWorstCaseCost = 0n; + for (const [model, policy] of Object.entries(catalog.models)) { + const cost = usageCostAtomic({ + schemaVersion: 2, + model, + inputTokens: policy.maxInputTokens, + outputTokens: policy.maxOutputTokens, + }, catalog); + if (cost > maximumWorstCaseCost) maximumWorstCaseCost = cost; + } + return assertLiveCatalogApproval({ + catalog, + approval, + grossAtomic: maximumWorstCaseCost.toString(), + }); +} + +function createProviderSpendBudget(catalog, approvedBoundary) { + const cap = BigInt(approvedBoundary.spendCapAtomic); + let committed = 0n; + let reserved = 0n; + + const worstCaseCost = (plan) => usageCostAtomic({ + schemaVersion: 2, + model: plan.model, + inputTokens: plan.policy.maxInputTokens, + outputTokens: plan.maxOutputTokens, + }, catalog); + + const availableReservation = (plan) => { + const amount = worstCaseCost(plan); + if (committed + reserved + amount > cap) { + gatewayFailure( + 'PROVIDER_SPEND_CAP', + 'live provider spend budget cannot cover this request', + 503, + ); + } + return amount; + }; + + return { + assertAvailable(plan) { + availableReservation(plan); + }, + reserve(plan) { + const amount = availableReservation(plan); + reserved += amount; + let state = 'reserved'; + return { + commit(usage) { + if (state !== 'reserved') return; + const actual = usageCostAtomic({ + schemaVersion: 2, + model: plan.model, + inputTokens: usage.prompt_tokens, + outputTokens: usage.completion_tokens, + }, catalog); + if (actual > amount) { + gatewayFailure( + 'UPSTREAM_PROVIDER_USAGE', + 'upstream provider usage is invalid', + 502, + ); + } + reserved -= amount; + committed += actual; + state = 'committed'; + }, + holdWorstCase() { + if (state !== 'reserved') return; + reserved -= amount; + committed += amount; + state = 'held'; + }, + releaseBeforeFetch() { + if (state !== 'reserved') return; + reserved -= amount; + state = 'released'; + }, + }; + }, + }; +} + +function requestPlan(c, catalog) { + const cached = c.get('gatewayRequestPlan'); + if (cached) return cached; + const body = gatewayRequestBody(c); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + gatewayFailure('REQUEST_SCHEMA', 'gateway request is invalid', 400); + } + if (typeof body.model !== 'string' || !Object.hasOwn(catalog.models, body.model)) { + gatewayFailure('MODEL_NOT_ALLOWED', 'gateway model is not allowed', 400); + } + const policy = catalog.models[body.model]; + const hasLegacyLimit = Object.hasOwn(body, 'max_tokens'); + const hasCurrentLimit = Object.hasOwn(body, 'max_completion_tokens'); + if (hasLegacyLimit && hasCurrentLimit) { + gatewayFailure('TOKEN_LIMIT', 'gateway request must provide only one output-token limit', 400); + } + const requestedOutputTokens = hasLegacyLimit + ? body.max_tokens + : hasCurrentLimit ? body.max_completion_tokens : Math.min(2_048, policy.maxOutputTokens); + if (!Number.isSafeInteger(requestedOutputTokens) + || requestedOutputTokens <= 0 + || requestedOutputTokens > policy.maxOutputTokens) { + gatewayFailure('TOKEN_LIMIT', 'gateway output-token limit exceeds the catalog policy', 400); + } + const requestBodyBytes = x402RequestBodyBytes(c).byteLength; + const conservativeInputTokenBound = requestBodyBytes + + GATEWAY_PROVIDER_FRAMING_TOKEN_ALLOWANCE; + if (conservativeInputTokenBound > policy.maxInputTokens) { + gatewayFailure( + 'PROMPT_TOKEN_BOUND', + 'gateway request plus provider framing exceeds the conservative catalog input-token bound', + 400, + ); + } + const plan = deepFreeze({ + body, + model: body.model, + provider: policy.provider, + policy, + maxOutputTokens: requestedOutputTokens, + requestBodyBytes, + conservativeInputTokenBound, + }); + c.set('gatewayRequestPlan', plan); + return plan; +} function gatewayRequestBody(c) { const cached = c.get('gatewayRequestBody'); @@ -42,9 +281,76 @@ function gatewayRequestBody(c) { export function createGateway({ facilitatorTransport, payTo = process.env.PAY_TO_ADDRESS || '0x000000000000000000000000000000000000dEaD', - mockLlm = process.env.MOCK_LLM === '1', + mockLlm = process.env.MOCK_LLM !== '0', + allowLiveProvider = process.env.ALLOW_LIVE_PROVIDER === '1', + providerCatalog = GATEWAY_EXECUTION_CATALOG, + liveApproval = undefined, + providerFetch = fetch, + providerApiKeys = undefined, + providerTimeoutMs = DEFAULT_GATEWAY_PROVIDER_TIMEOUT_MS, + maxProviderResponseBytes = MAX_GATEWAY_PROVIDER_RESPONSE_BYTES, } = {}) { + positiveLimit(providerTimeoutMs, 'providerTimeoutMs'); + positiveLimit(maxProviderResponseBytes, 'maxProviderResponseBytes'); + if (providerTimeoutMs > DEFAULT_GATEWAY_PROVIDER_TIMEOUT_MS) { + throw new RangeError(`providerTimeoutMs must not exceed ${DEFAULT_GATEWAY_PROVIDER_TIMEOUT_MS} milliseconds`); + } + if (maxProviderResponseBytes > MAX_GATEWAY_PROVIDER_RESPONSE_BYTES) { + throw new RangeError('maxProviderResponseBytes must not exceed one MiB'); + } + const catalog = frozenCatalog(providerCatalog); + const keys = configuredProviderKeys(providerApiKeys); + let approvedLiveBoundary = null; + if (!mockLlm) { + if (allowLiveProvider !== true) { + throw new Error('live gateway execution requires an explicit live provider gate'); + } + if (typeof providerFetch !== 'function') throw new TypeError('gateway providerFetch must be a function'); + approvedLiveBoundary = assertLiveGatewayApproval({ + catalog, + approval: configuredLiveApproval(liveApproval), + }); + for (const provider of new Set(Object.values(catalog.models).map((policy) => policy.provider))) { + if (typeof keys?.[provider] !== 'string' || keys[provider].length === 0) { + throw new Error(`live gateway requires an injected ${provider} provider key`); + } + } + } + const providerSpendBudget = approvedLiveBoundary + ? createProviderSpendBudget(catalog, approvedLiveBoundary) + : null; + const providerSpendClaims = new Map(); + const releaseProviderSpendClaim = (idempotencyKey) => { + const claim = providerSpendClaims.get(idempotencyKey); + if (!claim) return; + if (claim.state === 'reserved') claim.reservation.releaseBeforeFetch(); + providerSpendClaims.delete(idempotencyKey); + }; + const providerPaymentLifecycle = providerSpendBudget ? { + async beforeSettlement({ context, idempotencyKey }) { + if (providerSpendClaims.has(idempotencyKey)) { + gatewayFailure( + 'PROVIDER_ATTEMPT_IN_PROGRESS', + 'a provider attempt already owns this payment authorization', + 503, + ); + } + const plan = requestPlan(context, catalog); + const reservation = providerSpendBudget.reserve(plan); + providerSpendClaims.set(idempotencyKey, { plan, reservation, state: 'reserved' }); + }, + async onRejected({ idempotencyKey }) { + releaseProviderSpendClaim(idempotencyKey); + }, + async onUnresolved({ idempotencyKey }) { + releaseProviderSpendClaim(idempotencyKey); + }, + } : {}; const app = new Hono(); + app.onError((error, c) => { + const failure = publicGatewayFailure(error); + return c.json({ error: failure.message, code: failure.code }, failure.status); + }); app.get('/healthz', (c) => c.json({ ok: true, prices: MODEL_PRICES_USDC })); app.post( @@ -52,18 +358,51 @@ export function createGateway({ x402Paywall({ // The x402 boundary owns one bounded stream read. Pricing and execution // parse its cached text instead of consuming the request twice. - price: async (c) => priceFor(gatewayRequestBody(c).model), + price: async (c) => { + const plan = requestPlan(c, catalog); + providerSpendBudget?.assertAvailable(plan); + return priceForProvider(plan.provider); + }, payTo, facilitatorTransport, + lifecycle: providerPaymentLifecycle, description: 'per-call model inference (x402 reseller, testnet)', maxRequestBodyBytes: MAX_GATEWAY_REQUEST_BODY_BYTES, }), async (c) => { - const body = gatewayRequestBody(c); - const model = body.model ?? ''; - const completion = mockLlm ? mockCompletion(body) - : model.startsWith('claude') ? await viaAnthropic(body) - : await viaOpenAI(body); // gpt-* and anything else + const plan = requestPlan(c, catalog); + const body = plan.body; + let completion; + if (mockLlm) { + completion = mockCompletion(body); + } else { + const x402State = c.get('x402'); + const claim = providerSpendClaims.get(x402State?.idempotencyKey); + if (!claim || claim.state !== 'reserved' || claim.plan.model !== plan.model) { + if (x402State?.idempotencyKey) releaseProviderSpendClaim(x402State.idempotencyKey); + gatewayFailure( + 'PROVIDER_RESERVATION_MISSING', + 'provider spend authorization is unavailable after settlement', + 503, + ); + } + claim.state = 'executing'; + try { + completion = await executeLiveProvider(plan, { + fetchImpl: providerFetch, + apiKey: keys[plan.provider], + signal: c.req.raw.signal, + timeoutMs: providerTimeoutMs, + maxResponseBytes: maxProviderResponseBytes, + }); + claim.reservation.commit(completion.usage); + } catch (error) { + claim.reservation.holdWorstCase(); + throw error; + } finally { + providerSpendClaims.delete(x402State.idempotencyKey); + } + } if (!body.stream) return c.json(completion); // OpenAI-style clients (pi included) speak SSE. The spike computes the // full completion first, then replays it as one compliant stream: @@ -134,31 +473,142 @@ function toAnthropicMessages(oaiMessages = []) { return out; } -async function viaAnthropic(body) { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) throw new Error('ANTHROPIC_API_KEY required for claude-* models unless MOCK_LLM=1'); +function publicGatewayFailure(error) { + if (error instanceof GatewayBoundaryError) { + return { code: error.code, message: error.message, status: error.status }; + } + if (error instanceof RuntimeBoundaryError) { + const failures = { + UPSTREAM_PROVIDER_TIMEOUT: { + message: 'upstream provider timed out', status: 504, + }, + UPSTREAM_PROVIDER_ABORTED: { + message: 'upstream provider was aborted', status: 504, + }, + UPSTREAM_PROVIDER_RESPONSE_TOO_LARGE: { + message: 'upstream provider response exceeds the byte limit', status: 502, + }, + UPSTREAM_PROVIDER_RESPONSE_READ_FAILED: { + message: 'upstream provider response is invalid', status: 502, + }, + UPSTREAM_PROVIDER_RESPONSE_JSON: { + message: 'upstream provider response is invalid', status: 502, + }, + }; + const known = failures[error.code]; + if (known) return { code: error.code, ...known }; + } + return { + code: 'UPSTREAM_PROVIDER_ERROR', + message: 'upstream provider request failed', + status: 502, + }; +} + +function providerReadOptions(maxResponseBytes, signal) { + return { + maxBytes: maxResponseBytes, + tooLargeCode: 'UPSTREAM_PROVIDER_RESPONSE_TOO_LARGE', + tooLargeMessage: 'upstream provider response exceeds the JSON byte limit', + readErrorCode: 'UPSTREAM_PROVIDER_RESPONSE_READ_FAILED', + readErrorMessage: 'upstream provider response could not be read', + jsonErrorCode: 'UPSTREAM_PROVIDER_RESPONSE_JSON', + jsonErrorMessage: 'upstream provider response was not JSON', + signal, + }; +} + +function validateProviderUsage(plan, inputTokens, outputTokens) { + if (!Number.isSafeInteger(inputTokens) || inputTokens < 0 + || !Number.isSafeInteger(outputTokens) || outputTokens < 0 + || inputTokens > plan.policy.maxInputTokens + || outputTokens > plan.maxOutputTokens) { + gatewayFailure('UPSTREAM_PROVIDER_USAGE', 'upstream provider usage is invalid', 502); + } + return { inputTokens, outputTokens }; +} + +function anthropicRequest(plan, apiKey) { + const body = plan.body; const system = (body.messages ?? []).filter((m) => m.role === 'system').map((m) => contentToText(m.content)).join('\n') || undefined; const tools = (body.tools ?? []).map((t) => ({ name: t.function.name, description: t.function.description ?? '', input_schema: t.function.parameters ?? { type: 'object', properties: {} }, })); - const res = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, - body: JSON.stringify({ - model: body.model, - max_tokens: body.max_tokens ?? 2048, - system, - messages: toAnthropicMessages(body.messages), - ...(tools.length ? { tools } : {}), - }), - }); - if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${await res.text()}`); - const data = await res.json(); - const text = data.content?.filter((b) => b.type === 'text').map((b) => b.text).join('') ?? ''; - const toolCalls = (data.content ?? []).filter((b) => b.type === 'tool_use').map((b) => ({ - id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) }, + return { + url: 'https://api.anthropic.com/v1/messages', + init: { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: plan.model, + max_tokens: plan.maxOutputTokens, + system, + messages: toAnthropicMessages(body.messages), + ...(tools.length ? { tools } : {}), + }), + }, + }; +} + +function openAiRequest(plan, apiKey) { + const body = plan.body; + const allowedFields = [ + 'messages', 'tools', 'tool_choice', 'temperature', 'top_p', 'stop', + 'presence_penalty', 'frequency_penalty', 'response_format', 'seed', 'user', + ]; + const forwarded = Object.fromEntries(allowedFields + .filter((field) => Object.hasOwn(body, field)) + .map((field) => [field, body[field]])); + return { + url: 'https://api.openai.com/v1/chat/completions', + init: { + method: 'POST', + headers: { + accept: 'application/json', + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + ...forwarded, + model: plan.model, + max_completion_tokens: plan.maxOutputTokens, + n: 1, + stream: false, + }), + }, + }; +} + +function providerRequest(plan, apiKey) { + return plan.provider === 'anthropic' + ? anthropicRequest(plan, apiKey) + : openAiRequest(plan, apiKey); +} + +function anthropicCompletion(plan, data) { + if (!data || typeof data !== 'object' || Array.isArray(data) + || !Array.isArray(data.content) || !data.usage || typeof data.usage !== 'object') { + gatewayFailure('UPSTREAM_PROVIDER_RESPONSE_SCHEMA', 'upstream provider response is invalid', 502); + } + const usage = validateProviderUsage( + plan, + data.usage.input_tokens, + data.usage.output_tokens, + ); + const text = data.content.filter((block) => block?.type === 'text') + .map((block) => (typeof block.text === 'string' ? block.text : '')) + .join(''); + const toolCalls = data.content.filter((block) => block?.type === 'tool_use').map((block) => ({ + id: block.id, + type: 'function', + function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) }, })); return { id: data.id, @@ -176,28 +626,67 @@ async function viaAnthropic(body) { : data.stop_reason === 'max_tokens' ? 'length' : 'stop', }], usage: { - prompt_tokens: data.usage?.input_tokens ?? 0, - completion_tokens: data.usage?.output_tokens ?? 0, - total_tokens: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0), + prompt_tokens: usage.inputTokens, + completion_tokens: usage.outputTokens, + total_tokens: usage.inputTokens + usage.outputTokens, }, }; } -async function viaOpenAI(body) { - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) throw new Error('OPENAI_API_KEY required for gpt-* models unless MOCK_LLM=1'); - // Always fetch buffered — the gateway synthesizes its own SSE downstream. - const { stream, stream_options, max_tokens, ...rest } = body; - // Newer OpenAI models reject max_tokens (400 unsupported_parameter) and - // require max_completion_tokens; clients (pi included) send max_tokens. - if (max_tokens != null && rest.max_completion_tokens == null) rest.max_completion_tokens = max_tokens; - const res = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, - body: JSON.stringify(rest), - }); - if (!res.ok) throw new Error(`OpenAI API ${res.status}: ${await res.text()}`); - return res.json(); +function openAiCompletion(plan, data) { + if (!data || typeof data !== 'object' || Array.isArray(data) + || !Array.isArray(data.choices) || data.choices.length === 0 + || !data.usage || typeof data.usage !== 'object') { + gatewayFailure('UPSTREAM_PROVIDER_RESPONSE_SCHEMA', 'upstream provider response is invalid', 502); + } + validateProviderUsage( + plan, + data.usage.prompt_tokens, + data.usage.completion_tokens, + ); + return data; +} + +async function executeLiveProvider(plan, { + fetchImpl, + apiKey, + signal, + timeoutMs, + maxResponseBytes, +}) { + try { + return await withWallClockDeadline({ + signal, + timeoutMs, + timeoutCode: 'UPSTREAM_PROVIDER_TIMEOUT', + timeoutMessage: 'upstream provider timed out', + abortedCode: 'UPSTREAM_PROVIDER_ABORTED', + abortedMessage: 'upstream provider was aborted', + }, async (composedSignal) => { + const request = providerRequest(plan, apiKey); + const response = await fetchImpl(request.url, { + ...request.init, + redirect: 'error', + signal: composedSignal, + }); + if (!response?.ok) { + try { + Promise.resolve(response?.body?.cancel?.()).catch(() => {}); + } catch { /* the sanitized HTTP failure owns the result */ } + gatewayFailure('UPSTREAM_PROVIDER_HTTP', 'upstream provider request failed', 502); + } + const data = await readJsonBody( + response, + providerReadOptions(maxResponseBytes, composedSignal), + ); + return plan.provider === 'anthropic' + ? anthropicCompletion(plan, data) + : openAiCompletion(plan, data); + }); + } catch (error) { + if (error instanceof GatewayBoundaryError || error instanceof RuntimeBoundaryError) throw error; + gatewayFailure('UPSTREAM_PROVIDER_ERROR', 'upstream provider request failed', 502); + } } /** Boot helper shared by the standalone script and e2e.mjs. */ diff --git a/spikes/pi-wielder/src/invocation-journal.mjs b/spikes/pi-wielder/src/invocation-journal.mjs index bbfdfdf..ac2edb6 100644 --- a/spikes/pi-wielder/src/invocation-journal.mjs +++ b/spikes/pi-wielder/src/invocation-journal.mjs @@ -368,6 +368,7 @@ export function verifySignedReceipt(bundle, { publicKeyPem, keyId }) { const EVENT_DATA_KEYS = Object.freeze({ 'invocation.requested': ['invocationId', 'mode', 'skill', 'requestHash', 'creatorId', 'beneficiaryId'], 'payment.offered': ['quote'], + 'payment.authorization_verified': ['verifiedPaymentHash'], 'payment.signed': ['settlementReference', 'payer'], 'payment.settled': ['settlementReference', 'txHash', 'payer'], 'payment.unresolved': ['reason'], @@ -489,6 +490,7 @@ export function createInvocationJournal({ const records = new Map(); const settlementReferences = new Map(); const transactionHashes = new Map(); + const verifiedPaymentHashes = new Map(); const eventLog = []; let nextSequence = 1; let headHash = null; @@ -826,6 +828,18 @@ export function createInvocationJournal({ } validateQuote(event.data.quote, event.schemaVersion); break; + case 'payment.authorization_verified': + if (!record || record.schemaVersion !== 2 || record.payment.state !== 'offered') { + throw new Error('payment.authorization_verified requires an offered v2 payment'); + } + if (!/^sha256:[0-9a-f]{64}$/.test(String(event.data.verifiedPaymentHash ?? ''))) { + throw new Error('verified payment authorization hash must be one SHA-256 digest'); + } + if (verifiedPaymentHashes.has(event.idempotencyKey) + && verifiedPaymentHashes.get(event.idempotencyKey) !== event.data.verifiedPaymentHash) { + throw new Error('idempotency key already binds a different verified payment authorization'); + } + break; case 'payment.signed': if (!record || record.payment.state !== 'offered') throw new Error('payment.signed requires offered payment'); assertUnique(settlementReferences, event.data.settlementReference, event.idempotencyKey, 'settlement reference'); @@ -944,6 +958,9 @@ export function createInvocationJournal({ record.payment.state = 'offered'; record.execution.state = 'quoted'; break; + case 'payment.authorization_verified': + verifiedPaymentHashes.set(event.idempotencyKey, event.data.verifiedPaymentHash); + break; case 'payment.signed': record.payment = { ...record.payment, state: 'signed', ...event.data, reason: null }; record.wielderId = event.data.payer; @@ -1229,6 +1246,38 @@ export function createInvocationJournal({ const markExternalPaymentSigned = (key, input) => claimExternalPaymentSigned(key, input).record; + function recordExternalPaymentVerification(key, input) { + refreshFromAuthority(); + const record = requireRecord(records, key); + const verifiedPaymentHash = input?.verifiedPaymentHash; + if (!/^sha256:[0-9a-f]{64}$/.test(String(verifiedPaymentHash ?? ''))) { + throw new Error('verified payment authorization hash must be one SHA-256 digest'); + } + const existing = verifiedPaymentHashes.get(key); + if (existing) { + if (existing !== verifiedPaymentHash) { + throw new Error('idempotency key already binds a different verified payment authorization'); + } + return copy(record); + } + if (record.schemaVersion !== 2 || record.payment.state !== 'offered') { + throw new Error('verified payment authorization can only bind an offered v2 payment'); + } + try { + append('payment.authorization_verified', key, { verifiedPaymentHash }); + return copy(records.get(key)); + } catch (error) { + if (error.code !== 'JOURNAL_CONFLICT') throw error; + const winner = verifiedPaymentHashes.get(key); + if (winner !== verifiedPaymentHash) { + throw new Error('idempotency key concurrently bound a different verified payment authorization', { + cause: error, + }); + } + return copy(requireRecord(records, key)); + } + } + function markExternalPaymentSettled(key, input) { refreshFromAuthority(); const record = requireRecord(records, key); @@ -1485,6 +1534,7 @@ export function createInvocationJournal({ return Object.freeze({ requestInvocation, offerExternalPayment, + recordExternalPaymentVerification, claimExternalPaymentSigned, markExternalPaymentSigned, markExternalPaymentSettled, @@ -1502,6 +1552,10 @@ export function createInvocationJournal({ refreshFromAuthority(); return records.has(key) ? copy(records.get(key)) : null; }, + getVerifiedPaymentHash: (key) => { + refreshFromAuthority(); + return verifiedPaymentHashes.get(key) ?? null; + }, getBySettlementReference: (reference) => { refreshFromAuthority(); const key = settlementReferences.get(canonicalBytes32(reference, 'settlementReference')); diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index 1805f23..f6d8d59 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -75,6 +75,12 @@ function positiveLimit(value, label) { return value; } +function boundedLimit(value, label, ceiling) { + positiveLimit(value, label); + if (value > ceiling) throw new TypeError(`${label} cannot exceed ${ceiling}`); + return value; +} + function proxyBoundaryResponse(c, error) { const code = error instanceof RuntimeBoundaryError ? error.code : 'UPSTREAM_FAILURE'; const statuses = { @@ -299,8 +305,14 @@ export async function payingFetch(account, url, init, options = {}) { if (typeof nonceFactory !== 'function') { throw paymentError('NONCE_CAPABILITY', 'nonceFactory must be a synchronous function'); } - if (typeof idempotencyKey !== 'string' || !/^[A-Za-z0-9._:-]{1,200}$/.test(idempotencyKey)) { - throw paymentError('AUTHORIZATION_ID', 'idempotencyKey must be a bounded canonical token'); + boundedLimit(unpaidTimeoutMs, 'unpaidTimeoutMs', DEFAULT_UNPAID_FETCH_TIMEOUT_MS); + boundedLimit(paidTimeoutMs, 'paidTimeoutMs', DEFAULT_PAID_RETRY_TIMEOUT_MS); + if (typeof idempotencyKey !== 'string' + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(idempotencyKey)) { + throw paymentError( + 'AUTHORIZATION_ID', + 'idempotencyKey must be 1-128 canonical ASCII characters', + ); } const request = capturePayingRequestInit(init, idempotencyKey); const { method, callerSignal } = request; @@ -655,11 +667,19 @@ export function createProxy({ if (receiptKeyId(trustedCollarPublicKeyPem) !== trustedCollarKeyId) { throw new Error('pinned Collar public key and key ID do not match'); } - positiveLimit(maxSkillRequestBytes, 'maxSkillRequestBytes'); - positiveLimit(maxModelRequestBytes, 'maxModelRequestBytes'); - positiveLimit(maxUpstreamResponseBytes, 'maxUpstreamResponseBytes'); - positiveLimit(proxyRequestTimeoutMs, 'proxyRequestTimeoutMs'); - positiveLimit(proxyResponseTimeoutMs, 'proxyResponseTimeoutMs'); + boundedLimit(maxSkillRequestBytes, 'maxSkillRequestBytes', DEFAULT_PROXY_SKILL_REQUEST_BYTES); + boundedLimit(maxModelRequestBytes, 'maxModelRequestBytes', DEFAULT_PROXY_MODEL_REQUEST_BYTES); + boundedLimit( + maxUpstreamResponseBytes, + 'maxUpstreamResponseBytes', + DEFAULT_PROXY_UPSTREAM_RESPONSE_BYTES, + ); + boundedLimit(proxyRequestTimeoutMs, 'proxyRequestTimeoutMs', DEFAULT_PROXY_REQUEST_TIMEOUT_MS); + boundedLimit( + proxyResponseTimeoutMs, + 'proxyResponseTimeoutMs', + DEFAULT_PROXY_RESPONSE_TIMEOUT_MS, + ); const ledger = createLedger(ledgerFile); const sessionPaymentPolicy = paymentPolicy ?? createDefaultPaymentPolicy({ gatewayUrl, collarUrl }); const app = new Hono(); diff --git a/spikes/pi-wielder/src/runtime-boundaries.mjs b/spikes/pi-wielder/src/runtime-boundaries.mjs index e731d3c..0a28ee5 100644 --- a/spikes/pi-wielder/src/runtime-boundaries.mjs +++ b/spikes/pi-wielder/src/runtime-boundaries.mjs @@ -105,6 +105,11 @@ export async function readBodyBytes(source, { }) { assertPositiveLimit(maxBytes, 'maxBytes'); const bodySignal = assertSignal(signal); + if (bodySignal?.aborted) { + const error = new RuntimeBoundaryError(readErrorCode, readErrorMessage); + await cancelQuietly(source?.body, error); + throw error; + } const declared = contentLength(source); if (declared !== null && declared > maxBytes) { await cancelQuietly(source?.body, new RuntimeBoundaryError(tooLargeCode, tooLargeMessage)); diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index 84255f2..8e2acac 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -22,9 +22,15 @@ export const USDC_ADDRESS = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; export const USDC_EIP712 = Object.freeze({ name: 'USDC', version: '2' }); export const APPROVED_LIVE_FACILITATOR_BASE = 'https://x402.org/facilitator'; export const DEFAULT_X402_REQUEST_BODY_BYTES = 4096; +// The generic x402 default stays at the Collar's 4 KiB contract. The fixed +// model-gateway route is the only current consumer of the larger hard ceiling. +export const MAX_X402_REQUEST_BODY_BYTES = 1024 * 1024; export const DEFAULT_X402_REQUEST_BODY_TIMEOUT_MS = 5_000; export const DEFAULT_FACILITATOR_TIMEOUT_MS = 10_000; export const DEFAULT_FACILITATOR_RESPONSE_BYTES = 64 * 1024; +export const MAX_IDEMPOTENCY_KEY_CHARACTERS = 128; +export const DEFAULT_PENDING_OFFER_LIMIT = 128; +export const DEFAULT_PENDING_OFFER_TTL_MS = 60_000; export const usdcToAtomic = (display) => parseUsdc(display).toString(); export const atomicToUsdc = (atomic) => formatUsdc(BigInt(atomic)); @@ -105,6 +111,12 @@ function positiveLimit(value, label) { return value; } +function cappedLimit(value, label, ceiling) { + positiveLimit(value, label); + if (value > ceiling) throw new TypeError(`${label} cannot exceed ${ceiling}`); + return value; +} + const X402_BODY_TEXT_KEY = 'x402RequestBodyText'; const X402_BODY_BYTES_KEY = 'x402RequestBodyBytes'; @@ -200,18 +212,44 @@ export function x402Paywall({ requestBodyTimeoutMs = DEFAULT_X402_REQUEST_BODY_TIMEOUT_MS, facilitatorTimeoutMs = DEFAULT_FACILITATOR_TIMEOUT_MS, facilitatorResponseMaxBytes = DEFAULT_FACILITATOR_RESPONSE_BYTES, + maxPendingOffers = DEFAULT_PENDING_OFFER_LIMIT, + pendingOfferTtlMs = DEFAULT_PENDING_OFFER_TTL_MS, + now = Date.now, }) { const transport = requireFacilitatorTransport(facilitatorTransport); const canonicalPayTo = canonicalAddress(payTo); if (quote !== null && typeof quote !== 'function') { throw new TypeError('quote must be an injected function or null'); } - positiveLimit(maxRequestBodyBytes, 'maxRequestBodyBytes'); - positiveLimit(requestBodyTimeoutMs, 'requestBodyTimeoutMs'); - positiveLimit(facilitatorTimeoutMs, 'facilitatorTimeoutMs'); - positiveLimit(facilitatorResponseMaxBytes, 'facilitatorResponseMaxBytes'); - const frozenOffers = new Map(); + cappedLimit(maxRequestBodyBytes, 'maxRequestBodyBytes', MAX_X402_REQUEST_BODY_BYTES); + cappedLimit(requestBodyTimeoutMs, 'requestBodyTimeoutMs', DEFAULT_X402_REQUEST_BODY_TIMEOUT_MS); + cappedLimit(facilitatorTimeoutMs, 'facilitatorTimeoutMs', DEFAULT_FACILITATOR_TIMEOUT_MS); + cappedLimit( + facilitatorResponseMaxBytes, + 'facilitatorResponseMaxBytes', + DEFAULT_FACILITATOR_RESPONSE_BYTES, + ); + cappedLimit(maxPendingOffers, 'maxPendingOffers', DEFAULT_PENDING_OFFER_LIMIT); + cappedLimit(pendingOfferTtlMs, 'pendingOfferTtlMs', DEFAULT_PENDING_OFFER_TTL_MS); + if (typeof now !== 'function') throw new TypeError('now must be a trusted clock function'); + const pendingOffers = new Map(); const locallyUnresolvedSettlements = new Set(); + const transientAdmissions = new Set(); + + const activeKeyCount = () => new Set([ + ...pendingOffers.keys(), + ...locallyUnresolvedSettlements, + ...transientAdmissions, + ]).size; + + const reserveTransientAdmission = (idempotencyKey) => { + if (transientAdmissions.has(idempotencyKey)) return false; + const alreadyTracked = pendingOffers.has(idempotencyKey) + || locallyUnresolvedSettlements.has(idempotencyKey); + if (!alreadyTracked && activeKeyCount() >= maxPendingOffers) return false; + transientAdmissions.add(idempotencyKey); + return true; + }; return async (c, next) => { const notifyUnresolved = async (payload) => { @@ -223,8 +261,21 @@ export function x402Paywall({ // ambiguity into a second settlement attempt or an unstructured 500. } }; - const idempotencyKey = c.req.header('Idempotency-Key')?.trim(); + const idempotencyKey = c.req.header('Idempotency-Key'); if (!idempotencyKey) return c.json({ error: 'Idempotency-Key header is required' }, 400); + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(idempotencyKey)) { + return c.json({ + error: `Idempotency-Key must be 1-${MAX_IDEMPOTENCY_KEY_CHARACTERS} canonical ASCII characters`, + code: 'IDEMPOTENCY_KEY_INVALID', + }, 400); + } + const requestNowMs = now(); + if (!Number.isSafeInteger(requestNowMs) || requestNowMs < 0) { + return c.json({ error: 'trusted offer clock returned an invalid time' }, 503); + } + for (const [key, offer] of pendingOffers) { + if (offer.expiresAtMs <= requestNowMs) pendingOffers.delete(key); + } const paymentHeader = c.req.header('X-PAYMENT'); let requestBodyBytes; try { @@ -261,7 +312,23 @@ export function x402Paywall({ .update(requestBodyBytes) .digest('hex')}`; - let frozen = frozenOffers.get(idempotencyKey) ?? null; + let transientAdmission = false; + const capacityResponse = () => { + const response = c.json({ + error: 'pending x402 offer capacity is exhausted', + code: 'PENDING_OFFER_CAPACITY', + }, 503); + response.headers.set('Retry-After', '1'); + return response; + }; + const unresolvedResponse = (settlementReference = null) => c.json({ + error: 'payment settlement unresolved; trusted reconciliation is required', + settlementReference, + }, 503); + + try { + + let frozen = pendingOffers.get(idempotencyKey) ?? null; if (!frozen) { let recovered = null; try { @@ -273,13 +340,21 @@ export function x402Paywall({ return c.json({ error: 'frozen offer recovery conflicts with authoritative state' }, 409); } if (recovered) { - if (!exactPlainObject(recovered, ['requirements', 'executionQuote'])) { + if (!exactPlainObject(recovered, [ + 'requirements', 'executionQuote', 'verificationRequired', 'verifiedPaymentHash', + ]) || typeof recovered.verificationRequired !== 'boolean' + || (recovered.verifiedPaymentHash !== null + && !/^sha256:[0-9a-f]{64}$/.test(recovered.verifiedPaymentHash)) + || recovered.verificationRequired !== (recovered.verifiedPaymentHash === null)) { return c.json({ error: 'persisted frozen offer has an unsupported schema' }, 409); } frozen = structuredClone(recovered); - frozenOffers.set(idempotencyKey, frozen); } } + if (frozen && (paymentHeader || !pendingOffers.has(idempotencyKey))) { + if (!reserveTransientAdmission(idempotencyKey)) return capacityResponse(); + transientAdmission = true; + } let requirements = frozen?.requirements ?? null; let executionQuote = frozen?.executionQuote ?? null; if (requirements) { @@ -287,7 +362,10 @@ export function x402Paywall({ return c.json({ error: 'Idempotency-Key already binds a different request' }, 409); } } else { + if (locallyUnresolvedSettlements.has(idempotencyKey)) return unresolvedResponse(); if (paymentHeader) return c.json({ error: 'paid retry has no prior frozen x402 offer' }, 409); + if (!reserveTransientAdmission(idempotencyKey)) return capacityResponse(); + transientAdmission = true; try { executionQuote = quote ? structuredClone(await quote(c)) : null; } catch (error) { @@ -315,8 +393,9 @@ export function x402Paywall({ const amountAtomic = executionQuote == null ? usdcToAtomic(priceUsdc) : executionQuote.grossAtomic; - const issuedAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() + 60_000).toISOString(); + const issuedAt = new Date(requestNowMs).toISOString(); + const expiresAtMs = requestNowMs + pendingOfferTtlMs; + const expiresAt = new Date(expiresAtMs).toISOString(); const base = { scheme: 'exact', network: NETWORK, @@ -342,11 +421,18 @@ export function x402Paywall({ expiresAt, }, }; - frozen = { requirements, executionQuote }; - frozenOffers.set(idempotencyKey, structuredClone(frozen)); + frozen = { + requirements, + executionQuote, + expiresAtMs, + verificationRequired: true, + verifiedPaymentHash: null, + }; + pendingOffers.set(idempotencyKey, structuredClone(frozen)); } if (!paymentHeader) { + if (locallyUnresolvedSettlements.has(idempotencyKey)) return unresolvedResponse(); try { await lifecycle.onOffered?.({ idempotencyKey, @@ -379,7 +465,6 @@ export function x402Paywall({ try { authorization = validateAuthorizationEnvelope(paymentPayload, requirements); } catch (error) { - await lifecycle.onRejected?.({ idempotencyKey, reason: error.message }); return c.json({ x402Version: X402_VERSION, error: error.message, @@ -388,6 +473,49 @@ export function x402Paywall({ } const settlementReference = authorization.nonce.toLowerCase(); const payer = authorization.from.toLowerCase(); + const paymentHash = `sha256:${crypto.createHash('sha256').update(paymentHeader).digest('hex')}`; + const facilitatorBody = { + x402Version: X402_VERSION, + paymentPayload, + paymentRequirements: requirements, + }; + let facilitatorStarted = null; + if (frozen.verifiedPaymentHash !== null && frozen.verifiedPaymentHash !== paymentHash) { + return c.json({ + error: 'paid retry does not match the facilitator-verified payment authorization', + code: 'VERIFIED_PAYMENT_MISMATCH', + }, 409); + } + let paymentVerified = frozen.verifiedPaymentHash === paymentHash; + const verifyPayment = async () => { + facilitatorStarted ??= performance.now(); + try { + return await postJson(transport, 'verify', facilitatorBody, { + signal: c.req.raw.signal, + timeoutMs: facilitatorTimeoutMs, + maxResponseBytes: facilitatorResponseMaxBytes, + }); + } catch { + return null; + } + }; + if (frozen.verificationRequired && !paymentVerified) { + const verify = await verifyPayment(); + if (verify === null) { + return c.json({ error: 'payment verification unresolved', settlementReference }, 503); + } + if (!verify?.isValid) { + return c.json({ + x402Version: X402_VERSION, + error: 'payment verification failed', + accepts: [requirements], + }, 402); + } + paymentVerified = true; + if (pendingOffers.get(idempotencyKey) === frozen) { + frozen.verifiedPaymentHash = paymentHash; + } + } let priorDecision = null; try { priorDecision = await lifecycle.onSigned?.({ @@ -396,23 +524,30 @@ export function x402Paywall({ payer, requirements: structuredClone(requirements), executionQuote: structuredClone(executionQuote), + verifiedPaymentHash: paymentHash, }); } catch { return c.json({ error: 'paid retry conflicts with authoritative Invocation state' }, 409); } + const authoritativeSigned = priorDecision?.kind === 'signed'; + const retainLocalUnresolved = () => { + // This key already owns either a pending or transient admission, so + // converting it to unresolved cannot raise the hard unique-key count. + locallyUnresolvedSettlements.add(idempotencyKey); + if (authoritativeSigned) pendingOffers.delete(idempotencyKey); + }; if (locallyUnresolvedSettlements.has(idempotencyKey) && !['terminal', 'settled'].includes(priorDecision?.kind)) { - return c.json({ - error: 'payment settlement unresolved; trusted reconciliation is required', - settlementReference, - }, 503); + return unresolvedResponse(settlementReference); } if (priorDecision?.kind === 'terminal') { if (!terminalReplayIsTrusted(priorDecision, payer)) { return c.json({ error: 'terminal replay lacks a settled or refunded transaction' }, 503); } + locallyUnresolvedSettlements.delete(idempotencyKey); + pendingOffers.delete(idempotencyKey); const body = { replayed: true, receipt: priorDecision.receipt, @@ -443,11 +578,24 @@ export function x402Paywall({ }, 503); } - const facilitatorBody = { - x402Version: X402_VERSION, - paymentPayload, - paymentRequirements: requirements, - }; + try { + await lifecycle.beforeSettlement?.({ + context: c, + idempotencyKey, + settlementReference, + payer, + requirements: structuredClone(requirements), + executionQuote: structuredClone(executionQuote), + verifiedPaymentHash: paymentHash, + }); + } catch (error) { + const known = ['PROVIDER_SPEND_CAP', 'PROVIDER_ATTEMPT_IN_PROGRESS'].includes(error?.code); + return c.json({ + error: known ? error.message : 'pre-settlement authorization failed', + code: known ? error.code : 'PRE_SETTLEMENT_AUTHORIZATION', + }, 503); + } + let settle; let facilitatorMs = 0; if (priorDecision?.kind === 'settled') { @@ -455,6 +603,8 @@ export function x402Paywall({ || String(priorDecision.payer ?? '').toLowerCase() !== payer) { return c.json({ error: 'persisted settlement proof does not match the signed payer' }, 503); } + locallyUnresolvedSettlements.delete(idempotencyKey); + pendingOffers.delete(idempotencyKey); settle = { success: true, transaction: priorDecision.txHash, @@ -462,13 +612,18 @@ export function x402Paywall({ network: NETWORK, }; } else { - const started = performance.now(); - try { - const verify = await postJson(transport, 'verify', facilitatorBody, { - signal: c.req.raw.signal, - timeoutMs: facilitatorTimeoutMs, - maxResponseBytes: facilitatorResponseMaxBytes, - }); + if (!paymentVerified) { + const verify = await verifyPayment(); + if (verify === null) { + retainLocalUnresolved(); + await notifyUnresolved({ + idempotencyKey, + settlementReference, + payer, + reason: 'facilitator verification unresolved', + }); + return c.json({ error: 'payment verification unresolved', settlementReference }, 503); + } if (!verify?.isValid) { const reason = 'payment verification failed'; await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); @@ -478,13 +633,17 @@ export function x402Paywall({ accepts: [requirements], }, 402); } + paymentVerified = true; + } + try { + facilitatorStarted ??= performance.now(); settle = await postJson(transport, 'settle', facilitatorBody, { signal: c.req.raw.signal, timeoutMs: facilitatorTimeoutMs, maxResponseBytes: facilitatorResponseMaxBytes, }); } catch { - locallyUnresolvedSettlements.add(idempotencyKey); + retainLocalUnresolved(); await notifyUnresolved({ idempotencyKey, settlementReference, @@ -493,11 +652,11 @@ export function x402Paywall({ }); return c.json({ error: 'payment settlement unresolved', settlementReference }, 503); } - facilitatorMs = performance.now() - started; + facilitatorMs = performance.now() - facilitatorStarted; } if (settle?.success !== true) { if (settle?.success !== false) { - locallyUnresolvedSettlements.add(idempotencyKey); + retainLocalUnresolved(); await notifyUnresolved({ idempotencyKey, settlementReference, @@ -508,6 +667,7 @@ export function x402Paywall({ } const reason = 'payment settlement failed'; await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); + if (authoritativeSigned) pendingOffers.delete(idempotencyKey); return c.json({ x402Version: X402_VERSION, error: reason, @@ -519,7 +679,7 @@ export function x402Paywall({ if (!validTxHash(settledTxHash) || settledPayer !== payer || settle.network !== NETWORK) { - locallyUnresolvedSettlements.add(idempotencyKey); + retainLocalUnresolved(); await notifyUnresolved({ idempotencyKey, settlementReference, @@ -543,7 +703,7 @@ export function x402Paywall({ executionQuote: structuredClone(executionQuote), }); } catch { - locallyUnresolvedSettlements.add(idempotencyKey); + retainLocalUnresolved(); await notifyUnresolved({ idempotencyKey, settlementReference, @@ -555,6 +715,7 @@ export function x402Paywall({ settlementReference, }, 503); } + if (authoritativeSigned) pendingOffers.delete(idempotencyKey); } c.set('x402', { @@ -576,6 +737,9 @@ export function x402Paywall({ settlementReference, }))); c.res.headers.set('X-402-FACILITATOR-MS', facilitatorMs.toFixed(1)); + } finally { + if (transientAdmission) transientAdmissions.delete(idempotencyKey); + } }; } diff --git a/spikes/pi-wielder/tests/collar-cogs.test.mjs b/spikes/pi-wielder/tests/collar-cogs.test.mjs index 9a0f58e..c7c83d3 100644 --- a/spikes/pi-wielder/tests/collar-cogs.test.mjs +++ b/spikes/pi-wielder/tests/collar-cogs.test.mjs @@ -5,6 +5,8 @@ import test from 'node:test'; import { createAnthropicExecutor, createCollar, + DEFAULT_PROVIDER_RESPONSE_BYTES, + DEFAULT_PROVIDER_TIMEOUT_MS, SKILL_ID, } from '../src/collar.mjs'; import { @@ -406,13 +408,10 @@ test('Anthropic adapter sends frozen model/cap, emits strict v2 usage, and rejec apiKey: 'test-only', fetchImpl: async (_url, init) => { requests.push(JSON.parse(init.body)); - return { - ok: true, - json: async () => ({ + return new Response(JSON.stringify({ content: [{ text: 'ok' }], usage: { input_tokens: 11, output_tokens: 2 }, - }), - }; + }), { status: 200, headers: { 'content-type': 'application/json' } }); }, }); const frozen = { model: 'claude-sonnet-4-6', maxInputTokens: 300, maxOutputTokens: 17 }; @@ -431,6 +430,63 @@ test('Anthropic adapter sends frozen model/cap, emits strict v2 usage, and rejec assert.throws(() => createAnthropicExecutor({ apiKey: '', fetchImpl: async () => {} }), /API key/); }); +test('Anthropic constructor options cannot raise secure byte or deadline ceilings', () => { + for (const [option, value, ceiling] of [ + ['timeoutMs', DEFAULT_PROVIDER_TIMEOUT_MS + 1, DEFAULT_PROVIDER_TIMEOUT_MS], + ['maxResponseBytes', DEFAULT_PROVIDER_RESPONSE_BYTES + 1, DEFAULT_PROVIDER_RESPONSE_BYTES], + ]) { + assert.throws( + () => createAnthropicExecutor({ + apiKey: 'test-only', fetchImpl: async () => {}, [option]: value, + }), + new RegExp(`Anthropic ${option} cannot exceed ${ceiling}`), + ); + } +}); + +test('Collar constructor cannot raise the outer provider deadline ceiling', () => { + const facilitatorTransport = createMockFacilitatorTransport(async () => { + throw new Error('facilitator must not run during construction'); + }); + assert.throws( + () => createCollar({ + facilitatorTransport, + providerTimeoutMs: DEFAULT_PROVIDER_TIMEOUT_MS + 1, + }), + new RegExp(`providerTimeoutMs cannot exceed ${DEFAULT_PROVIDER_TIMEOUT_MS}`), + ); +}); + +test('Anthropic executor rejects a JSON-only response double without calling its parser', async () => { + let jsonCalls = 0; + const executor = createAnthropicExecutor({ + apiKey: 'test-only', + fetchImpl: async () => ({ + ok: true, + async json() { + jsonCalls += 1; + return { + content: [{ text: 'must not be trusted' }], + usage: { input_tokens: 11, output_tokens: 2 }, + }; + }, + }), + }); + await assert.rejects(() => executor({ + skillContent: 'system', + input: 'hello', + model: 'claude-sonnet-4-6', + maxInputTokens: 300, + maxOutputTokens: 17, + promptBytes: 11, + estimatedInputTokens: 267, + }), (error) => ( + error.code === 'UPSTREAM_PROVIDER_RESPONSE_SHAPE' + && error.message === 'provider response must expose a bounded byte stream' + )); + assert.equal(jsonCalls, 0); +}); + test('body, complete-prompt, model, and token caps reject before a 402 offer', async () => { for (const [body, status, code] of [ [{ input: 'x'.repeat(4097) }, 413, 'REQUEST_BODY_TOO_LARGE'], diff --git a/spikes/pi-wielder/tests/collar-failure.test.mjs b/spikes/pi-wielder/tests/collar-failure.test.mjs index b4a1140..346ec3f 100644 --- a/spikes/pi-wielder/tests/collar-failure.test.mjs +++ b/spikes/pi-wielder/tests/collar-failure.test.mjs @@ -155,6 +155,163 @@ test('standalone selection defaults to injected offline mock and live mode is ex assert.equal(live.transport.mode, 'live'); }); +test('unpaid and unverified offers never grow authority while a verified retry persists once', async () => { + const facilitator = createMockFacilitator(); + let verifyCalls = 0; + let settleCalls = 0; + let executions = 0; + const collar = createCollar({ + facilitatorTransport: createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname; + if (operation === '/verify') verifyCalls += 1; + if (operation === '/settle') settleCalls += 1; + return facilitator.request(url, init); + }), + executeSkill: async () => { + executions += 1; + return { output: 'verified output', usage: KNOWN_USAGE }; + }, + }); + const idempotencyKey = 'idem-unpaid-authority-boundary'; + let sellerRequests = 0; + const signed = await withheldPayingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey, + fetchImpl: async (url, init) => { + sellerRequests += 1; + if (sellerRequests === 1) { + const response = await collar.app.request(url, init); + assert.equal(response.status, 402); + assert.deepEqual(collar.journal.events, []); + return response; + } + return new Response(JSON.stringify({ error: 'withheld paid retry' }), { + status: 503, headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const invalid = JSON.parse(Buffer.from(signed.xPayment, 'base64').toString('utf8')); + const forgedPayer = throwawayAccount().address.toLowerCase(); + assert.notEqual(forgedPayer, invalid.payload.authorization.from); + invalid.payload.authorization.from = forgedPayer; + const invalidResponse = await collar.app.request(invokeUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': Buffer.from(JSON.stringify(invalid)).toString('base64'), + }, + body: requestBody, + }); + assert.equal(invalidResponse.status, 402); + assert.deepEqual(collar.journal.events, []); + assert.equal(verifyCalls, 1); + assert.equal(settleCalls, 0); + assert.equal(executions, 0); + + const verifiedResponse = await collar.app.request(invokeUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': signed.xPayment, + }, + body: requestBody, + }); + assert.equal(verifiedResponse.status, 200); + assert.deepEqual(collar.journal.events.slice(0, 4).map((event) => event.type), [ + 'invocation.requested', + 'payment.offered', + 'payment.authorization_verified', + 'payment.signed', + ]); + assert.equal(verifyCalls, 2); + assert.equal(settleCalls, 1); + assert.equal(executions, 1); +}); + +test('restart before verification fails closed without facilitator, execution, or journal growth', async () => { + let facilitatorCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async () => { + facilitatorCalls += 1; + throw new Error('facilitator must not run for an orphaned paid retry'); + }); + const beforeRestart = createCollar({ + facilitatorTransport: transport, + executeSkill: async () => { throw new Error('must not execute'); }, + }); + const idempotencyKey = 'idem-restart-before-verification'; + let requests = 0; + const signed = await withheldPayingFetch(throwawayAccount(), invokeUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: requestBody, + }, { + idempotencyKey, + fetchImpl: async (url, init) => { + requests += 1; + if (requests === 1) return beforeRestart.app.request(url, init); + return new Response(JSON.stringify({ error: 'simulated process stop' }), { + status: 503, headers: { 'content-type': 'application/json' }, + }); + }, + }); + assert.deepEqual(beforeRestart.journal.events, []); + + const afterRestart = createCollar({ + facilitatorTransport: transport, + journal: beforeRestart.journal, + executeSkill: async () => { + executions += 1; + return { output: 'must not run', usage: KNOWN_USAGE }; + }, + }); + const retry = await afterRestart.app.request(invokeUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'X-PAYMENT': signed.xPayment, + }, + body: requestBody, + }); + assert.equal(retry.status, 409); + assert.deepEqual(await retry.json(), { error: 'paid retry has no prior frozen x402 offer' }); + assert.equal(facilitatorCalls, 0); + assert.equal(executions, 0); + assert.deepEqual(afterRestart.journal.events, []); +}); + +test('durable terminal replay requires the exact facilitator-verified payment header', async () => { + const { collar, result } = await invokeSettledFailure(); + let facilitatorCalls = 0; + const restarted = createCollar({ + journal: collar.journal, + facilitatorTransport: createMockFacilitatorTransport(async () => { + facilitatorCalls += 1; + throw new Error('a conflicting durable replay must fail before facilitator I/O'); + }), + executeSkill: async () => { + throw new Error('a conflicting durable replay must not execute'); + }, + }); + const forged = JSON.parse(Buffer.from(result.xPayment, 'base64').toString('utf8')); + const signature = forged.payload.signature; + forged.payload.signature = `${signature.slice(0, -1)}${signature.endsWith('0') ? '1' : '0'}`; + const response = await restarted.app.request(invokeUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Idempotency-Key': result.idempotencyKey, + 'X-PAYMENT': Buffer.from(JSON.stringify(forged)).toString('base64'), + }, + body: requestBody, + }); + assert.equal(response.status, 409); + assert.equal(facilitatorCalls, 0); +}); + test('live settlement refuses ephemeral authority and accepts only paired persistent paths', () => { const live = createLiveFacilitatorTransport(APPROVED_LIVE_FACILITATOR_BASE, async () => { throw new Error('network must not run'); @@ -745,7 +902,7 @@ test('Skill provider and settlement resolver secrets are replaced with stable pu assert.doesNotMatch(await resolution.text(), new RegExp(resolverSecret)); }); -test('facilitator verification detail is absent from the response and durable journal', async () => { +test('failed facilitator verification creates no authority and leaks no detail', async () => { const secret = 'verify-invalidReason-secret-sentinel'; let settleCalls = 0; let executionCalls = 0; @@ -778,11 +935,11 @@ test('facilitator verification detail is absent from the response and durable jo }), (error) => error.code === 'SECOND_PAYMENT_REQUIRED' && !error.message.includes(secret)); const record = collar.journal.getByIdempotencyKey('idem-verifier-secret'); - assert.equal(record.payment.state, 'rejected'); - assert.equal(record.payment.reason, 'payment verification failed'); - const durableBytes = fs.readFileSync(journalFile, 'utf8'); + assert.equal(record, null); + assert.deepEqual(collar.journal.events, []); + const durableBytes = fs.existsSync(journalFile) ? fs.readFileSync(journalFile, 'utf8') : ''; assert.doesNotMatch(durableBytes, new RegExp(secret)); - assert.match(durableBytes, /payment verification failed/); + assert.equal(durableBytes, ''); assert.equal(settleCalls, 0); assert.equal(executionCalls, 0); }); diff --git a/spikes/pi-wielder/tests/gateway-transport.test.mjs b/spikes/pi-wielder/tests/gateway-transport.test.mjs index cbe45cd..cca02cd 100644 --- a/spikes/pi-wielder/tests/gateway-transport.test.mjs +++ b/spikes/pi-wielder/tests/gateway-transport.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import { createMockFacilitator } from '../src/facilitator-mock.mjs'; import { createGateway, MODEL_PRICES_USDC, startGateway } from '../src/gateway.mjs'; +import { catalogDigest } from '../src/execution-economics.mjs'; import { payingFetch as policyPayingFetch } from '../src/proxy.mjs'; import { throwawayAccount } from '../src/wallet.mjs'; import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; @@ -13,6 +14,112 @@ const payingFetch = (account, url, init, options = {}) => policyPayingFetch(acco ...options, }); +const GATEWAY_URL = 'http://gateway.test/v1/chat/completions'; +const PROVIDER_RESPONSE_LIMIT = 1024 * 1024; +const HUMAN_VERIFIED_CATALOG = Object.freeze({ + schemaVersion: 2, + version: 'gateway-human-verified-test-v1', + evidenceLabel: 'human_verified', + source: 'https://provider.example/pricing/2026-07-18', + asOf: '2026-07-18T00:00:00.000Z', + models: Object.freeze({ + 'claude-sonnet-4-6': Object.freeze({ + provider: 'anthropic', + inputAtomicPerMillionTokens: '1000000', + outputAtomicPerMillionTokens: '2000000', + maxInputTokens: 4096, + maxOutputTokens: 64, + }), + 'gpt-5.2': Object.freeze({ + provider: 'openai', + inputAtomicPerMillionTokens: '1000000', + outputAtomicPerMillionTokens: '2000000', + maxInputTokens: 4096, + maxOutputTokens: 64, + }), + }), +}); +const LIVE_APPROVAL = Object.freeze({ + catalogDigest: catalogDigest(HUMAN_VERIFIED_CATALOG), + spendCapAtomic: '5000', +}); +const SYNTHETIC_PROVIDER_KEYS = Object.freeze({ + anthropic: 'synthetic-anthropic-test-key', + openai: 'synthetic-openai-test-key', +}); + +function facilitatorTransport() { + const facilitator = createMockFacilitator(); + return createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); +} + +function liveGatewayOptions(providerFetch, overrides = {}) { + return { + facilitatorTransport: facilitatorTransport(), + mockLlm: false, + allowLiveProvider: true, + providerCatalog: structuredClone(HUMAN_VERIFIED_CATALOG), + liveApproval: { ...LIVE_APPROVAL }, + providerFetch, + providerApiKeys: { ...SYNTHETIC_PROVIDER_KEYS }, + providerTimeoutMs: 1_000, + maxProviderResponseBytes: PROVIDER_RESPONSE_LIMIT, + ...overrides, + }; +} + +function openAiCompletion({ promptTokens = 2, completionTokens = 3 } = {}) { + return { + id: 'chatcmpl-test', + object: 'chat.completion', + created: 1, + model: 'gpt-5.2', + choices: [{ + index: 0, + message: { role: 'assistant', content: 'provider output' }, + finish_reason: 'stop', + }], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }; +} + +async function paidGatewayRequest(gateway, body, idempotencyKey) { + return payingFetch(throwawayAccount(), GATEWAY_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, { + fetchImpl: (url, init) => gateway.request(url, init), + idempotencyKey, + }); +} + +async function withSyntheticGlobalProvider(providerFetch, operation, { unsetMock = false } = {}) { + const originalFetch = globalThis.fetch; + const originalOpenAiKey = process.env.OPENAI_API_KEY; + const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; + const originalMock = process.env.MOCK_LLM; + globalThis.fetch = providerFetch; + process.env.OPENAI_API_KEY = SYNTHETIC_PROVIDER_KEYS.openai; + process.env.ANTHROPIC_API_KEY = SYNTHETIC_PROVIDER_KEYS.anthropic; + if (unsetMock) delete process.env.MOCK_LLM; + try { + return await operation(); + } finally { + globalThis.fetch = originalFetch; + if (originalOpenAiKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = originalOpenAiKey; + if (originalAnthropicKey === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = originalAnthropicKey; + if (originalMock === undefined) delete process.env.MOCK_LLM; + else process.env.MOCK_LLM = originalMock; + } +} + test('gateway prices are decimal strings and the injected transport stays in process', async () => { assert.ok(Object.values(MODEL_PRICES_USDC).every((price) => typeof price === 'string')); const facilitator = createMockFacilitator(); @@ -39,6 +146,318 @@ test('gateway rejects an unapproved structural transport or legacy facilitator U assert.throws(() => createGateway({ facilitatorUrl: 'https://evil.test', mockLlm: true }), /facilitatorTransport/); }); +test('gateway defaults to mock execution and never calls a provider when MOCK_LLM is unset', async () => { + let providerCalls = 0; + const providerFetch = async () => { + providerCalls += 1; + return Response.json(openAiCompletion()); + }; + await withSyntheticGlobalProvider(providerFetch, async () => { + const gateway = createGateway({ facilitatorTransport: facilitatorTransport() }); + const paid = await paidGatewayRequest(gateway, { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'stay offline' }], + max_tokens: 8, + }, 'idem-gateway-default-mock'); + assert.equal(paid.res.status, 200); + assert.equal(providerCalls, 0); + const body = await paid.res.json(); + assert.match(body.choices[0].message.content, /^\[mock gpt-5\.2\]/); + }, { unsetMock: true }); +}); + +test('live gateway construction requires the explicit gate, human catalog digest, and provider spend cap', () => { + let providerCalls = 0; + const providerFetch = async () => { + providerCalls += 1; + throw new Error('must not fetch during approval'); + }; + assert.throws(() => createGateway(liveGatewayOptions(providerFetch, { + allowLiveProvider: false, + })), /explicit live provider gate/i); + for (const nonBooleanGate of [1, 'true', {}, []]) { + assert.throws(() => createGateway(liveGatewayOptions(providerFetch, { + allowLiveProvider: nonBooleanGate, + })), /explicit live provider gate/i); + } + assert.throws(() => createGateway(liveGatewayOptions(providerFetch, { + liveApproval: null, + })), /live approval/i); + assert.throws(() => createGateway(liveGatewayOptions(providerFetch, { + liveApproval: { ...LIVE_APPROVAL, catalogDigest: `sha256:${'0'.repeat(64)}` }, + })), /digest/i); + assert.throws(() => createGateway(liveGatewayOptions(providerFetch, { + liveApproval: { ...LIVE_APPROVAL, spendCapAtomic: '4159' }, + })), /spend cap/i); + assert.throws(() => createGateway(liveGatewayOptions(providerFetch, { + providerCatalog: { + ...structuredClone(HUMAN_VERIFIED_CATALOG), + evidenceLabel: 'synthetic_config', + source: null, + asOf: null, + }, + })), /human_verified/i); + assert.throws(() => createGateway(liveGatewayOptions(providerFetch, { + maxProviderResponseBytes: PROVIDER_RESPONSE_LIMIT + 1, + })), /must not exceed one MiB/i); + assert.throws(() => createGateway(liveGatewayOptions(providerFetch, { + providerTimeoutMs: 30_001, + })), /must not exceed 30000 milliseconds/i); + assert.equal(providerCalls, 0); +}); + +test('gateway rejects unknown models and request token bounds before offering payment', async () => { + let providerCalls = 0; + const gateway = createGateway(liveGatewayOptions(async () => { + providerCalls += 1; + return Response.json(openAiCompletion()); + })); + const framingAllowanceCase = { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'x'.repeat(3_200) }], + max_tokens: 8, + }; + const framingAllowanceBytes = Buffer.byteLength(JSON.stringify(framingAllowanceCase)); + assert.ok(framingAllowanceBytes < HUMAN_VERIFIED_CATALOG.models['gpt-5.2'].maxInputTokens); + assert.ok(framingAllowanceBytes + 1_024 > HUMAN_VERIFIED_CATALOG.models['gpt-5.2'].maxInputTokens); + const invalidBodies = [ + { model: 'attacker-model', messages: [], max_tokens: 8 }, + { model: 'gpt-5.2', messages: [], max_tokens: 65 }, + { model: 'gpt-5.2', messages: [], max_tokens: 8, max_completion_tokens: 8 }, + { model: 'gpt-5.2', messages: [{ role: 'user', content: 'x'.repeat(5000) }], max_tokens: 8 }, + framingAllowanceCase, + ]; + for (let index = 0; index < invalidBodies.length; index += 1) { + const response = await gateway.request(GATEWAY_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': `idem-gateway-invalid-${index}`, + }, + body: JSON.stringify(invalidBodies[index]), + }); + assert.equal(response.status, 400, `invalid case ${index}`); + assert.match((await response.json()).code, /MODEL_NOT_ALLOWED|TOKEN_LIMIT|PROMPT_TOKEN_BOUND/); + } + assert.equal(providerCalls, 0); +}); + +test('approved provider fetch receives redirect refusal and a composed request signal', async () => { + let capturedUrl = null; + let capturedInit = null; + const providerFetch = async (url, init) => { + capturedUrl = url; + capturedInit = init; + return Response.json(openAiCompletion()); + }; + await withSyntheticGlobalProvider(providerFetch, async () => { + const gateway = createGateway(liveGatewayOptions(providerFetch)); + const paid = await paidGatewayRequest(gateway, { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'bounded request' }], + max_tokens: 8, + }, 'idem-gateway-approved-live'); + assert.equal(paid.res.status, 200); + assert.equal(capturedUrl, 'https://api.openai.com/v1/chat/completions'); + assert.equal(capturedInit.redirect, 'error'); + assert.ok(capturedInit.signal instanceof AbortSignal); + assert.equal(capturedInit.signal.aborted, false); + }); +}); + +test('live provider spend approval is a cumulative process-run budget', async () => { + let providerCalls = 0; + const providerFetch = async () => { + providerCalls += 1; + return Response.json(openAiCompletion({ promptTokens: 998, completionTokens: 1 })); + }; + const gateway = createGateway(liveGatewayOptions(providerFetch)); + const body = { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'bounded cumulative spend' }], + max_tokens: 8, + }; + + const first = await paidGatewayRequest(gateway, body, 'idem-gateway-spend-first'); + assert.equal(first.res.status, 200); + assert.equal(providerCalls, 1); + + const second = await gateway.request(GATEWAY_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': 'idem-gateway-spend-second', + }, + body: JSON.stringify(body), + }); + assert.equal(second.status, 503); + assert.deepEqual(await second.json(), { + error: 'live provider spend budget cannot cover this request', + code: 'PROVIDER_SPEND_CAP', + }); + assert.equal(providerCalls, 1); +}); + +test('concurrent paid retries reserve provider budget before facilitator settlement', async () => { + const facilitator = createMockFacilitator(); + let verifyCalls = 0; + let settleCalls = 0; + let releaseVerifiers; + const bothVerifiersReady = new Promise((resolve) => { releaseVerifiers = resolve; }); + const transport = createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname; + if (operation === '/verify') { + verifyCalls += 1; + if (verifyCalls === 2) releaseVerifiers(); + await bothVerifiersReady; + } + if (operation === '/settle') settleCalls += 1; + return facilitator.request(url, init); + }); + let providerCalls = 0; + const providerFetch = async () => { + providerCalls += 1; + return Response.json(openAiCompletion({ promptTokens: 998, completionTokens: 1 })); + }; + const gateway = createGateway(liveGatewayOptions(providerFetch, { + facilitatorTransport: transport, + })); + const body = { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'concurrent budget claim' }], + max_tokens: 8, + }; + const outcomes = await Promise.allSettled([ + paidGatewayRequest(gateway, body, 'idem-gateway-concurrent-spend-a'), + paidGatewayRequest(gateway, body, 'idem-gateway-concurrent-spend-b'), + ]); + const fulfilled = outcomes.filter((outcome) => outcome.status === 'fulfilled'); + const rejected = outcomes.filter((outcome) => outcome.status === 'rejected'); + assert.equal(fulfilled.length, 1); + assert.equal(fulfilled[0].value.res.status, 200); + assert.equal(rejected.length, 1); + assert.equal(rejected[0].reason.code, 'SETTLEMENT_EVIDENCE'); + assert.equal(verifyCalls, 2); + assert.equal(settleCalls, 1); + assert.equal(providerCalls, 1); +}); + +test('provider wall-clock deadline aborts an ignoring fetch and returns only a stable error', async () => { + let providerSignal = null; + let providerCalls = 0; + const providerFetch = async (_url, init) => { + providerCalls += 1; + providerSignal = init.signal; + return new Promise((resolve) => { + setTimeout(() => resolve(Response.json(openAiCompletion())), 50); + }); + }; + await withSyntheticGlobalProvider(providerFetch, async () => { + const gateway = createGateway(liveGatewayOptions(providerFetch, { providerTimeoutMs: 5 })); + const started = performance.now(); + const paid = await paidGatewayRequest(gateway, { + model: 'gpt-5.2', messages: [], max_tokens: 8, + }, 'idem-gateway-provider-timeout'); + assert.ok(performance.now() - started < 45); + assert.equal(paid.res.status, 504); + assert.equal(providerSignal.aborted, true); + assert.deepEqual(await paid.res.json(), { + error: 'upstream provider timed out', + code: 'UPSTREAM_PROVIDER_TIMEOUT', + }); + const nextOffer = await gateway.request(GATEWAY_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': 'idem-gateway-after-provider-timeout', + }, + body: JSON.stringify({ model: 'gpt-5.2', messages: [], max_tokens: 8 }), + }); + assert.equal(nextOffer.status, 503); + assert.deepEqual(await nextOffer.json(), { + error: 'live provider spend budget cannot cover this request', + code: 'PROVIDER_SPEND_CAP', + }); + assert.equal(providerCalls, 1); + }); +}); + +test('provider response is cancelled on the first streamed byte over one MiB', async () => { + let cancelled = false; + const providerFetch = async () => ({ + ok: true, + status: 200, + headers: new Headers(), + body: new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.alloc(PROVIDER_RESPONSE_LIMIT, 0x20)); + controller.enqueue(Buffer.from('{}')); + }, + cancel() { cancelled = true; }, + }), + async json() { return {}; }, + }); + await withSyntheticGlobalProvider(providerFetch, async () => { + const gateway = createGateway(liveGatewayOptions(providerFetch)); + const paid = await paidGatewayRequest(gateway, { + model: 'gpt-5.2', messages: [], max_tokens: 8, + }, 'idem-gateway-provider-oversize'); + assert.equal(paid.res.status, 502); + assert.equal(cancelled, true); + assert.deepEqual(await paid.res.json(), { + error: 'upstream provider response exceeds the byte limit', + code: 'UPSTREAM_PROVIDER_RESPONSE_TOO_LARGE', + }); + }); +}); + +test('provider HTTP errors cancel without consuming or exposing the raw response body', async () => { + let textCalls = 0; + let cancelled = false; + const providerFetch = async () => ({ + ok: false, + status: 429, + headers: new Headers({ 'content-type': 'application/json' }), + body: new ReadableStream({ + cancel() { cancelled = true; }, + }), + async text() { + textCalls += 1; + return 'provider-secret-response-body'; + }, + }); + await withSyntheticGlobalProvider(providerFetch, async () => { + const gateway = createGateway(liveGatewayOptions(providerFetch)); + const paid = await paidGatewayRequest(gateway, { + model: 'gpt-5.2', messages: [], max_tokens: 8, + }, 'idem-gateway-provider-http-error'); + assert.equal(paid.res.status, 502); + assert.equal(textCalls, 0); + assert.equal(cancelled, true); + const body = await paid.res.json(); + assert.deepEqual(body, { + error: 'upstream provider request failed', + code: 'UPSTREAM_PROVIDER_HTTP', + }); + assert.doesNotMatch(JSON.stringify(body), /provider-secret/); + }); +}); + +test('provider usage outside the approved request bounds is rejected without output', async () => { + const providerFetch = async () => Response.json(openAiCompletion({ promptTokens: 4097 })); + await withSyntheticGlobalProvider(providerFetch, async () => { + const gateway = createGateway(liveGatewayOptions(providerFetch)); + const paid = await paidGatewayRequest(gateway, { + model: 'gpt-5.2', messages: [], max_tokens: 8, + }, 'idem-gateway-provider-usage-overrun'); + assert.equal(paid.res.status, 502); + assert.deepEqual(await paid.res.json(), { + error: 'upstream provider usage is invalid', + code: 'UPSTREAM_PROVIDER_USAGE', + }); + }); +}); + test('gateway listener binds only IPv4 loopback and closes cleanly', async () => { const facilitator = createMockFacilitator(); const gateway = await startGateway({ diff --git a/spikes/pi-wielder/tests/invocation-journal.test.mjs b/spikes/pi-wielder/tests/invocation-journal.test.mjs index 30beaa4..517ce19 100644 --- a/spikes/pi-wielder/tests/invocation-journal.test.mjs +++ b/spikes/pi-wielder/tests/invocation-journal.test.mjs @@ -387,6 +387,25 @@ test('payment authorization claim distinguishes the first signer from exact retr assert.equal(journal.events.filter((event) => event.type === 'payment.signed').length, 1); }); +test('verified payment header identity is append-only and rejects a conflicting replay', () => { + const journal = fixture(); + offer(journal); + const verifiedPaymentHash = `sha256:${'a'.repeat(64)}`; + journal.recordExternalPaymentVerification(declaration.idempotencyKey, { verifiedPaymentHash }); + journal.recordExternalPaymentVerification(declaration.idempotencyKey, { verifiedPaymentHash }); + assert.equal(journal.getVerifiedPaymentHash(declaration.idempotencyKey), verifiedPaymentHash); + assert.equal( + journal.events.filter((event) => event.type === 'payment.authorization_verified').length, + 1, + ); + assert.throws( + () => journal.recordExternalPaymentVerification(declaration.idempotencyKey, { + verifiedPaymentHash: `sha256:${'b'.repeat(64)}`, + }), + /different verified payment authorization/, + ); +}); + function temporaryAuthority(prefix = 'collar-journal-') { const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); return { diff --git a/spikes/pi-wielder/tests/paying-fetch.test.mjs b/spikes/pi-wielder/tests/paying-fetch.test.mjs index 788aad6..3c94749 100644 --- a/spikes/pi-wielder/tests/paying-fetch.test.mjs +++ b/spikes/pi-wielder/tests/paying-fetch.test.mjs @@ -10,7 +10,11 @@ import { createPaymentPolicy, PaymentPolicyError, } from '../src/payment-policy.mjs'; -import { payingFetch } from '../src/proxy.mjs'; +import { + DEFAULT_PAID_RETRY_TIMEOUT_MS, + DEFAULT_UNPAID_FETCH_TIMEOUT_MS, + payingFetch, +} from '../src/proxy.mjs'; const PAYEE = '0x000000000000000000000000000000000000dead'; const PAYER = '0x1000000000000000000000000000000000000000'; @@ -179,6 +183,65 @@ test('a forbidden first offer is never signed or retried', async () => { assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); }); +test('payingFetch timeout options cannot raise the secure deadline ceilings', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let fetches = 0; + for (const [option, value, ceiling] of [ + ['unpaidTimeoutMs', DEFAULT_UNPAID_FETCH_TIMEOUT_MS + 1, DEFAULT_UNPAID_FETCH_TIMEOUT_MS], + ['paidTimeoutMs', DEFAULT_PAID_RETRY_TIMEOUT_MS + 1, DEFAULT_PAID_RETRY_TIMEOUT_MS], + ]) { + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { + fetches += 1; + return challenge(); + }, + idempotencyKey: `idem-${option}`, + paymentPolicy, + [option]: value, + }), new RegExp(`${option} cannot exceed ${ceiling}`)); + } + assert.equal(fetches, 0); + assert.equal(signatureCount(), 0); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '0'); +}); + +test('payingFetch accepts exactly the seller canonical Idempotency-Key boundary', async () => { + const { account, paymentPolicy } = setup(); + const accepted = `a${'Z9._:-'.repeat(22)}`.slice(0, 128); + let forwardedKey = null; + const result = await payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async (_url, init) => { + forwardedKey = new Headers(init.headers).get('Idempotency-Key'); + return new Response(null, { status: 204 }); + }, + idempotencyKey: accepted, + paymentPolicy, + }); + assert.equal(accepted.length, 128); + assert.equal(forwardedKey, accepted); + assert.equal(result.paid, false); +}); + +test('payingFetch rejects Idempotency-Keys outside the seller canonical grammar', async () => { + const { account, paymentPolicy, signatureCount } = setup(); + let fetches = 0; + for (const idempotencyKey of [ + '.leading-punctuation', + `a${'x'.repeat(128)}`, + ]) { + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async () => { + fetches += 1; + return new Response(null, { status: 204 }); + }, + idempotencyKey, + paymentPolicy, + }), (error) => error.code === 'AUTHORIZATION_ID'); + } + assert.equal(fetches, 0); + assert.equal(signatureCount(), 0); +}); + test('freshness is captured from the injected clock immediately after the first 402', async () => { const { account, paymentPolicy, setClock, signatureCount } = setup(); let fetches = 0; diff --git a/spikes/pi-wielder/tests/proxy-trust.test.mjs b/spikes/pi-wielder/tests/proxy-trust.test.mjs index 5dbc4d5..749f7f4 100644 --- a/spikes/pi-wielder/tests/proxy-trust.test.mjs +++ b/spikes/pi-wielder/tests/proxy-trust.test.mjs @@ -11,6 +11,11 @@ import { canonicalJson, createReceiptSigner, verifySignedReceipt } from '../src/ import { assertReceiptMatchesPayment, createProxy, + DEFAULT_PROXY_MODEL_REQUEST_BYTES, + DEFAULT_PROXY_REQUEST_TIMEOUT_MS, + DEFAULT_PROXY_RESPONSE_TIMEOUT_MS, + DEFAULT_PROXY_SKILL_REQUEST_BYTES, + DEFAULT_PROXY_UPSTREAM_RESPONSE_BYTES, loadPinnedCollarTrust, startProxy, } from '../src/proxy.mjs'; @@ -114,6 +119,26 @@ test('proxy startup accepts only an explicitly pinned public key and matching ke }), /do not match/); }); +test('proxy constructor options cannot raise secure byte or deadline ceilings', () => { + const signer = createReceiptSigner(); + const trust = { + trustedCollarPublicKeyPem: signer.publicKeyPem, + trustedCollarKeyId: signer.keyId, + }; + for (const [option, value, ceiling] of [ + ['maxSkillRequestBytes', DEFAULT_PROXY_SKILL_REQUEST_BYTES + 1, DEFAULT_PROXY_SKILL_REQUEST_BYTES], + ['maxModelRequestBytes', DEFAULT_PROXY_MODEL_REQUEST_BYTES + 1, DEFAULT_PROXY_MODEL_REQUEST_BYTES], + ['maxUpstreamResponseBytes', DEFAULT_PROXY_UPSTREAM_RESPONSE_BYTES + 1, DEFAULT_PROXY_UPSTREAM_RESPONSE_BYTES], + ['proxyRequestTimeoutMs', DEFAULT_PROXY_REQUEST_TIMEOUT_MS + 1, DEFAULT_PROXY_REQUEST_TIMEOUT_MS], + ['proxyResponseTimeoutMs', DEFAULT_PROXY_RESPONSE_TIMEOUT_MS + 1, DEFAULT_PROXY_RESPONSE_TIMEOUT_MS], + ]) { + assert.throws( + () => createProxy({ account: throwawayAccount(), ...trust, [option]: value }), + new RegExp(`${option} cannot exceed ${ceiling}`), + ); + } +}); + test('a valid signature is insufficient when receipt semantics do not match the paid request', () => { const signer = createReceiptSigner(); const valid = signReceipt(signer, receiptFor()); diff --git a/spikes/pi-wielder/tests/runtime-boundaries.test.mjs b/spikes/pi-wielder/tests/runtime-boundaries.test.mjs index a191ede..3dee26b 100644 --- a/spikes/pi-wielder/tests/runtime-boundaries.test.mjs +++ b/spikes/pi-wielder/tests/runtime-boundaries.test.mjs @@ -59,6 +59,60 @@ test('caller abort is composed into the operation without exposing its reason', assert.equal(operationSignal.aborted, true); }); +test('an already-aborted body signal cancels and rejects before starting a stalled read', async () => { + const controller = new AbortController(); + controller.abort(new Error('caller secret must not escape')); + + let cancelled = false; + let readCalls = 0; + let releaseRead; + const stalledRead = new Promise((resolve) => { releaseRead = resolve; }); + const reader = { + async read() { + readCalls += 1; + return stalledRead; + }, + async cancel() { + cancelled = true; + releaseRead({ done: true, value: undefined }); + }, + releaseLock() {}, + }; + const source = { + headers: new Headers(), + body: { + getReader: () => reader, + async cancel() { + cancelled = true; + }, + }, + }; + + const observed = readBodyBytes(source, { + maxBytes: 4, + tooLargeCode: 'TEST_TOO_LARGE', + tooLargeMessage: 'test body too large', + readErrorCode: 'TEST_READ_ERROR', + readErrorMessage: 'test body read failed', + signal: controller.signal, + }).then( + (value) => ({ value }), + (error) => ({ error }), + ); + + await new Promise((resolve) => setImmediate(resolve)); + const cancelledBeforeRelease = cancelled; + const readCallsBeforeRelease = readCalls; + if (!cancelledBeforeRelease) releaseRead({ done: true, value: undefined }); + const result = await observed; + + assert.equal(cancelledBeforeRelease, true); + assert.equal(readCallsBeforeRelease, 0); + assert.ok(result.error instanceof RuntimeBoundaryError); + assert.equal(result.error.code, 'TEST_READ_ERROR'); + assert.equal(result.error.message, 'test body read failed'); +}); + test('streaming byte ceiling accepts exactly the limit and cancels on the first excess chunk', async () => { const exact = new Response(new ReadableStream({ start(controller) { diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs index 07b8c25..68389b1 100644 --- a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -11,6 +11,13 @@ import { APPROVED_LIVE_FACILITATOR_BASE, createLiveFacilitatorTransport, createMockFacilitatorTransport, + DEFAULT_FACILITATOR_RESPONSE_BYTES, + DEFAULT_FACILITATOR_TIMEOUT_MS, + DEFAULT_PENDING_OFFER_LIMIT, + DEFAULT_PENDING_OFFER_TTL_MS, + DEFAULT_X402_REQUEST_BODY_BYTES, + DEFAULT_X402_REQUEST_BODY_TIMEOUT_MS, + MAX_X402_REQUEST_BODY_BYTES, x402Paywall, } from '../src/x402-seller.mjs'; @@ -59,6 +66,9 @@ function resourceApp({ requestBodyTimeoutMs, facilitatorTimeoutMs, facilitatorResponseMaxBytes, + maxPendingOffers, + pendingOfferTtlMs, + now, } = {}) { const app = new Hono(); app.post('/resource', x402Paywall({ @@ -71,6 +81,9 @@ function resourceApp({ ...(requestBodyTimeoutMs === undefined ? {} : { requestBodyTimeoutMs }), ...(facilitatorTimeoutMs === undefined ? {} : { facilitatorTimeoutMs }), ...(facilitatorResponseMaxBytes === undefined ? {} : { facilitatorResponseMaxBytes }), + ...(maxPendingOffers === undefined ? {} : { maxPendingOffers }), + ...(pendingOfferTtlMs === undefined ? {} : { pendingOfferTtlMs }), + ...(now === undefined ? {} : { now }), }), handler ?? ((c) => c.json({ ok: true }))); return app; } @@ -128,7 +141,10 @@ test('a restarted paywall accepts only the complete persisted frozen offer', asy quote: async () => { quoteCalls += 1; return structuredClone(executionQuote); }, lifecycle: { async onOffered({ requirements, executionQuote: offeredQuote }) { - persistedOffer = structuredClone({ requirements, executionQuote: offeredQuote }); + persistedOffer = structuredClone({ + requirements, executionQuote: offeredQuote, verificationRequired: true, + verifiedPaymentHash: null, + }); offeredQuote.model = 'mutated-by-untrusted-hook'; }, }, @@ -194,7 +210,12 @@ test('restart rejects different request bytes under the frozen idempotency key b facilitatorTransport: transport, lifecycle: { async loadFrozenOffer() { - return { requirements: structuredClone(persistedRequirements), executionQuote: null }; + return { + requirements: structuredClone(persistedRequirements), + executionQuote: null, + verificationRequired: true, + verifiedPaymentHash: null, + }; }, }, handler: (c) => { executions += 1; return c.json({ ok: true }); }, @@ -229,6 +250,290 @@ test('missing idempotency and paid retry without a frozen offer fail before faci assert.equal(facilitatorCalls, 0); }); +test('idempotency keys are canonical bounded ASCII before quote, lifecycle, or facilitator work', async () => { + let quoteCalls = 0; + let lifecycleCalls = 0; + let facilitatorCalls = 0; + const app = resourceApp({ + facilitatorTransport: createMockFacilitatorTransport(async () => { + facilitatorCalls += 1; + throw new Error('facilitator must not run'); + }), + quote: async () => { + quoteCalls += 1; + return structuredClone(executionQuote); + }, + lifecycle: { async onOffered() { lifecycleCalls += 1; } }, + }); + + for (const idempotencyKey of [ + 'key-with-unicode-é', + 'x'.repeat(129), + 'key/with/slashes', + ]) { + const response = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': idempotencyKey }, + body: '{}', + }); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + error: 'Idempotency-Key must be 1-128 canonical ASCII characters', + code: 'IDEMPOTENCY_KEY_INVALID', + }); + } + + const accepted = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': `a${'x'.repeat(127)}` }, + body: '{}', + }); + assert.equal(accepted.status, 402); + assert.equal(quoteCalls, 1); + assert.equal(lifecycleCalls, 1); + assert.equal(facilitatorCalls, 0); +}); + +test('pending unpaid offers have a strict admission cap and expire without evicting active keys', async () => { + let clockMs = 1_000_000; + let quoteCalls = 0; + const app = resourceApp({ + facilitatorTransport: createMockFacilitatorTransport(async () => { + throw new Error('facilitator must not run for unpaid offers'); + }), + quote: async () => { + quoteCalls += 1; + return structuredClone(executionQuote); + }, + maxPendingOffers: 2, + pendingOfferTtlMs: 1_000, + now: () => clockMs, + }); + const request = (key) => app.request('http://seller.test/resource', { + method: 'POST', headers: { 'Idempotency-Key': key }, body: '{}', + }); + + assert.equal((await request('pending-a')).status, 402); + assert.equal((await request('pending-b')).status, 402); + const full = await request('pending-c'); + assert.equal(full.status, 503); + assert.deepEqual(await full.json(), { + error: 'pending x402 offer capacity is exhausted', + code: 'PENDING_OFFER_CAPACITY', + }); + assert.equal(full.headers.get('Retry-After'), '1'); + assert.equal((await request('pending-a')).status, 402); + assert.equal(quoteCalls, 2); + + clockMs += 1_001; + assert.equal((await request('pending-c')).status, 402); + assert.equal(quoteCalls, 3); +}); + +test('pending admission is reserved before concurrent quote work begins', async () => { + let quoteCalls = 0; + let releaseFirstQuote; + let firstQuoteStarted; + const quoteStarted = new Promise((resolve) => { firstQuoteStarted = resolve; }); + const quoteRelease = new Promise((resolve) => { releaseFirstQuote = resolve; }); + const app = resourceApp({ + facilitatorTransport: createMockFacilitatorTransport(async () => { + throw new Error('facilitator must not run for unpaid offers'); + }), + quote: async () => { + quoteCalls += 1; + firstQuoteStarted(); + await quoteRelease; + return structuredClone(executionQuote); + }, + maxPendingOffers: 1, + }); + const request = (key) => app.request('http://seller.test/resource', { + method: 'POST', headers: { 'Idempotency-Key': key }, body: '{}', + }); + + const firstPromise = request('concurrent-pending-a'); + await quoteStarted; + const secondPromise = request('concurrent-pending-b'); + await new Promise((resolve) => setImmediate(resolve)); + releaseFirstQuote(); + + const [first, second] = await Promise.all([firstPromise, secondPromise]); + assert.equal(first.status, 402); + assert.equal(second.status, 503); + assert.deepEqual(await second.json(), { + error: 'pending x402 offer capacity is exhausted', + code: 'PENDING_OFFER_CAPACITY', + }); + assert.equal(quoteCalls, 1); +}); + +test('a paid request retains admission if its pending offer expires during verification', async () => { + let clockMs = Date.now(); + let releaseVerification; + let verificationStarted; + const started = new Promise((resolve) => { verificationStarted = resolve; }); + const release = new Promise((resolve) => { releaseVerification = resolve; }); + const facilitator = createMockFacilitator(); + const app = resourceApp({ + facilitatorTransport: createMockFacilitatorTransport(async (url, init) => { + if (new URL(url).pathname === '/verify') { + verificationStarted(); + await release; + } + return facilitator.request(url, init); + }), + maxPendingOffers: 1, + pendingOfferTtlMs: 1_000, + now: () => clockMs, + }); + let sellerRequests = 0; + const signed = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'expiring-paid-admission', + fetchImpl: (url, init) => { + sellerRequests += 1; + return sellerRequests === 1 + ? app.request(url, init) + : Response.json({ error: 'withheld paid retry' }, { status: 503 }); + }, + }); + + const paidPromise = app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': signed.idempotencyKey, 'X-PAYMENT': signed.xPayment }, + body: '{}', + }); + await started; + clockMs += 1_001; + const concurrent = await app.request('http://seller.test/resource', { + method: 'POST', headers: { 'Idempotency-Key': 'blocked-by-paid-request' }, body: '{}', + }); + releaseVerification(); + const paid = await paidPromise; + assert.equal(concurrent.status, 503); + assert.equal(paid.status, 200); +}); + +test('unresolved keys retain bounded admission across TTL and release only for trusted authority', async () => { + for (const resolutionKind of ['settled', 'terminal']) { + let clockMs = Date.now(); + let persistedOffer = null; + let authorityDecision = null; + let executions = 0; + const facilitator = createMockFacilitator(); + const transport = createMockFacilitatorTransport(async (url, init) => { + if (new URL(url).pathname === '/verify') return facilitator.request(url, init); + return Response.json({}); + }); + const account = throwawayAccount(); + const originalKey = `unresolved-${resolutionKind}`; + const app = resourceApp({ + facilitatorTransport: transport, + maxPendingOffers: 1, + pendingOfferTtlMs: 1_000, + now: () => clockMs, + lifecycle: { + async onOffered({ idempotencyKey, requirements, executionQuote: offeredQuote }) { + if (idempotencyKey === originalKey) { + persistedOffer = structuredClone({ + requirements, + executionQuote: offeredQuote, + verificationRequired: true, + verifiedPaymentHash: null, + }); + } + }, + async loadFrozenOffer({ idempotencyKey }) { + return idempotencyKey === originalKey ? structuredClone(persistedOffer) : null; + }, + async onSigned() { return authorityDecision; }, + async onUnresolved() {}, + }, + handler: (c) => { executions += 1; return c.json({ ok: true }); }, + }); + const first = await withheldAttempt(account, 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: originalKey, + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(first.state, 'unresolved'); + + clockMs += 1_001; + const differentWhileUnresolved = await app.request('http://seller.test/resource', { + method: 'POST', headers: { 'Idempotency-Key': `blocked-${resolutionKind}` }, body: '{}', + }); + assert.equal(differentWhileUnresolved.status, 503, resolutionKind); + + const recreate = await app.request('http://seller.test/resource', { + method: 'POST', headers: { 'Idempotency-Key': originalKey }, body: '{}', + }); + assert.equal(recreate.status, 503, resolutionKind); + assert.deepEqual(await recreate.json(), { + error: 'payment settlement unresolved; trusted reconciliation is required', + settlementReference: null, + }); + + authorityDecision = resolutionKind === 'settled' + ? { kind: 'settled', txHash: `0x${'8'.repeat(64)}`, payer: `0x${'0'.repeat(40)}` } + : { + kind: 'terminal', paymentState: 'rejected', txHash: null, payer: account.address, + httpStatus: 500, receipt: { receipt: { execution: { message: 'failed' } } }, + }; + const untrusted = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': originalKey, 'X-PAYMENT': first.xPayment }, + body: '{}', + }); + assert.equal(untrusted.status, 503, resolutionKind); + const stillBlocked = await app.request('http://seller.test/resource', { + method: 'POST', headers: { 'Idempotency-Key': `still-blocked-${resolutionKind}` }, body: '{}', + }); + assert.equal(stillBlocked.status, 503, resolutionKind); + + authorityDecision = resolutionKind === 'settled' + ? { kind: 'settled', txHash: `0x${'8'.repeat(64)}`, payer: account.address.toLowerCase() } + : { + kind: 'terminal', paymentState: 'settled', txHash: `0x${'8'.repeat(64)}`, + payer: account.address.toLowerCase(), httpStatus: 500, + receipt: { receipt: { execution: { message: 'provider failed' } } }, + }; + const trusted = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': originalKey, 'X-PAYMENT': first.xPayment }, + body: '{}', + }); + assert.equal(trusted.status, resolutionKind === 'settled' ? 200 : 500, resolutionKind); + + const afterResolution = await app.request('http://seller.test/resource', { + method: 'POST', headers: { 'Idempotency-Key': `released-${resolutionKind}` }, body: '{}', + }); + assert.equal(afterResolution.status, 402, resolutionKind); + assert.equal(executions, resolutionKind === 'settled' ? 1 : 0, resolutionKind); + } +}); + +test('x402 constructor options cannot raise secure memory, byte, or deadline ceilings', () => { + const facilitatorTransport = createMockFacilitatorTransport(async () => { + throw new Error('facilitator must not run'); + }); + for (const [option, value, ceiling] of [ + ['maxRequestBodyBytes', MAX_X402_REQUEST_BODY_BYTES + 1, MAX_X402_REQUEST_BODY_BYTES], + ['requestBodyTimeoutMs', DEFAULT_X402_REQUEST_BODY_TIMEOUT_MS + 1, DEFAULT_X402_REQUEST_BODY_TIMEOUT_MS], + ['facilitatorTimeoutMs', DEFAULT_FACILITATOR_TIMEOUT_MS + 1, DEFAULT_FACILITATOR_TIMEOUT_MS], + ['facilitatorResponseMaxBytes', DEFAULT_FACILITATOR_RESPONSE_BYTES + 1, DEFAULT_FACILITATOR_RESPONSE_BYTES], + ['maxPendingOffers', DEFAULT_PENDING_OFFER_LIMIT + 1, DEFAULT_PENDING_OFFER_LIMIT], + ['pendingOfferTtlMs', DEFAULT_PENDING_OFFER_TTL_MS + 1, DEFAULT_PENDING_OFFER_TTL_MS], + ]) { + assert.throws( + () => resourceApp({ facilitatorTransport, [option]: value }), + new RegExp(`${option} cannot exceed ${ceiling}`), + ); + } +}); + test('authorization amount must equal the frozen quote exactly before facilitator submission', async () => { let facilitatorCalls = 0; const facilitator = createMockFacilitator(); @@ -296,11 +601,14 @@ test('seller rejects numeric and unknown authorization fields before facilitator } }); -test('unresolved payment retries return 503 without re-verification or settlement', async () => { +test('an unresolved authority decision after verification returns 503 without settlement', async () => { + const facilitator = createMockFacilitator(); let facilitatorCalls = 0; - const transport = createMockFacilitatorTransport(async () => { + let settleCalls = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { facilitatorCalls += 1; - throw new Error('must not run'); + if (new URL(url).pathname === '/settle') settleCalls += 1; + return facilitator.request(url, init); }); const app = resourceApp({ facilitatorTransport: transport, @@ -315,14 +623,17 @@ test('unresolved payment retries return 503 without re-verification or settlemen fetchImpl: (url, init) => app.request(url, init), }); assert.equal(result.state, 'unresolved'); - assert.equal(facilitatorCalls, 0); + assert.equal(facilitatorCalls, 1); + assert.equal(settleCalls, 0); }); test('terminal replay requires settled or refunded payment with a transaction and preserves HTTP status', async () => { + const facilitator = createMockFacilitator(); let calls = 0; - const transport = createMockFacilitatorTransport(async () => { + const transport = createMockFacilitatorTransport(async (url, init) => { calls += 1; - throw new Error('must not run'); + assert.equal(new URL(url).pathname, '/verify'); + return facilitator.request(url, init); }); const account = throwawayAccount(); for (const [decision, expectedStatus] of [[{ @@ -359,7 +670,7 @@ test('terminal replay requires settled or refunded payment with a transaction an assert.equal(result.txHash, decision.txHash); } } - assert.equal(calls, 0); + assert.equal(calls, 2); }); test('live facilitator configuration pins one exact HTTPS base before authorization exists', () => { @@ -652,14 +963,18 @@ test('facilitator verify and settle deadlines abort ignoring transports and rema fetchImpl: (url, init) => app.request(url, init), }); assert.equal(facilitatorSignal.aborted, true, timedOutOperation); - assert.equal(unresolvedReason, 'facilitator response unresolved', timedOutOperation); + assert.equal( + unresolvedReason, + timedOutOperation === 'verify' ? null : 'facilitator response unresolved', + timedOutOperation, + ); assert.equal(held.state, 'unresolved', timedOutOperation); assert.equal(held.paymentPolicy.snapshot().reservedAtomic, '250000', timedOutOperation); assert.equal(executions, 0, timedOutOperation); } }); -test('oversized chunked facilitator JSON is cancelled and treated as ambiguous settlement', async () => { +test('oversized verification JSON is cancelled before authoritative payment state', async () => { let cancelled = false; let unresolvedReason = null; let executions = 0; @@ -685,7 +1000,7 @@ test('oversized chunked facilitator JSON is cancelled and treated as ambiguous s fetchImpl: (url, init) => app.request(url, init), }); assert.equal(cancelled, true); - assert.equal(unresolvedReason, 'facilitator response unresolved'); + assert.equal(unresolvedReason, null); assert.equal(held.state, 'unresolved'); assert.equal(executions, 0); }); From 64f5699ded687faf0c7aa4f2fda3b6c82204c913 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 16:49:06 -0400 Subject: [PATCH 141/165] fix: close Pi payment replay and request gaps --- spikes/pi-wielder/README.md | 19 +- spikes/pi-wielder/RUNBOOK.md | 18 +- spikes/pi-wielder/e2e.mjs | 5 +- spikes/pi-wielder/src/collar.mjs | 10 +- spikes/pi-wielder/src/gateway.mjs | 246 ++++++++- spikes/pi-wielder/src/proxy.mjs | 7 +- spikes/pi-wielder/src/runtime-boundaries.mjs | 4 + spikes/pi-wielder/src/x402-seller.mjs | 214 +++++++- spikes/pi-wielder/tests/collar-cogs.test.mjs | 18 + .../pi-wielder/tests/collar-failure.test.mjs | 11 +- .../tests/gateway-transport.test.mjs | 484 +++++++++++++++++- spikes/pi-wielder/tests/paying-fetch.test.mjs | 32 ++ .../pi-wielder/tests/x402-lifecycle.test.mjs | 329 ++++++++++++ 13 files changed, 1364 insertions(+), 33 deletions(-) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index ede62b0..97011fd 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -59,6 +59,14 @@ key receives `503 PENDING_OFFER_CAPACITY` when all 128 pending slots are active; existing active key can still retrieve its exact frozen challenge. Expired unpaid slots are the only offer state reclaimed by this admission control. +The standalone, non-authoritative paywall can consume an authorization only once. Before +verification it atomically claims the fixed network, asset, payer, and nonce for one +Idempotency-Key, with the exact payment-header hash bound to that owner. Cross-key and +alternate-encoding replays fail before another facilitator or handler call; a successful +claim remains in the same bounded TTL-scoped admission state. It cannot replay lost +output; durable terminal replay belongs to the Collar journal. Facilitator verification +succeeds only for the exact boolean `true`. + The caller's method, body bytes, and headers are captured once before the unpaid request. Method and body bytes bind the policy hash and signed recovery; captured headers are reused for the unpaid and paid requests but are not signed or covered by @@ -178,7 +186,7 @@ npm test npm run e2e ``` -Expected current results are 222 offline unit/integration tests and 41 offline e2e +Expected current results are 235 offline unit/integration tests and 41 offline e2e checks. Counts can increase as regressions are added; zero failures is the contract. The e2e labels all timing output synthetic and uses in-process Hono requests only. @@ -230,6 +238,13 @@ body consumption and cannot be configured above 30 seconds. Caller abort signals composed into child signals; an internal timeout never aborts the caller's controller. Redirects remain disabled. +Before returning a gateway offer, the reseller validates a closed OpenAI-compatible +request contract: allowed model, non-empty canonical message roles and text parts, +function tools/calls/results, output-token bound, and provider-specific option types and +ranges. Malformed JSON, unknown fields, and unsupported shapes receive a stable `400` +without facilitator settlement or provider work. Anthropic options are either translated +explicitly or rejected; unsupported tool `strict` semantics are never silently dropped. + Timeout state follows the durable money boundary: an unpaid timeout creates no reservation; a signed retry or facilitator ambiguity stays `unresolved` with budget held; and a provider timeout after settlement finalizes a sanitized failed receipt, @@ -237,6 +252,8 @@ unknown COGS, one full-gross reconciliation hold, no output, and no Royalty cred Raw transport and provider errors are not returned or journaled. The manual Pi tool has no caller-selected Skill route: it invokes only the fixed, encoded `optimizing-claude-code-prompts` path. +Withheld paid output and unsuccessful facilitator or provider response bodies are +cancelled without being consumed or exposed. Live model execution requires the exact combination of `MOCK_LLM=0`, `ALLOW_LIVE_PROVIDER=1`, a `human_verified` immutable catalog, its exact operator-approved diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md index ea132f0..e8acc26 100644 --- a/spikes/pi-wielder/RUNBOOK.md +++ b/spikes/pi-wielder/RUNBOOK.md @@ -104,7 +104,9 @@ verify and settle, and 30 seconds for provider execution/upstream response reads gateway's provider deadline covers both the fetch and the streamed response read, cannot be configured above 30 seconds, composes the request abort signal, and refuses redirects. Provider HTTP failures never consume or expose the raw response body; all -public failures are stable and sanitized. +public failures are stable and sanitized. Unsuccessful facilitator and provider bodies, +plus paid output withheld for invalid settlement evidence, are cancelled without being +read or exposed. An unpaid timeout remains unreserved. Any timeout after the signature keeps the payment unresolved/held until trusted reconciliation. A provider timeout after settlement returns no output and finalizes a signed failed receipt with unknown COGS and the full @@ -119,6 +121,14 @@ digest and all signed, unresolved, settled, refunded, execution, and terminal st remain in the append-only journal and are never capacity-pruned. A replay must match that digest; legacy journal entries without one are sent back through facilitator verification. +The standalone gateway has no durable response authority. Before verification it claims +the fixed network, asset, payer, and nonce for exactly one Idempotency-Key and binds the +exact verified payment-header hash to that owner. Cross-key, concurrent, and alternate- +encoding reuse fails before a second verification, settlement, or provider execution; +a successful claim stays in bounded TTL-scoped admission state. Do not treat this as +response replay. The authoritative Collar journal remains the only terminal replay path. +Facilitator verification accepts only the exact boolean `true`. + A restart after `402` but before successful verification intentionally loses that non-authoritative offer. A paid retry carrying the old key then gets `409` before any facilitator or provider call. Keep the Wielder reservation unresolved, reconcile the @@ -213,7 +223,11 @@ set. A reviewed integration must inject a `human_verified` catalog and its exact The gateway enforces its catalog's exact model allowlist, output bound, and conservative input bound before offering payment. That input bound treats each raw request byte as at most one provider token and reserves another 1,024 tokens for provider-side chat -framing. Provider requests refuse redirects, use one absolute fetch-plus-body deadline, +framing. The pre-offer schema is closed: messages, text parts, function tools/calls/results, +and provider-specific options must match the documented Pi/OpenAI shapes. Unknown or +malformed fields fail with `400` before facilitator or provider activity. Anthropic +options are translated explicitly; unsupported `strict` tool semantics are rejected. +Provider requests refuse redirects, use one absolute fetch-plus-body deadline, whose configurable value cannot exceed 30 seconds, and stream responses through a hard 1 MiB cap. Automated verification stays on the mock facilitator and mock model and uses no real funds. diff --git a/spikes/pi-wielder/e2e.mjs b/spikes/pi-wielder/e2e.mjs index bc67aa6..af72b02 100644 --- a/spikes/pi-wielder/e2e.mjs +++ b/spikes/pi-wielder/e2e.mjs @@ -75,7 +75,10 @@ async function viaProxy(path, body, label = null) { console.log('unpaid requests are challenged:'); for (const [name, app, url, body] of [ - ['gateway', gateway, 'http://gateway.test/v1/chat/completions', { model: 'gpt-x' }], + ['gateway', gateway, 'http://gateway.test/v1/chat/completions', { + model: 'gpt-x', + messages: [{ role: 'user', content: 'challenge only' }], + }], ['collar', collar.app, `http://collar.test/invoke/${SKILL_ID}`, { input: 'x' }], ]) { const response = await app.request(url, { diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index e0047b7..8f02bad 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -39,6 +39,7 @@ import { createInvocationJournal, } from './invocation-journal.mjs'; import { + cancelResponseBody, readJsonBody, RuntimeBoundaryError, withWallClockDeadline, @@ -201,7 +202,7 @@ export function createCollar({ }); } if (!executor) { - if (!allowLiveProvider) { + if (allowLiveProvider !== true) { throw new ExecutionEconomicsError( 'LIVE_PRICING_UNAPPROVED', 'live provider execution requires an explicit gate', @@ -977,7 +978,12 @@ export function createAnthropicExecutor({ }), }); if (!response?.ok) { - throw new RuntimeBoundaryError('UPSTREAM_PROVIDER_ERROR', 'provider request failed'); + const error = new RuntimeBoundaryError( + 'UPSTREAM_PROVIDER_ERROR', + 'provider request failed', + ); + cancelResponseBody(response, error); + throw error; } if (!(response instanceof Response)) { throw new RuntimeBoundaryError( diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index ed230f9..81ecf7c 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -72,6 +72,17 @@ export const GATEWAY_EXECUTION_CATALOG = deepFreeze({ }); const LIVE_PROVIDERS = new Set(['anthropic', 'openai']); +const GATEWAY_MESSAGE_ROLES = new Set(['assistant', 'developer', 'system', 'tool', 'user']); +const GATEWAY_SYSTEM_MESSAGE_ROLES = new Set(['developer', 'system']); +const GATEWAY_TEXT_PART_TYPES = new Set(['input_text', 'output_text', 'text']); +const MAX_GATEWAY_PARTICIPANT_NAME_CHARACTERS = 64; +const MAX_GATEWAY_TOOL_CALL_ID_CHARACTERS = 256; +const GATEWAY_FUNCTION_NAME = /^[A-Za-z0-9_-]{1,64}$/; +const GATEWAY_REQUEST_KEYS = new Set([ + 'frequency_penalty', 'max_completion_tokens', 'max_tokens', 'messages', 'model', + 'presence_penalty', 'response_format', 'seed', 'stop', 'stream', 'temperature', + 'tool_choice', 'tools', 'top_p', 'user', +]); class GatewayBoundaryError extends Error { constructor(code, message, status) { @@ -101,6 +112,205 @@ function gatewayFailure(code, message, status) { throw new GatewayBoundaryError(code, message, status); } +function plainJsonObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasOnlyKeys(value, allowed) { + return Object.keys(value).every((key) => allowed.has(key)); +} + +function boundedString(value, maximumCharacters) { + return typeof value === 'string' && value.length > 0 && value.length <= maximumCharacters; +} + +function supportedMessageContent(content) { + if (typeof content === 'string') return true; + return Array.isArray(content) + && content.length > 0 + && content.every((part) => plainJsonObject(part) + && hasOnlyKeys(part, new Set(['text', 'type'])) + && GATEWAY_TEXT_PART_TYPES.has(part.type) + && typeof part.text === 'string'); +} + +function supportedToolArguments(value) { + return typeof value === 'string'; +} + +function supportedAssistantToolCalls(message) { + if (!Object.hasOwn(message, 'tool_calls')) return null; + if (!Array.isArray(message.tool_calls) || message.tool_calls.length === 0) return false; + return message.tool_calls.every((toolCall) => plainJsonObject(toolCall) + && hasOnlyKeys(toolCall, new Set(['function', 'id', 'type'])) + && boundedString(toolCall.id, MAX_GATEWAY_TOOL_CALL_ID_CHARACTERS) + && toolCall.type === 'function' + && plainJsonObject(toolCall.function) + && hasOnlyKeys(toolCall.function, new Set(['arguments', 'name'])) + && typeof toolCall.function.name === 'string' + && GATEWAY_FUNCTION_NAME.test(toolCall.function.name) + && supportedToolArguments(toolCall.function.arguments)); +} + +function assertGatewayMessages(body) { + if (!Object.hasOwn(body, 'messages') + || !Array.isArray(body.messages) + || body.messages.length === 0) { + gatewayFailure('REQUEST_SCHEMA', 'gateway request is invalid', 400); + } + for (const message of body.messages) { + if (!plainJsonObject(message) + || !Object.hasOwn(message, 'role') + || !GATEWAY_MESSAGE_ROLES.has(message.role) + || !Object.hasOwn(message, 'content')) { + gatewayFailure('REQUEST_SCHEMA', 'gateway request is invalid', 400); + } + const allowedKeys = new Set(['content', 'name', 'role']); + if (message.role === 'assistant') allowedKeys.add('tool_calls'); + if (message.role === 'tool') allowedKeys.add('tool_call_id'); + if (!hasOnlyKeys(message, allowedKeys) + || (Object.hasOwn(message, 'name') + && !boundedString(message.name, MAX_GATEWAY_PARTICIPANT_NAME_CHARACTERS))) { + gatewayFailure('REQUEST_SCHEMA', 'gateway request is invalid', 400); + } + const assistantToolCalls = message.role === 'assistant' + ? supportedAssistantToolCalls(message) + : null; + const contentIsSupported = supportedMessageContent(message.content) + || (message.role === 'assistant' && message.content === null && assistantToolCalls === true); + if (!contentIsSupported + || assistantToolCalls === false + || (message.role === 'tool' + && (!Object.hasOwn(message, 'tool_call_id') + || !boundedString(message.tool_call_id, MAX_GATEWAY_TOOL_CALL_ID_CHARACTERS)))) { + gatewayFailure('REQUEST_SCHEMA', 'gateway request is invalid', 400); + } + } +} + +function assertGatewayTools(body) { + if (!Object.hasOwn(body, 'tools')) return; + if (!Array.isArray(body.tools)) { + gatewayFailure('REQUEST_SCHEMA', 'gateway request is invalid', 400); + } + for (const tool of body.tools) { + if (!plainJsonObject(tool) + || !hasOnlyKeys(tool, new Set(['function', 'type'])) + || tool.type !== 'function' + || !Object.hasOwn(tool, 'function') + || !plainJsonObject(tool.function) + || !hasOnlyKeys(tool.function, new Set(['description', 'name', 'parameters', 'strict'])) + || !Object.hasOwn(tool.function, 'name') + || typeof tool.function.name !== 'string' + || !GATEWAY_FUNCTION_NAME.test(tool.function.name) + || (Object.hasOwn(tool.function, 'description') + && typeof tool.function.description !== 'string') + || (Object.hasOwn(tool.function, 'parameters') + && !plainJsonObject(tool.function.parameters)) + || (Object.hasOwn(tool.function, 'strict') + && typeof tool.function.strict !== 'boolean')) { + gatewayFailure('REQUEST_SCHEMA', 'gateway request is invalid', 400); + } + } +} + +function finiteNumberInRange(value, minimum, maximum) { + return typeof value === 'number' + && Number.isFinite(value) + && value >= minimum + && value <= maximum; +} + +function supportedStop(value) { + if (typeof value === 'string') return value.length > 0; + return Array.isArray(value) + && value.length > 0 + && value.length <= 4 + && value.every((stop) => typeof stop === 'string' && stop.length > 0); +} + +function supportedResponseFormat(value) { + if (!plainJsonObject(value) || typeof value.type !== 'string') return false; + if (value.type === 'text' || value.type === 'json_object') { + return hasOnlyKeys(value, new Set(['type'])); + } + if (value.type !== 'json_schema' + || !hasOnlyKeys(value, new Set(['json_schema', 'type'])) + || !plainJsonObject(value.json_schema) + || !hasOnlyKeys(value.json_schema, new Set(['description', 'name', 'schema', 'strict'])) + || !GATEWAY_FUNCTION_NAME.test(value.json_schema.name ?? '') + || !Object.hasOwn(value.json_schema, 'schema') + || !plainJsonObject(value.json_schema.schema) + || (Object.hasOwn(value.json_schema, 'description') + && typeof value.json_schema.description !== 'string') + || (Object.hasOwn(value.json_schema, 'strict') + && typeof value.json_schema.strict !== 'boolean')) { + return false; + } + return true; +} + +function supportedToolChoice(body) { + if (!Object.hasOwn(body, 'tool_choice')) return true; + const choice = body.tool_choice; + if (typeof choice === 'string') { + if (!new Set(['auto', 'none', 'required']).has(choice)) return false; + return choice !== 'required' || body.tools?.length > 0; + } + if (!plainJsonObject(choice) + || !hasOnlyKeys(choice, new Set(['function', 'type'])) + || choice.type !== 'function' + || !plainJsonObject(choice.function) + || !hasOnlyKeys(choice.function, new Set(['name'])) + || !GATEWAY_FUNCTION_NAME.test(choice.function.name ?? '')) { + return false; + } + return body.tools?.some((tool) => tool.function.name === choice.function.name) === true; +} + +function anthropicToolArgumentsAreObjects(body) { + for (const message of body.messages) { + for (const toolCall of message.tool_calls ?? []) { + let parsed; + try { parsed = JSON.parse(toolCall.function.arguments); } catch { return false; } + if (!plainJsonObject(parsed)) return false; + } + } + return true; +} + +function assertGatewayProviderOptions(body, provider) { + if (!hasOnlyKeys(body, GATEWAY_REQUEST_KEYS) + || (Object.hasOwn(body, 'stream') && typeof body.stream !== 'boolean') + || (Object.hasOwn(body, 'temperature') + && (provider !== 'openai' || !finiteNumberInRange(body.temperature, 0, 2))) + || (Object.hasOwn(body, 'top_p') + && (provider !== 'openai' || !finiteNumberInRange(body.top_p, 0, 1))) + || (Object.hasOwn(body, 'stop') && !supportedStop(body.stop)) + || !supportedToolChoice(body) + || (provider === 'anthropic' + && Object.hasOwn(body, 'tool_choice') + && body.tool_choice !== 'none' + && !(body.tools?.length > 0)) + || (provider === 'anthropic' && !anthropicToolArgumentsAreObjects(body)) + || (Object.hasOwn(body, 'presence_penalty') + && (provider !== 'openai' || !finiteNumberInRange(body.presence_penalty, -2, 2))) + || (Object.hasOwn(body, 'frequency_penalty') + && (provider !== 'openai' || !finiteNumberInRange(body.frequency_penalty, -2, 2))) + || (Object.hasOwn(body, 'response_format') + && (provider !== 'openai' || !supportedResponseFormat(body.response_format))) + || (Object.hasOwn(body, 'seed') + && (provider !== 'openai' || !Number.isSafeInteger(body.seed))) + || (Object.hasOwn(body, 'user') + && (provider !== 'openai' || !boundedString(body.user, 256))) + || (provider === 'anthropic' + && body.tools?.some((tool) => Object.hasOwn(tool.function, 'strict')))) { + gatewayFailure('REQUEST_SCHEMA', 'gateway request is invalid', 400); + } +} + function priceForProvider(provider) { if (provider === 'anthropic') return MODEL_PRICES_USDC.claude; if (provider === 'openai') return MODEL_PRICES_USDC.gpt; @@ -226,13 +436,16 @@ function requestPlan(c, catalog) { const cached = c.get('gatewayRequestPlan'); if (cached) return cached; const body = gatewayRequestBody(c); - if (!body || typeof body !== 'object' || Array.isArray(body)) { + if (!plainJsonObject(body)) { gatewayFailure('REQUEST_SCHEMA', 'gateway request is invalid', 400); } if (typeof body.model !== 'string' || !Object.hasOwn(catalog.models, body.model)) { gatewayFailure('MODEL_NOT_ALLOWED', 'gateway model is not allowed', 400); } + assertGatewayMessages(body); + assertGatewayTools(body); const policy = catalog.models[body.model]; + assertGatewayProviderOptions(body, policy.provider); const hasLegacyLimit = Object.hasOwn(body, 'max_tokens'); const hasCurrentLimit = Object.hasOwn(body, 'max_completion_tokens'); if (hasLegacyLimit && hasCurrentLimit) { @@ -271,9 +484,9 @@ function requestPlan(c, catalog) { function gatewayRequestBody(c) { const cached = c.get('gatewayRequestBody'); - if (cached) return cached; + if (cached !== undefined) return cached; let body; - try { body = JSON.parse(x402RequestBodyText(c)); } catch { body = {}; } + try { body = JSON.parse(x402RequestBodyText(c)); } catch { body = null; } c.set('gatewayRequestBody', body); return body; } @@ -455,7 +668,7 @@ function toAnthropicMessages(oaiMessages = []) { else out.push({ role, content: [...blocks] }); }; for (const m of oaiMessages) { - if (m.role === 'system') continue; + if (GATEWAY_SYSTEM_MESSAGE_ROLES.has(m.role)) continue; if (m.role === 'tool') { push('user', [{ type: 'tool_result', tool_use_id: m.tool_call_id, content: contentToText(m.content) }]); } else if (m.role === 'assistant') { @@ -530,12 +743,25 @@ function validateProviderUsage(plan, inputTokens, outputTokens) { function anthropicRequest(plan, apiKey) { const body = plan.body; - const system = (body.messages ?? []).filter((m) => m.role === 'system').map((m) => contentToText(m.content)).join('\n') || undefined; + const system = (body.messages ?? []) + .filter((message) => GATEWAY_SYSTEM_MESSAGE_ROLES.has(message.role)) + .map((message) => contentToText(message.content)) + .join('\n') || undefined; const tools = (body.tools ?? []).map((t) => ({ name: t.function.name, description: t.function.description ?? '', input_schema: t.function.parameters ?? { type: 'object', properties: {} }, })); + const toolChoice = !Object.hasOwn(body, 'tool_choice') || body.tool_choice === 'none' + ? null + : body.tool_choice === 'required' + ? { type: 'any' } + : body.tool_choice === 'auto' + ? { type: 'auto' } + : { type: 'tool', name: body.tool_choice.function.name }; + const stopSequences = !Object.hasOwn(body, 'stop') + ? null + : typeof body.stop === 'string' ? [body.stop] : body.stop; return { url: 'https://api.anthropic.com/v1/messages', init: { @@ -549,9 +775,11 @@ function anthropicRequest(plan, apiKey) { body: JSON.stringify({ model: plan.model, max_tokens: plan.maxOutputTokens, + ...(stopSequences ? { stop_sequences: stopSequences } : {}), system, messages: toAnthropicMessages(body.messages), - ...(tools.length ? { tools } : {}), + ...(tools.length && body.tool_choice !== 'none' ? { tools } : {}), + ...(toolChoice ? { tool_choice: toolChoice } : {}), }), }, }; @@ -566,6 +794,12 @@ function openAiRequest(plan, apiKey) { const forwarded = Object.fromEntries(allowedFields .filter((field) => Object.hasOwn(body, field)) .map((field) => [field, body[field]])); + forwarded.messages = body.messages.map((message) => ({ + ...message, + ...(Array.isArray(message.content) + ? { content: message.content.map((part) => ({ type: 'text', text: part.text })) } + : {}), + })); return { url: 'https://api.openai.com/v1/chat/completions', init: { diff --git a/spikes/pi-wielder/src/proxy.mjs b/spikes/pi-wielder/src/proxy.mjs index f6d8d59..1a0c206 100644 --- a/spikes/pi-wielder/src/proxy.mjs +++ b/spikes/pi-wielder/src/proxy.mjs @@ -20,6 +20,7 @@ import { loadAccount } from './wallet.mjs'; import { createLedger, renderLedger } from './ledger.mjs'; import { receiptKeyId, verifySignedReceipt } from './invocation-journal.mjs'; import { + cancelResponseBody, readBodyBytes, readJsonBody, RuntimeBoundaryError, @@ -507,11 +508,13 @@ export async function payingFetch(account, url, init, options = {}) { settlement = decodeSettlementHeader(res.headers.get('X-PAYMENT-RESPONSE')); paymentPolicy.acceptSettlement(idempotencyKey, settlement); } catch { - paymentPolicy.markUnresolved(idempotencyKey, { reasonCode: 'SETTLEMENT_EVIDENCE_INVALID' }); - throw paymentError( + const error = paymentError( 'SETTLEMENT_EVIDENCE', 'retry settlement evidence is missing, malformed, or mismatched; upstream output withheld', ); + cancelResponseBody(res, error); + paymentPolicy.markUnresolved(idempotencyKey, { reasonCode: 'SETTLEMENT_EVIDENCE_INVALID' }); + throw error; } const reportedFacilitatorMs = Number(res.headers.get('X-402-FACILITATOR-MS')); const msFacilitator = Number.isFinite(reportedFacilitatorMs) && reportedFacilitatorMs >= 0 diff --git a/spikes/pi-wielder/src/runtime-boundaries.mjs b/spikes/pi-wielder/src/runtime-boundaries.mjs index 0a28ee5..0086093 100644 --- a/spikes/pi-wielder/src/runtime-boundaries.mjs +++ b/spikes/pi-wielder/src/runtime-boundaries.mjs @@ -95,6 +95,10 @@ async function cancelQuietly(readerOrBody, reason) { try { await readerOrBody?.cancel?.(reason); } catch { /* bounded failure already owns the result */ } } +export function cancelResponseBody(source, reason) { + void cancelQuietly(source?.body, reason); +} + export async function readBodyBytes(source, { maxBytes, tooLargeCode, diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index 8e2acac..cc0ce2d 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -9,6 +9,7 @@ import crypto from 'node:crypto'; import { formatUsdc, parseUsdc } from '../../../prototype/atomic-money.mjs'; import { + cancelResponseBody, readBodyBytes, readJsonBody, RuntimeBoundaryError, @@ -233,18 +234,39 @@ export function x402Paywall({ cappedLimit(pendingOfferTtlMs, 'pendingOfferTtlMs', DEFAULT_PENDING_OFFER_TTL_MS); if (typeof now !== 'function') throw new TypeError('now must be a trusted clock function'); const pendingOffers = new Map(); + const authorizationClaims = new Map(); + const authorizationClaimByIdempotencyKey = new Map(); const locallyUnresolvedSettlements = new Set(); const transientAdmissions = new Set(); const activeKeyCount = () => new Set([ ...pendingOffers.keys(), + ...authorizationClaimByIdempotencyKey.keys(), ...locallyUnresolvedSettlements, ...transientAdmissions, ]).size; + const releaseAuthorizationClaim = (authorizationIdentity, claim) => { + if (authorizationClaims.get(authorizationIdentity) === claim) { + authorizationClaims.delete(authorizationIdentity); + } + if (authorizationClaimByIdempotencyKey.get(claim.idempotencyKey) + === authorizationIdentity) { + authorizationClaimByIdempotencyKey.delete(claim.idempotencyKey); + } + }; + + const claimForIdempotencyKey = (idempotencyKey) => { + const authorizationIdentity = authorizationClaimByIdempotencyKey.get(idempotencyKey); + if (!authorizationIdentity) return null; + const claim = authorizationClaims.get(authorizationIdentity); + return claim ? { authorizationIdentity, claim } : null; + }; + const reserveTransientAdmission = (idempotencyKey) => { if (transientAdmissions.has(idempotencyKey)) return false; const alreadyTracked = pendingOffers.has(idempotencyKey) + || authorizationClaimByIdempotencyKey.has(idempotencyKey) || locallyUnresolvedSettlements.has(idempotencyKey); if (!alreadyTracked && activeKeyCount() >= maxPendingOffers) return false; transientAdmissions.add(idempotencyKey); @@ -276,6 +298,11 @@ export function x402Paywall({ for (const [key, offer] of pendingOffers) { if (offer.expiresAtMs <= requestNowMs) pendingOffers.delete(key); } + for (const [authorizationIdentity, claim] of authorizationClaims) { + if (claim.state === 'consumed' && claim.expiresAtMs <= requestNowMs) { + releaseAuthorizationClaim(authorizationIdentity, claim); + } + } const paymentHeader = c.req.header('X-PAYMENT'); let requestBodyBytes; try { @@ -313,6 +340,7 @@ export function x402Paywall({ .digest('hex')}`; let transientAdmission = false; + let activeAuthorizationClaim = null; const capacityResponse = () => { const response = c.json({ error: 'pending x402 offer capacity is exhausted', @@ -364,6 +392,16 @@ export function x402Paywall({ } else { if (locallyUnresolvedSettlements.has(idempotencyKey)) return unresolvedResponse(); if (paymentHeader) return c.json({ error: 'paid retry has no prior frozen x402 offer' }, 409); + const outstandingAuthorization = claimForIdempotencyKey(idempotencyKey); + if (outstandingAuthorization) { + if (outstandingAuthorization.claim.state === 'consumed') { + return c.json({ + error: 'payment authorization was already consumed; response replay is unavailable', + code: 'PAYMENT_AUTHORIZATION_CONSUMED', + }, 409); + } + return unresolvedResponse(); + } if (!reserveTransientAdmission(idempotencyKey)) return capacityResponse(); transientAdmission = true; try { @@ -486,6 +524,100 @@ export function x402Paywall({ code: 'VERIFIED_PAYMENT_MISMATCH', }, 409); } + const authorizationIdentity = [ + NETWORK, USDC_ADDRESS, payer, settlementReference, + ].join(':'); + const ownerAuthorizationIdentity = authorizationClaimByIdempotencyKey.get(idempotencyKey); + let authorizationClaim = authorizationClaims.get(authorizationIdentity) ?? null; + if (authorizationClaim && authorizationClaim.idempotencyKey !== idempotencyKey) { + return c.json({ + error: 'payment authorization is already claimed by a different Idempotency-Key', + code: 'PAYMENT_AUTHORIZATION_CLAIMED', + }, 409); + } + if ((ownerAuthorizationIdentity && ownerAuthorizationIdentity !== authorizationIdentity) + || (authorizationClaim && authorizationClaim.paymentHash !== paymentHash)) { + return c.json({ + error: 'Idempotency-Key already binds a different payment authorization', + code: 'PAYMENT_AUTHORIZATION_MISMATCH', + }, 409); + } + if (authorizationClaim?.state === 'consumed' && !authorizationClaim.authoritative) { + return c.json({ + error: 'payment authorization was already consumed; response replay is unavailable', + code: 'PAYMENT_AUTHORIZATION_CONSUMED', + }, 409); + } + if (!authorizationClaim) { + authorizationClaim = { + idempotencyKey, + paymentHash, + state: 'processing', + phase: 'verification', + authoritative: false, + expiresAtMs: null, + }; + // Both indexes are populated synchronously before the first facilitator + // await. One authorization cannot race under another key, and one key + // cannot accumulate many ambiguous authorization identities. + authorizationClaims.set(authorizationIdentity, authorizationClaim); + authorizationClaimByIdempotencyKey.set(idempotencyKey, authorizationIdentity); + activeAuthorizationClaim = { + authorizationIdentity, + claim: authorizationClaim, + priorState: null, + }; + } else { + if (authorizationClaim.state === 'processing') { + return c.json({ + error: 'payment authorization is already being processed', + code: 'PAYMENT_AUTHORIZATION_IN_PROGRESS', + }, 409); + } + activeAuthorizationClaim = { + authorizationIdentity, + claim: authorizationClaim, + priorState: authorizationClaim.state, + }; + authorizationClaim.state = 'processing'; + } + + const retainAuthorizationClaim = (phase) => { + authorizationClaim.state = 'unresolved'; + authorizationClaim.phase = phase; + }; + const beginAuthorizationExecution = () => { + authorizationClaim.state = 'executing'; + authorizationClaim.phase = 'execution'; + authorizationClaim.expiresAtMs = null; + }; + const consumeAuthorizationClaim = () => { + let completionNowMs = requestNowMs; + try { + const candidateNowMs = now(); + if (Number.isSafeInteger(candidateNowMs) && candidateNowMs >= requestNowMs) { + completionNowMs = candidateNowMs; + } + } catch { + // The validated request clock remains the conservative fallback. + } + authorizationClaim.state = 'consumed'; + authorizationClaim.phase = 'consumed'; + authorizationClaim.expiresAtMs = completionNowMs + <= Number.MAX_SAFE_INTEGER - pendingOfferTtlMs + ? completionNowMs + pendingOfferTtlMs + : Number.MAX_SAFE_INTEGER; + }; + const rejectAndReleaseAuthorization = async (reason) => { + try { + await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); + } catch { + retainAuthorizationClaim('rejection_persistence'); + return false; + } + releaseAuthorizationClaim(authorizationIdentity, authorizationClaim); + return true; + }; let paymentVerified = frozen.verifiedPaymentHash === paymentHash; const verifyPayment = async () => { facilitatorStarted ??= performance.now(); @@ -502,20 +634,32 @@ export function x402Paywall({ if (frozen.verificationRequired && !paymentVerified) { const verify = await verifyPayment(); if (verify === null) { + retainAuthorizationClaim('verification'); return c.json({ error: 'payment verification unresolved', settlementReference }, 503); } - if (!verify?.isValid) { + if (verify?.isValid !== true) { + const reason = 'payment verification failed'; + // Verification precedes onSigned and therefore precedes durable + // Invocation authority. An explicit facilitator rejection proves that + // settlement did not begin, so this process-local claim is safe to + // release without asking a journal to reject a record that does not + // exist yet. + releaseAuthorizationClaim(authorizationIdentity, authorizationClaim); return c.json({ x402Version: X402_VERSION, - error: 'payment verification failed', + error: reason, accepts: [requirements], }, 402); } paymentVerified = true; - if (pendingOffers.get(idempotencyKey) === frozen) { - frozen.verifiedPaymentHash = paymentHash; + authorizationClaim.phase = 'verified'; + const pendingOffer = pendingOffers.get(idempotencyKey); + if (pendingOffer) { + pendingOffer.verifiedPaymentHash = paymentHash; + pendingOffer.verificationRequired = false; } } + if (paymentVerified) authorizationClaim.phase = 'verified'; let priorDecision = null; try { priorDecision = await lifecycle.onSigned?.({ @@ -527,27 +671,42 @@ export function x402Paywall({ verifiedPaymentHash: paymentHash, }); } catch { + retainAuthorizationClaim('signed_authority'); return c.json({ error: 'paid retry conflicts with authoritative Invocation state' }, 409); } const authoritativeSigned = priorDecision?.kind === 'signed'; + if (priorDecision?.kind) authorizationClaim.authoritative = true; const retainLocalUnresolved = () => { // This key already owns either a pending or transient admission, so // converting it to unresolved cannot raise the hard unique-key count. locallyUnresolvedSettlements.add(idempotencyKey); + retainAuthorizationClaim('settlement'); if (authoritativeSigned) pendingOffers.delete(idempotencyKey); }; + if (activeAuthorizationClaim.priorState === 'consumed' + && !['terminal', 'settled', 'payment_unresolved', 'execution_unresolved'] + .includes(priorDecision?.kind)) { + retainAuthorizationClaim('execution_authority'); + return c.json({ + error: 'consumed payment requires trusted terminal replay authority', + }, 503); + } + if (locallyUnresolvedSettlements.has(idempotencyKey) && !['terminal', 'settled'].includes(priorDecision?.kind)) { + retainAuthorizationClaim('settlement'); return unresolvedResponse(settlementReference); } if (priorDecision?.kind === 'terminal') { if (!terminalReplayIsTrusted(priorDecision, payer)) { + retainAuthorizationClaim('terminal_authority'); return c.json({ error: 'terminal replay lacks a settled or refunded transaction' }, 503); } locallyUnresolvedSettlements.delete(idempotencyKey); pendingOffers.delete(idempotencyKey); + consumeAuthorizationClaim(); const body = { replayed: true, receipt: priorDecision.receipt, @@ -566,18 +725,21 @@ export function x402Paywall({ return replay; } if (priorDecision?.kind === 'payment_unresolved') { + retainAuthorizationClaim('settlement'); return c.json({ error: 'payment settlement unresolved; trusted reconciliation is required', settlementReference, }, 503); } if (priorDecision?.kind === 'execution_unresolved') { + retainAuthorizationClaim('execution'); return c.json({ error: 'execution outcome unresolved; trusted executor reconciliation is required', executionAttemptId: priorDecision.executionAttemptId, }, 503); } + authorizationClaim.phase = 'pre_settlement'; try { await lifecycle.beforeSettlement?.({ context: c, @@ -589,6 +751,7 @@ export function x402Paywall({ verifiedPaymentHash: paymentHash, }); } catch (error) { + retainAuthorizationClaim('pre_settlement'); const known = ['PROVIDER_SPEND_CAP', 'PROVIDER_ATTEMPT_IN_PROGRESS'].includes(error?.code); return c.json({ error: known ? error.message : 'pre-settlement authorization failed', @@ -601,6 +764,7 @@ export function x402Paywall({ if (priorDecision?.kind === 'settled') { if (!validTxHash(priorDecision.txHash) || String(priorDecision.payer ?? '').toLowerCase() !== payer) { + retainAuthorizationClaim('settlement_authority'); return c.json({ error: 'persisted settlement proof does not match the signed payer' }, 503); } locallyUnresolvedSettlements.delete(idempotencyKey); @@ -624,9 +788,13 @@ export function x402Paywall({ }); return c.json({ error: 'payment verification unresolved', settlementReference }, 503); } - if (!verify?.isValid) { + if (verify?.isValid !== true) { const reason = 'payment verification failed'; - await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); + if (!await rejectAndReleaseAuthorization(reason)) { + return c.json({ + error: 'payment rejection conflicts with authoritative Invocation state', + }, 409); + } return c.json({ x402Version: X402_VERSION, error: reason, @@ -635,6 +803,7 @@ export function x402Paywall({ } paymentVerified = true; } + authorizationClaim.phase = 'settlement'; try { facilitatorStarted ??= performance.now(); settle = await postJson(transport, 'settle', facilitatorBody, { @@ -666,7 +835,12 @@ export function x402Paywall({ return c.json({ error: 'payment settlement unresolved', settlementReference }, 503); } const reason = 'payment settlement failed'; - await lifecycle.onRejected?.({ idempotencyKey, reason, settlementReference, payer }); + if (!await rejectAndReleaseAuthorization(reason)) { + return c.json({ + error: 'payment rejection conflicts with authoritative Invocation state', + }, 409); + } + locallyUnresolvedSettlements.delete(idempotencyKey); if (authoritativeSigned) pendingOffers.delete(idempotencyKey); return c.json({ x402Version: X402_VERSION, @@ -718,6 +892,13 @@ export function x402Paywall({ if (authoritativeSigned) pendingOffers.delete(idempotencyKey); } + if (['signed', 'settled'].includes(priorDecision?.kind)) { + authorizationClaim.authoritative = true; + } + // Claim consumption precedes the handler. A provider or response failure + // can never reopen the same monetary authorization for another execution. + beginAuthorizationExecution(); + c.set('x402', { idempotencyKey, settlementReference, @@ -729,6 +910,7 @@ export function x402Paywall({ legacySchemaVersion: priorDecision?.legacySchemaVersion ?? null, }); await next(); + consumeAuthorizationClaim(); c.res.headers.set('X-PAYMENT-RESPONSE', jsonToB64(paymentResponseEvidence({ idempotencyKey, requirements, @@ -738,6 +920,17 @@ export function x402Paywall({ }))); c.res.headers.set('X-402-FACILITATOR-MS', facilitatorMs.toFixed(1)); } finally { + if (activeAuthorizationClaim + && authorizationClaims.get(activeAuthorizationClaim.authorizationIdentity) + === activeAuthorizationClaim.claim + && ['processing', 'executing'].includes(activeAuthorizationClaim.claim.state)) { + if (activeAuthorizationClaim.claim.state === 'executing') { + consumeAuthorizationClaim(); + } else { + activeAuthorizationClaim.claim.state = 'unresolved'; + activeAuthorizationClaim.claim.phase = 'unexpected_failure'; + } + } if (transientAdmission) transientAdmissions.delete(idempotencyKey); } }; @@ -765,7 +958,12 @@ async function postJson(transport, operation, body, { body: JSON.stringify(body), }); if (!response?.ok) { - throw new RuntimeBoundaryError('FACILITATOR_HTTP', 'facilitator returned an unsuccessful status'); + const error = new RuntimeBoundaryError( + 'FACILITATOR_HTTP', + 'facilitator returned an unsuccessful status', + ); + cancelResponseBody(response, error); + throw error; } return readJsonBody(response, { maxBytes: maxResponseBytes, diff --git a/spikes/pi-wielder/tests/collar-cogs.test.mjs b/spikes/pi-wielder/tests/collar-cogs.test.mjs index c7c83d3..b64a26f 100644 --- a/spikes/pi-wielder/tests/collar-cogs.test.mjs +++ b/spikes/pi-wielder/tests/collar-cogs.test.mjs @@ -193,6 +193,24 @@ test('synthetic pricing blocks live adapter construction even when live mode is assert.equal(constructions, 0); }); +test('Collar live-provider gate accepts only the exact boolean true', () => { + let constructions = 0; + for (const allowLiveProvider of [false, 1, 'true', {}, []]) { + assert.throws(() => createCollar({ + facilitatorTransport: createMockFacilitatorTransport(async () => { + throw new Error('must not fetch'); + }), + mockLlm: false, + allowLiveProvider, + liveExecutorFactory: () => { + constructions += 1; + return async () => ({ output: '', usage: null }); + }, + }), (error) => error.code === 'LIVE_PRICING_UNAPPROVED'); + } + assert.equal(constructions, 0); +}); + test('live approval is rechecked against canonical catalog bytes before adapter construction', () => { const catalog = structuredClone(EXECUTION_CATALOG); Object.assign(catalog, { diff --git a/spikes/pi-wielder/tests/collar-failure.test.mjs b/spikes/pi-wielder/tests/collar-failure.test.mjs index 346ec3f..237251a 100644 --- a/spikes/pi-wielder/tests/collar-failure.test.mjs +++ b/spikes/pi-wielder/tests/collar-failure.test.mjs @@ -944,13 +944,19 @@ test('failed facilitator verification creates no authority and leaks no detail', assert.equal(executionCalls, 0); }); -test('Anthropic error response bodies are never copied into the failed receipt', async () => { +test('Anthropic error response bodies are cancelled and never copied into the failed receipt', async () => { const responseSecret = 'sk-ant-secret-inside-upstream-body'; + let cancelled = false; const executeSkill = createAnthropicExecutor({ apiKey: 'test-only-key', fetchImpl: async (url) => { assert.equal(url, 'https://api.anthropic.com/v1/messages'); - return new Response(JSON.stringify({ error: responseSecret }), { + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(JSON.stringify({ error: responseSecret }))); + }, + cancel() { cancelled = true; }, + }), { status: 500, headers: { 'content-type': 'application/json' }, }); @@ -965,6 +971,7 @@ test('Anthropic error response bodies are never copied into the failed receipt', }); assert.equal(result.res.status, 500); const text = await result.res.text(); + assert.equal(cancelled, true); assert.doesNotMatch(text, new RegExp(responseSecret)); assert.match(text, /Skill execution failed after settlement/); }); diff --git a/spikes/pi-wielder/tests/gateway-transport.test.mjs b/spikes/pi-wielder/tests/gateway-transport.test.mjs index cca02cd..e4a6ef4 100644 --- a/spikes/pi-wielder/tests/gateway-transport.test.mjs +++ b/spikes/pi-wielder/tests/gateway-transport.test.mjs @@ -87,6 +87,21 @@ function openAiCompletion({ promptTokens = 2, completionTokens = 3 } = {}) { }; } +function anthropicCompletion({ inputTokens = 2, outputTokens = 3 } = {}) { + return { + id: 'msg-test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'provider output' }], + stop_reason: 'end_turn', + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens, + }, + }; +} + async function paidGatewayRequest(gateway, body, idempotencyKey) { return payingFetch(throwawayAccount(), GATEWAY_URL, { method: 'POST', @@ -128,7 +143,10 @@ test('gateway prices are decimal strings and the injected transport stays in pro const paid = await payingFetch(throwawayAccount(), 'http://gateway.test/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] }), + body: JSON.stringify({ + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'price this request' }], + }), }, { fetchImpl: (url, init) => gateway.request(url, init), idempotencyKey: 'idem-gateway', @@ -221,9 +239,14 @@ test('gateway rejects unknown models and request token bounds before offering pa assert.ok(framingAllowanceBytes < HUMAN_VERIFIED_CATALOG.models['gpt-5.2'].maxInputTokens); assert.ok(framingAllowanceBytes + 1_024 > HUMAN_VERIFIED_CATALOG.models['gpt-5.2'].maxInputTokens); const invalidBodies = [ - { model: 'attacker-model', messages: [], max_tokens: 8 }, - { model: 'gpt-5.2', messages: [], max_tokens: 65 }, - { model: 'gpt-5.2', messages: [], max_tokens: 8, max_completion_tokens: 8 }, + { model: 'attacker-model', messages: [{ role: 'user', content: 'reject this model' }], max_tokens: 8 }, + { model: 'gpt-5.2', messages: [{ role: 'user', content: 'reject this limit' }], max_tokens: 65 }, + { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'reject conflicting limits' }], + max_tokens: 8, + max_completion_tokens: 8, + }, { model: 'gpt-5.2', messages: [{ role: 'user', content: 'x'.repeat(5000) }], max_tokens: 8 }, framingAllowanceCase, ]; @@ -242,6 +265,250 @@ test('gateway rejects unknown models and request token bounds before offering pa assert.equal(providerCalls, 0); }); +test('gateway rejects malformed message structure before offering payment', async () => { + const facilitator = createMockFacilitator(); + let facilitatorCalls = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + facilitatorCalls += 1; + return facilitator.request(url, init); + }); + let providerCalls = 0; + const gateway = createGateway(liveGatewayOptions(async () => { + providerCalls += 1; + return Response.json(openAiCompletion()); + }, { facilitatorTransport: transport })); + + const invalidMessages = [ + {}, + [], + [null], + [{ content: 'missing role' }], + [{ role: 'attacker', content: 'unsupported role' }], + [{ role: 'user' }], + [{ role: 'user', content: {} }], + [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.test' } }] }], + [{ role: 'user', content: [{ type: 'text' }] }], + ]; + for (let index = 0; index < invalidMessages.length; index += 1) { + const response = await gateway.request(GATEWAY_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': `idem-gateway-malformed-messages-${index}`, + }, + body: JSON.stringify({ model: 'gpt-5.2', messages: invalidMessages[index], max_tokens: 8 }), + }); + assert.equal(response.status, 400, `invalid message case ${index}`); + assert.deepEqual(await response.json(), { + error: 'gateway request is invalid', + code: 'REQUEST_SCHEMA', + }); + } + for (const [index, body] of ['{', 'null', '[]'].entries()) { + const response = await gateway.request(GATEWAY_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': `idem-gateway-malformed-json-${index}`, + }, + body, + }); + assert.equal(response.status, 400, `invalid JSON case ${index}`); + assert.deepEqual(await response.json(), { + error: 'gateway request is invalid', + code: 'REQUEST_SCHEMA', + }); + } + assert.equal(facilitatorCalls, 0); + assert.equal(providerCalls, 0); +}); + +test('gateway rejects malformed tool definitions before offering payment', async () => { + const facilitator = createMockFacilitator(); + let facilitatorCalls = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + facilitatorCalls += 1; + return facilitator.request(url, init); + }); + let providerCalls = 0; + const gateway = createGateway(liveGatewayOptions(async () => { + providerCalls += 1; + return Response.json(openAiCompletion()); + }, { facilitatorTransport: transport })); + const invalidTools = [ + {}, + [{}], + [null], + [{ type: 'custom', function: { name: 'lookup', parameters: {} } }], + [{ type: 'function' }], + [{ type: 'function', function: [] }], + [{ type: 'function', function: { parameters: {} } }], + [{ type: 'function', function: { name: '', parameters: {} } }], + [{ type: 'function', function: { name: 'lookup', parameters: [] } }], + [{ type: 'function', function: { name: 'lookup', parameters: {}, description: 7 } }], + [{ type: 'function', function: { name: 'lookup', parameters: {}, strict: 'true' } }], + [{ type: 'function', function: { name: 'lookup', parameters: {}, unsupported: true } }], + [{ type: 'function', function: { name: 'lookup', parameters: {} }, unsupported: true }], + ]; + + for (let index = 0; index < invalidTools.length; index += 1) { + const response = await gateway.request(GATEWAY_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': `idem-gateway-malformed-tools-${index}`, + }, + body: JSON.stringify({ + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'use a tool' }], + tools: invalidTools[index], + max_tokens: 8, + }), + }); + assert.equal(response.status, 400, `invalid tool case ${index}`); + assert.deepEqual(await response.json(), { + error: 'gateway request is invalid', + code: 'REQUEST_SCHEMA', + }); + } + assert.equal(facilitatorCalls, 0); + assert.equal(providerCalls, 0); +}); + +test('gateway rejects malformed tool-call messages before offering payment', async () => { + const facilitator = createMockFacilitator(); + let facilitatorCalls = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + facilitatorCalls += 1; + return facilitator.request(url, init); + }); + let providerCalls = 0; + const gateway = createGateway(liveGatewayOptions(async () => { + providerCalls += 1; + return Response.json(openAiCompletion()); + }, { facilitatorTransport: transport })); + const invalidMessages = [ + [{ role: 'assistant', content: null }], + [{ role: 'assistant', content: null, tool_calls: {} }], + [{ role: 'assistant', content: null, tool_calls: [] }], + [{ role: 'assistant', content: null, tool_calls: [{}] }], + [{ role: 'assistant', content: null, tool_calls: [{ + id: 'call-1', type: 'custom', function: { name: 'lookup', arguments: '{}' }, + }] }], + [{ role: 'assistant', content: null, tool_calls: [{ + id: '', type: 'function', function: { name: 'lookup', arguments: '{}' }, + }] }], + [{ role: 'assistant', content: null, tool_calls: [{ + id: 'call-1', type: 'function', function: { name: 'lookup', arguments: '{not-json' }, + }] }], + [{ role: 'assistant', content: null, tool_calls: [{ + id: 'call-1', type: 'function', function: { name: 'lookup', arguments: '[]' }, + }] }], + [{ role: 'assistant', content: null, tool_calls: [{ + id: 'call-1', type: 'function', function: { name: 'lookup', arguments: {} }, + }] }], + [{ role: 'assistant', content: null, tool_calls: [{ + id: 'call-1', type: 'function', function: { name: 'lookup', arguments: '{}', extra: true }, + }] }], + [{ role: 'tool', content: 'result' }], + [{ role: 'tool', content: 'result', tool_call_id: '' }], + [{ role: 'tool', content: 'result', tool_call_id: 'x'.repeat(257) }], + ]; + + for (let index = 0; index < invalidMessages.length; index += 1) { + const response = await gateway.request(GATEWAY_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': `idem-gateway-malformed-tool-calls-${index}`, + }, + body: JSON.stringify({ model: 'claude-sonnet-4-6', messages: invalidMessages[index], max_tokens: 8 }), + }); + assert.equal(response.status, 400, `invalid tool-call message case ${index}`); + assert.deepEqual(await response.json(), { + error: 'gateway request is invalid', + code: 'REQUEST_SCHEMA', + }); + } + assert.equal(facilitatorCalls, 0); + assert.equal(providerCalls, 0); +}); + +test('gateway rejects unknown and malformed provider options before offering payment', async () => { + const facilitator = createMockFacilitator(); + let facilitatorCalls = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + facilitatorCalls += 1; + return facilitator.request(url, init); + }); + let providerCalls = 0; + const gateway = createGateway(liveGatewayOptions(async () => { + providerCalls += 1; + return Response.json(openAiCompletion()); + }, { facilitatorTransport: transport })); + const openAiBase = { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'validate options' }], + max_tokens: 8, + }; + const anthropicBase = { ...openAiBase, model: 'claude-sonnet-4-6' }; + const invalidBodies = [ + { ...openAiBase, unknown_option: true }, + { ...openAiBase, stream: 'false' }, + { ...openAiBase, temperature: '0.5' }, + { ...openAiBase, temperature: 2.1 }, + { ...anthropicBase, temperature: 0.4 }, + { ...anthropicBase, temperature: 1.1 }, + { ...openAiBase, top_p: -0.1 }, + { ...openAiBase, top_p: 1.1 }, + { ...anthropicBase, top_p: 0.8 }, + { ...openAiBase, stop: {} }, + { ...openAiBase, stop: [] }, + { ...openAiBase, stop: ['', 'valid'] }, + { ...openAiBase, stop: ['1', '2', '3', '4', '5'] }, + { ...openAiBase, presence_penalty: 2.1 }, + { ...openAiBase, frequency_penalty: -2.1 }, + { ...anthropicBase, presence_penalty: 0 }, + { ...openAiBase, response_format: [] }, + { ...openAiBase, response_format: { type: 'unknown' } }, + { ...openAiBase, response_format: { type: 'json_schema', json_schema: {} } }, + { ...anthropicBase, response_format: { type: 'json_object' } }, + { ...openAiBase, seed: 1.5 }, + { ...anthropicBase, seed: 1 }, + { ...openAiBase, user: {} }, + { ...anthropicBase, user: 'wielder-1' }, + { + ...anthropicBase, + tools: [{ + type: 'function', + function: { name: 'lookup', parameters: {}, strict: true }, + }], + }, + { ...openAiBase, tool_choice: 'attacker' }, + { ...anthropicBase, tool_choice: 'auto' }, + { ...openAiBase, tool_choice: { type: 'function', function: {} } }, + { ...openAiBase, tool_choice: { type: 'function', function: { name: 'missing_tool' } } }, + ]; + + for (let index = 0; index < invalidBodies.length; index += 1) { + const response = await gateway.request(GATEWAY_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': `idem-gateway-malformed-options-${index}`, + }, + body: JSON.stringify(invalidBodies[index]), + }); + assert.equal(response.status, 400, `invalid option case ${index}`); + assert.deepEqual(await response.json(), { + error: 'gateway request is invalid', + code: 'REQUEST_SCHEMA', + }); + } + assert.equal(facilitatorCalls, 0); + assert.equal(providerCalls, 0); +}); + test('approved provider fetch receives redirect refusal and a composed request signal', async () => { let capturedUrl = null; let capturedInit = null; @@ -265,6 +532,201 @@ test('approved provider fetch receives redirect refusal and a composed request s }); }); +test('gateway normalizes canonical Pi text parts for an OpenAI provider', async () => { + let capturedBody = null; + const providerFetch = async (_url, init) => { + capturedBody = JSON.parse(init.body); + return Response.json(openAiCompletion()); + }; + const gateway = createGateway(liveGatewayOptions(providerFetch)); + const messages = [ + { + role: 'developer', + name: 'planner', + content: [{ type: 'input_text', text: 'Use the weather Skill.' }], + }, + { role: 'user', content: [{ type: 'text', text: 'Weather in Paris?' }] }, + { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_weather_1', + type: 'function', + function: { name: 'lookup_weather', arguments: '{not-json' }, + }], + }, + { + role: 'tool', + tool_call_id: 'call_weather_1', + content: [{ type: 'output_text', text: 'Sunny' }], + }, + ]; + const tools = [{ + type: 'function', + function: { + name: 'lookup_weather', + description: 'Look up weather', + parameters: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + additionalProperties: false, + }, + strict: true, + }, + }]; + + const paid = await paidGatewayRequest(gateway, { + model: 'gpt-5.2', + messages, + tools, + tool_choice: { type: 'function', function: { name: 'lookup_weather' } }, + temperature: 0.5, + top_p: 0.9, + stop: ['DONE'], + presence_penalty: 0.25, + frequency_penalty: -0.25, + response_format: { + type: 'json_schema', + json_schema: { + name: 'weather_result', + schema: { type: 'object', properties: { weather: { type: 'string' } } }, + strict: true, + }, + }, + seed: 7, + user: 'wielder-1', + max_completion_tokens: 8, + stream: true, + }, 'idem-gateway-valid-openai-tool-shape'); + + assert.equal(paid.res.status, 200); + assert.deepEqual(capturedBody, { + messages: [ + { + role: 'developer', + name: 'planner', + content: [{ type: 'text', text: 'Use the weather Skill.' }], + }, + { role: 'user', content: [{ type: 'text', text: 'Weather in Paris?' }] }, + { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_weather_1', + type: 'function', + function: { name: 'lookup_weather', arguments: '{not-json' }, + }], + }, + { + role: 'tool', + tool_call_id: 'call_weather_1', + content: [{ type: 'text', text: 'Sunny' }], + }, + ], + tools, + tool_choice: { type: 'function', function: { name: 'lookup_weather' } }, + temperature: 0.5, + top_p: 0.9, + stop: ['DONE'], + presence_penalty: 0.25, + frequency_penalty: -0.25, + response_format: { + type: 'json_schema', + json_schema: { + name: 'weather_result', + schema: { type: 'object', properties: { weather: { type: 'string' } } }, + strict: true, + }, + }, + seed: 7, + user: 'wielder-1', + model: 'gpt-5.2', + max_completion_tokens: 8, + n: 1, + stream: false, + }); +}); + +test('gateway translates canonical Pi tool messages for an Anthropic provider', async () => { + let capturedUrl = null; + let capturedBody = null; + const providerFetch = async (url, init) => { + capturedUrl = url; + capturedBody = JSON.parse(init.body); + return Response.json(anthropicCompletion()); + }; + const gateway = createGateway(liveGatewayOptions(providerFetch)); + + const paid = await paidGatewayRequest(gateway, { + model: 'claude-sonnet-4-6', + messages: [ + { role: 'developer', content: [{ type: 'input_text', text: 'Developer policy' }] }, + { role: 'system', content: 'System policy' }, + { role: 'user', content: [{ type: 'text', text: 'Weather in Paris?' }] }, + { + role: 'assistant', + content: [{ type: 'output_text', text: 'I will check.' }], + tool_calls: [{ + id: 'call_weather_2', + type: 'function', + function: { name: 'lookup_weather', arguments: '{"city":"Paris"}' }, + }], + }, + { + role: 'tool', + tool_call_id: 'call_weather_2', + content: [{ type: 'input_text', text: 'Sunny' }], + }, + ], + tools: [{ + type: 'function', + function: { + name: 'lookup_weather', + description: 'Look up weather', + parameters: { type: 'object', properties: { city: { type: 'string' } } }, + }, + }], + tool_choice: 'required', + stop: ['DONE'], + max_tokens: 8, + }, 'idem-gateway-valid-anthropic-tool-shape'); + + assert.equal(paid.res.status, 200); + assert.equal(capturedUrl, 'https://api.anthropic.com/v1/messages'); + assert.deepEqual(capturedBody, { + model: 'claude-sonnet-4-6', + max_tokens: 8, + stop_sequences: ['DONE'], + system: 'Developer policy\nSystem policy', + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Weather in Paris?' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'I will check.' }, + { + type: 'tool_use', + id: 'call_weather_2', + name: 'lookup_weather', + input: { city: 'Paris' }, + }, + ], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_weather_2', content: 'Sunny' }], + }, + ], + tools: [{ + name: 'lookup_weather', + description: 'Look up weather', + input_schema: { type: 'object', properties: { city: { type: 'string' } } }, + }], + tool_choice: { type: 'any' }, + }); +}); + test('live provider spend approval is a cumulative process-run budget', async () => { let providerCalls = 0; const providerFetch = async () => { @@ -356,7 +818,7 @@ test('provider wall-clock deadline aborts an ignoring fetch and returns only a s const gateway = createGateway(liveGatewayOptions(providerFetch, { providerTimeoutMs: 5 })); const started = performance.now(); const paid = await paidGatewayRequest(gateway, { - model: 'gpt-5.2', messages: [], max_tokens: 8, + model: 'gpt-5.2', messages: [{ role: 'user', content: 'time out' }], max_tokens: 8, }, 'idem-gateway-provider-timeout'); assert.ok(performance.now() - started < 45); assert.equal(paid.res.status, 504); @@ -371,7 +833,11 @@ test('provider wall-clock deadline aborts an ignoring fetch and returns only a s 'content-type': 'application/json', 'idempotency-key': 'idem-gateway-after-provider-timeout', }, - body: JSON.stringify({ model: 'gpt-5.2', messages: [], max_tokens: 8 }), + body: JSON.stringify({ + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'budget is held' }], + max_tokens: 8, + }), }); assert.equal(nextOffer.status, 503); assert.deepEqual(await nextOffer.json(), { @@ -400,7 +866,7 @@ test('provider response is cancelled on the first streamed byte over one MiB', a await withSyntheticGlobalProvider(providerFetch, async () => { const gateway = createGateway(liveGatewayOptions(providerFetch)); const paid = await paidGatewayRequest(gateway, { - model: 'gpt-5.2', messages: [], max_tokens: 8, + model: 'gpt-5.2', messages: [{ role: 'user', content: 'oversized response' }], max_tokens: 8, }, 'idem-gateway-provider-oversize'); assert.equal(paid.res.status, 502); assert.equal(cancelled, true); @@ -429,7 +895,7 @@ test('provider HTTP errors cancel without consuming or exposing the raw response await withSyntheticGlobalProvider(providerFetch, async () => { const gateway = createGateway(liveGatewayOptions(providerFetch)); const paid = await paidGatewayRequest(gateway, { - model: 'gpt-5.2', messages: [], max_tokens: 8, + model: 'gpt-5.2', messages: [{ role: 'user', content: 'provider failure' }], max_tokens: 8, }, 'idem-gateway-provider-http-error'); assert.equal(paid.res.status, 502); assert.equal(textCalls, 0); @@ -448,7 +914,7 @@ test('provider usage outside the approved request bounds is rejected without out await withSyntheticGlobalProvider(providerFetch, async () => { const gateway = createGateway(liveGatewayOptions(providerFetch)); const paid = await paidGatewayRequest(gateway, { - model: 'gpt-5.2', messages: [], max_tokens: 8, + model: 'gpt-5.2', messages: [{ role: 'user', content: 'invalid usage' }], max_tokens: 8, }, 'idem-gateway-provider-usage-overrun'); assert.equal(paid.res.status, 502); assert.deepEqual(await paid.res.json(), { diff --git a/spikes/pi-wielder/tests/paying-fetch.test.mjs b/spikes/pi-wielder/tests/paying-fetch.test.mjs index 3c94749..0e04b2a 100644 --- a/spikes/pi-wielder/tests/paying-fetch.test.mjs +++ b/spikes/pi-wielder/tests/paying-fetch.test.mjs @@ -627,6 +627,38 @@ test('missing, malformed, unknown-key, and mismatched settlement evidence withho } }); +test('missing or mismatched paid settlement evidence cancels the withheld output body', async () => { + for (const evidence of ['missing', 'mismatched']) { + const { account, paymentPolicy } = setup(); + let fetches = 0; + let cancelled = false; + await assert.rejects(() => payingFetch(account, URL, { method: 'POST', body: BODY }, { + fetchImpl: async (_url, init) => { + fetches += 1; + if (fetches === 1) return challenge(); + const headers = evidence === 'mismatched' + ? { + 'X-PAYMENT-RESPONSE': Buffer.from(JSON.stringify( + settlementFor(init, { value: '250001' }), + )).toString('base64'), + } + : {}; + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"secretOutput":"must stay withheld"}')); + }, + cancel() { cancelled = true; }, + }), { status: 200, headers }); + }, + idempotencyKey: `idem-cancel-bad-settlement-${evidence}`, + paymentPolicy, + }), (error) => error.code === 'SETTLEMENT_EVIDENCE'); + assert.equal(cancelled, true, evidence); + assert.equal(paymentPolicy.snapshot().reservedAtomic, '250000', evidence); + assert.equal(paymentPolicy.snapshot().authorizations[0].state, 'unresolved', evidence); + } +}); + test('a settled HTTP 500 consumes spend and returns exactly the documented paid result', async () => { const { account, paymentPolicy, signatureCount } = setup(); let fetches = 0; diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs index 68389b1..96667c2 100644 --- a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -130,6 +130,291 @@ test('challenge and retry emit one ordered lifecycle under one idempotency key', assert.equal(result.amountDisplay, '0.250000'); }); +test('a non-authoritative paywall consumes one exact paid authorization only once per offer', async () => { + const facilitator = createMockFacilitator(); + let verifyCalls = 0; + let settleCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname; + if (operation === '/verify') verifyCalls += 1; + if (operation === '/settle') settleCalls += 1; + return facilitator.request(url, init); + }); + const app = resourceApp({ + facilitatorTransport: transport, + handler: (c) => { + executions += 1; + return c.json({ ok: true }); + }, + }); + const idempotencyKey = 'idem-local-authorization-consumed-once'; + const first = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey, + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(first.res.status, 200); + + const replay = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': idempotencyKey, 'X-PAYMENT': first.xPayment }, + body: '{}', + }); + assert.equal(replay.status, 409); + assert.deepEqual(await replay.json(), { + error: 'payment authorization was already consumed; response replay is unavailable', + code: 'PAYMENT_AUTHORIZATION_CONSUMED', + }); + assert.equal(verifyCalls, 1); + assert.equal(settleCalls, 1); + assert.equal(executions, 1); +}); + +test('one payment authorization cannot execute again under a different idempotency key', async () => { + const fixedNow = Date.now(); + const facilitator = createMockFacilitator(); + let verifyCalls = 0; + let settleCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname; + if (operation === '/verify') verifyCalls += 1; + if (operation === '/settle') settleCalls += 1; + return facilitator.request(url, init); + }); + const app = resourceApp({ + facilitatorTransport: transport, + now: () => fixedNow, + handler: (c) => { + executions += 1; + return c.json({ ok: true }); + }, + }); + const first = await payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'authorization-owner', + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(first.res.status, 200); + + const secondOffer = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': 'authorization-replay' }, + body: '{}', + }); + assert.equal(secondOffer.status, 402); + const replay = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { + 'Idempotency-Key': 'authorization-replay', + 'X-PAYMENT': first.xPayment, + }, + body: '{}', + }); + + assert.equal(replay.status, 409); + assert.deepEqual(await replay.json(), { + error: 'payment authorization is already claimed by a different Idempotency-Key', + code: 'PAYMENT_AUTHORIZATION_CLAIMED', + }); + + const originalEnvelope = JSON.parse(Buffer.from(first.xPayment, 'base64').toString('utf8')); + const originalAuthorization = originalEnvelope.payload.authorization; + const reorderedPayment = Buffer.from(JSON.stringify({ + network: originalEnvelope.network, + payload: { + authorization: { + nonce: originalAuthorization.nonce, + validBefore: originalAuthorization.validBefore, + validAfter: originalAuthorization.validAfter, + value: originalAuthorization.value, + to: originalAuthorization.to, + from: originalAuthorization.from, + }, + signature: originalEnvelope.payload.signature, + }, + scheme: originalEnvelope.scheme, + x402Version: originalEnvelope.x402Version, + })).toString('base64'); + assert.notEqual(reorderedPayment, first.xPayment); + const reorderedOffer = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': 'authorization-reordered-replay' }, + body: '{}', + }); + assert.equal(reorderedOffer.status, 402); + const reorderedReplay = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { + 'Idempotency-Key': 'authorization-reordered-replay', + 'X-PAYMENT': reorderedPayment, + }, + body: '{}', + }); + assert.equal(reorderedReplay.status, 409); + assert.deepEqual(await reorderedReplay.json(), { + error: 'payment authorization is already claimed by a different Idempotency-Key', + code: 'PAYMENT_AUTHORIZATION_CLAIMED', + }); + assert.equal(verifyCalls, 1); + assert.equal(settleCalls, 1); + assert.equal(executions, 1); +}); + +test('concurrent cross-key replay loses the authorization claim before facilitator verification', { + timeout: 5_000, +}, async () => { + let clockMs = Date.now(); + const facilitator = createMockFacilitator(); + let releaseFirstVerification; + let firstVerificationStarted; + const firstStarted = new Promise((resolve) => { firstVerificationStarted = resolve; }); + const firstRelease = new Promise((resolve) => { releaseFirstVerification = resolve; }); + let releaseHandler; + let handlerStarted; + const handlerStart = new Promise((resolve) => { handlerStarted = resolve; }); + const handlerRelease = new Promise((resolve) => { releaseHandler = resolve; }); + let verifyCalls = 0; + let settleCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname; + if (operation === '/verify') { + verifyCalls += 1; + if (verifyCalls === 1) { + firstVerificationStarted(); + await firstRelease; + } + } + if (operation === '/settle') settleCalls += 1; + return facilitator.request(url, init); + }); + const app = resourceApp({ + facilitatorTransport: transport, + now: () => clockMs, + handler: async (c) => { + executions += 1; + handlerStarted(); + await handlerRelease; + return c.json({ ok: true }); + }, + }); + let interceptedRequests = 0; + const signed = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: 'concurrent-authorization-owner', + fetchImpl: (url, init) => { + interceptedRequests += 1; + return interceptedRequests === 1 + ? app.request(url, init) + : Response.json({ error: 'withheld paid retry' }, { status: 503 }); + }, + }); + const secondOffer = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': 'concurrent-authorization-replay' }, + body: '{}', + }); + assert.equal(secondOffer.status, 402); + + const ownerPromise = app.request('http://seller.test/resource', { + method: 'POST', + headers: { + 'Idempotency-Key': signed.idempotencyKey, + 'X-PAYMENT': signed.xPayment, + }, + body: '{}', + }); + await firstStarted; + let replay; + let replayAfterTtl; + try { + replay = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { + 'Idempotency-Key': 'concurrent-authorization-replay', + 'X-PAYMENT': signed.xPayment, + }, + body: '{}', + }); + releaseFirstVerification(); + await handlerStart; + clockMs += DEFAULT_PENDING_OFFER_TTL_MS + 1; + const afterTtlOffer = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': 'concurrent-replay-after-ttl' }, + body: '{}', + }); + assert.equal(afterTtlOffer.status, 402); + replayAfterTtl = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { + 'Idempotency-Key': 'concurrent-replay-after-ttl', + 'X-PAYMENT': signed.xPayment, + }, + body: '{}', + }); + } finally { + releaseFirstVerification(); + releaseHandler(); + } + const owner = await ownerPromise; + + assert.equal(owner.status, 200); + assert.equal(replay.status, 409); + assert.deepEqual(await replay.json(), { + error: 'payment authorization is already claimed by a different Idempotency-Key', + code: 'PAYMENT_AUTHORIZATION_CLAIMED', + }); + assert.equal(replayAfterTtl.status, 409); + assert.deepEqual(await replayAfterTtl.json(), { + error: 'payment authorization is already claimed by a different Idempotency-Key', + code: 'PAYMENT_AUTHORIZATION_CLAIMED', + }); + assert.equal(verifyCalls, 1); + assert.equal(settleCalls, 1); + assert.equal(executions, 1); +}); + +test('facilitator verification accepts only the exact boolean true', async () => { + let caseIndex = 0; + for (const isValid of [1, 'true', {}, []]) { + const facilitator = createMockFacilitator(); + let verifyCalls = 0; + let settleCalls = 0; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url, init) => { + const operation = new URL(url).pathname; + if (operation === '/verify') { + verifyCalls += 1; + return Response.json({ isValid }); + } + settleCalls += 1; + return facilitator.request(url, init); + }); + const app = resourceApp({ + facilitatorTransport: transport, + handler: (c) => { + executions += 1; + return c.json({ ok: true }); + }, + }); + await assert.rejects(() => payingFetch(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: `idem-exact-verify-boolean-${caseIndex++}`, + fetchImpl: (url, init) => app.request(url, init), + }), (error) => error.code === 'SECOND_PAYMENT_REQUIRED'); + assert.equal(verifyCalls, 1); + assert.equal(settleCalls, 0); + assert.equal(executions, 0); + } +}); + test('a restarted paywall accepts only the complete persisted frozen offer', async () => { const facilitator = createMockFacilitator(); const transport = createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); @@ -507,6 +792,12 @@ test('unresolved keys retain bounded admission across TTL and release only for t }); assert.equal(trusted.status, resolutionKind === 'settled' ? 200 : 500, resolutionKind); + const retainedAfterResolution = await app.request('http://seller.test/resource', { + method: 'POST', headers: { 'Idempotency-Key': `released-${resolutionKind}` }, body: '{}', + }); + assert.equal(retainedAfterResolution.status, 503, resolutionKind); + + clockMs += 1_001; const afterResolution = await app.request('http://seller.test/resource', { method: 'POST', headers: { 'Idempotency-Key': `released-${resolutionKind}` }, body: '{}', }); @@ -974,6 +1265,44 @@ test('facilitator verify and settle deadlines abort ignoring transports and rema } }); +test('facilitator non-success response bodies are cancelled before payment stays unresolved', async () => { + for (const failedOperation of ['verify', 'settle']) { + let cancelled = false; + let executions = 0; + const transport = createMockFacilitatorTransport(async (url) => { + const operation = new URL(url).pathname.slice(1); + if (operation === failedOperation) { + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"secret":"withheld"}')); + }, + cancel() { cancelled = true; }, + }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ isValid: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + const app = resourceApp({ + facilitatorTransport: transport, + handler: (c) => { executions += 1; return c.json({ ok: true }); }, + }); + const held = await withheldAttempt(throwawayAccount(), 'http://seller.test/resource', { + method: 'POST', body: '{}', + }, { + idempotencyKey: `idem-facilitator-${failedOperation}-http`, + fetchImpl: (url, init) => app.request(url, init), + }); + assert.equal(cancelled, true, failedOperation); + assert.equal(held.state, 'unresolved', failedOperation); + assert.equal(executions, 0, failedOperation); + } +}); + test('oversized verification JSON is cancelled before authoritative payment state', async () => { let cancelled = false; let unresolvedReason = null; From 4230ac47d556c0f944ce20116d20b875a24fc7fc Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 16:58:06 -0400 Subject: [PATCH 142/165] fix: bind payment validity before settlement --- spikes/pi-wielder/README.md | 10 +++-- spikes/pi-wielder/RUNBOOK.md | 10 +++-- spikes/pi-wielder/src/gateway.mjs | 1 + spikes/pi-wielder/src/x402-seller.mjs | 44 +++++++++++++++++-- .../tests/gateway-transport.test.mjs | 7 +++ .../pi-wielder/tests/x402-lifecycle.test.mjs | 29 +++++++++--- 6 files changed, 82 insertions(+), 19 deletions(-) diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 97011fd..6511e6e 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -63,9 +63,10 @@ The standalone, non-authoritative paywall can consume an authorization only once verification it atomically claims the fixed network, asset, payer, and nonce for one Idempotency-Key, with the exact payment-header hash bound to that owner. Cross-key and alternate-encoding replays fail before another facilitator or handler call; a successful -claim remains in the same bounded TTL-scoped admission state. It cannot replay lost -output; durable terminal replay belongs to the Collar journal. Facilitator verification -succeeds only for the exact boolean `true`. +claim remains through its frozen offer validity window in the same bounded TTL-scoped +admission state. Authorization times must match that frozen offer exactly. It cannot +replay lost output; durable terminal replay belongs to the Collar journal. Facilitator +verification succeeds only for the exact boolean `true`. The caller's method, body bytes, and headers are captured once before the unpaid request. Method and body bytes bind the policy hash and signed recovery; captured @@ -243,7 +244,8 @@ request contract: allowed model, non-empty canonical message roles and text part function tools/calls/results, output-token bound, and provider-specific option types and ranges. Malformed JSON, unknown fields, and unsupported shapes receive a stable `400` without facilitator settlement or provider work. Anthropic options are either translated -explicitly or rejected; unsupported tool `strict` semantics are never silently dropped. +explicitly or rejected; unsupported tool `strict` semantics are never silently dropped, +and system/developer-only input is rejected before it can translate to no provider message. Timeout state follows the durable money boundary: an unpaid timeout creates no reservation; a signed retry or facilitator ambiguity stays `unresolved` with budget diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md index e8acc26..6b291ef 100644 --- a/spikes/pi-wielder/RUNBOOK.md +++ b/spikes/pi-wielder/RUNBOOK.md @@ -125,9 +125,10 @@ The standalone gateway has no durable response authority. Before verification it the fixed network, asset, payer, and nonce for exactly one Idempotency-Key and binds the exact verified payment-header hash to that owner. Cross-key, concurrent, and alternate- encoding reuse fails before a second verification, settlement, or provider execution; -a successful claim stays in bounded TTL-scoped admission state. Do not treat this as -response replay. The authoritative Collar journal remains the only terminal replay path. -Facilitator verification accepts only the exact boolean `true`. +a successful claim stays through its exact frozen-offer validity window in bounded +TTL-scoped admission state. Do not treat this as response replay. The authoritative Collar +journal remains the only terminal replay path. Facilitator verification accepts only the +exact boolean `true`. A restart after `402` but before successful verification intentionally loses that non-authoritative offer. A paid retry carrying the old key then gets `409` before any @@ -226,7 +227,8 @@ most one provider token and reserves another 1,024 tokens for provider-side chat framing. The pre-offer schema is closed: messages, text parts, function tools/calls/results, and provider-specific options must match the documented Pi/OpenAI shapes. Unknown or malformed fields fail with `400` before facilitator or provider activity. Anthropic -options are translated explicitly; unsupported `strict` tool semantics are rejected. +options are translated explicitly; unsupported `strict` tool semantics and requests that +would translate to no non-system provider message are rejected. Provider requests refuse redirects, use one absolute fetch-plus-body deadline, whose configurable value cannot exceed 30 seconds, and stream responses through a hard 1 MiB cap. Automated verification stays on the mock facilitator and mock model and uses diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index 81ecf7c..decad16 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -295,6 +295,7 @@ function assertGatewayProviderOptions(body, provider) { && body.tool_choice !== 'none' && !(body.tools?.length > 0)) || (provider === 'anthropic' && !anthropicToolArgumentsAreObjects(body)) + || (provider === 'anthropic' && toAnthropicMessages(body.messages).length === 0) || (Object.hasOwn(body, 'presence_penalty') && (provider !== 'openai' || !finiteNumberInRange(body.presence_penalty, -2, 2))) || (Object.hasOwn(body, 'frequency_penalty') diff --git a/spikes/pi-wielder/src/x402-seller.mjs b/spikes/pi-wielder/src/x402-seller.mjs index cc0ce2d..b284215 100644 --- a/spikes/pi-wielder/src/x402-seller.mjs +++ b/spikes/pi-wielder/src/x402-seller.mjs @@ -169,7 +169,9 @@ function paymentResponseEvidence({ }; } -function validateAuthorizationEnvelope(paymentPayload, requirements) { +function validateAuthorizationEnvelope(paymentPayload, requirements, requestNowMs, { + allowExpired = false, +} = {}) { if (!exactPlainObject(paymentPayload, ['x402Version', 'scheme', 'network', 'payload']) || !exactPlainObject(paymentPayload.payload, ['signature', 'authorization']) || paymentPayload?.x402Version !== X402_VERSION @@ -199,6 +201,29 @@ function validateAuthorizationEnvelope(paymentPayload, requirements) { if (!/^0x[0-9a-f]{64}$/.test(authorization.nonce)) { throw new Error('payment authorization lacks a valid nonce or payer'); } + const issuedAtMs = Date.parse(requirements.extra?.issuedAt); + const expiresAtMs = Date.parse(requirements.extra?.expiresAt); + if (!Number.isSafeInteger(issuedAtMs) || !Number.isSafeInteger(expiresAtMs) + || expiresAtMs <= issuedAtMs + || !Number.isSafeInteger(requirements.maxTimeoutSeconds) + || requirements.maxTimeoutSeconds <= 0) { + throw new Error('frozen x402 offer has an invalid authorization window'); + } + const issuedSeconds = BigInt(Math.floor(issuedAtMs / 1_000)); + const expiresSeconds = BigInt(Math.floor(expiresAtMs / 1_000)); + const requestSeconds = BigInt(Math.floor(requestNowMs / 1_000)); + const validAfter = BigInt(authorization.validAfter); + const validBefore = BigInt(authorization.validBefore); + const maximumWindowSeconds = BigInt(requirements.maxTimeoutSeconds) + 60n; + const earliestValidAfter = issuedSeconds > 60n ? issuedSeconds - 60n : 0n; + if (validBefore !== expiresSeconds + || validAfter < earliestValidAfter + || validBefore <= validAfter + || validBefore - validAfter > maximumWindowSeconds + || expiresSeconds - issuedSeconds > BigInt(requirements.maxTimeoutSeconds) + || (!allowExpired && (requestSeconds <= validAfter || requestSeconds >= validBefore))) { + throw new Error('payment authorization validity must match the frozen x402 offer'); + } return authorization; } @@ -498,10 +523,20 @@ export function x402Paywall({ accepts: [requirements], }, 402); } + const paymentHash = `sha256:${crypto.createHash('sha256').update(paymentHeader).digest('hex')}`; + const existingOwnerClaim = claimForIdempotencyKey(idempotencyKey)?.claim ?? null; let authorization; try { - authorization = validateAuthorizationEnvelope(paymentPayload, requirements); + authorization = validateAuthorizationEnvelope(paymentPayload, requirements, requestNowMs, { + // Only a trusted recovered offer with the exact already-verified + // payment digest may reach durable terminal reconciliation after the + // original authorization window has elapsed. + allowExpired: !pendingOffers.has(idempotencyKey) + && (frozen.verifiedPaymentHash === paymentHash + || (existingOwnerClaim?.paymentHash === paymentHash + && typeof lifecycle.onSigned === 'function')), + }); } catch (error) { return c.json({ x402Version: X402_VERSION, @@ -511,7 +546,7 @@ export function x402Paywall({ } const settlementReference = authorization.nonce.toLowerCase(); const payer = authorization.from.toLowerCase(); - const paymentHash = `sha256:${crypto.createHash('sha256').update(paymentHeader).digest('hex')}`; + const authorizationValidBeforeMs = Number(BigInt(authorization.validBefore) * 1_000n); const facilitatorBody = { x402Version: X402_VERSION, paymentPayload, @@ -603,10 +638,11 @@ export function x402Paywall({ } authorizationClaim.state = 'consumed'; authorizationClaim.phase = 'consumed'; - authorizationClaim.expiresAtMs = completionNowMs + const ttlExpiryMs = completionNowMs <= Number.MAX_SAFE_INTEGER - pendingOfferTtlMs ? completionNowMs + pendingOfferTtlMs : Number.MAX_SAFE_INTEGER; + authorizationClaim.expiresAtMs = Math.max(ttlExpiryMs, authorizationValidBeforeMs); }; const rejectAndReleaseAuthorization = async (reason) => { try { diff --git a/spikes/pi-wielder/tests/gateway-transport.test.mjs b/spikes/pi-wielder/tests/gateway-transport.test.mjs index e4a6ef4..142a49b 100644 --- a/spikes/pi-wielder/tests/gateway-transport.test.mjs +++ b/spikes/pi-wielder/tests/gateway-transport.test.mjs @@ -477,6 +477,13 @@ test('gateway rejects unknown and malformed provider options before offering pay { ...anthropicBase, seed: 1 }, { ...openAiBase, user: {} }, { ...anthropicBase, user: 'wielder-1' }, + { + ...anthropicBase, + messages: [ + { role: 'system', content: 'system only' }, + { role: 'developer', content: 'still no provider message' }, + ], + }, { ...anthropicBase, tools: [{ diff --git a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs index 96667c2..be2b579 100644 --- a/spikes/pi-wielder/tests/x402-lifecycle.test.mjs +++ b/spikes/pi-wielder/tests/x402-lifecycle.test.mjs @@ -173,7 +173,7 @@ test('a non-authoritative paywall consumes one exact paid authorization only onc }); test('one payment authorization cannot execute again under a different idempotency key', async () => { - const fixedNow = Date.now(); + let clockMs = Date.now(); const facilitator = createMockFacilitator(); let verifyCalls = 0; let settleCalls = 0; @@ -186,7 +186,7 @@ test('one payment authorization cannot execute again under a different idempoten }); const app = resourceApp({ facilitatorTransport: transport, - now: () => fixedNow, + now: () => clockMs, handler: (c) => { executions += 1; return c.json({ ok: true }); @@ -259,6 +259,23 @@ test('one payment authorization cannot execute again under a different idempoten error: 'payment authorization is already claimed by a different Idempotency-Key', code: 'PAYMENT_AUTHORIZATION_CLAIMED', }); + + clockMs += DEFAULT_PENDING_OFFER_TTL_MS + 1; + const expiredOffer = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { 'Idempotency-Key': 'authorization-after-validity' }, + body: '{}', + }); + assert.equal(expiredOffer.status, 402); + const expiredReplay = await app.request('http://seller.test/resource', { + method: 'POST', + headers: { + 'Idempotency-Key': 'authorization-after-validity', + 'X-PAYMENT': first.xPayment, + }, + body: '{}', + }); + assert.equal(expiredReplay.status, 402); assert.equal(verifyCalls, 1); assert.equal(settleCalls, 1); assert.equal(executions, 1); @@ -370,11 +387,8 @@ test('concurrent cross-key replay loses the authorization claim before facilitat error: 'payment authorization is already claimed by a different Idempotency-Key', code: 'PAYMENT_AUTHORIZATION_CLAIMED', }); - assert.equal(replayAfterTtl.status, 409); - assert.deepEqual(await replayAfterTtl.json(), { - error: 'payment authorization is already claimed by a different Idempotency-Key', - code: 'PAYMENT_AUTHORIZATION_CLAIMED', - }); + assert.equal(replayAfterTtl.status, 402); + assert.match((await replayAfterTtl.json()).error, /validity.*frozen x402 offer/); assert.equal(verifyCalls, 1); assert.equal(settleCalls, 1); assert.equal(executions, 1); @@ -858,6 +872,7 @@ test('seller rejects numeric and unknown authorization fields before facilitator for (const mutate of [ (authorization) => { authorization.value = 250000; }, (authorization) => { authorization.injected = true; }, + (authorization) => { authorization.validBefore = '999999999999999999999999'; }, ]) { let facilitatorCalls = 0; const facilitator = createMockFacilitator(); From fa9078f9379940fc7a17faf9da350466c9ea5617 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 17:17:53 -0400 Subject: [PATCH 143/165] fix: require live settlement for live providers --- spikes/pi-wielder/.env.example | 3 +- spikes/pi-wielder/README.md | 5 ++- spikes/pi-wielder/RUNBOOK.md | 5 ++- spikes/pi-wielder/src/collar.mjs | 6 +++ spikes/pi-wielder/src/gateway.mjs | 3 ++ spikes/pi-wielder/tests/collar-cogs.test.mjs | 28 +++++++++++++ .../tests/gateway-transport.test.mjs | 41 +++++++++++++++---- 7 files changed, 80 insertions(+), 11 deletions(-) diff --git a/spikes/pi-wielder/.env.example b/spikes/pi-wielder/.env.example index 0b8122b..ce45e58 100644 --- a/spikes/pi-wielder/.env.example +++ b/spikes/pi-wielder/.env.example @@ -28,7 +28,8 @@ FACILITATOR_URL= # Only the exact value 0 requests a live provider; 1 uses canned output. MOCK_LLM=1 -# Live model execution additionally requires all three fields below. The +# Live model execution additionally requires ALLOW_LIVE_X402=1 and all three +# fields below. A live provider is rejected behind mock settlement. The # committed gateway catalog is synthetic_config, so it intentionally cannot be # approved for live use. A human must first review current provider pricing, # replace it with an immutable human_verified catalog (source + as-of), compute diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 6511e6e..94c6ccd 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -258,7 +258,8 @@ Withheld paid output and unsuccessful facilitator or provider response bodies ar cancelled without being consumed or exposed. Live model execution requires the exact combination of `MOCK_LLM=0`, -`ALLOW_LIVE_PROVIDER=1`, a `human_verified` immutable catalog, its exact operator-approved +`ALLOW_LIVE_PROVIDER=1`, live x402 settlement through the pinned approved facilitator, +a `human_verified` immutable catalog, its exact operator-approved digest, a cumulative process-run spend cap covering at least the maximum worst-case request across all allowed models, and the relevant provider key. Each live call reserves its request's worst-case catalog cost after payment verification and before facilitator @@ -275,6 +276,8 @@ detail. - Base Sepolia only; no mainnet and no real funds in automated verification. - Live facilitator construction accepts only the byte-exact approved HTTPS base and disables redirects for `/verify` and `/settle`. +- A mock facilitator can authorize mock execution only. Both the Collar and gateway + reject live-provider construction unless the authorized facilitator transport is live. - Live settlement requires paired absolute journal/private-key paths outside the checkout plus injected trusted settlement, refund-execution, and refund-resolution adapters. The standalone CLI intentionally provides no such live adapters and refuses diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md index 6b291ef..6ac8d12 100644 --- a/spikes/pi-wielder/RUNBOOK.md +++ b/spikes/pi-wielder/RUNBOOK.md @@ -205,8 +205,9 @@ The Collar and gateway use separate operator approvals: - Collar construction receives `LIVE_CATALOG_DIGEST` and `LIVE_SPEND_CAP_ATOMIC`. - The standalone gateway reads `GATEWAY_LIVE_CATALOG_DIGEST` and `GATEWAY_LIVE_SPEND_CAP_ATOMIC`. -- Both require `ALLOW_LIVE_PROVIDER=1` and `MOCK_LLM=0`; the provider credential is - supplied only through operator secret injection. +- Both require `ALLOW_LIVE_PROVIDER=1`, `MOCK_LLM=0`, and live x402 settlement through + the pinned approved facilitator; the provider credential is supplied only through + operator secret injection. A live provider can never run behind mock settlement. The Collar approval checks the gross ceiling for one Invocation. The gateway approval instead funds one cumulative in-memory process-run budget. After facilitator verification diff --git a/spikes/pi-wielder/src/collar.mjs b/spikes/pi-wielder/src/collar.mjs index 8f02bad..076f030 100644 --- a/spikes/pi-wielder/src/collar.mjs +++ b/spikes/pi-wielder/src/collar.mjs @@ -213,6 +213,12 @@ export function createCollar({ approval: liveApproval, grossAtomic: priceAtomic, }); + if (facilitatorTransport?.mode !== 'live') { + throw new ExecutionEconomicsError( + 'LIVE_SETTLEMENT_REQUIRED', + 'live provider execution requires live x402 settlement', + ); + } executor = liveExecutorFactory(); } if (typeof executor !== 'function') { diff --git a/spikes/pi-wielder/src/gateway.mjs b/spikes/pi-wielder/src/gateway.mjs index decad16..ed14b29 100644 --- a/spikes/pi-wielder/src/gateway.mjs +++ b/spikes/pi-wielder/src/gateway.mjs @@ -519,6 +519,9 @@ export function createGateway({ if (allowLiveProvider !== true) { throw new Error('live gateway execution requires an explicit live provider gate'); } + if (facilitatorTransport?.mode !== 'live') { + throw new Error('live provider execution requires live x402 settlement'); + } if (typeof providerFetch !== 'function') throw new TypeError('gateway providerFetch must be a function'); approvedLiveBoundary = assertLiveGatewayApproval({ catalog, diff --git a/spikes/pi-wielder/tests/collar-cogs.test.mjs b/spikes/pi-wielder/tests/collar-cogs.test.mjs index b64a26f..604bf56 100644 --- a/spikes/pi-wielder/tests/collar-cogs.test.mjs +++ b/spikes/pi-wielder/tests/collar-cogs.test.mjs @@ -232,6 +232,34 @@ test('live approval is rechecked against canonical catalog bytes before adapter assert.equal(constructions, 0); }); +test('Collar refuses live provider execution behind mock x402 settlement', () => { + const catalog = structuredClone(EXECUTION_CATALOG); + Object.assign(catalog, { + evidenceLabel: 'human_verified', + source: 'https://provider.example/pricing/2026-07-18', + asOf: '2026-07-18T00:00:00.000Z', + }); + const liveApproval = { + catalogDigest: catalogDigest(catalog), + spendCapAtomic: '250000', + }; + let constructions = 0; + assert.throws(() => createCollar({ + facilitatorTransport: createMockFacilitatorTransport(async () => { + throw new Error('mock facilitator must not run'); + }), + mockLlm: false, + allowLiveProvider: true, + executionCatalog: catalog, + liveApproval, + liveExecutorFactory: () => { + constructions += 1; + return async () => ({ output: '', usage: null }); + }, + }), /live provider execution requires live x402 settlement/i); + assert.equal(constructions, 0); +}); + test('a restarted Collar rejects pre-settlement quote drift before facilitator or payment.signed', async () => { const facilitator = createMockFacilitator(); let facilitatorCalls = 0; diff --git a/spikes/pi-wielder/tests/gateway-transport.test.mjs b/spikes/pi-wielder/tests/gateway-transport.test.mjs index 142a49b..9e041f5 100644 --- a/spikes/pi-wielder/tests/gateway-transport.test.mjs +++ b/spikes/pi-wielder/tests/gateway-transport.test.mjs @@ -6,7 +6,11 @@ import { createGateway, MODEL_PRICES_USDC, startGateway } from '../src/gateway.m import { catalogDigest } from '../src/execution-economics.mjs'; import { payingFetch as policyPayingFetch } from '../src/proxy.mjs'; import { throwawayAccount } from '../src/wallet.mjs'; -import { createMockFacilitatorTransport } from '../src/x402-seller.mjs'; +import { + APPROVED_LIVE_FACILITATOR_BASE, + createLiveFacilitatorTransport, + createMockFacilitatorTransport, +} from '../src/x402-seller.mjs'; import { paymentPolicyFor } from './payment-policy-fixture.mjs'; const payingFetch = (account, url, init, options = {}) => policyPayingFetch(account, url, init, { @@ -53,9 +57,21 @@ function facilitatorTransport() { return createMockFacilitatorTransport((url, init) => facilitator.request(url, init)); } +function liveTestFacilitatorTransport(fetchImpl = null) { + const facilitator = createMockFacilitator(); + const injectedFetch = fetchImpl ?? ((url, init) => facilitator.request(url, init)); + return createLiveFacilitatorTransport( + APPROVED_LIVE_FACILITATOR_BASE, + (url, init) => { + const operation = new URL(url).pathname.split('/').at(-1); + return injectedFetch(`http://facilitator.test/${operation}`, init); + }, + ); +} + function liveGatewayOptions(providerFetch, overrides = {}) { return { - facilitatorTransport: facilitatorTransport(), + facilitatorTransport: liveTestFacilitatorTransport(), mockLlm: false, allowLiveProvider: true, providerCatalog: structuredClone(HUMAN_VERIFIED_CATALOG), @@ -224,6 +240,17 @@ test('live gateway construction requires the explicit gate, human catalog digest assert.equal(providerCalls, 0); }); +test('live gateway provider execution refuses mock x402 settlement', () => { + let providerCalls = 0; + assert.throws(() => createGateway(liveGatewayOptions(async () => { + providerCalls += 1; + return Response.json(openAiCompletion()); + }, { + facilitatorTransport: facilitatorTransport(), + })), /live provider execution requires live x402 settlement/i); + assert.equal(providerCalls, 0); +}); + test('gateway rejects unknown models and request token bounds before offering payment', async () => { let providerCalls = 0; const gateway = createGateway(liveGatewayOptions(async () => { @@ -268,7 +295,7 @@ test('gateway rejects unknown models and request token bounds before offering pa test('gateway rejects malformed message structure before offering payment', async () => { const facilitator = createMockFacilitator(); let facilitatorCalls = 0; - const transport = createMockFacilitatorTransport(async (url, init) => { + const transport = liveTestFacilitatorTransport(async (url, init) => { facilitatorCalls += 1; return facilitator.request(url, init); }); @@ -326,7 +353,7 @@ test('gateway rejects malformed message structure before offering payment', asyn test('gateway rejects malformed tool definitions before offering payment', async () => { const facilitator = createMockFacilitator(); let facilitatorCalls = 0; - const transport = createMockFacilitatorTransport(async (url, init) => { + const transport = liveTestFacilitatorTransport(async (url, init) => { facilitatorCalls += 1; return facilitator.request(url, init); }); @@ -378,7 +405,7 @@ test('gateway rejects malformed tool definitions before offering payment', async test('gateway rejects malformed tool-call messages before offering payment', async () => { const facilitator = createMockFacilitator(); let facilitatorCalls = 0; - const transport = createMockFacilitatorTransport(async (url, init) => { + const transport = liveTestFacilitatorTransport(async (url, init) => { facilitatorCalls += 1; return facilitator.request(url, init); }); @@ -437,7 +464,7 @@ test('gateway rejects malformed tool-call messages before offering payment', asy test('gateway rejects unknown and malformed provider options before offering payment', async () => { const facilitator = createMockFacilitator(); let facilitatorCalls = 0; - const transport = createMockFacilitatorTransport(async (url, init) => { + const transport = liveTestFacilitatorTransport(async (url, init) => { facilitatorCalls += 1; return facilitator.request(url, init); }); @@ -773,7 +800,7 @@ test('concurrent paid retries reserve provider budget before facilitator settlem let settleCalls = 0; let releaseVerifiers; const bothVerifiersReady = new Promise((resolve) => { releaseVerifiers = resolve; }); - const transport = createMockFacilitatorTransport(async (url, init) => { + const transport = liveTestFacilitatorTransport(async (url, init) => { const operation = new URL(url).pathname; if (operation === '/verify') { verifyCalls += 1; From e7ddbebc4c39f6b9d9bf31783b231748b586d237 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 18 Jul 2026 14:43:40 -0400 Subject: [PATCH 144/165] docs: record adversarial readiness ledger --- .../2026-07-15-launch-week-handoff.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) diff --git a/docs/handoffs/2026-07-15-launch-week-handoff.md b/docs/handoffs/2026-07-15-launch-week-handoff.md index f903fda..7a0bc7c 100644 --- a/docs/handoffs/2026-07-15-launch-week-handoff.md +++ b/docs/handoffs/2026-07-15-launch-week-handoff.md @@ -66,3 +66,263 @@ executed and committed by 2026-07-12; see `docs/plans/2026-07-12-phase-a-finding Never commit `.env`/keys; testnet only; measured stays labeled measured; extend "What we have NOT validated", never delete from it. + +## Adversarial-remediation readiness ledger (2026-07-18) + +This is the mutable execution ledger required by +`docs/superpowers/plans/2026-07-17-corpus-amendment-proposal.md`. It records +readiness for Plans 1–10; it is not canonical product doctrine. + +- Baseline: `754c9513e6973916e616a0e9a096a83827f137b8` +- Branch: `codex/adversarial-remediation`, isolated worktree +- Exact readiness commit: `fa9078f9379940fc7a17faf9da350466c9ea5617` +- Result: all ten automated prerequisite gates pass. +- Protected corpus: + `git diff --exit-code 754c9513e6973916e616a0e9a096a83827f137b8..fa9078f9379940fc7a17faf9da350466c9ea5617 -- CONTEXT.md docs/PRD.md docs/adr` + exited 0 with no output. +- Final readiness status: `## codex/adversarial-remediation`; index and worktree + clean. + +### Plan and commit inventory + +| # | Prerequisite plan | Verified implementation tip(s) | +|---|---|---| +| 1 | `docs/superpowers/plans/2026-07-17-claims-quarantine.md` | `f651f7d83a5269ee8f77f7a157d84d005baadf69` | +| 2 | `docs/superpowers/plans/2026-07-17-clone-economics-evidence.md` | `9812dea4b759f2f768681eabbfe4029aba84a306`; bounded provider transport `412f012825145aebf9d8c0fd0c1e30fb0e3076b1`; locked ceilings `e2c92acb7fad483c56f53f898b3114380be8a47e` | +| 3 | `docs/superpowers/plans/2026-07-17-phase0-proof-safety.md` | `0b9ff1707857ac8517ab6e037cb4663792089656` | +| 4 | `docs/superpowers/plans/2026-07-17-atomic-money-kernel.md` | `a83294b5c13c9983eda800aa226988475dc57d2e` | +| 5 | `docs/superpowers/plans/2026-07-17-collar-invocation-journal.md` | `af2f455fd53fb85c08018444ce19a00b8db408dc`; integrated runtime `25c4af3f13748a0f7ffea5787858c5a4493f9217`; final runtime boundaries `ee19624851df696a5bf52ae6099814556ce37da6`, `64f5699ded687faf0c7aa4f2fda3b6c82204c913`, `4230ac47d556c0f944ce20116d20b875a24fc7fc`, `fa9078f9379940fc7a17faf9da350466c9ea5617` | +| 6 | `docs/superpowers/plans/2026-07-17-wielder-payment-policy.md` | `51abab0a3e9979c074df7b4413763798d4447e0e`; integrated runtime `25c4af3f13748a0f7ffea5787858c5a4493f9217`; bounded challenge/replay `cfac5b4f3879aae6ea0ba4e13de76340612e7ce3`, `64f5699ded687faf0c7aa4f2fda3b6c82204c913`, `4230ac47d556c0f944ce20116d20b875a24fc7fc`, `fa9078f9379940fc7a17faf9da350466c9ea5617` | +| 7 | `docs/superpowers/plans/2026-07-17-cogs-aware-execution.md` | `25c4af3f13748a0f7ffea5787858c5a4493f9217`; final runtime boundaries `ee19624851df696a5bf52ae6099814556ce37da6`, `64f5699ded687faf0c7aa4f2fda3b6c82204c913`, `4230ac47d556c0f944ce20116d20b875a24fc7fc`, `fa9078f9379940fc7a17faf9da350466c9ea5617` | +| 8 | `docs/superpowers/plans/2026-07-17-internal-invocation-awards-spike.md` | `d669c6d41d85ef065dc52397cee83d11ab08d4b0` | +| 9 | `docs/superpowers/plans/2026-07-17-authorship-attestation.md` | `ef42617f0bea5f0a4966ef56d08ba09966a537e1`; bounded metadata `0b9ff1707857ac8517ab6e037cb4663792089656`; corrected audit gate `eb227dc70c7b512e4416eb2a4be511a05deb21bf` | +| 10 | `docs/superpowers/plans/2026-07-17-public-surfaces.md` | `0779fa1a0bd39f28ca8fd18104d7bb21eb676006` | + +Every listed commit is an ancestor of the exact readiness commit. + +### Exact verification commands and results + +Plan 1: + +```bash +node scripts/marketing-claims.mjs +node --test scripts/tests/marketing-claims.test.mjs +node -e "const m=require('./spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json'); if(m.evidenceStatus!=='historical_unreproducible'||m.publication.allowed!==false||'samples' in m) process.exit(1)" +find spikes/pi-wielder/evidence/2026-07-15-overhead -maxdepth 1 -type f -print +git diff --exit-code bad032b -- CONTEXT.md docs/PRD.md docs/adr +git diff --exit-code bad032b -- docs/marketing/artifacts/raw-402-response-live.txt +``` + +Result: PASS. Four drafts pass quarantine, 2/2 regression tests pass, the tombstone +is sample-free and non-publishable, its directory contains only `manifest.json`, +the protected corpus is unchanged, and the historical raw capture is unchanged. + +Plan 2: + +```bash +cd spikes/clone-economics +npm test +npm run e2e +npm run fixtures:check +npm run sweep:preflight +npm run sweep:mock +node scripts/verify-bundle.mjs evidence/2026-07-12-n6-invalid +env -u APPROVE_LIVE_SWEEP_SHA256 -u MAX_SWEEP_COST_USD -u ANTHROPIC_API_KEY MOCK_LLM=0 ALLOW_LIVE_LLM=0 npm run sweep:live +! git ls-files | rg '(^|/)\.env$|runs/|distilled-raw|raw-provider' +``` + +Result: PASS with the deliberately negative live gate. Unit tests pass 97/97 and +e2e passes 106/106; fixtures have no drift; preflight reports 100 train, 30 +heldout, 12 cells, and 1,713 conservative requests; mock runs 12/12 cells with +`networkAttempts=0` and `publishable high-N: false`; 29 retained historical +samples recompute. The live command exits nonzero at +`Live budget snapshot must be approved` before provider construction and writes +no evidence. The final tracked-artifact scan has no matches. + +Plan 3: + +```bash +cd phase0 +npm test +npm run typecheck +node --import tsx --test --test-name-pattern='remaining new write|gas prices' tests/funding.test.ts +node --import tsx --test tests/transactions.test.ts +node --import tsx --test tests/story.test.ts +node --import tsx --test --test-name-pattern='resume after|intent-hash|manifest save|directory fsync|WIP balance|WIP allowance|Pinata unavailable|current run configuration' tests/demo.test.ts tests/registrations.test.ts tests/story.test.ts +node --import tsx --test tests/metadata.test.ts +git check-ignore -v .env pending-transactions.json pending-transactions.json.audit.tmp pending-transactions.json.lock registrations.json.audit.tmp +git ls-files .env pending-transactions.json pending-transactions.json.audit.tmp pending-transactions.json.lock registrations.json.audit.tmp +rg -n '1514|mainnet' src +``` + +Result: PASS. Full suite 187/187 and typecheck pass; focused funding 3/3, +transaction-journal 16/16, Story boundary 20/20, crash/WIP 8/8, and metadata +trust/path 34/34 pass. `registrations.json` remains `not-run` with null proof +fields. All five local paths are ignored and untracked; the network scan has no +configured mainnet target. + +Plans 4–7 shared umbrella: + +```bash +npm test --prefix prototype +npm test --prefix spikes/pi-wielder +npm run e2e --prefix spikes/pi-wielder +``` + +Result: PASS with local-loopback permission only: prototype 23/23, Pi 237/237, +and offline e2e 41/41. The e2e conserves +`250000 = 756 COGS + 1000 settlement + 6250 protocol fee + 5000 refund reserve + 236994 Royalty-claim pool`. +The full Pi suite also proves that both the Collar and gateway refuse live-provider +construction behind mock x402 settlement before any live executor or provider fetch. + +Plan 4: + +```bash +node --check prototype/atomic-money.mjs +rg -n "Math\.|parseFloat|toFixed|Number\(" prototype/atomic-money.mjs +rg -n "process\.env|PRIVATE_KEY|fetch\(|mainnet" prototype/atomic-money.mjs prototype/tests/atomic-money.test.mjs +``` + +Result: PASS: syntax exits 0; both negative scans return no matches; 152 external +and 20 internal allocation-matrix cases conserve integer atomic gross. + +Plan 5: + +```bash +npm run test:journal --prefix spikes/pi-wielder +node --test --test-name-pattern="settled-then-500" spikes/pi-wielder/tests/collar-failure.test.mjs +if git grep -I -q -E -e '-----END ([A-Z]+ )*PRIVATE KEY-----|PRIVATE_KEY[[:space:]]*=[[:space:]]*(0x)?[0-9a-fA-F]{64}' -- .; then + false +else + secret_scan_status=$? + test "$secret_scan_status" -eq 1 +fi +``` + +Result: PASS: journal 29/29, settled-then-500 1/1, and the tracked-secret scan +exits 0 with no output. The scan distinguishes a deliberate header-only test +sentinel from complete PEM material, emits no possible secret content, and fails +on a match or scan error. + +Plan 6: + +```bash +npm run test:payment --prefix spikes/pi-wielder +node --test --test-reporter=spec spikes/pi-wielder/tests/payment-policy.test.mjs +node --test --test-name-pattern="forbidden first offer|different request bytes|changed second offer" spikes/pi-wielder/tests/paying-fetch.test.mjs +rg -n "base-sepolia|84532|WIELDER_.*_USDC" spikes/pi-wielder/src spikes/pi-wielder/.env.example +git diff --check +! git ls-files | rg '(^|/)\.env$' +``` + +Result: PASS: payment tests 87/87, the named policy suite 51/51, forbidden/change +patterns 2/2, Base Sepolia/test-limit references only, clean diff, and no tracked +`.env`. + +Plan 7: + +```bash +npm run test:collar --prefix spikes/pi-wielder +npm run test:economics --prefix spikes/pi-wielder +node --test spikes/pi-wielder/tests/runtime-boundaries.test.mjs +node --test spikes/pi-wielder/tests/pi-extension-contract.test.mjs +node -e 'const m=require("./spikes/pi-wielder/evidence/2026-07-15-overhead/manifest.json"); if(m.evidenceStatus!=="historical_unreproducible"||m.publication.allowed!==false) process.exit(1)' +rg -n "status: 'unknown'|actualAtomic: null|chargedAtomic" spikes/pi-wielder/src/execution-economics.mjs spikes/pi-wielder/tests +``` + +Result: PASS: Collar 49/49, economics 36/36, runtime-boundary 5/5, extension +contract 1/1, tombstone remains non-publishable, and unknown COGS remains null +with the full reservation held. + +Plan 8: + +```bash +cd spikes/internal-invocation-awards +npm test +npm run demo +! rg -n 'grossAtomic\s*-|protocolFeeAtomic\s*-|invocationAwardAtomic\s*=' src +git diff --exit-code -- ../../CONTEXT.md ../../docs/PRD.md ../../docs/adr +``` + +Result: PASS: 86/86 tests and the exact deterministic demo pass. The demo says +`NO REAL FUNDS` and `not paid`; no duplicate allocation implementation or +protected-corpus diff exists. Reserved, executing, held-unresolved, and +vesting-pending exposure counts toward the signed period cap; only an +authenticated append-only reversal releases exposure. + +Plan 9: + +```bash +cd phase0 +npm test +npm run typecheck +npm run attestation-status -- --artifact-hash 0x0000000000000000000000000000000000000000000000000000000000000000 --json +! rg -n -P '\bauthored by\b|(? Date: Sat, 18 Jul 2026 14:44:47 -0400 Subject: [PATCH 145/165] docs: propose protected corpus alignment after readiness --- ...-17-protected-corpus-amendment-proposal.md | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md diff --git a/docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md b/docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md new file mode 100644 index 0000000..0ad2936 --- /dev/null +++ b/docs/proposals/2026-07-17-protected-corpus-amendment-proposal.md @@ -0,0 +1,267 @@ +# Protected Corpus Amendment Proposal — Employer-Funded Internal Invocations + +**Status:** PROPOSED / NOT CANONICAL + +**Date:** 2026-07-17 + +**Approval state:** Pending explicit user approval. No protected corpus file is changed by this proposal. + +**Protected targets:** `CONTEXT.md`, `docs/PRD.md`, `docs/adr/0003-payment-gated-execution.md`, `docs/adr/0005-two-leg-cross-chain-settlement.md`, `docs/adr/0006-phased-rollout-closed-modes-first.md`, `docs/adr/0007-closed-mode-compensation-layer-as-terminal-product.md`, and `docs/adr/0008-the-wielder-is-a-wallet.md`. + +## Why an amendment is proposed + +The protected corpus currently makes external Wielder revenue the source of Intra-org compensation. That leaves the terminal compensation product dependent on an external customer and makes the central design-partner pitch read like an internal usage award even though the architecture does not fund one. The remediation design proposes a different terminal-mode accounting event: a successful qualified internal Invocation consumes an employer-approved budget and may create an employer-sponsored Invocation award for the employee-Creator. External Invocations remain optional later upside distributed through the co-held Royalty claim. + +This proposal also aligns the corpus with seven evidence boundaries established by the adversarial review: + +1. the Collar is the authoritative Invocation and settlement ledger; +2. gross price is allocated only after COGS, settlement cost, protocol fee, and refund reserve; +3. a settled failure remains recorded; +4. Phase 0 proves wallet registration and declared ancestry, not authorship, originality, or safety; +5. registry telemetry is settlement-verifiable, not unfakeable; +6. historical results without committed normalized samples are preserved but suppressed from publication; +7. post-start execution cost is never defaulted to zero: unknown cost holds the full reservation for reconciliation. + +## Proposed decision summary + +1. **Terminal Intra-org event.** The employer is the Beneficiary and compensation-fund source for internal Invocations. An authorized internal Wielder uses a signed, single-use budget credential issued by a provisioned policy-permitted credential authorizer. A successful qualified Invocation creates an employer-sponsored Invocation award for the employee-Creator under an effective-dated employer policy. +2. **No platform custody.** The approved budget is an authorization and accounting limit retained by the employer, not a prepaid balance held by the platform. Employee payment occurs through employer payroll or accounts payable under a counsel-drafted instrument. +3. **Two distinct entitlements.** An internal Invocation award is an employer compensation obligation. A Royalty claim is an entitlement to external Invocation revenue. The employer receives no self-credit on internal use. +4. **Two credential sources.** External Invocations retain a settled x402 payment credential bound to the settlement transaction hash. Internal Invocations use budget-reservation credentials signed by a provisioned credential authorizer whose identifier is permitted by the effective policy. Request-supplied keys are never trust roots. Neither executes from a quote alone. +5. **Accounting authority.** The Collar owns append-only authoritative records and signed receipts. A Wielder ledger is a receipt view and never supplies authoritative splits. +6. **Registration language.** Phase 0 starts at `wallet_asserted`. `repository_control_verified` means a trusted forge signer observed the wallet-signed challenge in the exact proof commit of a verifier-provisioned repository snapshot; it does not prove current remote-repository ownership or legal authorship. Organization approval is a separately signed higher evidence level. None is a safety review. +7. **Closed-mode chain boundary.** Phase 0 remains registration-only for closed modes. It does not distribute native transferable Story royalty tokens while the contractual closed-mode entitlement must remain non-transferable. +8. **Registry language.** Settlement proves value moved. Independent/linked payer classification comes only from a verifier-controlled billing registry; caller claims are audit-only, and unknown relationships remain allow-listed with low confidence. Settlement does not prove independent demand, quality, usefulness, authorship, originality, or safety. +9. **Evidence language.** Measured claims require a committed normalized evidence bundle. A pinned historical receipt proves only the documented transaction fields and the repository's historical label. Historical unreproducible results remain historical and non-publishable. +10. **Education.** Education remains deferred because free re-authoring dominated every positive tested inherit rate under the stated deterministic baseline. Only living school-maintained value or direct school-to-employer licensing remains eligible for a future experiment. +11. **Post-start cost uncertainty.** A successful or known failed-after-start internal Invocation must carry validated actual execution COGS. A thrown executor, malformed outcome, or unknown post-start COGS becomes `unresolved` with the full reservation `held_unresolved`; it creates no award and cannot be reported as zero-cost or automatically released. + +## Proposed ubiquitous-language additions + +### Invocation award + +An **Invocation award** is an employer-sponsored compensation allocation created by a successful qualified internal Invocation under an approved, effective-dated employer policy. It is not external revenue, not a Royalty claim, not a transferable instrument, not an on-chain token, and not paid until the employer's payroll or accounts-payable process marks it paid. + +### Internal Execution credential + +An **internal Execution credential** is a signed, single-use, non-transferable authorization binding one Invocation, one employer-budget reservation, one immutable Skill version, one policy version, one credential-authorizer identifier, one expiry, and one nonce. Its signature must verify against the provisioned authorizer map and the authorizer identifier must be permitted by the effective policy; a request-supplied public key is ignored and rejected. It authorizes execution but is not money and cannot be redeemed or reused. + +### Registration attestation + +A **registration attestation** states the evidence attached to a Skill registration: + +- `wallet_asserted`: a wallet registered a content hash and declared ancestry; +- `repository_control_verified`: a trusted forge signer observed the wallet-signed challenge and exact bytes in the named immutable proof commit of a verifier-provisioned repository snapshot; +- `organization_approved`: an authorized organization signer approved the Skill and Creator relationship. + +Repository evidence does not establish current remote ownership or control; it establishes only the signed snapshot/commit observation above. Registration attestation does not prove originality, legal ownership, absence of prior art, or safety. Safety review is a separate status. + +## Proposed `CONTEXT.md` amendment map + +### Opening identity and archetypes + +Replace the Intra-org funding sentence with: + +> **Intra-org**: employee-Creator and employer co-hold the Royalty claim on external Invocation revenue. For internal use, the employer-Beneficiary authorizes an Invocation budget; a successful qualified internal Invocation may create an employer-sponsored Invocation award for the employee-Creator. Internal awards do not require external demand and do not credit the employer back to itself. + +Preserve Marketplace as future optionality and Education as deferred. + +### Wielder and Beneficiary + +Add: + +> An external Wielder proves authorization with a settled x402 payment credential. An internal Wielder proves authorization with an employer-budget credential signed by a provisioned policy-permitted credential authorizer. The Beneficiary funds the relevant path: an external Beneficiary funds external revenue; the employer-Beneficiary funds internal Invocation awards. + +### Collar + +Replace “off-chain meter” with: + +> The Collar is the authoritative append-only Invocation, funding, execution, cost, and allocation ledger. It signs receipts delivered to the Wielder, Beneficiary, Creator, and employer as applicable. Wielder-side ledgers are receipt views, not compensation ledgers. + +### Relationships + +Add: + +> A successful qualified internal Invocation may create an Invocation award under an employer policy. A successful externally funded Invocation may create Royalty-claim credits. These are separate events and are never reported as one revenue stream. + +### Execution credential + +Replace the payment-only definition with: + +> A single-use authorization required before a Skill runs. External Invocations use a settled x402 payment reference; internal Invocations use an employer-budget reservation signed by a provisioned policy-permitted credential authorizer. Finance and manager approvals likewise resolve provisioned signer identifiers, never request-supplied public keys. No accepted quote alone authorizes execution. + +### Flagged ambiguities + +Add: + +> Employer-funded internal compensation is an accounting and policy design until an employer agreement, counsel-drafted instrument, and payroll/AP integration exist. A successful accounting spike is not demand, tax, employment-law, securities, custody, or payment validation. + +## Proposed `docs/PRD.md` amendment map + +### Executive Summary + +Replace the statement that both Intra-org co-holders earn only from external Invocations with: + +> Intra-org compensation works without an external customer. The employer-Beneficiary authorizes a bounded internal Invocation budget. A successful qualified Invocation records actual COGS and fees and creates an employee-Creator Invocation award under the effective employer policy. External Wielders, if later enabled, create separate third-party revenue distributed through the employee/employer co-held Royalty claim. + +### Shared product loop + +Split `Invoke + pay` into two paths: + +> **External path:** quote -> Wielder policy validation -> signed x402 authorization -> settlement -> external Execution credential -> execute -> authoritative receipt. +> +> **Internal path:** quote -> employer policy validation -> serialized compare-and-swap budget reservation -> credential payload returned -> provisioned policy-permitted authorizer signature -> atomic executing transition and nonce consumption -> execute outside the lock -> compare-and-swap outcome. Success with validated actual COGS finalizes the Invocation award and releases exact unused reservation. Known `failed_after_start` with validated actual COGS records that COGS, creates no award, and releases only the exact unused amount. A thrown executor, malformed outcome, or unknown post-start COGS becomes `unresolved`; the full reservation becomes `held_unresolved` for reconciliation, with no award, zero-cost substitution, or automated release. + +Replace `Each payment lands in an auditable off-chain ledger` with: + +> Every attempted Invocation lands in the Collar's append-only authoritative journal. Settled payments remain recorded when execution fails or a seller response is lost. Internal reservations record allocation, reservation, execution, consumption, exact release, unresolved hold, and award state independently of external settlement. + +### Intra-org walkthrough + +Use this normative example: + +> MegaCorp approves a July budget for `ledger-recon`, Sam, named internal Wielders, the Platform Engineering cost center, and provisioned finance, manager, and credential-authorizer signer identifiers. The Collar serializes a compare-and-swap reservation of the maximum quoted COGS, fee, refund reserve, and award before execution. Reserved, executing, and held-unresolved maximum awards count toward the period cap. A successful Invocation with validated actual COGS finalizes exactly once, releases exact unused budget, and records Sam's Invocation award using kernel-returned account-identified journal entries. A known failed-after-start outcome records exact COGS and releases only its exact remainder; an unknown-cost outcome holds the full reservation and records no award. MegaCorp receives no employer self-credit. Sam and MegaCorp receive the same signed receipt. Payroll/AP payment remains a separate employer-controlled state. + +Retain OtherCo only as the external optionality example. On an OtherCo Invocation, third-party revenue may be distributed to Sam and MegaCorp through the co-held Royalty claim. + +### Architecture and trust model + +Add the lifecycle contract: + +```text +requested -> quoted -> authorized -> executing -> succeeded | failed | unresolved | cancelled +external: offered -> signed -> settled | rejected | unresolved -> refunded +internal reservation: allocated -> reserved -> executing -> consumed | released | held_unresolved +award: measured -> vesting_pending -> earned -> payable -> paid +``` + +State that monotonic sequence numbers plus cross-party receipt comparison provide a completeness signal; Merkle inclusion alone proves inclusion, not completeness. + +### Economic design + +Replace gross-to-royalty examples with: + +```text +external gross = execution COGS + settlement cost + protocol fee + refund reserve + Royalty-claim pool +internal gross payable = execution COGS + protocol fee + refund reserve + Invocation award +``` + +All monetary calculations use integer atomic units, reject negative/non-finite/over-precision input, and assign rounding remainders deterministically. Consumers persist the accounting kernel's account-identified journal entries verbatim and do not reconstruct splits. Success and known failed-after-start outcomes require validated actual COGS. Unknown post-start COGS remains unknown, cannot be treated as zero, and keeps the full reservation `held_unresolved` until an authorized reconciliation path exists. + +### Phase definitions + +Clarify: + +- Phase 0 closed mode: wallet-attested registration and declared ancestry only; no native transferable Story royalty-token distribution. +- Phase 1 terminal Intra-org: employer-retained budget authorization, internal Execution credential, authoritative Collar journal, signed receipts, Invocation-award payable ledger, employer payroll/AP payment. +- External x402 Invocation and co-held external Royalty-claim distribution: optional adjacent path, not required for internal compensation. +- Phase 2 Story settlement and Phase 3 tradeability: external-revenue optionality only. + +### Registration, disputes, and registry trust inputs + +Add: + +> `wallet_asserted` verifies only the registering wallet's signature over declared bytes and ancestry. `repository_control_verified` additionally requires a verifier-provisioned repository snapshot, a trusted-ref proof commit containing the wallet-signed challenge bound to the artifact hash, and a signed observation from a provisioned forge signer. Replay revalidates both signatures and the snapshot bytes. This status does not prove current remote-repository ownership or legal authorship. Challenge opening requires the challenger's wallet signature; resolution and revocation require a provisioned admin trust root. Same-host storage serializes replay-plus-append under an exclusive lock and fails closed on an active lock. +> +> Registry relationship and payer-cluster classifications derive only from a verifier-controlled billing registry. Event-supplied Beneficiary, relationship, or cluster claims are retained for audit and ignored for ranking. An unknown payer remains allow-listed with low confidence; it cannot self-declare independence. + +### Kill criteria and pilot acceptance + +Replace the LOI-only success gate with two separate gates: + +1. employer willingness to approve a policy, bounded Invocation budget, and counsel-drafted compensation instrument; +2. pilot evidence that authorized internal Invocations produce receipts and payroll/AP-reconcilable Invocation awards without platform custody. + +External willingness to pay remains a separate optionality gate and cannot validate the internal compensation product. + +## Proposed ADR amendment map + +### ADR-0003 — payment-gated execution + +Scope “no credential, no run” to both credential sources. Preserve x402 settlement as mandatory for externally funded execution. Add reserved-budget credentials signed by provisioned policy-permitted authorizers for internal execution. Reject request-supplied trust keys. Remove any implication that every credential must originate in a payment. + +### ADR-0005 — two-leg cross-chain settlement + +Constrain Base-to-Story cross-chain settlement and custody analysis to externally funded Invocation revenue. Internal Invocation awards stay in the employer's signed payable ledger and payroll/AP rail; they do not bridge or swap through Story. + +### ADR-0006 — phased rollout + +Define Phase 1 as the employer-funded internal compensation terminal state. Keep Phase 0 registration-only for closed modes. Preserve Phase 2/3 as external-revenue optionality. + +### ADR-0007 — closed mode as terminal product + +Replace external-demand dependence with employer-funded internal Invocations. Preserve compensation/retention positioning, non-transferability, counsel gate, vesting, clawback, termination, and “when Sam quits” requirements. + +### ADR-0008 — the Wielder is a wallet, not a harness + +Keep the thin-wallet decision for external Wielders. Add that an internal Wielder may be a non-wallet agent presenting an employer-budget credential signed by a provisioned policy-permitted authorizer through the same thin request/retry surface. The Collar remains authoritative in both modes. + +## Proposed new ADR + +# Employer-Funded Internal Invocations Create Invocation Awards + +**Status:** Proposed + +**Date:** 2026-07-17 + +### Context + +The accepted corpus made Intra-org compensation depend on an external Wielder buying access to an employer-owned Skill. That contradicts the terminal compensation pitch: most internal Skills may never be exposed to an external Beneficiary, and an employer design partner could sign a co-hold agreement while the employee-Creator earns nothing. Treating the employer's own internal use as external royalty revenue would create circular self-credit and inflated revenue. + +The product also must avoid platform custody, transferable closed-mode instruments, and dependence on Phase-2 Story settlement. Employers already operate payroll and accounts-payable rails and can approve bounded compensation budgets without transferring prepaid funds to the Collar. + +### Decision + +For a qualified internal Invocation, the employer is the Beneficiary and compensation-fund source. Before execution, the Collar validates an active effective-dated employer policy and serializes a compare-and-swap reservation of the maximum quoted amount from an employer-retained Invocation budget. A provisioned policy-permitted credential authorizer signs the single-use internal Execution credential, which binds the Invocation, reservation, Skill version, policy version, credential-authorizer identifier, expiry, and nonce. Finance, manager, and credential-authorizer keys are provisioned trust roots; request-supplied public keys are rejected. No quote alone authorizes execution. + +On success with validated actual execution COGS, the Collar persists the accounting kernel's account-identified journal entries for COGS, protocol fee, refund reserve, and the employee-Creator Invocation award, then releases the exact unused reservation. It never reconstructs those entries in a consumer. A known `failed_after_start` outcome must carry validated actual COGS; it creates no award, records that exact unavoidable cost, and releases only the exact unused remainder. A thrown executor, malformed outcome, or unknown post-start COGS transitions the Invocation to `unresolved` and the full reservation to `held_unresolved` for reconciliation. It creates no award, records no invented monetary entry, substitutes no zero cost, and performs no automated release. Reserved, executing, and held-unresolved maximum awards count toward the period cap. The employer receives no self-credit. + +Invocation-award states are `measured -> vesting_pending -> earned -> payable -> paid`; a no-vesting policy skips `vesting_pending`. Payroll/AP controls `payable -> paid`. Corrections are append-only reversals or prospective adjustments. + +External Invocation revenue remains separate. An external Wielder uses x402 and a settled transaction reference; the external Royalty-claim pool may credit employee and employer co-holders. External demand is not required for an internal award. + +The Collar is authoritative and signs append-only receipts. Employer and employee receive the same receipt and statement. Merkle roots prove inclusion; monotonic sequence numbers and cross-party receipt comparison provide the completeness signal. + +### Consequences + +- The terminal Intra-org product can compensate an employee-Creator without an external customer. +- Employer willingness to fund an internal program becomes the demand gate. +- The platform does not hold prepaid employer funds or pay employees. +- Payroll/AP, employment, tax, 409A, vesting, clawback, termination, and dispute terms remain human/counsel gates. +- Internal awards are not Royalty claims, securities, tokens, or on-chain settlement events. +- External x402 and Story settlement remain available optionality with their existing custody and compliance constraints. +- Self-Invocations require manager approval or exclusion, and caps/idempotency/authorized-Wielder lists prevent trivial award farming. +- Unknown post-start COGS reduces available budget and award-cap headroom through a full unresolved hold until authorized reconciliation; operational resolution remains unvalidated. + +### Rejected alternatives + +- **External Wielder revenue as the only Intra-org funding source:** rejected because it leaves compensation dependent on a second unvalidated market. +- **Employer pays itself and splits the gross:** rejected as circular revenue and metric inflation. +- **Platform prepaid omnibus balance:** rejected because it expands custody and money-transmission exposure. +- **One Story royalty token per internal Invocation:** rejected because it is unnecessary, transferable by default, and incompatible with the closed-mode contractual entitlement. +- **Unmetered discretionary bonus pool:** rejected because it removes the Invocation-level attribution and audit contract the product exists to supply. + +## Historical statements and evidence preservation + +- Do not erase the prior external-Wielder-funded Intra-org model. Mark it superseded by the approved amendment date if approval occurs. +- Preserve historical n=48 latency and clone results with their original dates and labels. If normalized samples are absent or target validity failed, retain the machine status `historical_unreproducible` and mark publication disallowed; do not fabricate evidence or silently restate them as measured. +- Preserve Education arithmetic as deterministic model evidence, not observed behavior. +- Extend the PRD's “What we have NOT validated” ledger; never delete prior open assumptions. + +## Proposed additions to “What we have NOT validated” + +1. Employer willingness to approve and fund an Invocation-award budget. +2. Counsel's treatment of the exact policy, earning, vesting, termination, and payroll/AP timing. +3. Whether qualified Invocation rules resist low-value repetition and manager-approved self-use abuse. +4. Whether employees trust Collar receipts and statement completeness enough for compensation. +5. Actual operational cost of payroll/AP reconciliation and disputes. +6. Whether verifier-provisioned repository-snapshot and organization-approval attestations improve adoption, and whether trusted forge/admin operation is sustainable. +7. Whether the verifier-controlled billing registry classifies independent Beneficiaries and payer clusters accurately enough for public registry ranking. +8. Any future external demand for paid Skill Invocations. +9. The authorized evidence, operator role, and dispute controls required to reconcile a `held_unresolved` reservation without inventing COGS or releasing value prematurely. + +## Approval and application gate + +This proposal does not change canonical doctrine. Its generation requires verified completion of all ten implementation-plan dependencies across Projects 1–5. Application requires one explicit user instruction approving the amendment set after that evidence is reviewed. Once approved, create a separate execution plan that updates all protected files in one coherence commit, runs link/terminology/contradiction scans, and preserves historical statements. Partial application is not allowed. + +**Current decision:** pending explicit approval. From 07c3549b815049ebfaf9c8c9876e4911ae6db437 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 31 Jul 2026 18:40:00 -0400 Subject: [PATCH 146/165] docs: design agent spend control plane --- ...-07-31-agent-spend-control-plane-design.md | 616 ++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-agent-spend-control-plane-design.md diff --git a/docs/superpowers/specs/2026-07-31-agent-spend-control-plane-design.md b/docs/superpowers/specs/2026-07-31-agent-spend-control-plane-design.md new file mode 100644 index 0000000..1f20ae9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-agent-spend-control-plane-design.md @@ -0,0 +1,616 @@ +# Agent Spend Control Plane — Design + +- **Status:** Approved in design review on 2026-07-31 +- **Implementation base:** `codex/prd-execution` at + `6f7006055cd14ca0b5c5961c7d0a3d09eff044ef` +- **Initial network:** Base Sepolia (`eip155:84532`) +- **Funding:** Test USDC only; no real funds or mainnet transactions + +## 1. Summary + +Build a customer-hosted **Agent Spend Control Plane** that gives an AI agent +bounded economic agency without giving the agent wallet custody or unrestricted +signing access. + +Pi is the first reference client. The product's central module is a **Wallet +Kernel** evolved from the hardened Pi-Wielder proxy. It converts ordinary agent +HTTP requests into canonical Spend Intents, evaluates customer policy, obtains +human approval when required, signs only permitted x402 payments through a +customer-owned wallet, and maintains a durable signed receipt for every outcome. + +The product is: + +- wallet-native; +- on-chain anchored for payment settlement; +- off-chain executed for model and tool work; +- customer-hosted for policy enforcement and authoritative records; +- x402-only in v1; +- provider-neutral at its wallet seam, with CDP as the first adapter. + +The commercial product is spending policy, auditability, and reconciliation. It +is not wallet custody, token trading, an inference reseller, or a marketplace. + +## 2. Product decision + +The first buyer is an AI platform or gateway team that needs to answer: + +> Can our agents buy API resources autonomously while remaining inside explicit +> budgets, approved sellers, human escalation rules, and a complete audit trail? + +The first paid offer is a customer-hosted design-partner pilot around one Pi +workflow, one customer-owned CDP wallet, and one or more Base Sepolia x402 +resource servers. + +Skill attribution and Creator compensation are deferred expansion modules. They +may later consume the Wallet Kernel's receipts, but they do not determine the v1 +interface or operator experience. + +## 3. Goals + +1. Allow Pi to pay an approved x402 request automatically. +2. Deny an unapproved seller or over-budget request before any signature. +3. Queue an out-of-policy request for exact human approval. +4. Preserve monetary and approval state across process restart. +5. Produce a signed, independently verifiable receipt for every terminal outcome. +6. Reconcile ambiguous signed, settled, failed, and refunded operations without + double-signing or inventing balance. +7. Keep wallet credentials, policy authority, and the authoritative journal in + the customer environment. +8. Reuse the hardened request binding, budget conservation, receipt, refund, and + reconciliation behavior already present on `codex/prd-execution`. + +## 4. Non-goals + +V1 does not include: + +- Base mainnet or any real funds; +- arbitrary wallet transfers; +- token trading, swaps, bridging, staking, or yield; +- arbitrary smart-contract calls; +- Story registration or royalty settlement; +- Creator compensation or employer reward pools; +- an open Skill Marketplace or registry; +- wallet key export or product custody; +- multiple operators, SSO, SCIM, or organization RBAC; +- mobile approval; +- a hosted service that can authorize spending; +- raw prompt or model-output storage; +- x402 `upto` or non-EVM schemes; v1 supports x402 v2 `exact` only. + +These exclusions are product and security boundaries, not unfinished acceptance +criteria. + +## 5. Roles and authority + +### Agent + +Pi or another HTTP-capable client. It requests a resource and receives the +resource response, a stable denial, or an approval-required result. It cannot +access wallet credentials, construct payment headers, change policy, approve an +intent, or invoke a signing interface. + +### Operator + +The human authorized to apply policy versions, approve or deny pending intents, +inspect receipts, and initiate trusted reconciliation. In v1 there is exactly one +local operator authority. + +### Customer wallet + +A wallet inside the customer's own CDP project. CDP credentials stay inside the +customer's local or VPC deployment. The product does not receive or store a raw +private key. + +### Wallet Kernel + +The authoritative local module for intent, policy, budget, approval, payment, +execution outcome, refund, reconciliation, and receipt state. + +### Resource server + +An x402 v2 seller that returns a challenge for an approved HTTP resource and +provides canonical settlement evidence after payment. + +## 6. Architecture and trust boundaries + +```text +Pi or another agent + | ordinary HTTP request + v +Local Agent Adapter + | canonical Spend Intent + v +Wallet Kernel + |-- Intent Builder + |-- Policy Engine + |-- Budget Ledger + |-- Approval Queue + |-- Durable Journal + |-- x402 v2 Transport + |-- Wallet Adapter + `-- Receipt Signer + | scoped x402 signing + v +Customer-owned CDP wallet + | test-USDC authorization + v +x402 resource server +``` + +The authoritative path runs locally or in the customer's VPC. It does not depend +on a vendor-hosted dashboard. A later hosted dashboard may receive sanitized, +signed, read-only projections, but it cannot apply policy, approve an intent, +request a signature, or reconcile money. + +The blockchain proves that value moved. It does not prove the agent's purpose, +the usefulness of returned output, policy compliance, execution success, or +refund entitlement. The Wallet Kernel's signed journal records those distinct +facts without collapsing them into a chain transaction. + +## 7. External interfaces + +### 7.1 Agent interface + +The agent uses a loopback HTTP proxy. Pi points its model provider or tool URL at +that proxy and otherwise behaves as a normal HTTP client. + +The proxy returns one of: + +- the upstream resource response plus a compact local receipt reference; +- `payment_approval_required` with a request ID and expiry; +- a stable policy or protocol denial; +- a stable unresolved-payment or reconciliation error. + +The agent cannot supply `PAYMENT-SIGNATURE`, payment identity, idempotency, or +approval headers. The proxy exclusively owns those fields. + +At startup, the local adapter opens a kernel-issued Spend Session bound to the +configured adapter identity, customer wallet, and active policy. Requests inherit +that opaque session identity; Pi cannot choose or replace it. + +### 7.2 Operator interface + +The operator uses a local CLI and loopback-only admin endpoint to: + +- validate and apply a new policy version; +- list, approve, deny, or inspect pending intents; +- view wallet and budget status; +- inspect and verify signed receipts; +- initiate a trusted reconciliation attempt; +- export a sanitized receipt bundle. + +Operator requests require a local operator credential. Agent traffic and +operator traffic use distinct routes and authorization middleware. + +### 7.3 Wallet adapter interface + +The wallet seam exposes only: + +```text +walletIdentity() -> WalletIdentity +signX402Exact(authorizedPermit, paymentRequirements) -> SignedPayment +``` + +The adapter accepts an internal `AuthorizedPermit`, not an arbitrary signing +payload. It must prove that the network, asset, payer, payee, amount, request hash, +quote identity, expiry, and nonce match the permit exactly. + +CDP is the first live-shaped adapter. A deterministic signer and a mock-settlement +adapter implement the same contract for offline verification. + +## 8. Canonical domain records + +The durable store contains explicit records rather than a generic event blob: + +### SpendSession + +A kernel-issued identifier bound to the local agent adapter, customer wallet, +creation time, lifecycle state, and session-budget accounting. The agent cannot +select or mutate the session identity. + +### PolicyVersion + +An immutable policy document, schema version, canonical hash, applied timestamp, +and predecessor hash. + +### SpendIntent + +The captured method, canonical URL, seller origin, resource path, body hash, +header allowlist hash, purpose label, kernel-issued session ID, correlation ID, +wallet, creation time, and kernel-issued idempotency key. Raw body bytes may exist +only in bounded process memory for the active request and are not journaled. + +### PolicyDecision + +Exactly one of `allow`, `approval_required`, or `deny`, bound to the Spend Intent, +policy version, challenge fingerprint, amount ceiling, and stable reason code. + +### BudgetReservation + +Canonical atomic-USDC amounts for reserved, committed, released, and unresolved +funds. Every transition must conserve non-negative integer atomic units. + +### Approval + +A one-time operator decision bound to the exact intent hash, challenge +fingerprint, amount ceiling, wallet, policy version, operator identity, and +expiry. An expired challenge or changed request requires a new approval. + +### PaymentAttempt + +The exact signed payment bytes, payment fingerprint, nonce, quote identity, +settlement evidence, transaction identity, and protocol timestamps. + +### ExecutionOutcome + +Success, failure, or unknown, including HTTP status, bounded metadata, response +hash when available, and no raw response body. + +### Refund + +The original transaction, requested amount, state, trusted evidence, refund +transaction when confirmed, and relationship to the held reservation. The buyer +kernel does not send an outbound refund; it records and reconciles a seller-side +refund. + +### Reconciliation + +An operator-triggered, capability-backed observation that can advance an +ambiguous state only when payer, payee, amount, nonce, request, and transaction +evidence match exactly. + +### SignedReceipt + +An Ed25519-signed terminal projection over the intent, policy, approval, payment, +execution, budget, refund, and reconciliation records. It never becomes the +source of authority for those records. + +## 9. Policy model + +Policy is a canonical JSON document validated against a closed schema. Unknown +fields fail validation. Applying a policy creates a new immutable PolicyVersion; +an active version is never edited in place. + +V1 policy can constrain: + +- exact CAIP-2 network (`eip155:84532`); +- exact Base Sepolia USDC asset; +- customer wallet identity; +- seller origin and optional resource-path prefix; +- allowed HTTP methods; +- maximum atomic USDC per request; +- maximum atomic USDC per seller per session; +- maximum atomic USDC for the complete session; +- maximum atomic USDC in a rolling 24-hour window; +- automatic-allow ceiling; +- human-approval ceiling; +- challenge and approval expiry; +- maximum concurrent pending approvals; +- default action. + +The default action is `deny`. A request is `allow` only when every applicable +rule permits it and all budget ceilings have capacity. A request is +`approval_required` only when its seller and protocol shape are permitted but its +amount or resource rule explicitly allows operator escalation. Unsupported +network, asset, scheme, method, seller, or malformed challenge is always `deny` +and cannot be overridden by approval. + +## 10. Modules + +### Intent Builder + +Captures the caller-owned request exactly once, creates the canonical request +hash and idempotency key, and prevents caller injection of kernel-owned headers. + +### Policy Engine + +A pure module with no network, database, clock, or wallet creation. It evaluates +an immutable input snapshot and returns a decision plus reason codes. + +### Budget Ledger + +Atomically reserves, commits, releases, or holds canonical atomic-USDC amounts. +It serializes monetary transitions through SQLite transactions and refuses any +transition that would make a counter negative or violate conservation. + +### Approval Queue + +Persists operator escalation without granting generic signing capability. An +approval is one-time and expires with its challenge. + +### Wallet Adapter + +Provides customer wallet identity and x402 signing only. The CDP adapter loads +customer-controlled CDP configuration from the process environment or a +customer-provisioned secret mount. Credentials are never written to SQLite, +receipts, logs, or exported projections. + +### x402 v2 Transport + +Owns the unpaid request, challenge parsing, exact validation, signature +construction, and one paid retry. V1 registers only the Base Sepolia EVM `exact` +scheme. + +### Durable Journal + +Uses local SQLite in WAL mode with full synchronous durability and a single +authoritative writer. Domain transitions and their canonical event-hash chain +commit in the same transaction. The database file and related side files must be +owner-only regular files on customer-controlled persistent storage. + +### Receipt Signer + +Uses a customer-local Ed25519 key stored outside the tracked checkout. It signs +terminal receipt projections and exposes the public key and key ID for +verification. + +### Projection Exporter + +Emits sanitized signed projections only when enabled. It has no import path and +cannot write to authoritative state. + +## 11. Transaction lifecycle + +```text +received + -> challenged + -> allowed | approval_pending | denied + -> budget_reserved + -> signed + -> payment_unresolved | settled + -> execution_succeeded | execution_failed | execution_unknown + -> finalized | refund_pending | refunded | reconciliation_required +``` + +1. Persist the Spend Intent before contacting the seller. +2. Perform one bounded unpaid request. +3. Parse and validate the x402 v2 challenge against the exact request and policy + boundary. +4. Persist the policy decision. +5. For `deny`, terminate without reservation or signature. +6. For `approval_required`, persist the challenge and wait for an exact operator + decision. Expiry terminates the approval; it does not create reusable authority. +7. For `allow` or exact approval, atomically reserve the maximum amount. +8. Claim the one-time signing transition and call the Wallet Adapter. +9. Persist the exact signature and payment payload before any paid retry. +10. Perform exactly one paid retry. +11. Require response settlement evidence that matches the signed payment. +12. Persist execution outcome separately from settlement outcome. +13. Commit the actual charge, release unused reservation, or retain an unresolved + hold as dictated by evidence. +14. Create and sign the terminal receipt only after the authoritative transition + commits. + +## 12. Failure and restart semantics + +- Failure before signature persistence releases the reservation unless the + signer may have returned a signature and persistence is ambiguous. +- Any uncertainty after a signature may exist retains the full reservation as + unresolved. +- Missing response, timeout, or crash never causes a replacement signature or + blind paid retry. +- A settled payment followed by failed execution remains settled and enters + `refund_pending` or `reconciliation_required`. +- An execution response without matching settlement evidence is withheld. +- A changed paid response, changed challenge, or second `402` is not accepted as + success. +- Operator approval cannot bless an already-signed or settled operation. +- On startup, safe unsigned pending approvals remain pending. Any signed, + payment-unresolved, settled-with-unknown-execution, or refund-unresolved record + blocks new spending from the same wallet until exact reconciliation or a safe + terminal transition completes. +- Recovery reuses persisted exact bytes and identities. It never reconstructs a + signature from intent fields. + +## 13. Operator and Pi experience + +The pilot package contains the Wallet Kernel, CLI, Pi adapter, and a loopback-only +operator console. + +### Setup + +1. Provision customer CDP credentials and a Base Sepolia wallet in the customer + environment. +2. Provision a local receipt-signing key and owner-only SQLite path. +3. Run preflight to verify network, wallet, asset, file permissions, schema, and + policy. +4. Apply the initial policy version. +5. Start the kernel and point Pi at the loopback proxy. + +### In-policy request + +Pi receives the upstream response and a compact receipt summary: seller, charged +amount, remaining session budget, terminal state, transaction prefix, and receipt +ID. + +### Approval-required request + +Pi receives `payment_approval_required` with a request ID and expiry. The local +console shows seller, resource, request hash, amount ceiling, wallet, policy +mismatch, and expiry. The operator approves once or denies. Pi then repeats only +the exact ordinary request while the approval remains valid. The proxy resolves +that retry against the exact approved intent using its kernel-owned session and +idempotency mapping; Pi does not send an approval or idempotency header. + +### Local console + +The console has four views: + +- **Overview:** wallet, budget, reserved/unresolved amounts, and kernel health. +- **Policies:** active version, validation, and immutable history. +- **Approvals:** pending, approved, denied, and expired requests. +- **Receipts:** settled, failed, refunded, and unresolved operations. + +There is no hosted write path and no raw prompt or output display. + +## 14. Security and privacy requirements + +- Agent and operator routes are separate. +- Admin routes bind to loopback by default and require a local operator credential. +- Redirects are disabled for unpaid and paid requests. +- Network, asset, scheme, seller, path, method, amount, nonce, request hash, + challenge expiry, and response evidence are validated exactly. +- Request, challenge, facilitator, and upstream response bodies use streaming byte + ceilings and total wall-clock deadlines. +- Database, receipt key, operator credential, and local configuration are + owner-only regular files. Symlinks, wrong owner, or permissive modes fail closed. +- CDP and provider credentials remain in customer-controlled secret storage and + are never persisted by the kernel. +- Logs and public errors use stable codes and remove provider exception detail. +- Raw prompts and outputs are not stored. Bounded hashes and status metadata are + sufficient for receipts. +- Policy history is append-only. +- No `.env`, private key, wallet secret, database, receipt key, or operator token + may be tracked by Git. + +## 15. Verification strategy + +### Pure tests + +- canonical request and policy serialization; +- closed-schema rejection; +- policy decision matrices; +- approval exactness and expiry; +- atomic-USDC conservation; +- state-machine transition legality; +- receipt canonicalization and signature verification. + +### Adapter contract tests + +Every wallet adapter must prove: + +- it refuses missing or mismatched AuthorizedPermits; +- it cannot alter payment fields; +- it never exposes key material; +- it returns canonical signer and payment evidence; +- the deterministic adapter and CDP adapter satisfy the same interface contract. + +### Persistence and crash tests + +Inject process failure immediately before and after every durable monetary +transition. Reopen the store and prove: + +- no reservation disappears; +- no amount is committed twice; +- no second signature is requested; +- no settlement is replayed; +- no refund is invented; +- every nonterminal state is either safely resumable or blocks for reconciliation. + +### Offline integration + +Run the complete x402 v2 `exact` flow with deterministic wallet, facilitator, +seller, approval, execution failure, refund, and reconciliation adapters. No +socket, API key, funded wallet, or network is required. + +### End-to-end + +Run Pi through real child processes and exercise: + +1. allowed automatic payment; +2. untrusted seller denial; +3. over-budget denial; +4. approval-required then approved request; +5. approval denial and expiry; +6. crash and restart at each money boundary; +7. settled execution failure; +8. unresolved settlement; +9. trusted reconciliation; +10. signed receipt verification from a fresh process. + +### Manual Base Sepolia evidence + +Only after offline gates pass, a human may fund a test wallet and authorize a +testnet run. The run writes a new immutable evidence directory: + +```text +spikes/pi-wielder/evidence/YYYY-MM-DD-agent-spend-control-RUN_ID/ + manifest.json + events.jsonl + summary.json + report.md + README.md +``` + +The bundle must include hashes, exact code commit, configuration digests, +normalized per-request evidence, transaction links, recomputation instructions, +and explicit testnet labels. Aggregate prose without retained samples is not +publishable evidence. + +## 16. Acceptance criteria + +V1 is complete only when fresh verification proves all of the following: + +1. Pi automatically pays an allowed x402 v2 `exact` request on the offline path. +2. An unapproved seller is denied before wallet-adapter invocation. +3. An over-budget request is denied before wallet-adapter invocation. +4. An escalatable request is persisted, approved by the operator, and paid only + for the exact approved request and quote. +5. A denied or expired approval never reaches the signer. +6. Restart preserves policy, approvals, reservations, signatures, payment state, + execution state, refunds, and receipts. +7. Restart never causes a second signature or settlement for the same attempt. +8. Every monetary transition conserves canonical atomic USDC. +9. Settled execution failure is distinguishable from unpaid failure and produces + no invented successful output. +10. Every terminal outcome produces a verifiable signed receipt. +11. Every ambiguous outcome remains held until trusted reconciliation. +12. No raw prompt, output, wallet secret, CDP credential, or unredacted provider + exception appears in the database, receipt, or logs. +13. Full automated verification uses no network and no funded wallet. +14. Any testnet claim is backed by a fresh recomputable evidence bundle. + +## 17. Implementation and release boundary + +Implementation begins from `codex/prd-execution`, not the older public spike on +`github-main`. Existing hardened journal, policy, receipt, refund, COGS, and +runtime-boundary tests remain regression requirements while modules are +extracted and deepened. + +The public website and README are updated only after the new implementation and +evidence meet this spec. The quarantined historical `n=48` aggregate is not used +in product claims. A new testnet run, if authorized, must retain its normalized +evidence. + +This design does not amend `CONTEXT.md`, `docs/PRD.md`, or `docs/adr/`. It is +consistent with ADR-0008's thin Wielder wallet boundary. Any later change to the +canonical product doctrine requires separate explicit instruction. + +## 18. Commercial pilot boundary + +The first commercial offer is a paid customer-hosted design-partner engagement, +not a self-service wallet or transaction take-rate business. + +The pilot delivers: + +- one customer-owned CDP testnet wallet integration; +- one Pi workflow; +- one or more allow-listed x402 testnet sellers; +- customer-defined automatic and approval-required policy; +- durable budgets and restart recovery; +- local operator console; +- signed receipt and reconciliation export; +- a final evidence and control review. + +The pilot validates whether an AI platform team will pay for governed autonomous +spending and auditability. It does not validate mainnet custody, market demand for +Skill royalties, or a public marketplace. + +## 19. Considered alternatives + +### Wrap an existing agent-wallet CLI + +Rejected as the core architecture. It reaches a demo quickly but makes policy, +recovery, and receipt semantics dependent on another product's command surface. +Wallet providers remain adapters rather than the product interface. + +### Build custom smart-account contracts + +Rejected for v1. It adds contract audits, recovery design, and wallet security +before buyer demand is proven, while duplicating managed wallet capability. + +### Keep the existing in-memory proxy + +Rejected for commercialization. It cannot guarantee budget or payment state +across restart and cannot safely support operator approval or production-shaped +reconciliation. From 045793133b1ec71bf23059c808d9785c2a864a39 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 31 Jul 2026 21:29:56 -0400 Subject: [PATCH 147/165] docs: plan agent spend control plane --- .../2026-07-31-agent-spend-control-plane.md | 6351 +++++++++++++++++ 1 file changed, 6351 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md diff --git a/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md b/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md new file mode 100644 index 0000000..7385796 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md @@ -0,0 +1,6351 @@ +# Agent Spend Control Plane Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the hardened Pi-Wielder spike into a customer-hosted Wallet Kernel that gives Pi policy-bounded, approval-aware, restart-safe x402 v2 spending through a customer-owned CDP wallet on Base Sepolia. + +**Architecture:** Build the new buyer control plane alongside the verified x402 v1 spike, using a local SQLite authority for policies, Spend Sessions, Spend Intents, budgets, approvals, payment attempts, outcomes, reconciliation, and signed receipts. A pure Policy Engine and one-time `AuthorizedPermit` capability sit in front of provider-neutral wallet adapters; a custom x402 v2 transport preserves the required persist-before-retry boundary that an automatic fetch wrapper cannot expose. After offline parity, cut the standalone Pi path over to the new loopback proxy while retaining the old Collar and payment suites as regression oracles. + +**Tech Stack:** Node.js 24.15+ for deterministic development and exact Node.js +24.15.0 for the attested `cdp-testnet` release, ECMAScript modules, built-in +`node:sqlite`, Hono, built-in `node:test`, Ed25519/SHA-256 from `node:crypto`, +`@coinbase/cdp-sdk` 1.54.0, `@x402/core` 2.19.0, `@x402/evm` 2.19.0, viem, Pi +0.80.6; Base Sepolia and test USDC only. + +--- + +## Scope, ordering, and baseline + +Implement against `codex/agent-spend-control-design` at or after design commit +`07c3549`. At execution time, create an isolated worktree with +`superpowers:using-git-worktrees`; do not implement directly in a dirty checkout. + +This is one vertical product slice. The modules are separately testable, but the +commercial acceptance milestone is reached only after the durable store, policy, +approval, wallet, transport, operator, Pi, and restart paths work together. + +Current verified baseline on 2026-07-31: + +- `npm run test --prefix spikes/pi-wielder`: 237/237 passing with loopback permission; +- `npm run e2e --prefix spikes/pi-wielder`: 41/41 passing offline; +- `npm test --prefix prototype`: 23/23 passing; +- `CONTEXT.md`, `docs/PRD.md`, and `docs/adr/` are protected and must remain unchanged; +- `docs/superpowers/plans/marketing-assets/` is user-owned and must remain untouched. + +The existing `src/payment-policy.mjs`, `src/proxy.mjs`, `src/x402-seller.mjs`, and +seller-side `src/invocation-journal.mjs` remain x402 v1 regression boundaries until +Task 14 proves parity. Do not migrate their data into the Wallet Kernel and do not +reinterpret `LEDGER_FILE` or `COLLAR_JOURNAL_FILE` as SQLite. + +The public `README.md` and `site/` exist only on the older public branch. They are +outside this plan. A website reframe is a separate post-evidence plan; no public claim +may revive the quarantined historical `n=48` aggregate. + +### Live trust boundary: Pi and the Wallet Kernel are different principals + +The commercial `cdp-testnet` mode is supported only when the Wallet Kernel runs as a +dedicated OS service account (or equivalently isolated container identity) and Pi runs +as a different unprivileged UID/container. The Kernel identity alone can read the +SQLite authority, receipt key, operator token, policy bootstrap inputs, CDP +credentials, and RPC secret. The Pi identity can read only its agent credential and +ordinary working files. A tool-capable Pi process must receive filesystem permission +denied—not merely an instruction—when it attempts to open, stat through, or modify the +Kernel authority directory or service environment. + +Pi creates its own agent credential under the Pi identity and emits a separate +non-secret enrollment descriptor containing only `agentInstanceId`, credential digest, +and Pi UID/GID. An authenticated/offline operator enrollment imports that descriptor into +SQLite. The Kernel never opens the Pi credential file or persists/logs the raw token; +the agent listener necessarily receives the bearer per request, hashes it immediately, +zeroes its temporary bytes, and retains only the digest. At startup, live mode +rejects UID/GID `0`, requires a distinct Kernel/Pi UID, pins the enrolled Pi UID/GID +to the closed configured expected identity, and clears supplementary groups in +the isolation probe. Same-identity execution is allowed only in injected +`deterministic` tests, is labeled `isolation: simulated`, and is never evidence of +commercial isolation. There is no environment flag that weakens this check in +`cdp-testnet`. + +The implementation includes a deployment preflight and negative process fixture under +the configured Pi identity that receives OS-level denial for the operator token, +database, receipt key, Kernel environment, and CDP secret while retaining access to +the Pi credential. Therefore a tool-capable Pi is constrained by host permissions, +not prompt instructions. If the host cannot +provide distinct identities/containers, live admission remains blocked and the pilot +is not security-ready on that host. + +The same boundary covers executable and operator-channel integrity. Commercial mode +runs from a root-owned manifest-verified release tree that neither Pi nor the Kernel +service account can modify; a privileged prelaunch gate verifies Node, source, +dependencies, launcher, service/socket definitions, and loader environment before +dropping privileges. The owner bearer travels only over a Kernel-owned Unix socket. +The live browser console uses a root service-manager-held loopback socket plus a +one-time UDS-minted launch capability, so Pi cannot impersonate the console during a +Kernel crash and the browser never sees the owner bearer. + +The first live pilot target is Linux with systemd service and socket activation; +macOS remains a deterministic development host in this slice. A different service +manager/container must implement and pass the same inherited-listener and privileged +prelaunch contract before it can claim `cdp-testnet` support. + +## File structure + +### New Kernel modules + +- `spikes/pi-wielder/src/kernel/canonical.mjs` — closed-object validation, + canonical JSON, hashes, identifiers, timestamps, and atomic-USDC strings. +- `spikes/pi-wielder/src/kernel/secure-storage.mjs` — absolute path, owner, mode, + symlink, database, WAL, SHM, key, and token checks. +- `spikes/pi-wielder/src/kernel/authority-lock.mjs` — crash-released, process-lifetime + single-writer exclusion shared by the daemon and offline bootstrap commands. +- `spikes/pi-wielder/src/kernel/release-integrity.mjs` — closed privileged deployment + manifest validation and runtime re-verification for the live executable tree. +- `spikes/pi-wielder/src/kernel/sqlite-schema.mjs` — explicit schema-v1 tables and + indexes; atomic money remains canonical decimal `TEXT`. +- `spikes/pi-wielder/src/kernel/sqlite-store.mjs` — SQLite open/migrate, + `BEGIN IMMEDIATE` transactions, domain mutation plus event-hash commit, and chain + verification. +- `spikes/pi-wielder/src/kernel/policy-engine.mjs` — immutable policy validation and + pure `allow | approval_required | deny` evaluation. +- `spikes/pi-wielder/src/kernel/policy-repository.mjs` — predecessor-linked policy + versions and active-version lookup. +- `spikes/pi-wielder/src/kernel/agent-enrollment.mjs` — non-secret Pi capability + digest enrollment, revocation, and distinct-identity binding. +- `spikes/pi-wielder/src/kernel/intent-builder.mjs` — kernel-issued Spend Sessions, + exact request capture, intent hashing, and retry matching without caller-owned + payment headers. +- `spikes/pi-wielder/src/kernel/budget-ledger.mjs` — atomic reserve, commit, release, + unresolved hold, per-seller/session, full-session, and rolling-24-hour accounting. +- `spikes/pi-wielder/src/kernel/approval-queue.mjs` — exact one-time approvals, + denials, expiry, and pending-cap enforcement. +- `spikes/pi-wielder/src/kernel/authorized-permit.mjs` — in-process, one-time, + unforgeable signing capabilities. +- `spikes/pi-wielder/src/kernel/receipt-signing.mjs` — generic Ed25519 receipt + primitives extracted without changing seller-journal behavior. +- `spikes/pi-wielder/src/kernel/signed-receipts.mjs` — terminal buyer receipt + projection, signature, verification, and superseding revisions. +- `spikes/pi-wielder/src/kernel/recovery.mjs` — startup audit, wallet blocking, + trusted reconciliation, and seller-side refund observation. +- `spikes/pi-wielder/src/kernel/projection-exporter.mjs` — sanitized signed read-only + export with no import path. +- `spikes/pi-wielder/src/kernel/wallet-kernel.mjs` — authoritative lifecycle + orchestrator. + +### New adapters and local surfaces + +- `spikes/pi-wielder/src/adapters/deterministic-wallet-adapter.mjs` — offline + contract adapter with injected deterministic signing. +- `spikes/pi-wielder/src/adapters/wallet-adapter-contract.mjs` — provider-neutral + identity/result validation and permit-bound signing contract. +- `spikes/pi-wielder/src/adapters/eip3009-exact.mjs` — closed x402 v2 EIP-3009 + typed-data and payload builder using Kernel-bound nonce and validity. +- `spikes/pi-wielder/src/adapters/cdp-wallet-adapter.mjs` — CDP Server Wallet adapter + using `account.signTypedData()` over the permit-bound authorization and no raw key. +- `spikes/pi-wielder/src/adapters/base-sepolia-observer.mjs` — read-only RPC balance, + settlement, nonce-use, and full-refund observation; it has no signer or send method. +- `spikes/pi-wielder/src/adapters/seller-evidence-resolver.mjs` — bounded same-origin + fetch and domain-separated signature verification for execution/refund attestations. +- `spikes/pi-wielder/src/adapters/x402-v2-transport.mjs` — bounded v2 challenge + decoding, signature-header encoding, one paid retry, and settlement decoding. +- `spikes/pi-wielder/src/spend-control-proxy.mjs` — Pi-facing route map and stable + approval, denial, unresolved, and receipt responses. +- `spikes/pi-wielder/src/operator/auth.mjs` — owner-only token and authenticated local + session handling. +- `spikes/pi-wielder/src/operator/api.mjs` — loopback operator API. +- `spikes/pi-wielder/src/operator/cli.mjs` — preflight, policy, approval, receipt, + isolation/enrollment, session, reconciliation, and export commands. +- `spikes/pi-wielder/src/operator/console.mjs` — local static console server. +- `spikes/pi-wielder/src/agent/credential.mjs` — Pi-identity-only raw capability + creation plus non-secret enrollment descriptor. +- `spikes/pi-wielder/src/agent/isolation-preflight.mjs` — distinct UID/GID, + OS-denial preflight, and durable isolation-attestation repository contracts for live + mode. +- `spikes/pi-wielder/scripts/build-release-manifest.mjs` — privileged, exclusive + manifest creation for a root-owned installed release. +- `spikes/pi-wielder/scripts/preflight-live-deployment.mjs` — root/service-manager + prelaunch verification before dropping to the Kernel UID. +- `spikes/pi-wielder/deploy/systemd/wallet-kernel.service` and + `wallet-kernel-console.socket` — hardened live-pilot service/socket activation units. +- `spikes/pi-wielder/src/agent/auth.mjs` — digest-only active-enrollment auth and + durable agent-instance/session lookup; it never reads the Pi credential file or is + accepted by the operator API. +- `spikes/pi-wielder/operator-console/index.html` — Overview, Policies, Approvals, + and Receipts shell. +- `spikes/pi-wielder/operator-console/app.mjs` — authenticated local API client. +- `spikes/pi-wielder/operator-console/styles.css` — self-contained local styles. +- `spikes/pi-wielder/src/control-plane.mjs` — environment construction and loopback + process entrypoint. +- `spikes/pi-wielder/src/config.mjs` — closed environment, route-map, policy-file, + testnet-mode, and secret-presence validation without secret serialization. + +### New tests and evidence tooling + +Keep tests flat under `spikes/pi-wielder/tests/` so the existing +`tests/*.test.mjs` script continues to discover them. Create focused +`kernel-*.test.mjs`, `wallet-adapter-*.test.mjs`, `x402-v2-transport.test.mjs`, +`spend-control-proxy.test.mjs`, `operator-*.test.mjs`, and +`spend-control-process-e2e.test.mjs` files as named in the tasks below. + +Create reusable fixtures in `spikes/pi-wielder/tests/fixtures/`, a new +`spikes/pi-wielder/spend-control-e2e.mjs`, and evidence builder/verifier scripts under +`spikes/pi-wielder/scripts/`. Existing evidence directories are immutable. + +### Modified files + +- `.gitignore` +- `spikes/pi-wielder/package.json` +- `spikes/pi-wielder/package-lock.json` +- `spikes/pi-wielder/.env.example` +- `spikes/pi-wielder/src/invocation-journal.mjs` +- `spikes/pi-wielder/pi-extension/x402.ts` +- `spikes/pi-wielder/tests/pi-extension-contract.test.mjs` +- `spikes/pi-wielder/README.md` +- `spikes/pi-wielder/RUNBOOK.md` +- `docs/handoffs/2026-07-31-agent-spend-control-release-handoff.md` (create) + +## Stable domain contracts + +Use these names and values consistently in every task: + +```js +export const KERNEL_SCHEMA_VERSION = 1; +export const X402_VERSION = 2; +export const BASE_SEPOLIA_CAIP2 = 'eip155:84532'; +export const BASE_SEPOLIA_USDC = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +export const BASE_SEPOLIA_USDC_EIP712_NAME = 'USDC'; +export const BASE_SEPOLIA_USDC_EIP712_VERSION = '2'; + +export const DECISIONS = Object.freeze(['allow', 'approval_required', 'deny']); +export const SPEND_SESSION_STATES = Object.freeze(['open', 'policy_blocked', 'closed']); +export const PAYMENT_STATES = Object.freeze([ + 'none', 'reserved', 'signing', 'signed', 'retrying', 'unresolved', 'settled', 'rejected', +]); +export const INTENT_STATES = Object.freeze([ + 'captured', 'challenged', 'approval_pending', 'authorized', 'reserved', 'signing', + 'signed', 'retrying', 'unresolved', 'terminal', +]); +export const EXECUTION_STATES = Object.freeze(['none', 'succeeded', 'failed', 'unknown']); +export const EXECUTION_RESOLUTION_STATES = Object.freeze([ + 'refund_pending', 'reconciliation_required', 'resolved', +]); +export const BUYER_OUTCOMES = Object.freeze([ + 'completed', 'upstream_failed', 'payment_denied', 'payment_failed', + 'payment_unresolved', 'payment_rejected', 'execution_failed', + 'execution_unknown', 'refunded', +]); +export const RECONCILIATION_OUTCOMES = Object.freeze([ + 'settled', 'rejected', 'execution_succeeded', 'execution_failed', + 'execution_unknown', 'refund_confirmed', 'refund_rejected', 'unresolved', +]); +``` + +All monetary values cross a persistence or API boundary as canonical non-negative +base-10 strings. Convert to `bigint` only for arithmetic. Never use SQLite `INTEGER`, +JavaScript `number`, floating point, `parseFloat`, or `toFixed` for USDC. + +Every EVM transaction/block hash crossing a boundary is canonical lowercase +`0x` plus 64 hex characters. A shared `canonicalEvmHash()` validates 32 bytes and +lowercases before any comparison, hash, confirmation display, event, or persistence; +closed signed attestations must already contain that canonical form. SQLite uniqueness +therefore operates on canonical bytes. Tests replay the same hash with uppercase and +mixed-case spelling across intents, payment candidates, settlements, refunds, and +reconciliations and prove it cannot bypass uniqueness. + +The Wallet Adapter interface is exactly: + +```js +/** + * @typedef {object} WalletAdapter + * @property {() => Promise<{ + * provider: string, + * walletId: string, + * address: string, + * network: 'eip155:84532', + * }>} walletIdentity + * @property {(authorizedPermit: object, paymentRequired: object) => + * Promise<{paymentPayload: object}>} signX402Exact + */ +``` + +The agent can never supply `PAYMENT-REQUIRED`, `PAYMENT-SIGNATURE`, +`PAYMENT-RESPONSE`, legacy `X-PAYMENT*`, `Idempotency-Key`, an approval identifier, +or a Spend Session identifier. The proxy owns those values. + +### Task 1: Lock the runtime, dependencies, and canonical boundaries + +**Files:** + +- Modify: `spikes/pi-wielder/package.json:1-25` +- Modify: `spikes/pi-wielder/package-lock.json` +- Create: `spikes/pi-wielder/src/kernel/canonical.mjs` +- Create: `spikes/pi-wielder/tests/kernel-canonical.test.mjs` + +- [ ] **Step 1: Write the failing canonical-boundary tests** + +Create `spikes/pi-wielder/tests/kernel-canonical.test.mjs`: + +```js +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + KernelError, + canonicalAtomic, + canonicalEvmHash, + canonicalJson, + exactRecord, + sha256, +} from '../src/kernel/canonical.mjs'; + +test('canonical JSON sorts object keys recursively without mutating input', () => { + const input = Object.freeze({ z: 1, a: Object.freeze({ y: 2, b: 3 }) }); + assert.equal(canonicalJson(input), '{"a":{"b":3,"y":2},"z":1}'); + assert.deepEqual(input, { z: 1, a: { y: 2, b: 3 } }); +}); + +test('atomic USDC accepts canonical strings only', () => { + assert.deepEqual(canonicalAtomic('0', 'amount'), { text: '0', value: 0n }); + assert.deepEqual(canonicalAtomic('250000', 'amount'), { text: '250000', value: 250000n }); + for (const value of [1, 1n, '', '01', '-1', '1.0', '1e6']) { + assert.throws(() => canonicalAtomic(value, 'amount'), + (error) => error instanceof KernelError && error.code === 'ATOMIC_FORMAT'); + } +}); + +test('closed records reject inherited, missing, and unknown fields', () => { + assert.deepEqual(exactRecord({ a: 1 }, ['a'], [], 'SHAPE', 'record'), { a: 1 }); + assert.throws(() => exactRecord({ a: 1, b: 2 }, ['a'], [], 'SHAPE', 'record'), + (error) => error.code === 'SHAPE'); + assert.throws(() => exactRecord(Object.create({ a: 1 }), ['a'], [], 'SHAPE', 'record'), + (error) => error.code === 'SHAPE'); + assert.throws(() => exactRecord({ a: 1, [Symbol('hidden')]: 2 }, ['a'], [], 'SHAPE', 'record'), + (error) => error.code === 'SHAPE'); +}); + +test('sha256 hashes canonical bytes with an explicit prefix', () => { + assert.match(sha256(Buffer.from('wallet-kernel')), /^sha256:[0-9a-f]{64}$/); +}); + +test('EVM hashes canonicalize once before comparison or persistence', () => { + const upper = `0x${'AB'.repeat(32)}`; + assert.equal(canonicalEvmHash(upper, 'transaction'), `0x${'ab'.repeat(32)}`); + for (const value of ['', 'ab', `0x${'ab'.repeat(31)}`, `0x${'gg'.repeat(32)}`]) { + assert.throws(() => canonicalEvmHash(value, 'transaction'), + (error) => error.code === 'EVM_HASH_FORMAT'); + } +}); + +test('canonical JSON rejects values JSON would drop, coerce, or ambiguously encode', () => { + for (const value of [ + { dropped: undefined }, + { fn() {} }, + { symbol: Symbol('x') }, + { bigint: 1n }, + { infinity: Number.POSITIVE_INFINITY }, + { negativeZero: -0 }, + { date: new Date('2026-07-31T00:00:00.000Z') }, + Object.defineProperty({ a: 1 }, 'hidden', { value: 2, enumerable: false }), + Object.defineProperty({}, 'getter', { enumerable: true, get() { throw new Error('must not run'); } }), + Object.defineProperty([1], 'hidden', { value: 2, enumerable: false }), + Object.defineProperty([1], '0', { enumerable: true, get() { throw new Error('must not run'); } }), + Object.assign([1], { [Symbol('hidden')]: 2 }), + ]) { + assert.throws(() => canonicalJson(value), + (error) => error instanceof KernelError && error.code === 'CANONICAL_TYPE'); + } +}); +``` + +- [ ] **Step 2: Run the test and verify the module is absent** + +Run: + +```bash +node --test spikes/pi-wielder/tests/kernel-canonical.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/kernel/canonical.mjs`. + +- [ ] **Step 3: Pin the runtime and protocol dependencies** + +From `spikes/pi-wielder`, run: + +```bash +npm install --save-exact @coinbase/cdp-sdk@1.54.0 @x402/core@2.19.0 @x402/evm@2.19.0 +npm install --save-dev --save-exact @earendil-works/pi-coding-agent@0.80.6 +npm pkg set engines.node=">=24.15.0" +``` + +Expected: `package.json` and `package-lock.json` contain exact dependency versions, +the existing Hono/viem dependencies remain present, and the live release procedure +later pins the attested Node executable to exactly `v24.15.0`. + +- [ ] **Step 4: Implement canonical values and stable errors** + +Create `spikes/pi-wielder/src/kernel/canonical.mjs`: + +```js +import crypto from 'node:crypto'; + +export class KernelError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = 'KernelError'; + this.code = code; + } +} + +export function exactRecord(value, required, optional = [], code = 'SCHEMA', label = 'value') { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) { + throw new KernelError(code, `${label} must be one plain object`); + } + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(value); + if (required.some((key) => !Object.hasOwn(value, key)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key)) + || keys.some((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return !descriptor.enumerable || !Object.hasOwn(descriptor, 'value'); + })) { + throw new KernelError(code, `${label} fields do not match the closed schema`); + } + return structuredClone(value); +} + +function canonicalize(value) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || Object.is(value, -0)) { + throw new KernelError('CANONICAL_TYPE', 'canonical numbers must be safe integers'); + } + return value; + } + if (Array.isArray(value)) { + const keys = Reflect.ownKeys(value); + const indexes = Array.from({ length: value.length }, (_, index) => String(index)); + const allowedKeys = new Set([...indexes, 'length']); + if (keys.length !== indexes.length + 1 + || !Object.hasOwn(value, 'length') + || indexes.some((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return !descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value'); + }) + || keys.some((key) => !allowedKeys.has(key))) { + throw new KernelError('CANONICAL_TYPE', + 'canonical arrays must contain only dense enumerable data elements'); + } + return value.map(canonicalize); + } + if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string') + || keys.some((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return !descriptor.enumerable || !Object.hasOwn(descriptor, 'value'); + })) { + throw new KernelError('CANONICAL_TYPE', 'canonical objects require enumerable data properties'); + } + return Object.fromEntries(keys.sort().map((key) => [key, canonicalize(value[key])])); + } + throw new KernelError('CANONICAL_TYPE', 'value is not canonical JSON data'); +} + +export const canonicalJson = (value) => JSON.stringify(canonicalize(value)); + +export function sha256(value) { + if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) { + throw new KernelError('HASH_INPUT', 'hash input must be a string or bytes'); + } + const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : Buffer.from(value); + return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`; +} + +export function canonicalAtomic(value, label) { + if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value)) { + throw new KernelError('ATOMIC_FORMAT', `${label} must be canonical atomic USDC text`); + } + return Object.freeze({ text: value, value: BigInt(value) }); +} + +export function canonicalEvmHash(value, label) { + if (typeof value !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(value)) { + throw new KernelError('EVM_HASH_FORMAT', `${label} must be one 32-byte EVM hash`); + } + return value.toLowerCase(); +} + +export function canonicalToken(value, label, maximum = 200) { + if (typeof value !== 'string' + || !new RegExp(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,${maximum - 1}}$`).test(value)) { + throw new KernelError('TOKEN_FORMAT', `${label} must be a bounded canonical token`); + } + return value; +} + +export function canonicalTimestamp(value, label) { + const milliseconds = typeof value === 'string' ? Date.parse(value) : Number.NaN; + if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) { + throw new KernelError('TIMESTAMP_FORMAT', `${label} must be a canonical ISO timestamp`); + } + return value; +} + +export function frozenCopy(value) { + const copy = structuredClone(value); + const freeze = (item) => { + if (item && typeof item === 'object' && !Object.isFrozen(item)) { + for (const child of Object.values(item)) freeze(child); + Object.freeze(item); + } + return item; + }; + return freeze(copy); +} +``` + +- [ ] **Step 5: Run focused and regression tests** + +Run: + +```bash +node --test spikes/pi-wielder/tests/kernel-canonical.test.mjs +npm run test --prefix spikes/pi-wielder +``` + +Expected: 6 canonical tests pass and the existing 237-test suite remains green. +Loopback listener tests require an environment that permits `127.0.0.1` binding. + +- [ ] **Step 6: Commit the runtime boundary** + +```bash +git add spikes/pi-wielder/package.json spikes/pi-wielder/package-lock.json \ + spikes/pi-wielder/src/kernel/canonical.mjs \ + spikes/pi-wielder/tests/kernel-canonical.test.mjs +git commit -m "build: pin wallet kernel runtime" +``` + +### Task 2: Create the secure SQLite authority and explicit schema + +**Files:** + +- Modify: `.gitignore` +- Modify: `spikes/pi-wielder/.env.example:1-62` +- Create: `spikes/pi-wielder/src/kernel/secure-storage.mjs` +- Create: `spikes/pi-wielder/src/kernel/authority-lock.mjs` +- Create: `spikes/pi-wielder/src/kernel/sqlite-schema.mjs` +- Create: `spikes/pi-wielder/src/kernel/sqlite-store.mjs` +- Create: `spikes/pi-wielder/tests/kernel-store.test.mjs` +- Create: `spikes/pi-wielder/tests/kernel-authority-lock.test.mjs` +- Create: `spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs` +- Create: `spikes/pi-wielder/tests/fixtures/kernel-lock-worker.mjs` + +- [ ] **Step 1: Add ignored local authority paths** + +Append to `.gitignore`: + +```gitignore +# Agent Spend Control Plane local authority +spikes/pi-wielder/**/*.sqlite +spikes/pi-wielder/**/*.sqlite-wal +spikes/pi-wielder/**/*.sqlite-shm +spikes/pi-wielder/**/*.operator-token +spikes/pi-wielder/**/*.receipt-key +spikes/pi-wielder/**/*.agent-credential +spikes/pi-wielder/**/*.agent-enrollment +spikes/pi-wielder/**/*.authority-lock.sqlite* +``` + +Append to `spikes/pi-wielder/.env.example`: + +```dotenv +# --- Agent Spend Control Plane local authority (absolute, outside checkout) -- +WALLET_KERNEL_DB_FILE= +WALLET_KERNEL_RECEIPT_KEY_FILE= +WALLET_KERNEL_OPERATOR_TOKEN_FILE= +WALLET_KERNEL_EXPECTED_AGENT_UID= +WALLET_KERNEL_EXPECTED_AGENT_GID= +WALLET_KERNEL_POLICY_FILE= +WALLET_KERNEL_ROUTE_FILE= +WALLET_KERNEL_PORT=8402 +WALLET_KERNEL_OPERATOR_PORT=8405 +``` + +- [ ] **Step 2: Write failing store, file-safety, and transaction tests** + +Create `spikes/pi-wielder/tests/kernel-store.test.mjs` with these concrete cases: + +```js +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; +import { acquireAuthorityLock } from '../src/kernel/authority-lock.mjs'; +import { readPrivateInputFile } from '../src/kernel/secure-storage.mjs'; + +function authority() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-')); + fs.chmodSync(directory, 0o700); + return { directory, databasePath: path.join(directory, 'kernel.sqlite') }; +} + +test('persistent store enables WAL, FULL sync, foreign keys, and schema v1', () => { + const { databasePath } = authority(); + const store = openKernelStore({ filePath: databasePath }); + assert.equal(store.pragma('journal_mode'), 'wal'); + assert.equal(store.pragma('synchronous'), 2); + assert.equal(store.pragma('foreign_keys'), 1); + assert.equal(store.pragma('user_version'), 1); + assert.equal(store.integrityCheck(), 'ok'); + store.close(); + for (const suffix of ['', '-wal', '-shm']) { + const target = `${databasePath}${suffix}`; + if (fs.existsSync(target)) assert.equal(fs.statSync(target).mode & 0o777, 0o600); + } +}); + +test('persistent store rejects checkout, symlink, permissive, and wrong-owner-like paths', () => { + assert.throws(() => openKernelStore({ filePath: path.resolve('spikes/pi-wielder/kernel.sqlite') }), + /outside the checkout/); + const { directory, databasePath } = authority(); + const target = path.join(directory, 'target.sqlite'); + fs.writeFileSync(target, '', { mode: 0o600 }); + fs.symlinkSync(target, databasePath); + assert.throws(() => openKernelStore({ filePath: databasePath }), /symlink/); + fs.unlinkSync(databasePath); + fs.chmodSync(directory, 0o755); + assert.throws(() => openKernelStore({ filePath: databasePath }), /owner-only/); +}); + +test('pre-existing SQLite sidecars fail closed instead of being chmod-repaired', () => { + for (const suffix of ['-wal', '-shm']) { + const { directory, databasePath } = authority(); + fs.writeFileSync(`${databasePath}${suffix}`, '', { mode: 0o644 }); + assert.throws(() => openKernelStore({ filePath: databasePath }), /owner-only/); + fs.chmodSync(`${databasePath}${suffix}`, 0o600); + fs.unlinkSync(`${databasePath}${suffix}`); + fs.symlinkSync(path.join(directory, 'missing'), `${databasePath}${suffix}`); + assert.throws(() => openKernelStore({ filePath: databasePath }), /symlink/); + } +}); + +test('production policy and route inputs must be owner-only files outside the checkout', () => { + const { directory } = authority(); + const configPath = path.join(directory, 'policy.json'); + fs.writeFileSync(configPath, '{}\n', { mode: 0o600 }); + assert.equal(readPrivateInputFile(configPath, 'Policy file').toString('utf8'), '{}\n'); + fs.chmodSync(configPath, 0o644); + assert.throws(() => readPrivateInputFile(configPath, 'Policy file'), /owner-only/); +}); + +test('domain mutation and event hash append commit or roll back together', () => { + const store = openKernelStore({ filePath: ':memory:', allowMemory: true }); + store.mutate({ entityType: 'test', entityId: 'one', eventType: 'test.created', data: { value: 1 } }, + ({ db }) => db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('sample', 'one')); + assert.equal(store.events().length, 1); + assert.equal(store.verifyEventChain(), true); + assert.throws(() => store.mutate( + { entityType: 'test', entityId: 'two', eventType: 'test.failed', data: { value: 2 } }, + () => { throw new Error('fault'); }, + ), /fault/); + assert.equal(store.events().length, 1); + assert.equal(store.getMetadata('sample'), 'one'); + store.close(); +}); + +test('a newer unknown schema fails closed', () => { + const { databasePath } = authority(); + const first = openKernelStore({ filePath: databasePath }); + first.close(); + const raw = new DatabaseSync(databasePath); + raw.exec('PRAGMA user_version = 99'); + raw.close(); + assert.throws(() => openKernelStore({ filePath: databasePath }), /newer schema/); +}); + +test('one process owns the authority until its lifetime lock closes', async () => { + const { databasePath } = authority(); + const owner = acquireAuthorityLock({ databasePath, role: 'kernel' }); + assert.throws(() => acquireAuthorityLock({ databasePath, role: 'bootstrap' }), + (error) => error.code === 'AUTHORITY_BUSY'); + owner.close(); + acquireAuthorityLock({ databasePath, role: 'bootstrap' }).close(); +}); +``` + +In `kernel-authority-lock.test.mjs`, also spawn `kernel-lock-worker.mjs` twice against +one path and prove only one reports ready. Abort the owner with `process.abort()` and +prove a new process acquires the same lock without deleting or trusting a PID file; +SQLite's operating-system lock is the lease and is released by process death. Reject +an in-checkout, symlinked, permissive, or wrong-owner-like derived lock database. + +- [ ] **Step 3: Run the store test and verify imports fail** + +Run: + +```bash +node --test spikes/pi-wielder/tests/kernel-store.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for the store and authority-lock modules. + +- [ ] **Step 4: Implement secure persistent paths** + +Create `spikes/pi-wielder/src/kernel/secure-storage.mjs`: + +```js +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../../', import.meta.url))); +const NOFOLLOW = fs.constants.O_NOFOLLOW; + +function assertSecurePlatform() { + if (typeof process.getuid !== 'function' || !Number.isInteger(NOFOLLOW)) { + throw new Error('Wallet Kernel pilot requires POSIX owner and O_NOFOLLOW semantics'); + } +} + +function inside(parent, child) { + const relative = path.relative(parent, child); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..'); +} + +function assertOwner(stat, label) { + assertSecurePlatform(); + if (stat.uid !== process.getuid()) { + throw new Error(`${label} must be owned by the current user`); + } +} + +function privateParent(filePath, label, checkoutRoot) { + if (!path.isAbsolute(filePath)) throw new Error(`${label} path must be absolute`); + const lexicalParent = path.resolve(path.dirname(filePath)); + const parent = fs.realpathSync(lexicalParent); + if (lexicalParent !== parent) throw new Error(`${label} directory must not use symlinks`); + if (inside(checkoutRoot, parent)) throw new Error(`${label} must be outside the checkout`); + const stat = fs.lstatSync(parent); + assertOwner(stat, `${label} directory`); + if (!stat.isDirectory() || (stat.mode & 0o077) !== 0) { + throw new Error(`${label} directory must be owner-only`); + } + return parent; +} + +export function preparePrivateFile(filePath, label, { checkoutRoot = CHECKOUT_ROOT } = {}) { + privateParent(filePath, label, checkoutRoot); + if (!fs.existsSync(filePath)) { + const descriptor = fs.openSync(filePath, + fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, 0o600); + fs.closeSync(descriptor); + } + const stat = fs.lstatSync(filePath); + assertOwner(stat, label); + if (stat.isSymbolicLink()) throw new Error(`${label} must not be a symlink`); + if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) { + throw new Error(`${label} must be an owner-only regular file`); + } + return filePath; +} + +export function readPrivateInputFile(filePath, label, { + checkoutRoot = CHECKOUT_ROOT, + maximumBytes = 1_048_576, +} = {}) { + privateParent(filePath, label, checkoutRoot); + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | NOFOLLOW); + try { + const stat = fs.fstatSync(descriptor); + assertOwner(stat, label); + if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) { + throw new Error(`${label} must be an owner-only regular file`); + } + if (stat.size <= 0 || stat.size > maximumBytes) { + throw new Error(`${label} size is outside the allowed boundary`); + } + return fs.readFileSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +export function preflightSqliteFiles(databasePath) { + const existing = new Set(); + for (const suffix of ['', '-wal', '-shm']) { + const target = `${databasePath}${suffix}`; + let stat; + try { + stat = fs.lstatSync(target); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + if (stat.isSymbolicLink()) throw new Error(`SQLite ${suffix || 'database'} must not be a symlink`); + if (!stat.isFile()) throw new Error(`SQLite ${suffix || 'database'} must be regular`); + assertOwner(stat, `SQLite ${suffix || 'database'}`); + if ((stat.mode & 0o777) !== 0o600) throw new Error(`SQLite ${suffix || 'database'} must be owner-only`); + existing.add(target); + } + return existing; +} + +export function secureNewSqliteSideFiles(databasePath, existing) { + for (const suffix of ['', '-wal', '-shm']) { + const target = `${databasePath}${suffix}`; + let descriptor; + try { descriptor = fs.openSync(target, fs.constants.O_RDONLY | NOFOLLOW); } + catch (error) { if (error.code === 'ENOENT') continue; throw error; } + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) throw new Error(`SQLite ${suffix || 'database'} must be regular`); + assertOwner(stat, `SQLite ${suffix || 'database'}`); + if (!existing.has(target)) fs.fchmodSync(descriptor, 0o600); + } finally { + fs.closeSync(descriptor); + } + } + preflightSqliteFiles(databasePath); +} + +export function loadOrInitializePrivateFile({ + filePath, + label, + createBytes, + validateBytes, + randomBytes = crypto.randomBytes, + faultInjector = () => {}, +}) { + const parent = privateParent(filePath, label, CHECKOUT_ROOT); + const readExisting = () => { + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | NOFOLLOW); + try { + const stat = fs.fstatSync(descriptor); + assertOwner(stat, label); + if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) { + throw new Error(`${label} must be an owner-only regular file`); + } + const bytes = fs.readFileSync(descriptor); + if (bytes.length === 0) throw new Error(`${label} must not be empty`); + return validateBytes(bytes); + } finally { + fs.closeSync(descriptor); + } + }; + try { + return readExisting(); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + const bytes = Buffer.from(createBytes()); + let temporary; + try { + if (bytes.length === 0) throw new Error(`${label} initializer returned empty content`); + validateBytes(bytes); + const suffix = randomBytes(16).toString('hex'); + temporary = path.join(parent, `.${path.basename(filePath)}.tmp-${process.pid}-${suffix}`); + const descriptor = fs.openSync(temporary, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, 0o600); + try { + fs.writeFileSync(descriptor, bytes); + faultInjector('after_private_temp_write'); + fs.fsyncSync(descriptor); + faultInjector('after_private_temp_fsync'); + } finally { + fs.closeSync(descriptor); + } + + try { + // link() is Node's no-replace publish primitive: EEXIST means a racer won. + fs.linkSync(temporary, filePath); + faultInjector('after_private_publish'); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + const parentDescriptor = fs.openSync(parent, fs.constants.O_RDONLY); + try { + fs.fsyncSync(parentDescriptor); + faultInjector('after_private_directory_fsync'); + } finally { + fs.closeSync(parentDescriptor); + } + return readExisting(); + } finally { + bytes.fill(0); + if (temporary) { + try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } + const cleanupDescriptor = fs.openSync(parent, fs.constants.O_RDONLY); + try { fs.fsyncSync(cleanupDescriptor); } finally { fs.closeSync(cleanupDescriptor); } + } + } +} +``` + +Use `readPrivateInputFile()` for every policy and route read in bootstrap and daemon +composition. Parse only the returned bounded bytes; never validate a path and reopen it +later, and never retain raw configuration bytes after canonical validation. + +Use `loadOrInitializePrivateFile()` for the receipt key, operator token, and agent +credential in Tasks 7, 13, and 14. The first two run only as the Kernel UID; the last +runs only in Task 14's Pi-side credential helper and is never called by Kernel +composition. Add a private-temp recovery helper used before +`readExisting()`: it may inspect +only the exact `..tmp--<32 hex>` namespace in the already validated +owner-only parent; it rejects symlinked, wrong-owner, permissive, or invalid candidates. +If the final file is absent it no-replace-publishes the lexicographically first valid +candidate, and if the final is valid it removes only validated candidates, fsyncing the +directory after each resolution. This makes every injected abort point restart-safe. + +Add fresh-process tests proving an existing empty, truncated, symlinked, or invalid +key/token fails closed; two processes racing initialization produce one valid final +file rather than an overwrite; and `process.abort()` after temp write, temp fsync, +no-replace publish, or parent-directory fsync recovers to one valid reusable value with +no truncated final file. + +- [ ] **Step 4a: Implement the shared process-lifetime authority lock** + +Create `authority-lock.mjs` with this exact public boundary: + +```js +export function acquireAuthorityLock({ databasePath, role }) { + // Return an idempotent close() handle, or throw KernelError('AUTHORITY_BUSY'). +} +``` + +Derive the path internally as `${databasePath}.authority-lock.sqlite`; callers cannot +choose it. Validate/create it as an owner-only regular file outside the checkout using +`secure-storage.mjs`, open a separate `DatabaseSync` with `timeout: 0`, force rollback +journal mode, and hold `BEGIN EXCLUSIVE` for the handle's entire lifetime. `role` is +exactly `kernel`, `bootstrap`, or `prelaunch` and is diagnostic only; do not persist PIDs, hostnames, +tokens, or owner-controlled lock content. Map only SQLite busy/locked results to +`AUTHORITY_BUSY`; malformed paths and filesystem state fail with their own stable +preflight error. `close()` rolls back the exclusive transaction and closes the lock +connection. An OS process death releases the SQLite lock automatically, so a leftover +lock database is reusable and is never treated as proof of a live or stale owner. + +The running control plane must acquire role `kernel` before opening the authority +database, recovery, wallet initialization, or either listener, and hold it until +admission stops, listeners close, the authority database closes, and finally the lock +handle closes. All offline bootstrap commands acquire role `bootstrap` through this +same module. The privileged service preflight acquires `prelaunch`, opens the main +authority strictly read-only for its bounded enrollment/attestation lookup, performs no +pragma/schema/event/write operation, closes it, then releases before the daemon starts. +Unit and fresh-process tests prove every pairwise Kernel/bootstrap/prelaunch contention, +clean close; crash release; and that a contender +can never mutate the main database before acquiring the lock. + +- [ ] **Step 5: Create schema v1 with explicit domain tables** + +Create `spikes/pi-wielder/src/kernel/sqlite-schema.mjs` with the following wrapper, +placing the SQL below between the backticks: + +```js +export const KERNEL_SCHEMA_VERSION = 1; + +export const SCHEMA_V1_SQL = String.raw` +-- exact schema shown below +`; +``` + +Replace the single comment line with these exact `STRICT` tables: + +```sql +CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS policy_versions ( + id TEXT PRIMARY KEY, + schema_version INTEGER NOT NULL, + canonical_json TEXT NOT NULL, + policy_hash TEXT NOT NULL UNIQUE, + predecessor_hash TEXT, + applied_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS spend_sessions ( + id TEXT PRIMARY KEY, + adapter_id TEXT NOT NULL, + wallet_address TEXT NOT NULL, + policy_version_id TEXT NOT NULL REFERENCES policy_versions(id), + state TEXT NOT NULL CHECK (state IN ('open','policy_blocked','closed')), + created_at TEXT NOT NULL, + closed_at TEXT +) STRICT; + +CREATE TABLE IF NOT EXISTS agent_enrollments ( + agent_instance_id TEXT PRIMARY KEY, + credential_digest TEXT NOT NULL UNIQUE, + enrollment_hash TEXT NOT NULL UNIQUE, + agent_uid TEXT NOT NULL CHECK ( + agent_uid GLOB '[1-9]*' AND agent_uid NOT GLOB '*[^0-9]*' + ), + agent_gid TEXT NOT NULL CHECK ( + agent_gid GLOB '[1-9]*' AND agent_gid NOT GLOB '*[^0-9]*' + ), + state TEXT NOT NULL CHECK (state IN ('active','revoked')), + enrolled_by_operator_hash TEXT NOT NULL, + enrolled_at TEXT NOT NULL, + revoked_by_operator_hash TEXT, + revoked_at TEXT, + UNIQUE(agent_instance_id, credential_digest), + UNIQUE(agent_instance_id, credential_digest, enrollment_hash), + CHECK ( + (state = 'active' AND revoked_by_operator_hash IS NULL AND revoked_at IS NULL) OR + (state = 'revoked' AND revoked_by_operator_hash IS NOT NULL AND revoked_at IS NOT NULL) + ) +) STRICT; + +CREATE TABLE IF NOT EXISTS isolation_attestations ( + id TEXT PRIMARY KEY, + report_hash TEXT NOT NULL UNIQUE, + enrollment_hash TEXT NOT NULL REFERENCES agent_enrollments(enrollment_hash), + report_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('current','superseded')), + imported_by_operator_hash TEXT NOT NULL, + probed_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + imported_at TEXT NOT NULL, + superseded_at TEXT, + CHECK ( + (state = 'current' AND superseded_at IS NULL) OR + (state = 'superseded' AND superseded_at IS NOT NULL) + ) +) STRICT; + +CREATE TABLE IF NOT EXISTS agent_session_bindings ( + id TEXT PRIMARY KEY, + agent_instance_id TEXT NOT NULL, + credential_digest TEXT NOT NULL, + enrollment_hash TEXT NOT NULL, + session_id TEXT NOT NULL UNIQUE REFERENCES spend_sessions(id), + state TEXT NOT NULL CHECK (state IN ('open','closed')), + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + closed_at TEXT, + FOREIGN KEY(agent_instance_id, credential_digest, enrollment_hash) + REFERENCES agent_enrollments(agent_instance_id, credential_digest, enrollment_hash) +) STRICT; + +CREATE TABLE IF NOT EXISTS spend_intents ( + id TEXT PRIMARY KEY, + request_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL REFERENCES spend_sessions(id), + enrollment_hash TEXT NOT NULL REFERENCES agent_enrollments(enrollment_hash), + route_id TEXT NOT NULL, + method TEXT NOT NULL, + request_url_hash TEXT NOT NULL, + seller_origin TEXT NOT NULL, + resource_path TEXT NOT NULL, + body_hash TEXT NOT NULL, + header_allowlist_hash TEXT NOT NULL, + ordinary_fingerprint TEXT NOT NULL, + retry_matchable INTEGER NOT NULL DEFAULT 1 CHECK (retry_matchable IN (0,1)), + purpose_label TEXT NOT NULL, + correlation_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + wallet_address TEXT NOT NULL, + intent_hash TEXT NOT NULL UNIQUE, + challenge_projection_json TEXT, + challenge_hash TEXT, + challenge_received_at TEXT, + state TEXT NOT NULL CHECK (state IN ( + 'captured','challenged','approval_pending','authorized','reserved','signing', + 'signed','retrying','unresolved','terminal' + )), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS policy_decisions ( + intent_id TEXT PRIMARY KEY REFERENCES spend_intents(id), + policy_version_id TEXT NOT NULL REFERENCES policy_versions(id), + decision TEXT NOT NULL CHECK (decision IN ('allow','approval_required','deny')), + reason_code TEXT NOT NULL, + challenge_hash TEXT NOT NULL, + accepted_index INTEGER, + quote_id TEXT, + amount_ceiling_atomic TEXT NOT NULL CHECK ( + amount_ceiling_atomic = '0' OR + (amount_ceiling_atomic GLOB '[1-9]*' AND amount_ceiling_atomic NOT GLOB '*[^0-9]*') + ), + decided_at TEXT NOT NULL, + CHECK ( + (accepted_index IS NULL AND quote_id IS NULL) OR + (accepted_index >= 0 AND quote_id IS NOT NULL) + ) +) STRICT; + +CREATE TABLE IF NOT EXISTS budget_reservations ( + intent_id TEXT PRIMARY KEY REFERENCES spend_intents(id), + session_id TEXT NOT NULL REFERENCES spend_sessions(id), + seller_origin TEXT NOT NULL, + reserved_atomic TEXT NOT NULL CHECK (reserved_atomic = '0' OR + (reserved_atomic GLOB '[1-9]*' AND reserved_atomic NOT GLOB '*[^0-9]*')), + committed_atomic TEXT NOT NULL CHECK (committed_atomic = '0' OR + (committed_atomic GLOB '[1-9]*' AND committed_atomic NOT GLOB '*[^0-9]*')), + released_atomic TEXT NOT NULL CHECK (released_atomic = '0' OR + (released_atomic GLOB '[1-9]*' AND released_atomic NOT GLOB '*[^0-9]*')), + unresolved_atomic TEXT NOT NULL CHECK (unresolved_atomic = '0' OR + (unresolved_atomic GLOB '[1-9]*' AND unresolved_atomic NOT GLOB '*[^0-9]*')), + state TEXT NOT NULL CHECK (state IN ('reserved','committed','released','unresolved')), + committed_at TEXT, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS approvals ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL UNIQUE REFERENCES spend_intents(id), + decision TEXT NOT NULL CHECK ( + decision IN ('pending','approved','denied','expired','cancelled','consumed') + ), + operator_id_hash TEXT, + intent_hash TEXT NOT NULL, + challenge_hash TEXT NOT NULL, + quote_id TEXT NOT NULL, + accepted_index INTEGER NOT NULL CHECK (accepted_index >= 0), + amount_ceiling_atomic TEXT NOT NULL CHECK ( + amount_ceiling_atomic = '0' OR + (amount_ceiling_atomic GLOB '[1-9]*' AND amount_ceiling_atomic NOT GLOB '*[^0-9]*') + ), + wallet_address TEXT NOT NULL, + policy_version_id TEXT NOT NULL REFERENCES policy_versions(id), + expires_at TEXT NOT NULL, + reason_code TEXT, + decided_at TEXT, + consumed_at TEXT +) STRICT; + +CREATE TABLE IF NOT EXISTS payment_attempts ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL UNIQUE REFERENCES spend_intents(id), + state TEXT NOT NULL CHECK (state IN ( + 'reserved','signing','signed','retrying','unresolved','settled','rejected' + )), + payment_required_projection_json TEXT NOT NULL, + accepted_index INTEGER NOT NULL CHECK (accepted_index >= 0), + payment_payload_json TEXT, + payment_header TEXT, + payment_hash TEXT, + quote_id TEXT NOT NULL, + nonce TEXT UNIQUE, + valid_after TEXT CHECK (valid_after IS NULL OR valid_after = '0' OR + (valid_after GLOB '[1-9]*' AND valid_after NOT GLOB '*[^0-9]*')), + valid_before TEXT CHECK (valid_before IS NULL OR valid_before = '0' OR + (valid_before GLOB '[1-9]*' AND valid_before NOT GLOB '*[^0-9]*')), + settlement_json TEXT, + transaction_id TEXT UNIQUE, + reason_code TEXT, + signing_claimed_at TEXT, + signed_at TEXT, + retry_started_at TEXT, + settled_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS payment_reconciliation_candidates ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL REFERENCES payment_attempts(intent_id), + transaction_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('pending','abandoned','rejected','confirmed')), + evidence_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS execution_outcomes ( + intent_id TEXT PRIMARY KEY REFERENCES spend_intents(id), + state TEXT NOT NULL CHECK (state IN ('succeeded','failed','unknown')), + http_status INTEGER CHECK (http_status IS NULL OR (http_status BETWEEN 100 AND 599)), + response_hash TEXT, + metadata_json TEXT NOT NULL, + recorded_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS execution_resolutions ( + intent_id TEXT PRIMARY KEY REFERENCES execution_outcomes(intent_id), + state TEXT NOT NULL CHECK (state IN ( + 'refund_pending','reconciliation_required','resolved' + )), + reason_code TEXT NOT NULL, + blocks_wallet INTEGER NOT NULL CHECK (blocks_wallet IN (0,1)), + opened_at TEXT NOT NULL, + resolved_at TEXT, + CHECK ( + (state = 'resolved' AND blocks_wallet = 0 AND resolved_at IS NOT NULL) OR + (state != 'resolved' AND blocks_wallet = 1 AND resolved_at IS NULL) + ) +) STRICT; + +CREATE TABLE IF NOT EXISTS refunds ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL REFERENCES spend_intents(id), + original_transaction_id TEXT NOT NULL, + amount_atomic TEXT NOT NULL CHECK (amount_atomic = '0' OR + (amount_atomic GLOB '[1-9]*' AND amount_atomic NOT GLOB '*[^0-9]*')), + state TEXT NOT NULL CHECK ( + state IN ('pending','unresolved','abandoned','confirmed','rejected') + ), + evidence_json TEXT, + refund_transaction_id TEXT UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS reconciliations ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL REFERENCES spend_intents(id), + kind TEXT NOT NULL CHECK (kind IN ('payment','execution','refund')), + outcome TEXT NOT NULL CHECK (outcome IN ( + 'settled','rejected','execution_succeeded','execution_failed', + 'execution_unknown','refund_confirmed','refund_rejected','unresolved' + )), + evidence_json TEXT NOT NULL, + operator_id_hash TEXT NOT NULL, + recorded_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS buyer_outcomes ( + intent_id TEXT PRIMARY KEY REFERENCES spend_intents(id), + status TEXT NOT NULL CHECK (status IN ( + 'completed','upstream_failed','payment_denied','payment_failed', + 'payment_unresolved','payment_rejected','execution_failed', + 'execution_unknown','refunded' + )), + reason_code TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + recorded_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS signed_receipts ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL REFERENCES spend_intents(id), + revision INTEGER NOT NULL CHECK (revision >= 1), + receipt_json TEXT NOT NULL, + receipt_hash TEXT NOT NULL UNIQUE, + signature TEXT NOT NULL, + algorithm TEXT NOT NULL CHECK (algorithm = 'Ed25519'), + key_id TEXT NOT NULL, + supersedes_receipt_hash TEXT, + created_at TEXT NOT NULL, + UNIQUE(intent_id, revision) +) STRICT; + +CREATE TABLE IF NOT EXISTS events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + event_type TEXT NOT NULL, + data_json TEXT NOT NULL, + previous_hash TEXT, + event_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_intents_session_hash + ON spend_intents(session_id, intent_hash, state); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_open_instance + ON agent_session_bindings(agent_instance_id) WHERE state = 'open'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_open_credential + ON agent_session_bindings(credential_digest) WHERE state = 'open'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_active_agent_enrollment + ON agent_enrollments(state) WHERE state = 'active'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_current_isolation_attestation + ON isolation_attestations(state) WHERE state = 'current'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_intents_retry_fingerprint + ON spend_intents(session_id, ordinary_fingerprint) WHERE retry_matchable = 1; +CREATE UNIQUE INDEX IF NOT EXISTS idx_intents_session_correlation + ON spend_intents(session_id, correlation_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_session_binding + ON spend_sessions(adapter_id, wallet_address, policy_version_id) + WHERE state = 'open'; +CREATE INDEX IF NOT EXISTS idx_budget_session_seller + ON budget_reservations(session_id, seller_origin, state); +CREATE INDEX IF NOT EXISTS idx_budget_committed_at + ON budget_reservations(committed_at); +CREATE INDEX IF NOT EXISTS idx_approvals_state_expiry + ON approvals(decision, expires_at); +CREATE INDEX IF NOT EXISTS idx_payment_state + ON payment_attempts(state); +CREATE UNIQUE INDEX IF NOT EXISTS idx_refunds_one_open_intent + ON refunds(intent_id) WHERE state IN ('pending','unresolved'); +CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_candidate_one_open_intent + ON payment_reconciliation_candidates(intent_id) WHERE state = 'pending'; +``` + +In `kernel-store.test.mjs`, attempt direct inserts for every declared state enum, +negative/leading-zero/non-digit/empty atomic text, negative accepted index, invalid HTTP +status, and nonpositive receipt revision. Assert SQLite rejects each invalid value and +accepts the boundary-valid forms. Bounds that depend on another JSON row remain the +startup semantic audit's responsibility in Task 11. + +- [ ] **Step 6: Implement the SQLite transaction and hash-chain store** + +Create `spikes/pi-wielder/src/kernel/sqlite-store.mjs` with this public contract: + +```js +import { DatabaseSync } from 'node:sqlite'; +import { canonicalJson, sha256 } from './canonical.mjs'; +import { + preflightSqliteFiles, + preparePrivateFile, + secureNewSqliteSideFiles, +} from './secure-storage.mjs'; +import { KERNEL_SCHEMA_VERSION, SCHEMA_V1_SQL } from './sqlite-schema.mjs'; + +export function openKernelStore({ filePath, allowMemory = false, now = () => new Date().toISOString() }) { + if (filePath === ':memory:' && !allowMemory) throw new Error('in-memory authority requires explicit test injection'); + const existing = filePath === ':memory:' ? new Set() : preflightSqliteFiles(filePath); + if (filePath !== ':memory:') preparePrivateFile(filePath, 'Wallet Kernel database'); + const db = new DatabaseSync(filePath, { timeout: 5_000, readBigInts: true }); + db.exec('PRAGMA foreign_keys = ON; PRAGMA trusted_schema = OFF; PRAGMA synchronous = FULL;'); + if (filePath !== ':memory:') db.exec('PRAGMA journal_mode = WAL;'); + const version = Number(db.prepare('PRAGMA user_version').get().user_version); + if (version > KERNEL_SCHEMA_VERSION) throw new Error('Wallet Kernel database uses a newer schema'); + if (version === 0) { + db.exec('BEGIN IMMEDIATE'); + try { + db.exec(SCHEMA_V1_SQL); + db.exec(`PRAGMA user_version = ${KERNEL_SCHEMA_VERSION}`); + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + } + if (filePath !== ':memory:') secureNewSqliteSideFiles(filePath, existing); + + const liveTransactions = new WeakSet(); + let transactionOpen = false; + const within = (token, operation) => { + if (!liveTransactions.has(token)) throw new Error('invalid authority transaction'); + return operation({ db, appendEvent: (event) => appendEvent(event, db) }); + }; + const transaction = (operation) => { + if (transactionOpen) throw new Error('nested authority transaction is forbidden'); + transactionOpen = true; + const token = Object.freeze(Object.create(null)); + try { + db.exec('BEGIN IMMEDIATE'); + liveTransactions.add(token); + const value = operation(token); + if (value && typeof value.then === 'function') { + throw new Error('authority transactions must be synchronous'); + } + db.exec('COMMIT'); + if (filePath !== ':memory:') preflightSqliteFiles(filePath); + return value; + } catch (error) { + if (db.isTransaction) db.exec('ROLLBACK'); + throw error; + } finally { + liveTransactions.delete(token); + transactionOpen = false; + } + }; + + const appendEvent = ({ entityType, entityId, eventType, data }, txDb = db) => { + const previous = txDb.prepare('SELECT event_hash FROM events ORDER BY sequence DESC LIMIT 1').get(); + const createdAt = now(); + const dataJson = canonicalJson(data); + const previousHash = previous?.event_hash ?? null; + const eventHash = sha256(canonicalJson({ + entityType, entityId, eventType, data, previousHash, createdAt, + })); + txDb.prepare(`INSERT INTO events + (entity_type, entity_id, event_type, data_json, previous_hash, event_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`) + .run(entityType, entityId, eventType, dataJson, previousHash, eventHash, createdAt); + return eventHash; + }; + + const mutate = (event, operation) => transaction((token) => within(token, + ({ db: txDb, appendEvent: appendInTransaction }) => { + const value = operation({ db: txDb }); + appendInTransaction(event); + return value; + })); + + const events = () => db.prepare('SELECT * FROM events ORDER BY sequence').all(); + const readStatement = (sql) => { + if (typeof sql !== 'string' || !/^\s*SELECT\b/i.test(sql) || sql.includes(';')) { + throw new Error('only one parameterized SELECT is exposed outside a transaction'); + } + return db.prepare(sql); + }; + const readOne = (sql, parameters = []) => readStatement(sql).get(...parameters); + const readAll = (sql, parameters = []) => readStatement(sql).all(...parameters); + const pragma = (name) => { + if (!['journal_mode', 'synchronous', 'foreign_keys', 'user_version'].includes(name)) { + throw new Error('PRAGMA is not exposed'); + } + const value = Object.values(db.prepare(`PRAGMA ${name}`).get())[0]; + return typeof value === 'bigint' ? Number(value) : value; + }; + const verifyEventChain = () => { + let previousHash = null; + for (const row of events()) { + const expected = sha256(canonicalJson({ + entityType: row.entity_type, + entityId: row.entity_id, + eventType: row.event_type, + data: JSON.parse(row.data_json), + previousHash, + createdAt: row.created_at, + })); + if (row.previous_hash !== previousHash || row.event_hash !== expected) return false; + previousHash = row.event_hash; + } + return true; + }; + + return Object.freeze({ + transaction, + within, + mutate, + readOne, + readAll, + events, + verifyEventChain, + pragma, + integrityCheck: () => db.prepare('PRAGMA integrity_check').get().integrity_check, + getMetadata: (key) => db.prepare('SELECT value FROM metadata WHERE key = ?').get(key)?.value ?? null, + close: () => db.close(), + ...(allowMemory ? { execForTest: (sql) => db.exec(sql) } : {}), + }); +} +``` + +Kernel modules use `readOne()`/`readAll()` for parameterized reads and receive the raw +database handle plus event appender only inside `mutate()` or `within(liveToken, ...)`. +The public store has no raw-handle or direct-event escape hatch; HTTP and operator +surfaces must never receive the store object. Store tests assert +`store.rawForModules === undefined` and `store.appendEvent === undefined`, reject +non-SELECT/multi-statement reads, and prove all writes require a store-owned +transaction. + +The opaque transaction token is the only cross-repository composition mechanism. +Standalone repository methods open one transaction. Every repository mutation that +participates in a multi-table aggregate exposes the explicit +`*InTransaction(token, ...)` method named in its task; those methods validate the live +token and never begin/commit/rollback. Wallet Kernel and reconciliation coordinators own the one outer +`store.transaction()` and call only those scoped methods for multi-table transitions. +The token never crosses an `await`, listener, worker, log, or return value. Store tests +prove nested transactions, an async callback, a stale/forged token, and calling a +standalone wrapper from inside a transaction all fail and fully roll back. + +Every event `data` object uses a closed per-event schema containing identifiers, +hashes, states, reason codes, and canonical atomic amounts only. In particular, never +append raw bodies, response bodies, payment payload/header bytes, credentials, tokens, +provider exceptions, or local paths to `events.data_json`; those private payment bytes +live only in the dedicated `payment_attempts` columns needed for exact retry/recovery. + +- [ ] **Step 7: Add the cross-process single-writer claim test** + +This fixture deliberately tests SQLite transaction serialization below the composition +root; it is not a production entrypoint. The daemon/bootstrap exclusion proof remains +`kernel-authority-lock.test.mjs`, and every production store open requires its caller +to already hold that capability. + +Create `spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs`: + +```js +import { openKernelStore } from '../../src/kernel/sqlite-store.mjs'; + +const [databasePath, claimId] = process.argv.slice(2); +const store = openKernelStore({ filePath: databasePath }); +try { + const outcome = store.transaction((token) => store.within(token, + ({ db, appendEvent }) => { + const current = db.prepare('SELECT value FROM metadata WHERE key = ?').get('claim'); + if (current) return 'already_claimed'; + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('claim', claimId); + appendEvent({ + entityType: 'test', + entityId: claimId, + eventType: 'test.claimed', + data: { claimId }, + }); + return 'claimed'; + })); + process.stdout.write(`${outcome}\n`); +} finally { + store.close(); +} +``` + +Add these imports and this test to `kernel-store.test.mjs`: + +```js +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +function childResult(child) { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', reject); + child.on('exit', (code) => code === 0 + ? resolve(stdout.trim()) + : reject(new Error(`writer exited ${code}: ${stderr}`))); + }); +} + +test('two processes serialize one conditional claim and one hash-chain event', async () => { + const { databasePath } = authority(); + const initial = openKernelStore({ filePath: databasePath }); + initial.close(); + const fixture = fileURLToPath(new URL('./fixtures/kernel-db-writer.mjs', import.meta.url)); + const children = ['a', 'b'].map((claimId) => spawn( + process.execPath, + [fixture, databasePath, claimId], + { stdio: ['ignore', 'pipe', 'pipe'] }, + )); + assert.deepEqual((await Promise.all(children.map(childResult))).sort(), + ['already_claimed', 'claimed']); + const reopened = openKernelStore({ filePath: databasePath }); + assert.equal(reopened.verifyEventChain(), true); + assert.equal(reopened.events().filter((event) => event.event_type === 'test.claimed').length, 1); + reopened.close(); +}); +``` + +Run: + +```bash +node --test spikes/pi-wielder/tests/kernel-store.test.mjs \ + spikes/pi-wielder/tests/kernel-authority-lock.test.mjs +``` + +Expected: all store/lock tests pass, including cross-process serialization, lifetime +contention, and crash release. + +- [ ] **Step 8: Commit the durable authority** + +```bash +git add .gitignore spikes/pi-wielder/.env.example \ + spikes/pi-wielder/src/kernel/secure-storage.mjs \ + spikes/pi-wielder/src/kernel/authority-lock.mjs \ + spikes/pi-wielder/src/kernel/sqlite-schema.mjs \ + spikes/pi-wielder/src/kernel/sqlite-store.mjs \ + spikes/pi-wielder/tests/kernel-store.test.mjs \ + spikes/pi-wielder/tests/kernel-authority-lock.test.mjs \ + spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs \ + spikes/pi-wielder/tests/fixtures/kernel-lock-worker.mjs +git commit -m "feat: add durable wallet kernel store" +``` + +### Task 3: Implement immutable policy versions and the pure Policy Engine + +**Files:** + +- Create: `spikes/pi-wielder/policies/base-sepolia.example.json` +- Create: `spikes/pi-wielder/src/kernel/policy-engine.mjs` +- Create: `spikes/pi-wielder/src/kernel/policy-repository.mjs` +- Create: `spikes/pi-wielder/tests/kernel-policy.test.mjs` + +- [ ] **Step 1: Write the failing policy matrix** + +Create tests that use this exact base policy: + +```js +const policy = { + schemaVersion: 1, + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + wallet: '0x1000000000000000000000000000000000000000', + methods: ['GET', 'POST'], + sellers: [{ + origin: 'https://seller.example', + pathPrefixes: ['/paid/'], + payTo: '0x2000000000000000000000000000000000000000', + evidencePath: '/.well-known/wallet-kernel/evidence', + executionSigner: '0x2000000000000000000000000000000000000000', + refundSigner: '0x2000000000000000000000000000000000000000', + refundSource: '0x3000000000000000000000000000000000000000', + perRequestMaxAtomic: '500000', + autoApproveAtomic: '100000', + humanApproveAtomic: '500000', + sellerSessionMaxAtomic: '1000000', + }], + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '5000000', + challengeMaxAgeMs: 60000, + approvalTtlMs: 300000, + maxPendingApprovals: 20, + defaultAction: 'deny', +}; +``` + +Assert all of the following: + +```text +50,000 atomic exact request -> allow / WITHIN_AUTO_LIMIT +250,000 atomic exact request -> approval_required / HUMAN_APPROVAL_REQUIRED +500,001 atomic request -> deny / PER_REQUEST_LIMIT +unsupported scheme, version, network, asset, method, seller, path, payee -> deny, never approval +non-canonical evidence path or invalid evidence signer/refund source -> policy validation error +duplicate canonical seller origin or duplicate canonical path prefix -> policy validation error +non-HTTPS seller origin other than literal loopback HTTP -> policy validation error +zero compatible payment options -> stable mismatch denial; duplicate/multiple compatible options -> deny / PAYMENT_OPTIONS_AMBIGUOUS +one compatible option plus unsupported alternatives -> select its original array index, never index zero by default +seller/session, full-session, or rolling-24-hour exposure overflow -> deny +pending approval capacity reached -> deny / APPROVAL_CAPACITY +unknown policy or challenge fields -> validation error before a decision +same frozen input snapshot -> byte-identical decision and no mutation +``` + +Also persist two policy versions and assert the second row points to the first policy +hash while the first row remains byte-identical. + +- [ ] **Step 2: Run the test and verify the policy module is absent** + +```bash +node --test spikes/pi-wielder/tests/kernel-policy.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Add the canonical example policy** + +Create `spikes/pi-wielder/policies/base-sepolia.example.json` using the exact policy +object above, formatted as JSON. It is public configuration, contains no credential, +and uses only documented test addresses. +`evidencePath` is a queryless absolute path on the exact seller origin and is pinned +inside the immutable PolicyVersion; it cannot be a full URL, redirect target, or route +file override. Require one leading slash (never `//`), no dot segment, encoded slash, +credentials, query, or fragment, and prove `new URL(evidencePath, origin)` preserves +the exact origin and canonical pathname. `executionSigner` and `refundSigner` are +public EVM addresses whose distinct domain-separated signatures attest execution and +future full-refund observations. `refundSource` is the separately pinned public EVM +address from which the on-chain USDC refund transfer must originate; it grants no +off-chain attestation authority. Validation requires the canonical path and all three +addresses. Neither evidence signer nor the refund source grants spending or signing +authority to the buyer, and an attestation can never substitute for the on-chain +payment/refund proof required by its reconciliation path. + +- [ ] **Step 4: Implement closed-schema validation and pure evaluation** + +Create `policy-engine.mjs` with these exports and decision shape: + +```js +export function validatePolicyDocument(document) { + // Return a deeply frozen, normalized closed-schema policy; throw KernelError on invalid input. +} + +export function selectExactCandidate({ policy, intent, paymentRequired }) { + // Return { acceptedIndex, accepted } for exactly one compatible candidate, + // or a stable deny result with null acceptedIndex/quoteId. +} + +export function evaluateSpendPolicy(input) { + // Return exactly: + return Object.freeze({ + decision: 'allow', + reasonCode: 'WITHIN_AUTO_LIMIT', + policyHash: computedPolicyHash, + challengeHash: computedChallengeHash, + quoteId: computedQuoteId, + amountCeilingAtomic: '50000', + acceptedIndex: 0, + }); +} +``` + +The return above specifies the closed output schema and one allowed example; +the implementation computes every value from `input` and returns the applicable +decision and reason code. The identifiers are locally computed values, not fixture +literals or caller inputs. + +The input is also closed and exact: + +```js +const validatedPolicy = validatePolicyDocument(policy); +const policyHash = sha256(canonicalJson(validatedPolicy)); +const input = Object.freeze({ + policy: validatedPolicy, + policyVersion: { id: 'policy-1', hash: policyHash }, + intent: { + id: 'intent-1', + method: 'POST', + requestUrl: 'https://seller.example/paid/infer', + sellerOrigin: 'https://seller.example', + resourcePath: '/paid/infer', + walletAddress: '0x1000000000000000000000000000000000000000', + }, + wallet: { + provider: 'deterministic', + walletId: 'buyer-a', + address: '0x1000000000000000000000000000000000000000', + network: 'eip155:84532', + }, + paymentRequired, + challengeReceivedAtMs: 1785502800000, + nowMs: 1785502801000, + budgetSnapshot: { + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + pendingApprovalCount: 0, + }, +}); +``` + +Reject unknown or missing keys at every level. `selectExactCandidate()` owns selection; +the transport and Kernel may never supply or default an index. It keeps the original +array indexes, filters for exact/Base-Sepolia/Base-Sepolia-USDC/EIP-3009 and the +selected seller policy's payee, and validates canonical amounts without using amount +to eliminate alternatives. Base-Sepolia-USDC compatibility requires exact +`extra.name === 'USDC'` and `extra.version === '2'`; these seller fields cannot select +another EIP-712 domain. EIP-3009 means `extra.assetTransferMethod` is absent or exactly +`eip3009`; `permit2` and unknown methods are unsupported. Zero compatible +candidates returns the stable ordered mismatch denial, while more than one—including +duplicates—returns `PAYMENT_OPTIONS_AMBIGUOUS`. Exactly one candidate is evaluated and +its original index is persisted in `policy_decisions`, approvals, and payment attempts. + +Validate `policyVersion.hash` against the canonical policy, and require the intent +wallet, live adapter identity, PolicyVersion wallet, selected network, and selected +asset to agree before applying amount or approval rules. Tests independently mutate +every input field and prove a stable denial/validation error with no mutation. Add +explicit 0/1/2-candidate, duplicate, reorder, and unsupported-alternative cases; +reordering changes `challengeHash`/`quoteId`, and no code path defaults to index `0`. +Policy validation accepts a seller origin only as canonical HTTPS or, solely so the +same pure validator can support offline evidence, HTTP at literal `127.0.0.1` or +`[::1]`; it never accepts an HTTP hostname. Task 12's mode-aware configuration and +route checks reject that loopback exception in `cdp-testnet`. + +The implementation must perform checks in this order so reason codes are stable: + +```js +const CHECK_ORDER = Object.freeze([ + 'X402_VERSION', + 'SCHEME_UNSUPPORTED', + 'NETWORK_MISMATCH', + 'ASSET_MISMATCH', + 'WALLET_MISMATCH', + 'METHOD_UNSUPPORTED', + 'SELLER_UNTRUSTED', + 'RESOURCE_PATH', + 'PAYEE_MISMATCH', + 'PAYMENT_OPTIONS_AMBIGUOUS', + 'CHALLENGE_EXPIRED', + 'PER_REQUEST_LIMIT', + 'SELLER_SESSION_LIMIT', + 'SESSION_LIMIT', + 'ROLLING_24H_LIMIT', + 'APPROVAL_CAPACITY', +]); +``` + +Use `paymentRequired.x402Version === 2`, `accepted.scheme === 'exact'`, the exact +CAIP-2 network, canonical lower-case asset/payee addresses, and +`new URL(paymentRequired.resource.url)` matched to the exact Spend Intent URL. Derive +`maxTimeoutSeconds` only from a structurally validated positive safe integer in the +pilot range `1..3600`; it is a protocol maximum, never an extension of the tighter +local challenge or approval deadline. +Reject duplicate canonical seller origins before evaluation. Seller selection is an +exact map lookup by canonical origin, never a first-match or array-order operation; +within that one seller entry, the canonical resource path must start with at least one +unique canonical `pathPrefixes` value. Reordering distinct seller entries cannot +change which authority applies to an intent. Add duplicate-origin, duplicate-prefix, +overlapping-prefix, and seller-array-reordering tests; overlapping unique prefixes in +one entry are harmless because they share one payee and one set of limits. + +Derive `challengeHash` from canonical closed challenge-projection bytes (version, hashed +resource URL, fixed public route metadata, and the ordered full financial requirements, +but no seller error/extensions/free text) and derive `quoteId` as +`sha256(canonicalJson({ challengeHash, acceptedIndex }))`; do not require a nonstandard +seller-supplied request hash. + +`challengeReceivedAt` is the Kernel clock value captured with the decoded unpaid +response; x402 v2 does not require a seller-issued challenge timestamp. Reject when +`nowMs - challengeReceivedAtMs > challengeMaxAgeMs`. Approval expiry is the earlier of +that local challenge deadline and `decisionTime + approvalTtlMs`; an operator decision +can never extend challenge validity. + +For a deny before unique selection, return `acceptedIndex: null`, `quoteId: null`, and +`amountCeilingAtomic: '0'`; these nullable pairs are persisted exactly as constrained +by schema v1. Return `allow` when the selected amount is at or below `autoApproveAtomic`, +`approval_required` when it is above that value and at or below +`humanApproveAtomic`, and `deny` above it. Unsupported protocol shape is always deny +and cannot be overridden. + +The module must not import `node:sqlite`, `node:fs`, `fetch`, a wallet module, or a +clock. It receives `nowMs` and `budgetSnapshot` as values. + +- [ ] **Step 5: Implement predecessor-linked PolicyVersion persistence** + +Create `policy-repository.mjs` exporting: + +```text +export function createPolicyRepository(store) { + return { + apply(document, appliedAt), + active(), + history(), + get(id), + }; +} +``` + +`apply()` validates and canonicalizes before entering a transaction and rejects a +policy whose wallet differs from an existing open or policy-blocked Spend Session. In +one transaction it inserts the immutable row, updates `metadata.active_policy_id`, +changes every prior-version open session to `policy_blocked`, and appends the policy +and per-session block events. Admission, approval retry, reservation, and new signing +must reject `policy_blocked` before transport; an already signed/retrying attempt may +only finish or become unresolved under its persisted old binding. Return the exact +blocked session IDs so the operator can transition them deliberately. Reapplying the +identical active hash is an idempotent lookup, not a duplicate version or re-block. + +At this task boundary, test only policy persistence and session-state effects that now +exist: applying a tighter same-wallet policy succeeds, becomes active, atomically marks +every prior-version open session `policy_blocked`, returns those IDs, and is idempotent +on exact replay. Do not make `kernel-policy.test.mjs` import future approval, budget, +transport, wallet, or Kernel modules. Tasks 6 and 10 add pending-approval, reservation, +in-flight signing, admission rejection, and guarded-transition enforcement once those +modules exist; Task 14 proves the same boundary through the agent surface. + +- [ ] **Step 6: Run the focused tests and the old policy regression suite** + +```bash +node --test spikes/pi-wielder/tests/kernel-policy.test.mjs +npm run test:policy --prefix spikes/pi-wielder +``` + +Expected: the new policy matrix passes and all 51 legacy v1 policy tests remain green. + +- [ ] **Step 7: Commit the policy boundary** + +```bash +git add spikes/pi-wielder/policies/base-sepolia.example.json \ + spikes/pi-wielder/src/kernel/policy-engine.mjs \ + spikes/pi-wielder/src/kernel/policy-repository.mjs \ + spikes/pi-wielder/tests/kernel-policy.test.mjs +git commit -m "feat: add versioned agent spend policy" +``` + +### Task 4: Add kernel-issued Spend Sessions and exact Spend Intents + +**Files:** + +- Create: `spikes/pi-wielder/src/kernel/agent-enrollment.mjs` +- Create: `spikes/pi-wielder/src/kernel/intent-builder.mjs` +- Create: `spikes/pi-wielder/tests/kernel-agent-enrollment.test.mjs` +- Create: `spikes/pi-wielder/tests/kernel-intent.test.mjs` + +- [ ] **Step 1: Write the failing session and intent boundary tests** + +Create `kernel-intent.test.mjs` around an in-memory Kernel store, an applied example +policy, one active non-secret enrollment, a deterministic `idFactory`, and a fixed +clock. First, `kernel-agent-enrollment.test.mjs` exercises this exact boundary: + +```js +const descriptor = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const descriptorHash = sha256(canonicalJson(descriptor)); +const enrollments = createAgentEnrollmentRepository({ store, now }); +const enrolled = enrollments.enroll({ + descriptor, + expectedDescriptorHash: descriptorHash, + operatorIdHash: `sha256:${'cd'.repeat(32)}`, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, +}); +assert.equal(enrolled.enrollmentHash, descriptorHash); +assert.equal(enrollments.active().credentialDigest, descriptor.credentialDigest); +``` + +`descriptorHash` is always SHA-256 over the UTF-8 canonical JSON object with no +trailing newline; the handoff file bytes are exactly that canonical JSON plus one +newline. The Pi helper and Kernel importer use this same rule. + +Require the exact five-field descriptor, canonical 16-byte instance ID, fixed SHA-256 +credential digest, canonical nonzero decimal UID/GID strings, exact descriptor hash, +bounded operator hash, and no token/token-like or unknown field. Parse each identity +string with an exact safe-integer round trip before OS use; configuration exposes +`expectedAgentUid`/`expectedAgentGid` as numbers. Live mode requires the parsed values +to equal those configured numbers, parsed `agentUid !== kernelUid`, and all UID/GID +values nonzero. Persist only the canonical strings. A shared primary group is permitted for macOS compatibility +only because every authority path has zero group permissions and the real denial +probe passes. The isolation probe must +clear supplementary groups before dropping to the +pinned primary GID/UID. Deterministic mode +permits only an explicit injected same-UID fixture and labels it simulated. Exact +reenrollment is idempotent; a second active identity/digest/UID/GID conflicts. Revocation +requires the exact persisted enrollment hash and operator hash, atomically marks the +row revoked and any `current` isolation attestation for that enrollment `superseded` +at the same timestamp, then returns any still-bound session IDs without closing, +releasing, or altering monetary state. Authentication and `currentFor()` must reject +that epoch immediately. A replacement may +be enrolled only after every binding for the revoked enrollment is safely closed. +Every session binding and Spend Intent carries the immutable `enrollmentHash`; Task +10 revalidates that epoch inside each authoritative capture/reservation/signing +transaction so a pre-revocation HTTP auth result cannot authorize post-revocation +spend. + +Then exercise the intent/session API: + +```js +const intents = createIntentRepository({ + store, + idFactory: sequenceIds('session', 'intent', 'request'), + now: () => '2026-07-31T12:00:00.000Z', + allowLoopbackHttp: false, +}); + +const session = intents.openOrResumeSession({ + agentInstanceId: descriptor.agentInstanceId, + walletAddress: '0x1000000000000000000000000000000000000000', + policyVersionId: activePolicy.id, +}); + +const intent = intents.captureIntent({ + sessionId: session.id, + routeId: 'example-skill', + method: 'POST', + requestUrl: 'https://seller.example/paid/infer', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + bodyBytes: Buffer.from('{"prompt":"redacted after hashing"}'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-001', +}); +``` + +Assert: + +```js +assert.equal(session.id, 'session-1'); +assert.equal(intent.id, 'intent-1'); +assert.equal(intent.requestId, 'request-1'); +assert.equal(intent.enrollmentHash, enrolled.enrollmentHash); +assert.equal(intent.routeId, 'example-skill'); +assert.equal(intent.sellerOrigin, 'https://seller.example'); +assert.equal(intent.resourcePath, '/paid/infer'); +assert.match(intent.requestUrlHash, /^sha256:[0-9a-f]{64}$/); +assert.match(intent.bodyHash, /^sha256:[0-9a-f]{64}$/); +assert.match(intent.headerAllowlistHash, /^sha256:[0-9a-f]{64}$/); +assert.match(intent.intentHash, /^sha256:[0-9a-f]{64}$/); +assert.match(intent.idempotencyKey, /^wk_[0-9a-f]{64}$/); +assert.equal(JSON.stringify(store.readOne( + 'SELECT * FROM spend_intents WHERE id = ?', [intent.id], +)).includes('redacted after hashing'), false); +``` + +Add table-driven rejection coverage for these lower-cased header names: + +```js +const FORBIDDEN_AGENT_HEADERS = Object.freeze([ + 'payment-required', + 'payment-signature', + 'payment-response', + 'x-payment', + 'x-payment-required', + 'x-payment-response', + 'idempotency-key', + 'x-approval-id', + 'x-spend-session', +]); +``` + +For each header, assert `captureIntent()` throws `AGENT_HEADER_FORBIDDEN` and inserts +no row. Also assert caller-supplied `sessionId` at `openOrResumeSession()`, a missing or +non-token `routeId`, credentials in a URL, a non-HTTPS upstream, fragments, search +parameters (including `?prompt=RAW_PROMPT_SENTINEL`), an unknown session, a closed +session, a policy wallet mismatch, and duplicate `correlationId` fail closed without +persisting the sentinel. With a repository explicitly constructed as +`allowLoopbackHttp: true`, accept HTTP only when the URL host is the literal canonical +loopback address `127.0.0.1` or `[::1]`; still reject hostnames and every non-loopback +HTTP URL. CDP mode never enables this option. + +Finally, capture the same ordinary retry twice and assert: + +```js +assert.equal(intents.matchRetry({ sessionId: session.id, request }), intent.id); +assert.equal(intents.matchRetry({ sessionId: 'another-session', request }), null); +assert.equal(intents.matchRetry({ sessionId: session.id, request: changedBody }), null); +``` + +Race two initial captures with the same session, normalized ordinary request, and +purpose. Assert both resolve to the same persisted intent/request ID, exactly one +`intent.captured` event exists, and no second approval can later be created. A +different session or fingerprint still creates its own intent. + +- [ ] **Step 2: Run the focused test and observe the missing module** + +```bash +node --test spikes/pi-wielder/tests/kernel-agent-enrollment.test.mjs \ + spikes/pi-wielder/tests/kernel-intent.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for the enrollment/intent modules. + +- [ ] **Step 3: Implement exact capture and retry matching** + +Create `agent-enrollment.mjs` with this exact surface: + +```text +export function createAgentEnrollmentRepository({ store, now }) -> { + enroll({ descriptor, expectedDescriptorHash, operatorIdHash, + mode, kernelUid, kernelGid, expectedAgentUid, expectedAgentGid }), + active(), + get(agentInstanceId), + revoke({ agentInstanceId, expectedEnrollmentHash, operatorIdHash }), +} +``` + +Every mutation owns one `BEGIN IMMEDIATE`, reloads state, and appends its event in the +same transaction. It persists only the descriptor projection/hash, operator hashes, +and timestamps—never the descriptor bytes or raw token. `revoke()` is allowed even +when a bound session has unresolved money because its only immediate effect is to deny +future agent authentication and supersede that enrollment's admission-only isolation +attestation; it does not pretend the session or money is resolved. The enrollment and +attestation events commit together. Revoke/reopen tests require zero `current` +attestations, a retained historical superseded row, recovery-only startup, and +semantic-corruption failure if a forged current row is reattached to the revoked epoch. + +Create `intent-builder.mjs` with this export surface: + +```js +export const FORBIDDEN_AGENT_HEADERS = Object.freeze([ + 'payment-required', 'payment-signature', 'payment-response', + 'x-payment', 'x-payment-required', 'x-payment-response', + 'idempotency-key', 'x-approval-id', 'x-spend-session', +]); + +export function canonicalIntentFingerprint({ routeId, method, requestUrl, headers, bodyBytes }) { + // Return the closed canonical object used by both captureIntent and matchRetry. +} + +export function createIntentRepository({ store, idFactory, now, allowLoopbackHttp = false }) { + return Object.freeze({ + openOrResumeSession, + transitionBlockedSessionInTransaction, + closeBoundSessionInTransaction, + getSession, + captureIntent, + attachChallenge, + transition, + transitionInTransaction, + getIntent, + matchRetry, + }); +} +``` + +`openOrResumeSession({ agentInstanceId, walletAddress, policyVersionId })` runs under +one `BEGIN IMMEDIATE`. It loads the exact active enrollment, derives +`adapterId = pi:`, credential digest, and immutable enrollment hash +internally, and validates +or creates the +`agent_session_bindings` row and SpendSession atomically, appending both events in that +same transaction; there is no committed unbound-session gap. It returns the one +existing open row for that exact binding without appending another event, or creates +both rows when none exists. It never resumes across a +changed enrollment, wallet, or policy; corruption that yields ambiguous open authority +fails startup with `SESSION_AUTHORITY_AMBIGUOUS`. Neither adapter ID nor credential +digest is accepted as a caller value; neither can be a PID, port, HTTP field, or +unauthenticated Pi header. +`captureIntent()` reloads that exact triple as `active` inside its write transaction, +copies `enrollment_hash` into the Spend Intent, and fails with `AGENT_REVOKED` before +persisting if revocation won the serialization race. +`transitionInTransaction(token, { intentId, expectedState, nextState, reasonCode })` +is the scoped form used by Wallet Kernel aggregates; it conditionally changes exactly +one legal state edge and appends the intent event through the live token without +starting a transaction. The standalone `transition()` wraps that same implementation +in its own transaction for non-composed paths. +Graceful process shutdown does not close the Spend Session. Only an explicit operator +session-end/policy-transition procedure in Task 10 may close it. That procedure may +terminalize pending/unsigned work but refuses signing, signed, retrying, unresolved, or +refund-unresolved work. Thus session and +seller/session budgets survive a Kernel restart. + +This repository is the sole creator/replacer/closer of the paired SpendSession and +`agent_session_bindings` rows. Define two guarded authority operations here so later +orchestration never writes either row directly or nests a second transaction: + +```text +transitionBlockedSessionInTransaction(token, + { sessionId, targetPolicyVersionId, expectedSessionHash }) + -> { previousSession, replacementSession } +closeBoundSessionInTransaction(token, { sessionId, expectedSessionHash }) + -> { closedSession } +``` + +Each operation validates Task 2's live opaque transaction token, starts no transaction, +reloads the exact session/binding pair inside the caller-owned transaction, checks the +displayed confirmation hash, and conditionally updates the pair. +Transition requires `policy_blocked` plus the current active same-wallet policy and +atomically closes the old pair and creates one replacement pair for the same enrolled +agent. Close accepts `open` or `policy_blocked` and creates no replacement. Both +refuse any signing/signed/retrying/unresolved payment, open execution/refund case, or +other nonterminal monetary ambiguity. They additionally require Task 10 to have +terminalized every pending/approved approval and unsigned intent, released every +definitely unsigned reservation, and written the corresponding BuyerOutcome inside the +same still-live token before the paired rows may close. They append only their owned +session/binding events. Exact replay returns the persisted result; a stale hash or +concurrent winner fails without mutation. Task 10 owns the outer transaction and then +issues any required receipt revisions before returning operator success. Task 4 tests +these methods through `store.transaction()`, including paired-row atomicity, stale +tokens/hashes, rollback, and sole-writer ownership; Tasks 6 and 10 +add the cross-module state matrix once those modules exist. + +Normalize and validate the bounded token `routeId`, and normalize methods to upper +case. Parse the full URL once in memory, require HTTPS by default, and reject +credentials, fragments, and every search parameter. When and only when +`allowLoopbackHttp` is explicitly true, also accept HTTP only for the literal canonical +loopback address `127.0.0.1` or `[::1]`; do not resolve a hostname for this exception. +Agent routes come from fixed queryless route-map paths, so Pi cannot encode +prompts in a URL. Persist the route ID plus only `request_url_hash = sha256(canonical +normalized URL)`, origin, fixed path, body hash, and an allowlisted-header hash—never +the full URL or search text. The allowlist is +exactly `accept`, `content-type`, and `user-agent`; values are trimmed, but order is +canonicalized. Retry matching recomputes the URL hash. Generate the session ID, intent +ID, public request ID, correlation identifier fallback, and `wk_` idempotency key +inside the repository. The request ID is opaque and safe to return to Pi; it is not an +approval capability and cannot authorize a retry by itself. +Derive and persist `ordinaryFingerprint` from route ID, canonical method, normalized +URL, allowlisted-header hash, body hash, and purpose label; it deliberately excludes the +Kernel-issued request ID and correlation ID. The partial unique index permits at most +one `retry_matchable=1` row for a session/fingerprint. `captureIntent()` handles a +concurrent unique-index loser by loading and exact-comparing the winner, never by +creating a second intent. `matchRetry()` queries only that unique active row and fails +closed if startup semantic validation ever observes ambiguity. Atomically clear +`retry_matchable` only when the intent reaches a proven terminal state. Keep it set +through approval, reservation, signing, signed, retrying, and unresolved states so a +late identical follower always resolves to the existing request ID and receives its +terminal result or `REQUEST_IN_FLIGHT`; it can never create a second signing path. +Only terminalization after settlement/rejection, a safe unsigned failure/denial, or +trusted reconciliation may release the fingerprint for a later identical request. +Derive `intentHash` from: + +```js +canonicalJson({ + requestId, + sessionId, + enrollmentHash, + routeId, + method, + requestUrlHash: sha256(requestUrl), + bodyHash, + headerAllowlistHash, + purposeLabel, + correlationId, + walletAddress, + policyVersionId, +}) +``` + +Persist `session.started`, `intent.captured`, challenge attachment, and every state +transition through `store.mutate()`. `attachChallenge()` is one-way: an exact replay +is idempotent and a different challenge for the same intent throws +`CHALLENGE_CHANGED`. The bounded raw `PaymentRequired` exists only in active process +memory. Persist `challengeHash` over a closed `challenge_projection_json` containing +only `x402Version`, hashed resource URL, fixed public route description/MIME metadata, +and every ordered accepted financial/protocol field; omit seller error text, full URL, +extensions, and all other free-form values. Approval retry performs a fresh probe, +rebuilds the projection, and must reproduce its exact hash before it can reuse the +intent. + +Add a file-backed reopen test: call `openOrResumeSession()`, close the store, reopen, +and call it with the same binding. Assert the same ID, one row, one `session.started` +event, and unchanged budget exposure. Each changed binding field must not resume the +old session. Scan the reopened SQLite rows and event JSON for +`RAW_PROMPT_SENTINEL`; it must be absent while the request/body hashes remain. + +- [ ] **Step 4: Prove capture precedes any transport call** + +Add a test with a transport spy whose `probe()` reads the SQLite row before returning. +The test invokes a minimal coordinator callback and asserts the row already exists and +has state `captured`. This regression guard remains when the callback moves into +`wallet-kernel.mjs` in Task 10. + +- [ ] **Step 5: Run focused and canonical tests** + +```bash +node --test spikes/pi-wielder/tests/kernel-canonical.test.mjs \ + spikes/pi-wielder/tests/kernel-agent-enrollment.test.mjs \ + spikes/pi-wielder/tests/kernel-intent.test.mjs +``` + +Expected: all tests pass; the persisted-row assertion contains hashes but no raw body. + +- [ ] **Step 6: Commit the Spend Intent boundary** + +```bash +git add spikes/pi-wielder/src/kernel/agent-enrollment.mjs \ + spikes/pi-wielder/src/kernel/intent-builder.mjs \ + spikes/pi-wielder/tests/kernel-agent-enrollment.test.mjs \ + spikes/pi-wielder/tests/kernel-intent.test.mjs +git commit -m "feat: persist exact agent spend intents" +``` + +### Task 5: Implement durable conserved budgets and unresolved holds + +**Files:** + +- Create: `spikes/pi-wielder/src/kernel/budget-ledger.mjs` +- Create: `spikes/pi-wielder/tests/kernel-budget.test.mjs` +- Create: `spikes/pi-wielder/tests/fixtures/budget-writer.mjs` + +- [ ] **Step 1: Write the failing budget conservation matrix** + +Create `kernel-budget.test.mjs` using canonical atomic strings. For one seller/session +limit of `1000000`, full-session limit of `2000000`, and rolling-24-hour limit of +`5000000`, assert this sequence exactly: + +```js +const ledger = createBudgetLedger({ store, now: fixedNow }); + +assert.deepEqual(ledger.snapshot({ sessionId, sellerOrigin }), { + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + walletBlocked: false, +}); + +ledger.reserve({ intentId: firstIntent, amountAtomic: '250000' }); +assert.equal(ledger.snapshot({ sessionId, sellerOrigin }).sessionExposureAtomic, '250000'); +seedRetryingPaymentAttempt({ + intentId: firstIntent, + amountAtomic: '250000', + paymentHash: `sha256:${'cd'.repeat(32)}`, +}); +ledger.commit({ + intentId: firstIntent, + settlementEvidence: Object.freeze({ + source: 'x402-payment-response', + headerHash: `sha256:${'bc'.repeat(32)}`, + success: true, + transaction: `0x${'aa'.repeat(32)}`, + network: 'eip155:84532', + payer: walletAddress, + amountAtomic: '250000', + paymentHash: `sha256:${'cd'.repeat(32)}`, + }), +}); +assert.equal(ledger.snapshot({ sessionId, sellerOrigin }).rolling24hExposureAtomic, '250000'); + +ledger.reserve({ intentId: secondIntent, amountAtomic: '300000' }); +ledger.release({ intentId: secondIntent, reasonCode: 'SIGNER_REJECTED' }); +assert.equal(ledger.snapshot({ sessionId, sellerOrigin }).sessionExposureAtomic, '250000'); + +ledger.reserve({ intentId: thirdIntent, amountAtomic: '400000' }); +ledger.holdUnresolved({ intentId: thirdIntent, reasonCode: 'PAID_RESPONSE_AMBIGUOUS' }); +assert.equal(ledger.snapshot({ sessionId, sellerOrigin }).walletBlocked, true); +assert.throws(() => ledger.reserve({ intentId: fourthIntent, amountAtomic: '1' }), + (error) => error.code === 'WALLET_UNRESOLVED'); +``` + +Seed a committed payment with `execution_outcomes.state = 'failed'` plus an open +`refund_pending` execution resolution, and another with execution `unknown` plus +`reconciliation_required`. In each case `snapshot().walletBlocked` is true and a new +reservation fails `WALLET_RESOLUTION_REQUIRED`. Marking only the execution row or only +the refund row resolved is insufficient; the authoritative resolution transition must +close every linked blocker atomically before `walletBlocked` becomes false. + +The invariant after every mutation is: + +```text +reserved_atomic + committed_atomic + released_atomic + unresolved_atomic + = PolicyDecision.amount_ceiling_atomic +``` + +x402 v2 `exact` uses one full disposition at a time. `reserve()` writes the amount to +`reserved_atomic`; `commit()`, `release()`, or `holdUnresolved()` atomically moves the +full amount into the corresponding column and zeros the prior column. Trusted payment +reconciliation moves the full amount from `unresolved_atomic` to either +`committed_atomic` or `released_atomic`; a confirmed full refund moves it from +`committed_atomic` to `released_atomic`. + +Also assert: + +- the same intent cannot reserve twice; +- a release is legal before a signing claim or through the exact typed pre-sign + rejection exception defined below, and can never release an unresolved hold; +- committing the same exact transaction is idempotent, while reusing it for another + intent is rejected; +- `commit()` rejects a missing/non-`retrying` PaymentAttempt, an amount or binding + mismatch, or a transaction ID not atomically written to that exact attempt; +- `recordConfirmedRefund()` moves one full committed amount to released exactly once + only when given a persisted matching reconciliation evidence ID and unique refund + transaction ID; +- seller/session, whole-session, and rolling-24-hour limits reject at one atomic unit + over their ceiling; +- the rolling window includes committed timestamps `> now - 24h` and excludes an + entry exactly at the lower boundary; +- reopening the file-backed database reconstructs identical snapshots; +- no database monetary column is returned as a JavaScript `number`. + +- [ ] **Step 2: Run the test and observe the missing module** + +```bash +node --test spikes/pi-wielder/tests/kernel-budget.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/kernel/budget-ledger.mjs`. + +- [ ] **Step 3: Implement transaction-local snapshots and mutations** + +Create `budget-ledger.mjs` with this API: + +```text +export function createBudgetLedger({ store, now }) { + return Object.freeze({ + snapshot({ sessionId, sellerOrigin, at }), + snapshotInTransaction(token, { sessionId, sellerOrigin, at }), + reserve({ intentId, amountAtomic }), + reserveInTransaction(token, { intentId, amountAtomic }), + commit({ intentId, settlementEvidence }), + commitInTransaction(token, { intentId, settlementEvidence }), + release({ intentId, reasonCode }), + releaseInTransaction(token, { intentId, reasonCode, preSignRejection }), + holdUnresolved({ intentId, reasonCode }), + holdUnresolvedInTransaction(token, { intentId, reasonCode }), + resolvePayment({ intentId, outcome, evidenceId }), + resolvePaymentInTransaction(token, { intentId, outcome, evidenceId }), + recordConfirmedRefund({ intentId, evidenceId, refundTransactionId }), + recordConfirmedRefundInTransaction(token, + { intentId, evidenceId, refundTransactionId }), + }); +} +``` + +Each standalone mutation starts `BEGIN IMMEDIATE`; its matching `*InTransaction` +method requires Task 2's live opaque token and starts no transaction. Both paths call +the same internal implementation, reload every relevant row, recompute all +exposure with `BigInt`, check invariants, update the reservation, and append its +event before commit. Never accept a caller-provided budget snapshot for a mutation; +the pure Policy Engine receives a snapshot, but `reserve()` joins the Spend Intent to +its persisted PolicyDecision and immutable PolicyVersion, parses the canonical policy, +requires `amountAtomic === PolicyDecision.amount_ceiling_atomic`, and reloads those +seller/session/rolling limits inside the authoritative transaction. No caller can +supply or widen a ceiling. This closes both substitution and time-of-check/time-of-use +gaps. + +`commit()` requires the exact persisted PaymentAttempt in `retrying` state and a closed +sanitized `settlementEvidence` returned by Task 9's pure classifier. In one transaction +it validates success, network, payer, canonical optional amount, payment/header hash, +and every available binding against the attempt; derives `transaction_id` only from +that validated evidence; writes the unique transaction plus canonical settlement; +moves the reservation from reserved to committed, and appends the event. Trusted +reconciliation alone moves an unresolved hold through `resolvePayment()`. It never accepts a detached +transaction ID or caller-authored settlement as budget evidence; idempotency is an +exact lookup of the already +committed attempt, and the database uniqueness constraint rejects reuse by any other +intent. + +Use these exposure formulas: + +```js +const exposure = (row) => BigInt(row.reserved_atomic) + + BigInt(row.committed_atomic) + + BigInt(row.unresolved_atomic); +const sellerSessionExposure = sum(rowsForSessionAndSeller.map(exposure)); +const sessionExposure = sum(rowsForSession.map(exposure)); +const rolling24hExposure = sum(rowsCommittedAfterWindowStart.map( + (row) => BigInt(row.committed_atomic), +)) + sum(allActiveOrUnresolvedRowsForWallet.map( + (row) => BigInt(row.reserved_atomic) + BigInt(row.unresolved_atomic), +)); +``` + +Every active or unresolved hold counts against rolling capacity regardless of age, +while finalized committed spend ages out at the exact 24-hour boundary. + +`snapshot().walletBlocked` is a transaction-local wallet query over unresolved +reservations, non-resolved `execution_resolutions`, and pending/unresolved refunds—not +merely the current session. Any such row blocks every new reservation until trusted +reconciliation closes it. `resolvePayment()` accepts only `settled` or `rejected` and +requires a pre-existing reconciliation evidence ID; it cannot be called by an agent +surface. For `settled`, its same `BEGIN IMMEDIATE` moves the monetary row to committed, +persists the canonical transaction, creates execution `unknown` plus +`reconciliation_required`, and writes the blocking `buyer_outcomes` revision. For +`rejected`, only exact post-expiry unused-authorization proof releases the hold and +writes `payment_rejected`. A candidate-level rejected transaction never calls +`resolvePayment()`. `recordConfirmedRefund()` requires the same evidence gate, an already +committed exact payment, and a seller-side full-refund record; it never invokes a +wallet transfer. + +Ordinary `release()` is legal from `reserved` before a signing claim. The one additional +legal release is an exact `signing -> rejected/released` transition inside the Kernel's +outer transaction after catching a real Task 8 `WalletSigningError` with code +`WALLET_PRE_SIGN_REJECTED` and `signatureMayExist === false`. Pass that typed object as +`preSignRejection`; independently require the PaymentAttempt is still `signing` with +null payload/header/hash/signed timestamp. Every other signing-state error, missing or +caller-shaped proof, and any may-exist state goes to `holdUnresolvedInTransaction()`. + +- [ ] **Step 4: Add a two-process oversubscription fixture** + +Create `tests/fixtures/budget-writer.mjs` that opens the database and calls +`ledger.reserve()` for the intent ID and amount from `process.argv`, then writes either +`reserved` or `LIMIT_EXCEEDED` to stdout. In `kernel-budget.test.mjs`, seed two intents +of `600000` against a `1000000` seller/session ceiling, launch two fixture processes, +and assert the sorted results are: + +```js +['LIMIT_EXCEEDED', 'reserved'] +``` + +Reopen the database and assert exactly one reservation exists, its amount is +`600000`, and `store.verifyEventChain()` is true. + +- [ ] **Step 5: Run budget, store, and legacy policy tests** + +```bash +node --test spikes/pi-wielder/tests/kernel-store.test.mjs \ + spikes/pi-wielder/tests/kernel-budget.test.mjs +npm run test:policy --prefix spikes/pi-wielder +``` + +Expected: the process race conserves the ceiling and all legacy policy tests pass. + +- [ ] **Step 6: Commit durable budgets** + +```bash +git add spikes/pi-wielder/src/kernel/budget-ledger.mjs \ + spikes/pi-wielder/tests/kernel-budget.test.mjs \ + spikes/pi-wielder/tests/fixtures/budget-writer.mjs +git commit -m "feat: enforce durable agent spend budgets" +``` + +### Task 6: Add exact approvals and one-time AuthorizedPermits + +**Files:** + +- Create: `spikes/pi-wielder/src/kernel/approval-queue.mjs` +- Create: `spikes/pi-wielder/src/kernel/authorized-permit.mjs` +- Create: `spikes/pi-wielder/tests/kernel-approvals.test.mjs` +- Create: `spikes/pi-wielder/tests/kernel-permit.test.mjs` + +- [ ] **Step 1: Write the failing durable approval tests** + +Create an approval for an existing `approval_required` decision and assert its public +record is bound to all of these exact fields: + +```js +const binding = Object.freeze({ + intentId, + intentHash, + challengeHash, + quoteId, + amountCeilingAtomic: '250000', + walletAddress: '0x1000000000000000000000000000000000000000', + policyVersionId, + acceptedIndex: 0, + expiresAt: '2026-07-31T12:05:00.000Z', +}); +``` + +Assert `approve({ approvalId, expectedIntentHash: intentHash, +operatorIdHash: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' })` persists only +the stable operator hash and time, +survives database reopen, and `consumeFor(binding)` changes `approved` to `consumed` +exactly once. For every individual changed binding field, assert +`APPROVAL_BINDING_MISMATCH` and no row/event mutation. Assert denial, expiry, an +already-consumed approval, and an unknown approval never return authorization. +An unconsumed approved row remains usable only before its immutable `expiresAt`; +`consumeFor()` at or after expiry atomically changes it to `expired` and returns no +authorization. + +At `maxPendingApprovals`, assert a new request is rejected atomically with +`APPROVAL_CAPACITY`; after one denial or expiry sweep, exactly one slot becomes +available. A pending approval must be discoverable only via +`findRetryable({ sessionId, intentHash })`, never from an agent-provided approval ID. + +- [ ] **Step 2: Write the failing capability-forgery tests** + +In `kernel-permit.test.mjs`: + +```js +const authority = createPermitAuthority(); +const intentHash = sha256(canonicalJson({ fixture: 'intent-1' })); +const challengeHash = sha256(canonicalJson({ fixture: 'challenge-1' })); +const quoteId = sha256(canonicalJson({ challengeHash, acceptedIndex: 0 })); +const binding = Object.freeze({ + intentId: 'intent-1', + intentHash, + challengeHash, + quoteId, + acceptedIndex: 0, + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + walletAddress: '0x1000000000000000000000000000000000000000', + payTo: '0x2000000000000000000000000000000000000000', + amountAtomic: '50000', + validAfter: '0', + validBefore: '1785502860', + nonce: `0x${'01'.repeat(32)}`, +}); +const permit = authority.issue(binding); + +assert.equal(Object.isFrozen(permit), true); +assert.deepEqual(Object.keys(permit), ['kind', 'intentId']); +assert.deepEqual(authority.verifyAndConsume(permit), binding); +assert.throws(() => authority.verifyAndConsume(permit), /already consumed/); +assert.throws(() => authority.verifyAndConsume(Object.freeze({ ...permit })), /forged/); +assert.throws(() => authority.verifyAndConsume({ kind: 'AuthorizedPermit', intentId: 'intent-1' }), + /forged/); +``` + +Also assert JSON serialization, structured cloning, and a fresh process cannot create a +valid permit. No permit is written to SQLite, logs, receipts, or HTTP. + +- [ ] **Step 3: Run both focused tests and observe missing modules** + +```bash +node --test spikes/pi-wielder/tests/kernel-approvals.test.mjs \ + spikes/pi-wielder/tests/kernel-permit.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for the two new modules. + +- [ ] **Step 4: Implement the durable approval queue** + +Create `approval-queue.mjs`: + +```text +export function createApprovalQueue({ store, idFactory, now }) { + return Object.freeze({ + request(binding), + get(approvalId), + list({ state, limit }), + approve({ approvalId, expectedIntentHash, operatorIdHash }), + deny({ approvalId, expectedIntentHash, operatorIdHash, reasonCode }), + expireDue(), + findRetryable({ sessionId, intentHash }), + consumeFor(binding), + consumeForInTransaction(token, binding), + cancelForIntentInTransaction(token, { intentId, reasonCode }), + }); +} +``` + +Every transition uses a conditional `UPDATE ... WHERE decision = ?` inside +`BEGIN IMMEDIATE`, checks exactly one changed row, and appends the matching event. An +exact duplicate request returns the existing record; a different binding for the same +intent fails. `expireDue()` compares canonical timestamps using the injected clock, +conditionally expires both `pending` and unconsumed `approved` rows, and returns the +IDs it changed. `request(binding)` derives `expiresAt` as the minimum of the +remaining local challenge lifetime and policy approval TTL; it never trusts an expiry +supplied by an operator or Pi. +Approve/deny additionally match `expectedIntentHash` in that same conditional +transaction, so the operator's displayed confirmation is not a pre-transaction check. +`consumeForInTransaction()` is the only approval-consumption path used by the Wallet +Kernel's reserve/signing aggregate. `cancelForIntentInTransaction()` conditionally +moves only `pending` or `approved` to `cancelled`, with exact reason +`POLICY_SUPERSEDED` or `SESSION_CLOSED`, and appends its event through the live token; +it never cancels a consumed approval. Both scoped methods start no transaction. + +- [ ] **Step 5: Implement the unforgeable in-process authority** + +Create `authorized-permit.mjs`: + +```js +export function deriveAuthorizationWindow({ + nowMs, challengeReceivedAtMs, challengeMaxAgeMs, + approvalExpiresAt, maxTimeoutSeconds, randomBytes, +}) { + // Return one deeply frozen { nonce, validAfter, validBefore } binding. +} + +export function createPermitAuthority() { + const live = new WeakMap(); + const consumed = new WeakSet(); + return Object.freeze({ + issue(binding) { + const permit = Object.freeze({ kind: 'AuthorizedPermit', intentId: binding.intentId }); + live.set(permit, Object.freeze(structuredClone(binding))); + return permit; + }, + verifyAndConsume(permit) { + if (consumed.has(permit)) throw new Error('AuthorizedPermit already consumed'); + const binding = live.get(permit); + if (!binding) throw new Error('AuthorizedPermit is forged'); + live.delete(permit); + consumed.add(permit); + return binding; + }, + }); +} +``` + +Only `wallet-kernel.mjs` receives `issue`; wallet adapters receive only the bound +`verifyAndConsume` function. A permit is issued after a fresh policy/approval/budget +check and consumed immediately before the one signer invocation. The signing binding +extends the approval/policy binding with exact `requestUrl`, `scheme`, `network`, +`asset`, `walletAddress` (payer), `payTo`, `amountAtomic`, `nonce`, `validAfter`, and +`validBefore`. `deriveAuthorizationWindow()` is pure apart from its injected +`randomBytes`: capture `nowMs` once, set `validAfter` exactly to `'0'`, require exactly +32 bytes from one injected cryptographic-randomness call, and calculate: + +```js +const nowSeconds = Math.floor(nowMs / 1000); +const challengeDeadlineSeconds = Math.floor( + (challengeReceivedAtMs + challengeMaxAgeMs) / 1000, +); +const approvalDeadlineSeconds = approvalExpiresAt === null + ? challengeDeadlineSeconds + : Math.floor(Date.parse(approvalExpiresAt) / 1000); +const validBefore = String(Math.min( + nowSeconds + maxTimeoutSeconds, + challengeDeadlineSeconds, + approvalDeadlineSeconds, +)); +``` + +Require `validBefore > nowSeconds`, canonical decimal seconds, and an integer +`maxTimeoutSeconds` in the already validated bounded protocol range. The no-approval +golden fixture has a 60-second challenge/protocol window, so its value remains fixed +now plus 60 seconds. The adapter may sign only the EIP-3009 typed data constructed from +that binding. In Task 6, inject the clock/randomness and test each of the three +deadlines as the minimum, sub-second truncation, an exhausted window, wrong-length +randomness, and exactly one randomness call. Task 10 owns persistence and the database +nonce-collision test. + +- [ ] **Step 6: Run focused tests and restart coverage** + +```bash +node --test spikes/pi-wielder/tests/kernel-approvals.test.mjs \ + spikes/pi-wielder/tests/kernel-permit.test.mjs +``` + +Expected: approval persistence and every permit-forgery case pass. + +- [ ] **Step 7: Commit approval authorization** + +```bash +git add spikes/pi-wielder/src/kernel/approval-queue.mjs \ + spikes/pi-wielder/src/kernel/authorized-permit.mjs \ + spikes/pi-wielder/tests/kernel-approvals.test.mjs \ + spikes/pi-wielder/tests/kernel-permit.test.mjs +git commit -m "feat: add exact one-time spend approvals" +``` + +### Task 7: Extract receipt crypto and sign every terminal buyer outcome + +**Files:** + +- Create: `spikes/pi-wielder/src/kernel/receipt-signing.mjs` +- Create: `spikes/pi-wielder/src/kernel/signed-receipts.mjs` +- Create: `spikes/pi-wielder/tests/kernel-receipts.test.mjs` +- Modify: `spikes/pi-wielder/src/invocation-journal.mjs:256-365` + +- [ ] **Step 1: Freeze the existing seller-journal receipt behavior** + +Run before editing: + +```bash +npm run test:journal --prefix spikes/pi-wielder +``` + +Expected: all 29 invocation-journal tests pass. Save this count in the commit message +notes; the extraction may not alter serialized seller events, receipt hashes, key +format, or schema-v1 terminal replay behavior. + +- [ ] **Step 2: Write failing Kernel receipt tests** + +Create `kernel-receipts.test.mjs` with a temporary owner-only Ed25519 key and this +closed receipt projection: + +```js +const receiptIntentHash = sha256(canonicalJson({ fixture: 'intent-1' })); +const responseHash = sha256(Buffer.from('{"ok":true}', 'utf8')); +const receipt = { + schemaVersion: 1, + receiptId: 'receipt-1', + revision: 1, + issuedAt: '2026-07-31T12:00:01.000Z', + intent: { + id: 'intent-1', + requestId: 'request-1', + intentHash: receiptIntentHash, + sessionId: 'session-1', + sellerOrigin: 'https://seller.example', + resourcePath: '/paid/infer', + purposeLabel: 'skill.invoke', + }, + outcome: { status: 'completed', reasonCode: 'PAYMENT_SETTLED' }, + policy: { versionId: 'policy-1', decision: 'allow', reasonCode: 'WITHIN_AUTO_LIMIT' }, + approval: { state: 'not_required', operatorIdHash: null }, + payment: { + state: 'settled', + amountAtomic: '50000', + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + payTo: '0x2000000000000000000000000000000000000000', + transactionId: `0x${'ab'.repeat(32)}`, + }, + execution: { state: 'succeeded', httpStatus: 200, responseHash }, + budget: { disposition: 'committed', amountAtomic: '50000' }, + reconciliation: null, + refund: null, + supersedesReceiptHash: null, +}; +``` + +Assert successful verification in a fresh signer/verifier instance using only the +public key. Assert field mutation, signature mutation, wrong key, unknown field, and +non-canonical atomic amount fail verification. + +Build terminal fixtures for all of these exact outcomes and assert each receives one +receipt: + +```text +ordinary non-402 success +ordinary non-402 HTTP failure +unpaid transport timeout/connection failure +malformed or oversized payment challenge denial +expired payment challenge denial +policy denied +approval denied +approval expired +approval challenge changed before signing +policy transition cancels unsigned work -> payment_denied / POLICY_SUPERSEDED +guarded session close cancels unsigned work -> payment_denied / SESSION_CLOSED +unsigned signing failure with released reservation +signed payment unresolved with held reservation +trusted post-expiry unused-authorization rejection with released reservation +payment settled and execution succeeded +payment settled and execution failed +payment settled and execution unknown +refund unresolved +refund confirmed +trusted reconciliation of payment, execution, or refund +``` + +Every fixture first writes exactly one closed `buyer_outcomes` row. Initial terminal +facts use revision `1`; each trusted reconciliation or refund fact increments that +row and the receipt revision together without deleting history from events/receipts. +The row is the sole source for receipt `outcome.status` and `outcome.reasonCode`. +Reason codes are bounded stable uppercase tokens, not seller/provider text. +The approval projection state is closed to `not_required`, `pending`, `approved`, +`denied`, `expired`, `cancelled`, or `consumed`; the two guarded-cancellation fixtures +project `cancelled` and their exact reason code. + +For all three ordinary/no-payment outcomes, use one closed projection: `policy: null`, +`approval: { state: 'not_required', operatorIdHash: null }`, +`payment: { state: 'none' }`, and `budget: null`. Execution is `succeeded` for a 2xx +ordinary response, `failed` for a received 4xx/5xx response, and `unknown` for a +pre-response transport failure; unavailable fields are explicit `null`, never omitted. +Approved/denied receipts contain only `operatorIdHash`, computed by the authenticated +operator service, and never a raw operator identifier. + +For malformed, oversized, or expired pre-policy challenges, use the exact closed +projection `policy: null`, `approval: { state: 'not_required', operatorIdHash: null }`, +`payment: { state: 'none' }`, `budget: null`, and +`execution: { state: 'none', httpStatus: null, responseHash: null }`, plus only the +matching authoritative `outcome: { status: 'payment_denied', reasonCode }` row. Never +infer the reason from generic event JSON, and never place rejected header/body bytes or seller error +text in the receipt. + +Assert the projection contains no raw body, prompt, response body, operator token, +CDP credential, payment signature header, payment payload, stack, or provider exception. +Refund and reconciliation receipts use revision `n + 1` and point +`supersedesReceiptHash` to revision `n`. + +- [ ] **Step 3: Extract generic signing without changing exports** + +Move the existing generic implementations of these functions to +`src/kernel/receipt-signing.mjs`: + +```js +export { canonicalJson, receiptKeyId, createReceiptSigner, + loadOrCreateReceiptSigner, verifySignedReceipt }; +``` + +In `invocation-journal.mjs`, import them and re-export the same five names so every +current caller remains source-compatible. Route key-file creation through +`loadOrInitializePrivateFile()` and retain the existing Ed25519 key encoding. Existing +empty or malformed keys fail closed; they are never silently replaced. + +`loadOrCreateReceiptSigner()` supplies one PKCS#8 Ed25519 private-key PEM to the atomic +initializer. Its validator parses exactly one key with `crypto.createPrivateKey()`, +requires `asymmetricKeyType === 'ed25519'`, rejects trailing non-whitespace or malformed +PEM, and derives the public key/key ID. It never trims, repairs, or replaces an invalid +existing key. Reopen and two-process-race tests prove both callers derive the same key +ID and no initialization temp file remains. + +- [ ] **Step 4: Implement post-commit terminal receipt issuance** + +Create `signed-receipts.mjs`: + +```text +export function createSignedReceiptRepository({ store, signer, idFactory, now }) { + return Object.freeze({ + issueForTerminal({ intentId }), + issueRevisionForTerminal({ intentId, supersedesReceiptHash }), + issueMissingTerminalReceipts(), + assertParity(), + assertParityInTransaction(token), + latest(intentId), + list({ sessionId, limit }), + verify(record), + }); +} +``` + +The authoritative terminal domain transition commits its required `buyer_outcomes` +row and event first. Then read that durable state, derive the closed projection, sign it, and insert the receipt plus +`receipt.issued` event in a second transaction. Public output remains withheld until +the receipt commit succeeds. If the process crashes or signing fails between those +transactions, the terminal domain fact remains authoritative and startup recovery +calls `issueMissingTerminalReceipts()` before serving traffic. That repair is +idempotent and can only project an existing buyer-outcome revision; it cannot infer a +reason from events or alter payment, execution, refund, reconciliation, approval, or +budget records. Enforce `signed_receipts.revision === buyer_outcomes.revision`, one +revision sequence per intent, and an exact predecessor receipt hash. + +Receipt parity is a global authority invariant, not merely a startup cleanup. Before +any transaction may insert or increment any `buyer_outcomes` row, it calls +`assertParityInTransaction(token)` and requires every existing outcome's current +revision to equal its latest signed receipt revision. In addition, every mutating +agent/operator entrypoint—including capture, challenge, approval, reservation, +signing claim, reconciliation-candidate persistence, policy/session mutation, and +revocation—runs through one in-process FIFO authority-mutation coordinator and +rechecks the admission gate only after acquiring it. A terminal operation holds that +exclusive lease continuously across its domain transaction and receipt-signing/insert +transaction; it performs no network call while holding it. If receipt creation fails +after a domain commit, the Kernel synchronously closes its admission gate with +`RECEIPT_PARITY_REQUIRED` before returning the error, rejects every subsequent agent +or operator mutation, and asks composition to stop both listeners. A mutation already +queued behind the terminal lease rechecks the now-closed gate and performs zero writes. +It may expose no +success and may not advance that or any other BuyerOutcome. An exclusive authority +recovery phase—daemon startup or Task 13's audited offline bootstrap preflight—is the +only repair path: it issues exactly the missing current revision, verifies global +parity, and only then opens listeners or permits the requested bootstrap write. Because revision `n + 1` cannot commit while +revision `n` is missing, recovery never has to infer an overwritten prior outcome from +events. + +Add a fault test immediately after the terminal commit and before signing. Reopen the +database, run recovery, assert exactly one valid receipt appears, and assert no +monetary row or pre-existing event is replayed. Also pause an in-process signing and +receipt-insert failure after its domain commit, concurrently submit capture, challenge, +reservation, approval, reconciliation, session, and revocation mutations, then release +the failure: assert none passes the held lease, the admission gate closes before the +terminal request returns, every queued mutation fails without a write, both listeners begin +shutdown, and reopen repairs the exact missing revision before serving. + +- [ ] **Step 5: Run Kernel and seller receipt suites** + +```bash +node --test spikes/pi-wielder/tests/kernel-receipts.test.mjs +npm run test:journal --prefix spikes/pi-wielder +``` + +Expected: all Kernel receipt cases pass and the legacy 29-test journal suite remains +byte-compatible. + +- [ ] **Step 6: Commit generic and buyer receipts** + +```bash +git add spikes/pi-wielder/src/kernel/receipt-signing.mjs \ + spikes/pi-wielder/src/kernel/signed-receipts.mjs \ + spikes/pi-wielder/src/invocation-journal.mjs \ + spikes/pi-wielder/tests/kernel-receipts.test.mjs +git commit -m "feat: sign terminal wallet kernel receipts" +``` + +### Task 8: Define the Wallet Adapter seam and deterministic offline adapter + +**Files:** + +- Create: `spikes/pi-wielder/src/adapters/wallet-adapter-contract.mjs` +- Create: `spikes/pi-wielder/src/adapters/eip3009-exact.mjs` +- Create: `spikes/pi-wielder/src/adapters/deterministic-wallet-adapter.mjs` +- Create: `spikes/pi-wielder/tests/wallet-adapter-contract.test.mjs` +- Create: `spikes/pi-wielder/tests/eip3009-exact.test.mjs` +- Create: `spikes/pi-wielder/tests/wallet-adapter-deterministic.test.mjs` + +- [ ] **Step 1: Write the failing adapter contract tests** + +In `wallet-adapter-contract.test.mjs`, define one reusable suite: + +```js +export function walletAdapterContract(name, factory) { + test(`${name}: exposes identity and exact signing only`, async () => { + const fixture = factory(); + assert.deepEqual(Object.keys(fixture.adapter).sort(), ['signX402Exact', 'walletIdentity']); + assert.deepEqual(await fixture.adapter.walletIdentity(), { + provider: fixture.provider, + walletId: fixture.walletId, + address: fixture.address, + network: 'eip155:84532', + }); + }); + + test(`${name}: rejects forged, consumed, and mismatched permits before signing`, async () => { + const fixture = factory(); + await assert.rejects(() => fixture.adapter.signX402Exact( + { kind: 'AuthorizedPermit', intentId: 'intent-1' }, + fixture.paymentRequired, + ), /forged/); + assert.equal(fixture.signCalls(), 0); + }); + + test(`${name}: never returns or serializes key material`, async () => { + const fixture = factory(); + const result = await fixture.signAuthorized(); + assert.deepEqual(Object.keys(result), ['paymentPayload']); + assert.doesNotMatch(JSON.stringify({ identity: await fixture.adapter.walletIdentity(), result }), + /private|secret|seed|mnemonic|api.key/i); + }); +} +``` + +The shared suite must additionally pass a genuine permit with one changed challenge, +accepted index, amount, payee, network, asset, wallet, expiry, or nonce field and assert +the signer is never called. Separately mutate each returned resource, accepted, and +authorization field and assert post-sign validation rejects it before persistence or +retry. A successful exact signing consumes the permit once; a second call with the +same object fails before signing. + +Define one typed error boundary in `wallet-adapter-contract.mjs`: + +```js +export class WalletSigningError extends KernelError { + constructor(code, message, { signatureMayExist }) { + super(code, message); + this.signatureMayExist = signatureMayExist; + } +} + +export function createDeadlineRunner({ + setTimeoutImpl = setTimeout, + clearTimeoutImpl = clearTimeout, +} = {}) {} + +export async function executeAuthorizedSigning({ + prepare, + invokeSigner, + finalize, + runWithDeadline, + preSignTimeoutMs, + signerTimeoutMs, +}) {} +``` + +Permit/payment validation, account resolution, identity validation, and typed-data +construction all occur before the signer call; their typed failure is +`WALLET_PRE_SIGN_REJECTED` with `signatureMayExist: false`. Immediately before +invoking `signTypedData`, enter the may-exist zone. A synchronous throw, rejected or +timed-out promise, malformed returned signature, `assemble()` failure, or post-sign +payload mismatch becomes `WALLET_SIGNATURE_AMBIGUOUS` with +`signatureMayExist: true`. Do not attach or serialize the provider exception. The +shared suite asserts this taxonomy and that arbitrary untyped adapter errors are never +treated as proof that no signature exists. + +`executeAuthorizedSigning()` is the sole two-zone implementation used by every +adapter. `createDeadlineRunner()` implements a one-shot `Promise.race`, clears its +timer on settlement, uses only stable timeout codes, and never retains the rejected +provider value. It calls `runWithDeadline({ phase, timeoutMs, operation })`, whose deadline is +armed before invoking the operation thunk. It wraps all `prepare()` errors/timeouts as +typed pre-sign rejection. It enters the may-exist zone before calling the +`invokeSigner()` thunk and wraps signer timeout plus every `finalize()` error as typed +ambiguity. Tests inject a fake clock/deadline and a never-settling promise, proving the +call cannot remain in `signing` indefinitely. The default composition uses bounded +5-second pre-sign and 15-second signer deadlines; a timeout never attempts cancellation +as proof that signing did not occur. + +- [ ] **Step 2: Write the exact EIP-3009 builder and deterministic adapter tests** + +Create `eip3009-exact.test.mjs` and `wallet-adapter-deterministic.test.mjs`. Use this +exact Kernel-issued signing binding. Test setup must first exercise the implemented +Task 3/4/6 path in an in-memory authority—apply the policy, capture the exact intent, +attach the `paymentRequired` fixture, evaluate it, and derive the authorization +window—then bind the returned hashes. Do not hand-author hashes that merely match the +SHA-256 regex: + +```js +const fixtureAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-eip3009-golden-test-only')), +); +const signingBinding = Object.freeze({ + intentId: 'intent-1', + intentHash: persistedIntent.intentHash, + requestUrl: 'https://seller.example/paid/infer', + resourceDescription: 'offline fixture', + resourceMimeType: 'application/json', + challengeHash: policyDecision.challengeHash, + quoteId: policyDecision.quoteId, + acceptedIndex: 0, + scheme: 'exact', + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + walletAddress: fixtureAccount.address, + payTo: '0x2000000000000000000000000000000000000000', + amountAtomic: '50000', + nonce: authorizationWindow.nonce, + validAfter: authorizationWindow.validAfter, + validBefore: authorizationWindow.validBefore, + policyVersionId: 'policy-1', +}); +``` + +Assert the builder returns typed data with the pinned Base Sepolia USDC EIP-712 domain +`{ name: 'USDC', version: '2', chainId: 84532, verifyingContract: +BASE_SEPOLIA_USDC }`, public `authorizationTypes`, and every permit-bound +authorization field. These name/version constants come from the token contract's +testnet domain and are local authority, not seller-selected metadata. Its +`assemble(signature)` returns this protocol-shaped payload: + +Pin this contract/domain tuple against Circle's Base Sepolia EIP-3009 quickstart +(`https://developers.circle.com/gateway/quickstarts/eco-gasless-deposits`) as well as +the version-pinned official x402 golden below; a later upstream change requires an +explicit dependency/policy migration, never runtime trust in challenge metadata. + +```js +const exact = buildEip3009Exact({ binding: signingBinding, paymentRequired }); +const fixtureSignature = await fixtureAccount.signTypedData(exact.typedData); +const paymentPayload = Object.freeze({ + x402Version: 2, + resource: { + url: 'https://seller.example/paid/infer', + description: 'offline fixture', + mimeType: 'application/json', + }, + accepted: { + scheme: 'exact', + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + amount: '50000', + payTo: '0x2000000000000000000000000000000000000000', + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }, + payload: { + signature: fixtureSignature, + authorization: { + from: fixtureAccount.address, + to: '0x2000000000000000000000000000000000000000', + value: '50000', + validAfter: '0', + validBefore: '1785502860', + nonce: `0x${'01'.repeat(32)}`, + }, + }, +}); +assert.deepEqual(await exact.assemble(fixtureSignature), paymentPayload); +``` + +The test-only account is derived in memory from the public domain-separated label; no +literal private key or generated key file exists. The `resource` and full `accepted` +objects above are the exact official v2 shapes; +`maxTimeoutSeconds` and closed `extra` are never dropped. Reject any token +`name`/`version` other than exact `USDC`/`2`, a non-EIP-3009 transfer method, wrong chain, +malformed bytes32 nonce, invalid canonical validity, expiry beyond the approved +challenge, and any output mismatch before any signer call. `validatePaymentPayload()` must recover the +typed-data signer from the canonical signature and require it to equal +`binding.walletAddress`; length-only validation is forbidden. Inject a `signTypedData` +function that delegates to `fixtureAccount.signTypedData()` and counts calls. Assert the adapter passes the +shared contract suite, +performs zero network calls, deep-freezes its result, and constructs rather than +accepts every payment-payload field. + +Add a golden compatibility test using `ExactEvmScheme` from +`@x402/evm/exact/client`. With `Date.now()` fixed to `1785502800000` and +`globalThis.crypto.getRandomValues()` fixed to 32 bytes of `0x01`, ask the official +scheme to build the exact same full accepted requirement through a recording +`signTypedData` stub that records the typed data and delegates signing to the same +fixture account. Its generated `validAfter` is `'0'`, `validBefore` is fixed-now +seconds plus `maxTimeoutSeconds`, and its nonce is the fixed 32 bytes. Assert its +recorded typed data equals `buildEip3009Exact(...).typedData` and its returned inner +payload equals `(await assemble(fixtureSignature)).payload`; also pass our full assembled payload +through the official v2 `PaymentPayload` schema/HTTP codec. Restore both globals in +`t.after()`. This prevents a self-authored buyer and seller fixture from agreeing on a +non-interoperable payload. Run the golden once with absent +`extra.assetTransferMethod` (the pinned official client defaults to EIP-3009) and once +with explicit `eip3009`; prove `permit2` is rejected before the signer stub is called. +Also mutate `extra.name` and `extra.version` independently and prove each challenge is +denied before permit creation or signer invocation. + +- [ ] **Step 3: Run the tests and observe missing modules** + +```bash +node --test spikes/pi-wielder/tests/wallet-adapter-contract.test.mjs \ + spikes/pi-wielder/tests/eip3009-exact.test.mjs \ + spikes/pi-wielder/tests/wallet-adapter-deterministic.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 4: Implement contract validation and the offline adapter** + +Create `wallet-adapter-contract.mjs`: + +```js +export function validateWalletIdentity(identity) { + // Enforce the exact provider, walletId, address, and eip155:84532 output shape. +} + +export function assertPermitMatchesPayment(binding, paymentRequired, acceptedIndex) { + // Recompute the canonical challenge hash and compare every signed field. +} + +export async function validatePaymentPayload({ paymentPayload, binding, paymentRequired, typedData }) { + // Enforce closed v2 equality and recover the exact permit-bound typed-data signer. +} +``` + +Create `eip3009-exact.mjs` using the public `authorizationTypes` export from +`@x402/evm` and `getAddress` from `viem`: + +```js +export function buildEip3009Exact({ binding, paymentRequired }) { + const accepted = paymentRequired.accepts[binding.acceptedIndex]; + const authorization = Object.freeze({ + from: binding.walletAddress, + to: accepted.payTo, + value: accepted.amount, + validAfter: binding.validAfter, + validBefore: binding.validBefore, + nonce: binding.nonce, + }); + const typedData = Object.freeze({ + domain: Object.freeze({ + name: BASE_SEPOLIA_USDC_EIP712_NAME, + version: BASE_SEPOLIA_USDC_EIP712_VERSION, + chainId: 84532, + verifyingContract: getAddress(accepted.asset), + }), + types: authorizationTypes, + primaryType: 'TransferWithAuthorization', + message: Object.freeze({ + from: getAddress(authorization.from), + to: getAddress(authorization.to), + value: BigInt(authorization.value), + validAfter: BigInt(authorization.validAfter), + validBefore: BigInt(authorization.validBefore), + nonce: authorization.nonce, + }), + }); + return Object.freeze({ + typedData, + async assemble(signature) { + return await validatePaymentPayload({ + paymentPayload: { + x402Version: 2, + resource: Object.freeze({ + url: binding.requestUrl, + description: binding.resourceDescription, + mimeType: binding.resourceMimeType, + }), + accepted: structuredClone(accepted), + payload: { signature, authorization }, + }, + binding, + paymentRequired, + typedData, + }); + }, + }); +} +``` + +Validate the entire binding and challenge before construction. For this pilot the +accepted requirement is closed to exactly `scheme`, `network`, `asset`, `amount`, +`payTo`, `maxTimeoutSeconds`, and +`extra: { name, version, assetTransferMethod? }`; `name` and `version` must equal the +pinned `BASE_SEPOLIA_USDC_EIP712_NAME` and +`BASE_SEPOLIA_USDC_EIP712_VERSION`, and the optional transfer method must be +`eip3009`. Extension-bearing or unknown shapes are unsupported. The builder imports +the pinned domain constants and never copies seller-provided name/version into typed +data, even after equality validation. Require the +challenge resource URL, description, and +MIME type to exactly equal static operator-owned route metadata before copying those +three public constants into the payload. Never copy seller `error`, extensions, agent +input, or other seller free text into signed/persisted bytes. Deep-freeze cloned +outputs so neither the caller nor signer can mutate them. The adapter never accepts +arbitrary typed data from Pi or an operator. + +Create `deterministic-wallet-adapter.mjs`: + +```js +export function createDeterministicWalletAdapter({ + identity, verifyAndConsume, signTypedData, + runWithDeadline = createDeadlineRunner(), + preSignTimeoutMs = 5_000, signerTimeoutMs = 15_000, +}) { + const normalizedIdentity = validateWalletIdentity(identity); + return Object.freeze({ + async walletIdentity() { + return structuredClone(normalizedIdentity); + }, + async signX402Exact(authorizedPermit, paymentRequired) { + return await executeAuthorizedSigning({ + runWithDeadline, preSignTimeoutMs, signerTimeoutMs, + prepare: async () => { + const binding = verifyAndConsume(authorizedPermit); + assertPermitMatchesPayment(binding, paymentRequired, binding.acceptedIndex); + if (getAddress(normalizedIdentity.address) !== getAddress(binding.walletAddress)) { + throw new Error('wallet identity mismatch'); + } + return { binding, exact: buildEip3009Exact({ binding, paymentRequired }) }; + }, + invokeSigner: ({ exact }) => signTypedData(exact.typedData), + finalize: async ({ exact }, signature) => Object.freeze({ + paymentPayload: await exact.assemble(signature), + }), + }); + }, + }); +} +``` + +Perform permit verification before awaiting any injected signer. The deterministic +adapter is test/evidence infrastructure only and may not be selectable when +`WALLET_KERNEL_MODE=cdp-testnet`. + +Wrap the deterministic implementation with the typed boundary above: everything +through `buildEip3009Exact()` is the pre-sign zone, while the call expression and all +subsequent signature/payload validation are the may-exist zone. Tests cover a +synchronous signer throw, async rejection, deadline timeout, malformed signature, and +post-sign mismatch; every one reports ambiguity and never invokes the signer twice. + +- [ ] **Step 5: Run the contract suite** + +```bash +node --test spikes/pi-wielder/tests/wallet-adapter-contract.test.mjs \ + spikes/pi-wielder/tests/eip3009-exact.test.mjs \ + spikes/pi-wielder/tests/wallet-adapter-deterministic.test.mjs +``` + +Expected: every adapter boundary and forged-capability assertion passes. + +- [ ] **Step 6: Commit the provider-neutral seam** + +```bash +git add spikes/pi-wielder/src/adapters/wallet-adapter-contract.mjs \ + spikes/pi-wielder/src/adapters/eip3009-exact.mjs \ + spikes/pi-wielder/src/adapters/deterministic-wallet-adapter.mjs \ + spikes/pi-wielder/tests/wallet-adapter-contract.test.mjs \ + spikes/pi-wielder/tests/eip3009-exact.test.mjs \ + spikes/pi-wielder/tests/wallet-adapter-deterministic.test.mjs +git commit -m "feat: define wallet signing adapter contract" +``` + +### Task 9: Implement the bounded x402 v2 HTTP transport + +**Files:** + +- Create: `spikes/pi-wielder/src/adapters/x402-v2-transport.mjs` +- Create: `spikes/pi-wielder/tests/fixtures/x402-v2-resource.mjs` +- Create: `spikes/pi-wielder/tests/x402-v2-transport.test.mjs` + +- [ ] **Step 1: Write failing protocol and adversarial transport tests** + +Use an injected in-process `fetchImpl` for unit tests; it records requests and returns +real `Response` objects. The unpaid request returns HTTP 402 with a +`PAYMENT-REQUIRED` header encoded from this fixture: + +```js +export const PAYMENT_REQUIRED = Object.freeze({ + x402Version: 2, + error: 'Payment required', + resource: { + url: 'https://seller.example/paid/infer', + description: 'offline fixture', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + amount: '50000', + payTo: '0x2000000000000000000000000000000000000000', + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], +}); +``` + +Exercise this exact API: + +```js +const transport = createX402V2Transport({ fetchImpl, mode: 'cdp-testnet', limits: { + requestTimeoutMs: 5_000, + maximumResponseBytes: 1_048_576, + maximumPaymentHeaderBytes: 16_384, +} }); + +const challenge = await transport.probe(requestSnapshot); +assert.equal(challenge.kind, 'payment_required'); +assert.deepEqual(challenge.paymentRequired, PAYMENT_REQUIRED); + +const paymentHeader = transport.encodePayment(paymentPayload); +const paymentHash = sha256(Buffer.from(paymentHeader, 'ascii')); +const settlementBinding = Object.freeze({ + network: 'eip155:84532', + walletAddress: '0x1000000000000000000000000000000000000000', + amountAtomic: '50000', + paymentHash, +}); +const paid = await transport.retryPaid({ + request: requestSnapshot, + paymentHeader, + binding: settlementBinding, +}); +assert.equal(paid.kind, 'settled_response'); +assert.deepEqual(paid.settlement, settlementFixture); +``` + +This ASCII-header digest is the one canonical `paymentHash` persisted with the exact +payload/header and reused by transport, settlement, recovery, and receipt bindings; +no layer invents a second payload-hash definition. + +Assert exactly one unpaid request and one paid request. The first has no payment +header; the second differs only by `PAYMENT-SIGNATURE`. Assert no automatic retry, +redirect, credential forwarding to another origin, legacy header fallback, third +request, or body mutation. + +Reject each of these before signing: + +```text +missing or duplicate PAYMENT-REQUIRED header +malformed base64 or JSON +x402Version other than 2 +missing/empty accepts or a structurally malformed requirement +header over 16 KiB +402 body over the byte ceiling +redirect response +resource URL mismatch +``` + +The transport deliberately accepts and returns two or more structurally valid options +without choosing one. It never filters by scheme, network, asset, payee, or amount; +Task 3's Policy Engine is the sole selection owner and persists the original index. +Official header codecs only decode base64/JSON, so apply bounded closed structural +validation after decoding rather than treating the codec's TypeScript cast as runtime +validation. + +For the paid response, separate settlement proof from resource-body delivery. Decode +and validate the single `PAYMENT-RESPONSE` header before reading or releasing any body. +A second 402, changed challenge, missing/malformed settlement, or connection/timeout +before trustworthy response headers is `paid_response_ambiguous` and retains the hold. +Once a valid settlement says the exact payment settled, return +`kind: 'settled_response'` even if the body later times out, disconnects, or exceeds +the byte ceiling; include the settlement and HTTP status, omit the body, and classify +execution as `unknown` with a stable delivery reason only when the received status is +2xx. Any validly settled 3xx, 4xx, or 5xx response is `execution_failed` immediately, +never followed, and opens refund resolution regardless of body delivery. Only a +bounded delivered 2xx body is `execution_succeeded`. The Kernel must commit that +payment and record execution separately. `success: false` is not safe rejection proof: +the EIP-3009 authorization can remain usable until expiry, so it always returns +`paid_response_ambiguous` and holds. HTTP status alone is never rejection proof. + +Define and directly test this pure classifier: + +```js +export function classifyX402PaymentResponse({ rawHeader, decoded, binding }) { + // Recognize only success, transaction, network, payer?, amount?, errorReason?, + // errorMessage?, extensions?, and extra? with exact runtime types. +} +``` + +Its sanitized evidence contains only source, header hash, `success`, transaction, +network, normalized payer, canonical amount when present, a bounded stable reason code, +and the already-persisted `paymentHash`; never raw header, error message, extensions, +or extra. It returns `settled` only when `success === true`, network equals the signed +binding, payer is present and equals the wallet, transaction canonicalizes through +`canonicalEvmHash()` to `/^0x[0-9a-f]{64}$/`, optional amount is canonical and equals the signed exact +amount, and success carries no error fields. The standard response has no +asset/payee/nonce/quote fields, so do not claim it cryptographically binds those; its +causal binding is arrival on the sole paid retry carrying the persisted payment hash. + +Missing, duplicate, malformed, unknown-key/type, `success: false`, network/payer/ +amount/transaction mismatch, second 402, timeout, or connection loss all return a +stable unresolved classification and retain the full hold. Mutate every recognized +field independently. Prove success with absent amount is valid for `exact`, but +missing payer is unresolved; prove false with an empty or reverted transaction remains +unresolved. Only Task 11's trusted post-expiry nonce observation may release. + +Add separate tests for: loss before headers -> unresolved hold; valid settlement plus +2xx body timeout -> committed payment/execution unknown; valid settlement plus +oversized 2xx body -> committed payment/execution unknown; table-driven valid +settlement plus HTTP 302/404/500 -> committed payment/execution failed with no redirect +follow; and malformed settlement with any status -> +unresolved hold. None may issue a third request. + +- [ ] **Step 2: Run the test and observe the missing module** + +```bash +node --test spikes/pi-wielder/tests/x402-v2-transport.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement the v2 codecs and bounded two-call transport** + +Create `x402-v2-transport.mjs` using only official protocol codecs: + +```js +import { + decodePaymentRequiredHeader, + decodePaymentResponseHeader, + encodePaymentSignatureHeader, +} from '@x402/core/http'; + +export function createX402V2Transport({ fetchImpl, mode, limits }) { + return Object.freeze({ + async probe(request) { + // One redirect-disabled unpaid fetch; return response or decoded v2 challenge. + }, + encodePayment(paymentPayload) { + const value = encodePaymentSignatureHeader(paymentPayload); + if (Buffer.byteLength(value) > limits.maximumPaymentHeaderBytes) { + throw new Error('PAYMENT-SIGNATURE exceeds byte ceiling'); + } + return value; + }, + async retryPaid({ request, paymentHeader, binding }) { + // One redirect-disabled retry. Decode settlement before bounded body delivery; + // never sign, commit budgets, or retry itself. + }, + }); +} +``` + +Reuse `composeDeadline()`, `readBodyWithLimit()`, and cancellation behavior from +`src/runtime-boundaries.mjs`. Pass `redirect: 'manual'`. Add a closed `mode` constructor +option: `cdp-testnet` rejects every non-HTTPS upstream, while `deterministic` additionally +accepts HTTP only for literal `127.0.0.1` or `[::1]` URLs. Tests use injected +protocol-shaped fetches without network. Clone request +body bytes once at capture and reuse those exact bytes for both calls. + +`retryPaid()` returns a closed discriminated union for `paid_response_ambiguous` or +`settled_response`; a response header alone never returns `payment_rejected`. Only +`settled_response` carries matched settlement evidence. Its body field is either the bounded bytes or `null` with +`executionState: 'unknown'` and a delivery reason; callers may never convert that +post-settlement delivery failure back into payment uncertainty. + +Do not use `wrapFetchWithPayment`, `CdpX402Client`, or any wrapper that signs and +retries internally. The Wallet Kernel must persist `paymentPayloadJson`, the exact +encoded `PAYMENT-SIGNATURE` value, and `paymentHash` before `retryPaid()` can run. + +- [ ] **Step 4: Add a reusable deterministic v2 resource fixture** + +Create `tests/fixtures/x402-v2-resource.mjs` exporting `PAYMENT_REQUIRED`, a valid +payment payload, a settlement object, codec-produced header values, and a +`createResourceFetch()` state machine. It must validate the second request’s exact +signature header and expose call counts; it must never contact a facilitator or chain. + +- [ ] **Step 5: Run transport and legacy paying-fetch suites** + +```bash +node --test spikes/pi-wielder/tests/x402-v2-transport.test.mjs +npm run test:policy --prefix spikes/pi-wielder +node --test spikes/pi-wielder/tests/paying-fetch.test.mjs +``` + +Expected: all v2 transport cases pass, and the v1 regression tests remain green. + +- [ ] **Step 6: Commit x402 v2 transport** + +```bash +git add spikes/pi-wielder/src/adapters/x402-v2-transport.mjs \ + spikes/pi-wielder/tests/fixtures/x402-v2-resource.mjs \ + spikes/pi-wielder/tests/x402-v2-transport.test.mjs +git commit -m "feat: add bounded x402 v2 transport" +``` + +### Task 10: Orchestrate the restart-safe Wallet Kernel lifecycle + +**Files:** + +- Create: `spikes/pi-wielder/src/kernel/wallet-kernel.mjs` +- Create: `spikes/pi-wielder/tests/wallet-kernel.test.mjs` + +- [ ] **Step 1: Write the failing lifecycle acceptance matrix** + +Construct the Kernel only from injected repositories, adapter, transport, signer, +clock, IDs, and fault injector: + +```js +const kernel = createWalletKernel({ + store, + policies, + intents, + budgets, + approvals, + receipts, + permitAuthority, + walletAdapter, + transport, + now, + faultInjector, +}); +``` + +Exercise `openOrResumeSession()` and `execute({ sessionId, routeId, request, purposeLabel, +correlationId })`. The route ID +comes from the trusted proxy's immutable route registry, never an agent body/header. +Every result includes +the Kernel-issued public `requestId`; never substitute an approval ID. Assert these terminal +results and durable side effects: + +| Scenario | Public result | Budget | Signing/retry | Receipt | +|---|---|---|---|---| +| ordinary non-402 2xx | `completed` | none | 0 / 0 | signed | +| ordinary non-402 4xx/5xx | `upstream_failed` | none | 0 / 0 | signed | +| unpaid timeout/connection failure | `upstream_failed` | none | 0 / 0 | signed | +| malformed/oversized/expired challenge | `payment_denied` | none | 0 / 0 | signed | +| policy deny | `payment_denied` | none | 0 / 0 | signed | +| approval needed | `payment_approval_required` | none | 0 / 0 | pending, nonterminal | +| approval denied/expired | `payment_denied` | none | 0 / 0 | signed | +| allowed settled success | `completed` | committed | 1 / 1 | signed | +| settled execution HTTP 3xx/4xx/5xx | `execution_failed` | committed | 1 / 1 | signed | +| valid settlement then 2xx body timeout/overflow | `execution_unknown` | committed | 1 / 1 | signed | +| typed pre-signer validation/account failure | `payment_failed` | released | 0 / 0 | signed | +| signer throw/rejection/timeout or post-sign validation failure | `payment_unresolved` | unresolved | 1 / 0 | signed | +| signer may have returned but persistence fails | process abort | held on recovery | 1 / 0 | issued after reconciliation | +| paid response ambiguous | `payment_unresolved` | unresolved | 1 / 1 | signed | +| settlement reports `success: false` | `payment_unresolved` | unresolved | 1 / 1 | signed | +| second 402 or changed/missing settlement | `payment_unresolved` | held | 1 / 1 | signed | + +For every terminal row in this matrix, the same authoritative transaction writes or +increments the exact `buyer_outcomes { status, reason_code, revision }` projection +before receipt issuance. No terminal path relies on event text to remember its public +status/reason, and recovery can deterministically fill a missing receipt. + +For every settled execution HTTP 3xx/4xx/5xx, the same authoritative transaction inserts +`execution_resolutions.state = 'refund_pending'`, opens one linked `refunds.state = +'pending'` row for the full committed amount/original transaction, and immediately +blocks new spending by that wallet. For `execution_unknown`, it inserts +`execution_resolutions.state = 'reconciliation_required'` with no invented refund and +also blocks immediately. Success creates no resolution row. Assert the settlement, +execution, resolution/refund, budget commit, and wallet-block result are atomic at the +fault boundary; neither failed nor unknown execution can appear without its required +open resolution case. + +For the approval path, call `execute()` again with the exact same ordinary request +after operator approval. Assert the Kernel finds the approved intent, performs a fresh +policy/challenge/budget check, consumes the approval, and never exposes or accepts an +approval identifier through the request. +The fresh check, `consumeForInTransaction()`, and budget reservation share one +`BEGIN IMMEDIATE` aggregate transaction. A crash may therefore leave an approved row +with no reservation before the retry begins, or a consumed row with its exact +reservation after commit, but never a consumed approval without its reservation. +That transaction also reloads the intent's exact enrollment hash and requires the +matching enrollment is still `active`. New-intent capture, fresh-probe admission, +auto-approved reservation, approved-retry reservation, and the later signing-claim +transaction repeat the same exact active-enrollment check; an auth result cached before +revocation is never sufficient. + +While approval remains pending, identical requests return the same public request ID +and do not create another Spend Intent or Approval. After approval, concurrent exact +retries serialize one approval consumption and at most one signature; followers return +the same terminal result or stable `REQUEST_IN_FLIGHT`. If the fresh unpaid probe +returns an expired or changed challenge, terminalize the old approval with a signed +`APPROVAL_CHALLENGE_CHANGED` receipt, create a new Spend Intent/Approval with a new +public request ID, and do not sign. A changed ordinary request never matches the old +approval. + +Add a barriered concurrency test that pauses immediately after the durable signing +claim, submits two identical agent retries, and proves both resolve to the original +intent, no new intent/approval/reservation appears, and signer/retry counts remain at +most one. Repeat while `signed`, `retrying`, and `unresolved`; the fingerprint is +released only after a committed terminal transition. + +At the signing claim, call Task 6's `deriveAuthorizationWindow()` exactly once and +persist its nonce/validity in the same transaction that moves the PaymentAttempt to +`signing`. Seed an existing attempt with the generated nonce and prove the unique-index +collision rolls back the claim, invokes the signer zero times, safely releases the +unsigned reservation as `payment_failed / NONCE_COLLISION`, and draws no implicit +second nonce inside that operation. + +Revocation and the signing claim have an explicit SQLite linearization point. If +revocation commits first, capture/reservation/signing revalidation fails; an existing +unsigned reservation is released and terminalized as `payment_denied / AGENT_REVOKED` +with a receipt, and the signer call count remains zero. If the signing-claim +transaction commits first, the attempt is already money-sensitive and may finish or +become unresolved under its persisted binding; later revocation blocks all new work +but never pretends to cancel a possibly signed authorization. Barrier tests pause at +authentication, capture, unpaid-probe return, reservation, and immediately before the +signing claim, race `revoke()`, and prove exactly those two serialized outcomes. + +- [ ] **Step 2: Specify and test the monetary transition order** + +The successful lifecycle order is exact: + +```text +1. persist SpendSession and SpendIntent +2. make one unpaid probe +3. persist decoded challenge and pure PolicyDecision +4. persist approval request, or reserve budget +5. generate and persist the bytes32 nonce and validity with the unique signing claim +6. issue one AuthorizedPermit and invoke signer once +7. validate the signer output against that nonce/validity, then persist the exact payment payload + JSON, encoded header, hash, and state=signed +8. persist state=retrying +9. make one paid retry with those exact bytes +10. persist settlement, execution outcome, budget disposition, and required execution + resolution/refund case plus the matching `buyer_outcomes` revision as authoritative facts; a valid settlement always commits + spend even when later body delivery is unknown, and failed/unknown execution blocks + the wallet in that same transaction +11. derive, sign, and persist the receipt from the committed terminal facts +12. return only sanitized result plus receipt +``` + +Add a trace array to injected fakes and assert byte-for-byte equality with this order. +Output is never released before steps 10–11 commit. Inject a crash between steps 10 +and 11 and prove startup issues the missing receipt without repeating a monetary +transition. + +Step 10 has exactly one transaction owner: `wallet-kernel.mjs` calls +`store.transaction((token) => ...)`, uses only BudgetLedger's `*InTransaction` +operations plus store/repository operations scoped through that same token, appends all +domain events, and returns synchronously. No participant starts a nested transaction, +and fault injection after each individual write proves the whole settlement/execution/ +resolution/refund/buyer-outcome unit rolls back together. + +Reservation and signing claim are deliberately two transactions so +`after_reservation_commit` is a real recoverable boundary. The first aggregate +re-evaluates the immutable policy/budget snapshot and calls `reserveInTransaction()`; +for an approved retry it also calls `consumeForInTransaction()` in that same token, +while the auto-approved path has no Approval row. The later signing-claim transaction +reloads the exact reservation/policy/enrollment epoch, rechecks the deadline and +active enrollment, derives/persists the one nonce/window, moves the attempt to +`signing`, and appends its events before issuing the in-memory permit. Expiry or +revocation at that second boundary safely releases the still-unsigned reservation and +terminalizes it in the same transaction; nonce collision or another failed claim +recheck likewise invokes the signer zero times. A crash between the two transactions +leaves exactly the `reserved, no signing claim` state classified by Task 11. + +- [ ] **Step 3: Run the focused test and observe the missing orchestrator** + +```bash +node --test spikes/pi-wielder/tests/wallet-kernel.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `wallet-kernel.mjs`. + +- [ ] **Step 4: Implement the closed Kernel API and state machine** + +Create `wallet-kernel.mjs` with no environment reads and no direct network, filesystem, +CDP, or HTTP-server imports: + +```text +export function createWalletKernel(dependencies) { + return Object.freeze({ + openOrResumeSession({ agentInstanceId, walletAddress, policyVersionId }), + transitionSessionPolicy({ sessionId, targetPolicyVersionId, expectedSessionHash }), + closeSession({ sessionId, expectedSessionHash }), + execute({ sessionId, routeId, request, purposeLabel, correlationId }), + status({ sessionId, intentId }), + }); +} +``` + +Dependencies include Task 7's receipt-parity checks, the shared FIFO +`authorityMutationCoordinator`, and one injected `markAuthorityUnhealthy(code)` +callback owned by `control-plane.mjs`. Every mutation acquires the coordinator and +rechecks its gate before its first write; every BuyerOutcome-writing transaction also +performs the token-scoped global parity check. A terminal mutation retains the lease +across its domain commit and receipt commit. After a domain commit, any receipt +signing/insertion failure invokes the callback synchronously while still holding that +lease; the callback closes the shared admission gate before queued work can recheck it +and before the Kernel rejects the call. Tests pause at this gap, queue a second intent +plus every operator mutation class, and prove each observes +`RECEIPT_PARITY_REQUIRED` with zero writes after the lease releases. + +`execute()` is the sole agent execution entrypoint. It first performs Task 4's exact +session/fingerprint lookup: a matching retryable intent with an approved decision is +dispatched internally to the private approved-retry path, while a new fingerprint is +captured once. The proxy never chooses between “new” and “retry,” never accepts an +approval ID, and never calls a second public retry method. + +At every state change, use a conditional transition and stable `KernelError.code`. +Signing claim is durable and unique. After a signer returns, immediately canonicalize +and encode its payload, then commit the exact payload JSON and header before any await +or paid-network operation. Never sign again for a row in `signing`, `signed`, +`retrying`, or `unresolved`. + +If the process cannot prove whether a signer returned or a paid request reached the +seller, fail closed to a recoverable hold. Do not infer rejection from timeout, +connection reset, process death, malformed response, or missing settlement header. +Release after a signing claim only for an error that is an actual `WalletSigningError` +with code `WALLET_PRE_SIGN_REJECTED` and `signatureMayExist === false`; every +untyped/unknown error and every `signatureMayExist !== false` case becomes unresolved. +In particular, a synchronous signer throw, async rejection/timeout, malformed +signature, and post-sign assembly/validation failure all hold and block without a +second signer call. A `PAYMENT-RESPONSE` reporting failure also holds until Task 11 can +prove the authorization unused after expiry. +Every terminal pre-payment outcome—including free failure, transport failure, invalid +challenge, policy denial, and approval denial/expiry—must transition with a signed +receipt even though no BudgetReservation exists. Its domain transaction first writes +the exact initial `buyer_outcomes` row; the receipt repository only projects that row. + +`transitionSessionPolicy()` is operator-only orchestration. It requires a +`policy_blocked` session, the current active PolicyVersion as target, exact wallet and +agent binding backed by the currently active enrollment, normal (not recovery-only) +mode, and a confirmation hash over the displayed session/binding state. If +any intent is signing, signed, retrying, unresolved, or has unresolved refund work, it +returns `SESSION_TRANSITION_BLOCKED` without mutation; that old session still cannot +admit spend. Otherwise it owns one `store.transaction((token) => ...)`: cancel each +`pending` or `approved` approval through +`cancelForIntentInTransaction(..., 'POLICY_SUPERSEDED')`, release only definitely +unsigned reservations through BudgetLedger, transition each affected intent to +`terminal`, and write buyer outcome `payment_denied / POLICY_SUPERSEDED` revision `1` +through `store.within()`. It then calls Task 4's repository-owned +`transitionBlockedSessionInTransaction()` with that same token. No Kernel or operator +code writes a session/binding row directly or opens a nested transaction. The +transaction appends every owned event and rolls back as one unit at each injected +write boundary. Issue all required terminal receipts from the committed BuyerOutcomes +before returning operator success. Recovery fills a crash-time receipt gap before listeners open. Test stale +confirmation, non-active target, concurrent transition, pending approval, unsigned +reservation, and every in-flight/ambiguous blocker. + +The lifecycle matrix here owns the deferred policy-block enforcement tests: after a +tighter same-wallet apply, the old session rejects admission before probe, approval +consumption, reservation, permit issue, or signer invocation; an already +signed/retrying attempt may record only its persisted outcome. Assert exact zero call +counts at every forbidden boundary. + +`closeSession()` is the authenticated operator session-end wrapper around the same +aggregate transaction, ending with Task 4's +`closeBoundSessionInTransaction()`. It requires `expectedSessionHash`, applies the +identical in-flight/ambiguity blockers, maps every safely cancelled/unsigned intent to +buyer outcome `payment_denied / SESSION_CLOSED`, changes its `pending` or `approved` +approval to `cancelled`, releases only definitely unsigned reservations, and commits +all intent/outcome/session/binding events together. It issues all resulting terminal +receipts after that domain commit and leaves the agent enrollment active but unbound. +It is the only supported way to end an exhausted +session or prepare a wallet rotation. Hot wallet rotation is forbidden: after close, +the daemon must stop; the operator provisions the new customer wallet, applies the +new-wallet policy under the offline bootstrap lock, updates closed configuration, and +restarts. Agent requests while unbound return `AGENT_SESSION_UNAVAILABLE`, never +silently create a new session under the old process configuration. Test guarded close, +same-policy restart creating one fresh session, and the no-hot-wallet-rotation gate. + +- [ ] **Step 5: Add fault injection at every monetary boundary** + +Call `faultInjector(point, context)` at these exact points: + +```js +export const KERNEL_FAULT_POINTS = Object.freeze([ + 'after_intent_commit', + 'after_challenge_commit', + 'after_reservation_commit', + 'after_signing_claim_commit', + 'after_signer_return', + 'after_signed_payment_commit', + 'after_retry_claim_commit', + 'after_paid_response', + 'after_settlement_commit', + 'before_terminal_receipt_commit', +]); +``` + +`after_challenge_commit` fires only after one transaction has persisted both the +challenge projection and PolicyDecision, and before any approval, reservation, or +deny outcome exists. Approval consumption and reservation commit together, so no +fault point can produce a consumed approval without its exact reservation. Task 11's +matrix gives each resulting row one explicit recovery classification. + +In `wallet-kernel.test.mjs`, throw a sentinel error at each point and inspect the +still-open store. Assert each row is either safely unsigned or in the exact durable +state that Task 11 recovery must classify. The separate-process abort/reopen matrix is +deliberately deferred until `recovery.mjs` exists in Task 11. + +```text +no missing or duplicated reservation +signed bytes never change +one transaction ID commits at most once +event chain and receipt signatures verify +``` + +Add explicit lifecycle assertions for the typed release predicate, each may-exist +signer failure, an arbitrary untyped adapter error, and a restart from `signing`. +Exactly the typed pre-signer case releases; all others preserve full unresolved +exposure and no terminal path asks the adapter to sign twice. + +- [ ] **Step 6: Run lifecycle and old x402 lifecycle suites** + +```bash +node --test spikes/pi-wielder/tests/wallet-kernel.test.mjs +node --test spikes/pi-wielder/tests/x402-lifecycle.test.mjs +``` + +Expected: every fault point lands in its specified durable state, and the existing v1 +lifecycle suite remains green. + +- [ ] **Step 7: Commit the Wallet Kernel lifecycle** + +```bash +git add spikes/pi-wielder/src/kernel/wallet-kernel.mjs \ + spikes/pi-wielder/tests/wallet-kernel.test.mjs +git commit -m "feat: orchestrate restart-safe agent spending" +``` + +### Task 11: Add startup recovery, trusted reconciliation, and sanitized export + +**Files:** + +- Create: `spikes/pi-wielder/src/kernel/recovery.mjs` +- Create: `spikes/pi-wielder/src/kernel/projection-exporter.mjs` +- Create: `spikes/pi-wielder/tests/kernel-recovery.test.mjs` +- Create: `spikes/pi-wielder/tests/kernel-reconciliation.test.mjs` +- Create: `spikes/pi-wielder/tests/projection-exporter.test.mjs` +- Create: `spikes/pi-wielder/tests/kernel-restart.test.mjs` +- Create: `spikes/pi-wielder/tests/fixtures/kernel-crash-worker.mjs` + +- [ ] **Step 1: Write the failing startup recovery matrix** + +Seed one database row at each nonterminal payment/execution/refund state, close the +store, reopen it, and call: + +```js +const report = recoverKernelAuthority({ store, receipts, now: fixedNow }); +``` + +Assert: + +| Durable state on reopen | Recovery action | Wallet can spend? | +|---|---|---| +| valid open agent binding/session | retain for the same credential digest | yes | +| valid `policy_blocked` binding/session | retain for transition/status only | no | +| revoked enrollment with retained binding/session | operator-only status/reconciliation; agent denied | no | +| active enrollment with zero bindings and zero orphan open/policy-blocked sessions | retain as valid unbound; composition may open only after policy/isolation gates | not until composition | +| dangling/mismatched binding, orphan open/policy-blocked session, or more than one candidate pair | fail startup semantic audit | no listener | +| captured, no challenge | intent `terminal`; buyer outcome `upstream_failed` / `RECOVERY_ABANDONED_UNSIGNED`; no reservation; receipt | yes | +| challenged + persisted deny decision, no outcome | intent `terminal`; buyer outcome `payment_denied` with the persisted PolicyDecision reason; receipt | yes | +| challenged + persisted allow/approval-required decision, no approval/reservation | intent `terminal`; buyer outcome `payment_failed` / `RECOVERY_ABANDONED_UNSIGNED`; receipt | yes | +| pending approval, unexpired | leave pending | yes | +| pending approval, expired | expire, terminal receipt | yes | +| approved but unconsumed, unexpired | retain for exact ordinary retry only; no reservation | yes | +| approved but unconsumed, expired | expire, terminal receipt | yes | +| consumed approval without its exact reservation | fail startup semantic audit | no listener | +| reserved, no signing claim | release, terminal receipt | yes | +| signing | change to `unresolved`, retain full hold | no | +| signed | retain exact bytes, change to `unresolved` | no | +| retrying | change to `unresolved`, retain full hold | no | +| unresolved with pending/rejected payment candidate history | retain exact history and hold | no | +| settled, execution missing | execution `unknown`, retain committed spend | no | +| failed execution without `refund_pending` case/refund row | fail semantic audit | no listener | +| unknown execution without `reconciliation_required` case | fail semantic audit | no listener | +| failed/unknown execution with open resolution case | retain exact case and full block | no | +| refund pending/unresolved | retain state and hold | no | + +Recovery never invents an extra state: `RECOVERY_ABANDONED_UNSIGNED` is a stable +reason code on the applicable existing `upstream_failed` or `payment_failed` +BuyerOutcome value while the Spend Intent uses the existing `terminal` state. That +transition and its first outcome revision commit atomically before the receipt is +projected. +`after_challenge_commit` means the challenge projection and PolicyDecision committed +atomically but Task 10 had not yet created an approval or reservation. Recovery uses +the persisted deny reason only for a deny; it never silently reconstructs a missing +approval/reservation or resumes network work. Approval consumption and reservation +are one aggregate transaction, so the consumed-without-reservation row is corruption, +not a recoverable crash state. + +Recovery is idempotent: a second call changes no rows, events, or receipt revisions. +Test both legal unbound cases: first clean bootstrap before its initial session, and +restart after guarded close with the still-active enrollment. Recovery creates no +session; only Task 14 composition may do so after all admission gates. Individually +seed each dangling/orphan/ambiguous variant and require corruption. +It verifies `PRAGMA integrity_check`, foreign keys, schema version, every SQL/domain +CHECK, the full cross-table semantic audit defined in Step 4, event hash chain, and all +receipt signatures before returning ready; any failure keeps the process out of the +serving state. + +- [ ] **Step 2: Write exact reconciliation and refund-observation tests** + +Inject a trusted resolver whose result is fetched by the Kernel, not supplied by the +agent. Keep RPC-observed facts separate from locally persisted x402 bindings: + +```js +const rpcTransferProof = Object.freeze({ + source: 'base-sepolia-rpc', + network: 'eip155:84532', + transactionId: `0x${'ab'.repeat(32)}`, + blockHash: `0x${'cd'.repeat(32)}`, + blockNumber: '1234567', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 4, + authorizationLogIndex: 5, + tokenContract: BASE_SEPOLIA_USDC, + from: walletAddress, + to: sellerPayTo, + valueAtomic: '50000', + authorizationNonce: `0x${'01'.repeat(32)}`, + observedAt: '2026-07-31T12:10:00.000Z', +}); +``` + +The resolver may claim only those chain facts. The Kernel independently loads the +persisted signed PaymentAttempt and its local `intentHash`, `challengeHash`, `quoteId`, +payload hash, payer, payee, asset, amount, nonce, and validity window; it verifies the +receipt/status plus the USDC `Transfer` and authorization-use logs, then stores a +canonical proof that contains `rpcProofHash` and `localAttemptHash` as separately named +provenance. Never label intent/challenge/quote values as RPC-observed. Require exact +network, transaction, payer, payee, asset, amount, and nonce agreement. One changed +field produces `RECONCILIATION_MISMATCH` and changes neither domain rows nor event +head. Replaying the exact proof is idempotent. + +Because a paid-response loss may leave no transaction ID in the Kernel, the +authenticated operator may optionally name one public `paymentTransactionId` while +confirming the displayed intent and payment-case hashes. The API accepts no receipt, +log, amount, nonce, wallet, payee, status, block, or generic evidence. Canonicalize and +persist the candidate in `payment_reconciliation_candidates` before invoking the +observer. Without a candidate, payment reconciliation may only attempt the +post-expiry unused-authorization observation below; it performs no unbounded log +search. A sufficiently confirmed reverted or exact-binding-mismatched candidate is +terminally `rejected` without releasing the hold, allowing a later candidate only with +the newly displayed case hash. Missing/insufficient evidence remains `pending`. +An exact settled transfer atomically marks its candidate `confirmed` with the payment +commit. A later exact post-expiry unused-authorization proof atomically marks any +still-pending candidate `rejected` before releasing the hold, because that transaction +cannot have consumed the persisted authorization. Exact replay is idempotent, while +concurrent/different candidates, cross-intent reuse, and case-variant transaction +spelling cannot overwrite or bypass the unique history. +If an operator recognizes a nonexistent or mistyped candidate before either proof is +available, a separate authenticated abandon operation may conditionally change only +that exact `pending` row to `abandoned`. It requires the newly displayed +domain-separated case hash, appends an immutable audit event, rotates the case hash, +and preserves the full monetary hold and BuyerOutcome without a receipt revision. A +replacement candidate is legal only against that fresh hash. It can never mark a +candidate rejected/confirmed, release/commit value, resolve execution, or overwrite +history. Test payment and refund candidates that never appear on-chain, stale/concurrent +abandon, abandonment racing confirmation, and replacement after abandonment. + +A reported settlement failure, reverted transaction, or missing transaction is not +release evidence while the signed authorization can still be submitted. The only +rejection proof that releases a signed hold is a trusted read-only observation with +this closed shape: + +```js +const unusedAfterExpiry = Object.freeze({ + kind: 'authorization_unused_after_expiry', + network: 'eip155:84532', + asset: BASE_SEPOLIA_USDC, + payer: walletAddress, + nonce: `0x${'01'.repeat(32)}`, + validBefore: '1785502860', + authorizationState: false, + observedBlockNumber: '1234570', + observedBlockHash: `0x${'ef'.repeat(32)}`, + observedBlockTimestamp: '1785502860', + confirmations: 3, +}); +``` + +Require every binding to match persistence, `observedBlockTimestamp >= validBefore`, +the configured minimum confirmation count, and a false USDC authorization-state read +at that recorded block. Before expiry, an already-used nonce, missing block identity, +or any uncertainty stays unresolved. Test that only this exact post-expiry unused-nonce +proof releases; a false `PAYMENT-RESPONSE` never does. + +Execution reconciliation uses a distinct signed seller proof, never RPC inference. The +trusted resolver fetches and verifies this closed attestation for the persisted intent: + +```js +const executionAttestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.execution.v1', + network: 'eip155:84532', + sellerOrigin: 'https://seller.example', + intentHash, + transactionId: `0x${'ab'.repeat(32)}`, + outcome: 'succeeded', // or 'failed'; never 'unknown' + httpStatus: 200, + responseHash: `sha256:${'12'.repeat(32)}`, // nullable only when no body was observed + issuedAt: '2026-07-31T12:10:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + signer: configuredExecutionSigner, + signature, +}); +``` + +The signed bytes are exactly the UTF-8 bytes of `canonicalJson()` over the closed +object above after removing only `signature`; the seller signs those bytes with +`signMessage({ message: { raw: bytes } })`, and the resolver uses +`recoverMessageAddress()` over the same raw bytes. The literal `domain` field is +mandatory and prevents execution/refund cross-use. Verify the recovered address +equals the PolicyVersion's exact `executionSigner`; require network, seller, intent, +settled transaction, bounded time window, outcome, and status to match persistence. +When the unknown execution row already has an HTTP status or response hash, the +attestation must repeat it exactly. When delivery failed before either was available, +the attestation may supply the missing bounded status/hash as signed seller evidence; +persist them in reconciliation metadata, but never construct or claim to deliver an +output body. `responseHash` may be `null` only when neither persistence nor the seller +has a body hash; a succeeded attestation still requires a success HTTP status. A verified +`succeeded` attestation changes unknown execution to succeeded, resolves its case, and +issues a receipt revision without inventing or delivering an output. A verified +`failed` attestation changes it to failed and atomically changes the case to +`refund_pending` while creating the one full-amount pending refund row. Missing, +expired, mismatched, or badly signed evidence leaves `reconciliation_required` and the +wallet blocked. + +For refunds, use this exact independently signed seller attestation: + +```js +const refundAttestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.refund.v1', + network: 'eip155:84532', + sellerOrigin: 'https://seller.example', + intentHash, + originalTransactionId: `0x${'ab'.repeat(32)}`, + refundTransactionId: `0x${'34'.repeat(32)}`, + asset: BASE_SEPOLIA_USDC, + originalPayer: walletAddress, + originalPayee: sellerPayTo, + refundSource: configuredRefundSource, + amountAtomic: '50000', + issuedAt: '2026-07-31T12:10:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + signer: configuredRefundSigner, + signature, +}); +``` + +Sign and recover the refund message with the same exact raw canonical-byte rule as +the execution attestation, but with its distinct literal domain. For refunds, the authenticated operator supplies exactly one public +`refundTransactionId` while confirming the persisted intent hash; no amount, asset, +wallet, payee, status, or evidence object is accepted. Persist that candidate ID on a +pending refund row bound to the original committed attempt before lookup. The trusted +seller evidence provider must first return a valid closed +`wallet-kernel.refund.v1` attestation signed by the PolicyVersion's exact +`refundSigner`, binding seller origin, original/refund transaction IDs, network, asset, +original payer/payee, the PolicyVersion's exact `refundSource`, and full amount with +bounded issue/expiry timestamps. The attestation signer is evidence authority only; +it need not control the refund source. Separately, +the read-only chain observer requires a sufficiently confirmed successful Base +Sepolia receipt with one exact USDC transfer from that configured refund source to the +buyer wallet for the full original amount. It stores separate `attestationHash`, +`rpcProofHash`, and `localRefundBindingHash` provenance and consumes each +transaction/log once. The buyer +never constructs, signs, broadcasts, or retries a refund transaction. A confirmed +refund releases the full amount exactly once and issues a superseding receipt. +Missing or insufficiently confirmed evidence remains pending and wallet-blocking. A +sufficiently confirmed reverted transaction or successful transaction that lacks the +exact full-refund transfer terminalizes only that candidate as `rejected`; it never +releases value or resolves the execution case. The same observation endpoint may then +persist one new candidate only with the newly displayed refund-case hash. A missing or +bad seller signature/provider result remains `unknown`, not rejected. Re-observing the +same candidate is idempotent, and no candidate/evidence row is overwritten. Test cross-intent transaction reuse, changed +original/refund transaction, wrong sender/recipient/chain/asset, partial amount, and +wrong attestation signer/refund source, operator-supplied fake evidence fields, +rejected-candidate replacement, concurrent +supersede, and the one-open-refund index. + +- [ ] **Step 3: Run the tests and observe missing modules** + +```bash +node --test spikes/pi-wielder/tests/kernel-recovery.test.mjs \ + spikes/pi-wielder/tests/kernel-reconciliation.test.mjs \ + spikes/pi-wielder/tests/projection-exporter.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 4: Implement fail-closed recovery and operator-only reconciliation** + +Create `recovery.mjs`: + +```text +export function recoverKernelAuthority({ store, receipts, now }) { + // Verify physical and semantic authority invariants, classify every incomplete row, + // and return a frozen report. +} + +export function createReconciler({ store, budgets, receipts, resolver, now, idFactory }) { + return Object.freeze({ + reconcilePayment({ intentId, operatorIdHash, paymentTransactionId = null, + expectedPaymentCaseHash }), + reconcileExecution({ intentId, operatorIdHash, expectedExecutionCaseHash }), + observeRefund({ intentId, operatorIdHash, refundTransactionId, + expectedRefundCaseHash }), + abandonCandidate({ intentId, kind, operatorIdHash, expectedCaseHash }), + }); +} +``` + +`resolver` is exactly `{ observePayment(persistedAttemptBinding), +observeExecution(persistedExecutionBinding), +observeRefund(persistedRefundBinding) }`. The payment method returns only +`settled_transfer`, `payment_candidate_rejected`, +`authorization_unused_after_expiry`, or `unknown`; the refund method returns only +`refund_attested_and_confirmed` with separate sanitized seller/RPC proofs, +`refund_candidate_rejected` with conclusive chain evidence, or `unknown`; the execution method returns only `execution_attested` with a +verified closed seller attestation projection and hash—but no signature—or `unknown`. +`reconcilePayment()` and `observeRefund()` first commit any operator-named transaction +into their one open candidate binding after checking the respective displayed case +hash, then pass that persisted row—not request data—to the resolver. A null payment +candidate selects only the post-expiry unused-authorization path. The +API never accepts an arbitrary caller evidence object. Reconciliation persistence commits +the domain change and incremented `buyer_outcomes` revision first; a superseding signed +receipt is then derived from that durable fact and committed +before public success is returned. Recovery fills an interrupted receipt gap +idempotently. Resolver timeout or malformed evidence cannot release a budget hold. +There is no caller-authored generic `rejected`, `settled`, or `refund_confirmed` +result. Each method validates its own closed result schema and source before any budget +transition. + +`reconcileExecution()` is legal only for an open +`execution_resolutions.state = 'reconciliation_required'` row. A successful resolution +updates the execution outcome plus case (and, for failure, pending refund) in one +authority transaction. A `settled_transfer` payment resolution atomically commits the +payment, marks its persisted candidate `confirmed`, inserts +`execution_outcomes.state = 'unknown'`, opens +`execution_resolutions.state = 'reconciliation_required'`, and writes buyer outcome +`execution_unknown`; it remains wallet-blocking until separate signed execution +evidence resolves it. It can never jump directly from payment ambiguity to completed. +An `authorization_unused_after_expiry` resolution atomically marks any open payment +candidate `rejected`, releases the authorization hold, and writes the terminal +`payment_rejected` outcome before its receipt is exposed. +`observeRefund()` is legal only for `refund_pending` with the matching open refund row; +confirmation resolves both the refund and execution case in the same budget +transaction. Candidate rejection closes only that candidate and increments the +blocking outcome/receipt revision; every untrusted mismatch or partial provider result +changes no row. Each reconciliation method recomputes its domain-separated displayed +case hash inside the same transaction; stale hashes and concurrent winners return a +stable conflict with no resolver call or mutation. + +Each candidate-persistence write and each later authoritative resolution write has one +transaction owner. Resolve external evidence between those transactions; for the +resolution call `store.transaction((token) => ...)`, re-read/revalidate +the persisted case/candidate/hash, and use only BudgetLedger's +`resolvePaymentInTransaction()` or `recordConfirmedRefundInTransaction()` plus +token-scoped domain mutations. No resolver/network call or nested transaction occurs +while the SQLite write transaction is open. +`abandonCandidate()` accepts `kind` only as `payment` or `refund-observation`, reloads +the one exact open candidate and case inside `BEGIN IMMEDIATE`, and conditionally marks +it `abandoned` with an event. It performs no resolver/network call, no budget or +BuyerOutcome mutation, and returns the fresh replacement case hash. Its stale hash or +a concurrent confirmation/rejection loses with zero writes. + +Before classifying recovery states, run a closed semantic audit across every row: +state values and transitions are legal; atomic strings are canonical; accepted indices +are in bounds for the persisted challenge; PolicyDecision/challenge/intent hashes +match; route IDs are canonical bounded tokens; approvals duplicate the exact immutable +binding; every reservation sums to its +decision ceiling and uses that PolicyVersion's limits; every PaymentAttempt payload, +header, hash, nonce, amount, and state agree; settlements own their unique transaction +IDs; payment/refund candidates are canonical, immutable, unique, and have at most one +open row per intent; every failed/unknown settled execution has exactly its required resolution/refund +state; executions/refunds/reconciliations have legal predecessors; retryable fingerprints +and open agent bindings are unique; every binding and intent carries its exact immutable +enrollment hash; enrollment, binding, and isolation-attestation hashes/states agree and +at most one attestation is current; every terminal intent has exactly one current +buyer-outcome row whose revision matches its domain/reconciliation history; and receipts +exactly project the corresponding buyer-outcome revisions. +Any mismatch returns `AUTHORITY_SEMANTIC_CORRUPTION`, keeps both listeners closed, and +performs no repair. Add corruption tests for each cross-table class using individually +well-formed rows so SQL constraints alone cannot make the tests vacuous. + +- [ ] **Step 5: Prove recovery in fresh processes at every fault point** + +Create `kernel-crash-worker.mjs` to acquire the shared Kernel authority lock, open a +supplied temporary database, run one intent, and call `process.abort()` at the supplied +`KERNEL_FAULT_POINTS` value from Task 10. The abort releases the OS-backed SQLite lock +without a cleanup callback; the parent must prove a new owner acquires it before +reopening the main authority. In +`kernel-restart.test.mjs`, spawn one worker per point, reopen the database, call the now +implemented `recoverKernelAuthority()`, and assert: + +```text +no missing or duplicated reservation +no second signer invocation +persisted signed bytes never change +no blind paid retry after restart +one transaction ID commits at most once +ambiguous states block the wallet +terminal receipt gaps are filled without replaying money +event chain and every receipt signature verify +``` + +Persist deterministic signer and transport call counts in separate owner-only fixture +files so the fresh test process can prove no repeat call occurred. Run: + +```bash +node --test spikes/pi-wielder/tests/kernel-restart.test.mjs +``` + +Expected: every process-abort boundary recovers or blocks exactly as specified. + +- [ ] **Step 6: Implement a one-way sanitized projection** + +Create `projection-exporter.mjs`: + +```text +export function createProjectionExporter({ store, receipts, signer, now }) { + return Object.freeze({ + snapshot({ sessionId }), + exportSigned({ sessionId }), + }); +} +``` + +The export contains schema/version, wallet public identity, hashed agent enrollment +state, isolation status/preflight digest (never raw UID/GID/path), policy hashes, aggregate +budgets, approval state counts, open execution/refund resolution counts and blocker +reason codes, sanitized intent metadata, signed receipts, event-head +hash, and export signature. It excludes raw request/response bodies, header values, +payment payloads, payment signatures, credentials, operator identities beyond a stable +hash, provider errors, and filesystem paths. There is deliberately no `import`, +`restore`, `apply`, or mutation method. + +In `projection-exporter.test.mjs`, recursively scan keys and serialized values against: + +```js +const FORBIDDEN_EXPORT_TERMS = /prompt|body|authorization|payment.signature|private|secret|token|stack|file.path/i; +``` + +Verify the export signature in a fresh process and prove mutating any projected field +breaks verification. + +- [ ] **Step 7: Run recovery, reconciliation, projection, and refund regressions** + +```bash +node --test spikes/pi-wielder/tests/kernel-recovery.test.mjs \ + spikes/pi-wielder/tests/kernel-reconciliation.test.mjs \ + spikes/pi-wielder/tests/projection-exporter.test.mjs \ + spikes/pi-wielder/tests/kernel-restart.test.mjs +node --test spikes/pi-wielder/tests/collar-failure.test.mjs +``` + +Expected: all Kernel recovery cases and the existing seller refund/reconciliation +suite pass. + +- [ ] **Step 8: Commit recovery and read-only projection** + +```bash +git add spikes/pi-wielder/src/kernel/recovery.mjs \ + spikes/pi-wielder/src/kernel/projection-exporter.mjs \ + spikes/pi-wielder/tests/kernel-recovery.test.mjs \ + spikes/pi-wielder/tests/kernel-reconciliation.test.mjs \ + spikes/pi-wielder/tests/projection-exporter.test.mjs \ + spikes/pi-wielder/tests/kernel-restart.test.mjs \ + spikes/pi-wielder/tests/fixtures/kernel-crash-worker.mjs +git commit -m "feat: recover and reconcile wallet authority" +``` + +### Task 12: Add the live-shaped CDP wallet adapter and closed configuration + +**Files:** + +- Create: `spikes/pi-wielder/src/config.mjs` +- Create: `spikes/pi-wielder/src/adapters/cdp-wallet-adapter.mjs` +- Create: `spikes/pi-wielder/src/adapters/base-sepolia-observer.mjs` +- Create: `spikes/pi-wielder/src/adapters/seller-evidence-resolver.mjs` +- Create: `spikes/pi-wielder/tests/config.test.mjs` +- Create: `spikes/pi-wielder/tests/wallet-adapter-cdp.test.mjs` +- Create: `spikes/pi-wielder/tests/base-sepolia-observer.test.mjs` +- Create: `spikes/pi-wielder/tests/seller-evidence-resolver.test.mjs` +- Modify: `spikes/pi-wielder/.env.example` + +- [ ] **Step 1: Extend the non-secret environment template** + +Append these blank values to `.env.example`; do not add an example value that could be +mistaken for a credential: + +```dotenv +WALLET_KERNEL_MODE=deterministic +WALLET_KERNEL_OPERATOR_SOCKET_FILE= +WALLET_KERNEL_ENROLLMENT_INBOX= +WALLET_KERNEL_AGENT_RUN_OUTBOX= +WALLET_KERNEL_RELEASE_ROOT= +WALLET_KERNEL_RELEASE_MANIFEST= +WALLET_KERNEL_SERVICE_DEFINITION_FILE= +WALLET_KERNEL_ENV_FILE= +WALLET_KERNEL_EVIDENCE_ROOT= +WALLET_KERNEL_ISOLATION_REPORT_FILE= +CDP_API_KEY_ID= +CDP_API_KEY_SECRET= +CDP_WALLET_SECRET= +CDP_WALLET_NAME= +WALLET_KERNEL_BASE_SEPOLIA_RPC_URL= +``` + +Document beside `WALLET_KERNEL_MODE` that only `deterministic` and `cdp-testnet` are +accepted, and that `cdp-testnet` is pinned to Base Sepolia. There is no mainnet mode. +The RPC URL is a customer-supplied read-only observation endpoint, never a funding, +signing, or transaction-send capability. + +- [ ] **Step 2: Write failing closed-configuration tests** + +Create `config.test.mjs` with an explicit environment object; never mutate or inspect +the developer's real `process.env`. Assert: + +```js +const config = loadControlPlaneConfig({ env: fixtureEnv, checkoutRoot, uid }); +assert.deepEqual(config.publicConfig, { + mode: 'cdp-testnet', + agentHost: '127.0.0.1', + agentPort: 8402, + operatorAdminTransport: 'unix', + operatorSocketPath: fixtureEnv.WALLET_KERNEL_OPERATOR_SOCKET_FILE, + operatorConsoleTransport: 'socket-activated-loopback', + operatorConsoleActivationName: 'wallet-kernel-console', + operatorHost: '127.0.0.1', + operatorPort: 8405, + databasePath: fixtureEnv.WALLET_KERNEL_DB_FILE, + policyPath: fixtureEnv.WALLET_KERNEL_POLICY_FILE, + routePath: fixtureEnv.WALLET_KERNEL_ROUTE_FILE, + receiptKeyPath: fixtureEnv.WALLET_KERNEL_RECEIPT_KEY_FILE, + operatorTokenPath: fixtureEnv.WALLET_KERNEL_OPERATOR_TOKEN_FILE, + enrollmentInboxPath: fixtureEnv.WALLET_KERNEL_ENROLLMENT_INBOX, + agentRunOutboxPath: fixtureEnv.WALLET_KERNEL_AGENT_RUN_OUTBOX, + releaseRoot: fixtureEnv.WALLET_KERNEL_RELEASE_ROOT, + releaseManifestPath: fixtureEnv.WALLET_KERNEL_RELEASE_MANIFEST, + serviceDefinitionPath: fixtureEnv.WALLET_KERNEL_SERVICE_DEFINITION_FILE, + environmentFilePath: fixtureEnv.WALLET_KERNEL_ENV_FILE, + evidenceRoot: fixtureEnv.WALLET_KERNEL_EVIDENCE_ROOT, + isolationReportPath: fixtureEnv.WALLET_KERNEL_ISOLATION_REPORT_FILE, + expectedAgentUid: Number(fixtureEnv.WALLET_KERNEL_EXPECTED_AGENT_UID), + expectedAgentGid: Number(fixtureEnv.WALLET_KERNEL_EXPECTED_AGENT_GID), + cdpWalletName: 'pilot-wallet', + network: 'eip155:84532', + observer: 'base-sepolia-read-only', +}); +assert.equal(JSON.stringify(config).includes(fixtureEnv.CDP_API_KEY_SECRET), false); +assert.equal(JSON.stringify(config).includes(fixtureEnv.CDP_WALLET_SECRET), false); +``` + +Reject missing/noncanonical/zero expected agent UID/GID, a zero live Kernel UID/GID, +an expected live agent UID equal to the injected Kernel UID, +missing CDP credential presence in CDP mode, empty wallet +name, unknown environment fields with +the `WALLET_KERNEL_` prefix, relative/in-checkout/symlink/permissive config paths, +missing live operator socket/release/handoff/evidence/isolation-report paths, a live self-bound TCP operator endpoint +or missing root-owned console socket activation, +non-loopback agent hosts, colliding deterministic ports, invalid ports, `production`, `mainnet`, `eip155:8453`, +or any asset other than Base Sepolia USDC. In `cdp-testnet`, require every policy seller +origin to be HTTPS and require an HTTPS RPC URL +without username/password and recognize it in the closed environment schema, but treat +the whole URL as secret because a path/query can contain a provider key. It must not +appear in `publicConfig`, logs, errors, receipts, projections, or evidence. The +deterministic mode does not require CDP credentials or an RPC endpoint and must ignore +rather than serialize any present values. +Also reject `NODE_OPTIONS`, `NODE_PATH`, `LD_PRELOAD`, and every environment key with +prefix `DYLD_` in live mode before dynamic SDK/adapter imports; tests cover each one. + +In the same test file, exercise `validateRouteMap({ document, mode })` before Task 14 +adds the concrete route file. Require a closed schema, unique bounded route IDs, fixed +methods/kinds/content types and byte ceilings, and queryless credential-free upstream +URLs. `cdp-testnet` accepts HTTPS only. `deterministic` also accepts HTTP only for the +literal canonical loopback address `127.0.0.1` or `[::1]`, never a hostname. Return a +deeply frozen registry with exact lookup by route ID, and +never allow a request to supply or replace an upstream URL. + +- [ ] **Step 3: Write the failing injected-CDP adapter contract** + +In `wallet-adapter-cdp.test.mjs`, create a fake client with: + +```js +const fixtureAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-cdp-adapter-test-only')), +); +const account = { + address: fixtureAccount.address, + async signTypedData(typedData) { + calls.push(typedData); + return await fixtureAccount.signTypedData(typedData); + }, +}; +const cdpClient = { + evm: { + async getAccount({ name }) { + assert.equal(name, 'pilot-wallet'); + return account; + }, + }, +}; +``` + +Run the reusable `walletAdapterContract('cdp', factory)` suite from Task 8. Assert an +authorized call invokes `account.signTypedData()` once with the exact permit-bound +`from`, `to`, `value`, `validAfter`, `validBefore`, and 32-byte nonce. +Assert every invalid permit/payload case invokes it zero times, concurrent initialization +resolves one account promise, and neither the client nor account is returned or logged. +Assert `createAccount()` and `getOrCreateAccount()` do not exist on the injected fake +and are never required: the customer must provision the named wallet before preflight. + +- [ ] **Step 4: Run the tests and observe missing modules** + +```bash +node --test spikes/pi-wielder/tests/config.test.mjs \ + spikes/pi-wielder/tests/wallet-adapter-cdp.test.mjs \ + spikes/pi-wielder/tests/base-sepolia-observer.test.mjs \ + spikes/pi-wielder/tests/seller-evidence-resolver.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 5: Implement secret-free public configuration** + +Create `config.mjs`: + +```js +export const CONTROL_PLANE_MODES = Object.freeze(['deterministic', 'cdp-testnet']); + +export function validateRouteMap({ document, mode }) { + // Return a deeply frozen exact-ID registry or throw KernelError on any unknown field. +} + +export function loadControlPlaneConfig({ env, checkoutRoot, + uid = process.getuid(), gid = process.getgid() }) { + return Object.freeze({ + publicConfig: Object.freeze({ + mode, + agentHost: '127.0.0.1', + agentPort, + operatorAdminTransport: mode === 'cdp-testnet' ? 'unix' : 'loopback-demo', + operatorSocketPath: mode === 'cdp-testnet' ? operatorSocketPath : null, + operatorConsoleTransport: mode === 'cdp-testnet' + ? 'socket-activated-loopback' : 'loopback-demo', + operatorConsoleActivationName: mode === 'cdp-testnet' + ? 'wallet-kernel-console' : null, + operatorHost: '127.0.0.1', + operatorPort, + databasePath, + policyPath, + routePath, + receiptKeyPath, + operatorTokenPath, + enrollmentInboxPath, + agentRunOutboxPath, + releaseRoot: mode === 'cdp-testnet' ? releaseRoot : null, + releaseManifestPath: mode === 'cdp-testnet' ? releaseManifestPath : null, + serviceDefinitionPath: mode === 'cdp-testnet' ? serviceDefinitionPath : null, + environmentFilePath: mode === 'cdp-testnet' ? environmentFilePath : null, + evidenceRoot: mode === 'cdp-testnet' ? evidenceRoot : null, + isolationReportPath: mode === 'cdp-testnet' ? isolationReportPath : null, + expectedAgentUid, + expectedAgentGid, + cdpWalletName, + network: 'eip155:84532', + observer: mode === 'cdp-testnet' ? 'base-sepolia-read-only' : 'deterministic', + }), + assertCredentialPresence() {}, + }); +} +``` + +Compute each identifier from validated input; the literal names above define the only +allowed public keys. `assertCredentialPresence()` checks that the three CDP variables +exist for `cdp-testnet` but returns no values. Pass the original environment only to +the SDK constructor in the process composition root; do not copy credentials into the +config, database, logs, errors, or receipts. +In `cdp-testnet`, the owner bearer may travel only over the Kernel-owned Unix-domain +socket named by `operatorSocketPath`. The live CLI must execute under the +Kernel/operator OS identity that can traverse the socket's owner-only parent. The live +browser console is served only from the inherited root service-manager socket named +`wallet-kernel-console`, bound to exact `127.0.0.1:8405` and held across Kernel +crashes/restarts; application code may never self-bind that port in live mode. Browser +auth uses a short-lived one-time launch capability minted through the Unix admin +channel, never the owner bearer. Missing/inconsistent socket activation blocks live +console and agent admission. +The Kernel configuration has no agent-credential path and rejects +`WALLET_KERNEL_AGENT_CREDENTIAL_FILE` if it appears in the Kernel environment. Live +composition loads only the active non-secret enrollment from SQLite and requires its +canonical `agent_uid` to equal `expectedAgentUid` and differ from the Kernel UID. +Deterministic tests may inject an explicit same-UID enrollment dependency, but no +environment field enables that exception. + +- [ ] **Step 6: Implement CDP signing over the permit-bound EIP-3009 builder** + +Reuse `buildEip3009Exact()` from Task 8. The Kernel has already generated and +persisted the cryptographically random bytes32 nonce plus canonical `validAfter` and +`validBefore` seconds with the signing claim before issuing the AuthorizedPermit. +Create `cdp-wallet-adapter.mjs`: + +```js +export function createCdpWalletAdapter({ + cdpClient, walletName, verifyAndConsume, + runWithDeadline = createDeadlineRunner(), + preSignTimeoutMs = 5_000, signerTimeoutMs = 15_000, +}) { + let accountPromise; + const account = () => { + accountPromise ??= cdpClient.evm.getAccount({ name: walletName }); + return accountPromise; + }; + return Object.freeze({ + async walletIdentity() { + const value = await runWithDeadline({ + phase: 'wallet_identity', timeoutMs: preSignTimeoutMs, operation: account, + }); + return validateWalletIdentity({ + provider: 'coinbase-cdp', + walletId: walletName, + address: value.address, + network: 'eip155:84532', + }); + }, + async signX402Exact(authorizedPermit, paymentRequired) { + return await executeAuthorizedSigning({ + runWithDeadline, preSignTimeoutMs, signerTimeoutMs, + prepare: async () => { + const binding = verifyAndConsume(authorizedPermit); + assertPermitMatchesPayment(binding, paymentRequired, binding.acceptedIndex); + const value = await account(); + const liveIdentity = validateWalletIdentity({ + provider: 'coinbase-cdp', walletId: walletName, + address: value.address, network: 'eip155:84532', + }); + if (getAddress(liveIdentity.address) !== getAddress(binding.walletAddress)) { + throw new Error('wallet identity mismatch'); + } + return { + value, + exact: buildEip3009Exact({ binding, paymentRequired }), + }; + }, + invokeSigner: ({ value, exact }) => value.signTypedData(exact.typedData), + finalize: async ({ exact }, signature) => Object.freeze({ + paymentPayload: await exact.assemble(signature), + }), + }); + }, + }); +} +``` + +Do not use `account.signX402Payment()`, `CdpX402Client`, or a wrapped fetch. The +higher-level x402 signer generates its nonce internally, so it cannot satisfy the +approved pre-sign AuthorizedPermit nonce binding; wrapped fetch also hides the exact +payload-persistence boundary. CDP's typed-data signing keeps key custody inside the +customer’s CDP project while signing the Kernel-constructed authorization. Automated +tests inject `cdpClient`; they never construct a real SDK client. + +The shared helper keeps permit validation, account resolution, live-address equality, +and builder creation definitively before `signTypedData`; its second zone begins before +the signer invocation and includes signature recovery/payload validation. Add CDP-specific +tests for account lookup rejection (zero signer calls and typed pre-sign failure), +synchronous signer throw, async rejection/timeout, malformed returned signature, and +post-sign payload mismatch (all typed ambiguous failures). No thrown object retains a +provider `cause`, stack fragment, credential, or raw response. + +In `control-plane.mjs`, and nowhere else, construct the real client with: + +```js +const cdpClient = new CdpClient({ + apiKeyId: env.CDP_API_KEY_ID, + apiKeySecret: env.CDP_API_KEY_SECRET, + walletSecret: env.CDP_WALLET_SECRET, +}); +``` + +Pass it directly to the adapter and discard the environment reference after +composition. Before opening agent admission, require `walletIdentity().address` to +equal the active PolicyVersion wallet. Never call account export, faucet, transfer, +transaction-send, or arbitrary sign methods from the control plane. + +- [ ] **Step 6a: Implement the Base Sepolia read-only observer** + +Create `base-sepolia-observer.mjs` with this exact provider-neutral surface: + +```text +createBaseSepoliaObserver({ publicClient, now, minimumConfirmations = 2 }) -> { + preflight(), + fundingStatus({ walletAddress, requiredAtomic }), + observePayment(persistedBinding), + observeRefund(persistedBinding), +} +``` + +`preflight()` requires chain ID `84532` and the exact Base Sepolia USDC contract. +`fundingStatus()` reads `balanceOf` at one captured block and returns only wallet, +asset, canonical `balanceAtomic`/`requiredAtomic`, `sufficient | insufficient`, block +number/hash, and observation time. It is informational and never authorizes policy or +mutates a budget. + +`observePayment()` and `observeRefund()` accept only persisted Kernel bindings, never +an HTTP request or caller-authored evidence object. For ambiguous payment, the binding +may contain the one open operator-named transaction from +`payment_reconciliation_candidates`; the observer performs no unbounded log or chain +search. A settled payment requires a sufficiently confirmed successful receipt for +that persisted candidate transaction plus exact USDC +`AuthorizationUsed(payer, nonce)` and `Transfer(payer, payee, amount)` logs. A reverted +or exact-binding-mismatched confirmed candidate returns +`payment_candidate_rejected` without releasing the authorization hold; a missing or +insufficiently confirmed candidate is `unknown`. With or without a candidate, it becomes +`authorization_unused_after_expiry` only when a recorded block timestamp is at or +after `validBefore` and an exact USDC `authorizationState(payer, nonce)` read at that +block is false. A refund observation takes the operator-named transaction only from +the persisted pending-refund row and requires a confirmed successful exact full-amount +`Transfer(refundSource, payer, amount)` in that transaction, where `refundSource` +comes from the attempt's immutable PolicyVersion seller projection—not the operator, +attestation signer, route file, or RPC response. Missing receipts, +insufficient confirmations, and provider uncertainty remain `unknown`. A sufficiently +confirmed reverted or exact-binding-mismatched refund transaction returns +`refund_candidate_rejected`; that closes only the candidate, never releases spend or +resolves the execution case. Even a matching transfer is only the chain half of refund proof; +Task 11/Step 6b's independently verified signed refund attestation is also mandatory. + +The adapter exposes no client, generic RPC, wallet, signer, faucet, transfer, +`sendTransaction`, or `writeContract` method. Fake-client tests allow only +`getChainId`, `getBlockNumber`, `getBlock`, `getTransactionReceipt`, and exact USDC +`readContract`; log decoding is local. Provider failures become stable redacted codes. +Test settled, reverted-still-valid, expired-unused, used/mismatched nonce, insufficient +confirmations, exact/mismatched payment and refund candidates, lowercase/case-variant +transaction reuse, sufficient/insufficient funding, wrong chain, and zero real network access. + +Only in `cdp-testnet`, `control-plane.mjs` constructs a viem public client with +`baseSepolia` and +`http(env.WALLET_KERNEL_BASE_SEPOLIA_RPC_URL, { timeout: 10_000, retryCount: 0 })`, +then runs observer preflight before either listener. Inject the observer as Task 11's +trusted payment resolver, the independent chain half of the composite refund resolver, +and the funding-status source for operator Overview and testnet evidence. A chain-only +refund match can never confirm a refund. `dependencies.publicClient` replaces live construction in tests. +Deterministic mode never constructs a live RPC client or calls an observer unless an +explicit deterministic fake is injected. + +- [ ] **Step 6b: Implement signed seller evidence resolution** + +Create `seller-evidence-resolver.mjs`: + +```text +createSellerEvidenceResolver({ fetchImpl, mode, now, limits }) -> { + observeExecution(persistedBinding), + observeRefund(persistedBinding), +} +``` + +Each persisted binding contains the exact immutable PolicyVersion ID/hash and seller +projection used for the payment. Revalidate that PolicyVersion, select its exact +seller origin, and construct the endpoint only from that origin plus its canonical +`evidencePath`; never read an evidence URL from the mutable route file or a request. +Each method independently matches the persisted seller origin, resource path, intent, +and settled transaction before it sends one +redirect-disabled bounded `Content-Type: application/json` POST with +one of these exact closed bodies: + +```js +{ schemaVersion: 1, kind: 'execution', sellerOrigin, intentHash, transactionId } +{ schemaVersion: 1, kind: 'refund', sellerOrigin, intentHash, + originalTransactionId, refundTransactionId } +``` + +Every value is loaded from persistence; the request contains no bearer, cookie, or +other authentication header because trust comes from the pinned response signer. It +never sends a body/prompt, payment +payload/header, agent/operator credential, approval ID, filesystem path, or provider +secret. Require same-origin HTTPS in `cdp-testnet`; the deterministic exception accepts +HTTP only for a literal canonical `127.0.0.1` or `[::1]` seller origin. Enforce a +5-second total deadline, 16-KiB +response ceiling, one JSON response, and no retry. + +Validate the exact execution/refund shapes from Task 11, remove `signature`, canonicalize +the remaining closed object with its declared domain, and use viem +`recoverMessageAddress()` to require the immutable PolicyVersion's respective +`executionSigner` or `refundSigner`. Validate issue/expiry against the injected clock +and independently match every persisted binding, including the refund attestation's +exact PolicyVersion `refundSource`. Return only the closed verified +attestation projection with `signature` removed plus its hash, or `unknown` with a +stable redacted reason; never return or persist raw response/error/signature text. +Require both the declared `signer` and recovered signer to equal the pinned policy +address. Tests mutate every field/signature, replay across +intent/origin/kind, exceed deadline/size, redirect, and inject provider exceptions. + +In `control-plane.mjs`, compose one trusted resolver whose payment method delegates to +the chain observer, whose execution method delegates to seller evidence, and whose +refund method returns success only when both the verified refund attestation and the +independent chain observer proof match the same persisted refund binding. Partial +success remains `unknown`. Offline process fixtures inject both fake providers; live +construction uses the immutable PolicyVersion seller binding and ordinary bounded +`fetch`. + +- [ ] **Step 7: Run both adapter suites offline** + +```bash +node --test spikes/pi-wielder/tests/config.test.mjs \ + spikes/pi-wielder/tests/wallet-adapter-contract.test.mjs \ + spikes/pi-wielder/tests/wallet-adapter-deterministic.test.mjs \ + spikes/pi-wielder/tests/wallet-adapter-cdp.test.mjs \ + spikes/pi-wielder/tests/base-sepolia-observer.test.mjs \ + spikes/pi-wielder/tests/seller-evidence-resolver.test.mjs +``` + +Expected: deterministic and CDP adapters pass the same contract with zero network +access and no credential values in output. + +- [ ] **Step 8: Commit the customer-wallet adapter** + +```bash +git add spikes/pi-wielder/.env.example \ + spikes/pi-wielder/src/config.mjs \ + spikes/pi-wielder/src/adapters/cdp-wallet-adapter.mjs \ + spikes/pi-wielder/src/adapters/base-sepolia-observer.mjs \ + spikes/pi-wielder/src/adapters/seller-evidence-resolver.mjs \ + spikes/pi-wielder/tests/config.test.mjs \ + spikes/pi-wielder/tests/wallet-adapter-cdp.test.mjs \ + spikes/pi-wielder/tests/base-sepolia-observer.test.mjs \ + spikes/pi-wielder/tests/seller-evidence-resolver.test.mjs +git commit -m "feat: adapt customer CDP wallets" +``` + +### Task 13: Build the authenticated local operator plane + +**Files:** + +- Create: `spikes/pi-wielder/src/operator/auth.mjs` +- Create: `spikes/pi-wielder/src/operator/api.mjs` +- Create: `spikes/pi-wielder/src/operator/cli.mjs` +- Create: `spikes/pi-wielder/src/operator/console.mjs` +- Create: `spikes/pi-wielder/src/kernel/release-integrity.mjs` +- Create: `spikes/pi-wielder/src/agent/isolation-preflight.mjs` +- Create: `spikes/pi-wielder/scripts/build-release-manifest.mjs` +- Create: `spikes/pi-wielder/scripts/preflight-live-deployment.mjs` +- Create: `spikes/pi-wielder/scripts/preflight-agent-isolation.mjs` +- Create: `spikes/pi-wielder/scripts/agent-isolation-probe-worker.mjs` +- Create: `spikes/pi-wielder/operator-console/index.html` +- Create: `spikes/pi-wielder/operator-console/app.mjs` +- Create: `spikes/pi-wielder/operator-console/styles.css` +- Create: `spikes/pi-wielder/tests/operator-auth.test.mjs` +- Create: `spikes/pi-wielder/tests/operator-api.test.mjs` +- Create: `spikes/pi-wielder/tests/operator-cli.test.mjs` +- Create: `spikes/pi-wielder/tests/operator-console.test.mjs` +- Create: `spikes/pi-wielder/tests/release-integrity.test.mjs` +- Create: `spikes/pi-wielder/tests/agent-isolation.test.mjs` + +- [ ] **Step 1: Write failing owner-credential tests** + +Create `operator-auth.test.mjs`. Generate a token file through the public API and +assert it is an owner-only regular file outside the checkout, contains 32 random bytes +encoded as base64url, is reused rather than overwritten, and is rejected when symlinked, +wrong-owner-like, or permissive. +Require exactly 43 ASCII characters matching `/^[A-Za-z0-9_-]{43}$/`, an exact +32-byte base64url decode, and encode/decode round-trip equality; newline, trimming, +padding, or an alternate encoding is invalid and never repaired. + +Assert bearer validation uses fixed-length SHA-256 digests and +`crypto.timingSafeEqual()`. Missing, malformed, short, wrong, duplicated, query-string, +or cookie bearer credentials produce only `OPERATOR_UNAUTHORIZED`; no response or log +contains the supplied value. + +Exercise the browser launch exchange: + +```text +POST /operator/v1/browser-launch on the authenticated admin channel + -> one random 32-byte, single-use, 60-second capability + -> exact http://127.0.0.1:8405/operator/#launch= +browser loads static app, app removes fragment with history.replaceState() +POST /operator/v1/session with body { launchToken } + -> 204 + HttpOnly; SameSite=Strict; Path=/operator; Secure only when TLS is configured + -> bounded in-memory session + CSRF value in response header +subsequent mutation -> exact loopback Origin + session cookie + X-CSRF-Token +DELETE /operator/v1/session -> invalidate server-side session and clear cookie +``` + +The owner token never enters browser memory, storage, URL, HTML, cookie, or TCP. The +launch capability exists only in Kernel memory, is deleted on first exchange, and is +invalid after expiry/restart; the fragment is not sent in the initial HTTP request. +Session expiry, replay, restart, changed origin, missing CSRF, and cross-site requests +fail closed. The static console has no owner-token input. + +For live auth, create a Kernel-owned Unix-domain socket under a Kernel-owned `0700` +parent and set the socket mode to `0600` before readiness. The CLI validates the +parent and socket with single-FD/lstat discipline, connects by `socketPath`, and sends +the owner bearer only on that channel. It must run as the Kernel/operator UID. A +wrong-owner, group/other-writable, symlink, regular-file, stale-active, or parent-swap +socket fails closed. After the authority lock proves no other daemon is active, startup +may unlink only a stale same-UID socket inode under that exact parent; it never removes +an arbitrary path. Separately, the root service manager owns and continuously holds +the exact loopback console listener and passes its verified listening FD to the Kernel; +application code never calls `listen(host, port)` for live console authority. A +dropped-Pi-UID fixture must fail to traverse/replace/bind the admin socket and must get +`EADDRINUSE` trying to claim 8405 while the socket unit is active. Missing inherited +FD, wrong socket address/type, or a service-manager restart that drops reservation +blocks live startup. The CLI sends the owner bearer only over UDS; `console launch` +prints the one-time fragment URL. + +- [ ] **Step 2: Write the failing operator API contract** + +Create an injected service fake and assert these exact routes and methods: + +```text +[admin channel only] POST /operator/v1/browser-launch +[console channel only] POST /operator/v1/session +[console channel only] DELETE /operator/v1/session +GET /operator/v1/overview +GET /operator/v1/policies +POST /operator/v1/policies/validate +POST /operator/v1/policies/apply +POST /operator/v1/agents/:agentInstanceId/revoke +POST /operator/v1/sessions/:sessionId/transition-policy +POST /operator/v1/sessions/:sessionId/close +GET /operator/v1/approvals?state=pending +POST /operator/v1/approvals/:approvalId/approve +POST /operator/v1/approvals/:approvalId/deny +GET /operator/v1/receipts +GET /operator/v1/receipts/:receiptId +POST /operator/v1/reconciliations/:intentId/:kind +POST /operator/v1/reconciliations/:intentId/:kind/abandon-candidate +GET /operator/v1/exports/:sessionId +GET /operator/v1/receipt-public-key +``` + +The admin browser-launch route requires the owner bearer on Unix in live mode (fixed +loopback demo transport only in deterministic mode), mints the Step 1 capability, and +never accepts a cookie. Session POST accepts only that capability plus exact Origin; +DELETE requires the browser session, Origin, and CSRF. Every other route requires a +Unix bearer on the live admin app or an authenticated browser session on the inherited +console app; channel-inappropriate auth is rejected. All mutation bodies use closed schemas and bounded byte +reads. Unknown route, query, body field, state, identifier, or pagination value is +rejected. Approval endpoints accept only operator intent plus a bounded reason code; +they cannot change amount, wallet, quote, policy, challenge, expiry, or request. The +policy validation body is exactly `{ document }`; it returns the normalized public +policy plus its canonical hash. Policy apply is stateless and accepts exactly +`{ document, expectedPolicyHash }`: it revalidates/recanonicalizes the document and +requires the recomputed hash to equal the displayed validation hash before calling +Task 3's repository. It never trusts a filename, cached browser object, or +caller-supplied normalized projection. The running API additionally requires the +document wallet to equal the already-loaded adapter identity; a wallet change returns +`WALLET_ROTATION_REQUIRES_OFFLINE_RESTART`. Only after guarded close and daemon stop may +the lock-owning offline apply accept a different wallet, before closed configuration +and the adapter are restarted together. Approval bodies +require the displayed intent hash; reconciliation bodies require the displayed intent +hash plus the applicable displayed case hash, and `kind` is exactly `payment`, +`execution`, or `refund-observation`. Execution accepts no financial evidence and +invokes only the trusted seller resolver. Payment additionally accepts either no +candidate (post-expiry unused-authorization check only) or one canonical public +`paymentTransactionId`; refund observation requires one canonical public +`refundTransactionId`. They reject every receipt/log/block/amount/nonce/wallet/payee/ +status or generic evidence field, persist the public candidate before observation, +and require a fresh case hash before replacing a terminally rejected or explicitly +abandoned candidate. The abandon-candidate route accepts only `kind` equal to +`payment` or `refund-observation` and the exact body +`{ expectedIntentHash, expectedCaseHash }`; it preserves the hold/outcome, performs no +resolver call, and returns the newly rotated case hash. Execution has no candidate to +abandon. +Agent revocation accepts exactly `expectedEnrollmentHash`, marks only the active +enrollment revoked through Task 4's repository, and immediately removes agent admission +without closing or resolving its session. It returns the bound session IDs that still +need safe operator reconciliation/close. +The session transition body contains exactly `targetPolicyHash` and +`expectedSessionHash`; the target must be active and the Kernel enforces Task 10's +safe-transition blockers. Applying policy returns blocked session summaries, so a +tighter policy is never presented as active-for-agent until each session is either +transitioned or visibly blocked from spending. +The session close body contains exactly `expectedSessionHash`; it uses the same +monetary blockers, never creates a replacement session, and returns an explicit +stop/reconfigure/restart requirement for wallet rotation. + +Assert `/agent/v1/*` rejects operator tokens and `/operator/v1/*` rejects agent +traffic. The API receives narrow service functions, never the raw SQLite store, wallet +adapter, permit authority, signer, SDK client, or environment object. + +- [ ] **Step 3: Write the failing CLI contract** + +Capture stdout/stderr/exit codes for: + +```text +wallet-kernel preflight +wallet-kernel agent enroll DESCRIPTOR --confirm DESCRIPTOR_HASH +wallet-kernel isolation attest REPORT --confirm REPORT_HASH +wallet-kernel agent revoke AGENT_INSTANCE_ID --confirm ENROLLMENT_HASH +wallet-kernel console launch +wallet-kernel policy validate FILE +wallet-kernel policy apply FILE --confirm POLICY_HASH +wallet-kernel sessions transition SESSION_ID --to-policy POLICY_HASH \ + --confirm SESSION_HASH +wallet-kernel sessions close SESSION_ID --confirm SESSION_HASH +wallet-kernel approvals list [--state pending] +wallet-kernel approvals approve APPROVAL_ID --confirm INTENT_HASH +wallet-kernel approvals deny APPROVAL_ID --confirm INTENT_HASH --reason OPERATOR_DENIED +wallet-kernel receipts list +wallet-kernel receipts verify RECEIPT_ID +wallet-kernel reconcile payment INTENT_ID --confirm INTENT_HASH \ + --confirm-case PAYMENT_CASE_HASH [--payment-transaction PAYMENT_TRANSACTION_ID] +wallet-kernel reconcile execution INTENT_ID --confirm INTENT_HASH \ + --confirm-case EXECUTION_CASE_HASH +wallet-kernel reconcile refund-observation INTENT_ID --confirm INTENT_HASH \ + --confirm-case REFUND_CASE_HASH --refund-transaction REFUND_TRANSACTION_ID +wallet-kernel reconcile abandon-candidate INTENT_ID \ + --kind payment|refund-observation --confirm INTENT_HASH --confirm-case CASE_HASH +wallet-kernel export SESSION_ID --output FILE +``` + +`--json` returns one closed machine-readable object; default output is compact text. +Unknown flags and missing operands exit `2`; authenticated API errors exit `1`; success +exits `0`. In `cdp-testnet`, the CLI reads the token file locally and sends it only as +an HTTP bearer over the prevalidated owner-only Unix socket; it never opens TCP. In +deterministic/demo mode only, the injected loopback adapter may send it to the fixed +loopback operator origin. It never prints the token, credentials, payment signature, +or raw content. +`console launch` is UDS-only in live mode, prints only the one-time fragment URL (or a +closed JSON object with URL/expiry), and never invokes a browser/GUI itself. The human +opens it. Tests prove expiry, one-use replay rejection, restart invalidation, and that +neither output nor server state contains the owner bearer. +API/CLI tests prove a tighter policy immediately reports the old session as blocked, +a stale confirmation cannot transition it, unresolved/signed work remains safely +blocked, and a successful transition returns the new session/policy hashes without +exposing the agent credential or accepting a session ID from Pi. +They also prove guarded close leaves the live agent unbound and cannot be used as a +hot wallet-rotation shortcut, and prove revocation immediately rejects the leaked +credential while leaving every unresolved monetary row untouched. + +Bootstrap is deliberately offline: `preflight`, `agent enroll`, `policy validate +FILE`, `policy apply FILE`, and `isolation attest` do not require a running listener. +The privileged probe runs after enrollment/policy setup and before attestation import, +as fixed by the exact clean-install sequence below. Agent enrollment +validates the descriptor's exact hash, closed schema, canonical different Pi UID in +live mode, uniqueness, and absence of any raw token before inserting the immutable +active `agent_enrollments` row. These commands validate the owner token, +call `acquireAuthorityLock({ databasePath, role: 'bootstrap' })`, prove no Kernel +writer owns the SQLite authority, perform only the requested operation, close/fsync, +and release the lock. Approval, receipt, reconciliation, and export commands remain +authenticated Unix-socket API clients in live mode and loopback clients only in the +deterministic demo. Add tests that a live Kernel or competing +bootstrap process makes offline apply fail with `AUTHORITY_BUSY` and no partial policy +row/event. + +Every bootstrap command that can mutate SQLite first opens the receipt signer and runs +the same integrity, semantic, event-chain, missing-receipt repair, signature, and global +receipt-parity checks as startup while still holding the bootstrap lock. Only after +that audit returns healthy may it perform its one requested mutation. If repair +signing or any audit fails, it makes zero enrollment/policy/attestation changes and +returns `AUTHORITY_RECOVERY_REQUIRED`. Tests seed a domain-commit/receipt gap and prove +offline apply/enroll/attest either repairs exact parity first or performs no requested +write; no bootstrap path can advance authority past a missing receipt. + +The final clean-install order is exact: Kernel `preflight`; Pi-side `credential init`; +offline `agent enroll`; offline `policy apply`; privileged isolation probe bound to +that enrollment; offline `isolation attest`; then daemon start. Normal replacement uses +guarded session close, authenticated `agent revoke --confirm ENROLLMENT_HASH`, daemon +stop, new Pi credential/descriptor, offline replacement enrollment, a fresh +probe/attestation, and clean restart. If compromise requires revocation before a close +that unresolved money blocks, revoke first, remain in operator-only recovery, reconcile +and close, then stop/enroll. Tests cover both orders and prove no second active row is +created. No step edits SQLite by hand. + +There are two distinct one-way handoff parents, never one shared writable directory. +`enrollmentInboxPath` is Pi-owned mode `0755`: the Pi helper publishes the untrusted +enrollment descriptor there as canonical JSON plus newline, mode exactly `0644`, and +the Kernel UID has traverse/read but no write permission. `agentRunOutboxPath` is +Kernel-owned mode `0755`: the Kernel publishes bounded public testnet run descriptors +there as `0644`, and the Pi UID has traverse/read but no write permission. The paths +must be distinct, outside both the Pi `0700` credential parent and Kernel `0700` +authority tree, with non-symlink parents whose own parents are not Pi-writable. +Wrong-direction create/rename/delete attempts under dropped Kernel/Pi identities must +fail with `EACCES`. + +The raw credential remains `0600` under its Pi-owned `0700` parent. Never put +the descriptor beside the credential. `agent enroll` preflights the handoff parent, +then opens the descriptor once with +`O_RDONLY | O_NOFOLLOW`, then `fstat`s that same descriptor: regular file, link count +one, exact configured nonzero Pi UID/GID, exact mode, and at most 1 KiB. It reads and +hashes only that descriptor, re-`fstat`s to reject size/inode/mtime changes, requires +the confirmed hash and exact canonical five-field bytes, then inserts under the +bootstrap authority transaction. It never resolves ownership from descriptor fields +or reopens by path. Tests cover symlink/hardlink, wrong owner/mode, oversize, +noncanonical bytes, raw-token fields, descriptor swap/race, and mutation after the +human confirmation; all fail without an enrollment row. + +Implement `isolation-preflight.mjs` with a pure metadata validator for nonzero +distinct Kernel/agent UIDs and pinned primary GIDs, Kernel-owned `0700` authority +paths, and a Pi-owned `0600` credential outside that tree. The privileged +`preflight-agent-isolation.mjs` launches `agent-isolation-probe-worker.mjs`; the worker +calls `setgroups([])`, then `setgid()`/`setuid()` to the exact target identity with an +empty environment. It must read the Pi credential but receive OS `EACCES`/`EPERM` for +the authority directory, database, operator token, receipt key, and Kernel-only +sentinel. Reject root targets, symlinks, permissive bits, path swaps, and unexpected +readability. A shared primary GID is allowed for macOS only when group permission bits +are zero and this real probe passes. + +After the dropped-identity worker exits, the privileged installer process +exclusive-creates a Kernel-UID-owned `0600` report in a preflighted Kernel `0700` +staging parent. Its canonical JSON plus one newline has exactly this closed shape: + +```js +const isolationReport = Object.freeze({ + schemaVersion: 1, + enrollmentHash, + kernelUid: String(kernelUid), + kernelGid: String(kernelGid), + agentUid: String(agentUid), + agentGid: String(agentGid), + authorityMetadataHash: `sha256:${'66'.repeat(32)}`, + credentialMetadataHash: `sha256:${'77'.repeat(32)}`, + releaseManifestHash: `sha256:${'88'.repeat(32)}`, + releaseTreeHash: `sha256:${'99'.repeat(32)}`, + nodeExecutableHash: `sha256:${'aa'.repeat(32)}`, + serviceDefinitionHash: `sha256:${'bb'.repeat(32)}`, + environmentMetadataHash: `sha256:${'cc'.repeat(32)}`, + probeResults: Object.freeze({ + authorityDirectory: 'EACCES', + database: 'EACCES', + operatorToken: 'EACCES', + receiptKey: 'EACCES', + kernelEnvironment: 'EACCES', + agentCredential: 'READABLE', + releaseTreeWrite: 'EACCES', + dependencyTreeWrite: 'EACCES', + serviceDefinitionWrite: 'EACCES', + kernelEnvironmentParentWrite: 'EACCES', + }), + probedAt: '2026-07-31T12:00:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', +}); +const reportHash = sha256(canonicalJson(isolationReport)); +``` + +The metadata hashes cover closed `(role, device, inode, uid, gid, mode)` projections, +not paths, file contents, mutable size/mtime, or secrets. Validate canonical nonzero +UID/GID strings, the exact active enrollment hash, every deployment hash, all ten +literal result codes, +`probedAt <= now < expiresAt`, and a maximum 15-minute interval. Print only +`reportHash`. + +`isolation-preflight.mjs` also exports this durable repository contract: + +```text +createIsolationAttestationRepository({ store, now, idFactory }) -> { + importCurrent({ reportBytes, expectedReportHash, operatorIdHash }), + currentFor({ enrollmentHash, authorityMetadataHash, releaseManifestHash, + expectedReportHash }), +} +``` + +`isolation attest REPORT --confirm REPORT_HASH` opens and hashes the report once under +the bootstrap lock using the descriptor's single-FD discipline. `importCurrent()` +revalidates the closed canonical bytes, requires the current active enrollment hash, +atomically supersedes any prior `current` row, inserts the exact report/hash and public +timestamps into `isolation_attestations`, and appends one redacted import event. Exact +replay is idempotent; a different report needs its own displayed hash. `currentFor()` +accepts no report bytes, returns only an unexpired `current` row whose enrollment and +freshly recomputed Kernel-accessible authority metadata hash match, and never treats a +superseded row as live. The stored `credentialMetadataHash` is the privileged probe's +short-lived attestation, not a value the Kernel recomputes: the Kernel UID must remain +unable to traverse or stat the Pi-owned `0700` credential parent. A real-UID test proves +normal live startup validates the imported report while direct Kernel-identity +credential traversal still receives `EACCES`; passing a caller-supplied or copied +credential metadata hash is not part of the API. +It also requires the freshly verified release-manifest hash to equal the report's +deployment binding; an otherwise valid isolation report for different code cannot +admit the agent. `expectedReportHash` is recomputed from the configured owner-only +report artifact opened once by the Kernel; it must equal the exact current row imported +into SQLite. +Recovery treats malformed JSON/hash, two current rows, enrollment mismatch, inverted +timestamps, or an impossible result code as `AUTHORITY_SEMANTIC_CORRUPTION` before +listeners. Tests inject stat/spawn, exercise import/reopen/expiry/supersede/corruption, +and keep an optional POSIX integration test for human-supplied safe test identities. A +host administrator, root/capability escape, or failed/missing probe is outside the +pilot trust boundary and blocks live admission. + +Live executable integrity is part of the same host boundary. `cdp-testnet` may run only +from an installed release such as `/opt/wallet-kernel/releases/`, never a +developer checkout or Pi-writable workspace. Every ancestor from the configured +trusted prefix through `releaseRoot`, every directory/file under it (including +`src/`, `package.json`, `package-lock.json`, `node_modules/`, launcher, and preflight +scripts), the absolute Node executable, and the service/socket definitions are +root-owned and have no group/other write bit. The Kernel and Pi UIDs can read/execute +what they need but cannot create, modify, rename, or delete any component. Writable +SQLite, keys, sockets, logs, temporary files, and evidence live under separate +Kernel-owned paths and are never children of `releaseRoot`. + +`build-release-manifest.mjs` runs only during the privileged install from an exact clean +commit, after `npm ci` and before service start. It exclusive-creates canonical JSON +plus newline under the root-owned release with this closed schema (the manifest itself +is excluded from its tree hash): + +```js +{ + schemaVersion: 1, + commit: '<40 lowercase hex>', + createdAt: '', + entrypoint: 'src/control-plane.mjs', + packageLockHash: 'sha256:<64 lowercase hex>', + releaseTreeHash: 'sha256:<64 lowercase hex>', + node: { + version: 'v24.15.0', + executablePathHash: 'sha256:<64 lowercase hex>', + executableSha256: 'sha256:<64 lowercase hex>', + uid: '0', gid: '', mode: '', + }, + service: { + definitionPathHash: 'sha256:<64 lowercase hex>', + definitionSha256: 'sha256:<64 lowercase hex>', + environmentMetadataHash: 'sha256:<64 lowercase hex>', + }, + entries: [{ + path: '', + kind: 'directory' | 'file' | 'symlink', + uid: '0', gid: '', mode: '', + bytes: null | '', + sha256: null | 'sha256:<64 lowercase hex>', + target: null | '', + }], +} +``` + +Entries are sorted by canonical relative path and cover the entire release tree except +the manifest. Reject absolute/dot/duplicate paths, devices/FIFOs/sockets, escaping or +dangling symlinks, hard-linked regular files, missing/extra entries, mutable +directories/files, a package-lock mismatch, an entrypoint outside the tree, or a Node +version outside the exact pinned runtime. The environment metadata hash covers only +`(device,inode,uid,gid,mode)` for the Kernel-owned `0600` environment file under its +Kernel-owned `0700` parent, never secret contents or its path. The service definition +and socket-activation definition are root-owned, hashed public configuration. + +`preflight-live-deployment.mjs` is invoked by the root-owned service manager before it +drops to the Kernel UID. Using the pinned absolute root-owned Node binary, it verifies +the release manifest/tree, Node executable, launcher, service/socket definitions, and +environment metadata and validates the service manager's reserved console listener. +It acquires the authority lock in read-only `prelaunch` role and inspects only the +active-enrollment/current-attestation keys. With one active enrollment, it single-FD +opens the already human-confirmed `isolationReportPath`, requires its hash to equal +SQLite's exact current attestation, revalidates its unexpired static bindings, then +reruns the dropped-Pi-identity probes and requires the current results/metadata to equal +that artifact. It does not generate, rewrite, import, supersede, or timestamp a report +and performs no SQLite mutation. Only `preflight-agent-isolation.mjs` generates a new +report, and only the confirmed offline `isolation attest` command imports it. With zero +active enrollment, prelaunch still verifies deployment integrity/write denial but skips +agent-credential report matching so operator-only recovery can start. + +The dropped-identity worker must receive `EACCES`/`EPERM` for +write/create/rename attempts against the release root, representative source, +lockfile, dependency, launcher, service definitions, environment file/parent, and +Kernel writable roots. Readability of public code is not a failure; writability is. +The previously imported privileged report binds `releaseManifestHash`, `releaseTreeHash`, +`nodeExecutableHash`, `serviceDefinitionHash`, and `environmentMetadataHash` alongside +the enrollment/isolation facts. + +The service launches with a closed environment and live startup rejects `NODE_OPTIONS`, +`NODE_PATH`, `LD_PRELOAD`, every `DYLD_*` variable, and any unrecognized code-loader +or `WALLET_KERNEL_` field. `release-integrity.mjs` runs again inside the Kernel before +opening SQLite, requires `import.meta.url`/the process entrypoint inside the attested +release, and recomputes the complete manifest/external artifact hashes. After it opens +and recovers SQLite, normal admission single-FD hashes the same configured report +artifact and requires `currentFor()` to match its exact DB row and release hash. This +runtime check supplements rather than replaces +the root prelaunch gate. Tests mutate every component/manifest field, inject each +loader variable, add an extra file, swap a parent, and run real dropped-UID negative +write probes; all block before a credential, database, or listener is opened. Restart +tests cover the exact same imported artifact, expiry, DB/artifact hash mismatch, +release-hash mismatch, and zero-active operator recovery without any implicit import. + +`agent revoke` is an authenticated operator mutation over the Unix admin channel or +browser session, so a compromised Pi capability +can be disabled without first stopping the Kernel; it never authorizes replacement +enrollment until the old binding is safely closed. + +- [ ] **Step 4: Write the failing four-view console and activation tests** + +Parse the static HTML and assert exactly these navigation views and data scopes: + +```text +Overview -> wallet public identity, agent enrollment/revocation, health, budgets, reserved/unresolved amounts +Policies -> active version, local validation result, immutable history, guarded transition/close +Approvals -> pending/approved/denied/expired/cancelled exact request metadata and actions +Receipts -> settled/failed/refunded/unresolved summaries, displayed case hashes, and bounded reconciliation actions +``` + +Assert all assets are local, no inline script/style, no raw prompt/output element, no +CDN/analytics/font URL, and no form can edit financial binding fields. HTTP tests must +verify CSP `default-src 'self'; connect-src 'self'; frame-ancestors 'none'`, +`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and no caching of +authenticated JSON. +The live test starts a real temporary Unix admin socket plus an already-listening +loopback FD that stands in for root socket activation. It exercises CLI +`console launch`, fragment exchange, session/CSRF mutation, restart invalidation, and +one authenticated CLI request. Missing/wrong inherited FD or self-bind attempts fail; +a rogue Pi-identity bind cannot displace the reserved console socket and never receives +the owner bearer. + +- [ ] **Step 5: Run the operator and isolation tests and observe missing modules/files** + +```bash +node --test spikes/pi-wielder/tests/operator-auth.test.mjs \ + spikes/pi-wielder/tests/operator-api.test.mjs \ + spikes/pi-wielder/tests/operator-cli.test.mjs \ + spikes/pi-wielder/tests/operator-console.test.mjs \ + spikes/pi-wielder/tests/agent-isolation.test.mjs \ + spikes/pi-wielder/tests/release-integrity.test.mjs +``` + +Expected: FAIL until the operator modules and static console exist. + +- [ ] **Step 6: Implement token/session authentication** + +Create `operator/auth.mjs` with: + +```text +export function loadOrCreateOperatorToken({ filePath, randomBytes = crypto.randomBytes }) {} +export function createOperatorAuth({ token, mode, origin, now, sessionTtlMs = 900_000 }) { + return Object.freeze({ + authenticateBearer(request, { transport }), + issueBrowserLaunch({ transport }), + exchangeBrowserSession(request), + authenticateBrowser(request, { mutation }), + revokeBrowserSession(request), + }); +} +``` + +Use `loadOrInitializePrivateFile()`, cryptographically random opaque session and CSRF values, +bounded in-memory session count, strict expiry, and constant-time digest comparison. +Restart invalidates all browser launch capabilities/sessions but not the owner token. +`issueBrowserLaunch()` is reachable only after bearer authentication on the admin +channel, stores only a digest plus expiry, and returns the one-time fragment URL. +`authenticateBearer()` requires `transport === 'unix'` in `cdp-testnet` and +rejects any forwarded/channel header that attempts to claim a Unix origin. +The operator-token initializer writes exactly the validated 43-character encoding +with no newline. It never trims, rewrites, or regenerates an existing token; Task 2's +atomic no-replace publication and crash/race tests apply unchanged. +Derive one stable `operatorIdHash` as SHA-256 over a domain separator plus the owner +token bytes. Authenticated services inject that hash into approvals/reconciliations; +HTTP bodies cannot supply it, and raw operator identity/token bytes never enter SQLite, +receipts, projections, evidence, or logs. + +- [ ] **Step 7: Implement the narrow API and CLI** + +Create `operator/api.mjs`: + +```js +export function createOperatorApp({ auth, services, bodyLimits, mode, transport, origin }) { + // Return a Hono app containing only the routes listed in Step 2. +} +``` + +Implement `release-integrity.mjs`, `build-release-manifest.mjs`, and +`preflight-live-deployment.mjs` against the exact manifest/prelaunch contracts above. +All filesystem tests inject a temporary synthetic release; the optional real-UID test +uses only human-supplied safe fixture identities and never changes the developer +checkout. + +Create `operator/cli.mjs` with exported `runOperatorCli({ argv, env, requestImpl, +stdout, stderr })` for tests and a direct-execution guard for the binary. `requestImpl` +accepts exactly `{ socketPath, origin, method, path, headers, body }`; the live adapter +uses `node:http.request({ socketPath, ... })`, while the deterministic test adapter may +use fixed-origin TCP. Node's standard `fetch()` is not treated as Unix-socket capable. +Parse commands +without shell interpolation. Write exports with exclusive create, mode `0600`, and +refuse symlink/overwrite unless an explicit safe output path does not yet exist. +Implement the bootstrap commands through a separate +`runOfflineBootstrap({ command, config, operatorToken })` helper that acquires the +same shared role-`bootstrap` process-lifetime authority lock before opening SQLite; it +must not construct an HTTP app or expose a general store +handle. This makes the documented sequence—preflight, Pi credential initialization, +agent enrollment, initial policy apply, privileged isolation probe, attestation import, +then Kernel start—executable without a circular bootstrap +listener. + +- [ ] **Step 8: Implement the self-contained local console** + +Create `operator/console.mjs` to serve the three static files and browser-authenticated +API. In `cdp-testnet` it accepts only the already-listening root service-manager FD, +validates its socket address/type and activation name, and never self-binds; deterministic +mode may bind its fixed loopback demo port. `app.mjs` reads the launch capability only +from the URL fragment, immediately removes the fragment, exchanges it once, drops the +value, and keeps only the CSRF value in memory. It has no owner-token field. Render +with DOM text properties, never `innerHTML`. Approval confirmation +shows the immutable seller, resource, request hash, amount ceiling, wallet, policy +reason, and expiry immediately before the one-time action. +Reconciliation forms display the immutable local binding and current case hash, accept +only the optional payment or required refund transaction candidate allowed above, and +refresh after every conflict/rejected candidate. They expose a separately confirmed +abandon action for the exact pending payment/refund candidate; its warning states that +the hold remains and only a fresh case hash can name a replacement. They never expose +raw evidence. + +- [ ] **Step 9: Run operator tests** + +```bash +node --test spikes/pi-wielder/tests/operator-*.test.mjs \ + spikes/pi-wielder/tests/agent-isolation.test.mjs \ + spikes/pi-wielder/tests/release-integrity.test.mjs +``` + +Expected: authentication, exact routes, CLI exits, CSP, activated four-view console, +and release/prelaunch integrity all pass. + +- [ ] **Step 10: Commit the operator plane** + +```bash +git add spikes/pi-wielder/src/operator \ + spikes/pi-wielder/src/kernel/release-integrity.mjs \ + spikes/pi-wielder/src/agent/isolation-preflight.mjs \ + spikes/pi-wielder/scripts/build-release-manifest.mjs \ + spikes/pi-wielder/scripts/preflight-live-deployment.mjs \ + spikes/pi-wielder/scripts/preflight-agent-isolation.mjs \ + spikes/pi-wielder/scripts/agent-isolation-probe-worker.mjs \ + spikes/pi-wielder/operator-console \ + spikes/pi-wielder/tests/operator-auth.test.mjs \ + spikes/pi-wielder/tests/operator-api.test.mjs \ + spikes/pi-wielder/tests/operator-cli.test.mjs \ + spikes/pi-wielder/tests/operator-console.test.mjs \ + spikes/pi-wielder/tests/agent-isolation.test.mjs \ + spikes/pi-wielder/tests/release-integrity.test.mjs +git commit -m "feat: add authenticated local wallet operations" +``` + +### Task 14: Put Pi behind the route-mapped Spend Control proxy + +**Files:** + +- Create: `spikes/pi-wielder/routes/base-sepolia.example.json` +- Create: `spikes/pi-wielder/pi-extension/agent.env.example` +- Create: `spikes/pi-wielder/src/agent/credential.mjs` +- Create: `spikes/pi-wielder/src/agent/credential-cli.mjs` +- Create: `spikes/pi-wielder/src/agent/auth.mjs` +- Create: `spikes/pi-wielder/src/spend-control-proxy.mjs` +- Create: `spikes/pi-wielder/src/control-plane.mjs` +- Create: `spikes/pi-wielder/tests/agent-credential.test.mjs` +- Create: `spikes/pi-wielder/tests/agent-auth.test.mjs` +- Modify: `spikes/pi-wielder/tests/agent-isolation.test.mjs` +- Create: `spikes/pi-wielder/tests/spend-control-proxy.test.mjs` +- Create: `spikes/pi-wielder/tests/control-plane.test.mjs` +- Modify: `spikes/pi-wielder/pi-extension/x402.ts:57-138` +- Modify: `spikes/pi-wielder/tests/pi-extension-contract.test.mjs` + +- [ ] **Step 1: Write the fixed route map and proxy rejection tests** + +Create `routes/base-sepolia.example.json`: + +```json +{ + "schemaVersion": 1, + "routes": [ + { + "id": "example-model", + "kind": "openai-chat", + "method": "POST", + "upstreamUrl": "https://seller.example/paid/chat/completions", + "resourceDescription": "Wallet Kernel example model route", + "resourceMimeType": "application/json", + "purposeLabel": "model.infer", + "requestContentTypes": ["application/json"], + "maximumRequestBytes": 262144, + "maximumResponseBytes": 1048576 + }, + { + "id": "example-skill", + "kind": "tool", + "method": "POST", + "upstreamUrl": "https://seller.example/paid/skill", + "resourceDescription": "Wallet Kernel example Skill route", + "resourceMimeType": "application/json", + "purposeLabel": "skill.invoke", + "requestContentTypes": ["application/json"], + "maximumRequestBytes": 262144, + "maximumResponseBytes": 1048576 + } + ] +} +``` + +Create the raw capability only under the Pi OS identity, never in Kernel composition. +`credential.mjs` uses Task 2's atomic initializer to publish canonical JSON plus one +newline with exact closed shape `{ schemaVersion: 1, agentInstanceId, token }`: the +instance ID is random 16-byte base64url and token is independent random 32-byte +base64url. Its validator enforces exact 22/43-character round trips, owner UID, `0600`, +no symlink/padding/trimming/unknown fields, and crash/race reuse. It separately writes +an exclusive-create non-secret descriptor: + +```js +{ + schemaVersion: 1, + agentInstanceId, + credentialDigest: sha256(tokenBytes), + agentUid: String(process.getuid()), + agentGid: String(process.getgid()), +} +``` + +`credential-cli.mjs init --credential FILE --enrollment FILE` runs without any Kernel +token, database, environment, or listener access, prints only the descriptor SHA-256, +and refuses an enrollment overwrite. Validate the digest as exactly one +`sha256:<64 lowercase hex>` value—never double-prefix it. The operator imports that descriptor with Task +13's offline confirmed `agent enroll`; the raw credential file never crosses into the +Kernel identity. Only the per-request bearer reaches bounded authentication memory, +and it never reaches SQLite, logs, receipts, or evidence. Create +`pi-extension/agent.env.example` containing only blank Pi-side route/origin/credential +variables; the Kernel `.env.example` contains no raw agent path. + +Implement the Kernel-side boundary in `src/agent/auth.mjs`: + +```text +export function createAgentAuth({ store, intents, walletIdentity, activePolicy, + kernelUid, kernelGid, expectedAgentUid, expectedAgentGid, mode }) { + return Object.freeze({ + authenticate(request), + openOrResumeSession(enrolledAgent), + resolveBoundSession(authenticatedAgent), + }); +} +``` + +`authenticate()` parses exactly `Authorization: WalletKernelAgent `, hashes the +decoded 32 bytes immediately, zeroes the temporary token buffer, and constant-time +compares only fixed-length digests against one active `agent_enrollments` row. It +returns only instance ID, digest, and enrolled UID/GID; it never stores/returns the token +or accepts a session ID. Missing, malformed, duplicate, operator, query/cookie, wrong, +or revoked credentials fail before body parsing. Multiple active enrollments, UID/GID +config mismatch, root identity, or shared Kernel UID fails startup before either +listener with `AGENT_ENROLLMENT_AMBIGUOUS` or the exact identity error. Zero active +enrollment enters explicit `recovery_only` mode: start the authenticated operator plane +and a closed-denial agent listener that returns `AGENT_ENROLLMENT_REQUIRED` before body +or route parsing, create no Spend Session or permit authority, and expose no signing +service. This preserves reconciliation and guarded-close access after a revocation plus +process crash. Initial/replacement enrollment remains an offline bootstrap step, so the +operator stops this recovery-only daemon before importing a replacement. +The recovery-only operator service allowlist is exact: health/Overview, receipt +read/verify, export, payment/execution/refund reconciliation, and guarded session close. +Approval decisions, policy apply/transition, agent enrollment/revocation, session +open/rebind, and every signing/spend route return +`RECOVERY_ONLY_OPERATION_FORBIDDEN` before mutation. In particular, +`transitionSessionPolicy()` cannot create a replacement session for a revoked +enrollment. Route/API tests invoke every normal mutation endpoint in recovery-only mode +and prove only reconciliation/close can write and session-create/permit/signer counts +remain zero. +`resolveBoundSession()` is a read-only exact lookup of the current `open` or +`policy_blocked` binding for that enrollment; it creates nothing and returns +`AGENT_SESSION_UNAVAILABLE` after guarded close. + +During composition and before either listener, load zero or one active enrollment—not +the Pi credential file. With zero, enter the recovery-only composition above, preserve +all revoked bindings for operator work, and call no open/create/signing method. With +one, inspect its binding before opening agent admission. With no binding, call Task 4's +atomic `openOrResumeSession({ agentInstanceId, walletAddress, +policyVersionId: activePolicy.id })`. With one exact `open` binding, require its wallet +and policy to equal the active configuration, then call `openOrResumeSession()` using +that already-pinned policy only to obtain the idempotent existing row. With one +`policy_blocked` binding, do not call an open/create method: preserve it for operator +recovery/status and reject every +agent execute/retry as `POLICY_TRANSITION_REQUIRED`; Task 10's explicit safe transition +must close the old session and atomically rebind the same agent to the active policy. +Any revoked-enrollment binding is retained as history but cannot be selected by an +active replacement enrollment. Two candidate bindings, an +`open` binding on a non-active policy, or any wallet/digest mismatch fails closed. +Task 4's paired operations remain the sole creators/replacers/closers of session and +binding rows; composition never inserts a binding separately. +Restart with the same credential therefore reuses the exact session and pending intent +without restoring spend admission; concurrent starts converge. +An enrollment/digest/UID/GID mismatch, multiple open bindings, closed/missing referenced session, +wallet/policy mismatch, or attempted implicit token rotation fails closed before +transport or signing. Tests restart the app over the same authority and prove a +pending approval is found through the same session/fingerprint; an unauthorized local +process, a fresh credential, or a guessed instance ID cannot read, approve, retry, or +spend from it. `agent-isolation.test.mjs` also proves live same-UID composition fails +before either listener and an injected deterministic same-UID fixture is labeled +`simulated`, never `verified`. + +In `spend-control-proxy.test.mjs`, call only: + +```text +POST /agent/v1/openai/example-model/chat/completions +POST /agent/v1/invoke/example-skill +GET /agent/v1/intents/:requestId +GET /agent/v1/receipts/:receiptId +``` + +The proxy owns one durable Kernel Spend Session for the authenticated Pi identity. +Assert Pi cannot +choose a target URL, method, upstream headers, wallet, session ID, approval ID, +idempotency key, payment header, policy, amount, or payee. Reject unknown route IDs, +wrong methods/content types, oversized bodies, forbidden headers, URL-like path +segments, and operator paths. + +Load the route file only through Task 12's shared `validateRouteMap()`; do not add a +second parser. In `cdp-testnet`, `upstreamUrl` is absolute HTTPS. In +`deterministic`, HTTPS is accepted and plain HTTP is accepted only for literal +`127.0.0.1` or `[::1]`; hostnames are ineligible for this exception, and any redirect +fails. Every mode forbids credentials, +fragments, and queries. `resourceDescription` and `resourceMimeType` are bounded public +constants. A 402 resource must match all three exactly before policy selection/signing. +Pi cannot add path segments or query parameters, and seller error or resource free text +is never copied into the payment payload. Unit tests exercise the same validator used +by `control-plane.mjs` in both modes. + +Forward only normalized `accept`, `content-type`, and `user-agent` headers. Strip +`authorization`, `cookie`, `host`, `connection`, `proxy-*`, `forwarded`, `x-forwarded-*`, +and every hop-by-hop header before seller contact; tests seed each credential-bearing +header and prove the seller never sees it. Scope intent and receipt lookups to the +proxy-owned Spend Session so a guessed public ID from another session returns `404`. + +The tool route uses these stable public envelopes: + +```js +{ status: 'completed', requestId, resource: { + httpStatus, contentType, body, +}, receipt: { + id, hash, sellerOrigin, chargedAtomic, remainingSessionAtomic, + terminalState, transactionPrefix, +} } +{ status: 'payment_approval_required', requestId, approval: { + expiresAt, amountAtomic, sellerOrigin, purposeLabel, +} } +{ status: 'payment_denied', requestId, reasonCode, receipt } +{ status: 'payment_failed', requestId, reasonCode, receipt } +{ status: 'payment_unresolved', requestId, reasonCode, receipt } +{ status: 'payment_rejected', requestId, reasonCode, receipt } +{ status: 'upstream_failed', requestId, reasonCode, receipt } +{ status: 'execution_failed', requestId, reasonCode, receipt } +{ status: 'execution_unknown', requestId, reasonCode, receipt } +{ status: 'refunded', requestId, reasonCode, receipt } +``` + +Map `completed` to `200`, approval-required to `409`, denial to `403`, definite +pre-sign `payment_failed` to `502`, unresolved to `503`, trusted post-expiry +`payment_rejected` to `402`, and post-hoc `refunded` status to `200` with no invented +resource body. Pre-payment +`upstream_failed` to `502`, settled execution failure to the received upstream status +only for 4xx/5xx (a settled 3xx maps to `502` and never forwards `Location`), and +settled-but-undeliverable `execution_unknown` to `502`. +Both execution outcomes retain the committed-payment receipt. The resource body is the +byte-bounded upstream output held only in process memory; return only its declared content type and no upstream cookies, authorization, +or hop-by-hop headers. Never journal that body or return raw signed bytes, approval ID, +operator identity, provider error, or internal database identifier. An exact ordinary +retry after approval resolves via the proxy’s durable credential-bound session and request +fingerprint. Agent receipt/status lookup is restricted to the proxy-owned current +Spend Session and uses opaque identifiers. + +The OpenAI-compatible route passes through a bounded valid OpenAI response body only +after terminal settlement/receipt commit. It adds compact +`X-Wallet-Receipt-Id`, `X-Wallet-Terminal-State`, `X-Wallet-Charged-Atomic`, +`X-Wallet-Session-Remaining-Atomic`, and `X-Wallet-Transaction-Prefix` response +headers. Approval, denial, and unresolved responses use the same stable JSON statuses +above and never masquerade as a model completion. +The agent-scoped GET intent/receipt routes project every `BUYER_OUTCOMES` value, +including reconciliation revisions, and never expose candidate rows, case hashes, +operator identity, or internal IDs. + +- [ ] **Step 2: Run the proxy test and observe missing modules** + +```bash +node --test spikes/pi-wielder/tests/spend-control-proxy.test.mjs \ + spikes/pi-wielder/tests/control-plane.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement the agent-only proxy and environment composition** + +Create `spend-control-proxy.mjs`: + +```js +export function createSpendControlProxy({ agentAuth, kernel, routes, maximumRequestBytes }) { + // Return a Hono app with only the four /agent/v1 routes above. +} +``` + +Create `control-plane.mjs`: + +```js +export async function createControlPlane({ env = process.env, dependencies = {} }) { + // Validate all configuration, secure/open authority, recover, construct adapter, + // Kernel, agent app, operator app, and close function without starting listeners. +} + +export async function startControlPlane(options = {}) { + // After recovery: live binds operator UDS, attaches activated console FD, then agent + // loopback; deterministic binds separate operator/agent loopback demo listeners. +} +``` + +In `cdp-testnet`, `createControlPlane()` first runs Task 13's in-process release +verification; this occurs before reading any secret, opening SQLite, or constructing +an SDK client and yields the recomputed release-manifest hash. After authority recovery, +`currentFor()` must bind that hash to the imported privileged report before agent +admission. Deterministic composition records `deployment: simulated` and cannot +satisfy this gate. + +`createControlPlane()` then acquires `role: 'kernel'` before opening the main database, +loads zero or one active non-secret agent enrollment, and always completes authority +recovery before constructing listeners. Zero selects `recovery_only`, with operator +services plus the closed-denial agent app and no session/signer admission. One loads +its zero-or-one exact binding and follows Task 14 Step 1's +no-binding/open/policy-blocked startup algorithm before constructing the normal proxy +closure. It calls `openOrResumeSession()` only for the first two legal binding cases +and never for `policy_blocked`; it never opens the Pi credential file. The authenticated agent identity and read-only session +resolver remain inside that closure; each admitted request resolves the current +binding, so a guarded policy transition takes effect without a daemon restart. The +session ID is never returned to Pi or read from an HTTP request, and startup does not +regenerate it merely because the process restarted. Add a file-backed two-start test +with a pending approval: the second start has the same session/intent IDs, exactly one +`session.started` event, and one approval. Add a live transition test proving the next +request resolves only the replacement session, while a safely closed/unbound agent is +denied before route lookup or body parsing. +Add a revocation-crash test: revoke the sole enrollment, abort/restart, enter +`recovery_only`, reconcile/close its retained binding through the operator API, stop, +then complete offline replacement enrollment and fresh isolation attestation before a +normal restart. Assert zero signer/permit/session-create calls in recovery-only mode. +Construct the intent repository with `allowLoopbackHttp: mode === 'deterministic'`; +the proxy passes the selected immutable route ID separately to the Kernel, and the +agent can never place a route ID inside a Kernel request object. + +Reject port collisions, non-loopback host configuration, old JSONL passed as SQLite, +wallet/policy mismatch, unknown mode, CDP mode outside Base Sepolia, invalid routes, +failed recovery, and dirty authority before either listener starts. Deterministic mode +is allowed only for offline test/evidence composition. In `cdp-testnet`, bind the +preflighted owner-only operator Unix socket and verify its final inode/mode before +publishing readiness, then attach the browser console only to the verified inherited +root socket-activation FD; never self-bind its port. Start the agent listener only +after both operator channels and the startup report are ready. In deterministic mode, +bind the loopback demo console directly. Shutdown stops admission, waits for +in-flight unsigned work, preserves signed/ambiguous holds, closes the agent listener, +console server, then admin socket, safely unlinks only its own Unix socket inode (the +service manager retains the console reservation), then +closes SQLite and finally releases the process-lifetime authority lock. Acquire that +same role=`kernel` lock before opening the main database or running recovery and retain +it across wallet initialization and both listeners; any offline bootstrap or second +Kernel must receive `AUTHORITY_BUSY` before it can mutate authority state. + +In normal `cdp-testnet` admission, preflight also requires non-root distinct +Kernel/agent UIDs, pinned nonzero GIDs, exact enrollment/config identity agreement, +Kernel authority parent mode `0700`, and `currentFor()` returning the unexpired stored +isolation-attestation row for the exact active enrollment and freshly recomputed +Kernel-accessible authority metadata, including the privileged report's attested Pi +credential metadata and successful denial results imported from Task 13's +`spawn`/credential-switch probe. The installer runs that probe +before dropping to the Kernel service account; ordinary runtime code never pretends it +can prove another UID by inspecting mode bits alone. A missing, expired, or +metadata-mismatched attestation blocks normal agent admission. Recovery-only mode has +no active agent identity to attest and therefore skips this admission gate, but still +requires authority recovery, operator authentication, wallet public-identity match, +and the read-only observer needed for reconciliation; it cannot construct or expose a +signing service. Deterministic composition injects `isolation: simulated`, which +is surfaced in health/evidence and cannot satisfy the live gate. + +At startup, cross-check every configured route's method, seller origin, and resource +path against the one unique canonical-origin seller entry in the active immutable +PolicyVersion and require the path to match at least one of that entry's canonical +prefixes. Reject a +route/policy mismatch rather than exposing a permanently denied or differently trusted +route. Also reject any non-HTTPS policy seller/evidence binding in `cdp-testnet`; only +deterministic mode may use the literal-loopback HTTP exception. + +In `cdp-testnet`, startup must also have the configured Base Sepolia observer from +Task 12 and pass its read-only preflight; missing observer/RPC configuration fails +before wallet signing or admission. + +`cdp-testnet` routes require HTTPS. `deterministic` may use plain HTTP only for literal +`127.0.0.1` or `[::1]`; this exception exists solely for the +separate-process offline suite and cannot be selected in CDP mode. + +- [ ] **Step 4: Update Pi to remain a thin ordinary HTTP client** + +Modify `pi-extension/x402.ts` so its provider points at +`${WALLET_KERNEL_ORIGIN}/agent/v1/openai/${WALLET_KERNEL_MODEL_ROUTE}` and its Skill +tool calls only +`${WALLET_KERNEL_ORIGIN}/agent/v1/invoke/${WALLET_KERNEL_SKILL_ROUTE}`. Both send +ordinary OpenAI/tool JSON plus `Content-Type` and the local-only +`Authorization: WalletKernelAgent ` loaded from the owner-only credential file; +the proxy authenticates then strips that header before seller contact. Neither sets any +forbidden payment, idempotency, approval, session, or wallet header. The extension +receives only `WALLET_KERNEL_AGENT_CREDENTIAL_FILE`, opens it once with `O_NOFOLLOW`, +validates the credential file's regular-file/current-owner/`0600` state, parses the closed +credential from those bounded bytes, and keeps the token in memory. It never validates +a path and then reopens it. +Provider/model names and route IDs come from bounded token environment values with +documented local defaults; none may be a URL. + +Before reading the credential, require `WALLET_KERNEL_ORIGIN` to use exactly the +`http:` scheme and be an exact queryless, fragmentless, credential-free loopback +origin using literal `127.0.0.1` or `[::1]` and a valid explicit port. Reject every +hostname, non-loopback address, path, and alternate scheme before the token enters +memory. Contract tests prove a hostile origin cannot +receive or cause a read of the credential. + +Render `payment_approval_required` with seller, amount, purpose, and expiry, then tell +the Wielder to retry the same tool call after an operator decision. Render denial and +definite payment failure separately from unresolved reason codes, with a compact +receipt ID/hash. Never auto-poll, auto-retry, or +open the operator console. + +Extend `pi-extension-contract.test.mjs` to scan the extension source for all forbidden +headers and imports, assert the fixed `/agent/v1/openai/` and `/agent/v1/invoke/` +prefixes, and test response +rendering for approval, denial, expiry, definite payment failure, upstream failure, +completed receipt, trusted `payment_rejected`, post-hoc `refunded`, and unresolved +outcomes. Tests also prove the credential value never reaches seller +fixtures, output, diagnostics, or persisted events. +The extension requires the credential file owner to equal its own nonzero +`process.getuid()`; a Kernel-owned, root-owned, or permissive credential is rejected. + +Add `RAW_PROMPT_SENTINEL` in an agent body/query attempt and +`PROVIDER_EXCEPTION_SENTINEL` in challenge free text and thrown adapter/transport +errors. Reopen SQLite and scan every text column/event plus captured logs and receipts; +neither sentinel may occur. The body/query attempt still yields only hashes or a stable +query rejection, and provider failures yield only stable reason codes. + +- [ ] **Step 5: Run proxy, Pi, and legacy proxy tests** + +```bash +node --test spikes/pi-wielder/tests/agent-credential.test.mjs \ + spikes/pi-wielder/tests/agent-auth.test.mjs \ + spikes/pi-wielder/tests/agent-isolation.test.mjs \ + spikes/pi-wielder/tests/spend-control-proxy.test.mjs \ + spikes/pi-wielder/tests/control-plane.test.mjs \ + spikes/pi-wielder/tests/pi-extension-contract.test.mjs +node --test spikes/pi-wielder/tests/proxy-trust.test.mjs \ + spikes/pi-wielder/tests/runtime-boundaries.test.mjs +``` + +Expected: the new Pi path can reach only configured routes, and the legacy proxy +security suite remains green. + +- [ ] **Step 6: Commit the Pi cutover surface** + +```bash +git add spikes/pi-wielder/routes/base-sepolia.example.json \ + spikes/pi-wielder/pi-extension/agent.env.example \ + spikes/pi-wielder/src/agent/credential.mjs \ + spikes/pi-wielder/src/agent/credential-cli.mjs \ + spikes/pi-wielder/src/agent/auth.mjs \ + spikes/pi-wielder/src/spend-control-proxy.mjs \ + spikes/pi-wielder/src/control-plane.mjs \ + spikes/pi-wielder/tests/agent-credential.test.mjs \ + spikes/pi-wielder/tests/agent-auth.test.mjs \ + spikes/pi-wielder/tests/agent-isolation.test.mjs \ + spikes/pi-wielder/tests/spend-control-proxy.test.mjs \ + spikes/pi-wielder/tests/control-plane.test.mjs \ + spikes/pi-wielder/pi-extension/x402.ts \ + spikes/pi-wielder/tests/pi-extension-contract.test.mjs +git commit -m "feat: route Pi spending through wallet kernel" +``` + +### Task 15: Prove the product slice across real processes and pinned Pi + +**Files:** + +- Create: `spikes/pi-wielder/tests/fixtures/x402-v2-seller-process.mjs` +- Create: `spikes/pi-wielder/tests/fixtures/pi-model-process.mjs` +- Create: `spikes/pi-wielder/tests/fixtures/pi-client-process.mjs` +- Create: `spikes/pi-wielder/tests/fixtures/control-plane-process.mjs` +- Create: `spikes/pi-wielder/tests/fixtures/loopback-only-preload.cjs` +- Create: `spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs` +- Create: `spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs` +- Create: `spikes/pi-wielder/spend-control-e2e.mjs` +- Modify: `spikes/pi-wielder/package.json` + +- [ ] **Step 1: Add exact package scripts** + +Add: + +```json +{ + "scripts": { + "test:kernel": "node --test tests/kernel-*.test.mjs tests/wallet-*.test.mjs tests/eip3009-exact.test.mjs tests/x402-v2-transport.test.mjs tests/base-sepolia-observer.test.mjs tests/seller-evidence-resolver.test.mjs tests/config.test.mjs tests/projection-exporter.test.mjs tests/agent-*.test.mjs tests/operator-*.test.mjs tests/spend-control-proxy.test.mjs tests/control-plane.test.mjs", + "e2e:spend-control": "node spend-control-e2e.mjs", + "verify:spend-control": "npm test && npm run e2e && npm run e2e:spend-control", + "control-plane": "node src/control-plane.mjs", + "operator": "node src/operator/cli.mjs" + } +} +``` + +Merge these keys into the existing scripts object; do not replace legacy scripts. + +- [ ] **Step 2: Build separate-process fixtures** + +`x402-v2-seller-process.mjs` starts a deterministic x402 v2 resource on +`127.0.0.1` and reports its assigned port over IPC. It has routes that simulate +settled success, settled HTTP 302/404/500, valid settlement followed by 2xx body loss, pre-header +paid-response loss, explicit rejection, malformed settlement, delayed response, and +refund observation. Its paid model route forwards +only the ordinary body to `pi-model-process`; its paid Skill route returns the scripted +tool result. It validates one exact `PAYMENT-SIGNATURE` per attempt and keeps counted +state in a supplied owner-only file. Its fixed evidence endpoint returns deterministic +domain-separated execution/refund attestations signed by a test-only derived seller +account matching the fixture PolicyVersion. The refund fixture also exposes a public +payment/refund transaction IDs plus separate RPC-shaped transfer proofs; tests mutate the +operator-supplied ID, each attestation, signature, and every observed chain field +independently and never treat local intent/quote hashes as RPC facts. + +`pi-model-process.mjs` implements the minimal local OpenAI-compatible streaming API +needed by the pinned Pi binary. The first deterministic response requests the fixed +`invoke_skill` tool once; the second summarizes the tool result and emits +`PI_WALLET_OK`. The Pi extension registers provider `wallet-kernel-e2e`, model +`scripted-local`, and points that provider through the Kernel's fixed +`/agent/v1/openai/example-model` route. Thus both model turns and the tool request are +ordinary Pi HTTP traffic governed by the Wallet Kernel. The model fixture makes no +network call. + +`loopback-only-preload.cjs` wraps Node socket/DNS entry points and throws +`EXTERNAL_EGRESS_FORBIDDEN` for any destination other than `127.0.0.1`, `::1`, or +`localhost`. Apply it through `NODE_OPTIONS=--require=` to Pi and every +fixture process; record attempted destinations without credentials and assert the log +is empty. + +`control-plane-process.mjs` is the only Kernel child entrypoint. It invokes +`startControlPlane()` in deterministic mode against supplied owner-only authority, +policy, route, and already-enrolled non-secret agent identity, with test-only injected listen overrides for +ephemeral loopback ports. After authority locking, recovery, session resumption, and +both listeners are ready, send one closed IPC message +`{ type: 'ready', agentOrigin, operatorOrigin, walletAddress, receiptPublicKey }`. +Never send the operator/agent token, store handle, environment, signed bytes, or +provider errors. `{ type: 'shutdown' }`, SIGINT, and SIGTERM perform bounded graceful +close; startup failure sends `{ type: 'fatal', code }` and exits `1`. Ephemeral-port +overrides exist only through injected deterministic test dependencies, never +`cdp-testnet` environment configuration. + +Create `scripts/lib/spend-control-process-runner.mjs` with this reusable API: + +```text +runSpendControlProcessAcceptance({ + authorityDirectory, + piExecutable, + nodeExecutable = process.execPath, +}) -> Promise<{ summary, evidenceInput, cleanup }> +``` + +The caller supplies an empty `0700` authority directory and owns its eventual removal. +The runner creates the Pi credential and enrollment descriptor through the real Pi-side +helper, imports only the descriptor through the real offline bootstrap path, and never +passes the credential path/token to the control-plane child. Because the process suite +runs as one CI UID, it injects the deterministic-only same-identity allowance and +records `isolation: simulated`; this suite cannot produce live-isolation evidence. The +runner writes closed `0600` policy/route/config inputs, starts model, seller, +control-plane, and Pi children with IPC readiness and deadlines, drives all acceptance +scenarios through agent/operator HTTP, restarts the control-plane child over the same +SQLite authority, and obtains the final sanitized projection, normalized events, +receipt envelopes, and public keys before shutdown. It always terminates children in +`finally`; returned `cleanup()` is idempotent and removes only runner-owned temporary +process artifacts, not the caller's directory. Child environments are allowlisted and +use the loopback preload. `evidenceInput` contains no absolute paths, request/response +bodies, payment payload/header, tokens, credentials, or provider errors. + +`pi-client-process.mjs` launches the repository-local binary only: + +```js +const piBin = path.resolve('node_modules/.bin/pi'); +spawn(piBin, [ + '-p', scriptedPrompt, + '--no-session', + '--no-context-files', + '--no-skills', + '--no-prompt-templates', + '--no-themes', + '--no-extensions', + '--no-builtin-tools', + '--no-approve', + '--offline', + '-e', path.resolve('pi-extension/x402.ts'), + '--provider', 'wallet-kernel-e2e', + '--model', 'scripted-local', +], { + cwd: path.resolve('.'), + env: { + ...minimalEnvironment, + PI_OFFLINE: '1', + PI_CODING_AGENT_DIR: temporaryPiDirectory, + WALLET_KERNEL_ORIGIN: `http://127.0.0.1:${kernelPort}`, + WALLET_KERNEL_AGENT_CREDENTIAL_FILE: agentCredentialFile, + WALLET_KERNEL_PROVIDER_NAME: 'wallet-kernel-e2e', + WALLET_KERNEL_MODEL_NAME: 'scripted-local', + WALLET_KERNEL_MODEL_ROUTE: 'example-model', + WALLET_KERNEL_SKILL_ROUTE: 'example-skill', + NODE_OPTIONS: `--require=${loopbackOnlyPreload}`, + }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +``` + +Resolve `piBin` from `spikes/pi-wielder`; assert `pi --version` is exactly `0.80.6` +before the test. Pass an allowlisted minimal environment, not `process.env`, so CDP, +wallet, and unrelated developer credentials cannot leak into fixtures. +Give every child a 30-second total deadline. On expiry send `SIGTERM`, wait at most two +seconds, then `SIGKILL` the isolated process group and fail with `PROCESS_DEADLINE`. + +- [ ] **Step 3: Write process-level commercial acceptance tests** + +In `spend-control-process-e2e.test.mjs`, create a temporary `0700` authority directory, +call `runSpendControlProcessAcceptance()` once, independently inspect its persisted +outcomes and `evidenceInput`, and remove only the caller-owned directory in `t.after()`. +The shared runner spawns seller, Kernel/operator, scripted model, and real pinned Pi as +separate children on dynamically assigned loopback ports. Drive and assert: + +```text +1. policy-allowed exact payment settles once and returns a verifiable receipt +2. untrusted seller and over-budget request deny before signer call +3. approval-needed survives Kernel restart; operator approves; exact Pi retry settles +4. operator denial and approval expiry never sign +5. a changed challenge after approval terminalizes the old approval and never signs it +6. table-driven settled HTTP 302/404/500 commits spend, opens refund-pending, never follows redirect, and blocks new wallet spend +7. valid settlement followed by body loss opens execution reconciliation; a signed execution attestation resolves once and revises the receipt +8. pre-settlement paid-response loss holds budget, blocks the wallet, and never blindly retries +9. a second 402 or `PAYMENT-RESPONSE success:false` after signature is unresolved, never treated as rejection +10. operator-named trusted settlement reconciliation commits once but remains execution-blocked until a signed seller execution attestation resolves and revises the receipt +11. a confirmed wrong refund candidate becomes rejected without release; a freshly confirmed operator-named, seller-attested, RPC-confirmed full refund then releases once and revises the receipt +12. fresh process verifies SQLite event chain, projection, and every receipt +13. Pi request contains none of the forbidden authority headers +14. every child records zero non-loopback egress attempts +15. unauthenticated/wrong-agent local calls fail before body read or signer invocation +16. the same agent credential reattaches to the same Spend Session after Kernel restart +17. tighter policy apply blocks old session immediately and guarded transition rebinds it +18. enrollment revocation rejects the old token; crash/restart with zero active enrollment enters signer-free recovery-only mode so the operator can reconcile and guarded-close the retained session; confirmed replacement enrollment plus fresh isolation attestation and clean restart create one fresh same-policy session without resetting wallet history +``` + +Assert exact process exit codes, HTTP counts, signer counts, transaction uniqueness, +and persisted states—not just console text. Kill children in `t.after()` and retain +stdout/stderr only on failure with secret redaction. + +- [ ] **Step 4: Create the one-command offline runner** + +`spend-control-e2e.mjs` creates a fresh `0700` temporary authority directory, calls +`runSpendControlProcessAcceptance()` directly (never shells out to `node --test`), +prints only `result.summary`, invokes cleanup, and removes the directory in `finally`. +It exits nonzero unless every invariant passes: + +```json +{ + "mode": "offline-deterministic", + "piVersion": "0.80.6", + "x402Version": 2, + "network": "eip155:84532", + "isolation": "simulated", + "tests": 18, + "passed": 18, + "liveCdp": "not-run", + "testnetTransaction": "not-run" +} +``` + +Do not label the output “on-chain”; it is protocol-shaped deterministic evidence. + +- [ ] **Step 5: Run focused and full offline acceptance** + +```bash +npm run test:kernel --prefix spikes/pi-wielder +npm run e2e:spend-control --prefix spikes/pi-wielder +npm run e2e --prefix spikes/pi-wielder +npm test --prefix spikes/pi-wielder +npm test --prefix prototype +``` + +Expected: all new tests pass; the legacy baseline remains at least 237/237 unit, +41/41 offline e2e, and 23/23 prototype. Loopback tests require an environment that +permits `127.0.0.1` listeners. + +- [ ] **Step 6: Commit process acceptance** + +```bash +git add spikes/pi-wielder/package.json \ + spikes/pi-wielder/tests/fixtures/x402-v2-seller-process.mjs \ + spikes/pi-wielder/tests/fixtures/pi-model-process.mjs \ + spikes/pi-wielder/tests/fixtures/pi-client-process.mjs \ + spikes/pi-wielder/tests/fixtures/control-plane-process.mjs \ + spikes/pi-wielder/tests/fixtures/loopback-only-preload.cjs \ + spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs \ + spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs \ + spikes/pi-wielder/spend-control-e2e.mjs +git commit -m "test: prove wallet kernel across real processes" +``` + +### Task 16: Produce recomputable evidence, operating docs, and the release handoff + +**Files:** + +- Create: `spikes/pi-wielder/src/evidence-bundle.mjs` +- Create: `spikes/pi-wielder/scripts/run-evidence.mjs` +- Create: `spikes/pi-wielder/scripts/run-testnet-agent.mjs` +- Create: `spikes/pi-wielder/scripts/verify-evidence.mjs` +- Create: `spikes/pi-wielder/scripts/verify-no-tracked-secrets.mjs` +- Create: `spikes/pi-wielder/tests/evidence-bundle.test.mjs` +- Create: `spikes/pi-wielder/tests/testnet-agent-runner.test.mjs` +- Create: `spikes/pi-wielder/tests/no-tracked-secrets.test.mjs` +- Modify: `spikes/pi-wielder/package.json` +- Modify: `spikes/pi-wielder/README.md` +- Modify: `spikes/pi-wielder/RUNBOOK.md` +- Modify: `spikes/pi-wielder/.env.example` +- Create: `docs/handoffs/2026-07-31-agent-spend-control-release-handoff.md` + +- [ ] **Step 1: Write failing evidence-bundle tests** + +Generate an offline bundle in a temporary directory containing exactly: + +```text +manifest.json +events.jsonl +summary.json +report.md +README.md +``` + +The manifest’s closed schema is: + +```js +{ + schemaVersion: 1, + createdAt, + mode: 'offline-deterministic' | 'base-sepolia-testnet', + git: { commit, dirty }, + runtime: { nodeVersion, piVersion }, + protocol: { x402Version: 2, network: 'eip155:84532', asset: BASE_SEPOLIA_USDC }, + wallet: { provider, walletIdHash, address }, + isolation: { + status: 'simulated' | 'enforced', + preflightDigest: null | 'sha256:<64 lowercase hex>', + kernelIdentityHash: 'sha256:<64 lowercase hex>', + agentIdentityHash: 'sha256:<64 lowercase hex>', + }, + deployment: { + status: 'simulated' | 'enforced', + releaseManifestDigest: null | 'sha256:<64 lowercase hex>', + releaseTreeHash: null | 'sha256:<64 lowercase hex>', + }, + inputs: { policyHash, routeMapHash, configHash }, + source: { + authorityEventHeadHash, + signedProjectionHash, + receiptKeys: [{ keyId, algorithm: 'Ed25519', publicKeyPem }], + }, + files: [{ path, sha256, bytes }], + status: { liveCdp, walletFunded, testnetTransaction }, +} +``` + +`files` contains exactly `events.jsonl`, `summary.json`, `report.md`, and `README.md`. +It deliberately excludes `manifest.json`, avoiding a self-referential hash. The +builder returns the manifest's own SHA-256 as an external trust-anchor value; the +verifier must receive that expected value from outside the bundle. + +Assert `verifyEvidenceBundle(directory, { expectedManifestSha256 })` first hashes the +exact manifest bytes and fails unless they match the required external digest, then +recomputes every listed file hash, normalized +event count, decision count, amount total, transaction uniqueness, receipt signature, +receipt revision link, and normalized evidence-chain head from `events.jsonl`; it never +trusts `summary.json`. It verifies the signed projection in `summary.json` against the +manifest public keys and proves its authority-event-head anchor equals +`source.authorityEventHeadHash`. It does not claim to recompute the private SQLite +event chain from redacted evidence. Mutate each file independently and assert +verification fails. Also mutate a manifest-only field, substitute a new receipt public +key/signature pair, and replace all files plus recomputed embedded hashes; each must +still fail against the original expected manifest digest. Missing or malformed +`expectedManifestSha256` always fails closed. +The verifier requires `offline-deterministic -> isolation.status = simulated` with a +null digest and `base-sepolia-testnet -> isolation.status = enforced` with a valid +unexpired imported preflight digest. Identity hashes are domain-separated hashes over +the pinned UID/GID pair, never raw local identity/path values. No offline bundle may +claim enforced isolation. The same mode relation applies to `deployment`: offline has +two null hashes, while testnet must match the root-owned release manifest/tree hashes +bound into the imported privileged report. + +Recursively scan the entire bundle for raw bodies, prompts, responses, payment +signatures/payloads, agent credentials, operator token/raw identity, provider exceptions, and +absolute paths. Synthetic tests write only under the test temporary directory and +never modify committed evidence. + +- [ ] **Step 2: Run the test and observe missing modules** + +```bash +node --test spikes/pi-wielder/tests/evidence-bundle.test.mjs \ + spikes/pi-wielder/tests/testnet-agent-runner.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement build and independent verification** + +Create `evidence-bundle.mjs`: + +```js +export function buildEvidenceBundle({ outputDirectory, manifestInput, events, receipts }) {} +export function verifyEvidenceBundle(outputDirectory, { expectedManifestSha256 }) {} +``` + +Use canonical JSON plus newline for JSON files and one closed sanitized event per JSONL +line. Each normalized event contains only sequence, event type, entity hash, decision, +canonical atomic amount when relevant, public transaction ID when settled, receipt +hash/signature, and its own normalized predecessor/event hashes. It never contains the +authoritative `data_json`, payment payload/header, raw request/response, or local path. +Sort events by durable sequence and file entries by path. Generate summary/report from +normalized events. Verification replays that normalized evidence chain and verifies +the signed authority projection and receipts with manifest public keys; it does not +open SQLite or call a network. + +Import the exact `runSpendControlProcessAcceptance()` runner created in Task 15; do not +fork a second orchestration path. Offline `run-evidence.mjs` creates the caller-owned +`0700` authority directory, calls the runner once, builds from its in-memory signed +projection, normalized events, receipt envelopes, public keys, and runtime summary, +and finishes writing/re-verifying before calling `cleanup()` and removing the authority +directory. A `finally` path still stops child processes, but temporary authority cannot +be removed while evidence construction needs it. +`buildEvidenceBundle()` returns `{ manifestSha256 }` after fsyncing the files and parent +directory. `run-evidence.mjs` passes that in-memory value directly into the verifier +before cleanup, prints it in its final canonical result, and writes the digest plus +newline to a required `--anchor-output` owner-only exclusive-create file outside the +bundle directory. It fsyncs that file and its parent and never reloads an "expected" +digest from the bundle it is verifying. Refuse an anchor path inside the bundle, a +symlink, or an overwrite. + +Create `run-evidence.mjs` with explicit `--mode offline-deterministic` and +`--mode base-sepolia-testnet`. Offline mode invokes the deterministic process runner. +Testnet mode must enforce all of these gates before constructing a real adapter: + +```text +attested root-owned release manifest/tree re-verifies and supplies exact commit/hash +explicit new output directory under configured Kernel-owned `evidenceRoot` as + YYYY-MM-DD-agent-spend-control-RUN_ID/ and it does not exist +validated public configuration network equals eip155:84532 +active policy wallet equals CDP wallet +operator preflight green +imported agent-isolation preflight is unexpired, metadata-matched, and `enforced` +`baseSepoliaObserver.fundingStatus({ walletAddress, +requiredAtomic: runIntent.maximumTotalAtomic })` reports `sufficient` at a recorded block +--confirm-sha256 equals SHA-256 of canonical run intent +``` + +For testnet, `manifest.git.commit` comes from the attested release manifest and +`git.dirty: false` means the installed tree exactly reverified against that manifest; +the live command never runs `git status` in a checkout. Offline/developer evidence +still records the actual Git worktree state. + +The run intent contains release manifest/tree hashes, commit, wallet address, policy hash, route hash, maximum total +atomic amount, exact seller routes, and expiry. Print its digest and exit `2` without +the exact human-provided confirmation. Never request faucet funds, transfer funds, +select mainnet, overwrite an evidence directory, or infer authorization from an +environment variable. Do not execute testnet mode during implementation. + +Testnet evidence preserves OS separation. The Kernel-side command never reads or +spawns with the Pi credential. After confirmation it exclusive-creates a public, +bounded synthetic agent-run descriptor in the Kernel-owned `0755` +`agentRunOutboxPath`, mode `0644`, using no-follow/no-overwrite single-FD checks, and +waits on a +bounded authority-event completion condition. The human separately runs +`run-testnet-agent.mjs --run-intent FILE --confirm-sha256 DIGEST` under the enrolled Pi +UID/GID; that script validates the descriptor, its own UID/GID and `0600` credential, +opens the run descriptor once with `O_RDONLY | O_NOFOLLOW`, and verifies the exact +Kernel owner/mode/hash before use, +then drives only the listed ordinary agent routes. It has no operator token/CDP +environment and emits no credential. `testnet-agent-runner.test.mjs` proves the split +with fake loopback services, wrong-identity/hash/parent-mode rejection, Pi write/rename +denial in the outbox, Kernel write/rename denial in the Pi-owned enrollment inbox, and +zero Kernel-side credential reads. Timeout leaves the evidence run `not-run`/incomplete and does not +weaken the spend ceiling. + +Observer preflight, funding availability, and the human digest gate must all pass +before any intent can reach the signer. An insufficient/unavailable balance observation +exits without signing. The observer has no mutation/funding method, and the command +never faucets, funds, transfers, or silently lowers the run-intent maximum. + +Only testnet mode writes under the configured Kernel-owned `0700` `evidenceRoot` +outside the immutable release, and it records normalized per-request evidence plus Base +Sepolia transaction links. The Pi UID must receive `EACCES` there. Offline mode +requires an explicit temporary output path and may not write into or replace any +existing evidence directory. Copying a verified sanitized testnet bundle into the +repository is a separate human-reviewed release step: verify against the out-of-band +anchor first, copy to a new immutable path, and commit from a developer checkout. The +live process never writes into its source/release tree. + +- [ ] **Step 4: Add evidence scripts** + +Add: + +```json +{ + "scripts": { + "evidence:offline": "node scripts/run-evidence.mjs --mode offline-deterministic", + "evidence:verify": "node scripts/verify-evidence.mjs", + "verify:no-secrets": "node scripts/verify-no-tracked-secrets.mjs" + } +} +``` + +`verify-evidence.mjs` requires one directory argument plus +`--expect-manifest-sha256 <64-lowercase-hex>`, prints canonical JSON, and exits `0` +only when the external anchor and all recomputation succeed. Public verification must +obtain that digest from the release handoff, signed release metadata, or another +out-of-band channel—never from a file inside the bundle being verified. + +`verify-no-tracked-secrets.mjs` obtains paths with `git ls-files -z`, rejects tracked +authority/key/token/agent-credential/local-enrollment filenames and common private-key encodings, then compares every +configured environment secret value of at least eight bytes against tracked file bytes. +It also safely reads the configured owner-only receipt-key, operator-token, and +CDP secret values available to the Kernel identity and performs the same comparison. +It must not read the Pi-owned agent credential, which the Kernel UID is required to be +unable to access. An optional separate `--agent-credential FILE` Pi-side invocation +runs under the Pi identity and scans that one token against tracked bytes without +granting the Kernel access. It prints +only the environment variable name and offending path—never the candidate value—and +exits nonzero on a match. `no-tracked-secrets.test.mjs` uses synthetic marker values to +prove exact, multiline, and base64-like values fail while variable names in example +files pass. + +- [ ] **Step 5: Update the runbook and package README honestly** + +Document: + +- supported POSIX host requirements, Node 24.15+ for deterministic development, + exact Node 24.15.0 for the attested live release, and `npm ci`; +- privileged install from a clean commit into the root-owned immutable release tree, + release-manifest creation/verification, service-manager prelaunch, forbidden loader + environment, and separate Kernel-writable data/evidence roots; +- owner-only authority directory creation outside the checkout; +- distinct non-root Kernel/Pi UID provisioning, pinned GID, cleared supplementary + groups, isolation probe/attestation, and the same-UID live startup refusal; +- policy and route validation before start; +- deterministic offline startup and one-command acceptance; +- CDP credential provisioning without values or shell-history examples; +- customer-owned wallet identity check and human-only Base Sepolia funding; +- Pi-owned credential creation, non-secret enrollment handoff/import, revocation, + safe replacement, and restart-stable session binding; +- the two directional handoff parents and wrong-direction write denial; +- the exact clean bootstrap and replacement order from Task 13, including fresh + isolation-attestation import before each live start; +- operator token location/mode, live Unix admin CLI, root socket-activated loopback + console, one-time `console launch` flow, and deterministic fallback; +- approval, denial, expiry, reconciliation, and full-refund observation procedures; +- backup/restore as an offline SQLite file operation with integrity verification; +- incident response for unresolved signing/payment, execution-evidence, and + seller-attested/on-chain refund states; +- exact shutdown/restart behavior; +- fresh evidence generation under the external Kernel evidence root, verification, + out-of-band anchor handling, optional human-reviewed repo copy, and + immutable-directory policy; +- an explicit statement that mainnet, custody, hosted policy authority, automated + funding, live CDP payment, and public website claims are out of scope. + +Keep current results labeled `measured offline` and live CDP/Base Sepolia labeled +`not-run` until a human authorizes and runs the testnet command. + +- [ ] **Step 6: Commit evidence tooling and operating documentation** + +Commit implementation/docs before generating release evidence so the tested commit is +clean and non-self-referential: + +```bash +git add spikes/pi-wielder/src/evidence-bundle.mjs \ + spikes/pi-wielder/scripts/run-evidence.mjs \ + spikes/pi-wielder/scripts/run-testnet-agent.mjs \ + spikes/pi-wielder/scripts/verify-evidence.mjs \ + spikes/pi-wielder/scripts/verify-no-tracked-secrets.mjs \ + spikes/pi-wielder/tests/evidence-bundle.test.mjs \ + spikes/pi-wielder/tests/testnet-agent-runner.test.mjs \ + spikes/pi-wielder/tests/no-tracked-secrets.test.mjs \ + spikes/pi-wielder/package.json \ + spikes/pi-wielder/README.md \ + spikes/pi-wielder/RUNBOOK.md \ + spikes/pi-wielder/.env.example +git commit -m "feat: add wallet kernel evidence pipeline" +``` + +- [ ] **Step 7: Request an independent code and security review** + +Use `superpowers:requesting-code-review` against `07c3549..HEAD`. Require the reviewer +to check the approved design section-by-section, especially permit forgery, +persist-before-retry, crash ambiguity, local auth, testnet gating, receipt redaction, +x402 interoperability, and preservation of v1 regressions. Fix every Critical or +Important finding, commit the fixes, and repeat review until no such finding remains. + +- [ ] **Step 8: Run the complete clean-commit verification story** + +Run from repo root: + +```bash +git status --short +git rev-parse HEAD +npm run verify:spend-control --prefix spikes/pi-wielder +evidence_parent="$(mktemp -d /tmp/pi-wielder-evidence.XXXXXX)" +npm run evidence:offline --prefix spikes/pi-wielder -- \ + --output "$evidence_parent/bundle" \ + --anchor-output "$evidence_parent/manifest.sha256" +manifest_sha256="$(tr -d '\n' < "$evidence_parent/manifest.sha256")" +npm run evidence:verify --prefix spikes/pi-wielder -- "$evidence_parent/bundle" \ + --expect-manifest-sha256 "$manifest_sha256" +npm run verify:no-secrets --prefix spikes/pi-wielder +npm test --prefix prototype +git diff --check +git status --short +git diff --exit-code HEAD -- CONTEXT.md docs/PRD.md docs/adr +git diff --exit-code 07c3549..HEAD -- CONTEXT.md docs/PRD.md docs/adr +``` + +Expected: + +- new and legacy test suites pass; +- evidence verification reports `valid: true`, `mode: offline-deterministic`, and + live/testnet status `not-run`, while the release handoff records the exact + `manifest_sha256` as the bundle's external trust anchor; +- protected corpus diff is empty; +- fail-closed tracked-secret verification passes without echoing candidates; +- `docs/superpowers/plans/marketing-assets/` remains untracked and untouched. + +The initial and final `git status --short` must be empty in the isolated implementation +worktree. Record the printed `git rev-parse HEAD` as `TESTED_IMPLEMENTATION_COMMIT`. +The evidence builder itself must continue to reject overwrite. + +- [ ] **Step 9: Write the release handoff against the tested commit** + +Only after Step 8 passes, create +`docs/handoffs/2026-07-31-agent-spend-control-release-handoff.md` with: + +```text +branch and TESTED_IMPLEMENTATION_COMMIT from Step 8 +scope delivered +automated command/result table +temporary offline evidence path, manifest hash, and verification result +live CDP and testnet status +agent isolation status and preflight digest (`simulated` is not live-ready) +deployment status, release-manifest/tree hashes, and socket-activation status +known limitations and unresolved records +agent-doable follow-ups +human-only CDP credential, wallet funding, testnet authorization, and commercialization items +website gate: no reframe until fresh qualifying testnet evidence exists +historical n=48 quarantine remains in force +``` + +The handoff explicitly calls its hash the tested implementation commit, not the future +handoff commit. Do not edit `CONTEXT.md`, `docs/PRD.md`, or `docs/adr/`; propose any +durable doctrine change in the handoff instead. Preserve corpus language: Pi is the +Wielder; Agent and Operator are local control-plane security roles. + +- [ ] **Step 10: Commit only the release handoff** + +```bash +git add docs/handoffs/2026-07-31-agent-spend-control-release-handoff.md +git commit -m "docs: hand off wallet kernel pilot" +git status --short +``` + +Expected: the handoff commit succeeds and the isolated implementation worktree is +clean. Report its final commit hash externally; do not amend the handoff to refer to +itself. + +## Approved-design coverage map + +| Approved design area | Implemented and proved in | +|---|---| +| Roles, distinct-identity isolation, enrollment/revocation, and agent/operator separation | Tasks 2, 4, 10, 12–16 | +| Canonical records and durable SQLite journal | Tasks 1, 2, 4–7, 10, 11 | +| Pure policy and all four budget ceilings | Tasks 3, 5 | +| Exact one-time approval and AuthorizedPermit | Tasks 6, 8, 10 | +| x402 v2 `exact`, Base Sepolia, CDP customer wallet | Tasks 8, 9, 12 | +| Persist-before-retry lifecycle and separate execution state | Tasks 9, 10 | +| Crash ambiguity, operator-named candidates, execution/refund reconciliation, receipt revision | Tasks 5, 7, 10, 11, 15 | +| Pi experience, Unix admin CLI, socket-activated loopback console, and customer-hosted operation | Tasks 13–15 | +| Privacy, filesystem security, redaction, and no hosted write path | Tasks 2, 7–14, 16 | +| Offline proof, real pinned Pi, and human-gated testnet evidence | Tasks 1, 8–10, 12, 15, 16 | +| Commercial pilot boundary and website-after-evidence gate | Task 16 and the completion boundary below | + +## Completion boundary + +This plan is complete only when the offline product slice passes through the pinned Pi +binary in separate processes, recovery invariants pass at every monetary crash point, +and a recomputable sanitized offline evidence bundle verifies. That proves the +customer-hosted spending-control product path; it does **not** prove live CDP signing, +testnet settlement, a particular host's enforced UID/container isolation, production +readiness, compliance readiness, or market demand. It also does not prove a live host +until that host's root-owned release/prelaunch and socket-activation attestations pass. +The deterministic bundle must say +`isolation: simulated`; live admission/evidence remains blocked until the separate +host probe and imported attestation pass. + +The next human-authorized milestone is one bounded Base Sepolia evidence run using the +customer’s CDP wallet and test USDC. Only after that fresh bundle verifies should a +separate plan reframe the public README/site around the commercial Wallet Kernel. That +future public work must keep the historical `n=48` claim quarantined and may expose +read-only sanitized evidence only—never the operator authority or spending controls. From 0bd9d7540ccacfb488bae549a730fe66609e99b9 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 31 Jul 2026 22:51:13 -0400 Subject: [PATCH 148/165] docs: harden agent spend control plan --- .../2026-07-31-agent-spend-control-plane.md | 1247 +++++++++++++---- 1 file changed, 994 insertions(+), 253 deletions(-) diff --git a/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md b/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md index 7385796..6619dd9 100644 --- a/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md +++ b/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md @@ -6,8 +6,8 @@ **Architecture:** Build the new buyer control plane alongside the verified x402 v1 spike, using a local SQLite authority for policies, Spend Sessions, Spend Intents, budgets, approvals, payment attempts, outcomes, reconciliation, and signed receipts. A pure Policy Engine and one-time `AuthorizedPermit` capability sit in front of provider-neutral wallet adapters; a custom x402 v2 transport preserves the required persist-before-retry boundary that an automatic fetch wrapper cannot expose. After offline parity, cut the standalone Pi path over to the new loopback proxy while retaining the old Collar and payment suites as regression oracles. -**Tech Stack:** Node.js 24.15+ for deterministic development and exact Node.js -24.15.0 for the attested `cdp-testnet` release, ECMAScript modules, built-in +**Tech Stack:** Node.js 24.18.1+ for deterministic development and exact Node.js +24.18.1 for the attested `cdp-testnet` release, ECMAScript modules, built-in `node:sqlite`, Hono, built-in `node:test`, Ed25519/SHA-256 from `node:crypto`, `@coinbase/cdp-sdk` 1.54.0, `@x402/core` 2.19.0, `@x402/evm` 2.19.0, viem, Pi 0.80.6; Base Sepolia and test USDC only. @@ -95,6 +95,9 @@ prelaunch contract before it can claim `cdp-testnet` support. canonical JSON, hashes, identifiers, timestamps, and atomic-USDC strings. - `spikes/pi-wielder/src/kernel/secure-storage.mjs` — absolute path, owner, mode, symlink, database, WAL, SHM, key, and token checks. +- `spikes/pi-wielder/src/kernel/trusted-path.mjs` — Linux live-mode descriptor walk + from the configured trusted ancestor, closed ownership policies, and stable + ancestor-chain metadata hashing. - `spikes/pi-wielder/src/kernel/authority-lock.mjs` — crash-released, process-lifetime single-writer exclusion shared by the daemon and offline bootstrap commands. - `spikes/pi-wielder/src/kernel/release-integrity.mjs` — closed privileged deployment @@ -119,6 +122,8 @@ prelaunch contract before it can claim `cdp-testnet` support. denials, expiry, and pending-cap enforcement. - `spikes/pi-wielder/src/kernel/authorized-permit.mjs` — in-process, one-time, unforgeable signing capabilities. +- `spikes/pi-wielder/src/kernel/authority-mutation-coordinator.mjs` — one shared + in-process FIFO lease for every live authority mutation and terminal receipt gap. - `spikes/pi-wielder/src/kernel/receipt-signing.mjs` — generic Ed25519 receipt primitives extracted without changing seller-journal behavior. - `spikes/pi-wielder/src/kernel/signed-receipts.mjs` — terminal buyer receipt @@ -150,7 +155,8 @@ prelaunch contract before it can claim `cdp-testnet` support. approval, denial, unresolved, and receipt responses. - `spikes/pi-wielder/src/operator/auth.mjs` — owner-only token and authenticated local session handling. -- `spikes/pi-wielder/src/operator/api.mjs` — loopback operator API. +- `spikes/pi-wielder/src/operator/api.mjs` — narrow channel-aware operator API for + the Unix admin transport and authenticated socket-activated browser session. - `spikes/pi-wielder/src/operator/cli.mjs` — preflight, policy, approval, receipt, isolation/enrollment, session, reconciliation, and export commands. - `spikes/pi-wielder/src/operator/console.mjs` — local static console server. @@ -161,8 +167,15 @@ prelaunch contract before it can claim `cdp-testnet` support. mode. - `spikes/pi-wielder/scripts/build-release-manifest.mjs` — privileged, exclusive manifest creation for a root-owned installed release. +- `spikes/pi-wielder/scripts/render-systemd-units.mjs` — closed privileged rendering + of exact-path service/socket templates for the installed Linux release. +- `spikes/pi-wielder/scripts/inspect-systemd-effective.mjs` — bounded PID1 + introspection and canonical effective service/socket projection after daemon reload. - `spikes/pi-wielder/scripts/preflight-live-deployment.mjs` — root/service-manager prelaunch verification before dropping to the Kernel UID. +- `spikes/pi-wielder/scripts/prelaunch-kernel-reader.mjs` — minimal privileged + trampoline that drops all groups/UID before dynamically loading the bounded + read-only authority/report checker. - `spikes/pi-wielder/deploy/systemd/wallet-kernel.service` and `wallet-kernel-console.socket` — hardened live-pilot service/socket activation units. - `spikes/pi-wielder/src/agent/auth.mjs` — digest-only active-enrollment auth and @@ -172,8 +185,9 @@ prelaunch contract before it can claim `cdp-testnet` support. and Receipts shell. - `spikes/pi-wielder/operator-console/app.mjs` — authenticated local API client. - `spikes/pi-wielder/operator-console/styles.css` — self-contained local styles. -- `spikes/pi-wielder/src/control-plane.mjs` — environment construction and loopback - process entrypoint. +- `spikes/pi-wielder/src/control-plane.mjs` — environment construction and + channel-aware process entrypoint for Unix admin, inherited console, and agent + loopback transports. - `spikes/pi-wielder/src/config.mjs` — closed environment, route-map, policy-file, testnet-mode, and secret-presence validation without secret serialization. @@ -188,6 +202,8 @@ Keep tests flat under `spikes/pi-wielder/tests/` so the existing Create reusable fixtures in `spikes/pi-wielder/tests/fixtures/`, a new `spikes/pi-wielder/spend-control-e2e.mjs`, and evidence builder/verifier scripts under `spikes/pi-wielder/scripts/`. Existing evidence directories are immutable. +Create `.github/workflows/pi-wielder-systemd.yml` for the mandatory secret-free Linux +service/socket integration gate. ### Modified files @@ -372,12 +388,12 @@ From `spikes/pi-wielder`, run: ```bash npm install --save-exact @coinbase/cdp-sdk@1.54.0 @x402/core@2.19.0 @x402/evm@2.19.0 npm install --save-dev --save-exact @earendil-works/pi-coding-agent@0.80.6 -npm pkg set engines.node=">=24.15.0" +npm pkg set engines.node=">=24.18.1" ``` Expected: `package.json` and `package-lock.json` contain exact dependency versions, the existing Hono/viem dependencies remain present, and the live release procedure -later pins the attested Node executable to exactly `v24.15.0`. +later pins the attested Node executable to exactly `v24.18.1`. - [ ] **Step 4: Implement canonical values and stable errors** @@ -531,10 +547,12 @@ git commit -m "build: pin wallet kernel runtime" - Modify: `.gitignore` - Modify: `spikes/pi-wielder/.env.example:1-62` - Create: `spikes/pi-wielder/src/kernel/secure-storage.mjs` +- Create: `spikes/pi-wielder/src/kernel/trusted-path.mjs` - Create: `spikes/pi-wielder/src/kernel/authority-lock.mjs` - Create: `spikes/pi-wielder/src/kernel/sqlite-schema.mjs` - Create: `spikes/pi-wielder/src/kernel/sqlite-store.mjs` - Create: `spikes/pi-wielder/tests/kernel-store.test.mjs` +- Create: `spikes/pi-wielder/tests/kernel-trusted-path.test.mjs` - Create: `spikes/pi-wielder/tests/kernel-authority-lock.test.mjs` - Create: `spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs` - Create: `spikes/pi-wielder/tests/fixtures/kernel-lock-worker.mjs` @@ -562,6 +580,7 @@ Append to `spikes/pi-wielder/.env.example`: WALLET_KERNEL_DB_FILE= WALLET_KERNEL_RECEIPT_KEY_FILE= WALLET_KERNEL_OPERATOR_TOKEN_FILE= +WALLET_KERNEL_TRUSTED_ANCESTOR= WALLET_KERNEL_EXPECTED_AGENT_UID= WALLET_KERNEL_EXPECTED_AGENT_GID= WALLET_KERNEL_POLICY_FILE= @@ -589,12 +608,18 @@ import { readPrivateInputFile } from '../src/kernel/secure-storage.mjs'; function authority() { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-')); fs.chmodSync(directory, 0o700); - return { directory, databasePath: path.join(directory, 'kernel.sqlite') }; + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); + return { directory, databasePath: path.join(directory, 'kernel.sqlite'), pathTrust }; } test('persistent store enables WAL, FULL sync, foreign keys, and schema v1', () => { - const { databasePath } = authority(); - const store = openKernelStore({ filePath: databasePath }); + const { databasePath, pathTrust } = authority(); + const store = openKernelStore({ filePath: databasePath, pathTrust }); assert.equal(store.pragma('journal_mode'), 'wal'); assert.equal(store.pragma('synchronous'), 2); assert.equal(store.pragma('foreign_keys'), 1); @@ -608,37 +633,39 @@ test('persistent store enables WAL, FULL sync, foreign keys, and schema v1', () }); test('persistent store rejects checkout, symlink, permissive, and wrong-owner-like paths', () => { - assert.throws(() => openKernelStore({ filePath: path.resolve('spikes/pi-wielder/kernel.sqlite') }), + const { directory, databasePath, pathTrust } = authority(); + assert.throws(() => openKernelStore({ + filePath: path.resolve('spikes/pi-wielder/kernel.sqlite'), pathTrust, + }), /outside the checkout/); - const { directory, databasePath } = authority(); const target = path.join(directory, 'target.sqlite'); fs.writeFileSync(target, '', { mode: 0o600 }); fs.symlinkSync(target, databasePath); - assert.throws(() => openKernelStore({ filePath: databasePath }), /symlink/); + assert.throws(() => openKernelStore({ filePath: databasePath, pathTrust }), /symlink/); fs.unlinkSync(databasePath); fs.chmodSync(directory, 0o755); - assert.throws(() => openKernelStore({ filePath: databasePath }), /owner-only/); + assert.throws(() => openKernelStore({ filePath: databasePath, pathTrust }), /owner-only/); }); test('pre-existing SQLite sidecars fail closed instead of being chmod-repaired', () => { for (const suffix of ['-wal', '-shm']) { - const { directory, databasePath } = authority(); + const { directory, databasePath, pathTrust } = authority(); fs.writeFileSync(`${databasePath}${suffix}`, '', { mode: 0o644 }); - assert.throws(() => openKernelStore({ filePath: databasePath }), /owner-only/); + assert.throws(() => openKernelStore({ filePath: databasePath, pathTrust }), /owner-only/); fs.chmodSync(`${databasePath}${suffix}`, 0o600); fs.unlinkSync(`${databasePath}${suffix}`); fs.symlinkSync(path.join(directory, 'missing'), `${databasePath}${suffix}`); - assert.throws(() => openKernelStore({ filePath: databasePath }), /symlink/); + assert.throws(() => openKernelStore({ filePath: databasePath, pathTrust }), /symlink/); } }); test('production policy and route inputs must be owner-only files outside the checkout', () => { - const { directory } = authority(); + const { directory, pathTrust } = authority(); const configPath = path.join(directory, 'policy.json'); fs.writeFileSync(configPath, '{}\n', { mode: 0o600 }); - assert.equal(readPrivateInputFile(configPath, 'Policy file').toString('utf8'), '{}\n'); + assert.equal(readPrivateInputFile(configPath, 'Policy file', { pathTrust }).toString('utf8'), '{}\n'); fs.chmodSync(configPath, 0o644); - assert.throws(() => readPrivateInputFile(configPath, 'Policy file'), /owner-only/); + assert.throws(() => readPrivateInputFile(configPath, 'Policy file', { pathTrust }), /owner-only/); }); test('domain mutation and event hash append commit or roll back together', () => { @@ -657,22 +684,22 @@ test('domain mutation and event hash append commit or roll back together', () => }); test('a newer unknown schema fails closed', () => { - const { databasePath } = authority(); - const first = openKernelStore({ filePath: databasePath }); + const { databasePath, pathTrust } = authority(); + const first = openKernelStore({ filePath: databasePath, pathTrust }); first.close(); const raw = new DatabaseSync(databasePath); raw.exec('PRAGMA user_version = 99'); raw.close(); - assert.throws(() => openKernelStore({ filePath: databasePath }), /newer schema/); + assert.throws(() => openKernelStore({ filePath: databasePath, pathTrust }), /newer schema/); }); test('one process owns the authority until its lifetime lock closes', async () => { - const { databasePath } = authority(); - const owner = acquireAuthorityLock({ databasePath, role: 'kernel' }); - assert.throws(() => acquireAuthorityLock({ databasePath, role: 'bootstrap' }), + const { databasePath, pathTrust } = authority(); + const owner = acquireAuthorityLock({ databasePath, role: 'kernel', pathTrust }); + assert.throws(() => acquireAuthorityLock({ databasePath, role: 'bootstrap', pathTrust }), (error) => error.code === 'AUTHORITY_BUSY'); owner.close(); - acquireAuthorityLock({ databasePath, role: 'bootstrap' }).close(); + acquireAuthorityLock({ databasePath, role: 'bootstrap', pathTrust }).close(); }); ``` @@ -682,18 +709,62 @@ prove a new process acquires the same lock without deleting or trusting a PID fi SQLite's operating-system lock is the lease and is released by process death. Reject an in-checkout, symlinked, permissive, or wrong-owner-like derived lock database. +In `kernel-trusted-path.test.mjs`, exercise the live path primitive independently. +The only live implementation is Linux: it opens the configured trusted ancestor and +each descendant directory by descriptor with `O_DIRECTORY | O_NOFOLLOW` (using the +Linux `/proc/self/fd//` open-at boundary), validates the opened +descriptor with `fstat()`, and re-`fstat()`s the held chain before returning. The +trusted ancestor must be root-owned with no group/other write bit. For Kernel-private +targets, every intermediate owner is exactly root or the configured Kernel UID, no +intermediate has group/other write, and the terminal parent is Kernel-owned `0700`. +World/group-writable ancestors are rejected even when the sticky bit is set. Symlinks, +dot components, path escape, device/inode changes, a non-Linux live host, and an +unavailable `/proc/self/fd` boundary all fail closed. Deterministic tests may inject a +same-UID synthetic trusted ancestor, but the result is explicitly `simulated` and is +not accepted by `cdp-testnet`. + +The descriptor walk returns a canonical ordered projection of +`(role, depth, device, inode, uid, gid, mode)` for the entire chain. Tests pause after +each opened component and attempt symlink, rename, and directory-entry swaps from a +dropped Pi UID both before and after validation; every attempt must receive +`EACCES`/`EPERM`, and any injected privileged swap must be detected by the final +descriptor recheck. Apply the same primitive to every live Kernel authority, policy, +route, environment, report, evidence, operator-socket, and directional-handoff root, +not merely to the SQLite parent. Release-tree validation applies the stricter +root-only variant. No live filesystem consumer may fall back to `realpath()` plus an +immediate-parent `lstat()`. + - [ ] **Step 3: Run the store test and verify imports fail** Run: ```bash -node --test spikes/pi-wielder/tests/kernel-store.test.mjs +node --test spikes/pi-wielder/tests/kernel-store.test.mjs \ + spikes/pi-wielder/tests/kernel-trusted-path.test.mjs ``` -Expected: FAIL with `ERR_MODULE_NOT_FOUND` for the store and authority-lock modules. +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for the store/trusted-path/authority-lock modules. - [ ] **Step 4: Implement secure persistent paths** +Create `spikes/pi-wielder/src/kernel/trusted-path.mjs` with this exact live boundary: + +```text +openTrustedParent({ mode, trustedAncestor, targetFile, kernelUid, agentUid, + terminalOwnerUid, terminalMode, role }) -> { + canonicalParentPath, ancestorMetadataHash, + openLeaf(flags, mode?), openSibling(suffix, flags), + openNamedLeaf(name, flags, mode?), linkNamedToLeaf(name), unlinkNamed(name), + fsyncParent(), revalidate(), close() + } +``` + +Every method rejects use after close and `close()` is idempotent. `openSibling()` is +private to the exact `['', '-wal', '-shm']` SQLite suffix set; named methods accept +only the private-temp grammar specified below. The guard holds every directory +descriptor until `close()`, and `revalidate()` compares the full original fstat +projection. It exposes no raw `/proc/self/fd` path to callers. + Create `spikes/pi-wielder/src/kernel/secure-storage.mjs`: ```js @@ -701,6 +772,7 @@ import fs from 'node:fs'; import crypto from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { openTrustedParent } from './trusted-path.mjs'; const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../../', import.meta.url))); const NOFOLLOW = fs.constants.O_NOFOLLOW; @@ -723,43 +795,54 @@ function assertOwner(stat, label) { } } -function privateParent(filePath, label, checkoutRoot) { +function privateParent(filePath, label, checkoutRoot, pathTrust) { if (!path.isAbsolute(filePath)) throw new Error(`${label} path must be absolute`); const lexicalParent = path.resolve(path.dirname(filePath)); - const parent = fs.realpathSync(lexicalParent); - if (lexicalParent !== parent) throw new Error(`${label} directory must not use symlinks`); - if (inside(checkoutRoot, parent)) throw new Error(`${label} must be outside the checkout`); - const stat = fs.lstatSync(parent); - assertOwner(stat, `${label} directory`); - if (!stat.isDirectory() || (stat.mode & 0o077) !== 0) { - throw new Error(`${label} directory must be owner-only`); - } - return parent; + if (inside(checkoutRoot, lexicalParent)) throw new Error(`${label} must be outside the checkout`); + const guard = openTrustedParent({ + ...pathTrust, + targetFile: filePath, + terminalOwnerUid: process.getuid(), + terminalMode: 0o700, + }); + return guard; } -export function preparePrivateFile(filePath, label, { checkoutRoot = CHECKOUT_ROOT } = {}) { - privateParent(filePath, label, checkoutRoot); - if (!fs.existsSync(filePath)) { - const descriptor = fs.openSync(filePath, - fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, 0o600); - fs.closeSync(descriptor); - } - const stat = fs.lstatSync(filePath); - assertOwner(stat, label); - if (stat.isSymbolicLink()) throw new Error(`${label} must not be a symlink`); - if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) { - throw new Error(`${label} must be an owner-only regular file`); +export function preparePrivateFile(filePath, label, + { checkoutRoot = CHECKOUT_ROOT, pathTrust } = {}) { + const guard = privateParent(filePath, label, checkoutRoot, pathTrust); + try { + try { + const created = guard.openLeaf( + fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, 0o600); + fs.closeSync(created); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + const descriptor = guard.openLeaf(fs.constants.O_RDONLY | NOFOLLOW); + try { + const stat = fs.fstatSync(descriptor); + assertOwner(stat, label); + if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) { + throw new Error(`${label} must be an owner-only regular file`); + } + guard.revalidate(); + } finally { fs.closeSync(descriptor); } + return filePath; + } finally { + guard.close(); } - return filePath; } export function readPrivateInputFile(filePath, label, { checkoutRoot = CHECKOUT_ROOT, maximumBytes = 1_048_576, + pathTrust, } = {}) { - privateParent(filePath, label, checkoutRoot); - const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | NOFOLLOW); + const guard = privateParent(filePath, label, checkoutRoot, pathTrust); + let descriptor; try { + descriptor = guard.openLeaf(fs.constants.O_RDONLY | NOFOLLOW); const stat = fs.fstatSync(descriptor); assertOwner(stat, label); if (!stat.isFile() || (stat.mode & 0o777) !== 0o600) { @@ -768,48 +851,59 @@ export function readPrivateInputFile(filePath, label, { if (stat.size <= 0 || stat.size > maximumBytes) { throw new Error(`${label} size is outside the allowed boundary`); } - return fs.readFileSync(descriptor); + const bytes = fs.readFileSync(descriptor); + guard.revalidate(); + return bytes; } finally { - fs.closeSync(descriptor); + if (descriptor !== undefined) fs.closeSync(descriptor); + guard.close(); } } -export function preflightSqliteFiles(databasePath) { +export function preflightSqliteFiles(databasePath, { pathTrust } = {}) { + const guard = privateParent(databasePath, 'Wallet Kernel database', CHECKOUT_ROOT, pathTrust); const existing = new Set(); - for (const suffix of ['', '-wal', '-shm']) { - const target = `${databasePath}${suffix}`; - let stat; - try { - stat = fs.lstatSync(target); - } catch (error) { - if (error.code === 'ENOENT') continue; - throw error; + try { + for (const suffix of ['', '-wal', '-shm']) { + const target = `${databasePath}${suffix}`; + let descriptor; + try { descriptor = guard.openSibling(suffix, fs.constants.O_RDONLY | NOFOLLOW); } + catch (error) { if (error.code === 'ENOENT') continue; throw error; } + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) throw new Error(`SQLite ${suffix || 'database'} must be regular`); + assertOwner(stat, `SQLite ${suffix || 'database'}`); + if ((stat.mode & 0o777) !== 0o600) throw new Error(`SQLite ${suffix || 'database'} must be owner-only`); + existing.add(target); + } finally { fs.closeSync(descriptor); } } - if (stat.isSymbolicLink()) throw new Error(`SQLite ${suffix || 'database'} must not be a symlink`); - if (!stat.isFile()) throw new Error(`SQLite ${suffix || 'database'} must be regular`); - assertOwner(stat, `SQLite ${suffix || 'database'}`); - if ((stat.mode & 0o777) !== 0o600) throw new Error(`SQLite ${suffix || 'database'} must be owner-only`); - existing.add(target); + guard.revalidate(); + return existing; + } finally { + guard.close(); } - return existing; } -export function secureNewSqliteSideFiles(databasePath, existing) { - for (const suffix of ['', '-wal', '-shm']) { - const target = `${databasePath}${suffix}`; - let descriptor; - try { descriptor = fs.openSync(target, fs.constants.O_RDONLY | NOFOLLOW); } - catch (error) { if (error.code === 'ENOENT') continue; throw error; } - try { - const stat = fs.fstatSync(descriptor); - if (!stat.isFile()) throw new Error(`SQLite ${suffix || 'database'} must be regular`); - assertOwner(stat, `SQLite ${suffix || 'database'}`); - if (!existing.has(target)) fs.fchmodSync(descriptor, 0o600); - } finally { - fs.closeSync(descriptor); +export function secureNewSqliteSideFiles(databasePath, existing, { pathTrust } = {}) { + const guard = privateParent(databasePath, 'Wallet Kernel database', CHECKOUT_ROOT, pathTrust); + try { + for (const suffix of ['', '-wal', '-shm']) { + const target = `${databasePath}${suffix}`; + let descriptor; + try { descriptor = guard.openSibling(suffix, fs.constants.O_RDONLY | NOFOLLOW); } + catch (error) { if (error.code === 'ENOENT') continue; throw error; } + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) throw new Error(`SQLite ${suffix || 'database'} must be regular`); + assertOwner(stat, `SQLite ${suffix || 'database'}`); + if (!existing.has(target)) fs.fchmodSync(descriptor, 0o600); + } finally { fs.closeSync(descriptor); } } + guard.revalidate(); + } finally { + guard.close(); } - preflightSqliteFiles(databasePath); + preflightSqliteFiles(databasePath, { pathTrust }); } export function loadOrInitializePrivateFile({ @@ -819,10 +913,11 @@ export function loadOrInitializePrivateFile({ validateBytes, randomBytes = crypto.randomBytes, faultInjector = () => {}, + pathTrust, }) { - const parent = privateParent(filePath, label, CHECKOUT_ROOT); + const guard = privateParent(filePath, label, CHECKOUT_ROOT, pathTrust); const readExisting = () => { - const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | NOFOLLOW); + const descriptor = guard.openLeaf(fs.constants.O_RDONLY | NOFOLLOW); try { const stat = fs.fstatSync(descriptor); assertOwner(stat, label); @@ -831,24 +926,23 @@ export function loadOrInitializePrivateFile({ } const bytes = fs.readFileSync(descriptor); if (bytes.length === 0) throw new Error(`${label} must not be empty`); + guard.revalidate(); return validateBytes(bytes); } finally { fs.closeSync(descriptor); } }; + let bytes; + let temporaryName; try { - return readExisting(); - } catch (error) { - if (error.code !== 'ENOENT') throw error; - } - const bytes = Buffer.from(createBytes()); - let temporary; - try { + try { return readExisting(); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + bytes = Buffer.from(createBytes()); if (bytes.length === 0) throw new Error(`${label} initializer returned empty content`); validateBytes(bytes); const suffix = randomBytes(16).toString('hex'); - temporary = path.join(parent, `.${path.basename(filePath)}.tmp-${process.pid}-${suffix}`); - const descriptor = fs.openSync(temporary, + temporaryName = `.${path.basename(filePath)}.tmp-${process.pid}-${suffix}`; + const descriptor = guard.openNamedLeaf(temporaryName, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, 0o600); try { fs.writeFileSync(descriptor, bytes); @@ -861,25 +955,25 @@ export function loadOrInitializePrivateFile({ try { // link() is Node's no-replace publish primitive: EEXIST means a racer won. - fs.linkSync(temporary, filePath); + guard.linkNamedToLeaf(temporaryName); faultInjector('after_private_publish'); } catch (error) { if (error.code !== 'EEXIST') throw error; } - const parentDescriptor = fs.openSync(parent, fs.constants.O_RDONLY); - try { - fs.fsyncSync(parentDescriptor); - faultInjector('after_private_directory_fsync'); - } finally { - fs.closeSync(parentDescriptor); - } + guard.fsyncParent(); + faultInjector('after_private_directory_fsync'); return readExisting(); } finally { - bytes.fill(0); - if (temporary) { - try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } - const cleanupDescriptor = fs.openSync(parent, fs.constants.O_RDONLY); - try { fs.fsyncSync(cleanupDescriptor); } finally { fs.closeSync(cleanupDescriptor); } + try { + if (bytes) bytes.fill(0); + if (temporaryName) { + try { guard.unlinkNamed(temporaryName); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + guard.fsyncParent(); + } + guard.revalidate(); + } finally { + guard.close(); } } } @@ -889,6 +983,17 @@ Use `readPrivateInputFile()` for every policy and route read in bootstrap and da composition. Parse only the returned bounded bytes; never validate a path and reopen it later, and never retain raw configuration bytes after canonical validation. +Import `openTrustedParent()` from `trusted-path.mjs`. Every file-backed public +function above and `openKernelStore()` requires an explicit frozen `pathTrust` object; +there is no permissive default. In live mode it contains the configured +`trustedAncestor`, Kernel/Pi UID policy, and `mode: 'cdp-testnet'`. In deterministic +tests it names the synthetic fixture ancestor and `mode: 'deterministic'`. The +returned guard owns the full descriptor chain through each leaf +`open(... | O_NOFOLLOW)` and final `fstat()`/parent fsync operation; its leaf/link/ +unlink methods resolve only through `/proc/self/fd`, validate bounded basenames, and +never reopen an absolute path. Propagate the same +object through SQLite sidecar creation and the authority-lock database. + Use `loadOrInitializePrivateFile()` for the receipt key, operator token, and agent credential in Tasks 7, 13, and 14. The first two run only as the Kernel UID; the last runs only in Task 14's Pi-side credential helper and is never called by Kernel @@ -911,7 +1016,7 @@ no truncated final file. Create `authority-lock.mjs` with this exact public boundary: ```js -export function acquireAuthorityLock({ databasePath, role }) { +export function acquireAuthorityLock({ databasePath, role, pathTrust }) { // Return an idempotent close() handle, or throw KernelError('AUTHORITY_BUSY'). } ``` @@ -931,9 +1036,11 @@ The running control plane must acquire role `kernel` before opening the authorit database, recovery, wallet initialization, or either listener, and hold it until admission stops, listeners close, the authority database closes, and finally the lock handle closes. All offline bootstrap commands acquire role `bootstrap` through this -same module. The privileged service preflight acquires `prelaunch`, opens the main -authority strictly read-only for its bounded enrollment/attestation lookup, performs no -pragma/schema/event/write operation, closes it, then releases before the daemon starts. +same module. Task 13's prelaunch child first drops to the exact Kernel UID/GID, then +acquires `prelaunch`, opens the main authority strictly read-only for its bounded +enrollment/attestation lookup, performs no pragma/schema/event/write operation, closes +it, and releases before the root preflight exits and the daemon starts. The root parent +never calls this module. Unit and fresh-process tests prove every pairwise Kernel/bootstrap/prelaunch contention, clean close; crash release; and that a contender can never mutate the main database before acquiring the lock. @@ -1299,10 +1406,14 @@ import { } from './secure-storage.mjs'; import { KERNEL_SCHEMA_VERSION, SCHEMA_V1_SQL } from './sqlite-schema.mjs'; -export function openKernelStore({ filePath, allowMemory = false, now = () => new Date().toISOString() }) { +export function openKernelStore({ filePath, allowMemory = false, pathTrust, + now = () => new Date().toISOString() }) { if (filePath === ':memory:' && !allowMemory) throw new Error('in-memory authority requires explicit test injection'); - const existing = filePath === ':memory:' ? new Set() : preflightSqliteFiles(filePath); - if (filePath !== ':memory:') preparePrivateFile(filePath, 'Wallet Kernel database'); + const existing = filePath === ':memory:' ? new Set() + : preflightSqliteFiles(filePath, { pathTrust }); + if (filePath !== ':memory:') { + preparePrivateFile(filePath, 'Wallet Kernel database', { pathTrust }); + } const db = new DatabaseSync(filePath, { timeout: 5_000, readBigInts: true }); db.exec('PRAGMA foreign_keys = ON; PRAGMA trusted_schema = OFF; PRAGMA synchronous = FULL;'); if (filePath !== ':memory:') db.exec('PRAGMA journal_mode = WAL;'); @@ -1319,7 +1430,7 @@ export function openKernelStore({ filePath, allowMemory = false, now = () => new throw error; } } - if (filePath !== ':memory:') secureNewSqliteSideFiles(filePath, existing); + if (filePath !== ':memory:') secureNewSqliteSideFiles(filePath, existing, { pathTrust }); const liveTransactions = new WeakSet(); let transactionOpen = false; @@ -1338,8 +1449,8 @@ export function openKernelStore({ filePath, allowMemory = false, now = () => new if (value && typeof value.then === 'function') { throw new Error('authority transactions must be synchronous'); } + if (filePath !== ':memory:') preflightSqliteFiles(filePath, { pathTrust }); db.exec('COMMIT'); - if (filePath !== ':memory:') preflightSqliteFiles(filePath); return value; } catch (error) { if (db.isTransaction) db.exec('ROLLBACK'); @@ -1458,8 +1569,12 @@ Create `spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs`: ```js import { openKernelStore } from '../../src/kernel/sqlite-store.mjs'; -const [databasePath, claimId] = process.argv.slice(2); -const store = openKernelStore({ filePath: databasePath }); +const [databasePath, trustedAncestor, claimId] = process.argv.slice(2); +const pathTrust = Object.freeze({ + mode: 'deterministic', trustedAncestor, + kernelUid: process.getuid(), agentUid: process.getuid(), +}); +const store = openKernelStore({ filePath: databasePath, pathTrust }); try { const outcome = store.transaction((token) => store.within(token, ({ db, appendEvent }) => { @@ -1500,18 +1615,18 @@ function childResult(child) { } test('two processes serialize one conditional claim and one hash-chain event', async () => { - const { databasePath } = authority(); - const initial = openKernelStore({ filePath: databasePath }); + const { directory, databasePath, pathTrust } = authority(); + const initial = openKernelStore({ filePath: databasePath, pathTrust }); initial.close(); const fixture = fileURLToPath(new URL('./fixtures/kernel-db-writer.mjs', import.meta.url)); const children = ['a', 'b'].map((claimId) => spawn( process.execPath, - [fixture, databasePath, claimId], + [fixture, databasePath, directory, claimId], { stdio: ['ignore', 'pipe', 'pipe'] }, )); assert.deepEqual((await Promise.all(children.map(childResult))).sort(), ['already_claimed', 'claimed']); - const reopened = openKernelStore({ filePath: databasePath }); + const reopened = openKernelStore({ filePath: databasePath, pathTrust }); assert.equal(reopened.verifyEventChain(), true); assert.equal(reopened.events().filter((event) => event.event_type === 'test.claimed').length, 1); reopened.close(); @@ -1522,6 +1637,7 @@ Run: ```bash node --test spikes/pi-wielder/tests/kernel-store.test.mjs \ + spikes/pi-wielder/tests/kernel-trusted-path.test.mjs \ spikes/pi-wielder/tests/kernel-authority-lock.test.mjs ``` @@ -1533,10 +1649,12 @@ contention, and crash release. ```bash git add .gitignore spikes/pi-wielder/.env.example \ spikes/pi-wielder/src/kernel/secure-storage.mjs \ + spikes/pi-wielder/src/kernel/trusted-path.mjs \ spikes/pi-wielder/src/kernel/authority-lock.mjs \ spikes/pi-wielder/src/kernel/sqlite-schema.mjs \ spikes/pi-wielder/src/kernel/sqlite-store.mjs \ spikes/pi-wielder/tests/kernel-store.test.mjs \ + spikes/pi-wielder/tests/kernel-trusted-path.test.mjs \ spikes/pi-wielder/tests/kernel-authority-lock.test.mjs \ spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs \ spikes/pi-wielder/tests/fixtures/kernel-lock-worker.mjs @@ -1792,6 +1910,9 @@ export function createPolicyRepository(store) { active(), history(), get(id), + recordDecisionInTransaction(token, { + intentId, policyVersionId, evaluation, decidedAt, + }), }; } ``` @@ -1806,6 +1927,18 @@ only finish or become unresolved under its persisted old binding. Return the exa blocked session IDs so the operator can transition them deliberately. Reapplying the identical active hash is an idempotent lookup, not a duplicate version or re-block. +`recordDecisionInTransaction()` is the sole PolicyDecision writer. It accepts only +Task 2's live opaque transaction token and starts no transaction. Through that token it +reloads the exact immutable PolicyVersion and SpendIntent, requires the intent's +persisted challenge hash to equal `evaluation.challengeHash`, requires the canonical +PolicyVersion hash to equal `evaluation.policyHash`, validates the closed pure-engine +result, inserts the immutable `policy_decisions` row, and appends its event. An exact +replay returns the existing row; any different result for that intent is semantic +corruption. Task 10 uses this scoped method in the same outer transaction that attaches +the challenge. Its changed-challenge replacement aggregate may additionally create the +next Approval through its own scoped repository method; no orchestrator writes +`policy_decisions` directly. + At this task boundary, test only policy persistence and session-state effects that now exist: applying a tighter same-wallet policy succeeds, becomes active, atomically marks every prior-version open session `policy_blocked`, returns those IDs, and is idempotent @@ -2040,7 +2173,9 @@ export function createIntentRepository({ store, idFactory, now, allowLoopbackHtt closeBoundSessionInTransaction, getSession, captureIntent, + captureIntentInTransaction, attachChallenge, + attachChallengeInTransaction, transition, transitionInTransaction, getIntent, @@ -2065,6 +2200,15 @@ unauthenticated Pi header. `captureIntent()` reloads that exact triple as `active` inside its write transaction, copies `enrollment_hash` into the Spend Intent, and fails with `AGENT_REVOKED` before persisting if revocation won the serialization race. +`captureIntentInTransaction(token, input)` and +`attachChallengeInTransaction(token, input)` are the scoped forms used by Wallet +Kernel aggregates. Each validates Task 2's live opaque token, starts no transaction, +reloads the same authority rows through that token, and invokes the same internal +implementation as its standalone wrapper. `captureIntent()` and `attachChallenge()` +each own one transaction only by wrapping that shared scoped implementation. This +allows a replacement intent, its challenge, its PolicyDecision, and its Approval to be +created without nested transactions or duplicate SQL after an old changed-challenge +aggregate has released its retry fingerprint in the same outer transaction. `transitionInTransaction(token, { intentId, expectedState, nextState, reasonCode })` is the scoped form used by Wallet Kernel aggregates; it conditionally changes exactly one legal state edge and appends the intent event through the live token without @@ -2456,16 +2600,19 @@ const binding = Object.freeze({ Assert `approve({ approvalId, expectedIntentHash: intentHash, operatorIdHash: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' })` persists only the stable operator hash and time, -survives database reopen, and `consumeFor(binding)` changes `approved` to `consumed` -exactly once. For every individual changed binding field, assert +survives database reopen. In a caller-owned transaction that also inserts the exact +matching reservation fixture, `consumeForInTransaction(token, binding)` changes +`approved` to `consumed` exactly once; rolling back either write preserves both prior +states. For every individual changed binding field, assert `APPROVAL_BINDING_MISMATCH` and no row/event mutation. Assert denial, expiry, an already-consumed approval, and an unknown approval never return authorization. An unconsumed approved row remains usable only before its immutable `expiresAt`; -`consumeFor()` at or after expiry atomically changes it to `expired` and returns no -authorization. +inside Task 10's caller-owned aggregate, `consumeForInTransaction()` at or after +expiry delegates to the scoped expiry transition and returns no authorization while +the caller terminalizes the intent and writes its BuyerOutcome in that same token. At `maxPendingApprovals`, assert a new request is rejected atomically with -`APPROVAL_CAPACITY`; after one denial or expiry sweep, exactly one slot becomes +`APPROVAL_CAPACITY`; after one Task 10 aggregate denial or expiry, exactly one slot becomes available. A pending approval must be discoverable only via `findRetryable({ sessionId, intentHash })`, never from an agent-provided approval ID. @@ -2524,14 +2671,19 @@ Create `approval-queue.mjs`: export function createApprovalQueue({ store, idFactory, now }) { return Object.freeze({ request(binding), + requestInTransaction(token, binding), get(approvalId), list({ state, limit }), approve({ approvalId, expectedIntentHash, operatorIdHash }), - deny({ approvalId, expectedIntentHash, operatorIdHash, reasonCode }), - expireDue(), + listDue({ at, limit }), findRetryable({ sessionId, intentHash }), - consumeFor(binding), consumeForInTransaction(token, binding), + denyForIntentInTransaction(token, { + approvalId, intentId, expectedIntentHash, operatorIdHash, reasonCode, + }), + expireForIntentInTransaction(token, { + approvalId, intentId, expectedIntentHash, at, + }), cancelForIntentInTransaction(token, { intentId, reasonCode }), }); } @@ -2540,18 +2692,30 @@ export function createApprovalQueue({ store, idFactory, now }) { Every transition uses a conditional `UPDATE ... WHERE decision = ?` inside `BEGIN IMMEDIATE`, checks exactly one changed row, and appends the matching event. An exact duplicate request returns the existing record; a different binding for the same -intent fails. `expireDue()` compares canonical timestamps using the injected clock, -conditionally expires both `pending` and unconsumed `approved` rows, and returns the -IDs it changed. `request(binding)` derives `expiresAt` as the minimum of the +intent fails. `listDue()` is read-only, compares canonical timestamps against its +explicit `at`, and returns bounded `(approvalId, intentId, intentHash)` candidates in +stable order; it never mutates a row. `request(binding)` derives `expiresAt` as the minimum of the remaining local challenge lifetime and policy approval TTL; it never trusts an expiry supplied by an operator or Pi. -Approve/deny additionally match `expectedIntentHash` in that same conditional -transaction, so the operator's displayed confirmation is not a pre-transaction check. +`requestInTransaction(token, binding)` is the scoped creation form: it validates the +live opaque token, starts no transaction, reloads the exact PolicyDecision and intent +binding, derives the same bounded expiry, and invokes the same internal implementation +as `request()`. The standalone method owns one transaction only by wrapping this +scoped implementation. Task 10 never calls the standalone method from an aggregate. +Approve and the scoped denial additionally match `expectedIntentHash` in the same +conditional transaction, so the operator's displayed confirmation is not a +pre-transaction check. `consumeForInTransaction()` is the only approval-consumption path used by the Wallet -Kernel's reserve/signing aggregate. `cancelForIntentInTransaction()` conditionally -moves only `pending` or `approved` to `cancelled`, with exact reason -`POLICY_SUPERSEDED` or `SESSION_CLOSED`, and appends its event through the live token; -it never cancels a consumed approval. Both scoped methods start no transaction. +Kernel's reserve/signing aggregate. `denyForIntentInTransaction()` moves only `pending` +to `denied`; `expireForIntentInTransaction()` moves only a due `pending` or unconsumed +`approved` row to `expired`. `cancelForIntentInTransaction()` moves only `pending` or +`approved` to `cancelled`, with exact reason `POLICY_SUPERSEDED`, `SESSION_CLOSED`, or +`APPROVAL_CHALLENGE_CHANGED`; the changed-challenge path therefore has the explicit +legal approval state `cancelled`. Each scoped method checks the exact intent binding, +appends its event through the live token, starts no transaction, and never changes a +consumed approval. No public repository method can deny or expire an approval in a +standalone transaction: Task 10 owns those aggregate mutations so approval state, +terminal SpendIntent, and BuyerOutcome cannot split across a crash. - [ ] **Step 5: Implement the unforgeable in-process authority** @@ -2642,8 +2806,10 @@ git commit -m "feat: add exact one-time spend approvals" **Files:** +- Create: `spikes/pi-wielder/src/kernel/authority-mutation-coordinator.mjs` - Create: `spikes/pi-wielder/src/kernel/receipt-signing.mjs` - Create: `spikes/pi-wielder/src/kernel/signed-receipts.mjs` +- Create: `spikes/pi-wielder/tests/kernel-authority-coordinator.test.mjs` - Create: `spikes/pi-wielder/tests/kernel-receipts.test.mjs` - Modify: `spikes/pi-wielder/src/invocation-journal.mjs:256-365` @@ -2760,6 +2926,18 @@ CDP credential, payment signature header, payment payload, stack, or provider ex Refund and reconciliation receipts use revision `n + 1` and point `supersedesReceiptHash` to revision `n`. +In `kernel-authority-coordinator.test.mjs`, queue labeled synchronous callbacks in +call order and assert their entry/exit trace is exact FIFO even when an earlier callback +throws. Close the injected admission gate while two callbacks are queued and prove +each rechecks only when it reaches the head and performs zero callback writes. A +callback that returns a Promise/thenable is an invariant violation: synchronously call +the injected fail-stop callback with `AUTHORITY_COORDINATOR_ASYNC_CALLBACK`, release +the slot, and ensure all followers fail the now-closed gate. Enqueue another mutation +from a terminal callback's injected post-domain/pre-receipt fault hook without awaiting +it; prove the follower does not enter until the terminal callback either commits its +receipt or synchronously closes admission. Also assert the public object is frozen and +exposes only `runExclusive(operation)`—no raw acquire, release, queue, or gate setter. + - [ ] **Step 3: Extract generic signing without changing exports** Move the existing generic implementations of these functions to @@ -2784,6 +2962,35 @@ ID and no initialization temp file remains. - [ ] **Step 4: Implement post-commit terminal receipt issuance** +Create `authority-mutation-coordinator.mjs` with this exact surface: + +```text +export function createAuthorityMutationCoordinator({ + assertAdmissionOpen, markAuthorityUnhealthy, +}) -> Object.freeze({ + runExclusive(operation) -> Promise, +}) +``` + +`runExclusive()` accepts exactly one function and queues calls in invocation order. +When a call reaches the head, it synchronously invokes `assertAdmissionOpen()` before +the operation; a closed gate rejects without invoking it. The operation itself must +finish synchronously and may contain one or more synchronous SQLite transactions plus +receipt projection/signing, but no `await`, fetch, timer, callback escape, listener +work, or returned thenable. The coordinator automatically releases the slot on return +or throw and advances exactly one follower. If the callback returns any thenable, call +`markAuthorityUnhealthy('AUTHORITY_COORDINATOR_ASYNC_CALLBACK')` synchronously before +release and reject the call. A callback may enqueue a follower but may not await it; +that follower remains FIFO-blocked until the current callback releases. The module +owns no database, resolver, listener, or public acquire/release primitive. + +Construct one instance per live authority process only in Task 14's composition root. +The same object identity and same synchronous fail-stop callback are injected into the +Wallet Kernel and Reconciler; live operator mutation routes call only those facades. +Startup recovery and audited offline bootstrap instead run under Task 2's exclusive +process-lifetime authority lock before live admission and never create a competing +coordinator. No repository, adapter, or route may construct a private instance. + Create `signed-receipts.mjs`: ```text @@ -2846,7 +3053,8 @@ shutdown, and reopen repairs the exact missing revision before serving. - [ ] **Step 5: Run Kernel and seller receipt suites** ```bash -node --test spikes/pi-wielder/tests/kernel-receipts.test.mjs +node --test spikes/pi-wielder/tests/kernel-authority-coordinator.test.mjs \ + spikes/pi-wielder/tests/kernel-receipts.test.mjs npm run test:journal --prefix spikes/pi-wielder ``` @@ -2857,8 +3065,10 @@ byte-compatible. ```bash git add spikes/pi-wielder/src/kernel/receipt-signing.mjs \ + spikes/pi-wielder/src/kernel/authority-mutation-coordinator.mjs \ spikes/pi-wielder/src/kernel/signed-receipts.mjs \ spikes/pi-wielder/src/invocation-journal.mjs \ + spikes/pi-wielder/tests/kernel-authority-coordinator.test.mjs \ spikes/pi-wielder/tests/kernel-receipts.test.mjs git commit -m "feat: sign terminal wallet kernel receipts" ``` @@ -3481,6 +3691,7 @@ clock, IDs, and fault injector: const kernel = createWalletKernel({ store, policies, + enrollments, intents, budgets, approvals, @@ -3488,6 +3699,8 @@ const kernel = createWalletKernel({ permitAuthority, walletAdapter, transport, + authorityMutationCoordinator, + markAuthorityUnhealthy, now, faultInjector, }); @@ -3514,7 +3727,7 @@ results and durable side effects: | valid settlement then 2xx body timeout/overflow | `execution_unknown` | committed | 1 / 1 | signed | | typed pre-signer validation/account failure | `payment_failed` | released | 0 / 0 | signed | | signer throw/rejection/timeout or post-sign validation failure | `payment_unresolved` | unresolved | 1 / 0 | signed | -| signer may have returned but persistence fails | process abort | held on recovery | 1 / 0 | issued after reconciliation | +| signer may have returned but persistence fails | process abort | held on recovery | 1 / 0 | signed during startup recovery; later reconciliation may supersede | | paid response ambiguous | `payment_unresolved` | unresolved | 1 / 1 | signed | | settlement reports `success: false` | `payment_unresolved` | unresolved | 1 / 1 | signed | | second 402 or changed/missing settlement | `payment_unresolved` | held | 1 / 1 | signed | @@ -3553,9 +3766,28 @@ and do not create another Spend Intent or Approval. After approval, concurrent e retries serialize one approval consumption and at most one signature; followers return the same terminal result or stable `REQUEST_IN_FLIGHT`. If the fresh unpaid probe returns an expired or changed challenge, terminalize the old approval with a signed -`APPROVAL_CHALLENGE_CHANGED` receipt, create a new Spend Intent/Approval with a new -public request ID, and do not sign. A changed ordinary request never matches the old -approval. +`APPROVAL_CHALLENGE_CHANGED` receipt and do not sign during that call. When the changed +challenge is valid and its fresh pure evaluation remains `approval_required`, create a +new Spend Intent/Approval with a new public request ID in the same domain transaction. +If the fresh result is `allow` or `deny`, or the challenge is expired/invalid, create +no replacement row in that transaction: return the old `payment_denied` result and +receipt, and let a later exact tool call enter the ordinary new-intent lifecycle from +scratch. A changed ordinary request never matches the old approval. +Concretely, the changed-challenge domain transaction calls +`cancelForIntentInTransaction(..., 'APPROVAL_CHALLENGE_CHANGED')`, so the old Approval +is legally `cancelled`, transitions the old intent to `terminal`, writes +`payment_denied / APPROVAL_CHALLENGE_CHANGED` revision 1, clears the old intent's +`retry_matchable` flag, and then, only for a fresh `approval_required` result, calls +`captureIntentInTransaction()`, `attachChallengeInTransaction()`, +`policies.recordDecisionInTransaction()`, and `requestInTransaction()` to create the +complete replacement binding from the already validated fresh challenge. Every call +uses the same live token; none opens a nested transaction or duplicates repository +SQL. The replacement receives newly Kernel-generated request, intent, correlation, +and idempotency identifiers, while preserving the same ordinary request fingerprint +only after the old row becomes non-matchable. The coordinator is retained until the +old terminal outcome has its receipt; a receipt failure closes global admission +before the new approval can be acted on. Faults at every write boundary prove the old +aggregate and new request either commit together or not at all. Add a barriered concurrency test that pauses immediately after the durable signing claim, submits two identical agent retries, and proves both resolve to the original @@ -3580,6 +3812,25 @@ but never pretends to cancel a possibly signed authorization. Barrier tests paus authentication, capture, unpaid-probe return, reservation, and immediately before the signing claim, race `revoke()`, and prove exactly those two serialized outcomes. +The signing claim is also the final wallet-wide admission linearization point. Inside +that same `BEGIN IMMEDIATE`, call +`budgetLedger.snapshotInTransaction(token, { sessionId, sellerOrigin, at })` and +recheck `walletBlocked` plus every open payment-resolution, execution-resolution, and +refund blocker across the wallet. The current intent's own expected unsigned +reservation is excluded only from the blocker predicate, never from exposure totals. +If another intent became payment-unresolved, execution unknown/failed, or refund +pending after this intent reserved, the later claim releases this still-unsigned +reservation and terminalizes it as `payment_denied / WALLET_RECOVERY_REQUIRED` in the +domain transaction, including the durable `BuyerOutcome`; while the mutation +coordinator lease remains held, Task 7's mandatory second transaction signs and +inserts the receipt before the call returns. If that receipt transaction fails, global +admission enters the same fail-stop used by every other money-sensitive mutation. The +claim issues no permit and invokes the signer zero times. +Barrier tests create each of those three blocker classes between reservation and +claim and prove the later claim loses safely. A blocker that commits after the signing +claim follows the ordinary money-sensitive recovery rules and cannot retroactively +release the authorization. + - [ ] **Step 2: Specify and test the monetary transition order** The successful lifecycle order is exact: @@ -3620,10 +3871,12 @@ Reservation and signing claim are deliberately two transactions so re-evaluates the immutable policy/budget snapshot and calls `reserveInTransaction()`; for an approved retry it also calls `consumeForInTransaction()` in that same token, while the auto-approved path has no Approval row. The later signing-claim transaction -reloads the exact reservation/policy/enrollment epoch, rechecks the deadline and -active enrollment, derives/persists the one nonce/window, moves the attempt to -`signing`, and appends its events before issuing the in-memory permit. Expiry or -revocation at that second boundary safely releases the still-unsigned reservation and +reloads the exact reservation/policy/enrollment epoch, calls the transaction-local +wallet snapshot, and rechecks the deadline, active enrollment, and absence of any +other wallet-wide recovery/refund blocker before it derives/persists the one +nonce/window, moves the attempt to `signing`, and appends its events before issuing +the in-memory permit. Expiry, revocation, or a newly committed wallet blocker at that +second boundary safely releases the still-unsigned reservation and terminalizes it in the same transaction; nonce collision or another failed claim recheck likewise invokes the signer zero times. A crash between the two transactions leaves exactly the `reserved, no signing claim` state classified by Task 11. @@ -3645,8 +3898,13 @@ CDP, or HTTP-server imports: export function createWalletKernel(dependencies) { return Object.freeze({ openOrResumeSession({ agentInstanceId, walletAddress, policyVersionId }), + applyPolicy({ document, expectedPolicyHash }), + revokeAgent({ agentInstanceId, expectedEnrollmentHash, operatorIdHash }), transitionSessionPolicy({ sessionId, targetPolicyVersionId, expectedSessionHash }), closeSession({ sessionId, expectedSessionHash }), + approvePending({ approvalId, expectedIntentHash, operatorIdHash }), + denyPending({ approvalId, expectedIntentHash, operatorIdHash, reasonCode }), + expireDueApprovals({ limit }), execute({ sessionId, routeId, request, purposeLabel, correlationId }), status({ sessionId, intentId }), }); @@ -3665,6 +3923,38 @@ and before the Kernel rejects the call. Tests pause at this gap, queue a second plus every operator mutation class, and prove each observes `RECEIPT_PARITY_REQUIRED` with zero writes after the lease releases. +`applyPolicy()` and `revokeAgent()` are the only live wrappers around Task 3's policy +mutation and Task 4's enrollment revocation. Each first validates its closed input and +displayed hash, then performs the repository's synchronous standalone transaction +inside `authorityMutationCoordinator.runExclusive()`. `applyPolicy()` recomputes the +canonical document hash, requires `expectedPolicyHash`, rechecks the loaded wallet and +same-wallet live-rotation rule, applies the immutable version, and returns its exact +blocked-session summaries. `revokeAgent()` requires the authenticated operator hash, +conditionally revokes only the exact active enrollment, supersedes its admission +attestation through the repository transaction, and returns the still-bound session +IDs without closing or resolving them. Neither writes a BuyerOutcome or receipt. Race +tests queue both methods against reservation and signing claim: FIFO order determines +the winner, a policy/revocation winner blocks the later admission check, and a prior +signing claim may only finish or become unresolved under its persisted epoch. + +The operator route never calls ApprovalQueue mutations directly. `approvePending()` +serializes the exact conditional approval under the coordinator. `denyPending()` owns +one domain transaction that calls `denyForIntentInTransaction()`, terminalizes the +same SpendIntent, and inserts BuyerOutcome revision 1 as `payment_denied` with the +validated bounded denial reason. `expireDueApprovals()` obtains a read-only +`listDue()` batch and, in stable order, gives each still-due approval its own +coordinator-held domain transaction calling `expireForIntentInTransaction()`, +terminalizing the intent, and inserting `payment_denied / APPROVAL_EXPIRED` revision +1. Each denial/expiry retains the lease through the mandatory second receipt +transaction; a crash can leave only a terminal outcome missing its receipt, which +startup parity repair fills before listeners. There is no durable `denied` or +`expired` approval paired with a nonterminal intent or missing BuyerOutcome. +Startup recovery runs the same expiry aggregate before readiness; normal operation +runs a bounded due batch before admitting a new approval at the capacity limit and +before applying an operator decision. An exact approved retry that discovers its own +expiry executes that one aggregate directly. Read-only list/status routes never sweep +or mutate as a side effect. + `execute()` is the sole agent execution entrypoint. It first performs Task 4's exact session/fingerprint lookup: a matching retryable intent with an approved decision is dispatched internally to the private approved-retry path, while a new fingerprint is @@ -3812,7 +4102,9 @@ Seed one database row at each nonterminal payment/execution/refund state, close store, reopen it, and call: ```js -const report = recoverKernelAuthority({ store, receipts, now: fixedNow }); +const report = recoverKernelAuthority({ + store, intents, budgets, approvals, receipts, now: fixedNow, +}); ``` Assert: @@ -3828,26 +4120,45 @@ Assert: | challenged + persisted deny decision, no outcome | intent `terminal`; buyer outcome `payment_denied` with the persisted PolicyDecision reason; receipt | yes | | challenged + persisted allow/approval-required decision, no approval/reservation | intent `terminal`; buyer outcome `payment_failed` / `RECOVERY_ABANDONED_UNSIGNED`; receipt | yes | | pending approval, unexpired | leave pending | yes | -| pending approval, expired | expire, terminal receipt | yes | +| pending approval, expired | atomically expire approval + terminalize intent + write `payment_denied / APPROVAL_EXPIRED` revision 1; receipt | yes | | approved but unconsumed, unexpired | retain for exact ordinary retry only; no reservation | yes | -| approved but unconsumed, expired | expire, terminal receipt | yes | +| approved but unconsumed, expired | atomically expire approval + terminalize intent + write `payment_denied / APPROVAL_EXPIRED` revision 1; receipt | yes | | consumed approval without its exact reservation | fail startup semantic audit | no listener | | reserved, no signing claim | release, terminal receipt | yes | -| signing | change to `unresolved`, retain full hold | no | -| signed | retain exact bytes, change to `unresolved` | no | -| retrying | change to `unresolved`, retain full hold | no | -| unresolved with pending/rejected payment candidate history | retain exact history and hold | no | -| settled, execution missing | execution `unknown`, retain committed spend | no | +| signing | atomically change PaymentAttempt and SpendIntent to `unresolved`, retain full hold and `retry_matchable = 1`, write `payment_unresolved / RECOVERY_PAYMENT_AMBIGUOUS` revision 1; receipt | no | +| signed | retain exact bytes, atomically change PaymentAttempt and SpendIntent to `unresolved`, retain full hold and `retry_matchable = 1`, write `payment_unresolved / RECOVERY_PAYMENT_AMBIGUOUS` revision 1; receipt | no | +| retrying | atomically change PaymentAttempt and SpendIntent to `unresolved`, retain full hold and `retry_matchable = 1`, write `payment_unresolved / RECOVERY_PAYMENT_AMBIGUOUS` revision 1; receipt | no | +| unresolved with pending/abandoned/rejected payment candidate history | retain exact immutable history, current fresh case hash, existing outcome/receipt, and hold | no | +| settled, execution missing | atomically insert execution `unknown`, open `reconciliation_required`, transition intent to `terminal` with `retry_matchable = 0`, write `execution_unknown / RECOVERY_EXECUTION_MISSING` revision 1, retain committed spend; receipt | no | | failed execution without `refund_pending` case/refund row | fail semantic audit | no listener | | unknown execution without `reconciliation_required` case | fail semantic audit | no listener | | failed/unknown execution with open resolution case | retain exact case and full block | no | -| refund pending/unresolved | retain state and hold | no | +| refund pending/unresolved, or only abandoned history awaiting a replacement | retain immutable history, current fresh case hash, open `refund_pending` resolution, and full block | no | Recovery never invents an extra state: `RECOVERY_ABANDONED_UNSIGNED` is a stable reason code on the applicable existing `upstream_failed` or `payment_failed` BuyerOutcome value while the Spend Intent uses the existing `terminal` state. That transition and its first outcome revision commit atomically before the receipt is projected. +The same rule applies to every recovery-created terminal fact. Expiring an approval +uses Task 6's scoped transition in the transaction that terminalizes its intent and +inserts the outcome. Each `signing`/`signed`/`retrying` ambiguity transaction +conditionally changes both the PaymentAttempt and its exact SpendIntent from their +matching predecessor states to `unresolved`, retains the exact hold/bytes and +`retry_matchable = 1`, and inserts the initial +`payment_unresolved / RECOVERY_PAYMENT_AMBIGUOUS` outcome. The settled-without- +execution transaction inserts the `unknown` execution, its wallet-blocking +`reconciliation_required` case, conditionally transitions the exact SpendIntent from +its legal predecessor to `terminal` while clearing `retry_matchable`, and inserts the +initial `execution_unknown / RECOVERY_EXECUTION_MISSING` outcome together. That stable +reason is the only recovery classification for a persisted settlement lacking an +execution row. Fault injection after each individual write proves the execution, +resolution case, terminal intent, outcome, and retained committed budget all roll back +or commit as one unit. +Only after each domain commit does recovery sign/insert its receipt; startup stays +closed until global receipt parity is restored. An existing matching outcome with a +missing receipt is repaired idempotently, while a conflicting existing outcome or +receipt is semantic corruption rather than a second revision. `after_challenge_commit` means the challenge projection and PolicyDecision committed atomically but Task 10 had not yet created an approval or reservation. Recovery uses the persisted deny reason only for a deny; it never silently reconstructs a missing @@ -3855,6 +4166,15 @@ approval/reservation or resumes network work. Approval consumption and reservati are one aggregate transaction, so the consumed-without-reservation row is corruption, not a recoverable crash state. +`abandoned` is immutable candidate history, not proof that payment/refund ambiguity +ended. Recovery accepts zero or more abandoned/rejected predecessors plus at most one +current pending payment candidate or pending/unresolved refund candidate, recomputes +the displayed case hash from the complete ordered history, and requires it to differ +from the pre-abandon hash. With no replacement yet, the rotated fresh hash and full +hold remain active. Restart tests cover abandon-before-crash, replacement-before- +crash, and abandon racing confirmation for both payment and refund observations; no +case releases value, removes the wallet block, or revises the BuyerOutcome/receipt. + Recovery is idempotent: a second call changes no rows, events, or receipt revisions. Test both legal unbound cases: first clean bootstrap before its initial session, and restart after guarded close with the still-active enrollment. Recovery creates no @@ -4062,12 +4382,15 @@ Expected: FAIL with `ERR_MODULE_NOT_FOUND`. Create `recovery.mjs`: ```text -export function recoverKernelAuthority({ store, receipts, now }) { +export function recoverKernelAuthority({ store, intents, budgets, approvals, receipts, now }) { // Verify physical and semantic authority invariants, classify every incomplete row, // and return a frozen report. } -export function createReconciler({ store, budgets, receipts, resolver, now, idFactory }) { +export function createReconciler({ + store, budgets, receipts, resolver, now, idFactory, + authorityMutationCoordinator, markAuthorityUnhealthy, +}) { return Object.freeze({ reconcilePayment({ intentId, operatorIdHash, paymentTransactionId = null, expectedPaymentCaseHash }), @@ -4100,6 +4423,29 @@ There is no caller-authored generic `rejected`, `settled`, or `refund_confirmed` result. Each method validates its own closed result schema and source before any budget transition. +The reconciler receives the same shared FIFO `authorityMutationCoordinator` and +fail-stop `markAuthorityUnhealthy(code)` callback as Task 10. It never holds a +coordinator lease across resolver/network work. `reconcilePayment()` and +`observeRefund()` use one short lease to recheck the admission gate and atomically +persist the candidate, release it before calling the resolver, then acquire a new +lease for the authoritative resolution. `reconcileExecution()` first takes a bounded +read-only binding snapshot, resolves outside any lease/transaction, then acquires its +resolution lease. On every resolution lease, recheck the shared gate and global +receipt parity before the first write, reload the exact persisted binding and case +hash inside the domain transaction, and discard a stale resolver result with zero +writes. + +Any resolution that writes a BuyerOutcome retains that second lease continuously +across the domain commit and required receipt-signing/insert transaction. If receipt +creation fails after the domain commit, synchronously call +`markAuthorityUnhealthy('RECEIPT_PARITY_REQUIRED')` while still holding the lease; +queued agent and operator mutations then recheck the closed gate and perform zero +writes. Candidate persistence and `abandonCandidate()` also use the shared coordinator +and gate but have no receipt phase because they do not write a BuyerOutcome. Barrier +tests pause before and after each lease, during the network gap, and after the terminal +domain commit to prove no network-under-lease, stale-result overwrite, post-commit +unsafe retry, or receipt-parity race is possible. + `reconcileExecution()` is legal only for an open `execution_resolutions.state = 'reconciliation_required'` row. A successful resolution updates the execution outcome plus case (and, for failure, pending refund) in one @@ -4133,24 +4479,37 @@ it `abandoned` with an event. It performs no resolver/network call, no budget or BuyerOutcome mutation, and returns the fresh replacement case hash. Its stale hash or a concurrent confirmation/rejection loses with zero writes. -Before classifying recovery states, run a closed semantic audit across every row: -state values and transitions are legal; atomic strings are canonical; accepted indices -are in bounds for the persisted challenge; PolicyDecision/challenge/intent hashes -match; route IDs are canonical bounded tokens; approvals duplicate the exact immutable -binding; every reservation sums to its -decision ceiling and uses that PolicyVersion's limits; every PaymentAttempt payload, -header, hash, nonce, amount, and state agree; settlements own their unique transaction -IDs; payment/refund candidates are canonical, immutable, unique, and have at most one -open row per intent; every failed/unknown settled execution has exactly its required resolution/refund -state; executions/refunds/reconciliations have legal predecessors; retryable fingerprints -and open agent bindings are unique; every binding and intent carries its exact immutable -enrollment hash; enrollment, binding, and isolation-attestation hashes/states agree and -at most one attestation is current; every terminal intent has exactly one current -buyer-outcome row whose revision matches its domain/reconciliation history; and receipts -exactly project the corresponding buyer-outcome revisions. -Any mismatch returns `AUTHORITY_SEMANTIC_CORRUPTION`, keeps both listeners closed, and -performs no repair. Add corruption tests for each cross-table class using individually -well-formed rows so SQL constraints alone cannot make the tests vacuous. +Recovery uses two explicit audit phases; it never applies the final-state invariants to +a crash gap before classifying that gap. The pre-classification audit verifies physical +integrity, schema/foreign keys/CHECKs, event-chain validity, signatures of every +existing receipt, canonical state/atomic strings, challenge indices and hashes, +route/policy/approval immutable bindings, reservation arithmetic, internally complete +PaymentAttempt fields for its current state, transaction/candidate uniqueness, +candidate history/case hashes, legal predecessors, enrollment/session uniqueness and +bindings, and isolation-attestation hashes. It admits only the exact incomplete shapes +listed in Step 1: missing dependent outcome/receipt rows at named fault boundaries, +`signing`/`signed`/`retrying`, settled-without-execution, due approval, reserved-before- +claim, and the other enumerated unsigned abandonment cases. A near miss—extra row, +wrong predecessor, conflicting outcome/receipt, partial execution case, or missing +data not named by the matrix—is `AUTHORITY_SEMANTIC_CORRUPTION` with zero repair. + +After deterministic classification and domain repair, run the full semantic audit: +every reservation sums to its decision ceiling and uses that PolicyVersion's limits; +every PaymentAttempt payload/header/hash/nonce/amount/state agrees with its +SpendIntent state and BudgetReservation disposition; settlements own +their unique transaction IDs; payment/refund candidate histories are canonical and +have at most one open row; every failed/unknown settled execution has exactly its +required resolution/refund state; executions/refunds/reconciliations have legal +predecessors; retryable fingerprints and open bindings are unique; every binding and +intent carries its exact immutable enrollment hash; at most one attestation is current; +every terminal/ambiguous intent has exactly one current BuyerOutcome whose revision +matches domain/reconciliation history; and every BuyerOutcome revision has exactly one +valid projecting receipt. A missing receipt for an otherwise exact committed outcome +is the sole receipt repair case and is filled before this second audit; a conflicting +receipt is never repaired. Any final mismatch returns +`AUTHORITY_SEMANTIC_CORRUPTION`, keeps both listeners closed, and performs no further +mutation. Add corruption and allowed-gap tests for each cross-table class using +individually well-formed rows so SQL constraints alone cannot make them vacuous. - [ ] **Step 5: Prove recovery in fresh processes at every fault point** @@ -4266,6 +4625,7 @@ WALLET_KERNEL_AGENT_RUN_OUTBOX= WALLET_KERNEL_RELEASE_ROOT= WALLET_KERNEL_RELEASE_MANIFEST= WALLET_KERNEL_SERVICE_DEFINITION_FILE= +WALLET_KERNEL_SOCKET_DEFINITION_FILE= WALLET_KERNEL_ENV_FILE= WALLET_KERNEL_EVIDENCE_ROOT= WALLET_KERNEL_ISOLATION_REPORT_FILE= @@ -4287,7 +4647,9 @@ Create `config.test.mjs` with an explicit environment object; never mutate or in the developer's real `process.env`. Assert: ```js -const config = loadControlPlaneConfig({ env: fixtureEnv, checkoutRoot, uid }); +const config = loadControlPlaneConfig({ + env: fixtureEnv, checkoutRoot, uid, platform: 'linux', +}); assert.deepEqual(config.publicConfig, { mode: 'cdp-testnet', agentHost: '127.0.0.1', @@ -4305,9 +4667,11 @@ assert.deepEqual(config.publicConfig, { operatorTokenPath: fixtureEnv.WALLET_KERNEL_OPERATOR_TOKEN_FILE, enrollmentInboxPath: fixtureEnv.WALLET_KERNEL_ENROLLMENT_INBOX, agentRunOutboxPath: fixtureEnv.WALLET_KERNEL_AGENT_RUN_OUTBOX, + trustedAncestor: fixtureEnv.WALLET_KERNEL_TRUSTED_ANCESTOR, releaseRoot: fixtureEnv.WALLET_KERNEL_RELEASE_ROOT, releaseManifestPath: fixtureEnv.WALLET_KERNEL_RELEASE_MANIFEST, serviceDefinitionPath: fixtureEnv.WALLET_KERNEL_SERVICE_DEFINITION_FILE, + socketDefinitionPath: fixtureEnv.WALLET_KERNEL_SOCKET_DEFINITION_FILE, environmentFilePath: fixtureEnv.WALLET_KERNEL_ENV_FILE, evidenceRoot: fixtureEnv.WALLET_KERNEL_EVIDENCE_ROOT, isolationReportPath: fixtureEnv.WALLET_KERNEL_ISOLATION_REPORT_FILE, @@ -4336,8 +4700,17 @@ the whole URL as secret because a path/query can contain a provider key. It must appear in `publicConfig`, logs, errors, receipts, projections, or evidence. The deterministic mode does not require CDP credentials or an RPC endpoint and must ignore rather than serialize any present values. -Also reject `NODE_OPTIONS`, `NODE_PATH`, `LD_PRELOAD`, and every environment key with -prefix `DYLD_` in live mode before dynamic SDK/adapter imports; tests cover each one. +Also reject `NODE_OPTIONS`, `NODE_PATH`, every key with prefix `LD_` or `DYLD_`, +`GCONV_PATH`, and `GLIBC_TUNABLES` in live mode before dynamic SDK/adapter imports; +tests cover each class. The rendered systemd unit removes the loader controls before +Node starts; this application check is defense in depth, not the first line of +protection. +`cdp-testnet` additionally requires the validated runtime platform to equal `linux`, the systemd +activation contract, a root-owned configured `trustedAncestor`, and distinct +root-owned service and socket unit files. `trustedAncestor` must lexically contain +every configured live filesystem path; Task 2's descriptor walker then applies the +role-specific owner/mode policy to the complete chain. A sticky writable ancestor, +even `/tmp`, is never a valid live root. In the same test file, exercise `validateRouteMap({ document, mode })` before Task 14 adds the concrete route file. Require a closed schema, unique bounded route IDs, fixed @@ -4403,7 +4776,7 @@ export function validateRouteMap({ document, mode }) { } export function loadControlPlaneConfig({ env, checkoutRoot, - uid = process.getuid(), gid = process.getgid() }) { + uid = process.getuid(), gid = process.getgid(), platform = process.platform }) { return Object.freeze({ publicConfig: Object.freeze({ mode, @@ -4424,9 +4797,11 @@ export function loadControlPlaneConfig({ env, checkoutRoot, operatorTokenPath, enrollmentInboxPath, agentRunOutboxPath, + trustedAncestor: mode === 'cdp-testnet' ? trustedAncestor : null, releaseRoot: mode === 'cdp-testnet' ? releaseRoot : null, releaseManifestPath: mode === 'cdp-testnet' ? releaseManifestPath : null, serviceDefinitionPath: mode === 'cdp-testnet' ? serviceDefinitionPath : null, + socketDefinitionPath: mode === 'cdp-testnet' ? socketDefinitionPath : null, environmentFilePath: mode === 'cdp-testnet' ? environmentFilePath : null, evidenceRoot: mode === 'cdp-testnet' ? evidenceRoot : null, isolationReportPath: mode === 'cdp-testnet' ? isolationReportPath : null, @@ -4446,6 +4821,13 @@ allowed public keys. `assertCredentialPresence()` checks that the three CDP vari exist for `cdp-testnet` but returns no values. Pass the original environment only to the SDK constructor in the process composition root; do not copy credentials into the config, database, logs, errors, or receipts. +`platform` is a closed unit-test seam, not an environment/configuration field. Accept +only Node's known platform tokens and require exact `linux` in `cdp-testnet`; tests +inject `linux` for the live-shape fixture and independently inject `darwin`/`win32` to +prove rejection. The real `control-plane.mjs` always omits this argument and, before +constructing any live dependency or listener, independently requires actual +`process.platform === 'linux'`. No composition dependency or environment value can +override that second gate, so the injection seam cannot make a macOS process live. In `cdp-testnet`, the owner bearer may travel only over the Kernel-owned Unix-domain socket named by `operatorSocketPath`. The live CLI must execute under the Kernel/operator OS identity that can traverse the socket's owner-only parent. The live @@ -4706,9 +5088,16 @@ git commit -m "feat: adapt customer CDP wallets" - Create: `spikes/pi-wielder/src/kernel/release-integrity.mjs` - Create: `spikes/pi-wielder/src/agent/isolation-preflight.mjs` - Create: `spikes/pi-wielder/scripts/build-release-manifest.mjs` +- Create: `spikes/pi-wielder/scripts/render-systemd-units.mjs` +- Create: `spikes/pi-wielder/scripts/inspect-systemd-effective.mjs` - Create: `spikes/pi-wielder/scripts/preflight-live-deployment.mjs` +- Create: `spikes/pi-wielder/scripts/prelaunch-kernel-reader.mjs` - Create: `spikes/pi-wielder/scripts/preflight-agent-isolation.mjs` - Create: `spikes/pi-wielder/scripts/agent-isolation-probe-worker.mjs` +- Create: `spikes/pi-wielder/deploy/systemd/wallet-kernel.service` +- Create: `spikes/pi-wielder/deploy/systemd/wallet-kernel-console.socket` +- Create: `.github/workflows/pi-wielder-systemd.yml` +- Modify: `spikes/pi-wielder/package.json` - Create: `spikes/pi-wielder/operator-console/index.html` - Create: `spikes/pi-wielder/operator-console/app.mjs` - Create: `spikes/pi-wielder/operator-console/styles.css` @@ -4717,6 +5106,7 @@ git commit -m "feat: adapt customer CDP wallets" - Create: `spikes/pi-wielder/tests/operator-cli.test.mjs` - Create: `spikes/pi-wielder/tests/operator-console.test.mjs` - Create: `spikes/pi-wielder/tests/release-integrity.test.mjs` +- Create: `spikes/pi-wielder/tests/systemd-units.test.mjs` - Create: `spikes/pi-wielder/tests/agent-isolation.test.mjs` - [ ] **Step 1: Write failing owner-credential tests** @@ -4805,16 +5195,20 @@ console app; channel-inappropriate auth is rejected. All mutation bodies use clo reads. Unknown route, query, body field, state, identifier, or pagination value is rejected. Approval endpoints accept only operator intent plus a bounded reason code; they cannot change amount, wallet, quote, policy, challenge, expiry, or request. The +approve and deny handlers call only Task 10's `approvePending()` and `denyPending()` +aggregate services; `operator/api.mjs` is never handed ApprovalQueue itself. The policy validation body is exactly `{ document }`; it returns the normalized public policy plus its canonical hash. Policy apply is stateless and accepts exactly `{ document, expectedPolicyHash }`: it revalidates/recanonicalizes the document and requires the recomputed hash to equal the displayed validation hash before calling -Task 3's repository. It never trusts a filename, cached browser object, or +Task 10's coordinated `applyPolicy()` facade. It never receives Task 3's mutable +repository and never trusts a filename, cached browser object, or caller-supplied normalized projection. The running API additionally requires the document wallet to equal the already-loaded adapter identity; a wallet change returns -`WALLET_ROTATION_REQUIRES_OFFLINE_RESTART`. Only after guarded close and daemon stop may -the lock-owning offline apply accept a different wallet, before closed configuration -and the adapter are restarted together. Approval bodies +`WALLET_ROTATION_REQUIRES_OFFLINE_RESTART`. Only after guarded close and Task 13's +verified socket-plus-service maintenance quiesce may the lock-owning offline apply +accept a different wallet, before closed configuration and the adapter are restarted +together. Approval bodies require the displayed intent hash; reconciliation bodies require the displayed intent hash plus the applicable displayed case hash, and `kind` is exactly `payment`, `execution`, or `refund-observation`. Execution accepts no financial evidence and @@ -4830,8 +5224,10 @@ abandoned candidate. The abandon-candidate route accepts only `kind` equal to resolver call, and returns the newly rotated case hash. Execution has no candidate to abandon. Agent revocation accepts exactly `expectedEnrollmentHash`, marks only the active -enrollment revoked through Task 4's repository, and immediately removes agent admission -without closing or resolving its session. It returns the bound session IDs that still +enrollment revoked through Task 10's coordinated `revokeAgent()` facade, and +immediately removes agent admission. The operator API never receives Task 4's mutable +repository. Revocation does not close or resolve its session; it returns the bound +session IDs that still need safe operator reconciliation/close. The session transition body contains exactly `targetPolicyHash` and `expectedSessionHash`; the target must be active and the Kernel enforces Task 10's @@ -4903,7 +5299,7 @@ as fixed by the exact clean-install sequence below. Agent enrollment validates the descriptor's exact hash, closed schema, canonical different Pi UID in live mode, uniqueness, and absence of any raw token before inserting the immutable active `agent_enrollments` row. These commands validate the owner token, -call `acquireAuthorityLock({ databasePath, role: 'bootstrap' })`, prove no Kernel +call `acquireAuthorityLock({ databasePath, role: 'bootstrap', pathTrust })`, prove no Kernel writer owns the SQLite authority, perform only the requested operation, close/fsync, and release the lock. Approval, receipt, reconciliation, and export commands remain authenticated Unix-socket API clients in live mode and loopback clients only in the @@ -4920,12 +5316,32 @@ returns `AUTHORITY_RECOVERY_REQUIRED`. Tests seed a domain-commit/receipt gap an offline apply/enroll/attest either repairs exact parity first or performs no requested write; no bootstrap path can advance authority past a missing receipt. -The final clean-install order is exact: Kernel `preflight`; Pi-side `credential init`; -offline `agent enroll`; offline `policy apply`; privileged isolation probe bound to -that enrollment; offline `isolation attest`; then daemon start. Normal replacement uses -guarded session close, authenticated `agent revoke --confirm ENROLLMENT_HASH`, daemon -stop, new Pi credential/descriptor, offline replacement enrollment, a fresh -probe/attestation, and clean restart. If compromise requires revocation before a close +The final clean-install order is exact: privileged immutable release/unit install; +Kernel `preflight`; Pi-side `credential init`; offline `agent enroll`; offline `policy +apply`; `systemctl daemon-reload`; `systemctl enable wallet-kernel-console.socket` +without starting it; effective-config inspection and release-manifest creation; +privileged isolation probe bound to that enrollment/manifest; offline `isolation +attest`; explicit socket start; then service start. Any failure after enablement runs a +root cleanup that disables/stops the socket again; even an abrupt reboot in that short +window remains fail-closed because live preflight cannot find the matching fresh +attestation. + +Normal replacement uses guarded session close, authenticated `agent revoke --confirm +ENROLLMENT_HASH`, then a privileged maintenance quiesce in this exact order: +`systemctl disable --now wallet-kernel-console.socket`, `systemctl stop +wallet-kernel.service`, and verification that both units are `inactive`, the socket is +`disabled`, both `Job` values are empty, `MainPID=0`, no listener remains at +127.0.0.1:8405, and a role-`bootstrap` authority-lock probe succeeds. Only then may a +new Pi credential/descriptor, offline replacement enrollment/configuration, and fresh +probe/attestation run. Restore performs `daemon-reload`, enables the socket without +starting it, rechecks the complete effective-config projection, imports the fresh +attestation, starts the socket, and starts the service. A failed maintenance step +leaves the socket disabled and service stopped; it never silently resumes an old +binding. A dropped-Pi connection-storm integration test runs throughout quiesce and +proves that, after socket disablement completes, traffic cannot reactivate the service, +acquire the authority lock, or interfere with the offline mutation. + +If compromise requires revocation before a close that unresolved money blocks, revoke first, remain in operator-only recovery, reconcile and close, then stop/enroll. Tests cover both orders and prove no second active row is created. No step edits SQLite by hand. @@ -4981,8 +5397,9 @@ const isolationReport = Object.freeze({ releaseManifestHash: `sha256:${'88'.repeat(32)}`, releaseTreeHash: `sha256:${'99'.repeat(32)}`, nodeExecutableHash: `sha256:${'aa'.repeat(32)}`, - serviceDefinitionHash: `sha256:${'bb'.repeat(32)}`, - environmentMetadataHash: `sha256:${'cc'.repeat(32)}`, + serviceArtifactsHash: `sha256:${'bb'.repeat(32)}`, + systemdEffectiveConfigHash: `sha256:${'cc'.repeat(32)}`, + environmentMetadataHash: `sha256:${'dd'.repeat(32)}`, probeResults: Object.freeze({ authorityDirectory: 'EACCES', database: 'EACCES', @@ -4992,7 +5409,7 @@ const isolationReport = Object.freeze({ agentCredential: 'READABLE', releaseTreeWrite: 'EACCES', dependencyTreeWrite: 'EACCES', - serviceDefinitionWrite: 'EACCES', + serviceArtifactsWrite: 'EACCES', kernelEnvironmentParentWrite: 'EACCES', }), probedAt: '2026-07-31T12:00:00.000Z', @@ -5001,8 +5418,11 @@ const isolationReport = Object.freeze({ const reportHash = sha256(canonicalJson(isolationReport)); ``` -The metadata hashes cover closed `(role, device, inode, uid, gid, mode)` projections, -not paths, file contents, mutable size/mtime, or secrets. Validate canonical nonzero +The authority and credential metadata hashes cover the closed, ordered ancestor-chain +projections from Task 2—`(role, depth, device, inode, uid, gid, mode)`—plus the leaf +projection, not paths, file contents, mutable size/mtime, or secrets. This binds every +Kernel private/writable root and the Pi credential root to a chain Pi cannot rename. +Validate canonical nonzero UID/GID strings, the exact active enrollment hash, every deployment hash, all ten literal result codes, `probedAt <= now < expiresAt`, and a maximum 15-minute interval. Print only @@ -5068,17 +5488,32 @@ is excluded from its tree hash): entrypoint: 'src/control-plane.mjs', packageLockHash: 'sha256:<64 lowercase hex>', releaseTreeHash: 'sha256:<64 lowercase hex>', + kernelIdentity: { + uid: '', + gid: '', + }, node: { - version: 'v24.15.0', + version: 'v24.18.1', executablePathHash: 'sha256:<64 lowercase hex>', executableSha256: 'sha256:<64 lowercase hex>', uid: '0', gid: '', mode: '', }, - service: { - definitionPathHash: 'sha256:<64 lowercase hex>', - definitionSha256: 'sha256:<64 lowercase hex>', + environment: { environmentMetadataHash: 'sha256:<64 lowercase hex>', }, + serviceArtifacts: [{ + role: 'kernel-service' | 'console-socket', + pathHash: 'sha256:<64 lowercase hex>', + sha256: 'sha256:<64 lowercase hex>', + uid: '0', gid: '', mode: '', + }], + systemd: { + managerVersion: '', + systemctlVersion: '', + systemctlExecutablePathHash: 'sha256:<64 lowercase hex>', + systemctlExecutableSha256: 'sha256:<64 lowercase hex>', + effectiveConfigHash: 'sha256:<64 lowercase hex>', + }, entries: [{ path: '', kind: 'directory' | 'file' | 'symlink', @@ -5094,39 +5529,148 @@ Entries are sorted by canonical relative path and cover the entire release tree the manifest. Reject absolute/dot/duplicate paths, devices/FIFOs/sockets, escaping or dangling symlinks, hard-linked regular files, missing/extra entries, mutable directories/files, a package-lock mismatch, an entrypoint outside the tree, or a Node -version outside the exact pinned runtime. The environment metadata hash covers only +version outside the exact pinned runtime. `kernelIdentity` is the install-time, +root-owned source of truth for the dedicated Kernel's numeric UID/GID; both values +must be canonical positive decimals, must differ from the Pi identity, and may never +be inferred later from an account name, environment variable, report, or mutable +configuration. `serviceArtifacts` is closed, sorted by +`role`, contains exactly one `kernel-service` and one `console-socket` row for the +systemd pilot, and rejects duplicates, missing/extra roles, or path reuse. Its +domain-separated canonical aggregate hash is `serviceArtifactsHash` in the isolation +report. The environment metadata hash covers only `(device,inode,uid,gid,mode)` for the Kernel-owned `0600` environment file under its -Kernel-owned `0700` parent, never secret contents or its path. The service definition -and socket-activation definition are root-owned, hashed public configuration. - -`preflight-live-deployment.mjs` is invoked by the root-owned service manager before it -drops to the Kernel UID. Using the pinned absolute root-owned Node binary, it verifies -the release manifest/tree, Node executable, launcher, service/socket definitions, and -environment metadata and validates the service manager's reserved console listener. -It acquires the authority lock in read-only `prelaunch` role and inspects only the -active-enrollment/current-attestation keys. With one active enrollment, it single-FD -opens the already human-confirmed `isolationReportPath`, requires its hash to equal -SQLite's exact current attestation, revalidates its unexpired static bindings, then -reruns the dropped-Pi-identity probes and requires the current results/metadata to equal -that artifact. It does not generate, rewrite, import, supersede, or timestamp a report -and performs no SQLite mutation. Only `preflight-agent-isolation.mjs` generates a new -report, and only the confirmed offline `isolation attest` command imports it. With zero -active enrollment, prelaunch still verifies deployment integrity/write denial but skips -agent-credential report matching so operator-only recovery can start. +Kernel-owned `0700` parent, never secret contents or its path. Both the service +definition and socket-activation definition are root-owned, content-hashed public +configuration; neither can be omitted merely because the service manager uses two +files. + +The install does not equate those file hashes with PID1's loaded configuration. +Before manifest creation it runs the fixed root-owned `/usr/bin/systemctl +daemon-reload`, enables (without starting) `wallet-kernel-console.socket`, and invokes +`inspect-systemd-effective.mjs`. That inspector verifies the absolute `systemctl` +inode/owner/mode and hashes its bytes, accepts bounded output, and invokes only closed +argument arrays—never a shell. For both units it requires `LoadState=loaded`, the +exact installed `FragmentPath`, empty `DropInPaths`, `NeedDaemonReload=no`, +`Transient=no`, and the expected `UnitFileState` (`static` for the socket-triggered +service and `enabled` for the socket). Masked, generated, transient, alias, linked, +runtime-enabled, stale, or overridden units fail. + +The inspector requests exactly this security-relevant property set with `systemctl +show --all --no-pager --property=...`, rejects duplicate/missing keys and unbounded or +malformed values, and splits each line only at its first `=`: + +```text +both: Id LoadState FragmentPath DropInPaths NeedDaemonReload Transient UnitFileState +service: User Group SupplementaryGroups EnvironmentFiles ExecStartPreEx ExecStartEx + Restart RestartUSec UMask NoNewPrivileges CapabilityBoundingSet AmbientCapabilities + ProtectSystem ProtectHome PrivateTmp PrivateDevices ProtectKernelTunables + ProtectKernelModules ProtectControlGroups LockPersonality RestrictAddressFamilies + ReadWritePaths UnsetEnvironment Requires After +socket: Listen Accept Service FileDescriptorName ReusePort +``` + +It canonicalizes scalar values and sorted sets directly. `ExecStartPreEx` and +`ExecStartEx` use a closed parser for systemd's flag-bearing command structure: retain +and hash only the static executable path, exact argv array, and sorted flags array. +Require the preflight flags to equal exactly `['privileged']` (the loaded form of +the unit's `+` prefix) and the main command flags to equal `[]`; `ignore-failure` or +any other flag is forbidden. Explicitly recognize but exclude the runtime-only start/exit timestamp, +PID, result code, and status fields from the hash, and reject any unknown structural +field instead of silently discarding it. The inspector then requires every value +represented by the rendered templates: +numeric Kernel UID/GID, empty supplementary/capability sets, exact environment and +command paths/argv, the complete sandbox and write-path sets, the service's socket +dependency/order, one exact loopback stream, `Accept=no`, exact target service and FD +name, and `ReusePort=no`. It separately records PID1's exact bounded `Version` manager +property and the bounded first `systemctl --version` client line, plus the root-owned +executable path hash/byte hash and domain-separated normalized projection hash, in the manifest's +closed `systemd` object. `build-release-manifest.mjs` accepts that result +only from this post-reload inspection and rechecks it against the renderer output. +The privileged live preflight repeats the same PID1 query and requires byte-for-byte +canonical projection/hash equality with the manifest before it drops identity. Thus a +drop-in, stale manager cache, alternate fragment, runtime property, changed +executable, or skipped daemon reload blocks startup even when the two unit files on +disk still hash correctly. + +`preflight-live-deployment.mjs` is invoked by the root-owned service manager before the +Kernel service. Using the pinned absolute root-owned Node binary, the root phase +verifies only root-owned facts: release manifest/tree, Node executable, launcher, both +service artifacts, the freshly loaded PID1 effective-config projection, loader +environment allowlist, Task 2 ancestor chains, and the +dropped-Pi write/create/rename denial probes. It never imports `secure-storage.mjs` or +`authority-lock.mjs`, never opens the Kernel-owned authority/database/report/token/key, +and never relaxes their exact-current-UID owner checks merely because it is root. +Its closed command line contains absolute `--release-manifest`, canonical numeric +`--kernel-uid`, and canonical numeric `--kernel-gid` values rendered into the unit. +Before spawning a child, the root phase requires those values to equal the manifest's +`kernelIdentity`, parses the hashed installed service artifact to require the same +literal numeric `User=`/`Group=` directives, and rejects account names, remapping, +unknown/repeated arguments, or a manifest/argument/unit disagreement. + +Authority/report comparison runs in `prelaunch-kernel-reader.mjs`. That file statically +imports built-ins only, starts under the privileged preflight process, immediately +calls `process.setgroups([])`, then `setgid(exactKernelGid)` and +`setuid(exactKernelUid)`, verifies the resulting real/effective identity and empty +supplementary groups, and only then dynamically imports the trusted-path, +secure-storage, authority-lock, and read-only SQLite code. It signals readiness over a +dedicated IPC channel with a root-generated nonce. The root phase spawns the pinned +Node binary with only the reader path plus the manifest-verified numeric +`--kernel-uid`/`--kernel-gid` arguments and `stdio: ['ignore', 'pipe', 'pipe', 'ipc']`; +the child validates that closed argv before dropping. The parent then sends one closed +canonical request containing the same UID/GID, validated public paths, expected +release/ancestor hashes, and current probe-result codes—never CDP credentials, owner +bearer, receipt key, environment contents, or open authority descriptors. The child +must cross-check the two identity copies and reject unknown IPC fields, a second +request, wrong nonce/parent PID, wrong UID/GID, or inherited loader variables. It does +not assert a total descriptor count because Node/libuv owns internal descriptors; +instead the production spawn passes no explicit descriptor beyond stdio plus IPC. +Tests open identifiable regular-file and listening-socket sentinels in the parent and +prove neither is inherited or usable in the normal child. A tampered spawn that adds a +sentinel to an explicit stdio slot, or an IPC request that names any authority, +listener, secret, or extra descriptor, must fail bootstrap before project imports. + +Under the exact Kernel UID, the child calls +`acquireAuthorityLock({ databasePath, role: 'prelaunch', pathTrust })`, opens the main +authority strictly read-only, and inspects only the active-enrollment/current- +attestation keys. With one active enrollment, it single-FD opens the already +human-confirmed `isolationReportPath`, requires its hash to equal SQLite's exact +current attestation, requires its `kernelUid`/`kernelGid` to equal the manifest and +fixed bootstrap identity, revalidates its unexpired static bindings and full ancestor-chain +metadata, and requires the root phase's fresh probe codes/hashes to equal that +artifact. It returns only a closed canonical status/digest object, closes SQLite, +releases the lock, and exits; the root parent verifies the nonce/status and exits too +before `ExecStart` begins. Neither phase generates, rewrites, imports, supersedes, +timestamps, or mutates a report/database. Only `preflight-agent-isolation.mjs` +generates a report, and only confirmed offline `isolation attest` imports it. With zero +active enrollment, the dropped child still validates ownership/deployment and returns +explicit `recovery_only`; it skips agent-credential report matching so the operator +plane can start closed to agent spend. + +Tests prove a root-direct `secure-storage`/authority open fails owner validation; a +child that remains root, drops to the wrong UID/GID, retains a supplementary group, +or imports project code before dropping cannot return green. A barrier keeps the +dropped child holding `prelaunch` and proves Kernel/bootstrap contenders receive +`AUTHORITY_BUSY`; killing it releases the OS lock. Ancestor swap attempts before and +after the root phase, between IPC readiness and the child open, and after child exit +are either OS-denied to the Pi UID or detected by the child's independently repeated +fd-walk/hash comparison. Root/capability mutation remains outside the pilot threat +boundary. The dropped-identity worker must receive `EACCES`/`EPERM` for write/create/rename attempts against the release root, representative source, lockfile, dependency, launcher, service definitions, environment file/parent, and Kernel writable roots. Readability of public code is not a failure; writability is. The previously imported privileged report binds `releaseManifestHash`, `releaseTreeHash`, -`nodeExecutableHash`, `serviceDefinitionHash`, and `environmentMetadataHash` alongside +`nodeExecutableHash`, `serviceArtifactsHash`, `systemdEffectiveConfigHash`, and +`environmentMetadataHash` alongside the enrollment/isolation facts. The service launches with a closed environment and live startup rejects `NODE_OPTIONS`, -`NODE_PATH`, `LD_PRELOAD`, every `DYLD_*` variable, and any unrecognized code-loader +`NODE_PATH`, every `LD_*`/`DYLD_*` variable, `GCONV_PATH`, `GLIBC_TUNABLES`, and any unrecognized code-loader or `WALLET_KERNEL_` field. `release-integrity.mjs` runs again inside the Kernel before opening SQLite, requires `import.meta.url`/the process entrypoint inside the attested -release, and recomputes the complete manifest/external artifact hashes. After it opens +release, requires `process.getuid()`/`process.getgid()` to equal the manifest's numeric +`kernelIdentity`, and recomputes the complete manifest/external artifact hashes. After it opens and recovers SQLite, normal admission single-FD hashes the same configured report artifact and requires `currentFor()` to match its exact DB row and release hash. This runtime check supplements rather than replaces @@ -5136,6 +5680,151 @@ write probes; all block before a credential, database, or listener is opened. Re tests cover the exact same imported artifact, expiry, DB/artifact hash mismatch, release-hash mismatch, and zero-active operator recovery without any implicit import. +- [ ] **Step 3a: Pin the Linux systemd service and socket-activation contract** + +The two committed files under `deploy/systemd/` are strict templates, not units that +silently discover a checkout. `render-systemd-units.mjs` accepts one closed canonical +install document with canonical positive numeric `kernelUid`/`kernelGid`, concrete immutable +`releaseRoot`, pinned absolute Node executable, owner-only environment file, authority/ +evidence/runtime/directional-handoff roots, and installed unit output paths. It rejects unknown fields, +relative paths, shell metacharacters/newlines, a mutable executable/release, same/root +Pi and Kernel identities, or output overwrite. It substitutes every template marker, +fails if any marker remains, and returns the exact service/socket bytes and their +hashes; the privileged installer exclusive-creates the installed units root-owned +`0644`, fsyncs them and their parent, then supplies those two installed paths to +`build-release-manifest.mjs`. No executable path comes from the Kernel environment +file, `PATH`, a `current` symlink, or shell expansion. The renderer accepts UID/GID +numbers only—never account or group names—and writes the same pair into the manifest +input, `User=`/`Group=`, and fixed preflight arguments. Installation may verify that a +human-provisioned account currently resolves to that pair for operator ergonomics, +but the account name is not persisted as authority and a later NSS name remap cannot +change the unit identity. + +The rendered `wallet-kernel.service` must contain, and the static test must parse: + +```text +[Unit] +Requires=wallet-kernel-console.socket +After=network-online.target wallet-kernel-console.socket + +[Service] +Type=simple +User= +Group= +SupplementaryGroups= +EnvironmentFile= +ExecStartPre=+ /scripts/preflight-live-deployment.mjs --release-manifest --kernel-uid --kernel-gid +ExecStart= /src/control-plane.mjs +Restart=on-failure +RestartSec=2s +UMask=0077 +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +LockPersonality=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +ReadWritePaths= +UnsetEnvironment=NODE_OPTIONS NODE_PATH LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT LD_DEBUG LD_PROFILE GLIBC_TUNABLES GCONV_PATH +``` + +The renderer emits absolute tokens as individual systemd arguments and rejects +whitespace rather than relying on shell quoting. The environment file remains subject +to the closed configuration allowlist, may not contain any `NODE_*` loader control, +`LD_*`, `DYLD_*`, `GCONV_PATH`, or `GLIBC_TUNABLES`, and is metadata-bound into the +report. The `UnsetEnvironment` defense is applied after all systemd environment +sources; runtime repeats the rejection before dynamic SDK imports. `ExecStartPre=+` +is the only privileged command; systemd's `+` execution deliberately bypasses the +per-command UID/capability/filesystem restrictions for that root preflight, while the +plain `ExecStart` receives empty bounding and ambient capability sets. The integration +test proves it can perform the exact +drop choreography above under the unit's sandbox; the long-running `ExecStart` has the +configured non-root UID/GID, no supplementary groups/capabilities, and write access +only to the four declared roots (the Pi-owned enrollment inbox remains read-only). A directive that weakens this list, grants release/ +unit/environment writes, invokes a shell, or derives executable paths from environment +fails the test. + +Host provisioning must also make the dedicated Kernel account a member of no +supplementary group in NSS/userdb; the empty `SupplementaryGroups=` directive prevents +unit-added groups but is not treated as proof that host membership is empty. Before +opening secrets or SQLite, live runtime parses `/proc/self/status`, requires the +`Groups:` set to contain no GID other than the exact primary GID (or to be empty under +the platform's representation), and fails closed otherwise. The Linux integration adds +the fixture Kernel user to an extra group, proves startup refusal, removes it, and then +proves green startup. +The same early `/proc/self/status` gate requires `CapInh`, `CapPrm`, `CapEff`, and +`CapAmb` all equal zero for the main process. Unit tests inject each nonzero field and +fail before secrets/SQLite; the root-prefixed preflight is tested separately and is +never mistaken for the long-running Kernel identity. + +The rendered `wallet-kernel-console.socket` must contain exactly one +`ListenStream=127.0.0.1:8405`, `Accept=no`, +`Service=wallet-kernel.service`, `FileDescriptorName=wallet-kernel-console`, and +`ReusePort=no`, followed by the exact install section +`[Install]` + `WantedBy=sockets.target`; it is installed/enabled separately under +`sockets.target` and has no +`PartOf=wallet-kernel.service`, so systemd retains the listener across service crashes. +The main process accepts exactly `LISTEN_PID === process.pid`, `LISTEN_FDS === 1`, and +`LISTEN_FDNAMES === 'wallet-kernel-console'`, adopts descriptor 3, verifies it is a +listening `AF_INET/SOCK_STREAM` socket bound to exact `127.0.0.1:8405`, clears the +activation variables, and never calls bind/listen by address. `ExecStartPre` does not +consume or pretend to validate `LISTEN_PID`; the non-root main process performs the +descriptor check before either application listener is admitted. + +Create `systemd-units.test.mjs` to render a synthetic immutable release, check both +installed artifact hashes appear in `serviceArtifacts`, run +`inspect-systemd-effective.mjs` after a real daemon reload, require its hash in the +manifest/report, and run `systemd-analyze +verify` plus `systemd-socket-activate --fdname=wallet-kernel-console` in mandatory +Linux CI. The inherited-FD fixture proves descriptor 3/name/address validation, then +crashes/restarts the service while a competing bind still gets `EADDRINUSE`. Static +negative cases remove/change every required directive, add a second listener, insert +an environment-derived executable/shell, make either installed unit mutable, or omit +either artifact from the manifest; all fail. Identity negatives pass an account name, +remap an NSS name after rendering, or alter one UID/GID copy in the unit, preflight +argv, manifest, isolation report, or runtime process fixture: name input is rejected, +remapping has no effect on the numeric unit, and every numeric disagreement fails +before secrets, SQLite, or listeners. A macOS local run reports this Linux +integration as explicitly skipped, never passed; `cdp-testnet` completion requires the +recorded Linux/systemd job to pass. + +Effective-config negatives install a drop-in, point `FragmentPath` at an alternate +unit, apply a runtime property, edit a fragment without `daemon-reload`, leave either +unit masked/transient/runtime-enabled, or change every security-relevant projected +property one at a time. They prove `DropInPaths`, fragment/state checks, +`NeedDaemonReload`, or the manifest hash rejects each case before the dropped child or +main process opens authority. A lifecycle fixture records the manifest projection +before first start, starts and cleanly restarts the service, and proves the effective +hash remains identical while the excluded Exec runtime timestamps/PID/status change; +altering any static executable/argv/flag field still changes the hash and blocks. The +fixture cleanup removes all unit/drop-in/enablement +artifacts and reloads PID1 even after a failed assertion. + +The Linux integration installs both synthetic units, runs `daemon-reload`, enables the +socket without starting it, and requires `systemctl is-enabled` to report `enabled` +with the exact root-owned `sockets.target.wants/wallet-kernel-console.socket` symlink. +It then starts the socket, proves activation survives a service crash and a simulated +target stop/start cycle, and fails if `[Install]`, `WantedBy=sockets.target`, the +enablement link, or the post-reload effective projection is absent or stale. + +Add `test:systemd` to `spikes/pi-wielder/package.json` for the systemd, release- +integrity, and agent-isolation files, and create +`.github/workflows/pi-wielder-systemd.yml`. The workflow has read-only repository +permissions, no secrets, `ubuntu-24.04`, exact Node `24.18.1`, `npm ci`, and actions +pinned to reviewed full commit SHAs. Its privileged step creates two disposable +non-root fixture identities, runs only the dedicated systemd integration wrapper under +`sudo -- /usr/bin/env -i PATH=/usr/bin:/bin` with a closed environment, then removes +the fixture units/users. +The job must not install/enable the real pilot units, contact CDP/Base Sepolia, upload +authority artifacts, or treat a skipped test as success. Task 16 records the workflow +run URL and commit alongside local verification before any `cdp-testnet` claim. + `agent revoke` is an authenticated operator mutation over the Unix admin channel or browser session, so a compromised Pi capability can be disabled without first stopping the Kernel; it never authorizes replacement @@ -5172,7 +5861,8 @@ node --test spikes/pi-wielder/tests/operator-auth.test.mjs \ spikes/pi-wielder/tests/operator-cli.test.mjs \ spikes/pi-wielder/tests/operator-console.test.mjs \ spikes/pi-wielder/tests/agent-isolation.test.mjs \ - spikes/pi-wielder/tests/release-integrity.test.mjs + spikes/pi-wielder/tests/release-integrity.test.mjs \ + spikes/pi-wielder/tests/systemd-units.test.mjs ``` Expected: FAIL until the operator modules and static console exist. @@ -5219,8 +5909,10 @@ export function createOperatorApp({ auth, services, bodyLimits, mode, transport, } ``` -Implement `release-integrity.mjs`, `build-release-manifest.mjs`, and -`preflight-live-deployment.mjs` against the exact manifest/prelaunch contracts above. +Implement `release-integrity.mjs`, `build-release-manifest.mjs`, +`render-systemd-units.mjs`, `inspect-systemd-effective.mjs`, +`preflight-live-deployment.mjs`, and +`prelaunch-kernel-reader.mjs` against the exact manifest/prelaunch contracts above. All filesystem tests inject a temporary synthetic release; the optional real-UID test uses only human-supplied safe fixture identities and never changes the developer checkout. @@ -5265,7 +5957,8 @@ raw evidence. ```bash node --test spikes/pi-wielder/tests/operator-*.test.mjs \ spikes/pi-wielder/tests/agent-isolation.test.mjs \ - spikes/pi-wielder/tests/release-integrity.test.mjs + spikes/pi-wielder/tests/release-integrity.test.mjs \ + spikes/pi-wielder/tests/systemd-units.test.mjs ``` Expected: authentication, exact routes, CLI exits, CSP, activated four-view console, @@ -5278,16 +5971,24 @@ git add spikes/pi-wielder/src/operator \ spikes/pi-wielder/src/kernel/release-integrity.mjs \ spikes/pi-wielder/src/agent/isolation-preflight.mjs \ spikes/pi-wielder/scripts/build-release-manifest.mjs \ + spikes/pi-wielder/scripts/render-systemd-units.mjs \ + spikes/pi-wielder/scripts/inspect-systemd-effective.mjs \ spikes/pi-wielder/scripts/preflight-live-deployment.mjs \ + spikes/pi-wielder/scripts/prelaunch-kernel-reader.mjs \ spikes/pi-wielder/scripts/preflight-agent-isolation.mjs \ spikes/pi-wielder/scripts/agent-isolation-probe-worker.mjs \ + spikes/pi-wielder/deploy/systemd/wallet-kernel.service \ + spikes/pi-wielder/deploy/systemd/wallet-kernel-console.socket \ + .github/workflows/pi-wielder-systemd.yml \ + spikes/pi-wielder/package.json \ spikes/pi-wielder/operator-console \ spikes/pi-wielder/tests/operator-auth.test.mjs \ spikes/pi-wielder/tests/operator-api.test.mjs \ spikes/pi-wielder/tests/operator-cli.test.mjs \ spikes/pi-wielder/tests/operator-console.test.mjs \ spikes/pi-wielder/tests/agent-isolation.test.mjs \ - spikes/pi-wielder/tests/release-integrity.test.mjs + spikes/pi-wielder/tests/release-integrity.test.mjs \ + spikes/pi-wielder/tests/systemd-units.test.mjs git commit -m "feat: add authenticated local wallet operations" ``` @@ -5381,7 +6082,6 @@ export function createAgentAuth({ store, intents, walletIdentity, activePolicy, kernelUid, kernelGid, expectedAgentUid, expectedAgentGid, mode }) { return Object.freeze({ authenticate(request), - openOrResumeSession(enrolledAgent), resolveBoundSession(authenticatedAgent), }); } @@ -5417,11 +6117,11 @@ remain zero. During composition and before either listener, load zero or one active enrollment—not the Pi credential file. With zero, enter the recovery-only composition above, preserve all revoked bindings for operator work, and call no open/create/signing method. With -one, inspect its binding before opening agent admission. With no binding, call Task 4's -atomic `openOrResumeSession({ agentInstanceId, walletAddress, +one, inspect its binding before opening agent admission. With no binding, call Task +10's coordinated `kernel.openOrResumeSession({ agentInstanceId, walletAddress, policyVersionId: activePolicy.id })`. With one exact `open` binding, require its wallet -and policy to equal the active configuration, then call `openOrResumeSession()` using -that already-pinned policy only to obtain the idempotent existing row. With one +and policy to equal the active configuration, then call that same Kernel facade using +the already-pinned policy only to obtain the idempotent existing row. With one `policy_blocked` binding, do not call an open/create method: preserve it for operator recovery/status and reject every agent execute/retry as `POLICY_TRANSITION_REQUIRED`; Task 10's explicit safe transition @@ -5429,8 +6129,10 @@ must close the old session and atomically rebind the same agent to the active po Any revoked-enrollment binding is retained as history but cannot be selected by an active replacement enrollment. Two candidate bindings, an `open` binding on a non-active policy, or any wallet/digest mismatch fails closed. -Task 4's paired operations remain the sole creators/replacers/closers of session and -binding rows; composition never inserts a binding separately. +Task 4's paired repository operations remain the sole underlying +creators/replacers/closers of session and binding rows; live composition reaches them +only through the shared-coordinator Kernel facade and never inserts a binding +separately. Restart with the same credential therefore reuses the exact session and pending intent without restoring spend admission; concurrent starts converge. An enrollment/digest/UID/GID mismatch, multiple open bindings, closed/missing referenced session, @@ -5555,6 +6257,27 @@ export async function startControlPlane(options = {}) { } ``` +After exclusive startup recovery restores receipt parity, `createControlPlane()` owns +one mutable in-memory admission state and one synchronous +`markAuthorityUnhealthy(code)` closure. That closure changes the gate from `open` to +`closed` before scheduling listener shutdown and is idempotent for the first stable +reason. Construct exactly one Task 7 `createAuthorityMutationCoordinator({ +assertAdmissionOpen, markAuthorityUnhealthy })` instance, then inject that exact object +identity and callback into both `createWalletKernel()` and `createReconciler()`. The +operator API receives those two facades and no mutable repository, so every live +operator mutation shares the same FIFO. Agent routes receive only the Kernel facade. +Neither facade, recovery, a route, nor a repository constructs another coordinator. + +In `control-plane.test.mjs`, inject spying Kernel/Reconciler factories and a coordinator +factory, assert the coordinator factory is called once, and assert strict object and +callback identity at both constructors. Queue one Kernel terminal receipt-gap fixture, +one reconciliation candidate write/network/resolution fixture, and one operator +mutation: prove FIFO order for each lease, prove the resolver runs only between two +released Reconciler leases, and prove a fail-stop from either facade closes the one +gate before every queued callback. A second coordinator construction, direct mutable +repository exposure to either HTTP app, or live composition without both injections +fails construction. + In `cdp-testnet`, `createControlPlane()` first runs Task 13's in-process release verification; this occurs before reading any secret, opening SQLite, or constructing an SDK client and yields the recomputed release-manifest hash. After authority recovery, @@ -5568,8 +6291,8 @@ recovery before constructing listeners. Zero selects `recovery_only`, with opera services plus the closed-denial agent app and no session/signer admission. One loads its zero-or-one exact binding and follows Task 14 Step 1's no-binding/open/policy-blocked startup algorithm before constructing the normal proxy -closure. It calls `openOrResumeSession()` only for the first two legal binding cases -and never for `policy_blocked`; it never opens the Pi credential file. The authenticated agent identity and read-only session +closure. It calls `kernel.openOrResumeSession()` only for the first two legal binding +cases and never for `policy_blocked`; it never opens the Pi credential file. The authenticated agent identity and read-only session resolver remain inside that closure; each admitted request resolves the current binding, so a guarded policy transition takes effect without a daemon restart. The session ID is never returned to Pi or read from an HTTP request, and startup does not @@ -5605,7 +6328,9 @@ Kernel must receive `AUTHORITY_BUSY` before it can mutate authority state. In normal `cdp-testnet` admission, preflight also requires non-root distinct Kernel/agent UIDs, pinned nonzero GIDs, exact enrollment/config identity agreement, -Kernel authority parent mode `0700`, and `currentFor()` returning the unexpired stored +Task 2's independently revalidated full trusted-ancestor chains for every configured +Kernel/config/report/evidence/socket/handoff path, exact terminal modes, and +`currentFor()` returning the unexpired stored isolation-attestation row for the exact active enrollment and freshly recomputed Kernel-accessible authority metadata, including the privileged report's attested Pi credential metadata and successful denial results imported from Task 13's @@ -5996,6 +6721,8 @@ The manifest’s closed schema is: status: 'simulated' | 'enforced', releaseManifestDigest: null | 'sha256:<64 lowercase hex>', releaseTreeHash: null | 'sha256:<64 lowercase hex>', + serviceArtifactsHash: null | 'sha256:<64 lowercase hex>', + systemdEffectiveConfigHash: null | 'sha256:<64 lowercase hex>', }, inputs: { policyHash, routeMapHash, configHash }, source: { @@ -6031,8 +6758,9 @@ null digest and `base-sepolia-testnet -> isolation.status = enforced` with a val unexpired imported preflight digest. Identity hashes are domain-separated hashes over the pinned UID/GID pair, never raw local identity/path values. No offline bundle may claim enforced isolation. The same mode relation applies to `deployment`: offline has -two null hashes, while testnet must match the root-owned release manifest/tree hashes -bound into the imported privileged report. +four null hashes, while testnet must match the root-owned release manifest/tree, +aggregate service-artifact hash, and PID1 effective-config hash bound into the +imported privileged report. Recursively scan the entire bundle for raw bodies, prompts, responses, payment signatures/payloads, agent credentials, operator token/raw identity, provider exceptions, and @@ -6104,7 +6832,8 @@ For testnet, `manifest.git.commit` comes from the attested release manifest and the live command never runs `git status` in a checkout. Offline/developer evidence still records the actual Git worktree state. -The run intent contains release manifest/tree hashes, commit, wallet address, policy hash, route hash, maximum total +The run intent contains release manifest/tree/service-artifact/effective-systemd +hashes, commit, wallet address, policy hash, route hash, maximum total atomic amount, exact seller routes, and expiry. Print its digest and exit `2` without the exact human-provided confirmation. Never request faucet funds, transfer funds, select mainnet, overwrite an evidence directory, or infer authorization from an @@ -6179,12 +6908,17 @@ files pass. Document: -- supported POSIX host requirements, Node 24.15+ for deterministic development, - exact Node 24.15.0 for the attested live release, and `npm ci`; +- supported POSIX host requirements, Node 24.18.1+ for deterministic development, + exact Node 24.18.1 for the attested live release, and `npm ci`; - privileged install from a clean commit into the root-owned immutable release tree, - release-manifest creation/verification, service-manager prelaunch, forbidden loader - environment, and separate Kernel-writable data/evidence roots; -- owner-only authority directory creation outside the checkout; + systemd-unit rendering to exact immutable paths, release-manifest creation/ + verification, mandatory daemon reload, PID1 effective-config hash verification, + `[Install] WantedBy=sockets.target`, enabling the console socket independently, + root-to-Kernel-child prelaunch choreography, forbidden loader environment, and + separate Kernel-writable data/evidence/runtime roots; +- owner-only authority directory creation outside the checkout, the configured + root-owned trusted ancestor, full descriptor-walk validation for every private/ + writable/config/handoff path, and explicit rejection of sticky writable ancestors; - distinct non-root Kernel/Pi UID provisioning, pinned GID, cleared supplementary groups, isolation probe/attestation, and the same-UID live startup refusal; - policy and route validation before start; @@ -6194,10 +6928,15 @@ Document: - Pi-owned credential creation, non-secret enrollment handoff/import, revocation, safe replacement, and restart-stable session binding; - the two directional handoff parents and wrong-direction write denial; -- the exact clean bootstrap and replacement order from Task 13, including fresh - isolation-attestation import before each live start; +- the exact clean bootstrap and replacement order from Task 13, including persistent + socket disablement before service stop, both-unit/job/listener/authority-lock + quiescence checks, failure-stays-disabled behavior, connection-storm verification, + and fresh isolation-attestation import before each live start; - operator token location/mode, live Unix admin CLI, root socket-activated loopback - console, one-time `console launch` flow, and deterministic fallback; + console, exact daemon-reload + `systemctl enable wallet-kernel-console.socket` + + effective-config check + `systemctl start wallet-kernel-console.socket` sequence + before service start, one-time `console launch` flow, crash-retained listener verification, and + deterministic fallback; - approval, denial, expiry, reconciliation, and full-refund observation procedures; - backup/restore as an offline SQLite file operation with integrity verification; - incident response for unresolved signing/payment, execution-evidence, and @@ -6211,6 +6950,8 @@ Document: Keep current results labeled `measured offline` and live CDP/Base Sepolia labeled `not-run` until a human authorizes and runs the testnet command. +The runbook must also distinguish local macOS deterministic tests from the mandatory +Linux/systemd CI result; a skipped systemd test can never satisfy the live-host gate. - [ ] **Step 6: Commit evidence tooling and operating documentation** @@ -6290,7 +7031,7 @@ automated command/result table temporary offline evidence path, manifest hash, and verification result live CDP and testnet status agent isolation status and preflight digest (`simulated` is not live-ready) -deployment status, release-manifest/tree hashes, and socket-activation status +deployment status, release-manifest/tree/service-artifact/PID1-effective hashes, and socket-activation status known limitations and unresolved records agent-doable follow-ups human-only CDP credential, wallet funding, testnet authorization, and commercialization items From 9dc190fa13e9502b59b6cc6ebee23f1116d7f4c0 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Fri, 31 Jul 2026 23:56:15 -0400 Subject: [PATCH 149/165] build: pin wallet kernel runtime --- spikes/pi-wielder/package-lock.json | 3529 ++++++++++++++++- spikes/pi-wielder/package.json | 9 + spikes/pi-wielder/src/kernel/canonical.mjs | 167 + .../tests/kernel-canonical.test.mjs | 196 + 4 files changed, 3841 insertions(+), 60 deletions(-) create mode 100644 spikes/pi-wielder/src/kernel/canonical.mjs create mode 100644 spikes/pi-wielder/tests/kernel-canonical.test.mjs diff --git a/spikes/pi-wielder/package-lock.json b/spikes/pi-wielder/package-lock.json index 5d0280a..2d62504 100644 --- a/spikes/pi-wielder/package-lock.json +++ b/spikes/pi-wielder/package-lock.json @@ -1,17 +1,26 @@ { - "name": "pi-wielder", - "version": "1.0.0", + "name": "pi-wielder-spike", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pi-wielder", - "version": "1.0.0", - "license": "ISC", + "name": "pi-wielder-spike", + "version": "0.1.0", + "license": "MIT", "dependencies": { + "@coinbase/cdp-sdk": "1.54.0", "@hono/node-server": "^2.0.8", + "@x402/core": "2.19.0", + "@x402/evm": "2.19.0", "hono": "^4.12.29", "viem": "^2.55.0" + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "0.80.6" + }, + "engines": { + "node": ">=24.18.1" } }, "node_modules/@adraffy/ens-normalize": { @@ -20,91 +29,3084 @@ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, - "node_modules/@hono/node-server": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.8.tgz", - "integrity": "sha512-GuCWzLxwg218fy1JaHculFsdcuY12hxit83V+algozTPnwhNjLrRL/Alg9OYjLZLoUZ1rw/S4CdTMsnkSKCmFA==", + "node_modules/@coinbase/cdp-sdk": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.54.0.tgz", + "integrity": "sha512-FfIJVEKAXgmr+dkXn/NBQO04/pps+oIgqEIdcmG67WZh+m07OsVnvSBEqEvbFO+VjSdaQAKu69EpO3NdHJd2Iw==", + "license": "MIT", + "dependencies": { + "@solana-program/system": "^0.10.0", + "@solana-program/token": "^0.9.0", + "@solana/kit": "^5.5.1", + "abitype": "1.0.6", + "axios": "1.16.0", + "axios-retry": "^4.5.0", + "bs58": "^6.0.0", + "jose": "^6.2.0", + "md5": "^2.3.0", + "uncrypto": "^0.1.3", + "viem": "^2.47.0", + "zod": "^3.25.76" + }, + "peerDependencies": { + "@x402/core": "^2.19.0", + "@x402/evm": "^2.19.0", + "@x402/extensions": "^2.19.0", + "@x402/svm": "^2.19.0" + }, + "peerDependenciesMeta": { + "@x402/core": { + "optional": true + }, + "@x402/evm": { + "optional": true + }, + "@x402/extensions": { + "optional": true + }, + "@x402/svm": { + "optional": true + } + } + }, + "node_modules/@coinbase/cdp-sdk/node_modules/abitype": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.6.tgz", + "integrity": "sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz", + "integrity": "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g==", + "dev": true, + "hasShrinkwrap": true, "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.80.6", + "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-tui": "^0.80.6", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.1.38", + "undici": "8.5.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, "engines": { - "node": ">=20" + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" }, "peerDependencies": { - "hono": "^4" + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } } }, - "node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.80.6", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.80.6", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/@hono/node-server": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.8.tgz", + "integrity": "sha512-GuCWzLxwg218fy1JaHculFsdcuY12hxit83V+algozTPnwhNjLrRL/Alg9OYjLZLoUZ1rw/S4CdTMsnkSKCmFA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@solana-program/system": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", + "integrity": "sha512-Go+LOEZmqmNlfr+Gjy5ZWAdY5HbYzk2RBewD9QinEU/bBSzpFfzqDRT55JjFRBGJUvMgf3C2vfXEGT4i8DSI4g==", + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0" + } + }, + "node_modules/@solana-program/token": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@solana-program/token/-/token-0.9.0.tgz", + "integrity": "sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==", + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^5.0" + } + }, + "node_modules/@solana/accounts": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-5.5.1.tgz", + "integrity": "sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/addresses": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-5.5.1.tgz", + "integrity": "sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/assertions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-5.5.1.tgz", + "integrity": "sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-5.5.1.tgz", + "integrity": "sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/options": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-5.5.1.tgz", + "integrity": "sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-data-structures": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-5.5.1.tgz", + "integrity": "sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-5.5.1.tgz", + "integrity": "sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-strings": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-5.5.1.tgz", + "integrity": "sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "fastestsmallesttextencoderdecoder": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/errors": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-5.5.1.tgz", + "integrity": "sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "commander": "14.0.2" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/fast-stable-stringify": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-5.5.1.tgz", + "integrity": "sha512-Ni7s2FN33zTzhTFgRjEbOVFO+UAmK8qi3Iu0/GRFYK4jN696OjKHnboSQH/EacQ+yGqS54bfxf409wU5dsLLCw==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/functional": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-5.5.1.tgz", + "integrity": "sha512-tTHoJcEQq3gQx5qsdsDJ0LEJeFzwNpXD80xApW9o/PPoCNimI3SALkZl+zNW8VnxRrV3l3yYvfHWBKe/X3WG3w==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instruction-plans": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-5.5.1.tgz", + "integrity": "sha512-7z3CB7YMcFKuVvgcnNY8bY6IsZ8LG61Iytbz7HpNVGX2u1RthOs1tRW8luTzSG1MPL0Ox7afyAVMYeFqSPHnaQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instructions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-5.5.1.tgz", + "integrity": "sha512-h0G1CG6S+gUUSt0eo6rOtsaXRBwCq1+Js2a+Ps9Bzk9q7YHNFA75/X0NWugWLgC92waRp66hrjMTiYYnLBoWOQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/keys": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-5.5.1.tgz", + "integrity": "sha512-KRD61cL7CRL+b4r/eB9dEoVxIf/2EJ1Pm1DmRYhtSUAJD2dJ5Xw8QFuehobOGm9URqQ7gaQl+Fkc1qvDlsWqKg==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/kit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-5.5.1.tgz", + "integrity": "sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==", + "license": "MIT", + "dependencies": { + "@solana/accounts": "5.5.1", + "@solana/addresses": "5.5.1", + "@solana/codecs": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instruction-plans": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/offchain-messages": "5.5.1", + "@solana/plugin-core": "5.5.1", + "@solana/programs": "5.5.1", + "@solana/rpc": "5.5.1", + "@solana/rpc-api": "5.5.1", + "@solana/rpc-parsed-types": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-subscriptions": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/signers": "5.5.1", + "@solana/sysvars": "5.5.1", + "@solana/transaction-confirmation": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/nominal-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-5.5.1.tgz", + "integrity": "sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/offchain-messages": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-5.5.1.tgz", + "integrity": "sha512-g+xHH95prTU+KujtbOzj8wn+C7ZNoiLhf3hj6nYq3MTyxOXtBEysguc97jJveUZG0K97aIKG6xVUlMutg5yxhw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/options": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-5.5.1.tgz", + "integrity": "sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/plugin-core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-5.5.1.tgz", + "integrity": "sha512-VUZl30lDQFJeiSyNfzU1EjYt2QZvoBFKEwjn1lilUJw7KgqD5z7mbV7diJhT+dLFs36i0OsjXvq5kSygn8YJ3A==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/programs": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-5.5.1.tgz", + "integrity": "sha512-7U9kn0Jsx1NuBLn5HRTFYh78MV4XN145Yc3WP/q5BlqAVNlMoU9coG5IUTJIG847TUqC1lRto3Dnpwm6T4YRpA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/promises": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-5.5.1.tgz", + "integrity": "sha512-T9lfuUYkGykJmppEcssNiCf6yiYQxJkhiLPP+pyAc2z84/7r3UVIb2tNJk4A9sucS66pzJnVHZKcZVGUUp6wzA==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-5.5.1.tgz", + "integrity": "sha512-ku8zTUMrkCWci66PRIBC+1mXepEnZH/q1f3ck0kJZ95a06bOTl5KU7HeXWtskkyefzARJ5zvCs54AD5nxjQJ+A==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/fast-stable-stringify": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/rpc-api": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-transport-http": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-api": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-5.5.1.tgz", + "integrity": "sha512-XWOQQPhKl06Vj0xi3RYHAc6oEQd8B82okYJ04K7N0Vvy3J4PN2cxeK7klwkjgavdcN9EVkYCChm2ADAtnztKnA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/rpc-parsed-types": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-parsed-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-5.5.1.tgz", + "integrity": "sha512-HEi3G2nZqGEsa3vX6U0FrXLaqnUCg4SKIUrOe8CezD+cSFbRTOn3rCLrUmJrhVyXlHoQVaRO9mmeovk31jWxJg==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-5.5.1.tgz", + "integrity": "sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/rpc-spec-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-5.5.1.tgz", + "integrity": "sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-5.5.1.tgz", + "integrity": "sha512-CTMy5bt/6mDh4tc6vUJms9EcuZj3xvK0/xq8IQ90rhkpYvate91RjBP+egvjgSayUg9yucU9vNuUpEjz4spM7w==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/fast-stable-stringify": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-subscriptions-api": "5.5.1", + "@solana/rpc-subscriptions-channel-websocket": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/subscribable": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-api": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-5.5.1.tgz", + "integrity": "sha512-5Oi7k+GdeS8xR2ly1iuSFkAv6CZqwG0Z6b1QZKbEgxadE1XGSDrhM2cn59l+bqCozUWCqh4c/A2znU/qQjROlw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/rpc-transformers": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-channel-websocket": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-5.5.1.tgz", + "integrity": "sha512-7tGfBBrYY8TrngOyxSHoCU5shy86iA9SRMRrPSyBhEaZRAk6dnbdpmUTez7gtdVo0BCvh9nzQtUycKWSS7PnFQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/rpc-subscriptions-spec": "5.5.1", + "@solana/subscribable": "5.5.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-spec": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-5.5.1.tgz", + "integrity": "sha512-iq+rGq5fMKP3/mKHPNB6MC8IbVW41KGZg83Us/+LE3AWOTWV1WT20KT2iH1F1ik9roi42COv/TpoZZvhKj45XQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/subscribable": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transformers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-5.5.1.tgz", + "integrity": "sha512-OsWqLCQdcrRJKvHiMmwFhp9noNZ4FARuMkHT5us3ustDLXaxOjF0gfqZLnMkulSLcKt7TGXqMhBV+HCo7z5M8Q==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transport-http": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-5.5.1.tgz", + "integrity": "sha512-yv8GoVSHqEV0kUJEIhkdOVkR2SvJ6yoWC51cJn2rSV7plr6huLGe0JgujCmB7uZhhaLbcbP3zxXxu9sOjsi7Fg==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1", + "@solana/rpc-spec": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "undici-types": "^7.19.2" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-types": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-5.5.1.tgz", + "integrity": "sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/nominal-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/signers": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-5.5.1.tgz", + "integrity": "sha512-FY0IVaBT2kCAze55vEieR6hag4coqcuJ31Aw3hqRH7mv6sV8oqwuJmUrx+uFwOp1gwd5OEAzlv6N4hOOple4sQ==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/offchain-messages": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/subscribable": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-5.5.1.tgz", + "integrity": "sha512-9K0PsynFq0CsmK1CDi5Y2vUIJpCqkgSS5yfDN0eKPgHqEptLEaia09Kaxc90cSZDZU5mKY/zv1NBmB6Aro9zQQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/sysvars": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-5.5.1.tgz", + "integrity": "sha512-k3Quq87Mm+geGUu1GWv6knPk0ALsfY6EKSJGw9xUJDHzY/RkYSBnh0RiOrUhtFm2TDNjOailg8/m0VHmi3reFA==", + "license": "MIT", + "dependencies": { + "@solana/accounts": "5.5.1", + "@solana/codecs": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "node_modules/@solana/transaction-confirmation": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-5.5.1.tgz", + "integrity": "sha512-j4mKlYPHEyu+OD7MBt3jRoX4ScFgkhZC6H65on4Fux6LMScgivPJlwnKoZMnsgxFgWds0pl+BYzSiALDsXlYtw==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.8.0" + "@solana/addresses": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/promises": "5.5.1", + "@solana/rpc": "5.5.1", + "@solana/rpc-subscriptions": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">=20.18.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "node_modules/@solana/transaction-messages": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-5.5.1.tgz", + "integrity": "sha512-aXyhMCEaAp3M/4fP0akwBBQkFPr4pfwoC5CLDq999r/FUwDax2RE/h4Ic7h2Xk+JdcUwsb+rLq85Y52hq84XvQ==", "license": "MIT", + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-types": "5.5.1" + }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">=20.18.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@scure/base": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", - "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "node_modules/@solana/transactions": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-5.5.1.tgz", + "integrity": "sha512-8hHtDxtqalZ157pnx6p8k10D7J/KY/biLzfgh9R09VNLLY3Fqi7kJvJCr7M2ik3oRll56pxhraAGCC9yIT6eOA==", "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" + "dependencies": { + "@solana/addresses": "5.5.1", + "@solana/codecs-core": "5.5.1", + "@solana/codecs-data-structures": "5.5.1", + "@solana/codecs-numbers": "5.5.1", + "@solana/codecs-strings": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/nominal-types": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/transaction-messages": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@scure/bip32": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", - "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", - "license": "MIT", + "node_modules/@x402/core": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.19.0.tgz", + "integrity": "sha512-IPQIHzIrwvbri1339QWHvwtiTTDbGsrF5Yff0LZfufmVXNdOSYbjY8oz6vZg7+4W13f7/k81NUMAFoDOU4gswQ==", + "license": "Apache-2.0", "dependencies": { - "@noble/curves": "~1.9.0", - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "zod": "^3.24.2" } }, - "node_modules/@scure/bip39": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", - "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", - "license": "MIT", + "node_modules/@x402/evm": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.19.0.tgz", + "integrity": "sha512-ycbYCjJmLC1I7tRQslAwB73FhJAVMPzsojYunWuR5hk+jKUwgC6djch1ZrKFC1f9lJi7IOiezZtRHj6TWyCCrg==", + "license": "Apache-2.0", "dependencies": { - "@noble/hashes": "~1.8.0", - "@scure/base": "~1.2.5" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "@x402/core": "~2.19.0", + "viem": "^2.48.11", + "zod": "^3.24.2" } }, "node_modules/abitype": { @@ -128,12 +3130,321 @@ } } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hono": { "version": "4.12.29", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.29.tgz", @@ -143,6 +3454,24 @@ "node": ">=16.9.0" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -158,6 +3487,56 @@ "ws": "*" } }, + "node_modules/jose": { + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.6.tgz", + "integrity": "sha512-HwMtbJjMw8rC8dUTwCNilHJD+fxTeKM3JV1eprSmTjS41qwXSSt6exJXgyPK1QOu0jB9eDYLESRDkB3qaT3jnw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ox": { "version": "0.14.30", "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.30.tgz", @@ -203,6 +3582,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.29.0.tgz", + "integrity": "sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==", + "license": "MIT" + }, "node_modules/viem": { "version": "2.55.0", "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.0.tgz", @@ -268,6 +3668,15 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/spikes/pi-wielder/package.json b/spikes/pi-wielder/package.json index 34f90b5..f4e86a6 100644 --- a/spikes/pi-wielder/package.json +++ b/spikes/pi-wielder/package.json @@ -19,8 +19,17 @@ }, "license": "MIT", "dependencies": { + "@coinbase/cdp-sdk": "1.54.0", "@hono/node-server": "^2.0.8", + "@x402/core": "2.19.0", + "@x402/evm": "2.19.0", "hono": "^4.12.29", "viem": "^2.55.0" + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "0.80.6" + }, + "engines": { + "node": ">=24.18.1" } } diff --git a/spikes/pi-wielder/src/kernel/canonical.mjs b/spikes/pi-wielder/src/kernel/canonical.mjs new file mode 100644 index 0000000..e1f8adb --- /dev/null +++ b/spikes/pi-wielder/src/kernel/canonical.mjs @@ -0,0 +1,167 @@ +import crypto from 'node:crypto'; + +export class KernelError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = 'KernelError'; + this.code = code; + } +} + +export function exactRecord(value, required, optional = [], code = 'SCHEMA', label = 'value') { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) { + throw new KernelError(code, `${label} must be one plain object`); + } + + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(value); + if (required.some((key) => !Object.hasOwn(value, key)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key)) + || keys.some((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return !descriptor.enumerable || !Object.hasOwn(descriptor, 'value'); + })) { + throw new KernelError(code, `${label} fields do not match the closed schema`); + } + + return structuredClone(value); +} + +function throwCanonicalTypeError(message) { + throw new KernelError('CANONICAL_TYPE', message); +} + +function canonicalSerialize(value, ancestors) { + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || Object.is(value, -0)) { + return throwCanonicalTypeError('canonical numbers must be safe integers'); + } + return String(value); + } + if (!value || typeof value !== 'object') { + return throwCanonicalTypeError('value is not canonical JSON data'); + } + if (ancestors.has(value)) { + return throwCanonicalTypeError('canonical JSON data must not contain cycles'); + } + + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + return throwCanonicalTypeError('canonical arrays must be ordinary arrays'); + } + + const keys = Reflect.ownKeys(value); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (!lengthDescriptor || lengthDescriptor.enumerable + || !Object.hasOwn(lengthDescriptor, 'value') + || keys.length !== lengthDescriptor.value + 1) { + return throwCanonicalTypeError( + 'canonical arrays must contain only dense enumerable data elements', + ); + } + + const elements = []; + for (let index = 0; index < lengthDescriptor.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + return throwCanonicalTypeError( + 'canonical arrays must contain only dense enumerable data elements', + ); + } + elements.push(canonicalSerialize(descriptor.value, ancestors)); + } + return `[${elements.join(',')}]`; + } + + if (Object.getPrototypeOf(value) !== Object.prototype) { + return throwCanonicalTypeError('value is not canonical JSON data'); + } + + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) { + return throwCanonicalTypeError( + 'canonical objects require enumerable string data properties', + ); + } + + const fields = []; + for (const key of keys.sort()) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + return throwCanonicalTypeError( + 'canonical objects require enumerable string data properties', + ); + } + fields.push(`${JSON.stringify(key)}:${canonicalSerialize(descriptor.value, ancestors)}`); + } + return `{${fields.join(',')}}`; + } finally { + ancestors.delete(value); + } +} + +export function canonicalJson(value) { + return canonicalSerialize(value, new Set()); +} + +export function sha256(value) { + if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) { + throw new KernelError('HASH_INPUT', 'hash input must be a string or bytes'); + } + const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : Buffer.from(value); + return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`; +} + +export function canonicalAtomic(value, label) { + if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value)) { + throw new KernelError('ATOMIC_FORMAT', `${label} must be canonical atomic USDC text`); + } + return Object.freeze({ text: value, value: BigInt(value) }); +} + +export function canonicalEvmHash(value, label) { + if (typeof value !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(value)) { + throw new KernelError('EVM_HASH_FORMAT', `${label} must be one 32-byte EVM hash`); + } + return value.toLowerCase(); +} + +export function canonicalToken(value, label, maximum = 200) { + if (!Number.isSafeInteger(maximum) || maximum < 1 + || typeof value !== 'string' || value.length > maximum + || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)) { + throw new KernelError('TOKEN_FORMAT', `${label} must be a bounded canonical token`); + } + return value; +} + +export function canonicalTimestamp(value, label) { + const milliseconds = typeof value === 'string' ? Date.parse(value) : Number.NaN; + if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) { + throw new KernelError('TIMESTAMP_FORMAT', `${label} must be a canonical ISO timestamp`); + } + return value; +} + +export function frozenCopy(value) { + const copy = structuredClone(value); + const seen = new WeakSet(); + const freeze = (item) => { + if (item && typeof item === 'object' && !seen.has(item)) { + seen.add(item); + for (const key of Reflect.ownKeys(item)) { + const descriptor = Object.getOwnPropertyDescriptor(item, key); + if (Object.hasOwn(descriptor, 'value')) freeze(descriptor.value); + } + Object.freeze(item); + } + return item; + }; + return freeze(copy); +} diff --git a/spikes/pi-wielder/tests/kernel-canonical.test.mjs b/spikes/pi-wielder/tests/kernel-canonical.test.mjs new file mode 100644 index 0000000..1a4112f --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-canonical.test.mjs @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + KernelError, + canonicalAtomic, + canonicalEvmHash, + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + sha256, +} from '../src/kernel/canonical.mjs'; + +function assertKernelError(action, code) { + assert.throws(action, (error) => { + assert.equal(error instanceof KernelError, true); + assert.equal(error.name, 'KernelError'); + assert.equal(error.code, code); + return true; + }); +} + +test('canonical JSON sorts object keys recursively without mutating input', () => { + const input = Object.freeze({ z: 1, a: Object.freeze({ y: 2, b: 3 }) }); + + assert.equal(canonicalJson(input), '{"a":{"b":3,"y":2},"z":1}'); + assert.deepEqual(input, { z: 1, a: { y: 2, b: 3 } }); + assert.equal(canonicalJson({ 2: 'two', 10: 'ten' }), '{"10":"ten","2":"two"}'); +}); + +test('atomic USDC accepts canonical nonnegative decimal strings only', () => { + const zero = canonicalAtomic('0', 'amount'); + const amount = canonicalAtomic('250000', 'amount'); + + assert.deepEqual(zero, { text: '0', value: 0n }); + assert.deepEqual(amount, { text: '250000', value: 250000n }); + assert.equal(Object.isFrozen(zero), true); + assert.equal(Object.isFrozen(amount), true); + + for (const value of [1, 1n, '', '01', '-1', '1.0', '1e6']) { + assertKernelError(() => canonicalAtomic(value, 'amount'), 'ATOMIC_FORMAT'); + } +}); + +test('closed records accept only exact own enumerable data fields', () => { + const source = { a: { nested: true } }; + const copy = exactRecord(source, ['a'], [], 'SHAPE', 'record'); + + assert.deepEqual(copy, source); + assert.notEqual(copy, source); + assert.notEqual(copy.a, source.a); + + const inherited = Object.assign(Object.create({ inherited: true }), { a: 1 }); + const withSymbol = { a: 1, [Symbol('hidden')]: 2 }; + const nonenumerable = Object.defineProperty({ a: 1 }, 'hidden', { + value: 2, + enumerable: false, + }); + let getterCalls = 0; + const accessor = Object.defineProperty({}, 'a', { + enumerable: true, + get() { + getterCalls += 1; + return 1; + }, + }); + + for (const value of [ + { a: 1, b: 2 }, + {}, + inherited, + Object.assign(Object.create(null), { a: 1 }), + withSymbol, + nonenumerable, + accessor, + ]) { + assertKernelError(() => exactRecord(value, ['a'], [], 'SHAPE', 'record'), 'SHAPE'); + } + assert.equal(getterCalls, 0); +}); + +test('sha256 hashes strings and bytes with an explicit lowercase prefix', () => { + assert.match(sha256(Buffer.from('wallet-kernel')), /^sha256:[0-9a-f]{64}$/); + assert.equal(sha256('wallet-kernel'), sha256(new Uint8Array(Buffer.from('wallet-kernel')))); + + for (const value of [null, 1, {}, new Uint16Array([1])]) { + assertKernelError(() => sha256(value), 'HASH_INPUT'); + } +}); + +test('EVM hashes canonicalize once before comparison or persistence', () => { + const upper = `0x${'AB'.repeat(32)}`; + assert.equal(canonicalEvmHash(upper, 'transaction'), `0x${'ab'.repeat(32)}`); + + for (const value of ['', 'ab'.repeat(32), `0x${'ab'.repeat(31)}`, `0x${'gg'.repeat(32)}`]) { + assertKernelError(() => canonicalEvmHash(value, 'transaction'), 'EVM_HASH_FORMAT'); + } +}); + +test('canonical JSON rejects values JSON would drop, coerce, or ambiguously encode', () => { + let objectGetterCalls = 0; + let arrayGetterCalls = 0; + const objectAccessor = Object.defineProperty({}, 'getter', { + enumerable: true, + get() { + objectGetterCalls += 1; + return 'must not run'; + }, + }); + const arrayAccessor = Object.defineProperty([1], '0', { + enumerable: true, + get() { + arrayGetterCalls += 1; + return 'must not run'; + }, + }); + const cyclic = {}; + cyclic.self = cyclic; + const customArray = [1]; + Object.setPrototypeOf(customArray, Object.create(Array.prototype)); + + for (const value of [ + undefined, + function functionValue() {}, + Symbol('x'), + 1n, + Number.POSITIVE_INFINITY, + Number.NaN, + -0, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + new Date('2026-07-31T00:00:00.000Z'), + { dropped: undefined }, + { fn() {} }, + { symbol: Symbol('x') }, + { bigint: 1n }, + { infinity: Number.POSITIVE_INFINITY }, + { negativeZero: -0 }, + { date: new Date('2026-07-31T00:00:00.000Z') }, + { a: 1, [Symbol('hidden')]: 2 }, + Object.assign(Object.create(null), { a: 1 }), + Object.defineProperty({ a: 1 }, 'hidden', { value: 2, enumerable: false }), + objectAccessor, + Object.defineProperty([1], 'hidden', { value: 2, enumerable: false }), + arrayAccessor, + Object.assign([1], { [Symbol('hidden')]: 2 }), + Object.assign([1], { extra: 2 }), + [1, , 3], + customArray, + cyclic, + ]) { + assertKernelError(() => canonicalJson(value), 'CANONICAL_TYPE'); + } + + assert.equal(objectGetterCalls, 0); + assert.equal(arrayGetterCalls, 0); +}); + +test('canonical tokens enforce the bounded ASCII grammar', () => { + assert.equal(canonicalToken('agent:alpha-1.2_name', 'agent id'), 'agent:alpha-1.2_name'); + assert.equal(canonicalToken('abcd', 'short token', 4), 'abcd'); + + for (const value of ['', '-agent', 'agent space', 'abcde']) { + const maximum = value === 'abcde' ? 4 : 200; + assertKernelError(() => canonicalToken(value, 'token', maximum), 'TOKEN_FORMAT'); + } +}); + +test('canonical timestamps require an exact ISO roundtrip', () => { + const timestamp = '2026-07-31T00:00:00.000Z'; + assert.equal(canonicalTimestamp(timestamp, 'created at'), timestamp); + + for (const value of [ + new Date(timestamp), + '2026-07-31T00:00:00Z', + '2026-07-30T20:00:00.000-04:00', + 'not-a-timestamp', + ]) { + assertKernelError(() => canonicalTimestamp(value, 'created at'), 'TIMESTAMP_FORMAT'); + } +}); + +test('frozen copies detach and recursively freeze nested values', () => { + const source = { nested: { values: [1, { ready: true }] } }; + const copy = frozenCopy(source); + + assert.deepEqual(copy, source); + assert.notEqual(copy, source); + assert.notEqual(copy.nested, source.nested); + assert.equal(Object.isFrozen(copy), true); + assert.equal(Object.isFrozen(copy.nested), true); + assert.equal(Object.isFrozen(copy.nested.values), true); + assert.equal(Object.isFrozen(copy.nested.values[1]), true); +}); From ed2f964000b5878ee576bc3de7ff9e66d6e0d059 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 00:16:00 -0400 Subject: [PATCH 150/165] fix: harden canonical copy boundaries --- spikes/pi-wielder/src/kernel/canonical.mjs | 77 ++++++++++++++++- .../tests/kernel-canonical.test.mjs | 85 +++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/spikes/pi-wielder/src/kernel/canonical.mjs b/spikes/pi-wielder/src/kernel/canonical.mjs index e1f8adb..bc03caa 100644 --- a/spikes/pi-wielder/src/kernel/canonical.mjs +++ b/spikes/pi-wielder/src/kernel/canonical.mjs @@ -8,6 +8,79 @@ export class KernelError extends Error { } } +function throwDataGraphError(code, label) { + throw new KernelError( + code, + `${label} must contain only primitives, plain objects, and dense arrays`, + ); +} + +function cloneDataGraph(value, code, label, seen = new Map()) { + if (value === null) return null; + if (['undefined', 'string', 'boolean', 'number', 'bigint'].includes(typeof value)) { + return value; + } + if (!value || typeof value !== 'object') { + return throwDataGraphError(code, label); + } + if (seen.has(value)) return seen.get(value); + + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + return throwDataGraphError(code, label); + } + const keys = Reflect.ownKeys(value); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (!lengthDescriptor || lengthDescriptor.enumerable + || !Object.hasOwn(lengthDescriptor, 'value') + || keys.length !== lengthDescriptor.value + 1) { + return throwDataGraphError(code, label); + } + + const descriptors = []; + for (let index = 0; index < lengthDescriptor.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + return throwDataGraphError(code, label); + } + descriptors.push(descriptor); + } + + const copy = new Array(lengthDescriptor.value); + seen.set(value, copy); + for (let index = 0; index < descriptors.length; index += 1) { + copy[index] = cloneDataGraph(descriptors[index].value, code, label, seen); + } + return copy; + } + + if (Object.getPrototypeOf(value) !== Object.prototype) { + return throwDataGraphError(code, label); + } + const keys = Reflect.ownKeys(value); + const descriptors = new Map(); + for (const key of keys) { + if (typeof key !== 'string') return throwDataGraphError(code, label); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + return throwDataGraphError(code, label); + } + descriptors.set(key, descriptor); + } + + const copy = {}; + seen.set(value, copy); + for (const [key, descriptor] of descriptors) { + Object.defineProperty(copy, key, { + configurable: true, + enumerable: true, + value: cloneDataGraph(descriptor.value, code, label, seen), + writable: true, + }); + } + return copy; +} + export function exactRecord(value, required, optional = [], code = 'SCHEMA', label = 'value') { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { @@ -25,7 +98,7 @@ export function exactRecord(value, required, optional = [], code = 'SCHEMA', lab throw new KernelError(code, `${label} fields do not match the closed schema`); } - return structuredClone(value); + return cloneDataGraph(value, code, label); } function throwCanonicalTypeError(message) { @@ -150,7 +223,7 @@ export function canonicalTimestamp(value, label) { } export function frozenCopy(value) { - const copy = structuredClone(value); + const copy = cloneDataGraph(value, 'CANONICAL_TYPE', 'frozen copy'); const seen = new WeakSet(); const freeze = (item) => { if (item && typeof item === 'object' && !seen.has(item)) { diff --git a/spikes/pi-wielder/tests/kernel-canonical.test.mjs b/spikes/pi-wielder/tests/kernel-canonical.test.mjs index 1a4112f..0298f6a 100644 --- a/spikes/pi-wielder/tests/kernel-canonical.test.mjs +++ b/spikes/pi-wielder/tests/kernel-canonical.test.mjs @@ -81,7 +81,32 @@ test('closed records accept only exact own enumerable data fields', () => { assert.equal(getterCalls, 0); }); +test('closed records reject unsafe nested data without invoking accessors', () => { + let getterCalls = 0; + const nestedAccessor = Object.defineProperty({}, 'secret', { + enumerable: true, + get() { + getterCalls += 1; + return 'must not run'; + }, + }); + + assertKernelError( + () => exactRecord({ a: nestedAccessor }, ['a'], [], 'SHAPE', 'record'), + 'SHAPE', + ); + assert.equal(getterCalls, 0); + + for (const nested of [new Date(), new Map(), { value: Symbol('hidden') }]) { + assertKernelError(() => exactRecord({ a: nested }, ['a'], [], 'SHAPE', 'record'), 'SHAPE'); + } +}); + test('sha256 hashes strings and bytes with an explicit lowercase prefix', () => { + assert.equal( + sha256('abc'), + 'sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); assert.match(sha256(Buffer.from('wallet-kernel')), /^sha256:[0-9a-f]{64}$/); assert.equal(sha256('wallet-kernel'), sha256(new Uint8Array(Buffer.from('wallet-kernel')))); @@ -166,6 +191,10 @@ test('canonical tokens enforce the bounded ASCII grammar', () => { const maximum = value === 'abcde' ? 4 : 200; assertKernelError(() => canonicalToken(value, 'token', maximum), 'TOKEN_FORMAT'); } + + for (const maximum of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, '4', null]) { + assertKernelError(() => canonicalToken('a', 'token', maximum), 'TOKEN_FORMAT'); + } }); test('canonical timestamps require an exact ISO roundtrip', () => { @@ -176,6 +205,7 @@ test('canonical timestamps require an exact ISO roundtrip', () => { new Date(timestamp), '2026-07-31T00:00:00Z', '2026-07-30T20:00:00.000-04:00', + '2025-02-29T00:00:00.000Z', 'not-a-timestamp', ]) { assertKernelError(() => canonicalTimestamp(value, 'created at'), 'TIMESTAMP_FORMAT'); @@ -194,3 +224,58 @@ test('frozen copies detach and recursively freeze nested values', () => { assert.equal(Object.isFrozen(copy.nested.values), true); assert.equal(Object.isFrozen(copy.nested.values[1]), true); }); + +test('frozen copies reject nested accessors without invoking them', () => { + let getterCalls = 0; + const nestedAccessor = Object.defineProperty({}, 'secret', { + enumerable: true, + get() { + getterCalls += 1; + return 'must not run'; + }, + }); + + assertKernelError(() => frozenCopy({ nested: nestedAccessor }), 'CANONICAL_TYPE'); + assert.equal(getterCalls, 0); +}); + +test('frozen copies reject mutable or ambiguous object and array shapes', () => { + const customArray = [1]; + Object.setPrototypeOf(customArray, Object.create(Array.prototype)); + + for (const value of [ + new Date('2026-07-31T00:00:00.000Z'), + new Map([['key', 'value']]), + new Set(['value']), + new Uint8Array([1]), + Object.assign(Object.create(null), { value: 1 }), + { value: Symbol('hidden') }, + { [Symbol('hidden')]: true }, + { fn() {} }, + Object.defineProperty({ visible: true }, 'hidden', { value: true, enumerable: false }), + Object.defineProperty([1], 'hidden', { value: true, enumerable: false }), + Object.assign([1], { extra: true }), + Object.assign([1], { [Symbol('hidden')]: true }), + [1, , 3], + customArray, + ]) { + assertKernelError(() => frozenCopy(value), 'CANONICAL_TYPE'); + } +}); + +test('frozen copies preserve and freeze cyclic plain data graphs', () => { + const source = { name: 'root' }; + const values = [source]; + source.self = source; + source.values = values; + values.push(values); + + const copy = frozenCopy(source); + + assert.notEqual(copy, source); + assert.equal(copy.self, copy); + assert.equal(copy.values[0], copy); + assert.equal(copy.values[1], copy.values); + assert.equal(Object.isFrozen(copy), true); + assert.equal(Object.isFrozen(copy.values), true); +}); From 0be3c200bf439cf5ad67f43a766737b2fa894c10 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 00:44:57 -0400 Subject: [PATCH 151/165] feat: add trusted authority path guard --- spikes/pi-wielder/src/kernel/trusted-path.mjs | 466 ++++++++++++++++++ .../tests/kernel-trusted-path.test.mjs | 376 ++++++++++++++ 2 files changed, 842 insertions(+) create mode 100644 spikes/pi-wielder/src/kernel/trusted-path.mjs create mode 100644 spikes/pi-wielder/tests/kernel-trusted-path.test.mjs diff --git a/spikes/pi-wielder/src/kernel/trusted-path.mjs b/spikes/pi-wielder/src/kernel/trusted-path.mjs new file mode 100644 index 0000000..bcb1c2a --- /dev/null +++ b/spikes/pi-wielder/src/kernel/trusted-path.mjs @@ -0,0 +1,466 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { canonicalJson, sha256 } from './canonical.mjs'; + +const MODES = new Set(['deterministic', 'cdp-testnet']); +const ROLES = new Set(['kernel-private', 'root-only']); +const SQLITE_SUFFIXES = new Set(['', '-wal', '-shm']); +const METADATA_DOMAIN = 'wallet-kernel/trusted-parent-metadata/v1\0'; +const DIRECTORY_FLAGS = fs.constants.O_RDONLY + | fs.constants.O_DIRECTORY + | fs.constants.O_NOFOLLOW; + +function fail(message, code) { + const error = new Error(message); + if (code) error.code = code; + throw error; +} + +function assertPlatformBoundary() { + if (typeof process.getuid !== 'function' + || !Number.isInteger(fs.constants.O_DIRECTORY) + || !Number.isInteger(fs.constants.O_NOFOLLOW)) { + fail('trusted authority paths require POSIX descriptor and O_NOFOLLOW semantics'); + } +} + +function assertUid(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + fail(`${label} UID must be a nonnegative safe integer`); + } +} + +function assertMode(value, label) { + if (!Number.isInteger(value) || value < 0 || value > 0o7777) { + fail(`${label} mode must be a bounded POSIX mode`); + } +} + +function pathComponents(value, label) { + if (typeof value !== 'string' || !path.isAbsolute(value)) { + fail(`${label} must be absolute`); + } + if (value.includes('\0') || value !== path.resolve(value)) { + fail(`${label} must be a direct canonical path without dot or empty components`); + } + const root = path.parse(value).root; + const components = value.slice(root.length).split(path.sep); + if (components.some((component) => component === '' || component === '.' || component === '..')) { + fail(`${label} must be a direct canonical path without dot or empty components`); + } + return components; +} + +function descendantComponents(trustedAncestor, parentPath) { + const relative = path.relative(trustedAncestor, parentPath); + if (relative === '') return []; + if (path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`)) { + fail('target parent must be beneath the trusted ancestor'); + } + const components = relative.split(path.sep); + if (components.some((component) => component === '' || component === '.' || component === '..')) { + fail('target parent must be directly beneath the trusted ancestor'); + } + return components; +} + +function assertLeafName(name) { + if (typeof name !== 'string' + || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name) + || Buffer.byteLength(name, 'utf8') > 128) { + fail('target leaf must be one bounded canonical name'); + } +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function wrapFileError(error, label) { + if (error?.code === 'ELOOP' || error?.code === 'ENOTDIR') { + const wrapped = new Error(`${label} rejected a symlink or non-directory`); + wrapped.code = error.code; + return wrapped; + } + const code = typeof error?.code === 'string' ? error.code : 'FILESYSTEM_ERROR'; + const wrapped = new Error(`${label} failed (${code})`); + wrapped.code = code; + return wrapped; +} + +function modeOf(stat) { + return Number(stat.mode & 0o7777n); +} + +function projectionFor(stat, role, depth) { + if (!stat.isDirectory()) fail('trusted path component must be a directory'); + const uid = Number(stat.uid); + const gid = Number(stat.gid); + if (!Number.isSafeInteger(uid) || !Number.isSafeInteger(gid)) { + fail('trusted path metadata contains an unsupported owner identifier'); + } + return { + role, + depth, + device: stat.dev.toString(10), + inode: stat.ino.toString(10), + uid, + gid, + mode: modeOf(stat), + }; +} + +function sameProjection(left, right) { + return left.role === right.role + && left.depth === right.depth + && left.device === right.device + && left.inode === right.inode + && left.uid === right.uid + && left.gid === right.gid + && left.mode === right.mode; +} + +function validatePolicy(projection, { + mode, + role, + kernelUid, + terminalOwnerUid, + terminalMode, +}) { + const terminal = projection.at(-1); + if (mode === 'deterministic') { + const currentUid = process.getuid(); + for (const component of projection) { + if (component.uid !== currentUid || component.mode !== 0o700) { + fail('deterministic trusted path components must be current-UID owner-only directories'); + } + } + } else if (role === 'root-only') { + for (const component of projection) { + if (component.uid !== 0 || (component.mode & 0o022) !== 0) { + fail('root-only trusted path components must be root-owned and not group/other writable'); + } + } + } else { + const ancestor = projection[0]; + if (ancestor.uid !== 0 || (ancestor.mode & 0o022) !== 0) { + fail('live trusted ancestor must be root-owned and not group/other writable'); + } + for (const component of projection.slice(1, -1)) { + if ((component.uid !== 0 && component.uid !== kernelUid) + || (component.mode & 0o022) !== 0) { + fail('Kernel-private intermediate must be root/Kernel-owned and not group/other writable'); + } + } + } + + if (terminal.uid !== terminalOwnerUid) { + fail('trusted terminal owner does not match the required terminal owner'); + } + if (terminal.mode !== terminalMode) { + fail('trusted terminal mode does not match the required terminal mode'); + } +} + +function procChild(parentDescriptor, name) { + return `/proc/self/fd/${parentDescriptor}/${name}`; +} + +function closeDescriptors(descriptors) { + let firstError; + for (let index = descriptors.length - 1; index >= 0; index -= 1) { + try { + fs.closeSync(descriptors[index]); + } catch (error) { + firstError ??= error; + } + } + descriptors.length = 0; + if (firstError) throw firstError; +} + +function openDirectory(location, label) { + let descriptor; + try { + descriptor = fs.openSync(location, DIRECTORY_FLAGS); + const stat = fs.fstatSync(descriptor, { bigint: true }); + if (!stat.isDirectory()) fail(`${label} must be a directory`); + return descriptor; + } catch (error) { + if (descriptor !== undefined) { + try { fs.closeSync(descriptor); } catch {} + } + if (error?.message === `${label} must be a directory`) throw error; + throw wrapFileError(error, label); + } +} + +export function openTrustedParent({ + mode, + trustedAncestor, + targetFile, + kernelUid, + agentUid, + terminalOwnerUid, + terminalMode, + role, +}) { + assertPlatformBoundary(); + if (!MODES.has(mode)) fail('trusted path mode must be deterministic or cdp-testnet'); + if (!ROLES.has(role)) fail('trusted path role must be kernel-private or root-only'); + assertUid(kernelUid, 'Kernel'); + assertUid(agentUid, 'Agent'); + assertUid(terminalOwnerUid, 'terminal owner'); + assertMode(terminalMode, 'terminal'); + pathComponents(trustedAncestor, 'trusted ancestor'); + pathComponents(targetFile, 'target file'); + + if (mode === 'deterministic') { + if (kernelUid !== process.getuid() || agentUid !== process.getuid()) { + fail('deterministic Kernel and Agent UIDs must both equal the current UID'); + } + } else { + if (kernelUid === 0 || agentUid === 0) { + fail('live Kernel and Pi UIDs must be nonzero'); + } + if (kernelUid === agentUid) fail('live Kernel and Pi UIDs must be distinct'); + if (role === 'kernel-private' && terminalOwnerUid !== kernelUid) { + fail('Kernel-private terminal owner must be the Kernel UID'); + } + if (role === 'root-only' && terminalOwnerUid !== 0) { + fail('root-only terminal owner must be root'); + } + if (process.platform !== 'linux') { + fail('cdp-testnet trusted paths require Linux'); + } + try { + if (!fs.statSync('/proc/self/fd').isDirectory()) throw new Error('not a directory'); + } catch { + fail('cdp-testnet trusted paths require the Linux /proc/self/fd boundary'); + } + } + + const canonicalParentPath = path.dirname(targetFile); + const leafName = path.basename(targetFile); + assertLeafName(leafName); + const descendants = descendantComponents(trustedAncestor, canonicalParentPath); + const chainPaths = [trustedAncestor]; + for (const component of descendants) { + chainPaths.push(path.join(chainPaths.at(-1), component)); + } + + const descriptors = []; + let closed = false; + try { + descriptors.push(openDirectory(trustedAncestor, 'trusted ancestor')); + for (let index = 0; index < descendants.length; index += 1) { + const location = mode === 'cdp-testnet' + ? procChild(descriptors.at(-1), descendants[index]) + : chainPaths[index + 1]; + descriptors.push(openDirectory(location, 'trusted descendant')); + } + + const originalProjection = descriptors.map((descriptor, depth) => projectionFor( + fs.fstatSync(descriptor, { bigint: true }), + role, + depth, + )); + validatePolicy(originalProjection, { + mode, + role, + kernelUid, + terminalOwnerUid, + terminalMode, + }); + const ancestorMetadataHash = sha256( + `${METADATA_DOMAIN}${canonicalJson(originalProjection)}`, + ); + const parentDescriptor = descriptors.at(-1); + + const assertOpen = () => { + if (closed) fail('trusted parent guard is closed'); + }; + + const verifyProjection = (actual, expected) => { + if (!sameProjection(actual, expected)) { + fail('trusted path descriptor or namespace metadata changed'); + } + }; + + const revalidate = () => { + assertOpen(); + for (let index = 0; index < descriptors.length; index += 1) { + const actual = projectionFor( + fs.fstatSync(descriptors[index], { bigint: true }), + role, + index, + ); + verifyProjection(actual, originalProjection[index]); + } + + for (let index = mode === 'cdp-testnet' ? 1 : 0; + index < descriptors.length; + index += 1) { + const location = mode === 'cdp-testnet' + ? procChild(descriptors[index - 1], descendants[index - 1]) + : chainPaths[index]; + const probe = openDirectory(location, 'trusted namespace'); + try { + const actual = projectionFor( + fs.fstatSync(probe, { bigint: true }), + role, + index, + ); + verifyProjection(actual, originalProjection[index]); + } finally { + fs.closeSync(probe); + } + } + return ancestorMetadataHash; + }; + + const childLocation = (name) => mode === 'cdp-testnet' + ? procChild(parentDescriptor, name) + : path.join(canonicalParentPath, name); + + const openBounded = (name, flags, creationMode, label) => { + assertOpen(); + if (!Number.isInteger(flags) || flags < 0) fail(`${label} flags must be an integer`); + if (creationMode !== undefined) assertMode(creationMode, label); + revalidate(); + let descriptor; + try { + const safeFlags = flags | fs.constants.O_NOFOLLOW; + descriptor = creationMode === undefined + ? fs.openSync(childLocation(name), safeFlags) + : fs.openSync(childLocation(name), safeFlags, creationMode); + revalidate(); + return descriptor; + } catch (error) { + if (descriptor !== undefined) { + try { fs.closeSync(descriptor); } catch {} + } + if (error?.message === 'trusted path descriptor or namespace metadata changed' + || error?.message === 'trusted parent guard is closed') { + throw error; + } + throw wrapFileError(error, label); + } + }; + + const assertSuffix = (suffix) => { + if (!SQLITE_SUFFIXES.has(suffix)) { + fail('SQLite sibling suffix is outside the closed suffix set'); + } + }; + + const privateNamePattern = new RegExp( + `^\\.${escapeRegExp(leafName)}\\.tmp-[1-9][0-9]*-[0-9a-f]{32}$`, + ); + const assertPrivateName = (name) => { + if (typeof name !== 'string' + || Buffer.byteLength(name, 'utf8') > 255 + || !privateNamePattern.test(name)) { + fail('name must match the exact private temporary name grammar'); + } + }; + + const openLeaf = (flags, creationMode) => openBounded( + leafName, + flags, + creationMode, + 'trusted leaf open', + ); + const openSibling = (suffix, flags) => { + assertSuffix(suffix); + return openBounded(`${leafName}${suffix}`, flags, undefined, 'SQLite sibling open'); + }; + const openNamedLeaf = (name, flags, creationMode) => { + assertPrivateName(name); + return openBounded(name, flags, creationMode, 'private temporary open'); + }; + + const linkNamedToLeaf = (name) => { + assertOpen(); + assertPrivateName(name); + revalidate(); + let sourceDescriptor; + try { + sourceDescriptor = openBounded(name, fs.constants.O_RDONLY, undefined, 'private temporary open'); + const source = fs.fstatSync(sourceDescriptor, { bigint: true }); + if (!source.isFile()) fail('private temporary link source must be a regular file'); + try { + fs.linkSync(childLocation(name), childLocation(leafName)); + } catch (error) { + throw wrapFileError(error, 'private temporary publish'); + } + let publishedDescriptor; + try { + publishedDescriptor = openBounded( + leafName, + fs.constants.O_RDONLY, + undefined, + 'published private leaf open', + ); + const published = fs.fstatSync(publishedDescriptor, { bigint: true }); + if (!published.isFile() || published.dev !== source.dev || published.ino !== source.ino) { + try { fs.unlinkSync(childLocation(leafName)); } catch {} + fail('private temporary publish did not preserve the held regular file'); + } + } finally { + if (publishedDescriptor !== undefined) fs.closeSync(publishedDescriptor); + } + revalidate(); + } finally { + if (sourceDescriptor !== undefined) fs.closeSync(sourceDescriptor); + } + }; + + const unlinkNamed = (name) => { + assertOpen(); + assertPrivateName(name); + revalidate(); + try { + fs.unlinkSync(childLocation(name)); + } catch (error) { + throw wrapFileError(error, 'private temporary unlink'); + } + revalidate(); + }; + + const fsyncParent = () => { + assertOpen(); + revalidate(); + fs.fsyncSync(parentDescriptor); + revalidate(); + }; + + const close = () => { + if (closed) return; + closed = true; + closeDescriptors(descriptors); + }; + + revalidate(); + return Object.freeze({ + canonicalParentPath, + ancestorMetadataHash, + status: mode === 'deterministic' ? 'simulated' : 'enforced', + openLeaf, + openSibling, + openNamedLeaf, + linkNamedToLeaf, + unlinkNamed, + fsyncParent, + revalidate, + close, + }); + } catch (error) { + closed = true; + try { + closeDescriptors(descriptors); + } catch {} + throw error; + } +} diff --git a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs new file mode 100644 index 0000000..0ec737a --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs @@ -0,0 +1,376 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { openTrustedParent } from '../src/kernel/trusted-path.mjs'; + +test('exports the trusted parent opener', () => { + assert.equal(typeof openTrustedParent, 'function'); +}); + +const CURRENT_UID = process.getuid(); +const METADATA_DOMAIN = 'wallet-kernel/trusted-parent-metadata/v1\0'; + +function makeFixture(t, { + ancestorMode = 0o700, + intermediateMode = 0o700, + terminalMode = 0o700, +} = {}) { + const trustedAncestor = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-parent-')); + fs.chmodSync(trustedAncestor, ancestorMode); + const intermediate = path.join(trustedAncestor, 'kernel'); + const terminal = path.join(intermediate, 'authority'); + fs.mkdirSync(intermediate, { mode: intermediateMode }); + fs.chmodSync(intermediate, intermediateMode); + fs.mkdirSync(terminal, { mode: terminalMode }); + fs.chmodSync(terminal, terminalMode); + const targetFile = path.join(terminal, 'kernel.sqlite'); + t.after(() => fs.rmSync(trustedAncestor, { force: true, recursive: true })); + return { trustedAncestor, intermediate, terminal, targetFile }; +} + +function deterministicOptions(fixture, overrides = {}) { + return { + mode: 'deterministic', + trustedAncestor: fixture.trustedAncestor, + targetFile: fixture.targetFile, + kernelUid: CURRENT_UID, + agentUid: CURRENT_UID, + terminalOwnerUid: CURRENT_UID, + terminalMode: 0o700, + role: 'kernel-private', + ...overrides, + }; +} + +function statProjection(role, paths) { + return paths.map((entry, depth) => { + const stat = fs.statSync(entry, { bigint: true }); + return { + role, + depth, + device: stat.dev.toString(10), + inode: stat.ino.toString(10), + uid: Number(stat.uid), + gid: Number(stat.gid), + mode: Number(stat.mode & 0o7777n), + }; + }); +} + +test('deterministic guard holds an owner-only chain and exposes only the frozen boundary', (t) => { + const fixture = makeFixture(t); + fs.writeFileSync(fixture.targetFile, 'database', { mode: 0o600 }); + fs.writeFileSync(`${fixture.targetFile}-wal`, 'wal', { mode: 0o600 }); + fs.writeFileSync(`${fixture.targetFile}-shm`, 'shm', { mode: 0o600 }); + + const guard = openTrustedParent(deterministicOptions(fixture)); + + assert.equal(Object.isFrozen(guard), true); + assert.deepEqual(Object.keys(guard), [ + 'canonicalParentPath', + 'ancestorMetadataHash', + 'status', + 'openLeaf', + 'openSibling', + 'openNamedLeaf', + 'linkNamedToLeaf', + 'unlinkNamed', + 'fsyncParent', + 'revalidate', + 'close', + ]); + assert.equal(guard.canonicalParentPath, fixture.terminal); + assert.equal(guard.status, 'simulated'); + + const leaf = guard.openLeaf(fs.constants.O_RDONLY); + try { + assert.equal(fs.readFileSync(leaf, 'utf8'), 'database'); + } finally { + fs.closeSync(leaf); + } + for (const [suffix, expected] of [['-wal', 'wal'], ['-shm', 'shm']]) { + const descriptor = guard.openSibling(suffix, fs.constants.O_RDONLY); + try { + assert.equal(fs.readFileSync(descriptor, 'utf8'), expected); + } finally { + fs.closeSync(descriptor); + } + } + + guard.fsyncParent(); + guard.revalidate(); + guard.close(); + guard.close(); +}); + +test('private temporary names publish by no-replace link and unlink only the exact name', (t) => { + const fixture = makeFixture(t); + const guard = openTrustedParent(deterministicOptions(fixture)); + const name = `.kernel.sqlite.tmp-${process.pid}-${'ab'.repeat(16)}`; + const descriptor = guard.openNamedLeaf( + name, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600, + ); + fs.writeFileSync(descriptor, 'candidate'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + + guard.linkNamedToLeaf(name); + assert.equal(fs.readFileSync(fixture.targetFile, 'utf8'), 'candidate'); + assert.throws(() => guard.linkNamedToLeaf(name), (error) => error.code === 'EEXIST'); + guard.unlinkNamed(name); + assert.equal(fs.existsSync(path.join(fixture.terminal, name)), false); + assert.equal(fs.readFileSync(fixture.targetFile, 'utf8'), 'candidate'); + guard.close(); +}); + +test('sibling and private-temp namespaces are closed and bounded', (t) => { + const fixture = makeFixture(t); + fs.writeFileSync(fixture.targetFile, 'database', { mode: 0o600 }); + const guard = openTrustedParent(deterministicOptions(fixture)); + + for (const suffix of ['-journal', '../escape', '/absolute', '-wal/escape', null]) { + assert.throws(() => guard.openSibling(suffix, fs.constants.O_RDONLY), /SQLite sibling suffix/); + } + for (const name of [ + `.kernel.sqlite.tmp-0-${'ab'.repeat(16)}`, + `.kernel.sqlite.tmp--1-${'ab'.repeat(16)}`, + `.kernel.sqlite.tmp-${process.pid}-${'AB'.repeat(16)}`, + `.kernelXsqlite.tmp-${process.pid}-${'ab'.repeat(16)}`, + `.other.tmp-${process.pid}-${'ab'.repeat(16)}`, + `.kernel.sqlite.tmp-${process.pid}-${'ab'.repeat(15)}`, + `../.kernel.sqlite.tmp-${process.pid}-${'ab'.repeat(16)}`, + ]) { + assert.throws( + () => guard.openNamedLeaf(name, fs.constants.O_RDONLY), + /private temporary name/, + ); + assert.throws(() => guard.linkNamedToLeaf(name), /private temporary name/); + assert.throws(() => guard.unlinkNamed(name), /private temporary name/); + } + guard.close(); +}); + +test('leaf opens are no-follow even when the caller omits O_NOFOLLOW', (t) => { + const fixture = makeFixture(t); + const outside = path.join(fixture.trustedAncestor, 'outside-file'); + fs.writeFileSync(outside, 'outside', { mode: 0o600 }); + fs.symlinkSync(outside, fixture.targetFile); + const guard = openTrustedParent(deterministicOptions(fixture)); + + assert.throws(() => guard.openLeaf(fs.constants.O_RDONLY), /symlink/); + guard.close(); +}); + +test('every operation rejects use after close while close remains idempotent', (t) => { + const fixture = makeFixture(t); + fs.writeFileSync(fixture.targetFile, 'database', { mode: 0o600 }); + const guard = openTrustedParent(deterministicOptions(fixture)); + const name = `.kernel.sqlite.tmp-${process.pid}-${'cd'.repeat(16)}`; + guard.close(); + + const actions = [ + () => guard.openLeaf(fs.constants.O_RDONLY), + () => guard.openSibling('', fs.constants.O_RDONLY), + () => guard.openNamedLeaf(name, fs.constants.O_RDONLY), + () => guard.linkNamedToLeaf(name), + () => guard.unlinkNamed(name), + () => guard.fsyncParent(), + () => guard.revalidate(), + ]; + for (const action of actions) assert.throws(action, /closed/); + assert.doesNotThrow(() => guard.close()); +}); + +test('mode, role, UID, path, and canonical-component inputs fail closed', (t) => { + const fixture = makeFixture(t); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-outside-')); + fs.chmodSync(outside, 0o700); + t.after(() => fs.rmSync(outside, { force: true, recursive: true })); + + const cases = [ + [deterministicOptions(fixture, { mode: 'live' }), /mode/], + [deterministicOptions(fixture, { role: 'other' }), /role/], + [deterministicOptions(fixture, { trustedAncestor: 'relative' }), /absolute/], + [deterministicOptions(fixture, { targetFile: 'relative' }), /absolute/], + [deterministicOptions(fixture, { targetFile: path.join(outside, 'file') }), /beneath/], + [deterministicOptions(fixture, { + targetFile: `${fixture.trustedAncestor}/./kernel/authority/kernel.sqlite`, + }), /canonical|dot/], + [deterministicOptions(fixture, { + targetFile: `${fixture.trustedAncestor}/kernel//authority/kernel.sqlite`, + }), /canonical|empty/], + [deterministicOptions(fixture, { trustedAncestor: `${fixture.trustedAncestor}/` }), /canonical/], + [deterministicOptions(fixture, { kernelUid: -1 }), /UID/], + [deterministicOptions(fixture, { agentUid: 1.5 }), /UID/], + [deterministicOptions(fixture, { + kernelUid: CURRENT_UID + 1, + agentUid: CURRENT_UID + 1, + }), /current UID/], + [deterministicOptions(fixture, { terminalMode: 0o10000 }), /mode/], + ]; + for (const [options, pattern] of cases) { + assert.throws(() => openTrustedParent(options), pattern); + } +}); + +test('deterministic chains reject symlink and non-directory components', (t) => { + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-target-')); + fs.chmodSync(target, 0o700); + t.after(() => fs.rmSync(target, { force: true, recursive: true })); + + { + const fixture = makeFixture(t); + const trustedLink = path.join(path.dirname(fixture.trustedAncestor), `trusted-link-${process.pid}`); + fs.symlinkSync(fixture.trustedAncestor, trustedLink); + t.after(() => fs.rmSync(trustedLink, { force: true })); + assert.throws( + () => openTrustedParent(deterministicOptions(fixture, { + trustedAncestor: trustedLink, + targetFile: path.join(trustedLink, 'kernel', 'authority', 'kernel.sqlite'), + })), + /symlink|directory/, + ); + } + { + const fixture = makeFixture(t); + fs.rmSync(fixture.intermediate, { recursive: true }); + fs.symlinkSync(target, fixture.intermediate); + assert.throws(() => openTrustedParent(deterministicOptions(fixture)), /symlink|directory/); + } + { + const fixture = makeFixture(t); + fs.rmSync(fixture.terminal, { recursive: true }); + fs.symlinkSync(target, fixture.terminal); + assert.throws(() => openTrustedParent(deterministicOptions(fixture)), /symlink|directory/); + } + { + const fixture = makeFixture(t); + fs.rmSync(fixture.intermediate, { recursive: true }); + fs.writeFileSync(fixture.intermediate, 'not a directory', { mode: 0o600 }); + assert.throws(() => openTrustedParent(deterministicOptions(fixture)), /directory/); + } +}); + +test('deterministic chains reject permissive ancestors, intermediates, and terminal parents', (t) => { + for (const key of ['ancestorMode', 'intermediateMode', 'terminalMode']) { + const fixture = makeFixture(t, { [key]: 0o755 }); + assert.throws(() => openTrustedParent(deterministicOptions(fixture)), /owner-only|mode/); + } +}); + +test('terminal owner and exact mode are authoritative', (t) => { + const fixture = makeFixture(t); + assert.throws( + () => openTrustedParent(deterministicOptions(fixture, { terminalOwnerUid: CURRENT_UID + 1 })), + /terminal owner/, + ); + assert.throws( + () => openTrustedParent(deterministicOptions(fixture, { terminalMode: 0o750 })), + /terminal mode/, + ); +}); + +test('metadata hash is domain-separated over the ordered path-free projection', (t) => { + const fixture = makeFixture(t); + const projection = statProjection('kernel-private', [ + fixture.trustedAncestor, + fixture.intermediate, + fixture.terminal, + ]); + const expected = sha256(`${METADATA_DOMAIN}${canonicalJson(projection)}`); + const guard = openTrustedParent(deterministicOptions(fixture)); + + assert.equal(guard.ancestorMetadataHash, expected); + assert.match(guard.ancestorMetadataHash, /^sha256:[0-9a-f]{64}$/); + assert.equal(guard.ancestorMetadataHash.includes(fixture.trustedAncestor), false); + assert.equal(guard.ancestorMetadataHash.includes(fixture.terminal), false); + guard.revalidate(); + assert.equal(guard.ancestorMetadataHash, expected); + guard.close(); +}); + +test('descriptor mode drift and namespace replacement are detected before reuse', (t) => { + { + const fixture = makeFixture(t); + const guard = openTrustedParent(deterministicOptions(fixture)); + fs.chmodSync(fixture.terminal, 0o755); + assert.throws(() => guard.revalidate(), /changed/); + guard.close(); + } + { + const fixture = makeFixture(t); + fs.writeFileSync(fixture.targetFile, 'old namespace', { mode: 0o600 }); + const guard = openTrustedParent(deterministicOptions(fixture)); + const moved = `${fixture.terminal}-moved`; + fs.renameSync(fixture.terminal, moved); + fs.mkdirSync(fixture.terminal, { mode: 0o700 }); + fs.writeFileSync(fixture.targetFile, 'replacement namespace', { mode: 0o600 }); + assert.throws(() => guard.openLeaf(fs.constants.O_RDONLY), /changed/); + guard.close(); + } +}); + +test('injected descriptor inode drift is detected by full-chain revalidation', (t) => { + const fixture = makeFixture(t); + const terminalStat = fs.statSync(fixture.terminal, { bigint: true }); + const guard = openTrustedParent(deterministicOptions(fixture)); + const original = fs.fstatSync; + fs.fstatSync = function injectedFstat(descriptor, options) { + const stat = original.call(fs, descriptor, options); + if (options?.bigint === true && stat.dev === terminalStat.dev && stat.ino === terminalStat.ino) { + return new Proxy(stat, { + get(target, property, receiver) { + if (property === 'ino') return target.ino + 1n; + return Reflect.get(target, property, receiver); + }, + }); + } + return stat; + }; + try { + assert.throws(() => guard.revalidate(), /changed/); + } finally { + fs.fstatSync = original; + guard.close(); + } +}); + +test('cdp-testnet rejects zero or equal Kernel and Pi UIDs before platform admission', (t) => { + const fixture = makeFixture(t); + assert.throws( + () => openTrustedParent(deterministicOptions(fixture, { + mode: 'cdp-testnet', kernelUid: 0, agentUid: 501, + })), + /nonzero/, + ); + assert.throws( + () => openTrustedParent(deterministicOptions(fixture, { + mode: 'cdp-testnet', kernelUid: 501, agentUid: 501, + })), + /distinct/, + ); +}); + +test('cdp-testnet fails closed on non-Linux and never returns simulated proof', { + skip: process.platform === 'linux' ? 'this negative admission test requires a non-Linux host' : false, +}, (t) => { + const fixture = makeFixture(t); + assert.throws( + () => openTrustedParent(deterministicOptions(fixture, { + mode: 'cdp-testnet', kernelUid: 501, agentUid: 502, + })), + /Linux/, + ); +}); + +test('real Linux root-owned and dropped-UID path integration', { + skip: process.platform !== 'linux' + ? 'requires Linux /proc/self/fd, root-owned fixtures, and disposable distinct UIDs' + : 'requires a separately provisioned privileged integration fixture', +}, () => {}); From 1c1a179c91851ce2f5b5e8acde9fc3cb39e239f5 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 01:15:30 -0400 Subject: [PATCH 152/165] fix: fail closed on trusted publish mismatch --- spikes/pi-wielder/src/kernel/trusted-path.mjs | 1 - .../tests/kernel-trusted-path.test.mjs | 49 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/spikes/pi-wielder/src/kernel/trusted-path.mjs b/spikes/pi-wielder/src/kernel/trusted-path.mjs index bcb1c2a..61c2cae 100644 --- a/spikes/pi-wielder/src/kernel/trusted-path.mjs +++ b/spikes/pi-wielder/src/kernel/trusted-path.mjs @@ -405,7 +405,6 @@ export function openTrustedParent({ ); const published = fs.fstatSync(publishedDescriptor, { bigint: true }); if (!published.isFile() || published.dev !== source.dev || published.ino !== source.ino) { - try { fs.unlinkSync(childLocation(leafName)); } catch {} fail('private temporary publish did not preserve the held regular file'); } } finally { diff --git a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs index 0ec737a..db07a66 100644 --- a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs +++ b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs @@ -129,6 +129,55 @@ test('private temporary names publish by no-replace link and unlink only the exa guard.close(); }); +test('publish mismatch fails closed without unlinking a replacement leaf by pathname', (t) => { + const fixture = makeFixture(t); + const guard = openTrustedParent(deterministicOptions(fixture)); + const name = `.kernel.sqlite.tmp-${process.pid}-${'ef'.repeat(16)}`; + const descriptor = guard.openNamedLeaf( + name, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600, + ); + fs.writeFileSync(descriptor, 'candidate'); + fs.closeSync(descriptor); + + const originalFstat = fs.fstatSync; + let regularFileFstats = 0; + let replacementInstalled = false; + fs.fstatSync = function replacePublishedLeaf(descriptorToInspect, options) { + const stat = originalFstat.call(fs, descriptorToInspect, options); + if (options?.bigint === true && stat.isFile()) { + regularFileFstats += 1; + if (regularFileFstats === 2) { + fs.unlinkSync(fixture.targetFile); + fs.writeFileSync(fixture.targetFile, 'replacement', { mode: 0o600 }); + replacementInstalled = true; + return new Proxy(stat, { + get(target, property, receiver) { + if (property === 'ino') return target.ino + 1n; + return Reflect.get(target, property, receiver); + }, + }); + } + } + return stat; + }; + + try { + assert.throws( + () => guard.linkNamedToLeaf(name), + /private temporary publish did not preserve the held regular file/, + ); + } finally { + fs.fstatSync = originalFstat; + guard.close(); + } + + assert.equal(replacementInstalled, true); + assert.equal(fs.existsSync(fixture.targetFile), true); + assert.equal(fs.readFileSync(fixture.targetFile, 'utf8'), 'replacement'); +}); + test('sibling and private-temp namespaces are closed and bounded', (t) => { const fixture = makeFixture(t); fs.writeFileSync(fixture.targetFile, 'database', { mode: 0o600 }); From d0f77d4fd4bec6cbc665c2fe07a34f76f83dc623 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 01:49:18 -0400 Subject: [PATCH 153/165] feat: secure wallet kernel storage paths --- .gitignore | 10 + spikes/pi-wielder/.env.example | 12 + .../pi-wielder/src/kernel/secure-storage.mjs | 380 ++++++++++ spikes/pi-wielder/src/kernel/trusted-path.mjs | 27 +- spikes/pi-wielder/tests/kernel-store.test.mjs | 714 ++++++++++++++++++ .../tests/kernel-trusted-path.test.mjs | 42 ++ 6 files changed, 1184 insertions(+), 1 deletion(-) create mode 100644 spikes/pi-wielder/src/kernel/secure-storage.mjs create mode 100644 spikes/pi-wielder/tests/kernel-store.test.mjs diff --git a/.gitignore b/.gitignore index 685583e..e3f9a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,13 @@ spikes/pi-wielder/**/*.key .agents/skills/* !.agents/skills/optimizing-claude-code-prompts/ skills-lock.json + +# Agent Spend Control Plane local authority +spikes/pi-wielder/**/*.sqlite +spikes/pi-wielder/**/*.sqlite-wal +spikes/pi-wielder/**/*.sqlite-shm +spikes/pi-wielder/**/*.operator-token +spikes/pi-wielder/**/*.receipt-key +spikes/pi-wielder/**/*.agent-credential +spikes/pi-wielder/**/*.agent-enrollment +spikes/pi-wielder/**/*.authority-lock.sqlite* diff --git a/spikes/pi-wielder/.env.example b/spikes/pi-wielder/.env.example index ce45e58..ece4914 100644 --- a/spikes/pi-wielder/.env.example +++ b/spikes/pi-wielder/.env.example @@ -63,3 +63,15 @@ LEDGER_FILE= # --- pricing (USDC per call) -------------------------------------------------- SKILL_PRICE_USDC=0.25 + +# --- Agent Spend Control Plane local authority (absolute, outside checkout) -- +WALLET_KERNEL_DB_FILE= +WALLET_KERNEL_RECEIPT_KEY_FILE= +WALLET_KERNEL_OPERATOR_TOKEN_FILE= +WALLET_KERNEL_TRUSTED_ANCESTOR= +WALLET_KERNEL_EXPECTED_AGENT_UID= +WALLET_KERNEL_EXPECTED_AGENT_GID= +WALLET_KERNEL_POLICY_FILE= +WALLET_KERNEL_ROUTE_FILE= +WALLET_KERNEL_PORT=8402 +WALLET_KERNEL_OPERATOR_PORT=8405 diff --git a/spikes/pi-wielder/src/kernel/secure-storage.mjs b/spikes/pi-wielder/src/kernel/secure-storage.mjs new file mode 100644 index 0000000..e47aa13 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/secure-storage.mjs @@ -0,0 +1,380 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { openTrustedParent } from './trusted-path.mjs'; + +const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../../', import.meta.url))); +const MAXIMUM_PRIVATE_BYTES = 1_048_576; +const NOFOLLOW = fs.constants.O_NOFOLLOW; +const PRIVATE_TEMP_LIST = Symbol.for( + 'skill-asset-protocol.wallet-kernel.trusted-parent.private-temp-list.v1', +); +const SQLITE_SUFFIXES = Object.freeze(['', '-wal', '-shm']); +const ABSENT = Symbol('absent-private-file'); + +function assertSecurePlatform() { + if (typeof process.getuid !== 'function' || !Number.isInteger(NOFOLLOW)) { + throw new Error('Wallet Kernel pilot requires POSIX owner and O_NOFOLLOW semantics'); + } +} + +function assertPathTrust(pathTrust) { + if (pathTrust === null + || typeof pathTrust !== 'object' + || !Object.isFrozen(pathTrust)) { + throw new Error('Wallet Kernel file access requires an explicit frozen pathTrust object'); + } + return pathTrust; +} + +function inside(parent, child) { + const relative = path.relative(parent, child); + return relative === '' + || (!relative.startsWith(`..${path.sep}`) && relative !== '..'); +} + +function assertOwner(stat, label) { + assertSecurePlatform(); + if (stat.uid !== process.getuid()) { + throw new Error(`${label} must be owned by the current user`); + } +} + +function assertOwnerOnlyRegular(stat, label) { + assertOwner(stat, label); + if (!stat.isFile()) throw new Error(`${label} must be a regular file`); + if ((stat.mode & 0o777) !== 0o600) { + throw new Error(`${label} must be an owner-only regular file`); + } +} + +function privateParent(filePath, label, checkoutRoot, pathTrust) { + assertSecurePlatform(); + const trust = assertPathTrust(pathTrust); + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) { + throw new Error(`${label} path must be absolute`); + } + const lexicalParent = path.resolve(path.dirname(filePath)); + if (inside(checkoutRoot, lexicalParent)) { + throw new Error(`${label} must be outside the checkout`); + } + return openTrustedParent({ + ...trust, + targetFile: filePath, + terminalOwnerUid: process.getuid(), + terminalMode: 0o700, + role: 'kernel-private', + }); +} + +function readValidatedDescriptor(descriptor, label, validateBytes) { + const stat = fs.fstatSync(descriptor); + assertOwnerOnlyRegular(stat, label); + if (stat.size <= 0 || stat.size > MAXIMUM_PRIVATE_BYTES) { + throw new Error(`${label} must not be empty and must remain within the size boundary`); + } + + const bytes = fs.readFileSync(descriptor); + try { + if (bytes.length <= 0 || bytes.length > MAXIMUM_PRIVATE_BYTES) { + throw new Error(`${label} must not be empty and must remain within the size boundary`); + } + return validateBytes(bytes); + } finally { + bytes.fill(0); + } +} + +export function preparePrivateFile(filePath, label, { + checkoutRoot = CHECKOUT_ROOT, + pathTrust, +} = {}) { + const guard = privateParent(filePath, label, checkoutRoot, pathTrust); + try { + try { + const created = guard.openLeaf( + fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, + 0o600, + ); + fs.closeSync(created); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + + const descriptor = guard.openLeaf(fs.constants.O_RDONLY | NOFOLLOW); + try { + assertOwnerOnlyRegular(fs.fstatSync(descriptor), label); + guard.revalidate(); + } finally { + fs.closeSync(descriptor); + } + return filePath; + } finally { + guard.close(); + } +} + +export function readPrivateInputFile(filePath, label, { + checkoutRoot = CHECKOUT_ROOT, + maximumBytes = MAXIMUM_PRIVATE_BYTES, + pathTrust, +} = {}) { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { + throw new Error(`${label} maximum size must be a positive safe integer`); + } + const guard = privateParent(filePath, label, checkoutRoot, pathTrust); + let descriptor; + try { + descriptor = guard.openLeaf(fs.constants.O_RDONLY | NOFOLLOW); + const stat = fs.fstatSync(descriptor); + assertOwnerOnlyRegular(stat, label); + if (stat.size <= 0 || stat.size > maximumBytes) { + throw new Error(`${label} size is outside the allowed boundary`); + } + const bytes = fs.readFileSync(descriptor); + if (bytes.length <= 0 || bytes.length > maximumBytes) { + bytes.fill(0); + throw new Error(`${label} size is outside the allowed boundary`); + } + guard.revalidate(); + return bytes; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + guard.close(); + } +} + +export function preflightSqliteFiles(databasePath, { pathTrust } = {}) { + const guard = privateParent( + databasePath, + 'Wallet Kernel database', + CHECKOUT_ROOT, + pathTrust, + ); + const existing = new Set(); + try { + for (const suffix of SQLITE_SUFFIXES) { + const target = `${databasePath}${suffix}`; + let descriptor; + try { + descriptor = guard.openSibling(suffix, fs.constants.O_RDONLY | NOFOLLOW); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + try { + const label = `SQLite ${suffix || 'database'}`; + assertOwnerOnlyRegular(fs.fstatSync(descriptor), label); + existing.add(target); + } finally { + fs.closeSync(descriptor); + } + } + guard.revalidate(); + return existing; + } finally { + guard.close(); + } +} + +export function secureNewSqliteSideFiles(databasePath, existing, { pathTrust } = {}) { + if (!(existing instanceof Set)) { + throw new Error('SQLite preflight state must be a Set'); + } + const guard = privateParent( + databasePath, + 'Wallet Kernel database', + CHECKOUT_ROOT, + pathTrust, + ); + try { + for (const suffix of SQLITE_SUFFIXES) { + const target = `${databasePath}${suffix}`; + let descriptor; + try { + descriptor = guard.openSibling(suffix, fs.constants.O_RDONLY | NOFOLLOW); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + try { + const label = `SQLite ${suffix || 'database'}`; + const stat = fs.fstatSync(descriptor); + assertOwner(stat, label); + if (!stat.isFile()) throw new Error(`${label} must be regular`); + if (existing.has(target)) { + if ((stat.mode & 0o777) !== 0o600) { + throw new Error(`${label} must be owner-only`); + } + } else { + fs.fchmodSync(descriptor, 0o600); + } + } finally { + fs.closeSync(descriptor); + } + } + guard.revalidate(); + } finally { + guard.close(); + } + preflightSqliteFiles(databasePath, { pathTrust }); +} + +function candidateError(label, error) { + const wrapped = new Error(`${label} candidate ${error.message}`); + if (error.code) wrapped.code = error.code; + return wrapped; +} + +function validateCandidate(guard, name, label, validateBytes) { + let descriptor; + try { + descriptor = guard.openNamedLeaf(name, fs.constants.O_RDONLY | NOFOLLOW); + } catch (error) { + if (error.code === 'ENOENT') return false; + throw candidateError(label, error); + } + + try { + try { + readValidatedDescriptor(descriptor, `${label} candidate`, validateBytes); + } catch (error) { + if (/owned by|owner-only|regular file|empty|size boundary/.test(error.message)) throw error; + throw new Error(`${label} candidate is invalid: ${error.message}`); + } + guard.revalidate(); + return true; + } finally { + fs.closeSync(descriptor); + } +} + +function unlinkCandidate(guard, name) { + try { + guard.unlinkNamed(name); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + guard.fsyncParent(); +} + +export function loadOrInitializePrivateFile({ + filePath, + label, + createBytes, + validateBytes, + randomBytes = crypto.randomBytes, + faultInjector = () => {}, + pathTrust, +}) { + if (typeof createBytes !== 'function' || typeof validateBytes !== 'function') { + throw new Error(`${label} initializer and validator must be functions`); + } + if (typeof randomBytes !== 'function' || typeof faultInjector !== 'function') { + throw new Error(`${label} randomness and fault injector must be functions`); + } + + const guard = privateParent(filePath, label, CHECKOUT_ROOT, pathTrust); + const readExisting = () => { + const descriptor = guard.openLeaf(fs.constants.O_RDONLY | NOFOLLOW); + try { + const value = readValidatedDescriptor(descriptor, label, validateBytes); + guard.revalidate(); + return value; + } finally { + fs.closeSync(descriptor); + } + }; + + const readExistingOrAbsent = () => { + try { + return readExisting(); + } catch (error) { + if (error.code === 'ENOENT') return ABSENT; + throw error; + } + }; + + const recover = () => { + const listPrivateNames = guard[PRIVATE_TEMP_LIST]; + if (typeof listPrivateNames !== 'function') { + throw new Error('trusted parent does not expose private recovery enumeration'); + } + const names = listPrivateNames(); + const validated = []; + for (const name of names) { + if (validateCandidate(guard, name, label, validateBytes)) validated.push(name); + } + + let existing = readExistingOrAbsent(); + if (existing === ABSENT && validated.length > 0) { + try { + guard.linkNamedToLeaf(validated[0]); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + guard.fsyncParent(); + existing = readExisting(); + } + + if (existing !== ABSENT) { + for (const name of validated) unlinkCandidate(guard, name); + } + return existing; + }; + + let bytes; + let temporaryCreated = false; + let temporaryName; + try { + const recovered = recover(); + if (recovered !== ABSENT) return recovered; + + bytes = Buffer.from(createBytes()); + if (bytes.length === 0) throw new Error(`${label} initializer returned empty content`); + if (bytes.length > MAXIMUM_PRIVATE_BYTES) { + throw new Error(`${label} initializer exceeded the size boundary`); + } + validateBytes(bytes); + + const random = Buffer.from(randomBytes(16)); + if (random.length !== 16) throw new Error(`${label} initializer requires 16 random bytes`); + const suffix = random.toString('hex'); + random.fill(0); + temporaryName = `.${path.basename(filePath)}.tmp-${process.pid}-${suffix}`; + const descriptor = guard.openNamedLeaf( + temporaryName, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, + 0o600, + ); + temporaryCreated = true; + try { + fs.writeFileSync(descriptor, bytes); + faultInjector('after_private_temp_write'); + fs.fsyncSync(descriptor); + faultInjector('after_private_temp_fsync'); + } finally { + fs.closeSync(descriptor); + } + + try { + guard.linkNamedToLeaf(temporaryName); + faultInjector('after_private_publish'); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + guard.fsyncParent(); + faultInjector('after_private_directory_fsync'); + return readExisting(); + } finally { + try { + if (bytes) bytes.fill(0); + if (temporaryCreated) unlinkCandidate(guard, temporaryName); + guard.revalidate(); + } finally { + guard.close(); + } + } +} diff --git a/spikes/pi-wielder/src/kernel/trusted-path.mjs b/spikes/pi-wielder/src/kernel/trusted-path.mjs index 61c2cae..944e43f 100644 --- a/spikes/pi-wielder/src/kernel/trusted-path.mjs +++ b/spikes/pi-wielder/src/kernel/trusted-path.mjs @@ -7,6 +7,9 @@ const MODES = new Set(['deterministic', 'cdp-testnet']); const ROLES = new Set(['kernel-private', 'root-only']); const SQLITE_SUFFIXES = new Set(['', '-wal', '-shm']); const METADATA_DOMAIN = 'wallet-kernel/trusted-parent-metadata/v1\0'; +const LIST_PRIVATE_NAMES = Symbol.for( + 'skill-asset-protocol.wallet-kernel.trusted-parent.private-temp-list.v1', +); const DIRECTORY_FLAGS = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW; @@ -365,6 +368,21 @@ export function openTrustedParent({ fail('name must match the exact private temporary name grammar'); } }; + const listPrivateNames = () => { + assertOpen(); + revalidate(); + let names; + try { + const parentLocation = mode === 'cdp-testnet' + ? `/proc/self/fd/${parentDescriptor}` + : canonicalParentPath; + names = fs.readdirSync(parentLocation, { encoding: 'utf8' }); + } catch (error) { + throw wrapFileError(error, 'private temporary listing'); + } + revalidate(); + return Object.freeze(names.filter((name) => privateNamePattern.test(name)).sort()); + }; const openLeaf = (flags, creationMode) => openBounded( leafName, @@ -442,7 +460,7 @@ export function openTrustedParent({ }; revalidate(); - return Object.freeze({ + const guard = { canonicalParentPath, ancestorMetadataHash, status: mode === 'deterministic' ? 'simulated' : 'enforced', @@ -454,7 +472,14 @@ export function openTrustedParent({ fsyncParent, revalidate, close, + }; + Object.defineProperty(guard, LIST_PRIVATE_NAMES, { + configurable: false, + enumerable: false, + value: listPrivateNames, + writable: false, }); + return Object.freeze(guard); } catch (error) { closed = true; try { diff --git a/spikes/pi-wielder/tests/kernel-store.test.mjs b/spikes/pi-wielder/tests/kernel-store.test.mjs new file mode 100644 index 0000000..7574f22 --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-store.test.mjs @@ -0,0 +1,714 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + loadOrInitializePrivateFile, + preflightSqliteFiles, + preparePrivateFile, + readPrivateInputFile, + secureNewSqliteSideFiles, +} from '../src/kernel/secure-storage.mjs'; + +const CURRENT_UID = process.getuid(); +const REPOSITORY_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../', import.meta.url))); +const SECURE_STORAGE_URL = new URL('../src/kernel/secure-storage.mjs', import.meta.url).href; +const PRIVATE_VALUE_PATTERN = /^secret:[0-9a-f]{32}\n$/; + +function authority(t, prefix = 'wallet-kernel-storage-') { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.chmodSync(directory, 0o700); + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: CURRENT_UID, + agentUid: CURRENT_UID, + }); + t.after(() => fs.rmSync(directory, { force: true, recursive: true })); + return { + directory, + databasePath: path.join(directory, 'kernel.sqlite'), + privatePath: path.join(directory, 'receipt.receipt-key'), + pathTrust, + }; +} + +function secret(pair) { + return `secret:${pair.repeat(16)}\n`; +} + +function validatePrivateValue(bytes) { + const value = Buffer.from(bytes).toString('utf8'); + if (!PRIVATE_VALUE_PATTERN.test(value)) throw new Error('invalid private value'); + return value; +} + +function privateCandidateNames(directory, basename) { + const escaped = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`^\\.${escaped}\\.tmp-[1-9][0-9]*-[0-9a-f]{32}$`); + return fs.readdirSync(directory).filter((name) => pattern.test(name)).sort(); +} + +async function storageChildMain() { + const fsModule = (await import('node:fs')).default; + const [moduleUrl, payloadText] = process.argv.slice(1); + const payload = JSON.parse(payloadText); + const storage = await import(moduleUrl); + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: payload.directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); + const validateBytes = (bytes) => { + const value = Buffer.from(bytes).toString('utf8'); + if (!/^secret:[0-9a-f]{32}\n$/.test(value)) throw new Error('invalid private value'); + return value; + }; + + if (payload.readyFile) { + fsModule.writeFileSync(payload.readyFile, 'ready', { mode: 0o600 }); + while (!fsModule.existsSync(payload.releaseFile)) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } + + const result = storage.loadOrInitializePrivateFile({ + filePath: payload.filePath, + label: 'Receipt key', + createBytes: () => Buffer.from(payload.value, 'utf8'), + validateBytes, + faultInjector: (point) => { + if (point === payload.faultPoint) process.abort(); + }, + pathTrust, + }); + process.stdout.write(JSON.stringify({ result })); +} + +const STORAGE_CHILD_SCRIPT = `(${storageChildMain.toString()})().catch((error) => { + process.stderr.write(String(error && error.message ? error.message : error)); + process.exitCode = 1; +})`; + +function runStorageChild(payload) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ + '--input-type=module', + '-e', + STORAGE_CHILD_SCRIPT, + SECURE_STORAGE_URL, + JSON.stringify(payload), + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal, stderr, stdout })); + }); +} + +async function waitForFiles(files, timeoutMilliseconds = 5_000) { + const deadline = Date.now() + timeoutMilliseconds; + while (!files.every((file) => fs.existsSync(file))) { + if (Date.now() >= deadline) throw new Error('timed out waiting for child readiness'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +test('exports the secure storage boundary', () => { + for (const value of [ + preparePrivateFile, + readPrivateInputFile, + preflightSqliteFiles, + secureNewSqliteSideFiles, + loadOrInitializePrivateFile, + ]) { + assert.equal(typeof value, 'function'); + } +}); + +test('local authority artifacts are ignored and the environment template stays nonsecret', () => { + const gitignore = fs.readFileSync(path.join(REPOSITORY_ROOT, '.gitignore'), 'utf8'); + const expectedIgnoreBlock = `# Agent Spend Control Plane local authority +spikes/pi-wielder/**/*.sqlite +spikes/pi-wielder/**/*.sqlite-wal +spikes/pi-wielder/**/*.sqlite-shm +spikes/pi-wielder/**/*.operator-token +spikes/pi-wielder/**/*.receipt-key +spikes/pi-wielder/**/*.agent-credential +spikes/pi-wielder/**/*.agent-enrollment +spikes/pi-wielder/**/*.authority-lock.sqlite*`; + assert.equal(gitignore.includes(expectedIgnoreBlock), true); + + const environment = fs.readFileSync( + path.join(REPOSITORY_ROOT, 'spikes/pi-wielder/.env.example'), + 'utf8', + ); + const expectedEnvironmentBlock = `# --- Agent Spend Control Plane local authority (absolute, outside checkout) -- +WALLET_KERNEL_DB_FILE= +WALLET_KERNEL_RECEIPT_KEY_FILE= +WALLET_KERNEL_OPERATOR_TOKEN_FILE= +WALLET_KERNEL_TRUSTED_ANCESTOR= +WALLET_KERNEL_EXPECTED_AGENT_UID= +WALLET_KERNEL_EXPECTED_AGENT_GID= +WALLET_KERNEL_POLICY_FILE= +WALLET_KERNEL_ROUTE_FILE= +WALLET_KERNEL_PORT=8402 +WALLET_KERNEL_OPERATOR_PORT=8405`; + assert.equal(environment.includes(expectedEnvironmentBlock), true); +}); + +test('every file-backed boundary requires an explicit frozen pathTrust object', (t) => { + const fixture = authority(t); + fs.writeFileSync(fixture.privatePath, secret('11'), { mode: 0o600 }); + const initializer = (pathTrust) => () => loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('22')), + validateBytes: validatePrivateValue, + pathTrust, + }); + const calls = (pathTrust) => [ + () => preparePrivateFile(fixture.privatePath, 'Receipt key', { pathTrust }), + () => readPrivateInputFile(fixture.privatePath, 'Receipt key', { pathTrust }), + () => preflightSqliteFiles(fixture.databasePath, { pathTrust }), + () => secureNewSqliteSideFiles(fixture.databasePath, new Set(), { pathTrust }), + initializer(pathTrust), + ]; + + for (const action of calls(undefined)) assert.throws(action, /frozen pathTrust/); + const mutableTrust = { ...fixture.pathTrust }; + for (const action of calls(mutableTrust)) assert.throws(action, /frozen pathTrust/); +}); + +test('private files must use absolute paths outside the checkout', (t) => { + const fixture = authority(t); + assert.throws( + () => preparePrivateFile('relative.receipt-key', 'Receipt key', { + pathTrust: fixture.pathTrust, + }), + /absolute/, + ); + assert.throws( + () => preparePrivateFile( + path.join(REPOSITORY_ROOT, 'spikes/pi-wielder/forbidden.receipt-key'), + 'Receipt key', + { pathTrust: fixture.pathTrust }, + ), + /outside the checkout/, + ); +}); + +test('preparePrivateFile creates or reuses only a current-owner 0600 regular file', (t) => { + const fixture = authority(t); + assert.equal( + preparePrivateFile(fixture.privatePath, 'Receipt key', { pathTrust: fixture.pathTrust }), + fixture.privatePath, + ); + let stat = fs.lstatSync(fixture.privatePath); + assert.equal(stat.isFile(), true); + assert.equal(stat.uid, CURRENT_UID); + assert.equal(stat.mode & 0o777, 0o600); + + fs.writeFileSync(fixture.privatePath, secret('11')); + assert.equal( + preparePrivateFile(fixture.privatePath, 'Receipt key', { pathTrust: fixture.pathTrust }), + fixture.privatePath, + ); + stat = fs.lstatSync(fixture.privatePath); + assert.equal(stat.mode & 0o777, 0o600); + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), secret('11')); +}); + +test('prepare and read never chmod-repair permissive, symlinked, or nonfile inputs', (t) => { + { + const fixture = authority(t); + fs.writeFileSync(fixture.privatePath, secret('11'), { mode: 0o644 }); + assert.throws( + () => preparePrivateFile(fixture.privatePath, 'Receipt key', { + pathTrust: fixture.pathTrust, + }), + /owner-only/, + ); + assert.equal(fs.statSync(fixture.privatePath).mode & 0o777, 0o644); + } + { + const fixture = authority(t); + const target = path.join(fixture.directory, 'symlink-target'); + fs.writeFileSync(target, secret('22'), { mode: 0o600 }); + fs.symlinkSync(target, fixture.privatePath); + assert.throws( + () => readPrivateInputFile(fixture.privatePath, 'Receipt key', { + pathTrust: fixture.pathTrust, + }), + /symlink/, + ); + assert.equal(fs.lstatSync(fixture.privatePath).isSymbolicLink(), true); + assert.equal(fs.readFileSync(target, 'utf8'), secret('22')); + } + { + const fixture = authority(t); + fs.mkdirSync(fixture.privatePath, { mode: 0o700 }); + assert.throws( + () => preparePrivateFile(fixture.privatePath, 'Receipt key', { + pathTrust: fixture.pathTrust, + }), + /regular file/, + ); + assert.equal(fs.statSync(fixture.privatePath).isDirectory(), true); + } +}); + +test('readPrivateInputFile reads one held descriptor and enforces nonempty bounded bytes', (t) => { + const fixture = authority(t); + const originalValue = secret('33'); + fs.writeFileSync(fixture.privatePath, originalValue, { mode: 0o600 }); + const moved = path.join(fixture.directory, 'original-held-value'); + const originalReadFile = fs.readFileSync; + let descriptorReads = 0; + fs.readFileSync = function swapPathBeforeDescriptorRead(input, ...rest) { + if (typeof input === 'number') { + descriptorReads += 1; + fs.renameSync(fixture.privatePath, moved); + fs.writeFileSync(fixture.privatePath, secret('44'), { mode: 0o600 }); + } + return originalReadFile.call(fs, input, ...rest); + }; + let bytes; + try { + bytes = readPrivateInputFile(fixture.privatePath, 'Policy file', { + maximumBytes: Buffer.byteLength(originalValue), + pathTrust: fixture.pathTrust, + }); + } finally { + fs.readFileSync = originalReadFile; + } + assert.equal(descriptorReads, 1); + assert.equal(bytes.toString('utf8'), originalValue); + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), secret('44')); + + fs.writeFileSync(fixture.privatePath, '', { mode: 0o600 }); + assert.throws( + () => readPrivateInputFile(fixture.privatePath, 'Policy file', { + pathTrust: fixture.pathTrust, + }), + /size/, + ); + fs.writeFileSync(fixture.privatePath, '12345', { mode: 0o600 }); + assert.throws( + () => readPrivateInputFile(fixture.privatePath, 'Policy file', { + maximumBytes: 4, + pathTrust: fixture.pathTrust, + }), + /size/, + ); +}); + +test('SQLite preflight accepts absent or exact 0600 current-owner regular files', (t) => { + const fixture = authority(t); + assert.deepEqual([...preflightSqliteFiles(fixture.databasePath, { + pathTrust: fixture.pathTrust, + })], []); + + const expected = ['', '-wal', '-shm'].map((suffix) => `${fixture.databasePath}${suffix}`); + for (const target of expected) fs.writeFileSync(target, '', { mode: 0o600 }); + const existing = preflightSqliteFiles(fixture.databasePath, { pathTrust: fixture.pathTrust }); + assert.equal(existing instanceof Set, true); + assert.deepEqual([...existing], expected); +}); + +test('SQLite preflight rejects permissive, symlinked, and nonfile database siblings', (t) => { + for (const suffix of ['', '-wal', '-shm']) { + { + const fixture = authority(t); + const target = `${fixture.databasePath}${suffix}`; + fs.writeFileSync(target, '', { mode: 0o644 }); + assert.throws( + () => preflightSqliteFiles(fixture.databasePath, { pathTrust: fixture.pathTrust }), + /owner-only/, + ); + assert.equal(fs.statSync(target).mode & 0o777, 0o644); + } + { + const fixture = authority(t); + const target = `${fixture.databasePath}${suffix}`; + const symlinkTarget = path.join(fixture.directory, `target${suffix || '-database'}`); + fs.writeFileSync(symlinkTarget, '', { mode: 0o600 }); + fs.symlinkSync(symlinkTarget, target); + assert.throws( + () => preflightSqliteFiles(fixture.databasePath, { pathTrust: fixture.pathTrust }), + /symlink/, + ); + assert.equal(fs.lstatSync(target).isSymbolicLink(), true); + } + { + const fixture = authority(t); + const target = `${fixture.databasePath}${suffix}`; + fs.mkdirSync(target, { mode: 0o700 }); + assert.throws( + () => preflightSqliteFiles(fixture.databasePath, { pathTrust: fixture.pathTrust }), + /regular/, + ); + assert.equal(fs.statSync(target).isDirectory(), true); + } + } +}); + +test('only SQLite files absent at preflight may be tightened to 0600', (t) => { + const fixture = authority(t); + fs.writeFileSync(fixture.databasePath, '', { mode: 0o600 }); + const existing = preflightSqliteFiles(fixture.databasePath, { pathTrust: fixture.pathTrust }); + fs.writeFileSync(`${fixture.databasePath}-wal`, '', { mode: 0o644 }); + fs.writeFileSync(`${fixture.databasePath}-shm`, '', { mode: 0o666 }); + + secureNewSqliteSideFiles(fixture.databasePath, existing, { pathTrust: fixture.pathTrust }); + assert.equal(fs.statSync(fixture.databasePath).mode & 0o777, 0o600); + assert.equal(fs.statSync(`${fixture.databasePath}-wal`).mode & 0o777, 0o600); + assert.equal(fs.statSync(`${fixture.databasePath}-shm`).mode & 0o777, 0o600); + + fs.chmodSync(fixture.databasePath, 0o644); + assert.throws( + () => secureNewSqliteSideFiles(fixture.databasePath, existing, { + pathTrust: fixture.pathTrust, + }), + /owner-only/, + ); + assert.equal(fs.statSync(fixture.databasePath).mode & 0o777, 0o644); +}); + +test('new SQLite symlinks and nonfiles fail closed instead of being repaired', (t) => { + for (const kind of ['symlink', 'directory']) { + const fixture = authority(t); + const existing = preflightSqliteFiles(fixture.databasePath, { pathTrust: fixture.pathTrust }); + const wal = `${fixture.databasePath}-wal`; + if (kind === 'symlink') { + const target = path.join(fixture.directory, 'wal-target'); + fs.writeFileSync(target, '', { mode: 0o600 }); + fs.symlinkSync(target, wal); + } else { + fs.mkdirSync(wal, { mode: 0o700 }); + } + assert.throws( + () => secureNewSqliteSideFiles(fixture.databasePath, existing, { + pathTrust: fixture.pathTrust, + }), + /symlink|regular/, + ); + assert.equal( + kind === 'symlink' ? fs.lstatSync(wal).isSymbolicLink() : fs.statSync(wal).isDirectory(), + true, + ); + } +}); + +test('private initializer atomically creates one value and reuses it without overwrite', (t) => { + const fixture = authority(t); + let createCalls = 0; + const firstValue = secret('55'); + const first = loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => { + createCalls += 1; + return Buffer.from(firstValue); + }, + validateBytes: validatePrivateValue, + randomBytes: () => Buffer.from('01'.repeat(16), 'hex'), + pathTrust: fixture.pathTrust, + }); + assert.equal(first, firstValue); + assert.equal(createCalls, 1); + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), firstValue); + assert.equal(fs.statSync(fixture.privatePath).mode & 0o777, 0o600); + assert.deepEqual(privateCandidateNames(fixture.directory, path.basename(fixture.privatePath)), []); + + const reused = loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => { + createCalls += 1; + return Buffer.from(secret('66')); + }, + validateBytes: validatePrivateValue, + pathTrust: fixture.pathTrust, + }); + assert.equal(reused, firstValue); + assert.equal(createCalls, 1); + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), firstValue); +}); + +test('existing empty, truncated, invalid, symlinked, permissive, and nonfile values fail closed', (t) => { + const cases = [ + ['empty', (fixture) => fs.writeFileSync(fixture.privatePath, '', { mode: 0o600 })], + ['truncated', (fixture) => fs.writeFileSync(fixture.privatePath, 'secret:aa\n', { mode: 0o600 })], + ['invalid', (fixture) => fs.writeFileSync(fixture.privatePath, 'not-secret\n', { mode: 0o600 })], + ['permissive', (fixture) => fs.writeFileSync(fixture.privatePath, secret('11'), { mode: 0o644 })], + ['directory', (fixture) => fs.mkdirSync(fixture.privatePath, { mode: 0o700 })], + ['symlink', (fixture) => { + const target = path.join(fixture.directory, 'existing-target'); + fs.writeFileSync(target, secret('11'), { mode: 0o600 }); + fs.symlinkSync(target, fixture.privatePath); + }], + ]; + + for (const [name, arrange] of cases) { + const fixture = authority(t, `wallet-kernel-${name}-`); + arrange(fixture); + let createCalls = 0; + assert.throws( + () => loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => { + createCalls += 1; + return Buffer.from(secret('22')); + }, + validateBytes: validatePrivateValue, + pathTrust: fixture.pathTrust, + }), + /empty|invalid|owner-only|regular file|symlink/, + ); + assert.equal(createCalls, 0); + if (name === 'permissive') { + assert.equal(fs.statSync(fixture.privatePath).mode & 0o777, 0o644); + } + if (name === 'symlink') assert.equal(fs.lstatSync(fixture.privatePath).isSymbolicLink(), true); + } +}); + +test('empty or invalid generated values fail before publishing and leave no candidate', (t) => { + for (const value of [Buffer.alloc(0), Buffer.from('invalid\n')]) { + const fixture = authority(t); + assert.throws( + () => loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => value, + validateBytes: validatePrivateValue, + randomBytes: () => Buffer.from('02'.repeat(16), 'hex'), + pathTrust: fixture.pathTrust, + }), + /empty|invalid/, + ); + assert.equal(fs.existsSync(fixture.privatePath), false); + assert.deepEqual(privateCandidateNames(fixture.directory, path.basename(fixture.privatePath)), []); + } +}); + +test('initializer never removes a colliding temp name it did not create', (t) => { + const fixture = authority(t); + const random = Buffer.from('03'.repeat(16), 'hex'); + const candidateName = `.${path.basename(fixture.privatePath)}.tmp-${process.pid}-${random.toString('hex')}`; + const candidatePath = path.join(fixture.directory, candidateName); + + assert.throws( + () => loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('33')), + validateBytes: validatePrivateValue, + randomBytes: () => { + fs.writeFileSync(candidatePath, secret('44'), { mode: 0o600 }); + return random; + }, + pathTrust: fixture.pathTrust, + }), + (error) => error.code === 'EEXIST', + ); + assert.equal(fs.existsSync(candidatePath), true); + assert.equal(fs.readFileSync(candidatePath, 'utf8'), secret('44')); + assert.equal(fs.existsSync(fixture.privatePath), false); +}); + +test('recovery publishes the lexicographically first valid candidate and ignores decoys', (t) => { + const fixture = authority(t); + const basename = path.basename(fixture.privatePath); + const first = `.${basename}.tmp-101-${'11'.repeat(16)}`; + const second = `.${basename}.tmp-202-${'22'.repeat(16)}`; + const decoys = [ + `.${basename}.tmp-0-${'33'.repeat(16)}`, + `.${basename}.tmp-303-${'AA'.repeat(16)}`, + `.other.tmp-404-${'44'.repeat(16)}`, + ]; + fs.writeFileSync(path.join(fixture.directory, second), secret('22'), { mode: 0o600 }); + fs.writeFileSync(path.join(fixture.directory, first), secret('11'), { mode: 0o600 }); + for (const name of decoys) { + fs.writeFileSync(path.join(fixture.directory, name), 'decoy', { mode: 0o644 }); + } + + const recovered = loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('55')), + validateBytes: validatePrivateValue, + pathTrust: fixture.pathTrust, + }); + assert.equal(recovered, secret('11')); + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), secret('11')); + assert.deepEqual(privateCandidateNames(fixture.directory, basename), []); + for (const name of decoys) assert.equal(fs.existsSync(path.join(fixture.directory, name)), true); +}); + +test('valid final value wins recovery and validated candidates are removed', (t) => { + const fixture = authority(t); + const basename = path.basename(fixture.privatePath); + const candidate = `.${basename}.tmp-505-${'55'.repeat(16)}`; + fs.writeFileSync(fixture.privatePath, secret('11'), { mode: 0o600 }); + fs.writeFileSync(path.join(fixture.directory, candidate), secret('22'), { mode: 0o600 }); + + const recovered = loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('33')), + validateBytes: validatePrivateValue, + pathTrust: fixture.pathTrust, + }); + assert.equal(recovered, secret('11')); + assert.equal(fs.existsSync(path.join(fixture.directory, candidate)), false); + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), secret('11')); +}); + +test('unsafe exact-namespace recovery candidates fail closed and are never deleted', (t) => { + const cases = [ + ['invalid', (candidate) => fs.writeFileSync(candidate, 'invalid\n', { mode: 0o600 })], + ['permissive', (candidate) => fs.writeFileSync(candidate, secret('11'), { mode: 0o644 })], + ['directory', (candidate) => fs.mkdirSync(candidate, { mode: 0o700 })], + ['symlink', (candidate, fixture) => { + const target = path.join(fixture.directory, 'candidate-target'); + fs.writeFileSync(target, secret('11'), { mode: 0o600 }); + fs.symlinkSync(target, candidate); + }], + ]; + for (const [kind, arrange] of cases) { + const fixture = authority(t, `wallet-kernel-candidate-${kind}-`); + const name = `.${path.basename(fixture.privatePath)}.tmp-606-${'66'.repeat(16)}`; + const candidate = path.join(fixture.directory, name); + arrange(candidate, fixture); + assert.throws( + () => loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('22')), + validateBytes: validatePrivateValue, + pathTrust: fixture.pathTrust, + }), + /candidate.*invalid|candidate.*owner-only|candidate.*regular|candidate.*symlink/, + ); + assert.doesNotThrow(() => fs.lstatSync(candidate)); + assert.equal(fs.existsSync(fixture.privatePath), false); + } +}); + +test('wrong-owner-like recovery candidate metadata fails closed without chown privileges', (t) => { + const fixture = authority(t, 'wallet-kernel-candidate-owner-'); + const name = `.${path.basename(fixture.privatePath)}.tmp-707-${'77'.repeat(16)}`; + const candidate = path.join(fixture.directory, name); + fs.writeFileSync(candidate, secret('11'), { mode: 0o600 }); + const candidateStat = fs.statSync(candidate); + const originalFstat = fs.fstatSync; + fs.fstatSync = function injectWrongOwner(descriptor, options) { + const stat = originalFstat.call(fs, descriptor, options); + if (options === undefined && stat.isFile() && stat.ino === candidateStat.ino) { + return new Proxy(stat, { + get(target, property, receiver) { + if (property === 'uid') return target.uid + 1; + return Reflect.get(target, property, receiver); + }, + }); + } + return stat; + }; + try { + assert.throws( + () => loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('22')), + validateBytes: validatePrivateValue, + pathTrust: fixture.pathTrust, + }), + /candidate.*owned by the current user/, + ); + } finally { + fs.fstatSync = originalFstat; + } + assert.doesNotThrow(() => fs.lstatSync(candidate)); + assert.equal(fs.existsSync(fixture.privatePath), false); +}); + +test('two fresh processes racing initialization converge on one value without overwrite', async (t) => { + const fixture = authority(t, 'wallet-kernel-race-'); + const coordination = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-race-gate-')); + fs.chmodSync(coordination, 0o700); + t.after(() => fs.rmSync(coordination, { force: true, recursive: true })); + const releaseFile = path.join(coordination, 'release'); + const readyFiles = [path.join(coordination, 'ready-one'), path.join(coordination, 'ready-two')]; + const values = [secret('77'), secret('88')]; + const children = values.map((value, index) => runStorageChild({ + directory: fixture.directory, + filePath: fixture.privatePath, + readyFile: readyFiles[index], + releaseFile, + value, + })); + + try { + await waitForFiles(readyFiles); + } finally { + fs.writeFileSync(releaseFile, 'release', { mode: 0o600 }); + } + const results = await Promise.all(children); + for (const result of results) { + assert.equal(result.code, 0, result.stderr); + assert.equal(result.signal, null); + } + const returned = results.map((result) => JSON.parse(result.stdout).result); + assert.equal(returned[0], returned[1]); + assert.equal(values.includes(returned[0]), true); + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), returned[0]); + assert.equal(fs.statSync(fixture.privatePath).mode & 0o777, 0o600); + assert.deepEqual(privateCandidateNames(fixture.directory, path.basename(fixture.privatePath)), []); +}); + +test('fresh processes recover one reusable value after every private-file crash boundary', async (t) => { + const faultPoints = [ + 'after_private_temp_write', + 'after_private_temp_fsync', + 'after_private_publish', + 'after_private_directory_fsync', + ]; + for (const [index, faultPoint] of faultPoints.entries()) { + const fixture = authority(t, `wallet-kernel-crash-${index}-`); + const original = secret('99'); + const crash = await runStorageChild({ + directory: fixture.directory, + faultPoint, + filePath: fixture.privatePath, + value: original, + }); + assert.equal(crash.code, null, `${faultPoint}: ${crash.stderr}`); + assert.equal(crash.signal, 'SIGABRT', `${faultPoint}: ${crash.stderr}`); + + const recovery = await runStorageChild({ + directory: fixture.directory, + filePath: fixture.privatePath, + value: secret('aa'), + }); + assert.equal(recovery.code, 0, `${faultPoint}: ${recovery.stderr}`); + assert.equal(JSON.parse(recovery.stdout).result, original); + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), original); + assert.equal(fs.statSync(fixture.privatePath).mode & 0o777, 0o600); + assert.deepEqual( + privateCandidateNames(fixture.directory, path.basename(fixture.privatePath)), + [], + ); + } +}); diff --git a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs index db07a66..d865f31 100644 --- a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs +++ b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs @@ -13,6 +13,9 @@ test('exports the trusted parent opener', () => { const CURRENT_UID = process.getuid(); const METADATA_DOMAIN = 'wallet-kernel/trusted-parent-metadata/v1\0'; +const LIST_PRIVATE_NAMES = Symbol.for( + 'skill-asset-protocol.wallet-kernel.trusted-parent.private-temp-list.v1', +); function makeFixture(t, { ancestorMode = 0o700, @@ -205,6 +208,45 @@ test('sibling and private-temp namespaces are closed and bounded', (t) => { guard.close(); }); +test('internal private-temp listing is descriptor-bound, exact, sorted, and revalidated', (t) => { + const fixture = makeFixture(t); + const guard = openTrustedParent(deterministicOptions(fixture)); + const first = `.kernel.sqlite.tmp-101-${'11'.repeat(16)}`; + const second = `.kernel.sqlite.tmp-202-${'22'.repeat(16)}`; + for (const name of [ + second, + first, + `.kernel.sqlite.tmp-0-${'33'.repeat(16)}`, + `.kernel.sqlite.tmp-303-${'AA'.repeat(16)}`, + 'unrelated', + ]) { + fs.writeFileSync(path.join(fixture.terminal, name), 'candidate', { mode: 0o600 }); + } + + assert.equal(Object.keys(guard).includes(String(LIST_PRIVATE_NAMES)), false); + const descriptor = Object.getOwnPropertyDescriptor(guard, LIST_PRIVATE_NAMES); + assert.equal(descriptor?.enumerable, false); + assert.equal(typeof descriptor?.value, 'function'); + const names = guard[LIST_PRIVATE_NAMES](); + assert.deepEqual(names, [first, second]); + assert.equal(Object.isFrozen(names), true); + assert.equal(names.some((name) => name.includes(fixture.terminal)), false); + + const originalReaddir = fs.readdirSync; + fs.readdirSync = function mutateAfterListing(location, options) { + const result = originalReaddir.call(fs, location, options); + fs.chmodSync(fixture.terminal, 0o755); + return result; + }; + try { + assert.throws(() => guard[LIST_PRIVATE_NAMES](), /changed/); + } finally { + fs.readdirSync = originalReaddir; + guard.close(); + } + assert.throws(() => guard[LIST_PRIVATE_NAMES](), /closed/); +}); + test('leaf opens are no-follow even when the caller omits O_NOFOLLOW', (t) => { const fixture = makeFixture(t); const outside = path.join(fixture.trustedAncestor, 'outside-file'); From 1e8f577c32c9834fe49e99f686fe494e513fb85b Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 02:16:40 -0400 Subject: [PATCH 154/165] fix: harden secure storage authority --- .../pi-wielder/src/kernel/secure-storage.mjs | 250 +++++++++++++---- spikes/pi-wielder/src/kernel/trusted-path.mjs | 103 ++++++- spikes/pi-wielder/tests/kernel-store.test.mjs | 261 ++++++++++++++++-- .../tests/kernel-trusted-path.test.mjs | 30 ++ 4 files changed, 574 insertions(+), 70 deletions(-) diff --git a/spikes/pi-wielder/src/kernel/secure-storage.mjs b/spikes/pi-wielder/src/kernel/secure-storage.mjs index e47aa13..839a465 100644 --- a/spikes/pi-wielder/src/kernel/secure-storage.mjs +++ b/spikes/pi-wielder/src/kernel/secure-storage.mjs @@ -12,7 +12,14 @@ const PRIVATE_TEMP_LIST = Symbol.for( 'skill-asset-protocol.wallet-kernel.trusted-parent.private-temp-list.v1', ); const SQLITE_SUFFIXES = Object.freeze(['', '-wal', '-shm']); +const PATH_TRUST_FIELDS = Object.freeze([ + 'mode', + 'trustedAncestor', + 'kernelUid', + 'agentUid', +]); const ABSENT = Symbol('absent-private-file'); +const SQLITE_PREFLIGHTS = new WeakMap(); function assertSecurePlatform() { if (typeof process.getuid !== 'function' || !Number.isInteger(NOFOLLOW)) { @@ -20,13 +27,37 @@ function assertSecurePlatform() { } } -function assertPathTrust(pathTrust) { +function capturePathTrust(pathTrust) { if (pathTrust === null || typeof pathTrust !== 'object' || !Object.isFrozen(pathTrust)) { throw new Error('Wallet Kernel file access requires an explicit frozen pathTrust object'); } - return pathTrust; + const prototype = Object.getPrototypeOf(pathTrust); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('pathTrust must be a plain object with exact fields'); + } + if (Object.getOwnPropertySymbols(pathTrust).length !== 0) { + throw new Error('pathTrust must not contain symbols'); + } + const descriptors = Object.getOwnPropertyDescriptors(pathTrust); + const keys = Object.keys(descriptors); + if (keys.length !== PATH_TRUST_FIELDS.length + || PATH_TRUST_FIELDS.some((field) => !Object.hasOwn(descriptors, field))) { + throw new Error('pathTrust must contain the exact fields'); + } + for (const field of PATH_TRUST_FIELDS) { + const descriptor = descriptors[field]; + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new Error('pathTrust fields must be own enumerable data fields'); + } + } + return Object.freeze({ + mode: descriptors.mode.value, + trustedAncestor: descriptors.trustedAncestor.value, + kernelUid: descriptors.kernelUid.value, + agentUid: descriptors.agentUid.value, + }); } function inside(parent, child) { @@ -37,7 +68,7 @@ function inside(parent, child) { function assertOwner(stat, label) { assertSecurePlatform(); - if (stat.uid !== process.getuid()) { + if (Number(stat.uid) !== process.getuid()) { throw new Error(`${label} must be owned by the current user`); } } @@ -45,14 +76,45 @@ function assertOwner(stat, label) { function assertOwnerOnlyRegular(stat, label) { assertOwner(stat, label); if (!stat.isFile()) throw new Error(`${label} must be a regular file`); - if ((stat.mode & 0o777) !== 0o600) { + const mode = typeof stat.mode === 'bigint' + ? Number(stat.mode & 0o777n) + : stat.mode & 0o777; + if (mode !== 0o600) { throw new Error(`${label} must be an owner-only regular file`); } } +function fileIdentityFor(stat) { + const mode = typeof stat.mode === 'bigint' + ? Number(stat.mode & 0o7777n) + : stat.mode & 0o7777; + const modificationTime = typeof stat.mtimeNs === 'bigint' + ? stat.mtimeNs.toString(10) + : String(Math.trunc(stat.mtimeMs * 1_000_000)); + return Object.freeze({ + device: stat.dev.toString(10), + inode: stat.ino.toString(10), + uid: Number(stat.uid), + gid: Number(stat.gid), + mode, + size: stat.size.toString(10), + modificationTime, + }); +} + +function sameFileIdentity(left, right) { + return left.device === right.device + && left.inode === right.inode + && left.uid === right.uid + && left.gid === right.gid + && left.mode === right.mode + && left.size === right.size + && left.modificationTime === right.modificationTime; +} + function privateParent(filePath, label, checkoutRoot, pathTrust) { assertSecurePlatform(); - const trust = assertPathTrust(pathTrust); + const trust = capturePathTrust(pathTrust); if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) { throw new Error(`${label} path must be absolute`); } @@ -69,19 +131,64 @@ function privateParent(filePath, label, checkoutRoot, pathTrust) { }); } -function readValidatedDescriptor(descriptor, label, validateBytes) { - const stat = fs.fstatSync(descriptor); - assertOwnerOnlyRegular(stat, label); - if (stat.size <= 0 || stat.size > MAXIMUM_PRIVATE_BYTES) { +function readBoundedDescriptor(descriptor, maximumBytes, label) { + const scratch = Buffer.allocUnsafe(maximumBytes + 1); + let total = 0; + try { + while (total < scratch.length) { + const read = fs.readSync(descriptor, scratch, total, scratch.length - total, null); + if (read === 0) break; + total += read; + } + if (total > maximumBytes) { + throw new Error(`${label} size is outside the allowed boundary`); + } + return Buffer.from(scratch.subarray(0, total)); + } finally { + scratch.fill(0); + } +} + +function detachAliasedValidatorResult(result, bytes, label) { + if (result instanceof ArrayBuffer && result === bytes.buffer) { + throw new Error(`${label} validator must not return the input ArrayBuffer`); + } + if (!ArrayBuffer.isView(result) || result.buffer !== bytes.buffer) return result; + const inputStart = bytes.byteOffset; + const inputEnd = inputStart + bytes.byteLength; + const resultStart = result.byteOffset; + const resultEnd = resultStart + result.byteLength; + if (resultEnd <= inputStart || resultStart >= inputEnd) return result; + if (resultStart < inputStart || resultEnd > inputEnd) { + throw new Error(`${label} validator must not expose memory outside its input bytes`); + } + if (Buffer.isBuffer(result)) return Buffer.from(result); + if (result instanceof DataView) { + const copy = Buffer.from(new Uint8Array(result.buffer, result.byteOffset, result.byteLength)); + return new DataView(copy.buffer, copy.byteOffset, copy.byteLength); + } + return new result.constructor(result); +} + +function readValidatedDescriptor(descriptor, label, validateBytes, { includeIdentity = false } = {}) { + const before = fs.fstatSync(descriptor, { bigint: true }); + assertOwnerOnlyRegular(before, label); + if (before.size <= 0n || before.size > BigInt(MAXIMUM_PRIVATE_BYTES)) { throw new Error(`${label} must not be empty and must remain within the size boundary`); } + const identity = fileIdentityFor(before); - const bytes = fs.readFileSync(descriptor); + const bytes = readBoundedDescriptor(descriptor, MAXIMUM_PRIVATE_BYTES, label); try { - if (bytes.length <= 0 || bytes.length > MAXIMUM_PRIVATE_BYTES) { + if (bytes.length <= 0) { throw new Error(`${label} must not be empty and must remain within the size boundary`); } - return validateBytes(bytes); + const value = detachAliasedValidatorResult(validateBytes(bytes), bytes, label); + const after = fileIdentityFor(fs.fstatSync(descriptor, { bigint: true })); + if (!sameFileIdentity(identity, after)) { + throw new Error(`${label} file identity changed during validation`); + } + return includeIdentity ? { identity, value } : value; } finally { bytes.fill(0); } @@ -121,8 +228,10 @@ export function readPrivateInputFile(filePath, label, { maximumBytes = MAXIMUM_PRIVATE_BYTES, pathTrust, } = {}) { - if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { - throw new Error(`${label} maximum size must be a positive safe integer`); + if (!Number.isSafeInteger(maximumBytes) + || maximumBytes <= 0 + || maximumBytes > MAXIMUM_PRIVATE_BYTES) { + throw new Error(`${label} maximum size must be a positive safe integer within the hard ceiling`); } const guard = privateParent(filePath, label, checkoutRoot, pathTrust); let descriptor; @@ -133,8 +242,8 @@ export function readPrivateInputFile(filePath, label, { if (stat.size <= 0 || stat.size > maximumBytes) { throw new Error(`${label} size is outside the allowed boundary`); } - const bytes = fs.readFileSync(descriptor); - if (bytes.length <= 0 || bytes.length > maximumBytes) { + const bytes = readBoundedDescriptor(descriptor, maximumBytes, label); + if (bytes.length <= 0) { bytes.fill(0); throw new Error(`${label} size is outside the allowed boundary`); } @@ -153,10 +262,9 @@ export function preflightSqliteFiles(databasePath, { pathTrust } = {}) { CHECKOUT_ROOT, pathTrust, ); - const existing = new Set(); + const existingSuffixes = new Set(); try { for (const suffix of SQLITE_SUFFIXES) { - const target = `${databasePath}${suffix}`; let descriptor; try { descriptor = guard.openSibling(suffix, fs.constants.O_RDONLY | NOFOLLOW); @@ -167,22 +275,25 @@ export function preflightSqliteFiles(databasePath, { pathTrust } = {}) { try { const label = `SQLite ${suffix || 'database'}`; assertOwnerOnlyRegular(fs.fstatSync(descriptor), label); - existing.add(target); + existingSuffixes.add(suffix); } finally { fs.closeSync(descriptor); } } guard.revalidate(); - return existing; + const capability = Object.freeze(Object.create(null)); + SQLITE_PREFLIGHTS.set(capability, Object.freeze({ + ancestorMetadataHash: guard.ancestorMetadataHash, + databasePath, + existingSuffixes, + })); + return capability; } finally { guard.close(); } } export function secureNewSqliteSideFiles(databasePath, existing, { pathTrust } = {}) { - if (!(existing instanceof Set)) { - throw new Error('SQLite preflight state must be a Set'); - } const guard = privateParent( databasePath, 'Wallet Kernel database', @@ -190,8 +301,16 @@ export function secureNewSqliteSideFiles(databasePath, existing, { pathTrust } = pathTrust, ); try { + const preflight = SQLITE_PREFLIGHTS.get(existing); + if (!preflight) throw new Error('SQLite repair requires an opaque preflight capability'); + SQLITE_PREFLIGHTS.delete(existing); + if (preflight.databasePath !== databasePath) { + throw new Error('SQLite preflight capability belongs to a different database path'); + } + if (preflight.ancestorMetadataHash !== guard.ancestorMetadataHash) { + throw new Error('SQLite preflight capability belongs to a different trusted parent'); + } for (const suffix of SQLITE_SUFFIXES) { - const target = `${databasePath}${suffix}`; let descriptor; try { descriptor = guard.openSibling(suffix, fs.constants.O_RDONLY | NOFOLLOW); @@ -204,7 +323,7 @@ export function secureNewSqliteSideFiles(databasePath, existing, { pathTrust } = const stat = fs.fstatSync(descriptor); assertOwner(stat, label); if (!stat.isFile()) throw new Error(`${label} must be regular`); - if (existing.has(target)) { + if (preflight.existingSuffixes.has(suffix)) { if ((stat.mode & 0o777) !== 0o600) { throw new Error(`${label} must be owner-only`); } @@ -238,22 +357,31 @@ function validateCandidate(guard, name, label, validateBytes) { } try { + let validated; try { - readValidatedDescriptor(descriptor, `${label} candidate`, validateBytes); + validated = readValidatedDescriptor( + descriptor, + `${label} candidate`, + validateBytes, + { includeIdentity: true }, + ); } catch (error) { - if (/owned by|owner-only|regular file|empty|size boundary/.test(error.message)) throw error; + if (/owned by|owner-only|regular file|empty|size boundary|identity changed/.test(error.message)) { + throw error; + } throw new Error(`${label} candidate is invalid: ${error.message}`); } guard.revalidate(); - return true; - } finally { + return Object.freeze({ descriptor, identity: validated.identity, name }); + } catch (error) { fs.closeSync(descriptor); + throw error; } } -function unlinkCandidate(guard, name) { +function unlinkCandidate(guard, candidate) { try { - guard.unlinkNamed(name); + guard.unlinkNamed(candidate.name, candidate.identity); } catch (error) { if (error.code !== 'ENOENT') throw error; } @@ -304,29 +432,44 @@ export function loadOrInitializePrivateFile({ } const names = listPrivateNames(); const validated = []; - for (const name of names) { - if (validateCandidate(guard, name, label, validateBytes)) validated.push(name); - } + try { + for (const name of names) { + const candidate = validateCandidate(guard, name, label, validateBytes); + if (candidate) validated.push(candidate); + } - let existing = readExistingOrAbsent(); - if (existing === ABSENT && validated.length > 0) { - try { - guard.linkNamedToLeaf(validated[0]); - } catch (error) { - if (error.code !== 'EEXIST') throw error; + let existing = readExistingOrAbsent(); + if (existing === ABSENT && validated.length > 0) { + let namespaceChanged = false; + for (const candidate of validated) { + try { + guard.linkNamedToLeaf(candidate.name, candidate.identity); + namespaceChanged = true; + break; + } catch (error) { + if (error.code === 'EEXIST') { + namespaceChanged = true; + break; + } + if (error.code !== 'ENOENT') throw error; + } + } + if (namespaceChanged) guard.fsyncParent(); + existing = readExistingOrAbsent(); } - guard.fsyncParent(); - existing = readExisting(); - } - if (existing !== ABSENT) { - for (const name of validated) unlinkCandidate(guard, name); + if (existing !== ABSENT) { + for (const candidate of validated) unlinkCandidate(guard, candidate); + } + return existing; + } finally { + for (const candidate of validated) fs.closeSync(candidate.descriptor); } - return existing; }; let bytes; let temporaryCreated = false; + let temporaryIdentity; let temporaryName; try { const recovered = recover(); @@ -356,11 +499,15 @@ export function loadOrInitializePrivateFile({ fs.fsyncSync(descriptor); faultInjector('after_private_temp_fsync'); } finally { - fs.closeSync(descriptor); + try { + temporaryIdentity = fileIdentityFor(fs.fstatSync(descriptor, { bigint: true })); + } finally { + fs.closeSync(descriptor); + } } try { - guard.linkNamedToLeaf(temporaryName); + guard.linkNamedToLeaf(temporaryName, temporaryIdentity); faultInjector('after_private_publish'); } catch (error) { if (error.code !== 'EEXIST') throw error; @@ -371,7 +518,12 @@ export function loadOrInitializePrivateFile({ } finally { try { if (bytes) bytes.fill(0); - if (temporaryCreated) unlinkCandidate(guard, temporaryName); + if (temporaryCreated && temporaryIdentity) { + unlinkCandidate(guard, Object.freeze({ + identity: temporaryIdentity, + name: temporaryName, + })); + } guard.revalidate(); } finally { guard.close(); diff --git a/spikes/pi-wielder/src/kernel/trusted-path.mjs b/spikes/pi-wielder/src/kernel/trusted-path.mjs index 944e43f..445ec61 100644 --- a/spikes/pi-wielder/src/kernel/trusted-path.mjs +++ b/spikes/pi-wielder/src/kernel/trusted-path.mjs @@ -7,6 +7,15 @@ const MODES = new Set(['deterministic', 'cdp-testnet']); const ROLES = new Set(['kernel-private', 'root-only']); const SQLITE_SUFFIXES = new Set(['', '-wal', '-shm']); const METADATA_DOMAIN = 'wallet-kernel/trusted-parent-metadata/v1\0'; +const FILE_IDENTITY_FIELDS = Object.freeze([ + 'device', + 'inode', + 'uid', + 'gid', + 'mode', + 'size', + 'modificationTime', +]); const LIST_PRIVATE_NAMES = Symbol.for( 'skill-asset-protocol.wallet-kernel.trusted-parent.private-temp-list.v1', ); @@ -114,6 +123,52 @@ function projectionFor(stat, role, depth) { }; } +function fileIdentityFor(stat) { + return { + device: stat.dev.toString(10), + inode: stat.ino.toString(10), + uid: Number(stat.uid), + gid: Number(stat.gid), + mode: modeOf(stat), + size: stat.size.toString(10), + modificationTime: stat.mtimeNs.toString(10), + }; +} + +function captureExpectedFileIdentity(value) { + if (value === undefined) return undefined; + if (value === null + || typeof value !== 'object' + || !Object.isFrozen(value) + || Object.getPrototypeOf(value) !== Object.prototype + || Object.getOwnPropertySymbols(value).length !== 0) { + fail('expected private file identity must be one frozen plain data object'); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Object.keys(descriptors); + if (keys.length !== FILE_IDENTITY_FIELDS.length + || FILE_IDENTITY_FIELDS.some((field) => !Object.hasOwn(descriptors, field))) { + fail('expected private file identity must contain exact fields'); + } + const captured = {}; + for (const field of FILE_IDENTITY_FIELDS) { + const descriptor = descriptors[field]; + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail('expected private file identity fields must be enumerable data'); + } + captured[field] = descriptor.value; + } + return captured; +} + +function assertExpectedFileIdentity(stat, expected) { + if (!expected) return; + const actual = fileIdentityFor(stat); + if (FILE_IDENTITY_FIELDS.some((field) => actual[field] !== expected[field])) { + fail('private temporary file identity changed'); + } +} + function sameProjection(left, right) { return left.role === right.role && left.depth === right.depth @@ -399,15 +454,17 @@ export function openTrustedParent({ return openBounded(name, flags, creationMode, 'private temporary open'); }; - const linkNamedToLeaf = (name) => { + const linkNamedToLeaf = (name, expectedIdentityValue) => { assertOpen(); assertPrivateName(name); + const expectedIdentity = captureExpectedFileIdentity(expectedIdentityValue); revalidate(); let sourceDescriptor; try { sourceDescriptor = openBounded(name, fs.constants.O_RDONLY, undefined, 'private temporary open'); const source = fs.fstatSync(sourceDescriptor, { bigint: true }); if (!source.isFile()) fail('private temporary link source must be a regular file'); + assertExpectedFileIdentity(source, expectedIdentity); try { fs.linkSync(childLocation(name), childLocation(leafName)); } catch (error) { @@ -425,6 +482,18 @@ export function openTrustedParent({ if (!published.isFile() || published.dev !== source.dev || published.ino !== source.ino) { fail('private temporary publish did not preserve the held regular file'); } + if (expectedIdentity) { + const publishedIdentity = fileIdentityFor(published); + for (const field of FILE_IDENTITY_FIELDS) { + if (field !== 'modificationTime' + && publishedIdentity[field] !== expectedIdentity[field]) { + fail('private temporary publish did not preserve validated identity'); + } + } + if (publishedIdentity.modificationTime !== expectedIdentity.modificationTime) { + fail('private temporary publish did not preserve validated identity'); + } + } } finally { if (publishedDescriptor !== undefined) fs.closeSync(publishedDescriptor); } @@ -434,14 +503,44 @@ export function openTrustedParent({ } }; - const unlinkNamed = (name) => { + const unlinkNamed = (name, expectedIdentityValue) => { assertOpen(); assertPrivateName(name); + const expectedIdentity = captureExpectedFileIdentity(expectedIdentityValue); revalidate(); + let descriptor; + let linkCountBefore; try { + if (expectedIdentity) { + descriptor = openBounded( + name, + fs.constants.O_RDONLY, + undefined, + 'private temporary cleanup open', + ); + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isFile()) fail('private temporary cleanup source must be regular'); + assertExpectedFileIdentity(before, expectedIdentity); + linkCountBefore = before.nlink; + } fs.unlinkSync(childLocation(name)); + if (descriptor !== undefined) { + const after = fs.fstatSync(descriptor, { bigint: true }); + if (after.dev !== BigInt(expectedIdentity.device) + || after.ino !== BigInt(expectedIdentity.inode) + || after.nlink >= linkCountBefore) { + fail('private temporary cleanup descriptor identity changed'); + } + } } catch (error) { + if (error?.message === 'private temporary file identity changed' + || error?.message === 'private temporary cleanup source must be regular' + || error?.message === 'private temporary cleanup descriptor identity changed') { + throw error; + } throw wrapFileError(error, 'private temporary unlink'); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); } revalidate(); }; diff --git a/spikes/pi-wielder/tests/kernel-store.test.mjs b/spikes/pi-wielder/tests/kernel-store.test.mjs index 7574f22..f37fc56 100644 --- a/spikes/pi-wielder/tests/kernel-store.test.mjs +++ b/spikes/pi-wielder/tests/kernel-store.test.mjs @@ -189,6 +189,56 @@ test('every file-backed boundary requires an explicit frozen pathTrust object', for (const action of calls(mutableTrust)) assert.throws(action, /frozen pathTrust/); }); +test('frozen pathTrust accepts only exact own enumerable data fields without invoking accessors', (t) => { + const fixture = authority(t); + let getterCalls = 0; + const accessorTrust = { + mode: 'deterministic', + kernelUid: CURRENT_UID, + agentUid: CURRENT_UID, + }; + Object.defineProperty(accessorTrust, 'trustedAncestor', { + enumerable: true, + get() { + getterCalls += 1; + return fixture.directory; + }, + }); + Object.freeze(accessorTrust); + assert.throws( + () => preparePrivateFile(fixture.privatePath, 'Receipt key', { pathTrust: accessorTrust }), + /data fields/, + ); + assert.equal(getterCalls, 0); + + const symbolTrust = { ...fixture.pathTrust }; + symbolTrust[Symbol('hidden')] = 'value'; + Object.freeze(symbolTrust); + assert.throws( + () => preparePrivateFile(fixture.privatePath, 'Receipt key', { pathTrust: symbolTrust }), + /symbols/, + ); + + const unknownTrust = Object.freeze({ ...fixture.pathTrust, extra: true }); + assert.throws( + () => preparePrivateFile(fixture.privatePath, 'Receipt key', { pathTrust: unknownTrust }), + /exact fields/, + ); + + const inheritedTrust = Object.freeze(Object.assign( + Object.create({ mode: 'deterministic' }), + { + trustedAncestor: fixture.directory, + kernelUid: CURRENT_UID, + agentUid: CURRENT_UID, + }, + )); + assert.throws( + () => preparePrivateFile(fixture.privatePath, 'Receipt key', { pathTrust: inheritedTrust }), + /plain object|exact fields/, + ); +}); + test('private files must use absolute paths outside the checkout', (t) => { const fixture = authority(t); assert.throws( @@ -272,15 +322,17 @@ test('readPrivateInputFile reads one held descriptor and enforces nonempty bound const originalValue = secret('33'); fs.writeFileSync(fixture.privatePath, originalValue, { mode: 0o600 }); const moved = path.join(fixture.directory, 'original-held-value'); - const originalReadFile = fs.readFileSync; - let descriptorReads = 0; - fs.readFileSync = function swapPathBeforeDescriptorRead(input, ...rest) { - if (typeof input === 'number') { - descriptorReads += 1; + const originalRead = fs.readSync; + const descriptorsRead = new Set(); + let swapped = false; + fs.readSync = function swapPathBeforeDescriptorRead(descriptor, ...rest) { + descriptorsRead.add(descriptor); + if (!swapped) { + swapped = true; fs.renameSync(fixture.privatePath, moved); fs.writeFileSync(fixture.privatePath, secret('44'), { mode: 0o600 }); } - return originalReadFile.call(fs, input, ...rest); + return originalRead.call(fs, descriptor, ...rest); }; let bytes; try { @@ -289,9 +341,9 @@ test('readPrivateInputFile reads one held descriptor and enforces nonempty bound pathTrust: fixture.pathTrust, }); } finally { - fs.readFileSync = originalReadFile; + fs.readSync = originalRead; } - assert.equal(descriptorReads, 1); + assert.equal(descriptorsRead.size, 1); assert.equal(bytes.toString('utf8'), originalValue); assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), secret('44')); @@ -310,19 +362,85 @@ test('readPrivateInputFile reads one held descriptor and enforces nonempty bound }), /size/, ); + assert.throws( + () => readPrivateInputFile(fixture.privatePath, 'Policy file', { + maximumBytes: 1_048_577, + pathTrust: fixture.pathTrust, + }), + /hard ceiling/, + ); +}); + +test('bounded descriptor reads reject growth without reading beyond limit plus one', (t) => { + const fixture = authority(t); + fs.writeFileSync(fixture.privatePath, '1', { mode: 0o600 }); + const originalRead = fs.readSync; + let maximumRequested = 0; + let grew = false; + fs.readSync = function growHeldInode(descriptor, buffer, offset, length, position) { + maximumRequested = Math.max(maximumRequested, length); + if (!grew) { + grew = true; + fs.appendFileSync(fixture.privatePath, '23456789'); + } + return originalRead.call(fs, descriptor, buffer, offset, length, position); + }; + try { + assert.throws( + () => readPrivateInputFile(fixture.privatePath, 'Policy file', { + maximumBytes: 4, + pathTrust: fixture.pathTrust, + }), + /size/, + ); + } finally { + fs.readSync = originalRead; + } + assert.equal(maximumRequested <= 5, true); }); test('SQLite preflight accepts absent or exact 0600 current-owner regular files', (t) => { const fixture = authority(t); - assert.deepEqual([...preflightSqliteFiles(fixture.databasePath, { - pathTrust: fixture.pathTrust, - })], []); + const absent = preflightSqliteFiles(fixture.databasePath, { pathTrust: fixture.pathTrust }); + assert.equal(Object.isFrozen(absent), true); + assert.deepEqual(Object.keys(absent), []); + assert.equal(absent instanceof Set, false); - const expected = ['', '-wal', '-shm'].map((suffix) => `${fixture.databasePath}${suffix}`); - for (const target of expected) fs.writeFileSync(target, '', { mode: 0o600 }); + for (const suffix of ['', '-wal', '-shm']) { + fs.writeFileSync(`${fixture.databasePath}${suffix}`, '', { mode: 0o600 }); + } const existing = preflightSqliteFiles(fixture.databasePath, { pathTrust: fixture.pathTrust }); - assert.equal(existing instanceof Set, true); - assert.deepEqual([...existing], expected); + assert.equal(Object.isFrozen(existing), true); + assert.deepEqual(Object.keys(existing), []); + assert.equal(existing instanceof Set, false); +}); + +test('SQLite repair requires its opaque one-use path-bound preflight capability', (t) => { + const first = authority(t, 'wallet-kernel-preflight-one-'); + const second = authority(t, 'wallet-kernel-preflight-two-'); + const capability = preflightSqliteFiles(first.databasePath, { pathTrust: first.pathTrust }); + fs.writeFileSync(`${first.databasePath}-wal`, '', { mode: 0o644 }); + + assert.throws( + () => secureNewSqliteSideFiles(first.databasePath, Object.freeze({}), { + pathTrust: first.pathTrust, + }), + /opaque preflight capability/, + ); + assert.equal(fs.statSync(`${first.databasePath}-wal`).mode & 0o777, 0o644); + assert.throws( + () => secureNewSqliteSideFiles(second.databasePath, capability, { + pathTrust: second.pathTrust, + }), + /different database path/, + ); + assert.equal(fs.statSync(`${first.databasePath}-wal`).mode & 0o777, 0o644); + assert.throws( + () => secureNewSqliteSideFiles(first.databasePath, capability, { + pathTrust: first.pathTrust, + }), + /consumed|opaque preflight capability/, + ); }); test('SQLite preflight rejects permissive, symlinked, and nonfile database siblings', (t) => { @@ -366,6 +484,9 @@ test('only SQLite files absent at preflight may be tightened to 0600', (t) => { const fixture = authority(t); fs.writeFileSync(fixture.databasePath, '', { mode: 0o600 }); const existing = preflightSqliteFiles(fixture.databasePath, { pathTrust: fixture.pathTrust }); + const secondPreflight = preflightSqliteFiles(fixture.databasePath, { + pathTrust: fixture.pathTrust, + }); fs.writeFileSync(`${fixture.databasePath}-wal`, '', { mode: 0o644 }); fs.writeFileSync(`${fixture.databasePath}-shm`, '', { mode: 0o666 }); @@ -376,7 +497,7 @@ test('only SQLite files absent at preflight may be tightened to 0600', (t) => { fs.chmodSync(fixture.databasePath, 0o644); assert.throws( - () => secureNewSqliteSideFiles(fixture.databasePath, existing, { + () => secureNewSqliteSideFiles(fixture.databasePath, secondPreflight, { pathTrust: fixture.pathTrust, }), /owner-only/, @@ -445,6 +566,32 @@ test('private initializer atomically creates one value and reuses it without ove assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), firstValue); }); +test('private validator results are detached before input bytes are zeroed', (t) => { + const fixture = authority(t); + const value = secret('5a'); + fs.writeFileSync(fixture.privatePath, value, { mode: 0o600 }); + + const whole = loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('5b')), + validateBytes: (bytes) => bytes, + pathTrust: fixture.pathTrust, + }); + assert.equal(Buffer.isBuffer(whole), true); + assert.equal(whole.toString('utf8'), value); + + const view = loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('5c')), + validateBytes: (bytes) => bytes.subarray(0, 6), + pathTrust: fixture.pathTrust, + }); + assert.equal(Buffer.isBuffer(view), true); + assert.equal(view.toString('utf8'), 'secret'); +}); + test('existing empty, truncated, invalid, symlinked, permissive, and nonfile values fail closed', (t) => { const cases = [ ['empty', (fixture) => fs.writeFileSync(fixture.privatePath, '', { mode: 0o600 })], @@ -557,6 +704,82 @@ test('recovery publishes the lexicographically first valid candidate and ignores for (const name of decoys) assert.equal(fs.existsSync(path.join(fixture.directory, name)), true); }); +test('recovery never publishes or removes a temp name whose validated inode was replaced', (t) => { + for (const finalExists of [false, true]) { + const fixture = authority(t, `wallet-kernel-identity-${finalExists ? 'unlink' : 'publish'}-`); + const basename = path.basename(fixture.privatePath); + const name = `.${basename}.tmp-808-${'88'.repeat(16)}`; + const candidate = path.join(fixture.directory, name); + const moved = `${candidate}.validated-inode`; + fs.writeFileSync(candidate, secret('88'), { mode: 0o600 }); + if (finalExists) fs.writeFileSync(fixture.privatePath, secret('11'), { mode: 0o600 }); + const candidateInode = fs.statSync(candidate).ino; + const originalRead = fs.readSync; + let swapped = false; + fs.readSync = function swapAfterValidatedRead(descriptor, ...rest) { + const inode = fs.fstatSync(descriptor).ino; + const read = originalRead.call(fs, descriptor, ...rest); + if (!swapped && inode === candidateInode && read > 0) { + fs.renameSync(candidate, moved); + fs.writeFileSync(candidate, 'unvalidated replacement\n', { mode: 0o600 }); + swapped = true; + } + return read; + }; + try { + assert.throws( + () => loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('22')), + validateBytes: validatePrivateValue, + pathTrust: fixture.pathTrust, + }), + /identity changed/, + ); + } finally { + fs.readSync = originalRead; + } + assert.equal(swapped, true); + assert.equal(fs.readFileSync(candidate, 'utf8'), 'unvalidated replacement\n'); + if (finalExists) { + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), secret('11')); + } else { + assert.equal(fs.existsSync(fixture.privatePath), false); + } + } +}); + +test('initializer cleanup leaves a replacement of its own validated temp name untouched', (t) => { + const fixture = authority(t, 'wallet-kernel-own-cleanup-'); + const random = Buffer.from('09'.repeat(16), 'hex'); + const name = `.${path.basename(fixture.privatePath)}.tmp-${process.pid}-${random.toString('hex')}`; + const candidate = path.join(fixture.directory, name); + const moved = `${candidate}.published-inode`; + let swapped = false; + assert.throws( + () => loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('99')), + validateBytes: validatePrivateValue, + randomBytes: () => random, + faultInjector: (point) => { + if (point === 'after_private_directory_fsync') { + fs.renameSync(candidate, moved); + fs.writeFileSync(candidate, 'replacement cleanup target\n', { mode: 0o600 }); + swapped = true; + } + }, + pathTrust: fixture.pathTrust, + }), + /identity changed/, + ); + assert.equal(swapped, true); + assert.equal(fs.readFileSync(fixture.privatePath, 'utf8'), secret('99')); + assert.equal(fs.readFileSync(candidate, 'utf8'), 'replacement cleanup target\n'); +}); + test('valid final value wins recovery and validated candidates are removed', (t) => { const fixture = authority(t); const basename = path.basename(fixture.privatePath); @@ -612,14 +835,14 @@ test('wrong-owner-like recovery candidate metadata fails closed without chown pr const name = `.${path.basename(fixture.privatePath)}.tmp-707-${'77'.repeat(16)}`; const candidate = path.join(fixture.directory, name); fs.writeFileSync(candidate, secret('11'), { mode: 0o600 }); - const candidateStat = fs.statSync(candidate); + const candidateStat = fs.statSync(candidate, { bigint: true }); const originalFstat = fs.fstatSync; fs.fstatSync = function injectWrongOwner(descriptor, options) { const stat = originalFstat.call(fs, descriptor, options); - if (options === undefined && stat.isFile() && stat.ino === candidateStat.ino) { + if (options?.bigint === true && stat.isFile() && stat.ino === candidateStat.ino) { return new Proxy(stat, { get(target, property, receiver) { - if (property === 'uid') return target.uid + 1; + if (property === 'uid') return target.uid + 1n; return Reflect.get(target, property, receiver); }, }); diff --git a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs index d865f31..b25b836 100644 --- a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs +++ b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs @@ -64,6 +64,19 @@ function statProjection(role, paths) { }); } +function privateFileIdentity(filePath) { + const stat = fs.statSync(filePath, { bigint: true }); + return Object.freeze({ + device: stat.dev.toString(10), + inode: stat.ino.toString(10), + uid: Number(stat.uid), + gid: Number(stat.gid), + mode: Number(stat.mode & 0o7777n), + size: stat.size.toString(10), + modificationTime: stat.mtimeNs.toString(10), + }); +} + test('deterministic guard holds an owner-only chain and exposes only the frozen boundary', (t) => { const fixture = makeFixture(t); fs.writeFileSync(fixture.targetFile, 'database', { mode: 0o600 }); @@ -181,6 +194,23 @@ test('publish mismatch fails closed without unlinking a replacement leaf by path assert.equal(fs.readFileSync(fixture.targetFile, 'utf8'), 'replacement'); }); +test('validated private-temp identity gates publish and cleanup before pathname mutation', (t) => { + const fixture = makeFixture(t); + const guard = openTrustedParent(deterministicOptions(fixture)); + const name = `.kernel.sqlite.tmp-${process.pid}-${'fa'.repeat(16)}`; + const candidate = path.join(fixture.terminal, name); + fs.writeFileSync(candidate, 'validated', { mode: 0o600 }); + const identity = privateFileIdentity(candidate); + fs.renameSync(candidate, `${candidate}.moved`); + fs.writeFileSync(candidate, 'replacement', { mode: 0o600 }); + + assert.throws(() => guard.linkNamedToLeaf(name, identity), /identity changed/); + assert.equal(fs.existsSync(fixture.targetFile), false); + assert.throws(() => guard.unlinkNamed(name, identity), /identity changed/); + assert.equal(fs.readFileSync(candidate, 'utf8'), 'replacement'); + guard.close(); +}); + test('sibling and private-temp namespaces are closed and bounded', (t) => { const fixture = makeFixture(t); fs.writeFileSync(fixture.targetFile, 'database', { mode: 0o600 }); From ebcf6d8785b7621108bd242d1a3fb69482f25e92 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 02:24:15 -0400 Subject: [PATCH 155/165] fix: detach secure validator views --- .../pi-wielder/src/kernel/secure-storage.mjs | 1 - spikes/pi-wielder/src/kernel/trusted-path.mjs | 3 +++ spikes/pi-wielder/tests/kernel-store.test.mjs | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/spikes/pi-wielder/src/kernel/secure-storage.mjs b/spikes/pi-wielder/src/kernel/secure-storage.mjs index 839a465..6957511 100644 --- a/spikes/pi-wielder/src/kernel/secure-storage.mjs +++ b/spikes/pi-wielder/src/kernel/secure-storage.mjs @@ -158,7 +158,6 @@ function detachAliasedValidatorResult(result, bytes, label) { const inputEnd = inputStart + bytes.byteLength; const resultStart = result.byteOffset; const resultEnd = resultStart + result.byteLength; - if (resultEnd <= inputStart || resultStart >= inputEnd) return result; if (resultStart < inputStart || resultEnd > inputEnd) { throw new Error(`${label} validator must not expose memory outside its input bytes`); } diff --git a/spikes/pi-wielder/src/kernel/trusted-path.mjs b/spikes/pi-wielder/src/kernel/trusted-path.mjs index 445ec61..835fe37 100644 --- a/spikes/pi-wielder/src/kernel/trusted-path.mjs +++ b/spikes/pi-wielder/src/kernel/trusted-path.mjs @@ -523,6 +523,9 @@ export function openTrustedParent({ assertExpectedFileIdentity(before, expectedIdentity); linkCountBefore = before.nlink; } + // POSIX has no unlink-if-inode primitive. Live callers rely on the held + // Kernel-owned 0700 chain, a distinct Pi UID, and the authority lock; + // the checks here detect faults inside that explicit trust boundary. fs.unlinkSync(childLocation(name)); if (descriptor !== undefined) { const after = fs.fstatSync(descriptor, { bigint: true }); diff --git a/spikes/pi-wielder/tests/kernel-store.test.mjs b/spikes/pi-wielder/tests/kernel-store.test.mjs index f37fc56..e856804 100644 --- a/spikes/pi-wielder/tests/kernel-store.test.mjs +++ b/spikes/pi-wielder/tests/kernel-store.test.mjs @@ -590,6 +590,24 @@ test('private validator results are detached before input bytes are zeroed', (t) }); assert.equal(Buffer.isBuffer(view), true); assert.equal(view.toString('utf8'), 'secret'); + + assert.throws( + () => loadOrInitializePrivateFile({ + filePath: fixture.privatePath, + label: 'Receipt key', + createBytes: () => Buffer.from(secret('5d')), + validateBytes: (bytes) => { + const inputEnd = bytes.byteOffset + bytes.byteLength; + if (bytes.byteOffset > 0) return new Uint8Array(bytes.buffer, 0, 1); + if (inputEnd < bytes.buffer.byteLength) { + return new Uint8Array(bytes.buffer, inputEnd, 1); + } + return bytes.buffer; + }, + pathTrust: fixture.pathTrust, + }), + /outside its input bytes|input ArrayBuffer/, + ); }); test('existing empty, truncated, invalid, symlinked, permissive, and nonfile values fail closed', (t) => { From adf1bd7cf23e42d8a6f28369b4980dc3c2e5bd96 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 02:41:36 -0400 Subject: [PATCH 156/165] feat: add wallet kernel authority lock --- .../pi-wielder/src/kernel/authority-lock.mjs | 115 +++++ .../tests/fixtures/kernel-lock-worker.mjs | 65 +++ .../tests/kernel-authority-lock.test.mjs | 398 ++++++++++++++++++ 3 files changed, 578 insertions(+) create mode 100644 spikes/pi-wielder/src/kernel/authority-lock.mjs create mode 100644 spikes/pi-wielder/tests/fixtures/kernel-lock-worker.mjs create mode 100644 spikes/pi-wielder/tests/kernel-authority-lock.test.mjs diff --git a/spikes/pi-wielder/src/kernel/authority-lock.mjs b/spikes/pi-wielder/src/kernel/authority-lock.mjs new file mode 100644 index 0000000..004ec2d --- /dev/null +++ b/spikes/pi-wielder/src/kernel/authority-lock.mjs @@ -0,0 +1,115 @@ +import { DatabaseSync } from 'node:sqlite'; + +import { KernelError } from './canonical.mjs'; +import { preparePrivateFile } from './secure-storage.mjs'; + +const AUTHORITY_LOCK_SUFFIX = '.authority-lock.sqlite'; +const ROLES = new Set(['kernel', 'bootstrap', 'prelaunch']); +const SQLITE_BUSY = 5; +const SQLITE_LOCKED = 6; +const ACQUISITION_ATTEMPTS = 4; +const RETRY_SIGNAL = new Int32Array(new SharedArrayBuffer(4)); + +function isSqliteContention(error) { + if (error?.code !== 'ERR_SQLITE_ERROR' || !Number.isInteger(error.errcode)) return false; + const primaryResultCode = error.errcode & 0xff; + return primaryResultCode === SQLITE_BUSY || primaryResultCode === SQLITE_LOCKED; +} + +function closeFailedAcquisition(database) { + if (!database) return; + try { + database.close(); + } catch { + // Preserve the acquisition failure. This connection never owned the authority transaction. + } +} + +function waitForZeroTimeoutTieBreak(attempt) { + // Two new zero-time connections can both lose a simultaneous lock upgrade. + // Bounded PID-staggered fresh-connection retries resolve that startup tie + // without changing SQLite's zero timeout or indefinitely waiting on an owner. + Atomics.wait(RETRY_SIGNAL, 0, 0, 1 + ((process.pid * (attempt + 1)) % 97)); +} + +function openExclusiveAuthorityDatabase(lockPath) { + let database; + try { + database = new DatabaseSync(lockPath, { timeout: 0 }); + database.exec('BEGIN EXCLUSIVE'); + let journalMode = database.prepare('PRAGMA journal_mode').get()?.journal_mode; + if (journalMode !== 'delete') { + database.exec('ROLLBACK'); + journalMode = database.prepare('PRAGMA journal_mode = DELETE').get()?.journal_mode; + database.exec('BEGIN EXCLUSIVE'); + } + if (journalMode !== 'delete') { + throw new KernelError( + 'AUTHORITY_JOURNAL_MODE', + 'Wallet Kernel authority lock requires SQLite rollback journal mode', + ); + } + return database; + } catch (error) { + closeFailedAcquisition(database); + throw error; + } +} + +export function acquireAuthorityLock({ databasePath, role, pathTrust }) { + if (!ROLES.has(role)) { + throw new KernelError( + 'AUTHORITY_ROLE_INVALID', + 'Wallet Kernel authority role must be kernel, bootstrap, or prelaunch', + ); + } + if (typeof databasePath !== 'string') { + throw new KernelError( + 'AUTHORITY_PATH_INVALID', + 'Wallet Kernel database path must be an absolute string', + ); + } + + const lockPath = `${databasePath}${AUTHORITY_LOCK_SUFFIX}`; + preparePrivateFile(lockPath, 'Wallet Kernel authority lock', { pathTrust }); + + let database; + let contention; + for (let attempt = 0; attempt < ACQUISITION_ATTEMPTS; attempt += 1) { + try { + database = openExclusiveAuthorityDatabase(lockPath); + break; + } catch (error) { + if (!isSqliteContention(error)) throw error; + contention = error; + if (attempt + 1 < ACQUISITION_ATTEMPTS) waitForZeroTimeoutTieBreak(attempt); + } + } + if (!database) { + throw new KernelError( + 'AUTHORITY_BUSY', + 'Wallet Kernel authority is already held by another process', + { cause: contention }, + ); + } + + let closed = false; + return Object.freeze({ + close() { + if (closed) return; + closed = true; + let failure; + try { + database.exec('ROLLBACK'); + } catch (error) { + failure = error; + } + try { + database.close(); + } catch (error) { + failure ??= error; + } + if (failure) throw failure; + }, + }); +} diff --git a/spikes/pi-wielder/tests/fixtures/kernel-lock-worker.mjs b/spikes/pi-wielder/tests/fixtures/kernel-lock-worker.mjs new file mode 100644 index 0000000..a45654f --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/kernel-lock-worker.mjs @@ -0,0 +1,65 @@ +import { DatabaseSync } from 'node:sqlite'; + +import { acquireAuthorityLock } from '../../src/kernel/authority-lock.mjs'; + +function report(message) { + if (typeof process.send === 'function') process.send(message); + else process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function mutateMainDatabase(databasePath, marker) { + const database = new DatabaseSync(databasePath, { timeout: 0 }); + try { + database.exec(` + CREATE TABLE IF NOT EXISTS authority_lock_probe ( + marker TEXT PRIMARY KEY NOT NULL + ) STRICT + `); + database.prepare('INSERT INTO authority_lock_probe(marker) VALUES (?)').run(marker); + } finally { + database.close(); + } +} + +const payload = JSON.parse(process.argv[2]); +const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: payload.trustedAncestor, + kernelUid: process.getuid(), + agentUid: process.getuid(), +}); + +let lock; +try { + lock = acquireAuthorityLock({ + databasePath: payload.databasePath, + role: payload.role, + pathTrust, + }); + if (payload.mutateMainDatabase === true) { + mutateMainDatabase(payload.databasePath, payload.marker); + } + report({ role: payload.role, type: 'ready' }); +} catch (error) { + report({ + code: error?.code ?? null, + message: error?.message ?? String(error), + name: error?.name ?? null, + type: 'error', + }); + process.exitCode = 1; +} + +if (lock) { + process.on('message', (message) => { + if (message?.type === 'abort') process.abort(); + if (message?.type !== 'release') return; + try { + lock.close(); + lock.close(); + report({ type: 'closed' }); + } finally { + process.exit(0); + } + }); +} diff --git a/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs b/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs new file mode 100644 index 0000000..d9307df --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs @@ -0,0 +1,398 @@ +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { acquireAuthorityLock } from '../src/kernel/authority-lock.mjs'; +import { KernelError } from '../src/kernel/canonical.mjs'; + +const CURRENT_UID = process.getuid(); +const REPOSITORY_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../', import.meta.url))); +const WORKER_PATH = fileURLToPath(new URL('./fixtures/kernel-lock-worker.mjs', import.meta.url)); +const ROLES = Object.freeze(['kernel', 'bootstrap', 'prelaunch']); + +function authority(t, prefix = 'wallet-kernel-authority-lock-') { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.chmodSync(directory, 0o700); + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: CURRENT_UID, + agentUid: CURRENT_UID, + }); + t.after(() => fs.rmSync(directory, { force: true, recursive: true })); + return { + databasePath: path.join(directory, 'kernel.sqlite'), + directory, + pathTrust, + }; +} + +function authorityLockPath(databasePath) { + return `${databasePath}.authority-lock.sqlite`; +} + +function startWorker(t, fixture, overrides = {}) { + const payload = { + databasePath: fixture.databasePath, + role: 'kernel', + trustedAncestor: fixture.directory, + ...overrides, + }; + const child = fork(WORKER_PATH, [JSON.stringify(payload)], { + silent: true, + }); + const messages = []; + const waiters = []; + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('message', (message) => { + const index = waiters.findIndex((waiter) => waiter.predicate(message)); + if (index === -1) { + messages.push(message); + return; + } + const [waiter] = waiters.splice(index, 1); + clearTimeout(waiter.timer); + waiter.resolve(message); + }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }); + + return { + child, + get stderr() { return stderr; }, + next(predicate = () => true, timeoutMilliseconds = 5_000) { + const index = messages.findIndex(predicate); + if (index !== -1) return Promise.resolve(messages.splice(index, 1)[0]); + return new Promise((resolve, reject) => { + const waiter = { predicate, resolve }; + waiter.timer = setTimeout(() => { + const waiterIndex = waiters.indexOf(waiter); + if (waiterIndex !== -1) waiters.splice(waiterIndex, 1); + reject(new Error(`timed out waiting for lock worker message; stderr=${stderr}`)); + }, timeoutMilliseconds); + waiters.push(waiter); + }); + }, + }; +} + +function waitForExit(worker, timeoutMilliseconds = 5_000) { + if (worker.child.exitCode !== null || worker.child.signalCode !== null) { + return Promise.resolve({ + code: worker.child.exitCode, + signal: worker.child.signalCode, + }); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`timed out waiting for lock worker exit; stderr=${worker.stderr}`)); + }, timeoutMilliseconds); + worker.child.once('exit', (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); +} + +async function releaseWorker(worker) { + worker.child.send({ type: 'release' }); + assert.deepEqual(await worker.next((message) => message.type === 'closed'), { + type: 'closed', + }); + assert.deepEqual(await waitForExit(worker), { code: 0, signal: null }); +} + +test('one process owns the shared authority until its idempotent close', (t) => { + const fixture = authority(t); + const owner = acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'kernel', + pathTrust: fixture.pathTrust, + }); + assert.equal(typeof owner.close, 'function'); + assert.throws( + () => acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'bootstrap', + pathTrust: fixture.pathTrust, + }), + (error) => error instanceof KernelError + && error.code === 'AUTHORITY_BUSY' + && /authority/i.test(error.message), + ); + owner.close(); + owner.close(); + acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'bootstrap', + pathTrust: fixture.pathTrust, + }).close(); + + const lockPath = authorityLockPath(fixture.databasePath); + assert.equal(fs.statSync(lockPath).mode & 0o777, 0o600); + const database = new DatabaseSync(lockPath, { readOnly: true }); + try { + assert.equal(database.prepare('PRAGMA journal_mode').get().journal_mode, 'delete'); + assert.deepEqual(database.prepare(` + SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name + `).all(), []); + } finally { + database.close(); + } +}); + +test('only kernel, bootstrap, and prelaunch roles are accepted', (t) => { + for (const role of ROLES) { + const fixture = authority(t, `wallet-kernel-role-${role}-`); + acquireAuthorityLock({ + databasePath: fixture.databasePath, + role, + pathTrust: fixture.pathTrust, + }).close(); + } + const fixture = authority(t, 'wallet-kernel-role-invalid-'); + for (const role of ['', 'operator', 'Kernel', null, 1]) { + assert.throws( + () => acquireAuthorityLock({ + databasePath: fixture.databasePath, + role, + pathTrust: fixture.pathTrust, + }), + (error) => error.code !== 'AUTHORITY_BUSY' && /role/.test(error.message), + ); + } +}); + +test('simultaneous fresh processes yield exactly one ready owner', async (t) => { + const fixture = authority(t, 'wallet-kernel-simultaneous-lock-'); + const first = startWorker(t, fixture, { role: 'kernel' }); + const second = startWorker(t, fixture, { role: 'bootstrap' }); + const outcomes = await Promise.all([first.next(), second.next()]); + assert.equal( + outcomes.filter((outcome) => outcome.type === 'ready').length, + 1, + JSON.stringify(outcomes), + ); + assert.equal(outcomes.filter( + (outcome) => outcome.type === 'error' && outcome.code === 'AUTHORITY_BUSY', + ).length, 1); + + const winner = outcomes[0].type === 'ready' ? first : second; + const loser = winner === first ? second : first; + assert.deepEqual(await waitForExit(loser), { code: 1, signal: null }); + await releaseWorker(winner); +}); + +test('every ordered role pair contends and clean close permits a successor', async (t) => { + for (const ownerRole of ROLES) { + for (const contenderRole of ROLES) { + const fixture = authority(t, `wallet-kernel-pair-${ownerRole}-${contenderRole}-`); + const owner = startWorker(t, fixture, { role: ownerRole }); + assert.deepEqual(await owner.next(), { role: ownerRole, type: 'ready' }); + + const contender = startWorker(t, fixture, { role: contenderRole }); + const rejected = await contender.next(); + assert.equal(rejected.type, 'error'); + assert.equal(rejected.code, 'AUTHORITY_BUSY'); + assert.deepEqual(await waitForExit(contender), { code: 1, signal: null }); + + await releaseWorker(owner); + const successor = startWorker(t, fixture, { role: contenderRole }); + assert.deepEqual(await successor.next(), { role: contenderRole, type: 'ready' }); + await releaseWorker(successor); + } + } +}); + +test('process.abort releases the OS lease and the leftover database is reusable', async (t) => { + const fixture = authority(t, 'wallet-kernel-crash-release-'); + const owner = startWorker(t, fixture, { role: 'kernel' }); + assert.deepEqual(await owner.next(), { role: 'kernel', type: 'ready' }); + const lockPath = authorityLockPath(fixture.databasePath); + assert.equal(fs.existsSync(lockPath), true); + const originalIdentity = fs.statSync(lockPath); + + owner.child.send({ type: 'abort' }); + const crashed = await waitForExit(owner); + assert.equal(crashed.code, null); + assert.equal(crashed.signal, 'SIGABRT'); + assert.equal(fs.existsSync(lockPath), true); + + const successor = startWorker(t, fixture, { role: 'prelaunch' }); + assert.deepEqual(await successor.next(), { role: 'prelaunch', type: 'ready' }); + await releaseWorker(successor); + const reusedIdentity = fs.statSync(lockPath); + assert.equal(reusedIdentity.dev, originalIdentity.dev); + assert.equal(reusedIdentity.ino, originalIdentity.ino); +}); + +test('PID-like files are neither trusted, created, nor deleted', (t) => { + const fixture = authority(t, 'wallet-kernel-no-pid-file-'); + const lockPath = authorityLockPath(fixture.databasePath); + const fakePidPath = `${lockPath}.pid`; + fs.writeFileSync(fakePidPath, '999999\n', { mode: 0o600 }); + + acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'bootstrap', + pathTrust: fixture.pathTrust, + }).close(); + + assert.equal(fs.readFileSync(fakePidPath, 'utf8'), '999999\n'); + assert.deepEqual( + fs.readdirSync(fixture.directory).filter((name) => name.endsWith('.pid')), + [path.basename(fakePidPath)], + ); +}); + +test('a contender cannot mutate the main database before acquiring authority', async (t) => { + const fixture = authority(t, 'wallet-kernel-mutation-order-'); + const database = new DatabaseSync(fixture.databasePath); + try { + database.exec(` + CREATE TABLE authority_lock_probe ( + marker TEXT PRIMARY KEY NOT NULL + ) STRICT + `); + } finally { + database.close(); + } + fs.chmodSync(fixture.databasePath, 0o600); + + const owner = startWorker(t, fixture, { role: 'kernel' }); + assert.deepEqual(await owner.next(), { role: 'kernel', type: 'ready' }); + const contender = startWorker(t, fixture, { + marker: 'must-not-appear', + mutateMainDatabase: true, + role: 'bootstrap', + }); + const rejected = await contender.next(); + assert.equal(rejected.type, 'error'); + assert.equal(rejected.code, 'AUTHORITY_BUSY'); + assert.deepEqual(await waitForExit(contender), { code: 1, signal: null }); + + let check = new DatabaseSync(fixture.databasePath, { readOnly: true }); + try { + assert.equal(check.prepare('SELECT count(*) AS count FROM authority_lock_probe').get().count, 0); + } finally { + check.close(); + } + await releaseWorker(owner); + + const successor = startWorker(t, fixture, { + marker: 'after-authority', + mutateMainDatabase: true, + role: 'bootstrap', + }); + assert.deepEqual(await successor.next(), { role: 'bootstrap', type: 'ready' }); + await releaseWorker(successor); + check = new DatabaseSync(fixture.databasePath, { readOnly: true }); + try { + assert.deepEqual( + check.prepare('SELECT marker FROM authority_lock_probe').all().map((row) => row.marker), + ['after-authority'], + ); + } finally { + check.close(); + } +}); + +test('derived lock database rejects checkout, symlink, permissive, and wrong-owner-like paths', (t) => { + { + const fixture = authority(t, 'wallet-kernel-checkout-lock-'); + assert.throws( + () => acquireAuthorityLock({ + databasePath: path.join(REPOSITORY_ROOT, 'spikes/pi-wielder/forbidden.sqlite'), + role: 'kernel', + pathTrust: fixture.pathTrust, + }), + (error) => error.code !== 'AUTHORITY_BUSY' && /outside the checkout/.test(error.message), + ); + } + { + const fixture = authority(t, 'wallet-kernel-symlink-lock-'); + const target = path.join(fixture.directory, 'symlink-target'); + fs.writeFileSync(target, '', { mode: 0o600 }); + fs.symlinkSync(target, authorityLockPath(fixture.databasePath)); + assert.throws( + () => acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'kernel', + pathTrust: fixture.pathTrust, + }), + (error) => error.code !== 'AUTHORITY_BUSY' && /symlink/.test(error.message), + ); + } + { + const fixture = authority(t, 'wallet-kernel-permissive-lock-'); + const lockPath = authorityLockPath(fixture.databasePath); + fs.writeFileSync(lockPath, '', { mode: 0o644 }); + assert.throws( + () => acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'prelaunch', + pathTrust: fixture.pathTrust, + }), + (error) => error.code !== 'AUTHORITY_BUSY' && /owner-only/.test(error.message), + ); + assert.equal(fs.statSync(lockPath).mode & 0o777, 0o644); + } + { + const fixture = authority(t, 'wallet-kernel-owner-lock-'); + fs.writeFileSync(authorityLockPath(fixture.databasePath), '', { mode: 0o600 }); + const originalFstat = fs.fstatSync; + fs.fstatSync = function reportDifferentFileOwner(descriptor, options) { + const stat = originalFstat.call(fs, descriptor, options); + if (stat.isFile()) stat.uid = CURRENT_UID + 1; + return stat; + }; + try { + assert.throws( + () => acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'prelaunch', + pathTrust: fixture.pathTrust, + }), + (error) => error.code !== 'AUTHORITY_BUSY' && /current user/.test(error.message), + ); + } finally { + fs.fstatSync = originalFstat; + } + } +}); + +test('malformed paths and non-busy SQLite failures are never mapped to AUTHORITY_BUSY', (t) => { + const fixture = authority(t, 'wallet-kernel-distinct-errors-'); + assert.throws( + () => acquireAuthorityLock({ + databasePath: 'relative.sqlite', + role: 'kernel', + pathTrust: fixture.pathTrust, + }), + (error) => error.code !== 'AUTHORITY_BUSY' && /absolute/.test(error.message), + ); + + fs.writeFileSync( + authorityLockPath(fixture.databasePath), + 'this is not a sqlite database', + { mode: 0o600 }, + ); + assert.throws( + () => acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'kernel', + pathTrust: fixture.pathTrust, + }), + (error) => error.code === 'ERR_SQLITE_ERROR' + && error.errcode !== 5 + && error.errcode !== 6, + ); +}); From ae9bce5d7e3f46a944710abf9e0ac596bf3f1c8a Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 02:55:05 -0400 Subject: [PATCH 157/165] fix: harden authority lock transitions --- .../pi-wielder/src/kernel/authority-lock.mjs | 69 ++++++---- .../tests/kernel-authority-lock.test.mjs | 126 ++++++++++++++++++ 2 files changed, 172 insertions(+), 23 deletions(-) diff --git a/spikes/pi-wielder/src/kernel/authority-lock.mjs b/spikes/pi-wielder/src/kernel/authority-lock.mjs index 004ec2d..cc6bf33 100644 --- a/spikes/pi-wielder/src/kernel/authority-lock.mjs +++ b/spikes/pi-wielder/src/kernel/authority-lock.mjs @@ -10,18 +10,32 @@ const SQLITE_LOCKED = 6; const ACQUISITION_ATTEMPTS = 4; const RETRY_SIGNAL = new Int32Array(new SharedArrayBuffer(4)); +class RetryableJournalTransition extends Error { + constructor() { + super('SQLite rollback journal transition did not complete'); + this.name = 'RetryableJournalTransition'; + } +} + function isSqliteContention(error) { if (error?.code !== 'ERR_SQLITE_ERROR' || !Number.isInteger(error.errcode)) return false; const primaryResultCode = error.errcode & 0xff; return primaryResultCode === SQLITE_BUSY || primaryResultCode === SQLITE_LOCKED; } -function closeFailedAcquisition(database) { +function closeFailedAcquisition(database, transactionHeld) { if (!database) return; + if (transactionHeld) { + try { + database.exec('ROLLBACK'); + } catch { + // Preserve the acquisition failure while still attempting connection close. + } + } try { database.close(); } catch { - // Preserve the acquisition failure. This connection never owned the authority transaction. + // Preserve the acquisition failure; a failed open never returns an authority handle. } } @@ -34,28 +48,31 @@ function waitForZeroTimeoutTieBreak(attempt) { function openExclusiveAuthorityDatabase(lockPath) { let database; + let transactionHeld = false; try { database = new DatabaseSync(lockPath, { timeout: 0 }); database.exec('BEGIN EXCLUSIVE'); + transactionHeld = true; let journalMode = database.prepare('PRAGMA journal_mode').get()?.journal_mode; if (journalMode !== 'delete') { database.exec('ROLLBACK'); + transactionHeld = false; journalMode = database.prepare('PRAGMA journal_mode = DELETE').get()?.journal_mode; + if (journalMode !== 'delete') throw new RetryableJournalTransition(); database.exec('BEGIN EXCLUSIVE'); - } - if (journalMode !== 'delete') { - throw new KernelError( - 'AUTHORITY_JOURNAL_MODE', - 'Wallet Kernel authority lock requires SQLite rollback journal mode', - ); + transactionHeld = true; } return database; } catch (error) { - closeFailedAcquisition(database); + closeFailedAcquisition(database, transactionHeld); throw error; } } +function isRetryableAcquisition(error) { + return isSqliteContention(error) || error instanceof RetryableJournalTransition; +} + export function acquireAuthorityLock({ databasePath, role, pathTrust }) { if (!ROLES.has(role)) { throw new KernelError( @@ -74,14 +91,14 @@ export function acquireAuthorityLock({ databasePath, role, pathTrust }) { preparePrivateFile(lockPath, 'Wallet Kernel authority lock', { pathTrust }); let database; - let contention; + let retryableFailure; for (let attempt = 0; attempt < ACQUISITION_ATTEMPTS; attempt += 1) { try { database = openExclusiveAuthorityDatabase(lockPath); break; } catch (error) { - if (!isSqliteContention(error)) throw error; - contention = error; + if (!isRetryableAcquisition(error)) throw error; + retryableFailure = error; if (attempt + 1 < ACQUISITION_ATTEMPTS) waitForZeroTimeoutTieBreak(attempt); } } @@ -89,27 +106,33 @@ export function acquireAuthorityLock({ databasePath, role, pathTrust }) { throw new KernelError( 'AUTHORITY_BUSY', 'Wallet Kernel authority is already held by another process', - { cause: contention }, + { cause: retryableFailure }, ); } - let closed = false; + let state = 'transaction-held'; return Object.freeze({ close() { - if (closed) return; - closed = true; - let failure; - try { - database.exec('ROLLBACK'); - } catch (error) { - failure = error; + if (state === 'closed' || state === 'rolling-back' || state === 'closing') return; + if (state === 'transaction-held') { + state = 'rolling-back'; + try { + database.exec('ROLLBACK'); + state = 'rollback-complete'; + } catch (error) { + state = 'transaction-held'; + throw error; + } } + + state = 'closing'; try { database.close(); } catch (error) { - failure ??= error; + state = 'rollback-complete'; + throw error; } - if (failure) throw failure; + state = 'closed'; }, }); } diff --git a/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs b/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs index d9307df..ada99af 100644 --- a/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs +++ b/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs @@ -149,6 +149,40 @@ test('one process owns the shared authority until its idempotent close', (t) => } }); +test('a failed connection close is retryable without rolling back twice', (t) => { + const fixture = authority(t, 'wallet-kernel-close-retry-'); + const owner = acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'kernel', + pathTrust: fixture.pathTrust, + }); + const originalClose = DatabaseSync.prototype.close; + const originalExec = DatabaseSync.prototype.exec; + let closeCalls = 0; + let rollbackCalls = 0; + DatabaseSync.prototype.exec = function countAuthorityRollback(statement) { + if (statement === 'ROLLBACK') rollbackCalls += 1; + return originalExec.call(this, statement); + }; + DatabaseSync.prototype.close = function failFirstAuthorityClose() { + closeCalls += 1; + if (closeCalls === 1) throw new Error('injected authority connection close failure'); + return originalClose.call(this); + }; + try { + assert.throws(() => owner.close(), /injected authority connection close failure/); + assert.equal(rollbackCalls, 1); + assert.doesNotThrow(() => owner.close()); + assert.equal(closeCalls, 2); + assert.equal(rollbackCalls, 1); + assert.doesNotThrow(() => owner.close()); + assert.equal(closeCalls, 2); + } finally { + DatabaseSync.prototype.close = originalClose; + DatabaseSync.prototype.exec = originalExec; + } +}); + test('only kernel, bootstrap, and prelaunch roles are accepted', (t) => { for (const role of ROLES) { const fixture = authority(t, `wallet-kernel-role-${role}-`); @@ -191,6 +225,98 @@ test('simultaneous fresh processes yield exactly one ready owner', async (t) => await releaseWorker(winner); }); +test('simultaneous fresh processes migrate WAL to one rollback-journal owner', async (t) => { + const fixture = authority(t, 'wallet-kernel-simultaneous-wal-lock-'); + const lockPath = authorityLockPath(fixture.databasePath); + fs.writeFileSync(lockPath, '', { mode: 0o600 }); + const setup = new DatabaseSync(lockPath, { timeout: 0 }); + try { + assert.equal( + setup.prepare('PRAGMA journal_mode = WAL').get().journal_mode, + 'wal', + ); + } finally { + setup.close(); + } + + const first = startWorker(t, fixture, { role: 'kernel' }); + const second = startWorker(t, fixture, { role: 'prelaunch' }); + const outcomes = await Promise.all([first.next(), second.next()]); + assert.equal( + outcomes.filter((outcome) => outcome.type === 'ready').length, + 1, + JSON.stringify(outcomes), + ); + assert.equal( + outcomes.filter( + (outcome) => outcome.type === 'error' && outcome.code === 'AUTHORITY_BUSY', + ).length, + 1, + JSON.stringify(outcomes), + ); + + const winner = outcomes[0].type === 'ready' ? first : second; + const loser = winner === first ? second : first; + assert.deepEqual(await waitForExit(loser), { code: 1, signal: null }); + await releaseWorker(winner); + + const check = new DatabaseSync(lockPath, { readOnly: true }); + try { + assert.equal(check.prepare('PRAGMA journal_mode').get().journal_mode, 'delete'); + } finally { + check.close(); + } +}); + +test('a transient non-delete WAL result exhausts as AUTHORITY_BUSY', (t) => { + const fixture = authority(t, 'wallet-kernel-wal-transition-busy-'); + const lockPath = authorityLockPath(fixture.databasePath); + fs.writeFileSync(lockPath, '', { mode: 0o600 }); + const setup = new DatabaseSync(lockPath, { timeout: 0 }); + try { + assert.equal( + setup.prepare('PRAGMA journal_mode = WAL').get().journal_mode, + 'wal', + ); + } finally { + setup.close(); + } + + const originalPrepare = DatabaseSync.prototype.prepare; + DatabaseSync.prototype.prepare = function retainWalMode(statement) { + if (statement === 'PRAGMA journal_mode = DELETE') { + return Object.freeze({ + get: () => Object.freeze({ journal_mode: 'wal' }), + }); + } + return originalPrepare.call(this, statement); + }; + try { + assert.throws( + () => acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'kernel', + pathTrust: fixture.pathTrust, + }), + (error) => error instanceof KernelError && error.code === 'AUTHORITY_BUSY', + ); + } finally { + DatabaseSync.prototype.prepare = originalPrepare; + } + + acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'kernel', + pathTrust: fixture.pathTrust, + }).close(); + const check = new DatabaseSync(lockPath, { readOnly: true }); + try { + assert.equal(check.prepare('PRAGMA journal_mode').get().journal_mode, 'delete'); + } finally { + check.close(); + } +}); + test('every ordered role pair contends and clean close permits a successor', async (t) => { for (const ownerRole of ROLES) { for (const contenderRole of ROLES) { From f27cd13cd33e6594dfd4e786cefbedc3feaacb07 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 02:56:45 -0400 Subject: [PATCH 158/165] fix: preserve authority lock error boundary --- spikes/pi-wielder/src/kernel/authority-lock.mjs | 7 +++++++ spikes/pi-wielder/tests/kernel-authority-lock.test.mjs | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/spikes/pi-wielder/src/kernel/authority-lock.mjs b/spikes/pi-wielder/src/kernel/authority-lock.mjs index cc6bf33..abf1804 100644 --- a/spikes/pi-wielder/src/kernel/authority-lock.mjs +++ b/spikes/pi-wielder/src/kernel/authority-lock.mjs @@ -103,6 +103,13 @@ export function acquireAuthorityLock({ databasePath, role, pathTrust }) { } } if (!database) { + if (retryableFailure instanceof RetryableJournalTransition) { + throw new KernelError( + 'AUTHORITY_JOURNAL_MODE', + 'Wallet Kernel authority lock could not enter SQLite rollback journal mode', + { cause: retryableFailure }, + ); + } throw new KernelError( 'AUTHORITY_BUSY', 'Wallet Kernel authority is already held by another process', diff --git a/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs b/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs index ada99af..7376d79 100644 --- a/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs +++ b/spikes/pi-wielder/tests/kernel-authority-lock.test.mjs @@ -268,7 +268,7 @@ test('simultaneous fresh processes migrate WAL to one rollback-journal owner', a } }); -test('a transient non-delete WAL result exhausts as AUTHORITY_BUSY', (t) => { +test('a persistent non-delete WAL result remains distinct from AUTHORITY_BUSY', (t) => { const fixture = authority(t, 'wallet-kernel-wal-transition-busy-'); const lockPath = authorityLockPath(fixture.databasePath); fs.writeFileSync(lockPath, '', { mode: 0o600 }); @@ -298,7 +298,9 @@ test('a transient non-delete WAL result exhausts as AUTHORITY_BUSY', (t) => { role: 'kernel', pathTrust: fixture.pathTrust, }), - (error) => error instanceof KernelError && error.code === 'AUTHORITY_BUSY', + (error) => error instanceof KernelError + && error.code === 'AUTHORITY_JOURNAL_MODE' + && /rollback journal mode/.test(error.message), ); } finally { DatabaseSync.prototype.prepare = originalPrepare; From 176fa23c17885d72760f12d48ca1aac6374bfba5 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 03:15:10 -0400 Subject: [PATCH 159/165] feat: add durable wallet kernel store --- .../pi-wielder/src/kernel/sqlite-schema.mjs | 327 ++++++++ spikes/pi-wielder/src/kernel/sqlite-store.mjs | 278 +++++++ .../tests/fixtures/kernel-db-writer.mjs | 28 + spikes/pi-wielder/tests/kernel-store.test.mjs | 749 ++++++++++++++++++ 4 files changed, 1382 insertions(+) create mode 100644 spikes/pi-wielder/src/kernel/sqlite-schema.mjs create mode 100644 spikes/pi-wielder/src/kernel/sqlite-store.mjs create mode 100644 spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs diff --git a/spikes/pi-wielder/src/kernel/sqlite-schema.mjs b/spikes/pi-wielder/src/kernel/sqlite-schema.mjs new file mode 100644 index 0000000..a840fae --- /dev/null +++ b/spikes/pi-wielder/src/kernel/sqlite-schema.mjs @@ -0,0 +1,327 @@ +export const KERNEL_SCHEMA_VERSION = 1; + +export const SCHEMA_V1_SQL = String.raw` +CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS policy_versions ( + id TEXT PRIMARY KEY, + schema_version INTEGER NOT NULL, + canonical_json TEXT NOT NULL, + policy_hash TEXT NOT NULL UNIQUE, + predecessor_hash TEXT, + applied_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS spend_sessions ( + id TEXT PRIMARY KEY, + adapter_id TEXT NOT NULL, + wallet_address TEXT NOT NULL, + policy_version_id TEXT NOT NULL REFERENCES policy_versions(id), + state TEXT NOT NULL CHECK (state IN ('open','policy_blocked','closed')), + created_at TEXT NOT NULL, + closed_at TEXT +) STRICT; + +CREATE TABLE IF NOT EXISTS agent_enrollments ( + agent_instance_id TEXT PRIMARY KEY, + credential_digest TEXT NOT NULL UNIQUE, + enrollment_hash TEXT NOT NULL UNIQUE, + agent_uid TEXT NOT NULL CHECK ( + agent_uid GLOB '[1-9]*' AND agent_uid NOT GLOB '*[^0-9]*' + ), + agent_gid TEXT NOT NULL CHECK ( + agent_gid GLOB '[1-9]*' AND agent_gid NOT GLOB '*[^0-9]*' + ), + state TEXT NOT NULL CHECK (state IN ('active','revoked')), + enrolled_by_operator_hash TEXT NOT NULL, + enrolled_at TEXT NOT NULL, + revoked_by_operator_hash TEXT, + revoked_at TEXT, + UNIQUE(agent_instance_id, credential_digest), + UNIQUE(agent_instance_id, credential_digest, enrollment_hash), + CHECK ( + (state = 'active' AND revoked_by_operator_hash IS NULL AND revoked_at IS NULL) OR + (state = 'revoked' AND revoked_by_operator_hash IS NOT NULL AND revoked_at IS NOT NULL) + ) +) STRICT; + +CREATE TABLE IF NOT EXISTS isolation_attestations ( + id TEXT PRIMARY KEY, + report_hash TEXT NOT NULL UNIQUE, + enrollment_hash TEXT NOT NULL REFERENCES agent_enrollments(enrollment_hash), + report_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('current','superseded')), + imported_by_operator_hash TEXT NOT NULL, + probed_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + imported_at TEXT NOT NULL, + superseded_at TEXT, + CHECK ( + (state = 'current' AND superseded_at IS NULL) OR + (state = 'superseded' AND superseded_at IS NOT NULL) + ) +) STRICT; + +CREATE TABLE IF NOT EXISTS agent_session_bindings ( + id TEXT PRIMARY KEY, + agent_instance_id TEXT NOT NULL, + credential_digest TEXT NOT NULL, + enrollment_hash TEXT NOT NULL, + session_id TEXT NOT NULL UNIQUE REFERENCES spend_sessions(id), + state TEXT NOT NULL CHECK (state IN ('open','closed')), + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + closed_at TEXT, + FOREIGN KEY(agent_instance_id, credential_digest, enrollment_hash) + REFERENCES agent_enrollments(agent_instance_id, credential_digest, enrollment_hash) +) STRICT; + +CREATE TABLE IF NOT EXISTS spend_intents ( + id TEXT PRIMARY KEY, + request_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL REFERENCES spend_sessions(id), + enrollment_hash TEXT NOT NULL REFERENCES agent_enrollments(enrollment_hash), + route_id TEXT NOT NULL, + method TEXT NOT NULL, + request_url_hash TEXT NOT NULL, + seller_origin TEXT NOT NULL, + resource_path TEXT NOT NULL, + body_hash TEXT NOT NULL, + header_allowlist_hash TEXT NOT NULL, + ordinary_fingerprint TEXT NOT NULL, + retry_matchable INTEGER NOT NULL DEFAULT 1 CHECK (retry_matchable IN (0,1)), + purpose_label TEXT NOT NULL, + correlation_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + wallet_address TEXT NOT NULL, + intent_hash TEXT NOT NULL UNIQUE, + challenge_projection_json TEXT, + challenge_hash TEXT, + challenge_received_at TEXT, + state TEXT NOT NULL CHECK (state IN ( + 'captured','challenged','approval_pending','authorized','reserved','signing', + 'signed','retrying','unresolved','terminal' + )), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS policy_decisions ( + intent_id TEXT PRIMARY KEY REFERENCES spend_intents(id), + policy_version_id TEXT NOT NULL REFERENCES policy_versions(id), + decision TEXT NOT NULL CHECK (decision IN ('allow','approval_required','deny')), + reason_code TEXT NOT NULL, + challenge_hash TEXT NOT NULL, + accepted_index INTEGER, + quote_id TEXT, + amount_ceiling_atomic TEXT NOT NULL CHECK ( + amount_ceiling_atomic = '0' OR + (amount_ceiling_atomic GLOB '[1-9]*' AND amount_ceiling_atomic NOT GLOB '*[^0-9]*') + ), + decided_at TEXT NOT NULL, + CHECK ( + (accepted_index IS NULL AND quote_id IS NULL) OR + (accepted_index >= 0 AND quote_id IS NOT NULL) + ) +) STRICT; + +CREATE TABLE IF NOT EXISTS budget_reservations ( + intent_id TEXT PRIMARY KEY REFERENCES spend_intents(id), + session_id TEXT NOT NULL REFERENCES spend_sessions(id), + seller_origin TEXT NOT NULL, + reserved_atomic TEXT NOT NULL CHECK (reserved_atomic = '0' OR + (reserved_atomic GLOB '[1-9]*' AND reserved_atomic NOT GLOB '*[^0-9]*')), + committed_atomic TEXT NOT NULL CHECK (committed_atomic = '0' OR + (committed_atomic GLOB '[1-9]*' AND committed_atomic NOT GLOB '*[^0-9]*')), + released_atomic TEXT NOT NULL CHECK (released_atomic = '0' OR + (released_atomic GLOB '[1-9]*' AND released_atomic NOT GLOB '*[^0-9]*')), + unresolved_atomic TEXT NOT NULL CHECK (unresolved_atomic = '0' OR + (unresolved_atomic GLOB '[1-9]*' AND unresolved_atomic NOT GLOB '*[^0-9]*')), + state TEXT NOT NULL CHECK (state IN ('reserved','committed','released','unresolved')), + committed_at TEXT, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS approvals ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL UNIQUE REFERENCES spend_intents(id), + decision TEXT NOT NULL CHECK ( + decision IN ('pending','approved','denied','expired','cancelled','consumed') + ), + operator_id_hash TEXT, + intent_hash TEXT NOT NULL, + challenge_hash TEXT NOT NULL, + quote_id TEXT NOT NULL, + accepted_index INTEGER NOT NULL CHECK (accepted_index >= 0), + amount_ceiling_atomic TEXT NOT NULL CHECK ( + amount_ceiling_atomic = '0' OR + (amount_ceiling_atomic GLOB '[1-9]*' AND amount_ceiling_atomic NOT GLOB '*[^0-9]*') + ), + wallet_address TEXT NOT NULL, + policy_version_id TEXT NOT NULL REFERENCES policy_versions(id), + expires_at TEXT NOT NULL, + reason_code TEXT, + decided_at TEXT, + consumed_at TEXT +) STRICT; + +CREATE TABLE IF NOT EXISTS payment_attempts ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL UNIQUE REFERENCES spend_intents(id), + state TEXT NOT NULL CHECK (state IN ( + 'reserved','signing','signed','retrying','unresolved','settled','rejected' + )), + payment_required_projection_json TEXT NOT NULL, + accepted_index INTEGER NOT NULL CHECK (accepted_index >= 0), + payment_payload_json TEXT, + payment_header TEXT, + payment_hash TEXT, + quote_id TEXT NOT NULL, + nonce TEXT UNIQUE, + valid_after TEXT CHECK (valid_after IS NULL OR valid_after = '0' OR + (valid_after GLOB '[1-9]*' AND valid_after NOT GLOB '*[^0-9]*')), + valid_before TEXT CHECK (valid_before IS NULL OR valid_before = '0' OR + (valid_before GLOB '[1-9]*' AND valid_before NOT GLOB '*[^0-9]*')), + settlement_json TEXT, + transaction_id TEXT UNIQUE, + reason_code TEXT, + signing_claimed_at TEXT, + signed_at TEXT, + retry_started_at TEXT, + settled_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS payment_reconciliation_candidates ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL REFERENCES payment_attempts(intent_id), + transaction_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('pending','abandoned','rejected','confirmed')), + evidence_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS execution_outcomes ( + intent_id TEXT PRIMARY KEY REFERENCES spend_intents(id), + state TEXT NOT NULL CHECK (state IN ('succeeded','failed','unknown')), + http_status INTEGER CHECK (http_status IS NULL OR (http_status BETWEEN 100 AND 599)), + response_hash TEXT, + metadata_json TEXT NOT NULL, + recorded_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS execution_resolutions ( + intent_id TEXT PRIMARY KEY REFERENCES execution_outcomes(intent_id), + state TEXT NOT NULL CHECK (state IN ( + 'refund_pending','reconciliation_required','resolved' + )), + reason_code TEXT NOT NULL, + blocks_wallet INTEGER NOT NULL CHECK (blocks_wallet IN (0,1)), + opened_at TEXT NOT NULL, + resolved_at TEXT, + CHECK ( + (state = 'resolved' AND blocks_wallet = 0 AND resolved_at IS NOT NULL) OR + (state != 'resolved' AND blocks_wallet = 1 AND resolved_at IS NULL) + ) +) STRICT; + +CREATE TABLE IF NOT EXISTS refunds ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL REFERENCES spend_intents(id), + original_transaction_id TEXT NOT NULL, + amount_atomic TEXT NOT NULL CHECK (amount_atomic = '0' OR + (amount_atomic GLOB '[1-9]*' AND amount_atomic NOT GLOB '*[^0-9]*')), + state TEXT NOT NULL CHECK ( + state IN ('pending','unresolved','abandoned','confirmed','rejected') + ), + evidence_json TEXT, + refund_transaction_id TEXT UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS reconciliations ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL REFERENCES spend_intents(id), + kind TEXT NOT NULL CHECK (kind IN ('payment','execution','refund')), + outcome TEXT NOT NULL CHECK (outcome IN ( + 'settled','rejected','execution_succeeded','execution_failed', + 'execution_unknown','refund_confirmed','refund_rejected','unresolved' + )), + evidence_json TEXT NOT NULL, + operator_id_hash TEXT NOT NULL, + recorded_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS buyer_outcomes ( + intent_id TEXT PRIMARY KEY REFERENCES spend_intents(id), + status TEXT NOT NULL CHECK (status IN ( + 'completed','upstream_failed','payment_denied','payment_failed', + 'payment_unresolved','payment_rejected','execution_failed', + 'execution_unknown','refunded' + )), + reason_code TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + recorded_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS signed_receipts ( + id TEXT PRIMARY KEY, + intent_id TEXT NOT NULL REFERENCES spend_intents(id), + revision INTEGER NOT NULL CHECK (revision >= 1), + receipt_json TEXT NOT NULL, + receipt_hash TEXT NOT NULL UNIQUE, + signature TEXT NOT NULL, + algorithm TEXT NOT NULL CHECK (algorithm = 'Ed25519'), + key_id TEXT NOT NULL, + supersedes_receipt_hash TEXT, + created_at TEXT NOT NULL, + UNIQUE(intent_id, revision) +) STRICT; + +CREATE TABLE IF NOT EXISTS events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + event_type TEXT NOT NULL, + data_json TEXT NOT NULL, + previous_hash TEXT, + event_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_intents_session_hash + ON spend_intents(session_id, intent_hash, state); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_open_instance + ON agent_session_bindings(agent_instance_id) WHERE state = 'open'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_open_credential + ON agent_session_bindings(credential_digest) WHERE state = 'open'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_active_agent_enrollment + ON agent_enrollments(state) WHERE state = 'active'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_current_isolation_attestation + ON isolation_attestations(state) WHERE state = 'current'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_intents_retry_fingerprint + ON spend_intents(session_id, ordinary_fingerprint) WHERE retry_matchable = 1; +CREATE UNIQUE INDEX IF NOT EXISTS idx_intents_session_correlation + ON spend_intents(session_id, correlation_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_session_binding + ON spend_sessions(adapter_id, wallet_address, policy_version_id) + WHERE state = 'open'; +CREATE INDEX IF NOT EXISTS idx_budget_session_seller + ON budget_reservations(session_id, seller_origin, state); +CREATE INDEX IF NOT EXISTS idx_budget_committed_at + ON budget_reservations(committed_at); +CREATE INDEX IF NOT EXISTS idx_approvals_state_expiry + ON approvals(decision, expires_at); +CREATE INDEX IF NOT EXISTS idx_payment_state + ON payment_attempts(state); +CREATE UNIQUE INDEX IF NOT EXISTS idx_refunds_one_open_intent + ON refunds(intent_id) WHERE state IN ('pending','unresolved'); +CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_candidate_one_open_intent + ON payment_reconciliation_candidates(intent_id) WHERE state = 'pending'; +`; diff --git a/spikes/pi-wielder/src/kernel/sqlite-store.mjs b/spikes/pi-wielder/src/kernel/sqlite-store.mjs new file mode 100644 index 0000000..eb2a518 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/sqlite-store.mjs @@ -0,0 +1,278 @@ +import { DatabaseSync } from 'node:sqlite'; + +import { canonicalJson, exactRecord, sha256 } from './canonical.mjs'; +import { + preflightSqliteFiles, + preparePrivateFile, + secureNewSqliteSideFiles, +} from './secure-storage.mjs'; +import { KERNEL_SCHEMA_VERSION, SCHEMA_V1_SQL } from './sqlite-schema.mjs'; + +const EXPOSED_PRAGMAS = Object.freeze([ + 'journal_mode', + 'synchronous', + 'foreign_keys', + 'user_version', +]); +function assertSynchronousOperation(operation) { + if (Object.getPrototypeOf(operation) !== Function.prototype) { + throw new Error( + 'authority transactions must be synchronous; only ordinary synchronous functions are accepted', + ); + } +} + +function hasThenBoundary(value) { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') { + return false; + } + let cursor = value; + while (cursor !== null) { + const descriptor = Object.getOwnPropertyDescriptor(cursor, 'then'); + if (descriptor) { + return !Object.hasOwn(descriptor, 'value') || typeof descriptor.value === 'function'; + } + cursor = Object.getPrototypeOf(cursor); + } + return false; +} + +function assertSafeTransactionReturn(value, token, seen = new Set()) { + if (value === token) { + throw new Error('authority transaction token must not cross a return boundary'); + } + if (hasThenBoundary(value)) throw new Error('authority transactions must be synchronous'); + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return; + if (typeof value === 'function') { + throw new Error('authority transaction return values must be inert data'); + } + if (seen.has(value)) return; + seen.add(value); + + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) { + throw new Error('authority transaction return values must be inert data'); + } + assertSafeTransactionReturn(descriptor.value, token, seen); + } + if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null && prototype !== Array.prototype) { + throw new Error('authority transaction return values must be inert data'); + } +} + +function rollbackWithoutMasking(db) { + try { + if (db?.isTransaction) db.exec('ROLLBACK'); + } catch { + // Preserve the initialization or transaction error that caused cleanup. + } +} + +function closeWithoutMasking(db) { + try { + db?.close(); + } catch { + // Preserve the initialization error that caused cleanup. + } +} + +export function openKernelStore({ + filePath, + allowMemory = false, + pathTrust, + now = () => new Date().toISOString(), +}) { + const inMemory = filePath === ':memory:'; + if (inMemory && !allowMemory) { + throw new Error('in-memory authority requires explicit test injection'); + } + + const existing = inMemory ? null : preflightSqliteFiles(filePath, { pathTrust }); + if (!inMemory) { + preparePrivateFile(filePath, 'Wallet Kernel database', { pathTrust }); + } + + let db; + try { + db = new DatabaseSync(filePath, { timeout: 5_000, readBigInts: true }); + db.exec('PRAGMA foreign_keys = ON; PRAGMA trusted_schema = OFF; PRAGMA synchronous = FULL;'); + if (!inMemory) db.exec('PRAGMA journal_mode = WAL;'); + + const version = Number(db.prepare('PRAGMA user_version').get().user_version); + if (version > KERNEL_SCHEMA_VERSION) { + throw new Error('Wallet Kernel database uses a newer schema'); + } + if (version === 0) { + db.exec('BEGIN IMMEDIATE'); + try { + db.exec(SCHEMA_V1_SQL); + db.exec(`PRAGMA user_version = ${KERNEL_SCHEMA_VERSION}`); + db.exec('COMMIT'); + } catch (error) { + rollbackWithoutMasking(db); + throw error; + } + } + + if (!inMemory) secureNewSqliteSideFiles(filePath, existing, { pathTrust }); + } catch (error) { + rollbackWithoutMasking(db); + closeWithoutMasking(db); + throw error; + } + + const liveTransactions = new WeakSet(); + let transactionOpen = false; + let closed = false; + + const assertOpen = () => { + if (closed) throw new Error('Wallet Kernel store is closed'); + }; + + const appendEvent = (event, txDb = db) => { + const { entityType, entityId, eventType, data } = exactRecord( + event, + ['entityType', 'entityId', 'eventType', 'data'], + [], + 'EVENT_SCHEMA', + 'event', + ); + const previous = txDb.prepare( + 'SELECT event_hash FROM events ORDER BY sequence DESC LIMIT 1', + ).get(); + const createdAt = now(); + const dataJson = canonicalJson(data); + const previousHash = previous?.event_hash ?? null; + const eventHash = sha256(canonicalJson({ + entityType, + entityId, + eventType, + data, + previousHash, + createdAt, + })); + txDb.prepare(`INSERT INTO events + (entity_type, entity_id, event_type, data_json, previous_hash, event_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`) + .run(entityType, entityId, eventType, dataJson, previousHash, eventHash, createdAt); + return eventHash; + }; + + const within = (token, operation) => { + assertOpen(); + if (!liveTransactions.has(token)) throw new Error('invalid authority transaction'); + if (typeof operation !== 'function') throw new TypeError('transaction operation must be a function'); + assertSynchronousOperation(operation); + const value = operation({ db, appendEvent: (event) => appendEvent(event, db) }); + assertSafeTransactionReturn(value, token); + return value; + }; + + const transaction = (operation) => { + assertOpen(); + if (typeof operation !== 'function') throw new TypeError('transaction operation must be a function'); + assertSynchronousOperation(operation); + if (transactionOpen) throw new Error('nested authority transaction is forbidden'); + transactionOpen = true; + const token = Object.freeze(Object.create(null)); + try { + db.exec('BEGIN IMMEDIATE'); + liveTransactions.add(token); + const value = operation(token); + assertSafeTransactionReturn(value, token); + if (!inMemory) preflightSqliteFiles(filePath, { pathTrust }); + db.exec('COMMIT'); + return value; + } catch (error) { + rollbackWithoutMasking(db); + throw error; + } finally { + liveTransactions.delete(token); + transactionOpen = false; + } + }; + + const mutate = (event, operation) => transaction((token) => within(token, + ({ db: txDb, appendEvent: appendInTransaction }) => { + if (typeof operation !== 'function') { + throw new TypeError('mutation operation must be a function'); + } + assertSynchronousOperation(operation); + const value = operation({ db: txDb }); + appendInTransaction(event); + return value; + })); + + const events = () => { + assertOpen(); + return db.prepare('SELECT * FROM events ORDER BY sequence').all(); + }; + + const readStatement = (sql) => { + assertOpen(); + if (typeof sql !== 'string' || !/^\s*SELECT\b/i.test(sql) || sql.includes(';')) { + throw new Error('only one parameterized SELECT is exposed outside a transaction'); + } + return db.prepare(sql); + }; + const readOne = (sql, parameters = []) => readStatement(sql).get(...parameters); + const readAll = (sql, parameters = []) => readStatement(sql).all(...parameters); + + const pragma = (name) => { + assertOpen(); + if (!EXPOSED_PRAGMAS.includes(name)) throw new Error('PRAGMA is not exposed'); + const value = Object.values(db.prepare(`PRAGMA ${name}`).get())[0]; + return typeof value === 'bigint' ? Number(value) : value; + }; + + const verifyEventChain = () => { + let previousHash = null; + for (const row of events()) { + const expected = sha256(canonicalJson({ + entityType: row.entity_type, + entityId: row.entity_id, + eventType: row.event_type, + data: JSON.parse(row.data_json), + previousHash, + createdAt: row.created_at, + })); + if (row.previous_hash !== previousHash || row.event_hash !== expected) return false; + previousHash = row.event_hash; + } + return true; + }; + + const close = () => { + if (closed) return; + if (transactionOpen) throw new Error('cannot close Wallet Kernel store during a transaction'); + db.close(); + closed = true; + }; + + return Object.freeze({ + transaction, + within, + mutate, + readOne, + readAll, + events, + verifyEventChain, + pragma, + integrityCheck: () => { + assertOpen(); + return db.prepare('PRAGMA integrity_check').get().integrity_check; + }, + getMetadata: (key) => { + assertOpen(); + return db.prepare('SELECT value FROM metadata WHERE key = ?').get(key)?.value ?? null; + }, + close, + ...(inMemory && allowMemory ? { execForTest: (sql) => { + assertOpen(); + return db.exec(sql); + } } : {}), + }); +} diff --git a/spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs b/spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs new file mode 100644 index 0000000..48ea71a --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs @@ -0,0 +1,28 @@ +import { openKernelStore } from '../../src/kernel/sqlite-store.mjs'; + +const [databasePath, trustedAncestor, claimId] = process.argv.slice(2); +const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor, + kernelUid: process.getuid(), + agentUid: process.getuid(), +}); +const store = openKernelStore({ filePath: databasePath, pathTrust }); +try { + const outcome = store.transaction((token) => store.within(token, + ({ db, appendEvent }) => { + const current = db.prepare('SELECT value FROM metadata WHERE key = ?').get('claim'); + if (current) return 'already_claimed'; + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('claim', claimId); + appendEvent({ + entityType: 'test', + entityId: claimId, + eventType: 'test.claimed', + data: { claimId }, + }); + return 'claimed'; + })); + process.stdout.write(`${outcome}\n`); +} finally { + store.close(); +} diff --git a/spikes/pi-wielder/tests/kernel-store.test.mjs b/spikes/pi-wielder/tests/kernel-store.test.mjs index e856804..41b93ef 100644 --- a/spikes/pi-wielder/tests/kernel-store.test.mjs +++ b/spikes/pi-wielder/tests/kernel-store.test.mjs @@ -3,9 +3,11 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; import { loadOrInitializePrivateFile, preflightSqliteFiles, @@ -37,6 +39,73 @@ function authority(t, prefix = 'wallet-kernel-storage-') { }; } +function childResult(child) { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', reject); + child.on('exit', (code) => code === 0 + ? resolve(stdout.trim()) + : reject(new Error(`writer exited ${code}: ${stderr}`))); + }); +} + +function memoryStore() { + return openKernelStore({ filePath: ':memory:', allowMemory: true }); +} + +function sqlText(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function seedSchemaDependencies(store) { + store.execForTest(` + INSERT INTO policy_versions + (id, schema_version, canonical_json, policy_hash, applied_at) + VALUES ('policy-1', 1, '{}', 'policy-hash-1', '2026-08-01T00:00:00.000Z'); + INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, + state, enrolled_by_operator_hash, enrolled_at) + VALUES ('agent-1', 'credential-1', 'enrollment-1', '1000', '1000', + 'active', 'operator-1', '2026-08-01T00:00:00.000Z'); + INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at) + VALUES ('session-1', 'adapter-1', '0xwallet', 'policy-1', 'open', + '2026-08-01T00:00:00.000Z'); + INSERT INTO spend_intents + (id, request_id, session_id, enrollment_hash, route_id, method, + request_url_hash, seller_origin, resource_path, body_hash, + header_allowlist_hash, ordinary_fingerprint, purpose_label, + correlation_id, idempotency_key, wallet_address, intent_hash, + state, created_at, updated_at) + VALUES ('intent-1', 'request-1', 'session-1', 'enrollment-1', 'route-1', 'POST', + 'url-hash-1', 'https://seller.example', '/resource', 'body-hash-1', + 'headers-hash-1', 'fingerprint-1', 'inference', 'correlation-1', + 'idempotency-1', '0xwallet', 'intent-hash-1', 'captured', + '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z'); + `); +} + +function withSeededStore(operation) { + const store = memoryStore(); + try { + seedSchemaDependencies(store); + return operation(store); + } finally { + store.close(); + } +} + +function assertAccepted(sql) { + withSeededStore((store) => assert.doesNotThrow(() => store.execForTest(sql))); +} + +function assertRejected(sql) { + withSeededStore((store) => assert.throws(() => store.execForTest(sql), /constraint/i)); +} + function secret(pair) { return `secret:${pair.repeat(16)}\n`; } @@ -953,3 +1022,683 @@ test('fresh processes recover one reusable value after every private-file crash ); } }); + +test('in-memory authority requires explicit injection and exposes test SQL only there', (t) => { + assert.throws( + () => openKernelStore({ filePath: ':memory:' }), + /explicit test injection/, + ); + const memory = memoryStore(); + assert.equal(typeof memory.execForTest, 'function'); + memory.close(); + + const fixture = authority(t, 'wallet-kernel-store-surface-'); + const persistent = openKernelStore({ + filePath: fixture.databasePath, + allowMemory: true, + pathTrust: fixture.pathTrust, + }); + assert.equal(persistent.execForTest, undefined); + assert.equal(persistent.rawForModules, undefined); + assert.equal(persistent.appendEvent, undefined); + assert.equal(Object.isFrozen(persistent), true); + persistent.close(); +}); + +test('persistent store enables WAL, FULL sync, foreign keys, and schema v1', (t) => { + const fixture = authority(t, 'wallet-kernel-store-pragmas-'); + const store = openKernelStore({ + filePath: fixture.databasePath, + pathTrust: fixture.pathTrust, + }); + assert.equal(store.pragma('journal_mode'), 'wal'); + assert.equal(store.pragma('synchronous'), 2); + assert.equal(store.pragma('foreign_keys'), 1); + assert.equal(store.pragma('user_version'), 1); + assert.equal(store.integrityCheck(), 'ok'); + assert.throws(() => store.pragma('trusted_schema'), /not exposed/); + store.close(); + for (const suffix of ['', '-wal', '-shm']) { + const target = `${fixture.databasePath}${suffix}`; + if (fs.existsSync(target)) assert.equal(fs.statSync(target).mode & 0o777, 0o600); + } +}); + +test('persistent store rejects checkout, symlink, permissive, and wrong-owner-like paths', (t) => { + const fixture = authority(t, 'wallet-kernel-store-paths-'); + assert.throws(() => openKernelStore({ + filePath: path.join(REPOSITORY_ROOT, 'spikes/pi-wielder/kernel.sqlite'), + pathTrust: fixture.pathTrust, + }), /outside the checkout/); + + const target = path.join(fixture.directory, 'target.sqlite'); + fs.writeFileSync(target, '', { mode: 0o600 }); + fs.symlinkSync(target, fixture.databasePath); + assert.throws( + () => openKernelStore({ filePath: fixture.databasePath, pathTrust: fixture.pathTrust }), + /symlink|ELOOP/, + ); + fs.unlinkSync(fixture.databasePath); + fs.chmodSync(fixture.directory, 0o755); + assert.throws( + () => openKernelStore({ filePath: fixture.databasePath, pathTrust: fixture.pathTrust }), + /owner-only/, + ); + + const ownerFixture = authority(t, 'wallet-kernel-store-owner-'); + fs.writeFileSync(ownerFixture.databasePath, '', { mode: 0o600 }); + const databaseStat = fs.statSync(ownerFixture.databasePath, { bigint: true }); + const originalFstat = fs.fstatSync; + fs.fstatSync = function injectWrongOwner(descriptor, options) { + const stat = originalFstat.call(fs, descriptor, options); + if (stat.isFile() && BigInt(stat.ino) === databaseStat.ino) { + return new Proxy(stat, { + get(targetStat, property, receiver) { + if (property === 'uid') { + return typeof targetStat.uid === 'bigint' ? targetStat.uid + 1n : targetStat.uid + 1; + } + return Reflect.get(targetStat, property, receiver); + }, + }); + } + return stat; + }; + try { + assert.throws( + () => openKernelStore({ + filePath: ownerFixture.databasePath, + pathTrust: ownerFixture.pathTrust, + }), + /owned by the current user/, + ); + } finally { + fs.fstatSync = originalFstat; + } +}); + +test('persistent store rejects insecure pre-existing SQLite sidecars', (t) => { + for (const suffix of ['-wal', '-shm']) { + const permissive = authority(t, `wallet-kernel-store-sidecar-${suffix.slice(1)}-`); + fs.writeFileSync(`${permissive.databasePath}${suffix}`, '', { mode: 0o644 }); + assert.throws( + () => openKernelStore({ + filePath: permissive.databasePath, + pathTrust: permissive.pathTrust, + }), + /owner-only/, + ); + + const symlinked = authority(t, `wallet-kernel-store-sidecar-link-${suffix.slice(1)}-`); + fs.symlinkSync(path.join(symlinked.directory, 'missing'), `${symlinked.databasePath}${suffix}`); + assert.throws( + () => openKernelStore({ + filePath: symlinked.databasePath, + pathTrust: symlinked.pathTrust, + }), + /symlink|ELOOP/, + ); + } +}); + +test('domain mutation and event append commit or roll back together', () => { + const store = memoryStore(); + try { + store.mutate({ + entityType: 'test', entityId: 'one', eventType: 'test.created', data: { value: 1 }, + }, ({ db }) => db.prepare( + 'INSERT INTO metadata(key, value) VALUES (?, ?)', + ).run('sample', 'one')); + assert.equal(store.events().length, 1); + assert.equal(store.verifyEventChain(), true); + + assert.throws(() => store.mutate({ + entityType: 'test', entityId: 'two', eventType: 'test.failed', data: { value: 2 }, + }, ({ db }) => { + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('rolled-back', 'yes'); + throw new Error('fault'); + }), /fault/); + assert.equal(store.events().length, 1); + assert.equal(store.getMetadata('sample'), 'one'); + assert.equal(store.getMetadata('rolled-back'), null); + } finally { + store.close(); + } +}); + +test('transaction tokens are live, synchronous, unforgeable, and non-nestable', () => { + const store = memoryStore(); + let stale; + try { + assert.equal(store.transaction((token) => store.within(token, ({ db }) => { + stale = token; + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('valid', 'yes'); + return 'committed'; + })), 'committed'); + assert.equal(store.getMetadata('valid'), 'yes'); + assert.throws(() => store.within(stale, () => undefined), /invalid authority transaction/); + assert.throws( + () => store.within(Object.freeze(Object.create(null)), () => undefined), + /invalid authority transaction/, + ); + + assert.throws(() => store.transaction((token) => { + store.within(token, ({ db }) => { + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('nested', 'no'); + }); + return store.transaction(() => 'forbidden'); + }), /nested authority transaction/); + assert.equal(store.getMetadata('nested'), null); + + assert.throws(() => store.transaction((token) => { + store.within(token, ({ db }) => { + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('async', 'no'); + }); + return Promise.resolve('forbidden'); + }), /must be synchronous/); + assert.equal(store.getMetadata('async'), null); + + assert.throws(() => store.transaction((token) => { + store.within(token, ({ db }) => { + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('wrapper', 'no'); + }); + return store.mutate({ + entityType: 'test', entityId: 'nested', eventType: 'test.nested', data: {}, + }, () => undefined); + }), /nested authority transaction/); + assert.equal(store.getMetadata('wrapper'), null); + assert.equal(store.events().length, 0); + + assert.throws(() => store.transaction((token) => token), /transaction token.*return/); + + assert.throws(() => store.transaction((token) => ({ nested: [token] })), + /transaction token.*return/); + assert.throws(() => store.transaction((token) => ({ [Symbol('hidden')]: token })), + /transaction token.*return/); + + assert.throws(() => store.transaction((token) => { + store.within(token, async ({ db }) => { + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('awaited', 'no'); + }); + return undefined; + }), /must be synchronous/); + assert.equal(store.getMetadata('awaited'), null); + + for (const operation of [ + function* generatorBoundary({ db }) { yield db; }, + async function* asyncGeneratorBoundary({ db }) { yield db; }, + ]) { + assert.throws(() => store.transaction((token) => { + store.within(token, operation); + return undefined; + }), /ordinary synchronous functions/); + } + } finally { + store.close(); + } +}); + +test('read boundary is one parameterized SELECT and persistent writes require a live token', (t) => { + const fixture = authority(t, 'wallet-kernel-store-reads-'); + const store = openKernelStore({ filePath: fixture.databasePath, pathTrust: fixture.pathTrust }); + try { + store.transaction((token) => store.within(token, ({ db }) => { + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('sample', 'one'); + })); + assert.deepEqual({ ...store.readOne( + 'SELECT value FROM metadata WHERE key = ?', ['sample'], + ) }, { value: 'one' }); + assert.deepEqual(store.readAll( + 'SELECT value FROM metadata WHERE key = ?', ['missing'], + ), []); + for (const sql of [ + 'INSERT INTO metadata(key, value) VALUES (?, ?)', + 'PRAGMA user_version', + 'SELECT 1; SELECT 2', + 'WITH row(value) AS (SELECT 1) SELECT value FROM row', + ]) { + assert.throws(() => store.readAll(sql), /only one parameterized SELECT/); + } + assert.equal(store.execForTest, undefined); + assert.throws( + () => store.within(Object.freeze(Object.create(null)), () => undefined), + /invalid authority transaction/, + ); + } finally { + store.close(); + } +}); + +test('event verification detects row tampering', () => { + const store = memoryStore(); + try { + store.mutate({ + entityType: 'test', entityId: 'one', eventType: 'test.created', data: { value: 1 }, + }, ({ db }) => db.prepare( + 'INSERT INTO metadata(key, value) VALUES (?, ?)', + ).run('sample', 'one')); + assert.equal(store.verifyEventChain(), true); + store.execForTest("UPDATE events SET data_json = '{\"value\":2}' WHERE sequence = 1"); + assert.equal(store.verifyEventChain(), false); + } finally { + store.close(); + } +}); + +test('event append validates one closed data-only envelope without invoking accessors', () => { + const store = memoryStore(); + let getterCalls = 0; + const event = { + entityId: 'one', + eventType: 'test.created', + data: { value: 1 }, + }; + Object.defineProperty(event, 'entityType', { + enumerable: true, + get() { + getterCalls += 1; + return 'test'; + }, + }); + try { + assert.throws(() => store.mutate(event, ({ db }) => db.prepare( + 'INSERT INTO metadata(key, value) VALUES (?, ?)', + ).run('accessor', 'no')), /closed schema/); + assert.equal(getterCalls, 0); + assert.equal(store.getMetadata('accessor'), null); + assert.equal(store.events().length, 0); + } finally { + store.close(); + } +}); + +test('a newer schema and initialization faults close the database without masking the error', (t) => { + const fixture = authority(t, 'wallet-kernel-store-newer-'); + const first = openKernelStore({ filePath: fixture.databasePath, pathTrust: fixture.pathTrust }); + first.close(); + const raw = new DatabaseSync(fixture.databasePath); + raw.exec('PRAGMA user_version = 99'); + raw.close(); + assert.throws( + () => openKernelStore({ filePath: fixture.databasePath, pathTrust: fixture.pathTrust }), + /newer schema/, + ); + const reusable = new DatabaseSync(fixture.databasePath, { timeout: 0 }); + reusable.exec('BEGIN EXCLUSIVE; ROLLBACK'); + reusable.close(); + + const sentinel = new Error('injected initialization fault'); + const originalPrepare = DatabaseSync.prototype.prepare; + const originalClose = DatabaseSync.prototype.close; + let closeCalls = 0; + DatabaseSync.prototype.prepare = function injectPrepare(sql) { + if (sql === 'PRAGMA user_version') throw sentinel; + return originalPrepare.call(this, sql); + }; + DatabaseSync.prototype.close = function countClose() { + closeCalls += 1; + return originalClose.call(this); + }; + try { + assert.throws( + () => openKernelStore({ filePath: ':memory:', allowMemory: true }), + (error) => error === sentinel, + ); + assert.equal(closeCalls, 1); + } finally { + DatabaseSync.prototype.prepare = originalPrepare; + DatabaseSync.prototype.close = originalClose; + } +}); + +const enumCases = [ + { + name: 'spend_sessions.state', + valid: ['open', 'policy_blocked', 'closed'], + insert: (value) => `INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at) + VALUES ('session-extra', 'adapter-extra', 'wallet-extra', 'policy-1', + ${sqlText(value)}, '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'agent_enrollments.state', + valid: ['active', 'revoked'], + insert: (value) => `DELETE FROM spend_intents; DELETE FROM spend_sessions; + DELETE FROM agent_enrollments; + INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, state, + enrolled_by_operator_hash, enrolled_at, revoked_by_operator_hash, revoked_at) + VALUES ('agent-extra', 'credential-extra', 'enrollment-extra', '1000', '1000', + ${sqlText(value)}, 'operator-1', '2026-08-01T00:00:00.000Z', + ${value === 'revoked' ? "'operator-2'" : 'NULL'}, + ${value === 'revoked' ? "'2026-08-01T01:00:00.000Z'" : 'NULL'})`, + }, + { + name: 'isolation_attestations.state', + valid: ['current', 'superseded'], + insert: (value) => `INSERT INTO isolation_attestations + (id, report_hash, enrollment_hash, report_json, state, imported_by_operator_hash, + probed_at, expires_at, imported_at, superseded_at) + VALUES ('attestation-1', 'report-1', 'enrollment-1', '{}', ${sqlText(value)}, + 'operator-1', '2026-08-01T00:00:00.000Z', '2026-08-02T00:00:00.000Z', + '2026-08-01T00:00:00.000Z', + ${value === 'superseded' ? "'2026-08-01T01:00:00.000Z'" : 'NULL'})`, + }, + { + name: 'agent_session_bindings.state', + valid: ['open', 'closed'], + insert: (value) => `INSERT INTO agent_session_bindings + (id, agent_instance_id, credential_digest, enrollment_hash, session_id, + state, created_at, last_seen_at) + VALUES ('binding-1', 'agent-1', 'credential-1', 'enrollment-1', 'session-1', + ${sqlText(value)}, '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'spend_intents.state', + valid: [ + 'captured', 'challenged', 'approval_pending', 'authorized', 'reserved', 'signing', + 'signed', 'retrying', 'unresolved', 'terminal', + ], + insert: (value) => `UPDATE spend_intents SET state = ${sqlText(value)} WHERE id = 'intent-1'`, + }, + { + name: 'policy_decisions.decision', + valid: ['allow', 'approval_required', 'deny'], + insert: (value) => `INSERT INTO policy_decisions + (intent_id, policy_version_id, decision, reason_code, challenge_hash, + amount_ceiling_atomic, decided_at) + VALUES ('intent-1', 'policy-1', ${sqlText(value)}, 'reason-1', 'challenge-1', '0', + '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'budget_reservations.state', + valid: ['reserved', 'committed', 'released', 'unresolved'], + insert: (value) => `INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, updated_at) + VALUES ('intent-1', 'session-1', 'https://seller.example', '1', '0', '0', '0', + ${sqlText(value)}, '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'approvals.decision', + valid: ['pending', 'approved', 'denied', 'expired', 'cancelled', 'consumed'], + insert: (value) => `INSERT INTO approvals + (id, intent_id, decision, intent_hash, challenge_hash, quote_id, accepted_index, + amount_ceiling_atomic, wallet_address, policy_version_id, expires_at) + VALUES ('approval-1', 'intent-1', ${sqlText(value)}, 'intent-hash-1', 'challenge-1', + 'quote-1', 0, '0', '0xwallet', 'policy-1', '2026-08-02T00:00:00.000Z')`, + }, + { + name: 'payment_attempts.state', + valid: ['reserved', 'signing', 'signed', 'retrying', 'unresolved', 'settled', 'rejected'], + insert: (value) => `INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, quote_id, + created_at, updated_at) + VALUES ('payment-1', 'intent-1', ${sqlText(value)}, '{}', 0, 'quote-1', + '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'payment_reconciliation_candidates.state', + valid: ['pending', 'abandoned', 'rejected', 'confirmed'], + insert: (value) => `INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, quote_id, + created_at, updated_at) + VALUES ('payment-1', 'intent-1', 'unresolved', '{}', 0, 'quote-1', + '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z'); + INSERT INTO payment_reconciliation_candidates + (id, intent_id, transaction_id, state, created_at, updated_at) + VALUES ('candidate-1', 'intent-1', 'transaction-1', ${sqlText(value)}, + '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'execution_outcomes.state', + valid: ['succeeded', 'failed', 'unknown'], + insert: (value) => `INSERT INTO execution_outcomes + (intent_id, state, metadata_json, recorded_at) + VALUES ('intent-1', ${sqlText(value)}, '{}', '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'execution_resolutions.state', + valid: ['refund_pending', 'reconciliation_required', 'resolved'], + insert: (value) => `INSERT INTO execution_outcomes + (intent_id, state, metadata_json, recorded_at) + VALUES ('intent-1', 'unknown', '{}', '2026-08-01T00:00:00.000Z'); + INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at, resolved_at) + VALUES ('intent-1', ${sqlText(value)}, 'reason-1', + ${value === 'resolved' ? 0 : 1}, '2026-08-01T00:00:00.000Z', + ${value === 'resolved' ? "'2026-08-01T01:00:00.000Z'" : 'NULL'})`, + }, + { + name: 'refunds.state', + valid: ['pending', 'unresolved', 'abandoned', 'confirmed', 'rejected'], + insert: (value) => `INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, created_at, updated_at) + VALUES ('refund-1', 'intent-1', 'transaction-1', '0', ${sqlText(value)}, + '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'reconciliations.kind', + valid: ['payment', 'execution', 'refund'], + insert: (value) => `INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-1', 'intent-1', ${sqlText(value)}, 'unresolved', '{}', + 'operator-1', '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'reconciliations.outcome', + valid: [ + 'settled', 'rejected', 'execution_succeeded', 'execution_failed', + 'execution_unknown', 'refund_confirmed', 'refund_rejected', 'unresolved', + ], + insert: (value) => `INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-1', 'intent-1', 'payment', ${sqlText(value)}, '{}', + 'operator-1', '2026-08-01T00:00:00.000Z')`, + }, + { + name: 'buyer_outcomes.status', + valid: [ + 'completed', 'upstream_failed', 'payment_denied', 'payment_failed', + 'payment_unresolved', 'payment_rejected', 'execution_failed', + 'execution_unknown', 'refunded', + ], + insert: (value) => `INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES ('intent-1', ${sqlText(value)}, 'reason-1', 1, + '2026-08-01T00:00:00.000Z')`, + }, +]; + +for (const enumCase of enumCases) { + test(`${enumCase.name} accepts every declared value and rejects undeclared values`, () => { + for (const value of enumCase.valid) assertAccepted(enumCase.insert(value)); + assertRejected(enumCase.insert('not-declared')); + }); +} + +test('canonical atomic columns reject negatives, leading zeroes, non-digits, and empty text', () => { + const invalid = ['-1', '01', '1x', '']; + const cases = [ + (value) => `INSERT INTO policy_decisions + (intent_id, policy_version_id, decision, reason_code, challenge_hash, + amount_ceiling_atomic, decided_at) + VALUES ('intent-1', 'policy-1', 'allow', 'reason-1', 'challenge-1', + ${sqlText(value)}, '2026-08-01T00:00:00.000Z')`, + (value) => `INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, updated_at) + VALUES ('intent-1', 'session-1', 'https://seller.example', ${sqlText(value)}, + '0', '0', '0', 'reserved', '2026-08-01T00:00:00.000Z')`, + (value) => `INSERT INTO approvals + (id, intent_id, decision, intent_hash, challenge_hash, quote_id, accepted_index, + amount_ceiling_atomic, wallet_address, policy_version_id, expires_at) + VALUES ('approval-1', 'intent-1', 'pending', 'intent-hash-1', 'challenge-1', + 'quote-1', 0, ${sqlText(value)}, '0xwallet', 'policy-1', + '2026-08-02T00:00:00.000Z')`, + (value) => `INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, created_at, updated_at) + VALUES ('refund-1', 'intent-1', 'transaction-1', ${sqlText(value)}, 'pending', + '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z')`, + ]; + for (const makeSql of cases) { + for (const value of ['0', '1']) assertAccepted(makeSql(value)); + for (const value of invalid) assertRejected(makeSql(value)); + } + + for (const column of ['committed_atomic', 'released_atomic', 'unresolved_atomic']) { + for (const value of invalid) { + assertRejected(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, updated_at) + VALUES ('intent-1', 'session-1', 'https://seller.example', '0', + ${column === 'committed_atomic' ? sqlText(value) : "'0'"}, + ${column === 'released_atomic' ? sqlText(value) : "'0'"}, + ${column === 'unresolved_atomic' ? sqlText(value) : "'0'"}, + 'reserved', '2026-08-01T00:00:00.000Z')`); + } + } +}); + +test('UID, validity-window, index, HTTP, and revision boundaries are enforced', () => { + for (const column of ['agent_uid', 'agent_gid']) { + for (const value of ['-1', '0', '01', '1x', '']) { + assertRejected(`DELETE FROM spend_intents; DELETE FROM spend_sessions; + DELETE FROM agent_enrollments; + INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, + state, enrolled_by_operator_hash, enrolled_at) + VALUES ('agent-extra', 'credential-extra', 'enrollment-extra', + ${column === 'agent_uid' ? sqlText(value) : "'1000'"}, + ${column === 'agent_gid' ? sqlText(value) : "'1000'"}, + 'active', 'operator-1', '2026-08-01T00:00:00.000Z')`); + } + } + + for (const column of ['valid_after', 'valid_before']) { + for (const value of ['0', '1']) { + assertAccepted(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, ${column}, created_at, updated_at) + VALUES ('payment-1', 'intent-1', 'reserved', '{}', 0, 'quote-1', + ${sqlText(value)}, '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z')`); + } + for (const value of ['-1', '01', '1x', '']) { + assertRejected(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, ${column}, created_at, updated_at) + VALUES ('payment-1', 'intent-1', 'reserved', '{}', 0, 'quote-1', + ${sqlText(value)}, '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z')`); + } + } + + const acceptedIndexStatements = [ + `INSERT INTO policy_decisions + (intent_id, policy_version_id, decision, reason_code, challenge_hash, + accepted_index, quote_id, amount_ceiling_atomic, decided_at) + VALUES ('intent-1', 'policy-1', 'allow', 'reason-1', 'challenge-1', -1, + 'quote-1', '0', '2026-08-01T00:00:00.000Z')`, + `INSERT INTO approvals + (id, intent_id, decision, intent_hash, challenge_hash, quote_id, accepted_index, + amount_ceiling_atomic, wallet_address, policy_version_id, expires_at) + VALUES ('approval-1', 'intent-1', 'pending', 'intent-hash-1', 'challenge-1', + 'quote-1', -1, '0', '0xwallet', 'policy-1', '2026-08-02T00:00:00.000Z')`, + `INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, quote_id, + created_at, updated_at) + VALUES ('payment-1', 'intent-1', 'reserved', '{}', -1, 'quote-1', + '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z')`, + ]; + for (const sql of acceptedIndexStatements) assertRejected(sql); + + for (const status of [100, 599]) { + assertAccepted(`INSERT INTO execution_outcomes + (intent_id, state, http_status, metadata_json, recorded_at) + VALUES ('intent-1', 'succeeded', ${status}, '{}', '2026-08-01T00:00:00.000Z')`); + } + for (const status of [99, 600]) { + assertRejected(`INSERT INTO execution_outcomes + (intent_id, state, http_status, metadata_json, recorded_at) + VALUES ('intent-1', 'succeeded', ${status}, '{}', '2026-08-01T00:00:00.000Z')`); + } + + for (const revision of [0, -1]) { + assertRejected(`INSERT INTO signed_receipts + (id, intent_id, revision, receipt_json, receipt_hash, signature, algorithm, + key_id, created_at) + VALUES ('receipt-1', 'intent-1', ${revision}, '{}', 'receipt-hash-1', + 'signature-1', 'Ed25519', 'key-1', '2026-08-01T00:00:00.000Z')`); + assertRejected(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES ('intent-1', 'completed', 'reason-1', ${revision}, + '2026-08-01T00:00:00.000Z')`); + } + assertAccepted(`INSERT INTO signed_receipts + (id, intent_id, revision, receipt_json, receipt_hash, signature, algorithm, + key_id, created_at) + VALUES ('receipt-1', 'intent-1', 1, '{}', 'receipt-hash-1', + 'signature-1', 'Ed25519', 'key-1', '2026-08-01T00:00:00.000Z')`); + assertRejected(`INSERT INTO signed_receipts + (id, intent_id, revision, receipt_json, receipt_hash, signature, algorithm, + key_id, created_at) + VALUES ('receipt-1', 'intent-1', 1, '{}', 'receipt-hash-1', + 'signature-1', 'RSA', 'key-1', '2026-08-01T00:00:00.000Z')`); +}); + +test('boolean and paired-index CHECK boundaries accept only their declared forms', () => { + for (const value of [0, 1]) { + assertAccepted(`UPDATE spend_intents SET retry_matchable = ${value} + WHERE id = 'intent-1'`); + } + for (const value of [-1, 2]) { + assertRejected(`UPDATE spend_intents SET retry_matchable = ${value} + WHERE id = 'intent-1'`); + } + + assertAccepted(`INSERT INTO policy_decisions + (intent_id, policy_version_id, decision, reason_code, challenge_hash, + accepted_index, quote_id, amount_ceiling_atomic, decided_at) + VALUES ('intent-1', 'policy-1', 'allow', 'reason-1', 'challenge-1', 0, + 'quote-1', '0', '2026-08-01T00:00:00.000Z')`); + assertRejected(`INSERT INTO policy_decisions + (intent_id, policy_version_id, decision, reason_code, challenge_hash, + accepted_index, amount_ceiling_atomic, decided_at) + VALUES ('intent-1', 'policy-1', 'allow', 'reason-1', 'challenge-1', 0, + '0', '2026-08-01T00:00:00.000Z')`); + + assertRejected(`INSERT INTO execution_outcomes + (intent_id, state, metadata_json, recorded_at) + VALUES ('intent-1', 'unknown', '{}', '2026-08-01T00:00:00.000Z'); + INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at) + VALUES ('intent-1', 'refund_pending', 'reason-1', 2, + '2026-08-01T00:00:00.000Z')`); +}); + +test('two processes serialize one conditional claim and one hash-chain event', async (t) => { + const fixtureAuthority = authority(t, 'wallet-kernel-store-writers-'); + const initial = openKernelStore({ + filePath: fixtureAuthority.databasePath, + pathTrust: fixtureAuthority.pathTrust, + }); + initial.close(); + const fixture = fileURLToPath(new URL('./fixtures/kernel-db-writer.mjs', import.meta.url)); + const children = ['a', 'b'].map((claimId) => spawn( + process.execPath, + [fixture, fixtureAuthority.databasePath, fixtureAuthority.directory, claimId], + { stdio: ['ignore', 'pipe', 'pipe'] }, + )); + assert.deepEqual( + (await Promise.all(children.map(childResult))).sort(), + ['already_claimed', 'claimed'], + ); + const reopened = openKernelStore({ + filePath: fixtureAuthority.databasePath, + pathTrust: fixtureAuthority.pathTrust, + }); + assert.equal(reopened.verifyEventChain(), true); + assert.equal( + reopened.events().filter((event) => event.event_type === 'test.claimed').length, + 1, + ); + reopened.close(); +}); From 76e98a053e1874f58a5e3fe2e6cf9db857f2b6f4 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 03:43:53 -0400 Subject: [PATCH 160/165] fix: preserve SQLite WAL locking protocol --- .../pi-wielder/src/kernel/secure-storage.mjs | 172 ++++++++++-- spikes/pi-wielder/src/kernel/sqlite-store.mjs | 45 ++- spikes/pi-wielder/src/kernel/trusted-path.mjs | 22 ++ .../fixtures/kernel-db-lock-contender.mjs | 29 ++ .../tests/fixtures/kernel-db-writer.mjs | 10 +- spikes/pi-wielder/tests/kernel-store.test.mjs | 262 +++++++++++++++++- 6 files changed, 509 insertions(+), 31 deletions(-) create mode 100644 spikes/pi-wielder/tests/fixtures/kernel-db-lock-contender.mjs diff --git a/spikes/pi-wielder/src/kernel/secure-storage.mjs b/spikes/pi-wielder/src/kernel/secure-storage.mjs index 6957511..ff1d76e 100644 --- a/spikes/pi-wielder/src/kernel/secure-storage.mjs +++ b/spikes/pi-wielder/src/kernel/secure-storage.mjs @@ -11,6 +11,9 @@ const NOFOLLOW = fs.constants.O_NOFOLLOW; const PRIVATE_TEMP_LIST = Symbol.for( 'skill-asset-protocol.wallet-kernel.trusted-parent.private-temp-list.v1', ); +const STAT_SQLITE_SIBLING = Symbol.for( + 'skill-asset-protocol.wallet-kernel.trusted-parent.sqlite-sibling-stat.v1', +); const SQLITE_SUFFIXES = Object.freeze(['', '-wal', '-shm']); const PATH_TRUST_FIELDS = Object.freeze([ 'mode', @@ -112,6 +115,30 @@ function sameFileIdentity(left, right) { && left.modificationTime === right.modificationTime; } +function sqliteIdentityFor(stat) { + const mode = typeof stat.mode === 'bigint' + ? Number(stat.mode & 0o7777n) + : stat.mode & 0o7777; + return Object.freeze({ + device: stat.dev.toString(10), + inode: stat.ino.toString(10), + uid: Number(stat.uid), + gid: Number(stat.gid), + mode, + }); +} + +function sameSqliteFile(left, right) { + return left?.device === right.device && left?.inode === right.inode; +} + +function sameSqliteIdentity(left, right) { + return sameSqliteFile(left, right) + && left.uid === right.uid + && left.gid === right.gid + && left.mode === right.mode; +} + function privateParent(filePath, label, checkoutRoot, pathTrust) { assertSecurePlatform(); const trust = capturePathTrust(pathTrust); @@ -255,13 +282,16 @@ export function readPrivateInputFile(filePath, label, { } export function preflightSqliteFiles(databasePath, { pathTrust } = {}) { + // Call only before SQLite opens this path in the current process. POSIX + // record locks are process-associated and closing any descriptor for a + // locked inode can release SQLite's locks on its separate descriptor. const guard = privateParent( databasePath, 'Wallet Kernel database', CHECKOUT_ROOT, pathTrust, ); - const existingSuffixes = new Set(); + const existingFiles = new Map(); try { for (const suffix of SQLITE_SUFFIXES) { let descriptor; @@ -273,8 +303,9 @@ export function preflightSqliteFiles(databasePath, { pathTrust } = {}) { } try { const label = `SQLite ${suffix || 'database'}`; - assertOwnerOnlyRegular(fs.fstatSync(descriptor), label); - existingSuffixes.add(suffix); + const stat = fs.fstatSync(descriptor, { bigint: true }); + assertOwnerOnlyRegular(stat, label); + existingFiles.set(suffix, sqliteIdentityFor(stat)); } finally { fs.closeSync(descriptor); } @@ -284,7 +315,7 @@ export function preflightSqliteFiles(databasePath, { pathTrust } = {}) { SQLITE_PREFLIGHTS.set(capability, Object.freeze({ ancestorMetadataHash: guard.ancestorMetadataHash, databasePath, - existingSuffixes, + existingFiles, })); return capability; } finally { @@ -292,13 +323,50 @@ export function preflightSqliteFiles(databasePath, { pathTrust } = {}) { } } -export function secureNewSqliteSideFiles(databasePath, existing, { pathTrust } = {}) { +export function secureNewSqliteSideFiles(databasePath, existing, { + pathTrust, + onAcquisitionFailure, +} = {}) { const guard = privateParent( databasePath, 'Wallet Kernel database', CHECKOUT_ROOT, pathTrust, ); + const heldFiles = new Map(); + // These proof descriptors deliberately remain open for the SQLite + // connection's lifetime. Revalidation uses fstat/lstat only; close() must + // run after DatabaseSync.close() so it cannot tear down SQLite's locks. + let guardClosed = false; + let state = 'acquiring'; + const closeHeldFiles = () => { + if (state === 'closed') return; + state = 'closing'; + let firstError; + for (const [suffix, held] of [...heldFiles.entries()].reverse()) { + if (!held) { + heldFiles.delete(suffix); + continue; + } + try { + fs.closeSync(held.descriptor); + heldFiles.delete(suffix); + } catch (error) { + firstError ??= error; + } + } + if (heldFiles.size === 0 && !guardClosed) { + try { + guard.close(); + guardClosed = true; + } catch (error) { + firstError ??= error; + } + } + if (heldFiles.size === 0 && guardClosed) state = 'closed'; + if (firstError) throw firstError; + }; + try { const preflight = SQLITE_PREFLIGHTS.get(existing); if (!preflight) throw new Error('SQLite repair requires an opaque preflight capability'); @@ -314,30 +382,90 @@ export function secureNewSqliteSideFiles(databasePath, existing, { pathTrust } = try { descriptor = guard.openSibling(suffix, fs.constants.O_RDONLY | NOFOLLOW); } catch (error) { - if (error.code === 'ENOENT') continue; + if (error.code === 'ENOENT') { + heldFiles.set(suffix, null); + continue; + } throw error; } - try { - const label = `SQLite ${suffix || 'database'}`; - const stat = fs.fstatSync(descriptor); - assertOwner(stat, label); - if (!stat.isFile()) throw new Error(`${label} must be regular`); - if (preflight.existingSuffixes.has(suffix)) { - if ((stat.mode & 0o777) !== 0o600) { - throw new Error(`${label} must be owner-only`); - } - } else { - fs.fchmodSync(descriptor, 0o600); + heldFiles.set(suffix, Object.freeze({ descriptor, identity: null })); + const label = `SQLite ${suffix || 'database'}`; + let stat = fs.fstatSync(descriptor, { bigint: true }); + assertOwner(stat, label); + if (!stat.isFile()) throw new Error(`${label} must be regular`); + const currentIdentity = sqliteIdentityFor(stat); + const existedAtPreflight = preflight.existingFiles.has(suffix); + const preflightIdentity = preflight.existingFiles.get(suffix); + if (existedAtPreflight) { + if (!sameSqliteFile(preflightIdentity, currentIdentity)) { + throw new Error(`${label} identity changed after preflight`); } - } finally { - fs.closeSync(descriptor); + if (currentIdentity.mode !== 0o600) { + throw new Error(`${label} must be owner-only`); + } + } else { + fs.fchmodSync(descriptor, 0o600); } + stat = fs.fstatSync(descriptor, { bigint: true }); + assertOwnerOnlyRegular(stat, label); + heldFiles.set(suffix, Object.freeze({ + descriptor, + identity: sqliteIdentityFor(stat), + })); } guard.revalidate(); - } finally { - guard.close(); + + const revalidate = () => { + if (state !== 'open') throw new Error('SQLite lifetime proof is closing or closed'); + guard.revalidate(); + const statSibling = guard[STAT_SQLITE_SIBLING]; + if (typeof statSibling !== 'function') { + throw new Error('trusted parent does not expose SQLite namespace stat'); + } + for (const suffix of SQLITE_SUFFIXES) { + const held = heldFiles.get(suffix); + let namespaceStat; + try { + namespaceStat = statSibling(suffix); + } catch (error) { + if (error.code === 'ENOENT' && held === null) continue; + if (error.code === 'ENOENT') { + throw new Error(`SQLite ${suffix || 'database'} namespace identity changed`); + } + throw error; + } + if (held === null) { + throw new Error(`SQLite ${suffix || 'database'} namespace identity changed`); + } + const descriptorStat = fs.fstatSync(held.descriptor, { bigint: true }); + const label = `SQLite ${suffix || 'database'}`; + assertOwnerOnlyRegular(descriptorStat, label); + const descriptorIdentity = sqliteIdentityFor(descriptorStat); + const namespaceIdentity = sqliteIdentityFor(namespaceStat); + if (!namespaceStat.isFile() + || !sameSqliteIdentity(descriptorIdentity, held.identity) + || !sameSqliteIdentity(namespaceIdentity, held.identity)) { + throw new Error(`${label} namespace identity changed`); + } + } + guard.revalidate(); + }; + + state = 'open'; + revalidate(); + return Object.freeze({ + revalidate, + close: closeHeldFiles, + }); + } catch (error) { + const cleanup = Object.freeze({ close: closeHeldFiles }); + if (typeof onAcquisitionFailure === 'function') { + try { onAcquisitionFailure(cleanup); } catch {} + } else { + try { cleanup.close(); } catch {} + } + throw error; } - preflightSqliteFiles(databasePath, { pathTrust }); } function candidateError(label, error) { diff --git a/spikes/pi-wielder/src/kernel/sqlite-store.mjs b/spikes/pi-wielder/src/kernel/sqlite-store.mjs index eb2a518..a58e141 100644 --- a/spikes/pi-wielder/src/kernel/sqlite-store.mjs +++ b/spikes/pi-wielder/src/kernel/sqlite-store.mjs @@ -74,8 +74,10 @@ function rollbackWithoutMasking(db) { function closeWithoutMasking(db) { try { db?.close(); + return true; } catch { // Preserve the initialization error that caused cleanup. + return false; } } @@ -96,6 +98,9 @@ export function openKernelStore({ } let db; + let sqliteFiles; + let failedSqliteProof; + let initializationDatabaseClosed = false; try { db = new DatabaseSync(filePath, { timeout: 5_000, readBigInts: true }); db.exec('PRAGMA foreign_keys = ON; PRAGMA trusted_schema = OFF; PRAGMA synchronous = FULL;'); @@ -117,19 +122,41 @@ export function openKernelStore({ } } - if (!inMemory) secureNewSqliteSideFiles(filePath, existing, { pathTrust }); + if (!inMemory) { + sqliteFiles = secureNewSqliteSideFiles(filePath, existing, { + pathTrust, + onAcquisitionFailure: (proof) => { + failedSqliteProof = proof; + rollbackWithoutMasking(db); + initializationDatabaseClosed = closeWithoutMasking(db); + if (initializationDatabaseClosed) { + try { + proof.close(); + failedSqliteProof = undefined; + } catch {} + } + }, + }); + } } catch (error) { - rollbackWithoutMasking(db); - closeWithoutMasking(db); + if (!initializationDatabaseClosed) { + rollbackWithoutMasking(db); + initializationDatabaseClosed = closeWithoutMasking(db); + } + if (initializationDatabaseClosed) { + try { failedSqliteProof?.close(); } catch {} + try { sqliteFiles?.close(); } catch {} + } throw error; } const liveTransactions = new WeakSet(); let transactionOpen = false; + let databaseClosed = false; let closed = false; const assertOpen = () => { - if (closed) throw new Error('Wallet Kernel store is closed'); + if (databaseClosed || closed) throw new Error('Wallet Kernel store is closed'); }; const appendEvent = (event, txDb = db) => { @@ -183,7 +210,7 @@ export function openKernelStore({ liveTransactions.add(token); const value = operation(token); assertSafeTransactionReturn(value, token); - if (!inMemory) preflightSqliteFiles(filePath, { pathTrust }); + if (!inMemory) sqliteFiles.revalidate(); db.exec('COMMIT'); return value; } catch (error) { @@ -248,7 +275,13 @@ export function openKernelStore({ const close = () => { if (closed) return; if (transactionOpen) throw new Error('cannot close Wallet Kernel store during a transaction'); - db.close(); + if (!databaseClosed) { + db.close(); + databaseClosed = true; + } + // POSIX may drop SQLite's process-associated locks when any proof fd for + // the same inode closes, so the database always closes first. + sqliteFiles?.close(); closed = true; }; diff --git a/spikes/pi-wielder/src/kernel/trusted-path.mjs b/spikes/pi-wielder/src/kernel/trusted-path.mjs index 835fe37..332a753 100644 --- a/spikes/pi-wielder/src/kernel/trusted-path.mjs +++ b/spikes/pi-wielder/src/kernel/trusted-path.mjs @@ -19,6 +19,9 @@ const FILE_IDENTITY_FIELDS = Object.freeze([ const LIST_PRIVATE_NAMES = Symbol.for( 'skill-asset-protocol.wallet-kernel.trusted-parent.private-temp-list.v1', ); +const STAT_SQLITE_SIBLING = Symbol.for( + 'skill-asset-protocol.wallet-kernel.trusted-parent.sqlite-sibling-stat.v1', +); const DIRECTORY_FLAGS = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW; @@ -449,6 +452,19 @@ export function openTrustedParent({ assertSuffix(suffix); return openBounded(`${leafName}${suffix}`, flags, undefined, 'SQLite sibling open'); }; + const statSibling = (suffix) => { + assertOpen(); + assertSuffix(suffix); + revalidate(); + let stat; + try { + stat = fs.lstatSync(childLocation(`${leafName}${suffix}`), { bigint: true }); + } catch (error) { + throw wrapFileError(error, 'SQLite sibling stat'); + } + revalidate(); + return stat; + }; const openNamedLeaf = (name, flags, creationMode) => { assertPrivateName(name); return openBounded(name, flags, creationMode, 'private temporary open'); @@ -581,6 +597,12 @@ export function openTrustedParent({ value: listPrivateNames, writable: false, }); + Object.defineProperty(guard, STAT_SQLITE_SIBLING, { + configurable: false, + enumerable: false, + value: statSibling, + writable: false, + }); return Object.freeze(guard); } catch (error) { closed = true; diff --git a/spikes/pi-wielder/tests/fixtures/kernel-db-lock-contender.mjs b/spikes/pi-wielder/tests/fixtures/kernel-db-lock-contender.mjs new file mode 100644 index 0000000..c9b3adc --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/kernel-db-lock-contender.mjs @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; + +const [databasePath, readyFile, startFile, attemptFile] = process.argv.slice(2); +const database = new DatabaseSync(databasePath, { timeout: 5_000 }); +try { + database.exec('PRAGMA foreign_keys = ON; PRAGMA synchronous = FULL;'); + fs.writeFileSync(readyFile, 'ready', { mode: 0o600 }); + while (!fs.existsSync(startFile)) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + fs.writeFileSync(attemptFile, 'attempt', { mode: 0o600 }); + database.exec('BEGIN IMMEDIATE'); + const current = database.prepare( + 'SELECT value FROM metadata WHERE key = ?', + ).get('claim'); + let outcome = 'already_claimed'; + if (!current) { + database.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('claim', 'contender'); + outcome = 'claimed'; + } + database.exec('COMMIT'); + process.stdout.write(`${outcome}\n`); +} catch (error) { + if (database.isTransaction) database.exec('ROLLBACK'); + throw error; +} finally { + database.close(); +} diff --git a/spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs b/spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs index 48ea71a..e61b2c4 100644 --- a/spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs +++ b/spikes/pi-wielder/tests/fixtures/kernel-db-writer.mjs @@ -1,6 +1,8 @@ +import fs from 'node:fs'; + import { openKernelStore } from '../../src/kernel/sqlite-store.mjs'; -const [databasePath, trustedAncestor, claimId] = process.argv.slice(2); +const [databasePath, trustedAncestor, claimId, readyFile, releaseFile] = process.argv.slice(2); const pathTrust = Object.freeze({ mode: 'deterministic', trustedAncestor, @@ -9,6 +11,12 @@ const pathTrust = Object.freeze({ }); const store = openKernelStore({ filePath: databasePath, pathTrust }); try { + if (readyFile && releaseFile) { + fs.writeFileSync(readyFile, 'ready', { mode: 0o600 }); + while (!fs.existsSync(releaseFile)) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } const outcome = store.transaction((token) => store.within(token, ({ db, appendEvent }) => { const current = db.prepare('SELECT value FROM metadata WHERE key = ?').get('claim'); diff --git a/spikes/pi-wielder/tests/kernel-store.test.mjs b/spikes/pi-wielder/tests/kernel-store.test.mjs index 41b93ef..c89d4c1 100644 --- a/spikes/pi-wielder/tests/kernel-store.test.mjs +++ b/spikes/pi-wielder/tests/kernel-store.test.mjs @@ -192,6 +192,15 @@ async function waitForFiles(files, timeoutMilliseconds = 5_000) { } } +function waitForFileSync(file, timeoutMilliseconds = 5_000) { + const deadline = Date.now() + timeoutMilliseconds; + const signal = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(file)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path.basename(file)}`); + Atomics.wait(signal, 0, 0, 5); + } +} + test('exports the secure storage boundary', () => { for (const value of [ preparePrivateFile, @@ -559,10 +568,15 @@ test('only SQLite files absent at preflight may be tightened to 0600', (t) => { fs.writeFileSync(`${fixture.databasePath}-wal`, '', { mode: 0o644 }); fs.writeFileSync(`${fixture.databasePath}-shm`, '', { mode: 0o666 }); - secureNewSqliteSideFiles(fixture.databasePath, existing, { pathTrust: fixture.pathTrust }); + const proof = secureNewSqliteSideFiles( + fixture.databasePath, + existing, + { pathTrust: fixture.pathTrust }, + ); assert.equal(fs.statSync(fixture.databasePath).mode & 0o777, 0o600); assert.equal(fs.statSync(`${fixture.databasePath}-wal`).mode & 0o777, 0o600); assert.equal(fs.statSync(`${fixture.databasePath}-shm`).mode & 0o777, 0o600); + proof.close(); fs.chmodSync(fixture.databasePath, 0o644); assert.throws( @@ -572,6 +586,85 @@ test('only SQLite files absent at preflight may be tightened to 0600', (t) => { /owner-only/, ); assert.equal(fs.statSync(fixture.databasePath).mode & 0o777, 0o644); + + const replaced = authority(t, 'wallet-kernel-preflight-replaced-'); + fs.writeFileSync(replaced.databasePath, 'original', { mode: 0o600 }); + const replacementPreflight = preflightSqliteFiles(replaced.databasePath, { + pathTrust: replaced.pathTrust, + }); + fs.renameSync(replaced.databasePath, `${replaced.databasePath}.original`); + fs.writeFileSync(replaced.databasePath, 'replacement', { mode: 0o600 }); + assert.throws( + () => secureNewSqliteSideFiles(replaced.databasePath, replacementPreflight, { + pathTrust: replaced.pathTrust, + }), + /identity changed after preflight/, + ); + assert.equal(fs.readFileSync(replaced.databasePath, 'utf8'), 'replacement'); +}); + +test('SQLite lifetime proof revalidates held files and namespace identity until close', (t) => { + const fixture = authority(t, 'wallet-kernel-sqlite-proof-'); + const preflight = preflightSqliteFiles(fixture.databasePath, { + pathTrust: fixture.pathTrust, + }); + for (const suffix of ['', '-wal', '-shm']) { + fs.writeFileSync(`${fixture.databasePath}${suffix}`, '', { mode: 0o644 }); + } + + const proof = secureNewSqliteSideFiles( + fixture.databasePath, + preflight, + { pathTrust: fixture.pathTrust }, + ); + assert.equal(Object.isFrozen(proof), true); + assert.deepEqual(Object.keys(proof), ['revalidate', 'close']); + assert.doesNotThrow(() => proof.revalidate()); + for (const suffix of ['', '-wal', '-shm']) { + assert.equal(fs.statSync(`${fixture.databasePath}${suffix}`).mode & 0o777, 0o600); + } + + const wal = `${fixture.databasePath}-wal`; + fs.renameSync(wal, `${wal}.replaced`); + fs.writeFileSync(wal, '', { mode: 0o600 }); + assert.throws(() => proof.revalidate(), /namespace identity changed/); + proof.close(); + proof.close(); + assert.throws(() => proof.revalidate(), /closed/); +}); + +test('SQLite lifetime proof retries only target descriptors whose close failed', (t) => { + const fixture = authority(t, 'wallet-kernel-sqlite-proof-close-'); + for (const suffix of ['', '-wal', '-shm']) { + fs.writeFileSync(`${fixture.databasePath}${suffix}`, '', { mode: 0o600 }); + } + const preflight = preflightSqliteFiles(fixture.databasePath, { + pathTrust: fixture.pathTrust, + }); + const proof = secureNewSqliteSideFiles( + fixture.databasePath, + preflight, + { pathTrust: fixture.pathTrust }, + ); + const walIdentity = fs.statSync(`${fixture.databasePath}-wal`, { bigint: true }); + const originalClose = fs.closeSync; + let walCloseCalls = 0; + fs.closeSync = function failFirstWalClose(descriptor) { + const stat = fs.fstatSync(descriptor, { bigint: true }); + if (stat.dev === walIdentity.dev && stat.ino === walIdentity.ino) { + walCloseCalls += 1; + if (walCloseCalls === 1) throw new Error('injected SQLite proof close fault'); + } + return originalClose.call(fs, descriptor); + }; + try { + assert.throws(() => proof.close(), /injected SQLite proof close fault/); + assert.doesNotThrow(() => proof.close()); + } finally { + fs.closeSync = originalClose; + } + assert.equal(walCloseCalls, 2); + assert.throws(() => proof.revalidate(), /closed/); }); test('new SQLite symlinks and nonfiles fail closed instead of being repaired', (t) => { @@ -1350,6 +1443,50 @@ test('a newer schema and initialization faults close the database without maskin } }); +test('SQLite proof acquisition failure closes the database before held target descriptors', (t) => { + const fixture = authority(t, 'wallet-kernel-store-proof-failure-'); + const sentinel = new Error('injected SQLite proof acquisition fault'); + const originalFchmod = fs.fchmodSync; + const originalClose = fs.closeSync; + const originalDatabaseClose = DatabaseSync.prototype.close; + const closeOrder = []; + let injected = false; + + fs.fchmodSync = function injectProofFailure() { + if (!injected) { + injected = true; + throw sentinel; + } + return originalFchmod.apply(fs, arguments); + }; + fs.closeSync = function recordProofClose(descriptor) { + if (injected) { + try { + if (fs.fstatSync(descriptor).isFile()) closeOrder.push('proof'); + } catch {} + } + return originalClose.call(fs, descriptor); + }; + DatabaseSync.prototype.close = function recordDatabaseClose() { + if (injected) closeOrder.push('database'); + return originalDatabaseClose.call(this); + }; + try { + assert.throws( + () => openKernelStore({ + filePath: fixture.databasePath, + pathTrust: fixture.pathTrust, + }), + (error) => error === sentinel, + ); + } finally { + fs.fchmodSync = originalFchmod; + fs.closeSync = originalClose; + DatabaseSync.prototype.close = originalDatabaseClose; + } + assert.deepEqual(closeOrder.slice(0, 2), ['database', 'proof']); +}); + const enumCases = [ { name: 'spend_sessions.state', @@ -1676,6 +1813,11 @@ test('boolean and paired-index CHECK boundaries accept only their declared forms test('two processes serialize one conditional claim and one hash-chain event', async (t) => { const fixtureAuthority = authority(t, 'wallet-kernel-store-writers-'); + const coordination = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-writer-gate-')); + fs.chmodSync(coordination, 0o700); + t.after(() => fs.rmSync(coordination, { force: true, recursive: true })); + const releaseFile = path.join(coordination, 'release'); + const readyFiles = [path.join(coordination, 'ready-a'), path.join(coordination, 'ready-b')]; const initial = openKernelStore({ filePath: fixtureAuthority.databasePath, pathTrust: fixtureAuthority.pathTrust, @@ -1684,9 +1826,21 @@ test('two processes serialize one conditional claim and one hash-chain event', a const fixture = fileURLToPath(new URL('./fixtures/kernel-db-writer.mjs', import.meta.url)); const children = ['a', 'b'].map((claimId) => spawn( process.execPath, - [fixture, fixtureAuthority.databasePath, fixtureAuthority.directory, claimId], + [ + fixture, + fixtureAuthority.databasePath, + fixtureAuthority.directory, + claimId, + readyFiles[claimId === 'a' ? 0 : 1], + releaseFile, + ], { stdio: ['ignore', 'pipe', 'pipe'] }, )); + try { + await waitForFiles(readyFiles); + } finally { + fs.writeFileSync(releaseFile, 'release', { mode: 0o600 }); + } assert.deepEqual( (await Promise.all(children.map(childResult))).sort(), ['already_claimed', 'claimed'], @@ -1702,3 +1856,107 @@ test('two processes serialize one conditional claim and one hash-chain event', a ); reopened.close(); }); + +test('normal store close releases SQLite before held file-proof descriptors', (t) => { + const fixture = authority(t, 'wallet-kernel-store-close-order-'); + const store = openKernelStore({ filePath: fixture.databasePath, pathTrust: fixture.pathTrust }); + const targetIdentities = new Set(['', '-wal', '-shm'].map((suffix) => { + const stat = fs.statSync(`${fixture.databasePath}${suffix}`, { bigint: true }); + return `${stat.dev}:${stat.ino}`; + })); + const originalClose = fs.closeSync; + const originalDatabaseClose = DatabaseSync.prototype.close; + const closeOrder = []; + fs.closeSync = function recordProofClose(descriptor) { + try { + const stat = fs.fstatSync(descriptor, { bigint: true }); + if (targetIdentities.has(`${stat.dev}:${stat.ino}`)) closeOrder.push('proof'); + } catch {} + return originalClose.call(fs, descriptor); + }; + DatabaseSync.prototype.close = function recordDatabaseClose() { + closeOrder.push('database'); + return originalDatabaseClose.call(this); + }; + try { + store.close(); + } finally { + fs.closeSync = originalClose; + DatabaseSync.prototype.close = originalDatabaseClose; + } + assert.equal(closeOrder[0], 'database'); + assert.equal(closeOrder.filter((entry) => entry === 'proof').length, 3); +}); + +test('pre-commit file proof never closes SQLite lock-bearing descriptors', async (t) => { + const fixtureAuthority = authority(t, 'wallet-kernel-store-lock-proof-'); + const coordination = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-lock-proof-gate-')); + fs.chmodSync(coordination, 0o700); + t.after(() => fs.rmSync(coordination, { force: true, recursive: true })); + const readyFile = path.join(coordination, 'ready'); + const startFile = path.join(coordination, 'start'); + const attemptFile = path.join(coordination, 'attempt'); + const store = openKernelStore({ + filePath: fixtureAuthority.databasePath, + pathTrust: fixtureAuthority.pathTrust, + }); + const contenderFixture = fileURLToPath( + new URL('./fixtures/kernel-db-lock-contender.mjs', import.meta.url), + ); + const contender = spawn(process.execPath, [ + contenderFixture, + fixtureAuthority.databasePath, + readyFile, + startFile, + attemptFile, + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + await waitForFiles([readyFile]); + + const sharedMemoryIdentity = fs.statSync( + `${fixtureAuthority.databasePath}-shm`, + { bigint: true }, + ); + const originalClose = fs.closeSync; + let lockBearingCloseCalls = 0; + fs.closeSync = function pauseAfterLockBearingClose(descriptor) { + let isSharedMemory = false; + try { + const stat = fs.fstatSync(descriptor, { bigint: true }); + isSharedMemory = stat.dev === sharedMemoryIdentity.dev + && stat.ino === sharedMemoryIdentity.ino; + } catch {} + const result = originalClose.call(fs, descriptor); + if (isSharedMemory) { + lockBearingCloseCalls += 1; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250); + } + return result; + }; + + let transactionError; + try { + store.transaction((token) => store.within(token, ({ db, appendEvent }) => { + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('claim', 'owner'); + appendEvent({ + entityType: 'test', + entityId: 'owner', + eventType: 'test.claimed', + data: { claimId: 'owner' }, + }); + fs.writeFileSync(startFile, 'start', { mode: 0o600 }); + waitForFileSync(attemptFile); + })); + } catch (error) { + transactionError = error; + } finally { + fs.closeSync = originalClose; + } + + const contenderOutcome = await childResult(contender); + assert.ifError(transactionError); + assert.equal(lockBearingCloseCalls, 0); + assert.equal(contenderOutcome, 'already_claimed'); + assert.equal(store.verifyEventChain(), true); + assert.equal(store.getMetadata('claim'), 'owner'); + store.close(); +}); From e1dfb31d269cdfe3a91ddae6354e871c9d72e851 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 04:17:49 -0400 Subject: [PATCH 161/165] fix: close wallet kernel authority escapes --- spikes/pi-wielder/src/kernel/canonical.mjs | 7 +- .../pi-wielder/src/kernel/secure-storage.mjs | 11 +- spikes/pi-wielder/src/kernel/sqlite-store.mjs | 99 +++++- spikes/pi-wielder/src/kernel/trusted-path.mjs | 45 ++- .../tests/kernel-canonical.test.mjs | 29 ++ spikes/pi-wielder/tests/kernel-store.test.mjs | 301 +++++++++++++++++- .../tests/kernel-trusted-path.test.mjs | 41 +++ 7 files changed, 514 insertions(+), 19 deletions(-) diff --git a/spikes/pi-wielder/src/kernel/canonical.mjs b/spikes/pi-wielder/src/kernel/canonical.mjs index bc03caa..3c30d27 100644 --- a/spikes/pi-wielder/src/kernel/canonical.mjs +++ b/spikes/pi-wielder/src/kernel/canonical.mjs @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import { types as utilTypes } from 'node:util'; export class KernelError extends Error { constructor(code, message, options) { @@ -23,6 +24,7 @@ function cloneDataGraph(value, code, label, seen = new Map()) { if (!value || typeof value !== 'object') { return throwDataGraphError(code, label); } + if (utilTypes.isProxy(value)) return throwDataGraphError(code, label); if (seen.has(value)) return seen.get(value); if (Array.isArray(value)) { @@ -82,7 +84,7 @@ function cloneDataGraph(value, code, label, seen = new Map()) { } export function exactRecord(value, required, optional = [], code = 'SCHEMA', label = 'value') { - if (!value || typeof value !== 'object' || Array.isArray(value) + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { throw new KernelError(code, `${label} must be one plain object`); } @@ -118,6 +120,9 @@ function canonicalSerialize(value, ancestors) { if (!value || typeof value !== 'object') { return throwCanonicalTypeError('value is not canonical JSON data'); } + if (utilTypes.isProxy(value)) { + return throwCanonicalTypeError('value is not canonical JSON data'); + } if (ancestors.has(value)) { return throwCanonicalTypeError('canonical JSON data must not contain cycles'); } diff --git a/spikes/pi-wielder/src/kernel/secure-storage.mjs b/spikes/pi-wielder/src/kernel/secure-storage.mjs index ff1d76e..10ba2cc 100644 --- a/spikes/pi-wielder/src/kernel/secure-storage.mjs +++ b/spikes/pi-wielder/src/kernel/secure-storage.mjs @@ -14,6 +14,9 @@ const PRIVATE_TEMP_LIST = Symbol.for( const STAT_SQLITE_SIBLING = Symbol.for( 'skill-asset-protocol.wallet-kernel.trusted-parent.sqlite-sibling-stat.v1', ); +const OPEN_SQLITE_SIBLING_HELD = Symbol.for( + 'skill-asset-protocol.wallet-kernel.trusted-parent.sqlite-sibling-held-open.v1', +); const SQLITE_SUFFIXES = Object.freeze(['', '-wal', '-shm']); const PATH_TRUST_FIELDS = Object.freeze([ 'mode', @@ -377,10 +380,14 @@ export function secureNewSqliteSideFiles(databasePath, existing, { if (preflight.ancestorMetadataHash !== guard.ancestorMetadataHash) { throw new Error('SQLite preflight capability belongs to a different trusted parent'); } + const openSqliteSiblingHeld = guard[OPEN_SQLITE_SIBLING_HELD]; + if (typeof openSqliteSiblingHeld !== 'function') { + throw new Error('trusted parent does not expose held-open SQLite acquisition'); + } for (const suffix of SQLITE_SUFFIXES) { let descriptor; try { - descriptor = guard.openSibling(suffix, fs.constants.O_RDONLY | NOFOLLOW); + descriptor = openSqliteSiblingHeld(suffix, fs.constants.O_RDONLY); } catch (error) { if (error.code === 'ENOENT') { heldFiles.set(suffix, null); @@ -397,7 +404,7 @@ export function secureNewSqliteSideFiles(databasePath, existing, { const existedAtPreflight = preflight.existingFiles.has(suffix); const preflightIdentity = preflight.existingFiles.get(suffix); if (existedAtPreflight) { - if (!sameSqliteFile(preflightIdentity, currentIdentity)) { + if (!sameSqliteIdentity(preflightIdentity, currentIdentity)) { throw new Error(`${label} identity changed after preflight`); } if (currentIdentity.mode !== 0o600) { diff --git a/spikes/pi-wielder/src/kernel/sqlite-store.mjs b/spikes/pi-wielder/src/kernel/sqlite-store.mjs index a58e141..23169d7 100644 --- a/spikes/pi-wielder/src/kernel/sqlite-store.mjs +++ b/spikes/pi-wielder/src/kernel/sqlite-store.mjs @@ -1,6 +1,14 @@ import { DatabaseSync } from 'node:sqlite'; +import { types as utilTypes } from 'node:util'; -import { canonicalJson, exactRecord, sha256 } from './canonical.mjs'; +import { + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + KernelError, + sha256, +} from './canonical.mjs'; import { preflightSqliteFiles, preparePrivateFile, @@ -15,19 +23,33 @@ const EXPOSED_PRAGMAS = Object.freeze([ 'user_version', ]); function assertSynchronousOperation(operation) { - if (Object.getPrototypeOf(operation) !== Function.prototype) { + if (utilTypes.isProxy(operation) + || Object.getPrototypeOf(operation) !== Function.prototype) { throw new Error( 'authority transactions must be synchronous; only ordinary synchronous functions are accepted', ); } } +function assertScopedSql(sql) { + if (typeof sql !== 'string' + || sql.includes(';') + || !/^\s*(?:SELECT|INSERT|UPDATE|DELETE)\b/i.test(sql)) { + throw new Error( + 'only one SELECT, INSERT, UPDATE, or DELETE statement is exposed inside a transaction', + ); + } +} + function hasThenBoundary(value) { if ((typeof value !== 'object' || value === null) && typeof value !== 'function') { return false; } let cursor = value; while (cursor !== null) { + if (utilTypes.isProxy(cursor)) { + throw new Error('authority transaction return values must not contain a proxy'); + } const descriptor = Object.getOwnPropertyDescriptor(cursor, 'then'); if (descriptor) { return !Object.hasOwn(descriptor, 'value') || typeof descriptor.value === 'function'; @@ -41,6 +63,11 @@ function assertSafeTransactionReturn(value, token, seen = new Set()) { if (value === token) { throw new Error('authority transaction token must not cross a return boundary'); } + if (value !== null + && (typeof value === 'object' || typeof value === 'function') + && utilTypes.isProxy(value)) { + throw new Error('authority transaction return values must not contain a proxy'); + } if (hasThenBoundary(value)) throw new Error('authority transactions must be synchronous'); if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return; if (typeof value === 'function') { @@ -159,19 +186,68 @@ export function openKernelStore({ if (databaseClosed || closed) throw new Error('Wallet Kernel store is closed'); }; + const assertLiveTransaction = (token) => { + if (!liveTransactions.has(token)) throw new Error('invalid authority transaction'); + assertOpen(); + }; + + const frozenCapability = (fields) => { + const capability = Object.create(null); + for (const [name, value] of fields) { + Object.defineProperty(capability, name, { + configurable: false, + enumerable: true, + value: Object.freeze(value), + writable: false, + }); + } + return Object.freeze(capability); + }; + + const statementCapability = (token, statement) => frozenCapability([ + ['run', (...parameters) => { + assertLiveTransaction(token); + return statement.run(...parameters); + }], + ['get', (...parameters) => { + assertLiveTransaction(token); + return statement.get(...parameters); + }], + ['all', (...parameters) => { + assertLiveTransaction(token); + return statement.all(...parameters); + }], + ]); + + const databaseCapability = (token) => frozenCapability([ + ['prepare', (sql) => { + assertLiveTransaction(token); + assertScopedSql(sql); + return statementCapability(token, db.prepare(sql)); + }], + ]); + const appendEvent = (event, txDb = db) => { - const { entityType, entityId, eventType, data } = exactRecord( + const normalized = exactRecord( event, ['entityType', 'entityId', 'eventType', 'data'], [], 'EVENT_SCHEMA', 'event', ); + const entityType = canonicalToken(normalized.entityType, 'event entity type'); + const entityId = canonicalToken(normalized.entityId, 'event entity ID'); + const eventType = canonicalToken(normalized.eventType, 'event type'); + const { data } = normalized; + if (!data || typeof data !== 'object' || Array.isArray(data) + || Object.getPrototypeOf(data) !== Object.prototype) { + throw new KernelError('EVENT_SCHEMA', 'event data must be one plain object'); + } + const createdAt = canonicalTimestamp(now(), 'event createdAt'); + const dataJson = canonicalJson(data); const previous = txDb.prepare( 'SELECT event_hash FROM events ORDER BY sequence DESC LIMIT 1', ).get(); - const createdAt = now(); - const dataJson = canonicalJson(data); const previousHash = previous?.event_hash ?? null; const eventHash = sha256(canonicalJson({ entityType, @@ -189,11 +265,18 @@ export function openKernelStore({ }; const within = (token, operation) => { - assertOpen(); - if (!liveTransactions.has(token)) throw new Error('invalid authority transaction'); + assertLiveTransaction(token); if (typeof operation !== 'function') throw new TypeError('transaction operation must be a function'); assertSynchronousOperation(operation); - const value = operation({ db, appendEvent: (event) => appendEvent(event, db) }); + const scopedDatabase = databaseCapability(token); + const scope = frozenCapability([ + ['db', scopedDatabase], + ['appendEvent', (event) => { + assertLiveTransaction(token); + return appendEvent(event, db); + }], + ]); + const value = operation(scope); assertSafeTransactionReturn(value, token); return value; }; diff --git a/spikes/pi-wielder/src/kernel/trusted-path.mjs b/spikes/pi-wielder/src/kernel/trusted-path.mjs index 332a753..f5e0080 100644 --- a/spikes/pi-wielder/src/kernel/trusted-path.mjs +++ b/spikes/pi-wielder/src/kernel/trusted-path.mjs @@ -22,6 +22,9 @@ const LIST_PRIVATE_NAMES = Symbol.for( const STAT_SQLITE_SIBLING = Symbol.for( 'skill-asset-protocol.wallet-kernel.trusted-parent.sqlite-sibling-stat.v1', ); +const OPEN_SQLITE_SIBLING_HELD = Symbol.for( + 'skill-asset-protocol.wallet-kernel.trusted-parent.sqlite-sibling-held-open.v1', +); const DIRECTORY_FLAGS = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW; @@ -233,11 +236,11 @@ function closeDescriptors(descriptors) { for (let index = descriptors.length - 1; index >= 0; index -= 1) { try { fs.closeSync(descriptors[index]); + descriptors.splice(index, 1); } catch (error) { firstError ??= error; } } - descriptors.length = 0; if (firstError) throw firstError; } @@ -312,7 +315,7 @@ export function openTrustedParent({ } const descriptors = []; - let closed = false; + let state = 'open'; try { descriptors.push(openDirectory(trustedAncestor, 'trusted ancestor')); for (let index = 0; index < descendants.length; index += 1) { @@ -340,7 +343,7 @@ export function openTrustedParent({ const parentDescriptor = descriptors.at(-1); const assertOpen = () => { - if (closed) fail('trusted parent guard is closed'); + if (state !== 'open') fail('trusted parent guard is closing or closed'); }; const verifyProjection = (actual, expected) => { @@ -403,7 +406,7 @@ export function openTrustedParent({ try { fs.closeSync(descriptor); } catch {} } if (error?.message === 'trusted path descriptor or namespace metadata changed' - || error?.message === 'trusted parent guard is closed') { + || error?.message === 'trusted parent guard is closing or closed') { throw error; } throw wrapFileError(error, label); @@ -452,6 +455,26 @@ export function openTrustedParent({ assertSuffix(suffix); return openBounded(`${leafName}${suffix}`, flags, undefined, 'SQLite sibling open'); }; + const openSqliteSiblingHeld = (suffix, flags) => { + assertOpen(); + assertSuffix(suffix); + if (flags !== fs.constants.O_RDONLY) { + fail('SQLite held-open flags must be read-only'); + } + revalidate(); + try { + // Ownership transfers to the SQLite lifetime proof as soon as openSync + // returns. No fallible validation or cleanup may run in this layer + // after acquisition because closing this descriptor could release + // process-associated SQLite locks before DatabaseSync closes. + return fs.openSync( + childLocation(`${leafName}${suffix}`), + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); + } catch (error) { + throw wrapFileError(error, 'SQLite held-open sibling'); + } + }; const statSibling = (suffix) => { assertOpen(); assertSuffix(suffix); @@ -572,9 +595,10 @@ export function openTrustedParent({ }; const close = () => { - if (closed) return; - closed = true; + if (state === 'closed') return; + state = 'closing'; closeDescriptors(descriptors); + state = 'closed'; }; revalidate(); @@ -603,12 +627,19 @@ export function openTrustedParent({ value: statSibling, writable: false, }); + Object.defineProperty(guard, OPEN_SQLITE_SIBLING_HELD, { + configurable: false, + enumerable: false, + value: openSqliteSiblingHeld, + writable: false, + }); return Object.freeze(guard); } catch (error) { - closed = true; + state = 'closing'; try { closeDescriptors(descriptors); } catch {} + if (descriptors.length === 0) state = 'closed'; throw error; } } diff --git a/spikes/pi-wielder/tests/kernel-canonical.test.mjs b/spikes/pi-wielder/tests/kernel-canonical.test.mjs index 0298f6a..104a7d5 100644 --- a/spikes/pi-wielder/tests/kernel-canonical.test.mjs +++ b/spikes/pi-wielder/tests/kernel-canonical.test.mjs @@ -102,6 +102,35 @@ test('closed records reject unsafe nested data without invoking accessors', () = } }); +test('canonical data boundaries reject proxies before invoking their traps', () => { + let trapCalls = 0; + const target = { value: 1 }; + const proxy = new Proxy(target, { + get() { + trapCalls += 1; + return Reflect.get(...arguments); + }, + getOwnPropertyDescriptor() { + trapCalls += 1; + return Reflect.getOwnPropertyDescriptor(...arguments); + }, + getPrototypeOf() { + trapCalls += 1; + return Reflect.getPrototypeOf(...arguments); + }, + ownKeys() { + trapCalls += 1; + return Reflect.ownKeys(...arguments); + }, + }); + + assertKernelError(() => exactRecord(proxy, ['value'], [], 'SHAPE', 'record'), 'SHAPE'); + assertKernelError(() => exactRecord({ value: proxy }, ['value'], [], 'SHAPE', 'record'), 'SHAPE'); + assertKernelError(() => canonicalJson(proxy), 'CANONICAL_TYPE'); + assertKernelError(() => frozenCopy(proxy), 'CANONICAL_TYPE'); + assert.equal(trapCalls, 0); +}); + test('sha256 hashes strings and bytes with an explicit lowercase prefix', () => { assert.equal( sha256('abc'), diff --git a/spikes/pi-wielder/tests/kernel-store.test.mjs b/spikes/pi-wielder/tests/kernel-store.test.mjs index c89d4c1..fd63b15 100644 --- a/spikes/pi-wielder/tests/kernel-store.test.mjs +++ b/spikes/pi-wielder/tests/kernel-store.test.mjs @@ -583,7 +583,7 @@ test('only SQLite files absent at preflight may be tightened to 0600', (t) => { () => secureNewSqliteSideFiles(fixture.databasePath, secondPreflight, { pathTrust: fixture.pathTrust, }), - /owner-only/, + /owner-only|identity changed/, ); assert.equal(fs.statSync(fixture.databasePath).mode & 0o777, 0o644); @@ -603,6 +603,55 @@ test('only SQLite files absent at preflight may be tightened to 0600', (t) => { assert.equal(fs.readFileSync(replaced.databasePath, 'utf8'), 'replacement'); }); +test('SQLite repair compares the full stable preflight identity', (t) => { + const fixture = authority(t, 'wallet-kernel-preflight-metadata-'); + fs.writeFileSync(fixture.databasePath, '', { mode: 0o600 }); + const databaseIdentity = fs.statSync(fixture.databasePath, { bigint: true }); + const preflight = preflightSqliteFiles(fixture.databasePath, { + pathTrust: fixture.pathTrust, + }); + const originalFstat = fs.fstatSync; + const originalLstat = fs.lstatSync; + let proof; + const withDifferentGroup = (stat) => new Proxy(stat, { + get(target, property, receiver) { + if (property === 'gid') { + return typeof target.gid === 'bigint' ? target.gid + 1n : target.gid + 1; + } + return Reflect.get(target, property, receiver); + }, + }); + + fs.fstatSync = function injectDifferentGroup(descriptor, options) { + const stat = originalFstat.call(fs, descriptor, options); + if (stat.isFile() + && BigInt(stat.dev) === databaseIdentity.dev + && BigInt(stat.ino) === databaseIdentity.ino) { + return withDifferentGroup(stat); + } + return stat; + }; + fs.lstatSync = function injectNamespaceGroup(location, options) { + const stat = originalLstat.call(fs, location, options); + if (location === fixture.databasePath) return withDifferentGroup(stat); + return stat; + }; + try { + assert.throws( + () => { + proof = secureNewSqliteSideFiles(fixture.databasePath, preflight, { + pathTrust: fixture.pathTrust, + }); + }, + /identity changed after preflight/, + ); + } finally { + fs.fstatSync = originalFstat; + fs.lstatSync = originalLstat; + proof?.close(); + } +}); + test('SQLite lifetime proof revalidates held files and namespace identity until close', (t) => { const fixture = authority(t, 'wallet-kernel-sqlite-proof-'); const preflight = preflightSqliteFiles(fixture.databasePath, { @@ -1258,6 +1307,53 @@ test('domain mutation and event append commit or roll back together', () => { } }); +test('event envelopes normalize canonical text before hash and insert', () => { + let clock = '2026-08-01T00:00:00.000Z'; + const store = openKernelStore({ + filePath: ':memory:', + allowMemory: true, + now: () => clock, + }); + const baseEvent = { + entityType: 'test', + entityId: 'one', + eventType: 'test.created', + data: { value: 1 }, + }; + let attempt = 0; + const mutation = (event) => store.mutate(event, ({ db }) => db.prepare( + 'INSERT INTO metadata(key, value) VALUES (?, ?)', + ).run(`invalid-${attempt += 1}`, 'no')); + + try { + for (const field of ['entityType', 'entityId', 'eventType']) { + for (const value of [1, true]) { + assert.throws( + () => mutation({ ...baseEvent, [field]: value }), + (error) => error?.code === 'TOKEN_FORMAT', + ); + } + } + for (const data of [null, 1, true, [], 'value']) { + assert.throws( + () => mutation({ ...baseEvent, data }), + (error) => error?.code === 'EVENT_SCHEMA', + ); + } + for (const value of [1, true, '2026-08-01T00:00:00Z', 'not-a-time']) { + clock = value; + assert.throws( + () => mutation(baseEvent), + (error) => error?.code === 'TIMESTAMP_FORMAT', + ); + } + assert.equal(store.readOne('SELECT COUNT(*) AS count FROM metadata').count, 0n); + assert.equal(store.events().length, 0); + } finally { + store.close(); + } +}); + test('transaction tokens are live, synchronous, unforgeable, and non-nestable', () => { const store = memoryStore(); let stale; @@ -1330,6 +1426,158 @@ test('transaction tokens are live, synchronous, unforgeable, and non-nestable', } }); +test('transaction capabilities are frozen, narrow, and revoked at every stale call site', async () => { + const store = memoryStore(); + let leakedDatabase; + let leakedStatement; + let leakedRun; + let leakedAppender; + let microtaskError; + let timerError; + + try { + await new Promise((resolve) => { + store.transaction((token) => store.within(token, (scope) => { + assert.equal(Object.getPrototypeOf(scope), null); + assert.equal(Object.isFrozen(scope), true); + assert.deepEqual(Object.keys(scope), ['db', 'appendEvent']); + assert.equal(Object.getPrototypeOf(scope.db), null); + assert.equal(Object.isFrozen(scope.db), true); + assert.deepEqual(Object.keys(scope.db), ['prepare']); + assert.equal(scope.db.exec, undefined); + assert.equal(scope.db.close, undefined); + assert.equal(scope.db.constructor, undefined); + + leakedDatabase = scope.db; + leakedStatement = scope.db.prepare( + 'INSERT INTO metadata(key, value) VALUES (?, ?)', + ); + assert.equal(Object.getPrototypeOf(leakedStatement), null); + assert.equal(Object.isFrozen(leakedStatement), true); + assert.deepEqual(Object.keys(leakedStatement), ['run', 'get', 'all']); + leakedRun = leakedStatement.run; + leakedAppender = scope.appendEvent; + + queueMicrotask(() => { + try { + leakedDatabase.prepare('SELECT 1'); + } catch (error) { + microtaskError = error; + } + }); + setTimeout(() => { + try { + leakedStatement.run('timer', 'no'); + } catch (error) { + timerError = error; + } + resolve(); + }, 0); + })); + }); + + for (const action of [ + () => leakedDatabase.prepare('SELECT 1'), + () => leakedStatement.run('statement', 'no'), + () => leakedRun('extracted', 'no'), + () => leakedAppender({ + entityType: 'test', + entityId: 'stale', + eventType: 'test.stale', + data: {}, + }), + ]) { + assert.throws(action, /invalid authority transaction/); + } + assert.match(microtaskError?.message ?? '', /invalid authority transaction/); + assert.match(timerError?.message ?? '', /invalid authority transaction/); + assert.equal(store.readOne('SELECT COUNT(*) AS count FROM metadata').count, 0n); + assert.equal(store.events().length, 0); + + let rolledBackStatement; + assert.throws(() => store.transaction((token) => store.within(token, ({ db }) => { + rolledBackStatement = db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)'); + throw new Error('rollback capability'); + })), /rollback capability/); + assert.throws( + () => rolledBackStatement.run('rollback', 'no'), + /invalid authority transaction/, + ); + } finally { + store.close(); + } +}); + +test('scoped SQL cannot take over transaction or database configuration authority', () => { + const store = memoryStore(); + const forbidden = [ + 'BEGIN IMMEDIATE', + 'COMMIT', + 'END', + 'ROLLBACK', + 'SAVEPOINT nested', + 'RELEASE nested', + 'PRAGMA foreign_keys = OFF', + "ATTACH DATABASE ':memory:' AS extra", + 'DETACH DATABASE extra', + 'VACUUM', + '-- hidden transaction control\nCOMMIT', + ]; + + try { + for (const [index, sql] of forbidden.entries()) { + assert.throws( + () => store.transaction((token) => store.within(token, ({ db }) => { + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)') + .run(`forbidden-${index}`, 'no'); + db.prepare(sql).run(); + throw new Error('manual transaction control escaped'); + })), + /only one.*SELECT.*INSERT.*UPDATE.*DELETE/i, + ); + assert.equal(store.getMetadata(`forbidden-${index}`), null); + } + assert.equal(store.events().length, 0); + } finally { + store.close(); + } +}); + +test('transaction return validation rejects proxies before invoking their traps', () => { + const store = memoryStore(); + let trapCalls = 0; + const handler = { + get(target, property, receiver) { + trapCalls += 1; + return Reflect.get(target, property, receiver); + }, + getOwnPropertyDescriptor(target, property) { + trapCalls += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + getPrototypeOf(target) { + trapCalls += 1; + return Reflect.getPrototypeOf(target); + }, + ownKeys(target) { + trapCalls += 1; + return Reflect.ownKeys(target); + }, + }; + const hidden = new Proxy(Object.create(null), handler); + + try { + assert.throws(() => store.transaction((token) => store.within(token, ({ db }) => { + db.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)').run('proxy', 'no'); + return { nested: [hidden] }; + })), /proxy|inert/i); + assert.equal(trapCalls, 0); + assert.equal(store.getMetadata('proxy'), null); + } finally { + store.close(); + } +}); + test('read boundary is one parameterized SELECT and persistent writes require a live token', (t) => { const fixture = authority(t, 'wallet-kernel-store-reads-'); const store = openKernelStore({ filePath: fixture.databasePath, pathTrust: fixture.pathTrust }); @@ -1487,6 +1735,57 @@ test('SQLite proof acquisition failure closes the database before held target de assert.deepEqual(closeOrder.slice(0, 2), ['database', 'proof']); }); +test('SQLite target ownership transfers before fallible post-open path checks', (t) => { + const fixture = authority(t, 'wallet-kernel-store-proof-transfer-'); + const originalExec = DatabaseSync.prototype.exec; + const originalDatabaseClose = DatabaseSync.prototype.close; + const originalOpen = fs.openSync; + const originalClose = fs.closeSync; + const closeOrder = []; + let armed = false; + let targetDescriptor; + + DatabaseSync.prototype.exec = function armAfterWal(sql) { + const value = originalExec.call(this, sql); + if (sql === 'PRAGMA journal_mode = WAL;') armed = true; + return value; + }; + fs.openSync = function driftAfterTargetOpen(location) { + const descriptor = originalOpen.apply(fs, arguments); + if (armed && location === fixture.databasePath && targetDescriptor === undefined) { + targetDescriptor = descriptor; + fs.chmodSync(fixture.directory, 0o755); + } + return descriptor; + }; + fs.closeSync = function recordTargetClose(descriptor) { + if (descriptor === targetDescriptor) closeOrder.push('target'); + return originalClose.call(fs, descriptor); + }; + DatabaseSync.prototype.close = function recordDatabaseClose() { + if (armed) closeOrder.push('database'); + return originalDatabaseClose.call(this); + }; + + try { + assert.throws( + () => openKernelStore({ + filePath: fixture.databasePath, + pathTrust: fixture.pathTrust, + }), + /changed/, + ); + } finally { + DatabaseSync.prototype.exec = originalExec; + DatabaseSync.prototype.close = originalDatabaseClose; + fs.openSync = originalOpen; + fs.closeSync = originalClose; + fs.chmodSync(fixture.directory, 0o700); + } + + assert.deepEqual(closeOrder.slice(0, 2), ['database', 'target']); +}); + const enumCases = [ { name: 'spend_sessions.state', diff --git a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs index b25b836..e3c4507 100644 --- a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs +++ b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs @@ -308,6 +308,47 @@ test('every operation rejects use after close while close remains idempotent', ( assert.doesNotThrow(() => guard.close()); }); +test('trusted parent close retains a failed descriptor for one bounded retry', (t) => { + const fixture = makeFixture(t); + fs.writeFileSync(fixture.targetFile, 'database', { mode: 0o600 }); + const terminalIdentity = fs.statSync(fixture.terminal, { bigint: true }); + const guard = openTrustedParent(deterministicOptions(fixture)); + const originalClose = fs.closeSync; + let terminalDescriptor; + let terminalCloseCalls = 0; + + fs.closeSync = function failFirstTerminalClose(descriptor) { + const stat = fs.fstatSync(descriptor, { bigint: true }); + if (stat.isDirectory() + && stat.dev === terminalIdentity.dev + && stat.ino === terminalIdentity.ino) { + terminalDescriptor = descriptor; + terminalCloseCalls += 1; + if (terminalCloseCalls === 1) { + throw new Error('injected trusted parent close fault'); + } + } + return originalClose.call(fs, descriptor); + }; + + try { + assert.throws(() => guard.close(), /injected trusted parent close fault/); + assert.throws(() => guard.revalidate(), /closing|closed/); + assert.doesNotThrow(() => guard.close()); + assert.doesNotThrow(() => guard.close()); + } finally { + fs.closeSync = originalClose; + if (terminalDescriptor !== undefined) { + try { + fs.fstatSync(terminalDescriptor); + originalClose.call(fs, terminalDescriptor); + } catch {} + } + } + + assert.equal(terminalCloseCalls, 2); +}); + test('mode, role, UID, path, and canonical-component inputs fail closed', (t) => { const fixture = makeFixture(t); const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-outside-')); From 76023838a7d4e58d6215e59138e04394fb79764d Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 05:07:18 -0400 Subject: [PATCH 162/165] feat: add versioned agent spend policy --- .../policies/base-sepolia.example.json | 33 + .../pi-wielder/src/kernel/policy-engine.mjs | 910 +++++++++++++ .../src/kernel/policy-repository.mjs | 534 ++++++++ .../pi-wielder/tests/kernel-policy.test.mjs | 1145 +++++++++++++++++ 4 files changed, 2622 insertions(+) create mode 100644 spikes/pi-wielder/policies/base-sepolia.example.json create mode 100644 spikes/pi-wielder/src/kernel/policy-engine.mjs create mode 100644 spikes/pi-wielder/src/kernel/policy-repository.mjs create mode 100644 spikes/pi-wielder/tests/kernel-policy.test.mjs diff --git a/spikes/pi-wielder/policies/base-sepolia.example.json b/spikes/pi-wielder/policies/base-sepolia.example.json new file mode 100644 index 0000000..e83423b --- /dev/null +++ b/spikes/pi-wielder/policies/base-sepolia.example.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "network": "eip155:84532", + "asset": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + "wallet": "0x1000000000000000000000000000000000000000", + "methods": [ + "GET", + "POST" + ], + "sellers": [ + { + "origin": "https://seller.example", + "pathPrefixes": [ + "/paid/" + ], + "payTo": "0x2000000000000000000000000000000000000000", + "evidencePath": "/.well-known/wallet-kernel/evidence", + "executionSigner": "0x2000000000000000000000000000000000000000", + "refundSigner": "0x2000000000000000000000000000000000000000", + "refundSource": "0x3000000000000000000000000000000000000000", + "perRequestMaxAtomic": "500000", + "autoApproveAtomic": "100000", + "humanApproveAtomic": "500000", + "sellerSessionMaxAtomic": "1000000" + } + ], + "sessionMaxAtomic": "2000000", + "rolling24hMaxAtomic": "5000000", + "challengeMaxAgeMs": 60000, + "approvalTtlMs": 300000, + "maxPendingApprovals": 20, + "defaultAction": "deny" +} diff --git a/spikes/pi-wielder/src/kernel/policy-engine.mjs b/spikes/pi-wielder/src/kernel/policy-engine.mjs new file mode 100644 index 0000000..1c7f9a1 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/policy-engine.mjs @@ -0,0 +1,910 @@ +import { + canonicalAtomic, + canonicalJson, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; + +const BASE_SEPOLIA = 'eip155:84532'; +const BASE_SEPOLIA_USDC = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const DECISIONS = new Set(['allow', 'approval_required', 'deny']); +const PRESELECTION_DENIALS = new Set([ + 'X402_VERSION', + 'SCHEME_UNSUPPORTED', + 'NETWORK_MISMATCH', + 'ASSET_MISMATCH', + 'WALLET_MISMATCH', + 'METHOD_UNSUPPORTED', + 'SELLER_UNTRUSTED', + 'RESOURCE_PATH', + 'PAYEE_MISMATCH', + 'PAYMENT_OPTIONS_AMBIGUOUS', +]); +const SELECTED_DENIALS = new Set([ + 'CHALLENGE_EXPIRED', + 'PER_REQUEST_LIMIT', + 'SELLER_SESSION_LIMIT', + 'SESSION_LIMIT', + 'ROLLING_24H_LIMIT', + 'APPROVAL_CAPACITY', +]); +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; +const CANONICAL_ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i; +const POLICY_FIELDS = Object.freeze([ + 'schemaVersion', + 'network', + 'asset', + 'wallet', + 'methods', + 'sellers', + 'sessionMaxAtomic', + 'rolling24hMaxAtomic', + 'challengeMaxAgeMs', + 'approvalTtlMs', + 'maxPendingApprovals', + 'defaultAction', +]); +const SELLER_FIELDS = Object.freeze([ + 'origin', + 'pathPrefixes', + 'payTo', + 'evidencePath', + 'executionSigner', + 'refundSigner', + 'refundSource', + 'perRequestMaxAtomic', + 'autoApproveAtomic', + 'humanApproveAtomic', + 'sellerSessionMaxAtomic', +]); +const INPUT_FIELDS = Object.freeze([ + 'policy', + 'policyVersion', + 'intent', + 'wallet', + 'paymentRequired', + 'challengeReceivedAtMs', + 'nowMs', + 'budgetSnapshot', +]); + +function fail(code, message) { + throw new KernelError(code, message); +} + +function boundedString(value, label, code, maximum = 1_024) { + if (typeof value !== 'string' + || value.length === 0 + || Buffer.byteLength(value, 'utf8') > maximum) { + fail(code, `${label} must be one nonempty bounded string`); + } + return value; +} + +function canonicalAddress(value, label, code = 'POLICY_ADDRESS') { + if (typeof value !== 'string' || !ADDRESS_PATTERN.test(value)) { + fail(code, `${label} must be one canonical EVM address`); + } + return value.toLowerCase(); +} + +function canonicalHash(value, label, code = 'HASH_FORMAT') { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + fail(code, `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function atomicText(value, label, code) { + try { + return canonicalAtomic(value, label); + } catch (error) { + if (error instanceof KernelError) fail(code, `${label} must be canonical atomic text`); + throw error; + } +} + +function positiveSafeInteger(value, label, code, maximum = Number.MAX_SAFE_INTEGER) { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + fail(code, `${label} must be a positive safe integer`); + } + return value; +} + +function nonnegativeSafeInteger(value, label, code) { + if (!Number.isSafeInteger(value) || value < 0) { + fail(code, `${label} must be a nonnegative safe integer`); + } + return value; +} + +function isCanonicalLiteralLoopbackHttp(value, parsed) { + if (parsed.protocol !== 'http:' || !value.startsWith('http://')) return false; + const authority = value.slice('http://'.length).split(/[/?#]/u, 1)[0]; + if (!/^(?:127\.0\.0\.1|\[::1\])(?::[1-9][0-9]{0,4})?$/.test(authority)) { + return false; + } + return parsed.origin === `http://${authority}`; +} + +function normalizeOrigin(value, code = 'POLICY_SELLER_ORIGIN') { + boundedString(value, 'seller origin', code, 2_048); + let parsed; + try { + parsed = new URL(value); + } catch { + return fail(code, 'seller origin must be an absolute canonical origin'); + } + if (parsed.username !== '' + || parsed.password !== '' + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '' + || parsed.origin === 'null') { + fail(code, 'seller origin must contain only scheme, host, and optional port'); + } + if (parsed.protocol !== 'https:' && !isCanonicalLiteralLoopbackHttp(value, parsed)) { + fail(code, 'seller origin must be HTTPS or literal loopback HTTP'); + } + return parsed.origin; +} + +function canonicalPath(value, origin, label, code) { + boundedString(value, label, code, 2_048); + if (!value.startsWith('/') + || value.startsWith('//') + || value.includes('?') + || value.includes('#') + || value.includes('\\') + || ENCODED_PATH_SEPARATOR.test(value)) { + fail(code, `${label} must be one queryless canonical absolute path`); + } + let parsed; + try { + parsed = new URL(value, `${origin}/`); + } catch { + return fail(code, `${label} must be one queryless canonical absolute path`); + } + if (parsed.origin !== origin + || parsed.pathname !== value + || parsed.search !== '' + || parsed.hash !== '') { + fail(code, `${label} must preserve its exact origin and pathname`); + } + return value; +} + +function canonicalResourceUrl(value, code = 'CHALLENGE_RESOURCE') { + boundedString(value, 'resource URL', code, 4_096); + let parsed; + try { + parsed = new URL(value); + } catch { + return fail(code, 'resource URL must be absolute and canonical'); + } + if ((parsed.protocol !== 'https:' && !isCanonicalLiteralLoopbackHttp(value, parsed)) + || parsed.username !== '' + || parsed.password !== '' + || parsed.search !== '' + || parsed.hash !== '' + || parsed.href !== value + || parsed.pathname.startsWith('//') + || parsed.pathname.includes('\\') + || ENCODED_PATH_SEPARATOR.test(parsed.pathname)) { + fail(code, 'resource URL must be one exact queryless HTTPS or loopback URL'); + } + return Object.freeze({ + href: value, + origin: parsed.origin, + pathname: parsed.pathname, + }); +} + +function protocolToken(value, label, code) { + if (typeof value !== 'string' + || value.length > 200 + || !/^[A-Za-z0-9][A-Za-z0-9._:+-]*$/.test(value)) { + fail(code, `${label} must be one bounded protocol token`); + } + return value; +} + +function validatePolicySeller(value) { + const seller = exactRecord( + value, + SELLER_FIELDS, + [], + 'POLICY_SCHEMA', + 'policy seller', + ); + const origin = normalizeOrigin(seller.origin); + if (!Array.isArray(seller.pathPrefixes) + || seller.pathPrefixes.length < 1 + || seller.pathPrefixes.length > 100) { + fail('POLICY_RESOURCE_PATH', 'seller pathPrefixes must be one bounded nonempty array'); + } + const pathPrefixes = seller.pathPrefixes.map((entry) => canonicalPath( + entry, + origin, + 'seller path prefix', + 'POLICY_RESOURCE_PATH', + )); + if (new Set(pathPrefixes).size !== pathPrefixes.length) { + fail('POLICY_PATH_DUPLICATE', 'seller path prefixes must be unique'); + } + const perRequest = atomicText( + seller.perRequestMaxAtomic, + 'seller per-request maximum', + 'POLICY_ATOMIC', + ); + const automatic = atomicText( + seller.autoApproveAtomic, + 'seller automatic-approval maximum', + 'POLICY_ATOMIC', + ); + const human = atomicText( + seller.humanApproveAtomic, + 'seller human-approval maximum', + 'POLICY_ATOMIC', + ); + const sellerSession = atomicText( + seller.sellerSessionMaxAtomic, + 'seller session maximum', + 'POLICY_ATOMIC', + ); + if (perRequest.value <= 0n || sellerSession.value <= 0n + || automatic.value > human.value + || human.value > perRequest.value) { + fail('POLICY_LIMIT_ORDER', 'seller spend limits are inconsistent'); + } + return { + origin, + pathPrefixes, + payTo: canonicalAddress(seller.payTo, 'seller payee'), + evidencePath: canonicalPath( + seller.evidencePath, + origin, + 'seller evidence path', + 'POLICY_EVIDENCE_PATH', + ), + executionSigner: canonicalAddress(seller.executionSigner, 'execution signer'), + refundSigner: canonicalAddress(seller.refundSigner, 'refund signer'), + refundSource: canonicalAddress(seller.refundSource, 'refund source'), + perRequestMaxAtomic: perRequest.text, + autoApproveAtomic: automatic.text, + humanApproveAtomic: human.text, + sellerSessionMaxAtomic: sellerSession.text, + }; +} + +export function validatePolicyDocument(document) { + const policy = exactRecord( + document, + POLICY_FIELDS, + [], + 'POLICY_SCHEMA', + 'policy', + ); + if (policy.schemaVersion !== 1) { + fail('POLICY_SCHEMA_VERSION', 'policy schemaVersion must equal 1'); + } + if (policy.network !== BASE_SEPOLIA) { + fail('POLICY_NETWORK', 'pilot policy network must be Base Sepolia'); + } + const asset = canonicalAddress(policy.asset, 'policy asset'); + if (asset !== BASE_SEPOLIA_USDC) { + fail('POLICY_ASSET', 'pilot policy asset must be Base Sepolia USDC'); + } + if (!Array.isArray(policy.methods) + || policy.methods.length < 1 + || policy.methods.length > 32 + || policy.methods.some((method) => typeof method !== 'string' + || !/^[A-Z][A-Z0-9-]{0,31}$/.test(method)) + || new Set(policy.methods).size !== policy.methods.length) { + fail('POLICY_METHODS', 'policy methods must be unique canonical HTTP method tokens'); + } + if (!Array.isArray(policy.sellers) + || policy.sellers.length < 1 + || policy.sellers.length > 100) { + fail('POLICY_SELLERS', 'policy sellers must be one bounded nonempty array'); + } + const sellers = policy.sellers.map(validatePolicySeller); + const origins = sellers.map((seller) => seller.origin); + if (new Set(origins).size !== origins.length) { + fail('POLICY_SELLER_DUPLICATE', 'policy seller origins must be unique'); + } + const session = atomicText(policy.sessionMaxAtomic, 'session maximum', 'POLICY_ATOMIC'); + const rolling = atomicText( + policy.rolling24hMaxAtomic, + 'rolling 24-hour maximum', + 'POLICY_ATOMIC', + ); + if (session.value <= 0n || rolling.value <= 0n) { + fail('POLICY_LIMIT_ORDER', 'session and rolling limits must be positive'); + } + positiveSafeInteger( + policy.challengeMaxAgeMs, + 'challenge maximum age', + 'POLICY_TIME', + ); + positiveSafeInteger(policy.approvalTtlMs, 'approval TTL', 'POLICY_TIME'); + positiveSafeInteger( + policy.maxPendingApprovals, + 'pending approval maximum', + 'POLICY_APPROVAL_CAPACITY', + ); + if (policy.defaultAction !== 'deny') { + fail('POLICY_DEFAULT', 'policy defaultAction must be deny'); + } + return frozenCopy({ + schemaVersion: 1, + network: BASE_SEPOLIA, + asset, + wallet: canonicalAddress(policy.wallet, 'policy wallet'), + methods: [...policy.methods], + sellers, + sessionMaxAtomic: session.text, + rolling24hMaxAtomic: rolling.text, + challengeMaxAgeMs: policy.challengeMaxAgeMs, + approvalTtlMs: policy.approvalTtlMs, + maxPendingApprovals: policy.maxPendingApprovals, + defaultAction: 'deny', + }); +} + +function validateIntent(value) { + const intent = exactRecord(value, [ + 'id', + 'method', + 'requestUrl', + 'sellerOrigin', + 'resourcePath', + 'walletAddress', + ], [], 'INPUT_SCHEMA', 'spend intent'); + const id = canonicalToken(intent.id, 'intent ID'); + if (typeof intent.method !== 'string' + || !/^[A-Z][A-Z0-9-]{0,31}$/.test(intent.method)) { + fail('INPUT_SCHEMA', 'intent method must be one canonical HTTP method'); + } + const request = canonicalResourceUrl(intent.requestUrl, 'INPUT_SCHEMA'); + const sellerOrigin = normalizeOrigin(intent.sellerOrigin, 'INPUT_SCHEMA'); + const resourcePath = canonicalPath( + intent.resourcePath, + sellerOrigin, + 'intent resource path', + 'INPUT_SCHEMA', + ); + if (request.origin !== sellerOrigin || request.pathname !== resourcePath) { + fail('INPUT_SCHEMA', 'intent URL, seller origin, and resource path must agree'); + } + return Object.freeze({ + id, + method: intent.method, + requestUrl: request.href, + sellerOrigin, + resourcePath, + walletAddress: canonicalAddress(intent.walletAddress, 'intent wallet', 'INPUT_SCHEMA'), + }); +} + +function validateWallet(value) { + const wallet = exactRecord(value, [ + 'provider', + 'walletId', + 'address', + 'network', + ], [], 'INPUT_SCHEMA', 'wallet identity'); + return Object.freeze({ + provider: canonicalToken(wallet.provider, 'wallet provider'), + walletId: canonicalToken(wallet.walletId, 'wallet ID'), + address: canonicalAddress(wallet.address, 'wallet address', 'INPUT_SCHEMA'), + network: protocolToken(wallet.network, 'wallet network', 'INPUT_SCHEMA'), + }); +} + +function validateCandidate(value) { + const candidate = exactRecord(value, [ + 'scheme', + 'network', + 'asset', + 'amount', + 'payTo', + 'maxTimeoutSeconds', + 'extra', + ], [], 'CHALLENGE_SCHEMA', 'payment requirement'); + const extra = exactRecord( + candidate.extra, + ['name', 'version'], + ['assetTransferMethod'], + 'CHALLENGE_SCHEMA', + 'payment requirement extra', + ); + const amount = atomicText(candidate.amount, 'challenge amount', 'CHALLENGE_AMOUNT'); + if (amount.value <= 0n) { + fail('CHALLENGE_AMOUNT', 'challenge amount must be positive'); + } + positiveSafeInteger( + candidate.maxTimeoutSeconds, + 'challenge maximum timeout', + 'CHALLENGE_TIMEOUT', + 3_600, + ); + if (typeof candidate.asset !== 'string' + || !CANONICAL_ADDRESS_PATTERN.test(candidate.asset) + || typeof candidate.payTo !== 'string' + || !CANONICAL_ADDRESS_PATTERN.test(candidate.payTo)) { + fail( + 'CHALLENGE_SCHEMA', + 'challenge asset and payee must be canonical lowercase EVM addresses', + ); + } + const assetTransferMethod = Object.hasOwn(extra, 'assetTransferMethod') + ? boundedString( + extra.assetTransferMethod, + 'asset transfer method', + 'CHALLENGE_SCHEMA', + 100, + ) + : undefined; + return frozenCopy({ + scheme: protocolToken(candidate.scheme, 'challenge scheme', 'CHALLENGE_SCHEMA'), + network: protocolToken(candidate.network, 'challenge network', 'CHALLENGE_SCHEMA'), + asset: candidate.asset, + amount: amount.text, + payTo: candidate.payTo, + maxTimeoutSeconds: candidate.maxTimeoutSeconds, + extra: { + name: boundedString(extra.name, 'EIP-712 name', 'CHALLENGE_SCHEMA', 100), + version: boundedString(extra.version, 'EIP-712 version', 'CHALLENGE_SCHEMA', 100), + ...(assetTransferMethod === undefined ? {} : { assetTransferMethod }), + }, + }); +} + +function validatePaymentRequired(value) { + const payment = exactRecord( + value, + ['x402Version', 'resource', 'accepts'], + ['error'], + 'CHALLENGE_SCHEMA', + 'PaymentRequired', + ); + if (!Number.isSafeInteger(payment.x402Version) || payment.x402Version < 1) { + fail('CHALLENGE_SCHEMA', 'x402Version must be a positive safe integer'); + } + if (Object.hasOwn(payment, 'error')) { + boundedString(payment.error, 'seller error', 'CHALLENGE_SCHEMA', 2_048); + } + const resource = exactRecord( + payment.resource, + ['url', 'description', 'mimeType'], + [], + 'CHALLENGE_SCHEMA', + 'payment resource', + ); + const parsedResource = canonicalResourceUrl(resource.url); + const description = boundedString( + resource.description, + 'resource description', + 'CHALLENGE_SCHEMA', + 1_024, + ); + const mimeType = boundedString( + resource.mimeType, + 'resource MIME type', + 'CHALLENGE_SCHEMA', + 200, + ); + if (!/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/.test(mimeType)) { + fail('CHALLENGE_SCHEMA', 'resource MIME type must be canonical'); + } + if (!Array.isArray(payment.accepts) || payment.accepts.length > 100) { + fail('CHALLENGE_SCHEMA', 'accepts must be one bounded ordered array'); + } + const accepts = payment.accepts.map(validateCandidate); + return frozenCopy({ + x402Version: payment.x402Version, + ...(Object.hasOwn(payment, 'error') ? { error: payment.error } : {}), + resource: { + url: parsedResource.href, + description, + mimeType, + }, + accepts, + }); +} + +function validatePolicyVersion(value) { + const version = exactRecord( + value, + ['id', 'hash'], + [], + 'INPUT_SCHEMA', + 'policy version', + ); + return Object.freeze({ + id: canonicalToken(version.id, 'policy version ID'), + hash: canonicalHash(version.hash, 'policy version hash', 'POLICY_HASH_MISMATCH'), + }); +} + +function validateBudgetSnapshot(value) { + const snapshot = exactRecord(value, [ + 'sellerSessionExposureAtomic', + 'sessionExposureAtomic', + 'rolling24hExposureAtomic', + 'pendingApprovalCount', + ], [], 'INPUT_SCHEMA', 'budget snapshot'); + return Object.freeze({ + sellerSessionExposure: atomicText( + snapshot.sellerSessionExposureAtomic, + 'seller session exposure', + 'BUDGET_SNAPSHOT', + ), + sessionExposure: atomicText( + snapshot.sessionExposureAtomic, + 'session exposure', + 'BUDGET_SNAPSHOT', + ), + rolling24hExposure: atomicText( + snapshot.rolling24hExposureAtomic, + 'rolling 24-hour exposure', + 'BUDGET_SNAPSHOT', + ), + pendingApprovalCount: nonnegativeSafeInteger( + snapshot.pendingApprovalCount, + 'pending approval count', + 'BUDGET_SNAPSHOT', + ), + }); +} + +function challengeProjection(paymentRequired) { + return { + x402Version: paymentRequired.x402Version, + resource: { + urlHash: sha256(paymentRequired.resource.url), + description: paymentRequired.resource.description, + mimeType: paymentRequired.resource.mimeType, + }, + accepts: paymentRequired.accepts.map((candidate) => ({ + scheme: candidate.scheme, + network: candidate.network, + asset: candidate.asset, + amount: candidate.amount, + payTo: candidate.payTo, + maxTimeoutSeconds: candidate.maxTimeoutSeconds, + extra: { ...candidate.extra }, + })), + }; +} + +export function projectPaymentRequired(value) { + return frozenCopy(challengeProjection(validatePaymentRequired(value))); +} + +export function validateChallengeProjection(value) { + const projection = exactRecord( + value, + ['x402Version', 'resource', 'accepts'], + [], + 'CHALLENGE_PROJECTION_SCHEMA', + 'challenge projection', + ); + if (!Number.isSafeInteger(projection.x402Version) || projection.x402Version < 1) { + fail( + 'CHALLENGE_PROJECTION_SCHEMA', + 'challenge projection version must be a positive safe integer', + ); + } + const resource = exactRecord( + projection.resource, + ['urlHash', 'description', 'mimeType'], + [], + 'CHALLENGE_PROJECTION_SCHEMA', + 'challenge projection resource', + ); + const urlHash = canonicalHash( + resource.urlHash, + 'challenge projection resource URL hash', + 'CHALLENGE_PROJECTION_SCHEMA', + ); + const description = boundedString( + resource.description, + 'challenge projection resource description', + 'CHALLENGE_PROJECTION_SCHEMA', + 1_024, + ); + const mimeType = boundedString( + resource.mimeType, + 'challenge projection resource MIME type', + 'CHALLENGE_PROJECTION_SCHEMA', + 200, + ); + if (!/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/.test( + mimeType, + )) { + fail('CHALLENGE_PROJECTION_SCHEMA', 'challenge projection MIME type must be canonical'); + } + if (!Array.isArray(projection.accepts) || projection.accepts.length > 100) { + fail( + 'CHALLENGE_PROJECTION_SCHEMA', + 'challenge projection accepts must be one bounded ordered array', + ); + } + let accepts; + try { + accepts = projection.accepts.map(validateCandidate); + } catch (error) { + if (error instanceof KernelError) { + fail('CHALLENGE_PROJECTION_SCHEMA', 'challenge projection candidate is invalid'); + } + throw error; + } + return frozenCopy({ + x402Version: projection.x402Version, + resource: { urlHash, description, mimeType }, + accepts, + }); +} + +function sellerFor(policy, intent) { + return policy.sellers.find((seller) => seller.origin === intent.sellerOrigin) ?? null; +} + +function candidateAnalysis(policy, intent, paymentRequired) { + const scheme = paymentRequired.accepts.filter((candidate) => candidate.scheme === 'exact' + && (!Object.hasOwn(candidate.extra, 'assetTransferMethod') + || candidate.extra.assetTransferMethod === 'eip3009')); + const network = scheme.filter((candidate) => candidate.network === policy.network); + const asset = network.filter((candidate) => candidate.asset === policy.asset + && candidate.extra.name === 'USDC' + && candidate.extra.version === '2'); + const seller = sellerFor(policy, intent); + const payee = seller + ? asset.filter((candidate) => candidate.payTo === seller.payTo) + : []; + return Object.freeze({ scheme, network, asset, seller, payee }); +} + +function selectValidatedCandidate(policy, intent, paymentRequired) { + const analysis = candidateAnalysis(policy, intent, paymentRequired); + let reasonCode = null; + if (paymentRequired.x402Version !== 2) reasonCode = 'X402_VERSION'; + else if (analysis.scheme.length === 0) reasonCode = 'SCHEME_UNSUPPORTED'; + else if (analysis.network.length === 0) reasonCode = 'NETWORK_MISMATCH'; + else if (analysis.asset.length === 0) reasonCode = 'ASSET_MISMATCH'; + else if (!analysis.seller) reasonCode = 'SELLER_UNTRUSTED'; + else if (!analysis.seller.pathPrefixes.some((prefix) => intent.resourcePath.startsWith(prefix))) { + reasonCode = 'RESOURCE_PATH'; + } else if (paymentRequired.resource.url !== intent.requestUrl) reasonCode = 'RESOURCE_PATH'; + else if (analysis.payee.length === 0) reasonCode = 'PAYEE_MISMATCH'; + else if (analysis.payee.length > 1) reasonCode = 'PAYMENT_OPTIONS_AMBIGUOUS'; + + if (reasonCode) { + return Object.freeze({ acceptedIndex: null, accepted: null, reasonCode }); + } + const accepted = analysis.payee[0]; + const acceptedIndex = paymentRequired.accepts.indexOf(accepted); + return Object.freeze({ acceptedIndex, accepted, reasonCode: null }); +} + +export function selectExactCandidate(value) { + const input = exactRecord( + value, + ['policy', 'intent', 'paymentRequired'], + [], + 'INPUT_SCHEMA', + 'candidate selection input', + ); + const normalizedPolicy = validatePolicyDocument(input.policy); + const normalizedIntent = validateIntent(input.intent); + const normalizedPayment = validatePaymentRequired(input.paymentRequired); + return selectValidatedCandidate(normalizedPolicy, normalizedIntent, normalizedPayment); +} + +function decision({ + decision: outcome, + reasonCode, + policyHash, + challengeHash, + selection, +}) { + if (!DECISIONS.has(outcome)) fail('DECISION_SCHEMA', 'unknown policy decision'); + const acceptedIndex = selection?.acceptedIndex ?? null; + const quoteId = acceptedIndex === null + ? null + : sha256(canonicalJson({ challengeHash, acceptedIndex })); + return Object.freeze({ + decision: outcome, + reasonCode, + policyHash, + challengeHash, + quoteId, + amountCeilingAtomic: selection?.accepted?.amount ?? '0', + acceptedIndex, + }); +} + +export function evaluateSpendPolicy(value) { + const input = exactRecord( + value, + INPUT_FIELDS, + [], + 'INPUT_SCHEMA', + 'policy evaluation input', + ); + const policy = validatePolicyDocument(input.policy); + const policyVersion = validatePolicyVersion(input.policyVersion); + const intent = validateIntent(input.intent); + const wallet = validateWallet(input.wallet); + const paymentRequired = validatePaymentRequired(input.paymentRequired); + const budget = validateBudgetSnapshot(input.budgetSnapshot); + nonnegativeSafeInteger( + input.challengeReceivedAtMs, + 'challenge received time', + 'CHALLENGE_TIME', + ); + nonnegativeSafeInteger(input.nowMs, 'decision time', 'CHALLENGE_TIME'); + if (input.nowMs < input.challengeReceivedAtMs) { + fail('CHALLENGE_TIME', 'decision time must not precede challenge receipt'); + } + const policyHash = sha256(canonicalJson(policy)); + if (policyVersion.hash !== policyHash) { + fail('POLICY_HASH_MISMATCH', 'policy version hash does not match canonical policy'); + } + const challengeHash = sha256(canonicalJson(challengeProjection(paymentRequired))); + const analysis = candidateAnalysis(policy, intent, paymentRequired); + const preselectionDeny = (reasonCode) => decision({ + decision: 'deny', + reasonCode, + policyHash, + challengeHash, + selection: null, + }); + + if (paymentRequired.x402Version !== 2) return preselectionDeny('X402_VERSION'); + if (analysis.scheme.length === 0) return preselectionDeny('SCHEME_UNSUPPORTED'); + if (analysis.network.length === 0 || wallet.network !== policy.network) { + return preselectionDeny('NETWORK_MISMATCH'); + } + if (analysis.asset.length === 0) return preselectionDeny('ASSET_MISMATCH'); + if (intent.walletAddress !== policy.wallet || wallet.address !== policy.wallet) { + return preselectionDeny('WALLET_MISMATCH'); + } + if (!policy.methods.includes(intent.method)) return preselectionDeny('METHOD_UNSUPPORTED'); + if (!analysis.seller) return preselectionDeny('SELLER_UNTRUSTED'); + if (!analysis.seller.pathPrefixes.some((prefix) => intent.resourcePath.startsWith(prefix)) + || paymentRequired.resource.url !== intent.requestUrl) { + return preselectionDeny('RESOURCE_PATH'); + } + if (analysis.payee.length === 0) return preselectionDeny('PAYEE_MISMATCH'); + if (analysis.payee.length > 1) return preselectionDeny('PAYMENT_OPTIONS_AMBIGUOUS'); + + const accepted = analysis.payee[0]; + const selection = Object.freeze({ + accepted, + acceptedIndex: paymentRequired.accepts.indexOf(accepted), + }); + const selectedDeny = (reasonCode) => decision({ + decision: 'deny', + reasonCode, + policyHash, + challengeHash, + selection, + }); + if (input.nowMs - input.challengeReceivedAtMs > policy.challengeMaxAgeMs) { + return selectedDeny('CHALLENGE_EXPIRED'); + } + const amount = BigInt(accepted.amount); + const perRequest = BigInt(analysis.seller.perRequestMaxAtomic); + const humanMaximum = BigInt(analysis.seller.humanApproveAtomic); + if (amount > perRequest || amount > humanMaximum) return selectedDeny('PER_REQUEST_LIMIT'); + if (budget.sellerSessionExposure.value + amount + > BigInt(analysis.seller.sellerSessionMaxAtomic)) { + return selectedDeny('SELLER_SESSION_LIMIT'); + } + if (budget.sessionExposure.value + amount > BigInt(policy.sessionMaxAtomic)) { + return selectedDeny('SESSION_LIMIT'); + } + if (budget.rolling24hExposure.value + amount > BigInt(policy.rolling24hMaxAtomic)) { + return selectedDeny('ROLLING_24H_LIMIT'); + } + if (amount > BigInt(analysis.seller.autoApproveAtomic) + && budget.pendingApprovalCount >= policy.maxPendingApprovals) { + return selectedDeny('APPROVAL_CAPACITY'); + } + if (amount <= BigInt(analysis.seller.autoApproveAtomic)) { + return decision({ + decision: 'allow', + reasonCode: 'WITHIN_AUTO_LIMIT', + policyHash, + challengeHash, + selection, + }); + } + return decision({ + decision: 'approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + policyHash, + challengeHash, + selection, + }); +} + +export function validatePolicyEvaluation(value) { + const evaluation = exactRecord(value, [ + 'decision', + 'reasonCode', + 'policyHash', + 'challengeHash', + 'quoteId', + 'amountCeilingAtomic', + 'acceptedIndex', + ], [], 'POLICY_DECISION_SCHEMA', 'policy evaluation'); + if (!DECISIONS.has(evaluation.decision)) { + fail('POLICY_DECISION_SCHEMA', 'policy evaluation decision is invalid'); + } + const reasonCode = canonicalToken(evaluation.reasonCode, 'policy reason code'); + const policyHash = canonicalHash( + evaluation.policyHash, + 'evaluation policy hash', + 'POLICY_DECISION_SCHEMA', + ); + const challengeHash = canonicalHash( + evaluation.challengeHash, + 'evaluation challenge hash', + 'POLICY_DECISION_SCHEMA', + ); + const amount = atomicText( + evaluation.amountCeilingAtomic, + 'evaluation amount ceiling', + 'POLICY_DECISION_SCHEMA', + ); + const indexIsNull = evaluation.acceptedIndex === null; + if (!indexIsNull + && (!Number.isSafeInteger(evaluation.acceptedIndex) || evaluation.acceptedIndex < 0)) { + fail('POLICY_DECISION_SCHEMA', 'accepted index must be null or nonnegative'); + } + if (indexIsNull) { + if (evaluation.quoteId !== null + || amount.value !== 0n + || evaluation.decision !== 'deny' + || !PRESELECTION_DENIALS.has(reasonCode)) { + fail('POLICY_DECISION_SCHEMA', 'preselection denial fields are inconsistent'); + } + } else { + if (amount.value <= 0n) { + fail('POLICY_DECISION_SCHEMA', 'selected policy decisions require a positive amount'); + } + const expectedQuote = sha256(canonicalJson({ + challengeHash, + acceptedIndex: evaluation.acceptedIndex, + })); + if (evaluation.quoteId !== expectedQuote) { + fail('POLICY_DECISION_SCHEMA', 'quote ID does not match its challenge and index'); + } + const reasonMatchesDecision = (evaluation.decision === 'allow' + && reasonCode === 'WITHIN_AUTO_LIMIT') + || (evaluation.decision === 'approval_required' + && reasonCode === 'HUMAN_APPROVAL_REQUIRED') + || (evaluation.decision === 'deny' && SELECTED_DENIALS.has(reasonCode)); + if (!reasonMatchesDecision) { + fail('POLICY_DECISION_SCHEMA', 'policy decision and reason code are inconsistent'); + } + } + return Object.freeze({ + decision: evaluation.decision, + reasonCode, + policyHash, + challengeHash, + quoteId: evaluation.quoteId, + amountCeilingAtomic: amount.text, + acceptedIndex: evaluation.acceptedIndex, + }); +} diff --git a/spikes/pi-wielder/src/kernel/policy-repository.mjs b/spikes/pi-wielder/src/kernel/policy-repository.mjs new file mode 100644 index 0000000..e9ffdb8 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/policy-repository.mjs @@ -0,0 +1,534 @@ +import { + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; +import { + validatePolicyDocument, + validatePolicyEvaluation, + validateChallengeProjection, +} from './policy-engine.mjs'; + +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; + +function fail(code, message) { + throw new KernelError(code, message); +} + +function canonicalHash(value, label, code = 'POLICY_CORRUPTION') { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + fail(code, `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function rowToPolicyVersion(row) { + if (!row) return null; + let parsed; + try { + parsed = JSON.parse(row.canonical_json); + } catch { + return fail('POLICY_CORRUPTION', 'persisted policy JSON is invalid'); + } + const policy = validatePolicyDocument(parsed); + const canonical = canonicalJson(policy); + const hash = sha256(canonical); + if (canonical !== row.canonical_json || hash !== row.policy_hash) { + fail('POLICY_CORRUPTION', 'persisted policy bytes or hash changed'); + } + const schemaVersion = Number(row.schema_version); + if (schemaVersion !== policy.schemaVersion) { + fail('POLICY_CORRUPTION', 'persisted policy schema version changed'); + } + if (row.predecessor_hash !== null) { + canonicalHash(row.predecessor_hash, 'policy predecessor hash'); + } + return frozenCopy({ + id: canonicalToken(row.id, 'policy version ID'), + schemaVersion, + policy, + canonicalJson: canonical, + hash, + predecessorHash: row.predecessor_hash, + appliedAt: canonicalTimestamp(row.applied_at, 'policy appliedAt'), + }); +} + +function rowToDecision(row) { + if (!row) return null; + return Object.freeze({ + intentId: row.intent_id, + policyVersionId: row.policy_version_id, + decision: row.decision, + reasonCode: row.reason_code, + challengeHash: row.challenge_hash, + acceptedIndex: row.accepted_index === null ? null : Number(row.accepted_index), + quoteId: row.quote_id, + amountCeilingAtomic: row.amount_ceiling_atomic, + decidedAt: row.decided_at, + }); +} + +function sameDecision(left, right) { + const semantic = (value) => ({ + intentId: value.intentId, + policyVersionId: value.policyVersionId, + decision: value.decision, + reasonCode: value.reasonCode, + challengeHash: value.challengeHash, + acceptedIndex: value.acceptedIndex, + quoteId: value.quoteId, + amountCeilingAtomic: value.amountCeilingAtomic, + }); + return canonicalJson(semantic(left)) === canonicalJson(semantic(right)); +} + +function parsePersistedProjection(bytes) { + if (typeof bytes !== 'string') { + fail('POLICY_DECISION_CORRUPTION', 'SpendIntent challenge projection is missing'); + } + let parsed; + try { + parsed = JSON.parse(bytes); + } catch { + fail('POLICY_DECISION_CORRUPTION', 'SpendIntent challenge projection is invalid JSON'); + } + let projection; + try { + projection = validateChallengeProjection(parsed); + } catch (error) { + if (error instanceof KernelError) { + fail('POLICY_DECISION_CORRUPTION', 'SpendIntent challenge projection is invalid'); + } + throw error; + } + if (canonicalJson(projection) !== bytes) { + fail('POLICY_DECISION_CORRUPTION', 'SpendIntent challenge projection is not canonical'); + } + return projection; +} + +function candidateStages(policy, seller, accepts) { + const scheme = accepts.filter((candidate) => candidate.scheme === 'exact' + && (!Object.hasOwn(candidate.extra, 'assetTransferMethod') + || candidate.extra.assetTransferMethod === 'eip3009')); + const network = scheme.filter((candidate) => candidate.network === policy.network); + const asset = network.filter((candidate) => candidate.asset === policy.asset + && candidate.extra.name === 'USDC' + && candidate.extra.version === '2'); + const payee = seller + ? asset.filter((candidate) => candidate.payTo === seller.payTo) + : []; + return { scheme, network, asset, payee }; +} + +function persistedPreselectionReason({ policy, projection, intent }) { + const seller = policy.sellers.find((entry) => entry.origin === intent.seller_origin) ?? null; + const stages = candidateStages(policy, seller, projection.accepts); + if (projection.x402Version !== 2) return { reasonCode: 'X402_VERSION', seller, stages }; + if (stages.scheme.length === 0) { + return { reasonCode: 'SCHEME_UNSUPPORTED', seller, stages }; + } + if (stages.network.length === 0) return { reasonCode: 'NETWORK_MISMATCH', seller, stages }; + if (stages.asset.length === 0) return { reasonCode: 'ASSET_MISMATCH', seller, stages }; + if (intent.wallet_address !== policy.wallet || intent.session_wallet_address !== policy.wallet) { + return { reasonCode: 'WALLET_MISMATCH', seller, stages }; + } + if (!policy.methods.includes(intent.method)) { + return { reasonCode: 'METHOD_UNSUPPORTED', seller, stages }; + } + if (!seller) return { reasonCode: 'SELLER_UNTRUSTED', seller, stages }; + if (!seller.pathPrefixes.some((prefix) => intent.resource_path.startsWith(prefix)) + || projection.resource.urlHash !== intent.request_url_hash) { + return { reasonCode: 'RESOURCE_PATH', seller, stages }; + } + if (stages.payee.length === 0) return { reasonCode: 'PAYEE_MISMATCH', seller, stages }; + if (stages.payee.length > 1) { + return { reasonCode: 'PAYMENT_OPTIONS_AMBIGUOUS', seller, stages }; + } + return { reasonCode: null, seller, stages }; +} + +function preselectionReasonIsReproducible(evaluationReason, staticResult, projection) { + if (evaluationReason === staticResult.reasonCode) return true; + if (evaluationReason === 'NETWORK_MISMATCH') { + return projection.x402Version === 2 && staticResult.stages.scheme.length > 0; + } + if (evaluationReason === 'WALLET_MISMATCH') { + return projection.x402Version === 2 + && staticResult.stages.scheme.length > 0 + && staticResult.stages.network.length > 0 + && staticResult.stages.asset.length > 0; + } + return false; +} + +function assertPersistedEvaluationBinding({ + policyVersion, + intent, + projection, + evaluation, + decidedAt, +}) { + const projectionHash = sha256(canonicalJson(projection)); + if (projectionHash !== intent.challenge_hash + || projectionHash !== evaluation.challengeHash) { + fail('POLICY_DECISION_CORRUPTION', 'challenge projection hash binding changed'); + } + const receivedAt = canonicalTimestamp( + intent.challenge_received_at, + 'SpendIntent challenge receivedAt', + ); + const elapsed = Date.parse(decidedAt) - Date.parse(receivedAt); + if (elapsed < 0) { + fail('POLICY_DECISION_CORRUPTION', 'PolicyDecision predates its challenge'); + } + + const staticResult = persistedPreselectionReason({ + policy: policyVersion.policy, + projection, + intent, + }); + if (evaluation.acceptedIndex === null) { + if (evaluation.decision !== 'deny' + || !preselectionReasonIsReproducible( + evaluation.reasonCode, + staticResult, + projection, + )) { + fail('POLICY_DECISION_CORRUPTION', 'preselection PolicyDecision is not reproducible'); + } + return; + } + if (staticResult.reasonCode !== null) { + fail('POLICY_DECISION_CORRUPTION', 'selected PolicyDecision bypasses a static denial'); + } + + const selected = staticResult.stages.payee[0]; + const selectedIndex = projection.accepts.indexOf(selected); + if (selectedIndex !== evaluation.acceptedIndex + || selected?.amount !== evaluation.amountCeilingAtomic) { + fail('POLICY_DECISION_CORRUPTION', 'PolicyDecision selected candidate binding changed'); + } + const amount = BigInt(selected.amount); + const automatic = BigInt(staticResult.seller.autoApproveAtomic); + const human = BigInt(staticResult.seller.humanApproveAtomic); + const perRequest = BigInt(staticResult.seller.perRequestMaxAtomic); + const expired = elapsed > policyVersion.policy.challengeMaxAgeMs; + + if (evaluation.decision === 'allow') { + if (expired || evaluation.reasonCode !== 'WITHIN_AUTO_LIMIT' || amount > automatic) { + fail('POLICY_DECISION_CORRUPTION', 'automatic PolicyDecision exceeds static authority'); + } + return; + } + if (evaluation.decision === 'approval_required') { + if (expired + || evaluation.reasonCode !== 'HUMAN_APPROVAL_REQUIRED' + || amount <= automatic + || amount > human + || amount > perRequest) { + fail('POLICY_DECISION_CORRUPTION', 'approval PolicyDecision exceeds static authority'); + } + return; + } + if (evaluation.reasonCode === 'CHALLENGE_EXPIRED') { + if (!expired) fail('POLICY_DECISION_CORRUPTION', 'challenge-expired denial is premature'); + return; + } + if (expired) { + fail('POLICY_DECISION_CORRUPTION', 'expired challenge has the wrong denial reason'); + } + if (evaluation.reasonCode === 'PER_REQUEST_LIMIT') { + if (amount <= human && amount <= perRequest) { + fail('POLICY_DECISION_CORRUPTION', 'per-request denial is below its static limit'); + } + return; + } + if (amount > human || amount > perRequest) { + fail('POLICY_DECISION_CORRUPTION', 'selected denial bypasses per-request precedence'); + } + if (evaluation.reasonCode === 'APPROVAL_CAPACITY' && amount <= automatic) { + fail('POLICY_DECISION_CORRUPTION', 'approval-capacity denial needs human approval'); + } +} + +export function createPolicyRepository(store) { + if (!store || typeof store.transaction !== 'function' || typeof store.within !== 'function') { + throw new TypeError('policy repository requires a Wallet Kernel store'); + } + + const loadById = (database, id) => rowToPolicyVersion(database.prepare( + 'SELECT * FROM policy_versions WHERE id = ?', + ).get(id)); + + const loadActive = (database) => { + const activeId = database.prepare( + 'SELECT value FROM metadata WHERE key = ?', + ).get('active_policy_id')?.value; + if (activeId === undefined) { + const count = database.prepare('SELECT COUNT(*) AS count FROM policy_versions').get().count; + if (BigInt(count) !== 0n) { + fail('POLICY_CORRUPTION', 'policy history exists without an active version'); + } + return null; + } + const active = loadById(database, activeId); + if (!active) fail('POLICY_CORRUPTION', 'active policy metadata points to no version'); + return active; + }; + + const apply = (document, appliedAt) => { + const policy = validatePolicyDocument(document); + const canonical = canonicalJson(policy); + const hash = sha256(canonical); + const timestamp = canonicalTimestamp(appliedAt, 'policy appliedAt'); + + return store.transaction((token) => store.within(token, ({ db, appendEvent }) => { + const current = loadActive(db); + const liveSessions = db.prepare(`SELECT id, wallet_address, state + FROM spend_sessions + WHERE state IN ('open', 'policy_blocked') + ORDER BY id`).all(); + if (liveSessions.some((session) => session.wallet_address !== policy.wallet)) { + fail( + 'POLICY_WALLET_MISMATCH', + 'new policy wallet differs from a live Spend Session wallet', + ); + } + if (current?.hash === hash) { + return frozenCopy({ + policyVersion: current, + blockedSessionIds: [], + idempotent: true, + }); + } + if (db.prepare('SELECT id FROM policy_versions WHERE policy_hash = ?').get(hash)) { + fail( + 'POLICY_VERSION_REUSE', + 'an inactive immutable policy hash cannot be inserted or silently reactivated', + ); + } + + const count = BigInt(db.prepare( + 'SELECT COUNT(*) AS count FROM policy_versions', + ).get().count); + if (count >= BigInt(Number.MAX_SAFE_INTEGER)) { + fail('POLICY_CORRUPTION', 'policy version sequence exceeded its safe boundary'); + } + const id = `policy-${Number(count) + 1}`; + const predecessorHash = current?.hash ?? null; + db.prepare(`INSERT INTO policy_versions + (id, schema_version, canonical_json, policy_hash, predecessor_hash, applied_at) + VALUES (?, ?, ?, ?, ?, ?)`) + .run(id, policy.schemaVersion, canonical, hash, predecessorHash, timestamp); + db.prepare(`INSERT INTO metadata(key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`) + .run('active_policy_id', id); + + const blockedSessionIds = liveSessions + .filter((session) => session.state === 'open') + .map((session) => session.id); + for (const sessionId of blockedSessionIds) { + const result = db.prepare(`UPDATE spend_sessions + SET state = 'policy_blocked' + WHERE id = ? AND state = 'open'`).run(sessionId); + if (result.changes !== 1n) { + fail('POLICY_CORRUPTION', 'open Spend Session changed during policy apply'); + } + } + + appendEvent({ + entityType: 'policy', + entityId: id, + eventType: 'policy.applied', + data: { + policyHash: hash, + predecessorHash, + blockedSessionIds, + }, + }); + for (const sessionId of blockedSessionIds) { + appendEvent({ + entityType: 'spend_session', + entityId: sessionId, + eventType: 'session.policy_blocked', + data: { + previousPolicyVersionId: current?.id ?? null, + targetPolicyVersionId: id, + }, + }); + } + + const policyVersion = loadById(db, id); + return frozenCopy({ + policyVersion, + blockedSessionIds, + idempotent: false, + }); + })); + }; + + const active = () => { + const activeId = store.getMetadata('active_policy_id'); + if (activeId === null) { + const rows = store.readAll('SELECT id FROM policy_versions'); + if (rows.length !== 0) { + fail('POLICY_CORRUPTION', 'policy history exists without an active version'); + } + return null; + } + const policyVersion = rowToPolicyVersion(store.readOne( + 'SELECT * FROM policy_versions WHERE id = ?', + [activeId], + )); + if (!policyVersion) fail('POLICY_CORRUPTION', 'active policy version is missing'); + return policyVersion; + }; + + const history = () => Object.freeze(store.readAll( + 'SELECT * FROM policy_versions ORDER BY rowid', + ).map(rowToPolicyVersion)); + + const get = (id) => rowToPolicyVersion(store.readOne( + 'SELECT * FROM policy_versions WHERE id = ?', + [canonicalToken(id, 'policy version ID')], + )); + + const recordDecisionInTransaction = (token, input) => store.within( + token, + ({ db, appendEvent }) => { + const record = exactRecord(input, [ + 'intentId', + 'policyVersionId', + 'evaluation', + 'decidedAt', + ], [], 'POLICY_DECISION_SCHEMA', 'policy decision write'); + const intentId = canonicalToken(record.intentId, 'decision intent ID'); + const policyVersionId = canonicalToken( + record.policyVersionId, + 'decision policy version ID', + ); + const decidedAt = canonicalTimestamp(record.decidedAt, 'decision decidedAt'); + const existing = rowToDecision(db.prepare( + 'SELECT * FROM policy_decisions WHERE intent_id = ?', + ).get(intentId)); + let evaluation; + try { + evaluation = validatePolicyEvaluation(record.evaluation); + } catch (error) { + if (existing) { + fail('POLICY_DECISION_CORRUPTION', 'PolicyDecision replay is not a valid result'); + } + throw error; + } + if (existing && existing.policyVersionId !== policyVersionId) { + fail('POLICY_DECISION_CORRUPTION', 'PolicyDecision replay changed PolicyVersion'); + } + const policyVersion = loadById(db, policyVersionId); + if (!policyVersion) fail('POLICY_DECISION_MISSING', 'PolicyVersion does not exist'); + const intent = db.prepare(`SELECT spend_intents.method, + spend_intents.request_url_hash, + spend_intents.seller_origin, + spend_intents.resource_path, + spend_intents.wallet_address, + spend_intents.challenge_projection_json, + spend_intents.challenge_hash, + spend_intents.challenge_received_at, + spend_sessions.wallet_address AS session_wallet_address, + spend_sessions.policy_version_id AS session_policy_version_id + FROM spend_intents + JOIN spend_sessions ON spend_sessions.id = spend_intents.session_id + WHERE spend_intents.id = ?`).get(intentId); + if (!intent) fail('POLICY_DECISION_MISSING', 'SpendIntent does not exist'); + if (intent.session_policy_version_id !== policyVersionId) { + fail('POLICY_DECISION_CORRUPTION', 'SpendIntent session policy binding changed'); + } + if (evaluation.policyHash !== policyVersion.hash) { + fail('POLICY_HASH_MISMATCH', 'evaluation policy hash does not match PolicyVersion'); + } + if (intent.challenge_hash !== evaluation.challengeHash) { + fail('POLICY_CHALLENGE_MISMATCH', 'evaluation challenge hash does not match SpendIntent'); + } + + const projection = parsePersistedProjection(intent.challenge_projection_json); + + const expected = Object.freeze({ + intentId, + policyVersionId, + decision: evaluation.decision, + reasonCode: evaluation.reasonCode, + challengeHash: evaluation.challengeHash, + acceptedIndex: evaluation.acceptedIndex, + quoteId: evaluation.quoteId, + amountCeilingAtomic: evaluation.amountCeilingAtomic, + decidedAt, + }); + if (existing) { + if (!sameDecision(existing, expected)) { + fail('POLICY_DECISION_CORRUPTION', 'PolicyDecision replay differs from persisted row'); + } + assertPersistedEvaluationBinding({ + policyVersion, + intent, + projection, + evaluation, + decidedAt: existing.decidedAt, + }); + return existing; + } + + assertPersistedEvaluationBinding({ + policyVersion, + intent, + projection, + evaluation, + decidedAt, + }); + + db.prepare(`INSERT INTO policy_decisions + (intent_id, policy_version_id, decision, reason_code, challenge_hash, + accepted_index, quote_id, amount_ceiling_atomic, decided_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + intentId, + policyVersionId, + evaluation.decision, + evaluation.reasonCode, + evaluation.challengeHash, + evaluation.acceptedIndex, + evaluation.quoteId, + evaluation.amountCeilingAtomic, + decidedAt, + ); + appendEvent({ + entityType: 'spend_intent', + entityId: intentId, + eventType: 'policy.decision_recorded', + data: { + policyVersionId, + decision: evaluation.decision, + reasonCode: evaluation.reasonCode, + challengeHash: evaluation.challengeHash, + acceptedIndex: evaluation.acceptedIndex, + quoteId: evaluation.quoteId, + amountCeilingAtomic: evaluation.amountCeilingAtomic, + decidedAt, + }, + }); + return expected; + }, + ); + + return Object.freeze({ + apply, + active, + history, + get, + recordDecisionInTransaction, + }); +} diff --git a/spikes/pi-wielder/tests/kernel-policy.test.mjs b/spikes/pi-wielder/tests/kernel-policy.test.mjs new file mode 100644 index 0000000..3e88eba --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-policy.test.mjs @@ -0,0 +1,1145 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { + canonicalJson, + KernelError, + sha256, +} from '../src/kernel/canonical.mjs'; +import { + evaluateSpendPolicy, + projectPaymentRequired, + selectExactCandidate, + validatePolicyDocument, +} from '../src/kernel/policy-engine.mjs'; +import { createPolicyRepository } from '../src/kernel/policy-repository.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const BASE_POLICY = { + schemaVersion: 1, + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + wallet: '0x1000000000000000000000000000000000000000', + methods: ['GET', 'POST'], + sellers: [{ + origin: 'https://seller.example', + pathPrefixes: ['/paid/'], + payTo: '0x2000000000000000000000000000000000000000', + evidencePath: '/.well-known/wallet-kernel/evidence', + executionSigner: '0x2000000000000000000000000000000000000000', + refundSigner: '0x2000000000000000000000000000000000000000', + refundSource: '0x3000000000000000000000000000000000000000', + perRequestMaxAtomic: '500000', + autoApproveAtomic: '100000', + humanApproveAtomic: '500000', + sellerSessionMaxAtomic: '1000000', + }], + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '5000000', + challengeMaxAgeMs: 60000, + approvalTtlMs: 300000, + maxPendingApprovals: 20, + defaultAction: 'deny', +}; + +const EXACT_OFFER = { + scheme: 'exact', + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + amount: '50000', + payTo: '0x2000000000000000000000000000000000000000', + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, +}; + +function clone(value) { + return structuredClone(value); +} + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function policyDocument(overrides = {}) { + return { + ...clone(BASE_POLICY), + ...overrides, + }; +} + +function offer(overrides = {}) { + const base = clone(EXACT_OFFER); + return { + ...base, + ...overrides, + ...(Object.hasOwn(overrides, 'extra') ? { extra: overrides.extra } : {}), + }; +} + +function paymentRequired({ accepts = [offer()], ...overrides } = {}) { + return { + x402Version: 2, + error: 'Payment required', + resource: { + url: 'https://seller.example/paid/infer', + description: 'offline fixture', + mimeType: 'application/json', + }, + accepts, + ...overrides, + }; +} + +function evaluationInput({ + policyDocument: rawPolicy = policyDocument(), + policyVersion: policyVersionOverrides = {}, + intent: intentOverrides = {}, + wallet: walletOverrides = {}, + paymentRequired: challenge = paymentRequired(), + challengeReceivedAtMs = 1785502800000, + nowMs = 1785502801000, + budgetSnapshot: budgetOverrides = {}, +} = {}) { + const validatedPolicy = validatePolicyDocument(rawPolicy); + const policyHash = sha256(canonicalJson(validatedPolicy)); + return deepFreeze({ + policy: validatedPolicy, + policyVersion: { + id: 'policy-1', + hash: policyHash, + ...policyVersionOverrides, + }, + intent: { + id: 'intent-1', + method: 'POST', + requestUrl: 'https://seller.example/paid/infer', + sellerOrigin: 'https://seller.example', + resourcePath: '/paid/infer', + walletAddress: '0x1000000000000000000000000000000000000000', + ...intentOverrides, + }, + wallet: { + provider: 'deterministic', + walletId: 'buyer-a', + address: '0x1000000000000000000000000000000000000000', + network: 'eip155:84532', + ...walletOverrides, + }, + paymentRequired: challenge, + challengeReceivedAtMs, + nowMs, + budgetSnapshot: { + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + pendingApprovalCount: 0, + ...budgetOverrides, + }, + }); +} + +function assertKernelError(operation, expectedCode) { + assert.throws(operation, (error) => { + assert.ok(error instanceof KernelError); + if (expectedCode) assert.equal(error.code, expectedCode); + return true; + }); +} + +function assertDecision(input, expectedDecision, expectedReason, expectedIndex = undefined) { + const result = evaluateSpendPolicy(input); + assert.equal(result.decision, expectedDecision); + assert.equal(result.reasonCode, expectedReason); + if (expectedIndex !== undefined) assert.equal(result.acceptedIndex, expectedIndex); + return result; +} + +test('canonical example is the exact deeply frozen base policy', () => { + const example = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', + )); + assert.deepEqual(example, BASE_POLICY); + + const validated = validatePolicyDocument(BASE_POLICY); + assert.deepEqual(validated, BASE_POLICY); + assert.ok(Object.isFrozen(validated)); + assert.ok(Object.isFrozen(validated.methods)); + assert.ok(Object.isFrozen(validated.sellers)); + assert.ok(Object.isFrozen(validated.sellers[0])); + assert.ok(Object.isFrozen(validated.sellers[0].pathPrefixes)); +}); + +test('policy validation is closed at every level', () => { + assertKernelError(() => validatePolicyDocument({ ...policyDocument(), injected: true }), + 'POLICY_SCHEMA'); + const missingTopLevel = policyDocument(); + delete missingTopLevel.defaultAction; + assertKernelError(() => validatePolicyDocument(missingTopLevel), 'POLICY_SCHEMA'); + assertKernelError(() => validatePolicyDocument(policyDocument({ + sellers: [{ ...clone(BASE_POLICY.sellers[0]), injected: true }], + })), 'POLICY_SCHEMA'); + const missingSellerField = clone(BASE_POLICY.sellers[0]); + delete missingSellerField.refundSource; + assertKernelError(() => validatePolicyDocument(policyDocument({ + sellers: [missingSellerField], + })), 'POLICY_SCHEMA'); +}); + +test('policy scalar, collection, and limit fields reject ambiguous configuration', () => { + const invalidDocuments = [ + policyDocument({ schemaVersion: 2 }), + policyDocument({ network: 'eip155:1' }), + policyDocument({ asset: '0x4000000000000000000000000000000000000000' }), + policyDocument({ asset: '0x1234' }), + policyDocument({ wallet: '0x1234' }), + policyDocument({ methods: [] }), + policyDocument({ methods: ['POST', 'POST'] }), + policyDocument({ methods: ['post'] }), + policyDocument({ sellers: [] }), + policyDocument({ sessionMaxAtomic: '01' }), + policyDocument({ rolling24hMaxAtomic: -1 }), + policyDocument({ challengeMaxAgeMs: 0 }), + policyDocument({ approvalTtlMs: 1.5 }), + policyDocument({ maxPendingApprovals: 0 }), + policyDocument({ defaultAction: 'allow' }), + policyDocument({ + sellers: [{ ...clone(BASE_POLICY.sellers[0]), perRequestMaxAtomic: '1.0' }], + }), + policyDocument({ + sellers: [{ + ...clone(BASE_POLICY.sellers[0]), + autoApproveAtomic: '500001', + }], + }), + policyDocument({ + sellers: [{ + ...clone(BASE_POLICY.sellers[0]), + humanApproveAtomic: '500001', + }], + }), + ]; + for (const document of invalidDocuments) { + assertKernelError(() => validatePolicyDocument(document)); + } +}); + +test('evidence paths and public evidence/refund authorities are canonical and mandatory', () => { + for (const evidencePath of [ + 'https://seller.example/evidence', + '//other.example/evidence', + '/a/../evidence', + '/%2e%2e/evidence', + '/evidence/%2fescape', + '/evidence/%5cescape', + '/evidence?revision=1', + '/evidence#fragment', + ]) { + assertKernelError(() => validatePolicyDocument(policyDocument({ + sellers: [{ ...clone(BASE_POLICY.sellers[0]), evidencePath }], + })), 'POLICY_EVIDENCE_PATH'); + } + + for (const field of ['executionSigner', 'refundSigner', 'refundSource']) { + assertKernelError(() => validatePolicyDocument(policyDocument({ + sellers: [{ ...clone(BASE_POLICY.sellers[0]), [field]: '0x1234' }], + })), 'POLICY_ADDRESS'); + } +}); + +test('seller origins are canonicalized, unique, and HTTPS except literal loopback HTTP', () => { + const duplicate = clone(BASE_POLICY.sellers[0]); + duplicate.origin = 'https://SELLER.example:443'; + assertKernelError(() => validatePolicyDocument(policyDocument({ + sellers: [clone(BASE_POLICY.sellers[0]), duplicate], + })), 'POLICY_SELLER_DUPLICATE'); + + for (const origin of [ + 'http://seller.example', + 'http://localhost:8787', + 'http://2130706433:8787', + 'http://127.1:8787', + 'http://127.0.0.1:080', + 'ftp://seller.example', + ]) { + assertKernelError(() => validatePolicyDocument(policyDocument({ + sellers: [{ ...clone(BASE_POLICY.sellers[0]), origin }], + })), 'POLICY_SELLER_ORIGIN'); + } + + for (const origin of ['http://127.0.0.1:8787', 'http://[::1]:8787']) { + const validated = validatePolicyDocument(policyDocument({ + sellers: [{ ...clone(BASE_POLICY.sellers[0]), origin }], + })); + assert.equal(validated.sellers[0].origin, origin); + } +}); + +test('seller path prefixes are canonical and unique while overlapping prefixes are harmless', () => { + assertKernelError(() => validatePolicyDocument(policyDocument({ + sellers: [{ ...clone(BASE_POLICY.sellers[0]), pathPrefixes: ['/paid/', '/paid/'] }], + })), 'POLICY_PATH_DUPLICATE'); + for (const prefix of ['paid/', '//paid/', '/paid/../admin', '/paid/%2fadmin', '/paid/?x=1']) { + assertKernelError(() => validatePolicyDocument(policyDocument({ + sellers: [{ ...clone(BASE_POLICY.sellers[0]), pathPrefixes: [prefix] }], + })), 'POLICY_RESOURCE_PATH'); + } + + const overlapping = validatePolicyDocument(policyDocument({ + sellers: [{ ...clone(BASE_POLICY.sellers[0]), pathPrefixes: ['/paid/', '/paid/infer'] }], + })); + assert.equal( + evaluateSpendPolicy(evaluationInput({ policyDocument: overlapping })).decision, + 'allow', + ); +}); + +test('exact spend thresholds yield allow, approval, and deny decisions', () => { + const allowed = assertDecision( + evaluationInput({ paymentRequired: paymentRequired({ accepts: [offer({ amount: '50000' })] }) }), + 'allow', + 'WITHIN_AUTO_LIMIT', + 0, + ); + assert.equal(allowed.amountCeilingAtomic, '50000'); + assert.match(allowed.policyHash, /^sha256:[0-9a-f]{64}$/); + assert.match(allowed.challengeHash, /^sha256:[0-9a-f]{64}$/); + assert.match(allowed.quoteId, /^sha256:[0-9a-f]{64}$/); + assert.equal(allowed.quoteId, sha256(canonicalJson({ + challengeHash: allowed.challengeHash, + acceptedIndex: 0, + }))); + assert.deepEqual(Object.keys(allowed), [ + 'decision', + 'reasonCode', + 'policyHash', + 'challengeHash', + 'quoteId', + 'amountCeilingAtomic', + 'acceptedIndex', + ]); + + assertDecision( + evaluationInput({ paymentRequired: paymentRequired({ accepts: [offer({ amount: '250000' })] }) }), + 'approval_required', + 'HUMAN_APPROVAL_REQUIRED', + 0, + ); + assertDecision( + evaluationInput({ paymentRequired: paymentRequired({ accepts: [offer({ amount: '500001' })] }) }), + 'deny', + 'PER_REQUEST_LIMIT', + 0, + ); +}); + +test('unsupported protocol, request, seller, resource, and payee inputs deny in stable order', () => { + const cases = [ + ['X402_VERSION', { paymentRequired: paymentRequired({ x402Version: 1 }) }], + ['SCHEME_UNSUPPORTED', { + paymentRequired: paymentRequired({ accepts: [offer({ scheme: 'upto' })] }), + }], + ['NETWORK_MISMATCH', { + paymentRequired: paymentRequired({ accepts: [offer({ network: 'eip155:1' })] }), + }], + ['ASSET_MISMATCH', { + paymentRequired: paymentRequired({ accepts: [offer({ + asset: '0x4000000000000000000000000000000000000000', + })] }), + }], + ['METHOD_UNSUPPORTED', { intent: { method: 'PUT' } }], + ['SELLER_UNTRUSTED', { + intent: { + sellerOrigin: 'https://other.example', + requestUrl: 'https://other.example/paid/infer', + resourcePath: '/paid/infer', + }, + paymentRequired: paymentRequired({ + resource: { + url: 'https://other.example/paid/infer', + description: 'offline fixture', + mimeType: 'application/json', + }, + }), + }], + ['RESOURCE_PATH', { + intent: { + requestUrl: 'https://seller.example/free/infer', + resourcePath: '/free/infer', + }, + paymentRequired: paymentRequired({ + resource: { + url: 'https://seller.example/free/infer', + description: 'offline fixture', + mimeType: 'application/json', + }, + }), + }], + ['PAYEE_MISMATCH', { + paymentRequired: paymentRequired({ accepts: [offer({ + payTo: '0x4000000000000000000000000000000000000000', + })] }), + }], + ]; + + for (const [reasonCode, options] of cases) { + const decision = assertDecision(evaluationInput(options), 'deny', reasonCode); + assert.notEqual(decision.decision, 'approval_required'); + } +}); + +test('wallet identity and policy hash are bound before amount rules', () => { + assertDecision(evaluationInput({ + intent: { walletAddress: '0x4000000000000000000000000000000000000000' }, + paymentRequired: paymentRequired({ accepts: [offer({ amount: '1' })] }), + }), 'deny', 'WALLET_MISMATCH'); + assertDecision(evaluationInput({ + wallet: { address: '0x4000000000000000000000000000000000000000' }, + paymentRequired: paymentRequired({ accepts: [offer({ amount: '1' })] }), + }), 'deny', 'WALLET_MISMATCH'); + assertDecision(evaluationInput({ + wallet: { network: 'eip155:1' }, + paymentRequired: paymentRequired({ accepts: [offer({ amount: '1' })] }), + }), 'deny', 'NETWORK_MISMATCH'); + assertKernelError(() => evaluateSpendPolicy(evaluationInput({ + policyVersion: { hash: `sha256:${'0'.repeat(64)}` }, + })), 'POLICY_HASH_MISMATCH'); +}); + +test('candidate selection owns 0/1/2 cardinality and preserves the original array index', () => { + const input = evaluationInput(); + const none = selectExactCandidate({ + policy: input.policy, + intent: input.intent, + paymentRequired: paymentRequired({ accepts: [] }), + }); + assert.equal(none.acceptedIndex, null); + assert.equal(none.accepted, null); + + const onlyAtOne = paymentRequired({ accepts: [ + offer({ scheme: 'upto' }), + offer({ amount: '250000' }), + ] }); + const selected = selectExactCandidate({ + policy: input.policy, + intent: input.intent, + paymentRequired: onlyAtOne, + }); + assert.equal(selected.acceptedIndex, 1); + assert.equal(selected.accepted.amount, '250000'); + assertDecision(evaluationInput({ paymentRequired: onlyAtOne }), + 'approval_required', 'HUMAN_APPROVAL_REQUIRED', 1); + + for (const accepts of [ + [offer(), offer()], + [offer({ amount: '50000' }), offer({ amount: '250000' })], + ]) { + const decision = assertDecision( + evaluationInput({ paymentRequired: paymentRequired({ accepts }) }), + 'deny', + 'PAYMENT_OPTIONS_AMBIGUOUS', + ); + assert.equal(decision.acceptedIndex, null); + assert.equal(decision.quoteId, null); + assert.equal(decision.amountCeilingAtomic, '0'); + } +}); + +test('zero compatible options produce a stable ordered mismatch denial', () => { + const challenge = paymentRequired({ accepts: [ + offer({ scheme: 'upto', network: 'eip155:1' }), + offer({ scheme: 'exact', network: 'eip155:1' }), + ] }); + const first = evaluateSpendPolicy(evaluationInput({ paymentRequired: challenge })); + const second = evaluateSpendPolicy(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [...challenge.accepts].reverse() }), + })); + assert.equal(first.decision, 'deny'); + assert.equal(first.reasonCode, 'NETWORK_MISMATCH'); + assert.equal(second.reasonCode, 'NETWORK_MISMATCH'); + assert.equal(first.acceptedIndex, null); + assert.equal(first.quoteId, null); + assert.equal(first.amountCeilingAtomic, '0'); + assertDecision( + evaluationInput({ paymentRequired: paymentRequired({ accepts: [] }) }), + 'deny', + 'SCHEME_UNSUPPORTED', + ); +}); + +test('deny checks keep the declared precedence when several mismatches coexist', () => { + assertDecision(evaluationInput({ + intent: { + method: 'PUT', + walletAddress: '0x4000000000000000000000000000000000000000', + }, + paymentRequired: paymentRequired({ + x402Version: 1, + accepts: [offer({ + scheme: 'upto', + network: 'eip155:1', + asset: '0x4000000000000000000000000000000000000000', + payTo: '0x5000000000000000000000000000000000000000', + amount: '999999999', + })], + }), + budgetSnapshot: { + sellerSessionExposureAtomic: '999999999', + sessionExposureAtomic: '999999999', + rolling24hExposureAtomic: '999999999', + pendingApprovalCount: 20, + }, + }), 'deny', 'X402_VERSION'); + + assertDecision(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [offer({ + network: 'eip155:1', + asset: '0x4000000000000000000000000000000000000000', + })] }), + }), 'deny', 'NETWORK_MISMATCH'); +}); + +test('USDC v2 and EIP-3009 are part of exact candidate compatibility', () => { + for (const extra of [ + { name: 'USD Coin', version: '2' }, + { name: 'USDC', version: '1' }, + ]) { + assertDecision(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [offer({ extra })] }), + }), 'deny', 'ASSET_MISMATCH'); + } + assertDecision(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [offer({ + extra: { name: 'USDC', version: '2', assetTransferMethod: 'permit2' }, + })] }), + }), 'deny', 'SCHEME_UNSUPPORTED'); + assertDecision(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [offer({ + extra: { name: 'USDC', version: '2', assetTransferMethod: 'eip3009' }, + })] }), + }), 'allow', 'WITHIN_AUTO_LIMIT', 0); +}); + +test('challenge candidate amounts and timeouts are structurally validated before selection', () => { + for (const amount of ['0', '01', '-1', '1.0', 1]) { + assertKernelError(() => evaluateSpendPolicy(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [ + offer({ scheme: 'upto' }), + offer({ amount }), + ] }), + })), 'CHALLENGE_AMOUNT'); + } + for (const maxTimeoutSeconds of [0, 3601, '60', 1.5]) { + assertKernelError(() => evaluateSpendPolicy(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [offer({ maxTimeoutSeconds })] }), + })), 'CHALLENGE_TIMEOUT'); + } + for (const [field, value] of [ + ['asset', '0x036CBD53842C5426634E7929541EC2318F3DCF7E'], + ['asset', 'asset-token'], + ['payTo', '0x200000000000000000000000000000000000000A'], + ['payTo', 'seller-account'], + ]) { + assertKernelError(() => evaluateSpendPolicy(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [offer({ [field]: value })] }), + })), 'CHALLENGE_SCHEMA'); + } +}); + +test('challenge age is local and cannot be extended by the protocol timeout', () => { + assertDecision(evaluationInput({ + challengeReceivedAtMs: 1785502800000, + nowMs: 1785502860001, + paymentRequired: paymentRequired({ accepts: [offer({ maxTimeoutSeconds: 3600 })] }), + }), 'deny', 'CHALLENGE_EXPIRED', 0); + assertKernelError(() => evaluateSpendPolicy(evaluationInput({ + challengeReceivedAtMs: 1785502801000, + nowMs: 1785502800000, + })), 'CHALLENGE_TIME'); +}); + +test('seller, session, rolling-day, and approval capacity limits include the selected amount', () => { + const cases = [ + ['SELLER_SESSION_LIMIT', { + sellerSessionExposureAtomic: '950001', + }], + ['SESSION_LIMIT', { + sessionExposureAtomic: '1950001', + }], + ['ROLLING_24H_LIMIT', { + rolling24hExposureAtomic: '4950001', + }], + ]; + for (const [reasonCode, budgetSnapshot] of cases) { + assertDecision(evaluationInput({ budgetSnapshot }), 'deny', reasonCode, 0); + } + assertDecision(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [offer({ amount: '250000' })] }), + budgetSnapshot: { pendingApprovalCount: 20 }, + }), 'deny', 'APPROVAL_CAPACITY', 0); +}); + +test('payment challenge and evaluation input schemas are closed at every level', () => { + const base = evaluationInput(); + const badInputs = [ + { ...base, injected: true }, + { ...base, policyVersion: { ...base.policyVersion, injected: true } }, + { ...base, intent: { ...base.intent, injected: true } }, + { ...base, wallet: { ...base.wallet, injected: true } }, + { ...base, budgetSnapshot: { ...base.budgetSnapshot, injected: true } }, + { ...base, paymentRequired: { ...base.paymentRequired, injected: true } }, + { + ...base, + paymentRequired: { + ...base.paymentRequired, + resource: { ...base.paymentRequired.resource, injected: true }, + }, + }, + { + ...base, + paymentRequired: { + ...base.paymentRequired, + accepts: [{ ...base.paymentRequired.accepts[0], injected: true }], + }, + }, + { + ...base, + paymentRequired: { + ...base.paymentRequired, + accepts: [{ + ...base.paymentRequired.accepts[0], + extra: { ...base.paymentRequired.accepts[0].extra, injected: true }, + }], + }, + }, + ]; + for (const badInput of badInputs) assertKernelError(() => evaluateSpendPolicy(badInput)); + + const missingChallenge = clone(base.paymentRequired); + delete missingChallenge.accepts; + assertKernelError(() => evaluateSpendPolicy({ ...base, paymentRequired: missingChallenge }), + 'CHALLENGE_SCHEMA'); + + for (const field of Object.keys(base)) { + const missing = { ...base }; + delete missing[field]; + assertKernelError(() => evaluateSpendPolicy(missing)); + } + for (const [field, value] of [ + ['policyVersion', base.policyVersion], + ['intent', base.intent], + ['wallet', base.wallet], + ['budgetSnapshot', base.budgetSnapshot], + ]) { + for (const key of Object.keys(value)) { + const missingNested = { ...value }; + delete missingNested[key]; + assertKernelError(() => evaluateSpendPolicy({ ...base, [field]: missingNested })); + } + } + for (const key of ['x402Version', 'resource', 'accepts']) { + const missing = clone(base.paymentRequired); + delete missing[key]; + assertKernelError(() => evaluateSpendPolicy({ ...base, paymentRequired: missing })); + } + for (const key of ['url', 'description', 'mimeType']) { + const missing = clone(base.paymentRequired); + delete missing.resource[key]; + assertKernelError(() => evaluateSpendPolicy({ ...base, paymentRequired: missing })); + } + for (const key of ['scheme', 'network', 'asset', 'amount', 'payTo', 'maxTimeoutSeconds', 'extra']) { + const missing = clone(base.paymentRequired); + delete missing.accepts[0][key]; + assertKernelError(() => evaluateSpendPolicy({ ...base, paymentRequired: missing })); + } + for (const key of ['name', 'version']) { + const missing = clone(base.paymentRequired); + delete missing.accepts[0].extra[key]; + assertKernelError(() => evaluateSpendPolicy({ ...base, paymentRequired: missing })); + } +}); + +test('resource URL is exact and seller error text is excluded from the challenge binding', () => { + const input = evaluationInput(); + const a = evaluateSpendPolicy(input); + const b = evaluateSpendPolicy(evaluationInput({ + paymentRequired: paymentRequired({ error: 'Different seller prose' }), + })); + assert.equal(a.challengeHash, b.challengeHash); + assert.equal(a.quoteId, b.quoteId); + + const mismatch = paymentRequired({ + resource: { + url: 'https://seller.example/paid/other', + description: 'offline fixture', + mimeType: 'application/json', + }, + }); + assertDecision(evaluationInput({ paymentRequired: mismatch }), 'deny', 'RESOURCE_PATH'); +}); + +test('ordered financial requirements bind challenge hash and quote index', () => { + const unsupported = offer({ scheme: 'upto', amount: '1' }); + const exact = offer({ amount: '50000' }); + const first = evaluateSpendPolicy(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [unsupported, exact] }), + })); + const second = evaluateSpendPolicy(evaluationInput({ + paymentRequired: paymentRequired({ accepts: [exact, unsupported] }), + })); + assert.equal(first.acceptedIndex, 1); + assert.equal(second.acceptedIndex, 0); + assert.notEqual(first.challengeHash, second.challengeHash); + assert.notEqual(first.quoteId, second.quoteId); +}); + +test('seller lookup is origin keyed rather than dependent on seller array order', () => { + const secondSeller = { + ...clone(BASE_POLICY.sellers[0]), + origin: 'https://other.example', + pathPrefixes: ['/other/'], + payTo: '0x4000000000000000000000000000000000000000', + executionSigner: '0x4000000000000000000000000000000000000000', + refundSigner: '0x4000000000000000000000000000000000000000', + refundSource: '0x5000000000000000000000000000000000000000', + autoApproveAtomic: '1', + }; + const sellersA = [clone(BASE_POLICY.sellers[0]), secondSeller]; + const sellersB = [...sellersA].reverse(); + const a = evaluateSpendPolicy(evaluationInput({ + policyDocument: policyDocument({ sellers: sellersA }), + })); + const b = evaluateSpendPolicy(evaluationInput({ + policyDocument: policyDocument({ sellers: sellersB }), + })); + assert.equal(a.decision, 'allow'); + assert.equal(b.decision, 'allow'); + assert.equal(a.reasonCode, b.reasonCode); + assert.equal(a.amountCeilingAtomic, b.amountCeilingAtomic); +}); + +test('a frozen input snapshot is deterministic and never mutated', () => { + const input = evaluationInput(); + const before = canonicalJson(input); + const first = evaluateSpendPolicy(input); + const second = evaluateSpendPolicy(input); + assert.equal(canonicalJson(first), canonicalJson(second)); + assert.equal(canonicalJson(input), before); + assert.ok(Object.isFrozen(first)); + assert.ok(Object.isFrozen(input)); + assert.ok(Object.isFrozen(input.paymentRequired.accepts[0])); +}); + +test('policy versions are immutable, predecessor linked, wallet safe, and idempotent', () => { + const store = openKernelStore({ + filePath: ':memory:', + allowMemory: true, + now: () => '2026-08-01T12:00:00.000Z', + }); + try { + const repository = createPolicyRepository(store); + const first = repository.apply(policyDocument(), '2026-08-01T12:00:00.000Z'); + assert.equal(first.idempotent, false); + assert.deepEqual(first.blockedSessionIds, []); + assert.deepEqual(repository.active(), first.policyVersion); + const originalRow = store.readOne('SELECT * FROM policy_versions WHERE id = ?', [ + first.policyVersion.id, + ]); + + store.transaction((token) => store.within(token, ({ db }) => { + const insert = db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at, closed_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`); + insert.run('session-open', 'adapter-a', BASE_POLICY.wallet, + first.policyVersion.id, 'open', '2026-08-01T12:00:01.000Z', null); + insert.run('session-already-blocked', 'adapter-b', BASE_POLICY.wallet, + first.policyVersion.id, 'policy_blocked', '2026-08-01T12:00:02.000Z', null); + insert.run('session-closed', 'adapter-c', BASE_POLICY.wallet, + first.policyVersion.id, 'closed', '2026-08-01T12:00:03.000Z', + '2026-08-01T12:00:04.000Z'); + })); + + const differentWallet = policyDocument({ + wallet: '0x4000000000000000000000000000000000000000', + }); + assertKernelError( + () => repository.apply(differentWallet, '2026-08-01T12:01:00.000Z'), + 'POLICY_WALLET_MISMATCH', + ); + assert.equal(repository.history().length, 1); + + const tighter = policyDocument({ + sessionMaxAtomic: '1500000', + rolling24hMaxAtomic: '4000000', + }); + const second = repository.apply(tighter, '2026-08-01T12:02:00.000Z'); + assert.equal(second.idempotent, false); + assert.deepEqual(second.blockedSessionIds, ['session-open']); + assert.equal(second.policyVersion.predecessorHash, first.policyVersion.hash); + assert.deepEqual(store.readOne('SELECT * FROM policy_versions WHERE id = ?', [ + first.policyVersion.id, + ]), originalRow); + assert.deepEqual(repository.active(), second.policyVersion); + assert.equal(store.readOne('SELECT state FROM spend_sessions WHERE id = ?', [ + 'session-open', + ]).state, 'policy_blocked'); + assert.equal(store.readOne('SELECT state FROM spend_sessions WHERE id = ?', [ + 'session-already-blocked', + ]).state, 'policy_blocked'); + assert.equal(store.readOne('SELECT state FROM spend_sessions WHERE id = ?', [ + 'session-closed', + ]).state, 'closed'); + assert.equal(store.events().filter((event) => event.event_type === 'policy.applied').length, 2); + assert.equal(store.events().filter( + (event) => event.event_type === 'session.policy_blocked', + ).length, 1); + + const eventsBeforeHistoricalReuse = store.events(); + assertKernelError( + () => repository.apply(policyDocument(), '2026-08-01T12:30:00.000Z'), + 'POLICY_VERSION_REUSE', + ); + assert.deepEqual(store.events(), eventsBeforeHistoricalReuse); + assert.equal(repository.history().length, 2); + + const eventsBeforeReplay = store.events(); + const replay = repository.apply(tighter, '2026-08-01T13:00:00.000Z'); + assert.equal(replay.idempotent, true); + assert.deepEqual(replay.policyVersion, second.policyVersion); + assert.deepEqual(replay.blockedSessionIds, []); + assert.equal(repository.history().length, 2); + assert.deepEqual(store.events(), eventsBeforeReplay); + assert.equal(store.verifyEventChain(), true); + } finally { + store.close(); + } +}); + +test('idempotent policy replay still audits every live Spend Session wallet', () => { + const store = openKernelStore({ filePath: ':memory:', allowMemory: true }); + try { + const repository = createPolicyRepository(store); + const applied = repository.apply(policyDocument(), '2026-08-01T12:00:00.000Z'); + store.transaction((token) => store.within(token, ({ db }) => db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at) + VALUES (?, ?, ?, ?, ?, ?)`) + .run('session-corrupt', 'adapter-corrupt', + '0x4000000000000000000000000000000000000000', applied.policyVersion.id, + 'open', '2026-08-01T12:00:01.000Z'))); + const eventsBefore = store.events(); + assertKernelError( + () => repository.apply(policyDocument(), '2026-08-01T12:01:00.000Z'), + 'POLICY_WALLET_MISMATCH', + ); + assert.deepEqual(store.events(), eventsBefore); + assert.equal(repository.history().length, 1); + } finally { + store.close(); + } +}); + +function seedDecisionIntent(store, { + policyVersion, + challengeHash, + challengeProjection, + intentId = 'intent-1', +} = {}) { + store.transaction((token) => store.within(token, ({ db }) => { + db.prepare(`INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, + state, enrolled_by_operator_hash, enrolled_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + .run('agent-1', 'credential-1', 'enrollment-1', '501', '20', 'active', + 'operator-1', '2026-08-01T12:00:00.000Z'); + db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at) + VALUES (?, ?, ?, ?, ?, ?)`) + .run('session-1', 'adapter-1', BASE_POLICY.wallet, policyVersion.id, 'open', + '2026-08-01T12:00:00.000Z'); + db.prepare(`INSERT INTO spend_intents + (id, request_id, session_id, enrollment_hash, route_id, method, + request_url_hash, seller_origin, resource_path, body_hash, + header_allowlist_hash, ordinary_fingerprint, purpose_label, + correlation_id, idempotency_key, wallet_address, intent_hash, + challenge_projection_json, challenge_hash, challenge_received_at, + state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(intentId, `request-${intentId}`, 'session-1', 'enrollment-1', 'route-1', 'POST', + sha256('https://seller.example/paid/infer'), 'https://seller.example', + '/paid/infer', 'body-hash', + 'headers-hash', `fingerprint-${intentId}`, 'skill.invoke', `correlation-${intentId}`, + `idempotency-${intentId}`, BASE_POLICY.wallet, `intent-hash-${intentId}`, + canonicalJson(challengeProjection), challengeHash, '2026-08-01T12:00:01.000Z', + 'challenged', '2026-08-01T12:00:00.000Z', '2026-08-01T12:00:01.000Z'); + })); +} + +test('PolicyDecision persistence is scoped, atomic, and exact-replay idempotent', () => { + const store = openKernelStore({ filePath: ':memory:', allowMemory: true }); + try { + const repository = createPolicyRepository(store); + const applied = repository.apply(policyDocument(), '2026-08-01T12:00:00.000Z'); + const challenge = paymentRequired(); + const input = evaluationInput({ + policyVersion: { + id: applied.policyVersion.id, + hash: applied.policyVersion.hash, + }, + paymentRequired: challenge, + }); + const evaluation = evaluateSpendPolicy(input); + seedDecisionIntent(store, { + policyVersion: applied.policyVersion, + challengeHash: evaluation.challengeHash, + challengeProjection: projectPaymentRequired(challenge), + }); + + assert.throws(() => repository.recordDecisionInTransaction( + Object.freeze(Object.create(null)), + { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation, + decidedAt: '2026-08-01T12:00:02.000Z', + }, + ), /invalid authority transaction/); + + let staleToken; + const first = store.transaction((token) => { + staleToken = token; + return repository.recordDecisionInTransaction(token, { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation, + decidedAt: '2026-08-01T12:00:02.000Z', + }); + }); + assert.equal(first.intentId, 'intent-1'); + const persistedFirst = store.readOne( + 'SELECT * FROM policy_decisions WHERE intent_id = ?', ['intent-1'], + ); + assert.equal(persistedFirst.decision, evaluation.decision); + assert.equal(persistedFirst.reason_code, evaluation.reasonCode); + const eventsAfterFirst = store.events(); + const replay = store.transaction((token) => repository.recordDecisionInTransaction(token, { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation, + decidedAt: '2026-08-01T12:00:03.000Z', + })); + assert.deepEqual(replay, first); + assert.deepEqual(store.events(), eventsAfterFirst); + assert.throws(() => repository.recordDecisionInTransaction(staleToken, { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation, + decidedAt: '2026-08-01T12:00:02.000Z', + }), /invalid authority transaction/); + + assertKernelError(() => store.transaction((token) => repository.recordDecisionInTransaction( + token, + { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation: Object.freeze({ ...evaluation, reasonCode: 'DIFFERENT_RESULT' }), + decidedAt: '2026-08-01T12:00:02.000Z', + }, + )), 'POLICY_DECISION_CORRUPTION'); + assertKernelError(() => store.transaction((token) => repository.recordDecisionInTransaction( + token, + { + intentId: 'intent-1', + policyVersionId: 'policy-999', + evaluation, + decidedAt: '2026-08-01T12:00:02.000Z', + }, + )), 'POLICY_DECISION_CORRUPTION'); + assert.deepEqual(store.readOne('SELECT * FROM policy_decisions WHERE intent_id = ?', [ + 'intent-1', + ]), persistedFirst); + } finally { + store.close(); + } +}); + +test('PolicyDecision write rolls back with its caller-owned aggregate transaction', () => { + const store = openKernelStore({ filePath: ':memory:', allowMemory: true }); + try { + const repository = createPolicyRepository(store); + const applied = repository.apply(policyDocument(), '2026-08-01T12:00:00.000Z'); + const challenge = paymentRequired(); + const evaluation = evaluateSpendPolicy(evaluationInput({ + policyVersion: { + id: applied.policyVersion.id, + hash: applied.policyVersion.hash, + }, + paymentRequired: challenge, + })); + seedDecisionIntent(store, { + policyVersion: applied.policyVersion, + challengeHash: evaluation.challengeHash, + challengeProjection: projectPaymentRequired(challenge), + }); + const eventsBefore = store.events(); + + assertKernelError(() => store.transaction((token) => repository.recordDecisionInTransaction( + token, + { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation: Object.freeze({ + ...evaluation, + decision: 'deny', + reasonCode: 'WITHIN_AUTO_LIMIT', + }), + decidedAt: '2026-08-01T12:00:02.000Z', + }, + )), 'POLICY_DECISION_SCHEMA'); + const differentChallengeHash = sha256(canonicalJson({ different: true })); + assertKernelError(() => store.transaction((token) => repository.recordDecisionInTransaction( + token, + { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation: Object.freeze({ + ...evaluation, + challengeHash: differentChallengeHash, + quoteId: sha256(canonicalJson({ + challengeHash: differentChallengeHash, + acceptedIndex: evaluation.acceptedIndex, + })), + }), + decidedAt: '2026-08-01T12:00:02.000Z', + }, + )), 'POLICY_CHALLENGE_MISMATCH'); + + assert.throws(() => store.transaction((token) => { + repository.recordDecisionInTransaction(token, { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation, + decidedAt: '2026-08-01T12:00:02.000Z', + }); + throw new Error('aggregate fault'); + }), /aggregate fault/); + assert.equal(store.readOne( + 'SELECT * FROM policy_decisions WHERE intent_id = ?', ['intent-1'], + ), undefined); + assert.deepEqual(store.events(), eventsBefore); + } finally { + store.close(); + } +}); + +test('PolicyDecision writer accepts pure denials from live wallet identity mismatches', () => { + for (const wallet of [ + { network: 'eip155:1' }, + { address: '0x4000000000000000000000000000000000000000' }, + ]) { + const store = openKernelStore({ filePath: ':memory:', allowMemory: true }); + try { + const repository = createPolicyRepository(store); + const applied = repository.apply(policyDocument(), '2026-08-01T12:00:00.000Z'); + const challenge = paymentRequired(); + const evaluation = evaluateSpendPolicy(evaluationInput({ + policyVersion: { + id: applied.policyVersion.id, + hash: applied.policyVersion.hash, + }, + paymentRequired: challenge, + wallet, + })); + seedDecisionIntent(store, { + policyVersion: applied.policyVersion, + challengeHash: evaluation.challengeHash, + challengeProjection: projectPaymentRequired(challenge), + }); + + const persisted = store.transaction((token) => repository.recordDecisionInTransaction( + token, + { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation, + decidedAt: '2026-08-01T12:00:02.000Z', + }, + )); + assert.equal(persisted.decision, 'deny'); + assert.equal(persisted.reasonCode, evaluation.reasonCode); + } finally { + store.close(); + } + } +}); + +test('PolicyDecision writer binds persisted projection, selected candidate, and thresholds', () => { + const store = openKernelStore({ filePath: ':memory:', allowMemory: true }); + try { + const repository = createPolicyRepository(store); + const applied = repository.apply(policyDocument(), '2026-08-01T12:00:00.000Z'); + const challenge = paymentRequired({ + accepts: [offer({ scheme: 'subscription', amount: '1' }), offer()], + }); + const evaluation = evaluateSpendPolicy(evaluationInput({ + policyVersion: { + id: applied.policyVersion.id, + hash: applied.policyVersion.hash, + }, + paymentRequired: challenge, + })); + seedDecisionIntent(store, { + policyVersion: applied.policyVersion, + challengeHash: evaluation.challengeHash, + challengeProjection: projectPaymentRequired(challenge), + }); + + const assertRejected = (forged) => assertKernelError( + () => store.transaction((token) => repository.recordDecisionInTransaction(token, { + intentId: 'intent-1', + policyVersionId: applied.policyVersion.id, + evaluation: forged, + decidedAt: '2026-08-01T12:00:02.000Z', + })), + 'POLICY_DECISION_CORRUPTION', + ); + + assertRejected(Object.freeze({ + ...evaluation, + acceptedIndex: 9, + amountCeilingAtomic: '1', + quoteId: sha256(canonicalJson({ + challengeHash: evaluation.challengeHash, + acceptedIndex: 9, + })), + })); + assertRejected(Object.freeze({ + ...evaluation, + acceptedIndex: 0, + amountCeilingAtomic: '1', + quoteId: sha256(canonicalJson({ + challengeHash: evaluation.challengeHash, + acceptedIndex: 0, + })), + })); + assertRejected(Object.freeze({ + ...evaluation, + decision: 'approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + })); + assert.equal(store.readOne( + 'SELECT * FROM policy_decisions WHERE intent_id = ?', ['intent-1'], + ), undefined); + + store.transaction((token) => store.within(token, ({ db }) => db.prepare( + 'UPDATE spend_intents SET challenge_projection_json = ? WHERE id = ?', + ).run(canonicalJson({ tampered: true }), 'intent-1'))); + assertRejected(evaluation); + } finally { + store.close(); + } +}); From 80672cd7a13fecd4125a2de167ef958d01780214 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sat, 1 Aug 2026 22:53:36 -0400 Subject: [PATCH 163/165] feat: add offline wallet kernel spending controls --- .github/workflows/pi-wielder-systemd.yml | 63 + spikes/pi-wielder/.env.example | 53 +- spikes/pi-wielder/README.md | 134 +- spikes/pi-wielder/RUNBOOK.md | 485 +- .../systemd/wallet-kernel-console.socket | 12 + .../deploy/systemd/wallet-kernel.service | 38 + spikes/pi-wielder/operator-console/app.mjs | 458 ++ spikes/pi-wielder/operator-console/index.html | 112 + spikes/pi-wielder/operator-console/styles.css | 267 + spikes/pi-wielder/package.json | 13 +- .../pi-wielder/pi-extension/agent.env.example | 6 + spikes/pi-wielder/pi-extension/x402.ts | 735 ++- .../routes/base-sepolia.example.json | 29 + .../scripts/agent-isolation-probe-worker.mjs | 127 + .../scripts/build-release-manifest.mjs | 68 + .../scripts/inspect-systemd-effective.mjs | 224 + .../lib/spend-control-process-runner.mjs | 2267 +++++++ .../scripts/preflight-agent-isolation.mjs | 185 + .../scripts/preflight-live-deployment.mjs | 168 + .../scripts/prelaunch-kernel-reader.mjs | 204 + .../scripts/render-systemd-units.mjs | 192 + spikes/pi-wielder/scripts/run-evidence.mjs | 507 ++ .../pi-wielder/scripts/run-testnet-agent.mjs | 717 +++ spikes/pi-wielder/scripts/verify-evidence.mjs | 48 + .../scripts/verify-no-tracked-secrets.mjs | 274 + spikes/pi-wielder/spend-control-e2e.mjs | 31 + .../src/adapters/base-sepolia-observer.mjs | 1047 ++++ .../src/adapters/cdp-wallet-adapter.mjs | 164 + .../adapters/deterministic-wallet-adapter.mjs | 63 + .../pi-wielder/src/adapters/eip3009-exact.mjs | 91 + .../src/adapters/seller-evidence-resolver.mjs | 701 +++ .../src/adapters/wallet-adapter-contract.mjs | 643 ++ .../src/adapters/x402-v2-transport.mjs | 836 +++ spikes/pi-wielder/src/agent/auth.mjs | 413 ++ .../pi-wielder/src/agent/credential-cli.mjs | 106 + spikes/pi-wielder/src/agent/credential.mjs | 352 ++ .../src/agent/isolation-preflight.mjs | 341 ++ spikes/pi-wielder/src/config.mjs | 865 +++ spikes/pi-wielder/src/control-plane.mjs | 1372 +++++ spikes/pi-wielder/src/evidence-bundle.mjs | 1370 +++++ spikes/pi-wielder/src/invocation-journal.mjs | 92 +- .../src/kernel/agent-enrollment.mjs | 522 ++ .../pi-wielder/src/kernel/approval-queue.mjs | 1343 ++++ .../kernel/authority-mutation-coordinator.mjs | 116 + .../src/kernel/authorized-permit.mjs | 373 ++ .../pi-wielder/src/kernel/budget-ledger.mjs | 3304 ++++++++++ .../pi-wielder/src/kernel/intent-builder.mjs | 2277 +++++++ .../src/kernel/projection-exporter.mjs | 1888 ++++++ .../pi-wielder/src/kernel/receipt-signing.mjs | 158 + spikes/pi-wielder/src/kernel/recovery.mjs | 5406 +++++++++++++++++ .../src/kernel/release-integrity.mjs | 524 ++ .../pi-wielder/src/kernel/secure-storage.mjs | 67 +- .../pi-wielder/src/kernel/signed-receipts.mjs | 1514 +++++ spikes/pi-wielder/src/kernel/trusted-path.mjs | 82 +- .../pi-wielder/src/kernel/wallet-kernel.mjs | 2253 +++++++ spikes/pi-wielder/src/offline-bootstrap.mjs | 731 +++ spikes/pi-wielder/src/operator/api.mjs | 855 +++ spikes/pi-wielder/src/operator/auth.mjs | 467 ++ spikes/pi-wielder/src/operator/cli.mjs | 1037 ++++ spikes/pi-wielder/src/operator/console.mjs | 68 + spikes/pi-wielder/src/spend-control-proxy.mjs | 1115 ++++ spikes/pi-wielder/tests/agent-auth.test.mjs | 380 ++ .../tests/agent-credential-cli.test.mjs | 131 + .../tests/agent-credential.test.mjs | 283 + .../pi-wielder/tests/agent-isolation.test.mjs | 381 ++ .../tests/base-sepolia-observer.test.mjs | 827 +++ spikes/pi-wielder/tests/config.test.mjs | 779 +++ .../pi-wielder/tests/control-plane.test.mjs | 1075 ++++ .../pi-wielder/tests/eip3009-exact.test.mjs | 477 ++ .../pi-wielder/tests/evidence-bundle.test.mjs | 862 +++ .../pi-wielder/tests/evidence-runner.test.mjs | 393 ++ .../tests/fixtures/budget-writer.mjs | 49 + .../tests/fixtures/control-plane-process.mjs | 941 +++ .../tests/fixtures/kernel-crash-worker.mjs | 371 ++ .../tests/fixtures/loopback-only-preload.cjs | 137 + .../tests/fixtures/pi-client-process.mjs | 227 + .../tests/fixtures/pi-model-process.mjs | 201 + .../tests/fixtures/x402-v2-resource.mjs | 141 + .../tests/fixtures/x402-v2-seller-process.mjs | 579 ++ .../tests/kernel-agent-enrollment.test.mjs | 527 ++ .../tests/kernel-approvals.test.mjs | 1081 ++++ .../kernel-authority-coordinator.test.mjs | 449 ++ .../pi-wielder/tests/kernel-budget.test.mjs | 3185 ++++++++++ .../pi-wielder/tests/kernel-intent.test.mjs | 2060 +++++++ .../pi-wielder/tests/kernel-permit.test.mjs | 533 ++ .../pi-wielder/tests/kernel-receipts.test.mjs | 2748 +++++++++ .../tests/kernel-reconciliation.test.mjs | 1654 +++++ .../pi-wielder/tests/kernel-recovery.test.mjs | 1981 ++++++ .../pi-wielder/tests/kernel-restart.test.mjs | 521 ++ .../tests/kernel-trusted-path.test.mjs | 34 +- .../tests/no-tracked-secrets.test.mjs | 146 + .../tests/offline-bootstrap.test.mjs | 513 ++ spikes/pi-wielder/tests/operator-api.test.mjs | 1123 ++++ .../pi-wielder/tests/operator-auth.test.mjs | 500 ++ spikes/pi-wielder/tests/operator-cli.test.mjs | 721 +++ .../tests/operator-console.test.mjs | 140 + .../tests/pi-extension-contract.test.mjs | 613 +- .../tests/projection-exporter.test.mjs | 1506 +++++ .../tests/release-integrity.test.mjs | 238 + .../tests/seller-evidence-resolver.test.mjs | 954 +++ .../tests/spend-control-process-e2e.test.mjs | 213 + .../tests/spend-control-proxy.test.mjs | 1792 ++++++ .../pi-wielder/tests/systemd-units.test.mjs | 247 + .../tests/testnet-agent-runner.test.mjs | 383 ++ .../tests/wallet-adapter-cdp.test.mjs | 333 + .../tests/wallet-adapter-contract.test.mjs | 1157 ++++ .../wallet-adapter-deterministic.test.mjs | 566 ++ .../pi-wielder/tests/wallet-kernel.test.mjs | 4189 +++++++++++++ .../tests/x402-v2-transport.test.mjs | 753 +++ 109 files changed, 78800 insertions(+), 267 deletions(-) create mode 100644 .github/workflows/pi-wielder-systemd.yml create mode 100644 spikes/pi-wielder/deploy/systemd/wallet-kernel-console.socket create mode 100644 spikes/pi-wielder/deploy/systemd/wallet-kernel.service create mode 100644 spikes/pi-wielder/operator-console/app.mjs create mode 100644 spikes/pi-wielder/operator-console/index.html create mode 100644 spikes/pi-wielder/operator-console/styles.css create mode 100644 spikes/pi-wielder/pi-extension/agent.env.example create mode 100644 spikes/pi-wielder/routes/base-sepolia.example.json create mode 100644 spikes/pi-wielder/scripts/agent-isolation-probe-worker.mjs create mode 100644 spikes/pi-wielder/scripts/build-release-manifest.mjs create mode 100644 spikes/pi-wielder/scripts/inspect-systemd-effective.mjs create mode 100644 spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs create mode 100644 spikes/pi-wielder/scripts/preflight-agent-isolation.mjs create mode 100644 spikes/pi-wielder/scripts/preflight-live-deployment.mjs create mode 100644 spikes/pi-wielder/scripts/prelaunch-kernel-reader.mjs create mode 100644 spikes/pi-wielder/scripts/render-systemd-units.mjs create mode 100644 spikes/pi-wielder/scripts/run-evidence.mjs create mode 100644 spikes/pi-wielder/scripts/run-testnet-agent.mjs create mode 100644 spikes/pi-wielder/scripts/verify-evidence.mjs create mode 100644 spikes/pi-wielder/scripts/verify-no-tracked-secrets.mjs create mode 100644 spikes/pi-wielder/spend-control-e2e.mjs create mode 100644 spikes/pi-wielder/src/adapters/base-sepolia-observer.mjs create mode 100644 spikes/pi-wielder/src/adapters/cdp-wallet-adapter.mjs create mode 100644 spikes/pi-wielder/src/adapters/deterministic-wallet-adapter.mjs create mode 100644 spikes/pi-wielder/src/adapters/eip3009-exact.mjs create mode 100644 spikes/pi-wielder/src/adapters/seller-evidence-resolver.mjs create mode 100644 spikes/pi-wielder/src/adapters/wallet-adapter-contract.mjs create mode 100644 spikes/pi-wielder/src/adapters/x402-v2-transport.mjs create mode 100644 spikes/pi-wielder/src/agent/auth.mjs create mode 100644 spikes/pi-wielder/src/agent/credential-cli.mjs create mode 100644 spikes/pi-wielder/src/agent/credential.mjs create mode 100644 spikes/pi-wielder/src/agent/isolation-preflight.mjs create mode 100644 spikes/pi-wielder/src/config.mjs create mode 100644 spikes/pi-wielder/src/control-plane.mjs create mode 100644 spikes/pi-wielder/src/evidence-bundle.mjs create mode 100644 spikes/pi-wielder/src/kernel/agent-enrollment.mjs create mode 100644 spikes/pi-wielder/src/kernel/approval-queue.mjs create mode 100644 spikes/pi-wielder/src/kernel/authority-mutation-coordinator.mjs create mode 100644 spikes/pi-wielder/src/kernel/authorized-permit.mjs create mode 100644 spikes/pi-wielder/src/kernel/budget-ledger.mjs create mode 100644 spikes/pi-wielder/src/kernel/intent-builder.mjs create mode 100644 spikes/pi-wielder/src/kernel/projection-exporter.mjs create mode 100644 spikes/pi-wielder/src/kernel/receipt-signing.mjs create mode 100644 spikes/pi-wielder/src/kernel/recovery.mjs create mode 100644 spikes/pi-wielder/src/kernel/release-integrity.mjs create mode 100644 spikes/pi-wielder/src/kernel/signed-receipts.mjs create mode 100644 spikes/pi-wielder/src/kernel/wallet-kernel.mjs create mode 100644 spikes/pi-wielder/src/offline-bootstrap.mjs create mode 100644 spikes/pi-wielder/src/operator/api.mjs create mode 100644 spikes/pi-wielder/src/operator/auth.mjs create mode 100644 spikes/pi-wielder/src/operator/cli.mjs create mode 100644 spikes/pi-wielder/src/operator/console.mjs create mode 100644 spikes/pi-wielder/src/spend-control-proxy.mjs create mode 100644 spikes/pi-wielder/tests/agent-auth.test.mjs create mode 100644 spikes/pi-wielder/tests/agent-credential-cli.test.mjs create mode 100644 spikes/pi-wielder/tests/agent-credential.test.mjs create mode 100644 spikes/pi-wielder/tests/agent-isolation.test.mjs create mode 100644 spikes/pi-wielder/tests/base-sepolia-observer.test.mjs create mode 100644 spikes/pi-wielder/tests/config.test.mjs create mode 100644 spikes/pi-wielder/tests/control-plane.test.mjs create mode 100644 spikes/pi-wielder/tests/eip3009-exact.test.mjs create mode 100644 spikes/pi-wielder/tests/evidence-bundle.test.mjs create mode 100644 spikes/pi-wielder/tests/evidence-runner.test.mjs create mode 100644 spikes/pi-wielder/tests/fixtures/budget-writer.mjs create mode 100644 spikes/pi-wielder/tests/fixtures/control-plane-process.mjs create mode 100644 spikes/pi-wielder/tests/fixtures/kernel-crash-worker.mjs create mode 100644 spikes/pi-wielder/tests/fixtures/loopback-only-preload.cjs create mode 100644 spikes/pi-wielder/tests/fixtures/pi-client-process.mjs create mode 100644 spikes/pi-wielder/tests/fixtures/pi-model-process.mjs create mode 100644 spikes/pi-wielder/tests/fixtures/x402-v2-resource.mjs create mode 100644 spikes/pi-wielder/tests/fixtures/x402-v2-seller-process.mjs create mode 100644 spikes/pi-wielder/tests/kernel-agent-enrollment.test.mjs create mode 100644 spikes/pi-wielder/tests/kernel-approvals.test.mjs create mode 100644 spikes/pi-wielder/tests/kernel-authority-coordinator.test.mjs create mode 100644 spikes/pi-wielder/tests/kernel-budget.test.mjs create mode 100644 spikes/pi-wielder/tests/kernel-intent.test.mjs create mode 100644 spikes/pi-wielder/tests/kernel-permit.test.mjs create mode 100644 spikes/pi-wielder/tests/kernel-receipts.test.mjs create mode 100644 spikes/pi-wielder/tests/kernel-reconciliation.test.mjs create mode 100644 spikes/pi-wielder/tests/kernel-recovery.test.mjs create mode 100644 spikes/pi-wielder/tests/kernel-restart.test.mjs create mode 100644 spikes/pi-wielder/tests/no-tracked-secrets.test.mjs create mode 100644 spikes/pi-wielder/tests/offline-bootstrap.test.mjs create mode 100644 spikes/pi-wielder/tests/operator-api.test.mjs create mode 100644 spikes/pi-wielder/tests/operator-auth.test.mjs create mode 100644 spikes/pi-wielder/tests/operator-cli.test.mjs create mode 100644 spikes/pi-wielder/tests/operator-console.test.mjs create mode 100644 spikes/pi-wielder/tests/projection-exporter.test.mjs create mode 100644 spikes/pi-wielder/tests/release-integrity.test.mjs create mode 100644 spikes/pi-wielder/tests/seller-evidence-resolver.test.mjs create mode 100644 spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs create mode 100644 spikes/pi-wielder/tests/spend-control-proxy.test.mjs create mode 100644 spikes/pi-wielder/tests/systemd-units.test.mjs create mode 100644 spikes/pi-wielder/tests/testnet-agent-runner.test.mjs create mode 100644 spikes/pi-wielder/tests/wallet-adapter-cdp.test.mjs create mode 100644 spikes/pi-wielder/tests/wallet-adapter-contract.test.mjs create mode 100644 spikes/pi-wielder/tests/wallet-adapter-deterministic.test.mjs create mode 100644 spikes/pi-wielder/tests/wallet-kernel.test.mjs create mode 100644 spikes/pi-wielder/tests/x402-v2-transport.test.mjs diff --git a/.github/workflows/pi-wielder-systemd.yml b/.github/workflows/pi-wielder-systemd.yml new file mode 100644 index 0000000..f9ec562 --- /dev/null +++ b/.github/workflows/pi-wielder-systemd.yml @@ -0,0 +1,63 @@ +name: Pi Wielder Linux isolation and blocked live-launch contract + +on: + pull_request: + paths: + - 'spikes/pi-wielder/**' + - '.github/workflows/pi-wielder-systemd.yml' + push: + branches: [main] + paths: + - 'spikes/pi-wielder/**' + - '.github/workflows/pi-wielder-systemd.yml' + +permissions: + contents: read + +jobs: + systemd-isolation: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + defaults: + run: + working-directory: spikes/pi-wielder + steps: + - name: Check out reviewed source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Install exact Node runtime + uses: actions/setup-node@1e60f620b9541dca1515407b8e2b6c3026562c9e + with: + node-version: 24.18.1 + cache: npm + cache-dependency-path: spikes/pi-wielder/package-lock.json + + - name: Install locked dependencies + run: npm ci + + - name: Verify secret-free systemd contract and explicit live-launch blockers + shell: bash + run: | + set -euo pipefail + kernel_user="wallet-kernel-ci-${GITHUB_RUN_ID}" + agent_user="wallet-agent-ci-${GITHUB_RUN_ID}" + cleanup() { + sudo userdel --remove "$agent_user" 2>/dev/null || true + sudo userdel --remove "$kernel_user" 2>/dev/null || true + } + trap cleanup EXIT + sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin "$kernel_user" + sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin "$agent_user" + kernel_uid="$(id -u "$kernel_user")" + kernel_gid="$(id -g "$kernel_user")" + agent_uid="$(id -u "$agent_user")" + agent_gid="$(id -g "$agent_user")" + node_bin="$(dirname "$(command -v node)")" + sudo -- /usr/bin/env -i \ + PATH="$node_bin:/usr/bin:/bin" \ + WALLET_KERNEL_SYSTEMD_INTEGRATION=1 \ + WALLET_KERNEL_TEST_KERNEL_UID="$kernel_uid" \ + WALLET_KERNEL_TEST_KERNEL_GID="$kernel_gid" \ + WALLET_KERNEL_TEST_AGENT_UID="$agent_uid" \ + WALLET_KERNEL_TEST_AGENT_GID="$agent_gid" \ + npm run test:systemd diff --git a/spikes/pi-wielder/.env.example b/spikes/pi-wielder/.env.example index ece4914..7009450 100644 --- a/spikes/pi-wielder/.env.example +++ b/spikes/pi-wielder/.env.example @@ -1,9 +1,15 @@ # Pi-Wielder spike — environment template. Copy to .env and fill in. # NEVER commit a real .env (the repo root .gitignore already excludes it). # Mock mode (`npm run e2e`) needs NONE of these. +# Deterministic development requires Node 24.18.1+; an attested live release +# requires Linux/systemd and exactly Node 24.18.1. +# This is a developer inventory, not a production secret-delivery mechanism. +# The production listener/secret-loader composition is intentionally blocked by +# LIVE_LAUNCH_NOT_READY (exit 78). No value below bypasses that gate. # --- the Wielder wallet (testnet ONLY — never a mainnet key) ----------------- -# 0x-prefixed private key of a throwaway Base Sepolia account. +# Legacy Collar demo only: 0x-prefixed private key of a throwaway Base Sepolia +# account. The Agent Spend Control Plane does not accept or use PRIVATE_KEY. # Fund only for a separately reviewed Base Sepolia integration; mock mode needs none. PRIVATE_KEY= @@ -64,6 +70,13 @@ LEDGER_FILE= # --- pricing (USDC per call) -------------------------------------------------- SKILL_PRICE_USDC=0.25 +# The trusted ancestor and every path below must be canonical and non-symlinked. +# In live mode, mutable authority/evidence/runtime roots stay outside the release +# as Kernel-owned 0700 directories with private 0600 files. The policy seed and +# fixed route map are instead root-owned, single-link files inside the verified +# immutable release tree. +# The Operator token is an independent owner bearer. Never place its value here; +# this variable names only its owner-only file. # --- Agent Spend Control Plane local authority (absolute, outside checkout) -- WALLET_KERNEL_DB_FILE= WALLET_KERNEL_RECEIPT_KEY_FILE= @@ -75,3 +88,41 @@ WALLET_KERNEL_POLICY_FILE= WALLET_KERNEL_ROUTE_FILE= WALLET_KERNEL_PORT=8402 WALLET_KERNEL_OPERATOR_PORT=8405 + +# --- Agent Spend Control Plane deployment mode ------------------------------ +# Accepted values: deterministic or cdp-testnet. The cdp-testnet mode is pinned +# to Base Sepolia; there is deliberately no mainnet mode. +# deterministic is offline/simulated only. cdp-testnet has not been run and its +# installed launch path currently exits 78 with LIVE_LAUNCH_NOT_READY. +WALLET_KERNEL_MODE=deterministic +# Live cdp-testnet requires a Kernel-owned 0600 Unix admin socket and distinct +# non-root Kernel/Pi UIDs. The two 0755 handoff parents are directional: +# enrollment inbox is Pi-owned; Agent-run outbox is Kernel-owned. +WALLET_KERNEL_OPERATOR_SOCKET_FILE= +WALLET_KERNEL_ENROLLMENT_INBOX= +WALLET_KERNEL_AGENT_RUN_OUTBOX= +# Release files are root-owned and immutable. Evidence is written only to a +# separate Kernel-owned 0700 root, never to this checkout or release tree. +WALLET_KERNEL_RELEASE_ROOT= +WALLET_KERNEL_RELEASE_MANIFEST= +WALLET_KERNEL_SERVICE_DEFINITION_FILE= +WALLET_KERNEL_SOCKET_DEFINITION_FILE= +WALLET_KERNEL_ENV_FILE= +WALLET_KERNEL_EVIDENCE_ROOT= +WALLET_KERNEL_ISOLATION_REPORT_FILE= +# CDP values are Kernel-only testnet credentials. Provision through a reviewed +# owner-only secret mechanism without values in shell history, unit files, +# manifests, logs, evidence, or tracked files. Root preflight receives only the +# WALLET_KERNEL_ENV_FILE path and must not receive or read these values. +CDP_API_KEY_ID= +CDP_API_KEY_SECRET= +CDP_WALLET_SECRET= +CDP_WALLET_NAME= +# Customer-supplied read-only observation endpoint. It is never used for +# funding, signing, or sending transactions and may contain a secret path/query. +WALLET_KERNEL_BASE_SEPOLIA_RPC_URL= + +# The raw Pi Agent credential is intentionally absent. It belongs in a Pi-owned +# 0700 parent as a 0600 file and is configured only in +# pi-extension/agent.env.example. The Kernel must receive EACCES when traversing +# that parent; only the non-secret enrollment descriptor crosses the handoff. diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 94c6ccd..346099e 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -1,14 +1,59 @@ -# Pi-Wielder spike — Collar-authoritative Invocations +# Pi-Wielder spike - Agent Spend Control Plane Design context: [Pi-Wielder design](../../docs/plans/2026-07-11-reframe-and-pi-wielder-design.md). -This is an executable design spike, not a production payment service. Its automated -proof is fully offline: an unfunded throwaway wallet signs x402 authorizations, an -injected mock verifies the signatures and synthesizes settlement, and canned model -responses avoid external APIs. Mock execution is fail-closed and remains the default -when `MOCK_LLM` is unset. +This is an executable design spike, not a production payment or custody service. Pi is +the **Wielder**. The Agent and Operator are local control-plane security roles: the +Agent may ask the Wallet Kernel to use an operator-approved route, while the Operator +sets policy, decides approvals, reconciles ambiguous outcomes, and reviews signed +receipts. Pi never receives the wallet signer, CDP credentials, operator bearer, or a +caller-selected destination capability. + +The directory retains the earlier Collar-authoritative seller proof and now adds a +durable Wielder-side Wallet Kernel around it. The Kernel authenticates one enrolled Pi +instance, binds it to one Spend Session, evaluates immutable PolicyVersions, enforces +per-request, per-seller-session, session, and rolling-24-hour ceilings, persists intent +state before signing or retrying, and emits signed receipt revisions. The route map — +root-owned and manifest-covered in live mode — not Pi, selects the method and upstream +seller. + +## Current spend-control status + +| Mode | What has been exercised | Honest status | +|---|---|---| +| `offline-deterministic` | Real local child processes, SQLite authority, policy/approval paths, restart recovery, deterministic x402 settlement, Pi adapter path, signed per-session projections, and recomputable evidence | **Measured offline**; wallet, deployment, and OS isolation are explicitly simulated. No external provider or chain is contacted. | +| `cdp-testnet` | Closed configuration, CDP adapter, Base Sepolia observer, release-integrity, systemd, isolation, run-intent, and evidence gates have offline tests | **Not run.** The production listener/secret-delivery composition and Linux lifecycle evidence are incomplete. The installed launch path deliberately stops with `LIVE_LAUNCH_NOT_READY` (exit 78). | +| Mainnet | No mode, route, deployment, or evidence path exists | **Out of scope and unsupported.** No automated funding, mainnet transaction, or real-funds operation is allowed. | + +An offline pass is not evidence of live isolation, CDP payment, wallet funding, or a +Base Sepolia transaction. The current implementation must not be used to reframe public +website claims until a fresh, human-authorized, externally anchored testnet evidence +bundle qualifies. Historical network results remain separately labeled below. +`run-evidence.mjs --mode base-sepolia-testnet` currently exits 2 with +`EVIDENCE_TESTNET_NOT_RUN`; it does not construct a real wallet adapter. + +The control boundary is: -## Accounting authority +```text +Pi (Wielder; Pi-owned Agent credential) + | + v +loopback Agent API -> fixed route map -> Wallet Kernel -> CDP wallet adapter + | (testnet only) + +-> SQLite authority + budgets + +-> approval queue + +-> signed receipts/session projections + +Operator CLI -> owner-only bearer -> Unix admin socket in live design +Operator console -> root socket-activated loopback listener -> one-time fragment +``` + +The automated proof is fully offline: an unfunded throwaway wallet signs deterministic +x402 authorizations, injected adapters verify and synthesize settlement, and canned +model responses avoid external APIs. Mock execution is fail-closed and remains the +default when `MOCK_LLM` is unset. + +## Legacy Collar accounting authority The Collar's append-only Invocation journal is authoritative for hosted Skill Invocations. Payment and execution are independent state machines, so a settled @@ -44,7 +89,7 @@ Mock transaction hashes and timings are synthetic protocol evidence. They are no evidence of live funds, mainnet readiness, production custody, distributed locking, or durable production key management. -## Wielder payment policy +## Legacy one-process Wielder payment policy The Wielder does not accept the first x402 offer blindly. Before signing, it requires the exact Base Sepolia network (`84532`) and Base Sepolia USDC contract, a canonical @@ -95,7 +140,7 @@ cross-restart budget guarantee. A durable deployment must persist and replay sig authorizations, reject nonce and transaction reuse across workers, and reconcile every unresolved reservation before it can advertise such a guarantee. -## What the offline proof demonstrates +## What the legacy offline proof demonstrates `npm run e2e` exercises one wallet across two paid asset classes without opening a socket: @@ -156,8 +201,14 @@ The quoted execution catalog is immutable and versioned. Its initial rates are l ## Architecture +The new Wielder-side control plane is composed from `src/control-plane.mjs`. It keeps +Agent admission, Operator authority, policy decisions, wallet signing, and public +projections as separate capabilities. The deterministic acceptance runner supplies the +complete offline dependency graph. The direct production entrypoint refuses to invent +that graph; live launch remains blocked as described above. + ```text -Pi or another HTTP client +Pi or another HTTP client (legacy Collar demonstration) │ ▼ src/proxy.mjs — Wielder wallet + paying fetch + local receipt view @@ -174,22 +225,50 @@ Both sellers use an explicitly constructed facilitator transport. Offline tests inject src/facilitator-mock.mjs; no arbitrary URL is accepted. ``` -The proxy demonstrates the wallet-bound HTTP 402 transport shape contemplated by -ADR-0008 plus a conservative one-process payment policy, but it contains no Story SDK, -token custody, or Royalty calculator. It is not proof of the complete protocol, -cross-process spend enforcement, or production readiness. +The legacy proxy demonstrates the wallet-bound HTTP 402 transport shape contemplated by +ADR-0008 plus a conservative one-process payment policy. The Wallet Kernel adds durable +cross-restart spend authority for the Pi Agent, but neither path contains a Story SDK, +token custody product, hosted policy authority, or Royalty calculator. This spike is not +proof of the complete protocol or production readiness. ## Run the verified path +Use a POSIX host and Node 24.18.1 or newer for deterministic development. The future +attested live release is stricter: it requires Linux/systemd and exactly Node 24.18.1. +From this directory, install the lockfile exactly and run the complete offline story: + +```bash +npm ci +npm run verify:spend-control +``` + +The acceptance path starts real local child processes where the host permits loopback +listeners. Results are still **measured offline**: the wallet adapter and settlement are +deterministic, and identity/deployment isolation is marked simulated. A sandbox that +forbids local listeners may report those process checks as skipped; a skip is not a +passing live-host result. + +Generate a fresh sanitized offline evidence bundle and keep its manifest digest outside +the bundle: + ```bash -npm install -npm test -npm run e2e +evidence_parent="$(mktemp -d /tmp/pi-wielder-evidence.XXXXXX)" +evidence_parent="$(cd "$evidence_parent" && pwd -P)" +npm run evidence:offline -- \ + --output "$evidence_parent/bundle" \ + --anchor-output "$evidence_parent/manifest.sha256" +manifest_sha256="$(tr -d '\n' < "$evidence_parent/manifest.sha256")" +npm run evidence:verify -- "$evidence_parent/bundle" \ + --expect-manifest-sha256 "$manifest_sha256" +npm run verify:no-secrets ``` -Expected current results are 235 offline unit/integration tests and 41 offline e2e -checks. Counts can increase as regressions are added; zero failures is the contract. -The e2e labels all timing output synthetic and uses in-process Hono requests only. +The bundle contains exactly `manifest.json`, `events.jsonl`, `summary.json`, +`report.md`, and `README.md`. Verification requires the external manifest hash, +recomputes normalized events, verifies every per-session projection signature, and +requires the signed receipts to partition exactly once across those projections. It +never treats a hash read from inside the bundle as its trust anchor. Offline evidence records +`liveCdp`, `walletFunded`, and `testnetTransaction` as `not-run`. Focused commands: @@ -200,6 +279,8 @@ npm run test:proxy npm run test:policy npm run test:payment npm run test:economics +npm run test:kernel +npm run test:systemd ``` For standalone mock processes, persistent trust bootstrapping, and the intentionally @@ -220,6 +301,14 @@ blocked live boundary, see [RUNBOOK.md](./RUNBOOK.md). | `src/facilitator-mock.mjs` | Offline signature verification plus synthetic settlement | | `pi-extension/x402.ts` | Manual Pi adapter for provider, Skill tool, and `/ledger` view | | `e2e.mjs` | Fully in-process offline proof | +| `src/control-plane.mjs` | Wallet Kernel composition boundary for Agent and Operator apps; direct live composition fails closed | +| `src/kernel/wallet-kernel.mjs` | Coordinated intent, policy, approval, budget, payment, execution, reconciliation, and receipt authority | +| `src/spend-control-proxy.mjs` | Agent-authenticated fixed-route API used by Pi; no caller-selected destination or wallet capability | +| `src/operator/` | Owner-authenticated local API, CLI, and one-time browser-console launch flow | +| `src/adapters/` | Deterministic test wallet plus CDP testnet wallet and read-only Base Sepolia observation boundaries | +| `scripts/lib/spend-control-process-runner.mjs` | Reused real-process deterministic acceptance runner | +| `src/evidence-bundle.mjs` and `scripts/verify-evidence.mjs` | Sanitized five-file evidence builder and externally anchored independent verifier | +| `deploy/systemd/` and `scripts/preflight-live-deployment.mjs` | Root-owned deployment contract and explicit `LIVE_LAUNCH_NOT_READY` live gate | ## Security and operational boundaries @@ -287,8 +376,9 @@ detail. not a distributed consensus mechanism. - The proxy trusts an operator-pinned public key file and one SHA-256 key ID of its SPKI DER. A key ID or key embedded in a receipt cannot authenticate that receipt. -- The Pi extension is a manual demo adapter and is not type-compiled by this spike; - an offline source-contract test pins its fixed Skill route and tool schema. +- The Pi extension is pinned to Pi `0.80.6`; offline tests import its TypeScript, + exercise the real five-argument tool ABI, and pin fixed model/Skill routes plus + same-key retry behavior. Installing it into a Pi host remains manual. - Successful mock accounting records synthetic-config provider usage and allocates execution COGS and settlement cost before the Royalty pool. It is executable evidence of ordering and conservation, not a validated production margin model or current diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md index 6ac8d12..03e8498 100644 --- a/spikes/pi-wielder/RUNBOOK.md +++ b/spikes/pi-wielder/RUNBOOK.md @@ -1,21 +1,64 @@ -# RUNBOOK — offline verification and Collar trust +# RUNBOOK - Pi Wielder Agent Spend Control Plane -The supported automated workflow is offline and in-process. No funded wallet, API key, -listener, or live facilitator is needed. +The supported automated workflow is deterministic and offline. It covers both the +legacy Collar proof and the newer Wallet Kernel process-acceptance path. No funded +wallet, provider key, CDP credential, or live facilitator is needed. Pi is the Wielder; +Agent and Operator name local control-plane security roles. + +Current release status is intentionally asymmetric: + +- deterministic verification and evidence are **measured offline**; wallet settlement, + deployment, and cross-UID isolation are simulated; +- live CDP and Base Sepolia payment are **not-run**; +- Linux/systemd launch is **blocked** by `LIVE_LAUNCH_NOT_READY` (exit 78) until the + production preflight/control-plane composition, secret delivery, compatible listener + adapters, and Linux lifecycle evidence exist; +- there is no mainnet mode, automated funding, custody service, or real-funds workflow. + +Do not interpret a local macOS pass, a deterministic adapter, or a skipped systemd test +as evidence that the live host boundary works. ## 1. Run the verified workflow -From `spikes/pi-wielder`: +Requirements: + +- a POSIX host; +- Node 24.18.1 or newer for deterministic development; +- the package-lock-installed dependency tree (`npm ci`, not an unconstrained update). + +The future attested live release is narrower: Linux with systemd and exactly Node +24.18.1. From `spikes/pi-wielder`: + +```bash +npm ci +npm run verify:spend-control +``` + +The legacy unit/integration path uses injected Hono apps. The spend-control acceptance +path starts real loopback child processes when the host sandbox permits it. Both use an +unfunded deterministic wallet, canned model output, signed local receipts, and +synthetic settlement. Timing and isolation output are explicitly offline/simulated. + +Create and independently verify a fresh offline evidence bundle: ```bash -npm install -npm test -npm run e2e +evidence_parent="$(mktemp -d /tmp/pi-wielder-evidence.XXXXXX)" +evidence_parent="$(cd "$evidence_parent" && pwd -P)" +npm run evidence:offline -- \ + --output "$evidence_parent/bundle" \ + --anchor-output "$evidence_parent/manifest.sha256" +manifest_sha256="$(tr -d '\n' < "$evidence_parent/manifest.sha256")" +npm run evidence:verify -- "$evidence_parent/bundle" \ + --expect-manifest-sha256 "$manifest_sha256" +npm run verify:no-secrets ``` -The unit/integration suite uses injected Hono apps. The e2e uses one unfunded throwaway -wallet, canned model output, an ephemeral receipt signer, and synthetic settlement. -Timing output is explicitly synthetic. +Keep `manifest.sha256` outside the bundle. The verifier requires that external value, +hashes the exact manifest bytes, replays the normalized evidence chain, and verifies +the canonical set of signed per-session authority projections and exact receipt +partition/revision history. It does not open SQLite, call a +network, or claim to reconstruct redacted private event data. The bundle and anchor +paths are exclusive-create; generate a new directory for every run. ## 2. Run standalone processes in mock mode @@ -258,19 +301,25 @@ testnet-only wallet. No such live run was performed during this remediation. This fail-closed boundary is expected behavior, not a setup bug. -## 6. Manual Pi adapter +## 6. Pi adapter -The Pi extension is optional and not compiled in CI: +The extension is pinned to Pi `0.80.6`. Automated contract tests import the TypeScript +module with Node's type stripping and invoke Pi's real five-argument tool ABI. Copying +it into a Pi installation remains a manual host step: ```bash mkdir -p .pi/extensions cp /spikes/pi-wielder/pi-extension/x402.ts .pi/extensions/ ``` -With the three standalone mock processes running, start the compatible Pi version and -reload extensions. The extension points model calls and `invoke_skill` at the local -proxy and renders the signed receipt bundle shape. `/ledger` shows the proxy's local -view. Verify the extension API against the installed Pi version before a demo. +With the Wallet Kernel's loopback Agent API running, start the exact compatible Pi +version and reload extensions. The extension points model calls and `invoke_skill` at +fixed local routes. Model retries retain one logical call ID until success or final +failure. The tool uses its real `toolCallId` to derive a stable call ID and performs at +most one same-key retry after a transport/read failure. A completed replay returns a +non-success JSON envelope: the charge and signed receipt remain available, but provider +output is not fabricated or retained. `npm run e2e:spend-control` exercises this path +fully offline without external providers or chain access. ## 7. Manual-only boundaries @@ -282,3 +331,407 @@ view. Verify the extension API against the installed Pi version before a demo. Secure card, billing, private-key, and wallet-funding details do not belong in chat, tracked files, receipts, or logs. + +## 8. Wallet Kernel operating model + +The Wallet Kernel is the Wielder-side spending authority. Pi receives only a Pi-owned +Agent credential and the loopback Agent API. It cannot choose the wallet, target URL, +HTTP method, seller, payee, amount, policy, Spend Session, approval ID, payment +idempotency key, or payment header. Those values come from the active PolicyVersion, +fixed route map, durable intent, and wallet adapter. Pi must provide a separate +32-byte `x-agent-call-id`; it grants no spend authority, never reaches the seller, and +is bound by the Kernel to the exact session, route, method, body, allowlisted headers, +and purpose. Reusing it for the same completed call cannot pay again; reusing it for a +different request fails with `CORRELATION_CONFLICT`. + +The policy decision is made before signing and enforces all of these independent +limits: + +- the seller's per-request maximum; +- the seller's exposure maximum within the Spend Session; +- the Wielder's whole-session exposure maximum; +- the Wielder's rolling-24-hour exposure maximum. + +Amounts at or below `autoApproveAtomic` may proceed automatically. Amounts above that +threshold and at or below `humanApproveAtomic` enter the bounded approval queue. All +other requests fail closed. A signature is never recreated for a retry: the exact +authorization and payment bytes are persisted before the one paid attempt. Ambiguous +payment or execution outcomes continue to consume budget until trusted reconciliation +resolves them. + +There are two local principals: + +- **Agent:** the Pi process acting as the Wielder. It authenticates only to the narrow + Agent API with `WalletKernelAgent`; it cannot use Operator routes. +- **Operator:** the wallet owner. The Operator owns an independent bearer and uses the + offline bootstrap CLI or the authenticated local admin plane. Operator responses are + closed public projections and do not contain raw credentials, prompts, bodies, + payment payloads, or private paths. + +Zero active Agent enrollments starts only an operator recovery surface. It creates no +Spend Session or signer admission. A revoked enrollment cannot spend after restart; +the Operator may still inspect, reconcile, export, and guarded-close retained records. + +## 9. Live host and filesystem prerequisites + +These are mandatory for a future `cdp-testnet` host. They are documented now so an +operator can review the trust boundary; satisfying them does not remove the current +`LIVE_LAUNCH_NOT_READY` block. + +1. Use Linux with systemd, a clean exact Git commit, `npm ci`, and exactly Node + 24.18.1. The pinned Node executable and the full installed dependency tree are part + of release integrity. +2. Provision distinct, non-root numeric Kernel and Pi UIDs with pinned primary GIDs. + Clear supplementary groups before either privileged probe or service execution. + Live startup refuses root, a shared UID, identity drift, or enrollment/config drift. +3. Choose one root-owned trusted ancestor. Every configured private, writable, + configuration, release, socket, evidence, and handoff path must be reached by a + complete non-symlink descriptor walk beneath it. Any group/other-writable or sticky + writable ancestor is rejected. +4. Keep the immutable release and all writable state separate. A reference ownership + layout is: + + | Path role | Owner and mode | Direction or contents | + |---|---|---| + | Versioned release tree | root; no group/other write | Source, lockfile-installed dependencies, pinned Node binding, manifest, unit artifacts, policy seed, and fixed route map | + | Authority root | Kernel `0700` | SQLite, receipt key, Operator token, and authority lock; private files are `0600` | + | Runtime root | Kernel `0700` | Unix admin socket and runtime-only state | + | Evidence root | Kernel `0700` | New immutable testnet run directories; never under the release tree | + | Pi credential parent | Pi `0700` | Raw Agent credential `0600`; Kernel traversal must receive `EACCES` | + | Enrollment inbox | Pi `0755` | Pi writes one non-secret descriptor `0644`; Kernel can read but must not write/rename/delete | + | Agent-run outbox | Kernel `0755` | Kernel writes one public run descriptor `0644`; Pi can read but must not write/rename/delete | + | Attested environment file | root-owned regular file, `0600` | Names the future Kernel inputs; current secret-delivery composition is missing and blocked | + +The two handoff parents are deliberately separate. Before live admission, dropped-UID +probes must prove both wrong-direction write/rename/delete attempts fail with `EACCES`, +Pi can read only its own credential, and Pi cannot read or modify Kernel authority, +release, service, environment, or evidence state. + +## 10. Clean install, bootstrap, and the intentional live block + +There is not yet a complete privileged installer or production listener/secret-loader +composition. The repository contains independently tested release-manifest, systemd +rendering/inspection, isolation-probe, offline bootstrap, and control-plane primitives. +Do not wire them together with an ad hoc root shell or bypass the readiness gate. + +The required clean-install order is exact: + +1. From a clean commit, install the dependency-locked tree into a version-addressed, + root-owned immutable release path such as `/opt/wallet-kernel/releases/`. + Never run live from a developer checkout, symlink named `current`, or Pi-writable + workspace. +2. Provision distinct Kernel-writable authority, runtime, and evidence roots plus the + two directional handoff parents described above. +3. Render both systemd artifacts to their exact immutable paths. The renderer is a + library boundary intended for a privileged installer and refuses an ad hoc direct + invocation. +4. Run Kernel `preflight` offline, before opening a network listener. +5. Under the Pi UID, create the raw Agent credential and public enrollment descriptor. +6. Under the Kernel UID, import that descriptor with its separately confirmed hash. +7. Validate the candidate policy, then apply it only with its separately confirmed + hash. Validate the route map before any start. +8. Run `systemctl daemon-reload`. +9. Run `systemctl enable wallet-kernel-console.socket` without starting it. The socket + template has `[Install] WantedBy=sockets.target`; enable it independently from the + static service. +10. Inspect PID1's complete effective service/socket configuration, then create and + verify the release manifest against the immutable tree, exact Node 24.18.1 binary, + both unit artifacts, and that effective-config hash. +11. Run the privileged dropped-identity isolation probe bound to the enrollment and + release manifest. Import its fresh, Kernel-owned `0600` report with the separately + confirmed report hash. The report expires after at most 15 minutes and must be + regenerated before each live start. +12. Verify the same effective configuration, run + `systemctl start wallet-kernel-console.socket`, and only then start the service. + +The loaded service must pass only `WALLET_KERNEL_ENV_FILE=` to the +root-prefixed preflight. It may not use `EnvironmentFile=`, `PassEnvironment=`, or +place CDP/RPC secret values in root's environment. Loader controls including +`NODE_OPTIONS`, `NODE_PATH`, `LD_*`, `DYLD_*`, `GCONV_PATH`, and `GLIBC_TUNABLES` are +forbidden. Root verifies public release, unit, PID1, and path facts, then spawns one +empty-environment child that clears groups and drops to the exact Kernel GID/UID before +opening the authority for a read-only recovery/parity audit. + +At present, step 12 does **not** produce a live service. The preflight command emits a +machine-readable gate with code `LIVE_LAUNCH_NOT_READY` and exits 78. Its unresolved +requirements are: + +- `LIVE_PREFLIGHT_COMPOSITION_REQUIRED`; +- `CONTROL_PLANE_COMPOSITION_REQUIRED`; +- `LIVE_SECRET_DELIVERY_COMPOSITION_REQUIRED`; +- `LIVE_LISTENER_RESPONSE_COMPATIBILITY_REQUIRED`; +- `LIVE_SYSTEMD_LIFECYCLE_EVIDENCE_REQUIRED`. + +The listener requirement includes production `@hono/node-server` adapters configured +with `overrideGlobalObjects: false`. A local macOS test cannot supply the required +Linux root/systemd lifecycle evidence. A skipped Linux/systemd integration is recorded +as skipped, never passed. Any failure after socket enablement must leave the socket +disabled and the service stopped. + +## 11. Agent enrollment, policy, and credential handling + +Never put a raw Agent credential in the Kernel `.env`, enrollment inbox, database, +logs, receipts, evidence, or Operator output. The Kernel must remain unable to open its +Pi-owned `0700` parent. + +Under the Pi identity, create or reuse the owner-only credential and exclusive-create +the non-secret descriptor: + +```bash +node src/agent/credential-cli.mjs init \ + --credential /absolute/pi-private/agent-credential.json \ + --enrollment /absolute/enrollment-inbox/agent-enrollment.json +``` + +The command prints only the descriptor's `sha256:...` digest. Confirm that digest by a +separate trusted channel. With the Kernel environment already installed and no daemon +holding the authority lock, use the Operator CLI: + +```bash +npm run operator -- preflight --json +npm run operator -- agent enroll /absolute/enrollment-inbox/agent-enrollment.json \ + --confirm sha256: --json +npm run operator -- policy validate /absolute/operator-input/policy.json --json +npm run operator -- policy apply /absolute/operator-input/policy.json \ + --confirm sha256: --json +npm run operator -- isolation attest /absolute/kernel-staging/isolation-report.json \ + --confirm sha256: --json +``` + +Each mutating bootstrap command acquires the bootstrap authority lock, opens the +persistent receipt signer, performs full integrity/recovery/receipt-parity checks, and +only then applies its one mutation. Never edit SQLite directly. + +Restarting with the same active credential and unchanged PolicyVersion reuses the +existing Agent binding, Spend Session, pending intent, and approval state. Startup does +not create a replacement session merely because the process restarted. + +For ordinary planned replacement, first guarded-close the active Spend Session, then +revoke the current enrollment with its displayed hashes: + +```bash +npm run operator -- sessions close \ + --confirm sha256: --json +npm run operator -- agent revoke \ + --confirm sha256: --json +``` + +If unresolved money makes close unsafe, revoke first, remain in operator-only recovery, +reconcile and close, then stop and replace. In either case, quiesce the host exactly as +described in section 13 before creating a second credential. A replacement requires a +new descriptor confirmation, enrollment import, policy/config validation, and fresh +isolation report; it never silently rotates a token inside an existing binding. + +CDP credentials are human-provisioned secrets. The required names are +`CDP_API_KEY_ID`, `CDP_API_KEY_SECRET`, `CDP_WALLET_SECRET`, and `CDP_WALLET_NAME`, but +the current release does not have a reviewed path that loads them after privileged +preflight without passing them through the root-prefixed preflight process. Provision +them only in an external owner-only secret store for the future non-root loader; do not +populate an improvised environment +or paste values into shell history, chat, unit files, release manifests, run +descriptors, evidence, or tracked files. `CDP_WALLET_NAME` must resolve to the same +customer-owned wallet address pinned by the active PolicyVersion. Funding that wallet +with Base Sepolia test USDC is a human action; the software never invokes a faucet or +transfers funds to satisfy a preflight. + +## 12. Operator procedures + +The Operator token is an independent 32-byte base64url bearer stored as a regular +`0600` file in the Kernel-owned `0700` authority parent. It is read locally and never +printed. In `cdp-testnet`, the admin CLI sends it only over the Kernel-owned `0600` Unix +socket. Deterministic development uses the fixed loopback operator endpoint and must +remain labeled simulated. + +### Console startup and launch + +For a future live release, the order before service start is exact: + +```bash +systemctl daemon-reload +systemctl enable wallet-kernel-console.socket +# Run the privileged effective-config inspection and compare its hash. +systemctl start wallet-kernel-console.socket +``` + +Then `npm run operator -- console launch` asks the Unix admin API for a one-time URL. +The bearer travels only in the URL fragment, which browsers do not send in HTTP. The +root-owned socket-activated loopback listener retains the reserved port across a Kernel +crash; do not replace it with a self-bound listener. Deterministic mode has a direct +loopback fallback for offline testing only. + +### Approval and denial + +```bash +npm run operator -- approvals list --state pending --json +npm run operator -- approvals approve \ + --confirm sha256: --json +npm run operator -- approvals deny \ + --confirm sha256: --reason OPERATOR_DENIED --json +``` + +Use only IDs and confirmation hashes from a fresh authenticated projection. Approval is +compare-and-swap, scoped to the exact intent, and bounded by its expiry. An expired +approval is not renewed or widened; the Agent must submit a fresh ordinary request. + +### Receipt and session observation + +```bash +npm run operator -- receipts list --json +npm run operator -- receipts verify --json +npm run operator -- export \ + --output /absolute/operator-private/session-export.json --json +``` + +Exports exclusive-create an owner-only file and refuse overwrite or symlinks. Signed +receipt revisions are authoritative public outcomes; never infer a final outcome from +a candidate row or raw provider response. + +### Reconciliation and refunds + +For a payment, execution, or refund case, first obtain the current intent hash and case +hash from the authenticated Operator view. Then use the one matching operation: + +```bash +npm run operator -- reconcile payment \ + --confirm sha256: --confirm-case sha256: \ + --payment-transaction 0x --json + +npm run operator -- reconcile execution \ + --confirm sha256: --confirm-case sha256: --json + +npm run operator -- reconcile refund-observation \ + --confirm sha256: --confirm-case sha256: \ + --refund-transaction 0x --json +``` + +The Base Sepolia observer is read-only. It checks finalized transaction/receipt facts, +exact asset, wallet, payee/source, amount, nonce, and seller attestations; it cannot +fund, sign, send, or execute a refund. A full refund becomes final only after the +seller-attested and on-chain facts agree with the durable original payment and the +Kernel emits the superseding signed receipt revision. Never retry an ambiguous refund +execution, accept caller-supplied proof as final, or release exposure from a pending +candidate. A demonstrably invalid payment or refund candidate may be abandoned only +with the current intent/case hashes and the explicit `reconcile abandon-candidate` +operation. + +## 13. Shutdown, replacement, backup, and incidents + +### Normal shutdown and restart + +The control plane closes admission first, waits for in-flight unsigned work, preserves +all signed or ambiguous holds, closes Agent, console, and admin listeners in order, +closes SQLite, and releases the process-lifetime authority lock last. A normal restart +must run full recovery, event-chain and receipt-parity validation before reopening +admission. Do not delete a lock, socket, intent, or pending row to make startup pass. + +For maintenance or Agent replacement, prevent socket activation before stopping the +service: + +```bash +systemctl disable --now wallet-kernel-console.socket +systemctl stop wallet-kernel.service +``` + +Verify both units are `inactive`, the socket is `disabled`, both `Job` values are +empty, `MainPID=0`, no listener remains on `127.0.0.1:8405`, and a role-`bootstrap` +authority-lock probe succeeds. Keep a connection storm running during the check; after +socket disablement, dropped Pi traffic must not reactivate the service or acquire the +authority lock. If any check fails, leave the socket disabled and the service stopped. + +Restore the service only after replacement/bootstrap succeeds: `daemon-reload`, enable +the socket without starting it, verify the full PID1 effective projection, import a +fresh isolation attestation, start the socket, then start the service. There is no +failure path that silently resumes an old enrollment or policy binding. + +### Offline backup and restore + +Treat backup/restore as an offline SQLite authority operation: + +1. Complete the maintenance quiesce above and prove the authority lock is available. +2. Use a trusted SQLite backup operation, or an exact file copy only after all Kernel + connections are closed and SQLite has checkpointed its WAL. Do not copy a live + database or omit live `-wal`/`-shm` state by guesswork. +3. Store the backup as sensitive authority data outside the checkout. Protect receipt + signing keys and Operator credentials separately under the same security policy; + never put them in the evidence bundle. +4. Restore to a newly provisioned Kernel-owned `0700` parent with exact `0600` file + modes. Run SQLite `PRAGMA integrity_check`, then Kernel `preflight`, full semantic + recovery, event-chain verification, receipt signature/parity verification, policy + validation, and fresh isolation attestation before enabling the socket. + +Never repair an incident by hand-editing the database. If integrity, semantic recovery, +or receipt parity fails, preserve the files, keep admission closed, and escalate the +exact stable error code. + +### Incident decision points + +- **Signing/payment ambiguity:** stop new admission, retain the full reservation, and + use read-only Base Sepolia observation plus `reconcile payment`. Never create a new + signature for the old intent or resend merely because a response was lost. +- **Execution-evidence ambiguity:** preserve the committed payment and response hold. + Use `reconcile execution`; never invent output or a Royalty claim from transport + failure details. +- **Seller-attested refund or on-chain refund ambiguity:** preserve the pending full + exposure. Use `reconcile refund-observation` only when the independent seller and + chain bindings are available. Never execute a second refund from an uncertain first + attempt. +- **Credential compromise:** revoke immediately if necessary, restart in recovery-only + mode, reconcile and close retained sessions, then follow the full quiesce and + replacement sequence. Do not retain the compromised credential for convenience. +- **Authority corruption or missing receipt:** do not start listeners. Recovery may + repair only the exact designed missing-receipt gap; all other corruption remains a + blocked incident for preserved forensic review. + +## 14. Human-gated Base Sepolia evidence + +A qualifying testnet run is a separate human authorization event, not an environment +toggle. In this implementation, +`run-evidence.mjs --mode base-sepolia-testnet` deliberately exits 2 with canonical +`EVIDENCE_TESTNET_NOT_RUN`; there is no privileged Kernel-side live orchestration API +and no real adapter is constructed. Do not treat the separately testable Pi-side +descriptor runner as a complete testnet workflow. + +A future reviewed Kernel-side runner must stop before constructing a real adapter +unless all of these are true at the same time: + +- the root-owned release manifest/tree, exact commit, Node binary, both unit artifacts, + and fresh PID1 effective-config hash reverify; +- network and asset equal Base Sepolia `eip155:84532` and its pinned USDC contract; +- the active PolicyVersion wallet equals the customer-owned CDP wallet; +- Operator preflight and the unexpired imported Agent isolation attestation are green; +- the read-only observer reports sufficient funds at a recorded block for the run + intent's full `maximumTotalAtomic` amount; +- the output is a new `YYYY-MM-DD-agent-spend-control-RUN_ID` directory under the + external Kernel-owned `0700` evidence root; +- the human supplies `--confirm-sha256` equal to the canonical run-intent digest. + +Confirmation may not come from an environment variable. Insufficient or unavailable +funding exits before signing; the runner never faucets, funds, transfers, selects +mainnet, lowers the declared ceiling, overwrites an evidence directory, or writes into +the release/source tree. + +After a future reviewed Kernel-side runner publishes the bounded `0644` descriptor in +the Kernel-owned outbox, the human separately invokes the Pi-side runner under the +enrolled Pi UID/GID: + +```bash +node scripts/run-testnet-agent.mjs \ + --run-intent /absolute/kernel-run-outbox/run-intent.json \ + --confirm-sha256 sha256: +``` + +That Pi-side process validates the single-open descriptor, Kernel ownership/mode/hash, +its own identity, the `0600` credential, exact routes, amount ceiling, and expiry. It +has no Operator token or CDP environment and emits no credential. A timeout leaves the +run incomplete/`not-run`; it does not weaken policy or imply success. + +Every completed evidence directory is immutable. Verify it against the out-of-band +manifest anchor before any optional human-reviewed copy into the repository. A public +website reframe remains gated on fresh qualifying testnet evidence; the quarantined +2026-07-15 n=48 aggregate cannot satisfy that gate because normalized per-call samples +were not retained. + +The following remain outside this spike: mainnet, real funds, custody, hosted policy +authority, automated funding, live CDP payment, production secret delivery, a complete +privileged installer/launcher, and any public commercialization claim based only on +offline evidence. diff --git a/spikes/pi-wielder/deploy/systemd/wallet-kernel-console.socket b/spikes/pi-wielder/deploy/systemd/wallet-kernel-console.socket new file mode 100644 index 0000000..9b6791f --- /dev/null +++ b/spikes/pi-wielder/deploy/systemd/wallet-kernel-console.socket @@ -0,0 +1,12 @@ +[Unit] +Description=Wallet Kernel local operator console socket + +[Socket] +ListenStream=127.0.0.1:8405 +Accept=no +Service=wallet-kernel.service +FileDescriptorName=wallet-kernel-console +ReusePort=no + +[Install] +WantedBy=sockets.target diff --git a/spikes/pi-wielder/deploy/systemd/wallet-kernel.service b/spikes/pi-wielder/deploy/systemd/wallet-kernel.service new file mode 100644 index 0000000..4b869c6 --- /dev/null +++ b/spikes/pi-wielder/deploy/systemd/wallet-kernel.service @@ -0,0 +1,38 @@ +[Unit] +Description=Wallet Kernel +Requires=wallet-kernel-console.socket +After=network-online.target wallet-kernel-console.socket + +[Service] +Type=simple +User={{KERNEL_UID}} +Group={{KERNEL_GID}} +SupplementaryGroups= +# The root-prefixed preflight receives only this public path. It must never +# inherit the file's CDP/RPC secret values. A future reviewed non-root launcher +# will own bounded secret-file loading after the privileged preflight exits. +Environment=WALLET_KERNEL_ENV_FILE={{ENVIRONMENT_PATH}} +ExecStartPre=+{{NODE_PATH}} {{RELEASE_ROOT}}/scripts/preflight-live-deployment.mjs --release-manifest {{RELEASE_ROOT}}/manifest.json --kernel-uid {{KERNEL_UID}} --kernel-gid {{KERNEL_GID}} +ExecStart={{NODE_PATH}} {{RELEASE_ROOT}}/src/control-plane.mjs +# Live composition is explicitly blocked in this spike. Do not retry a +# privileged preflight until a reviewed release replaces the readiness gate. +Restart=no +RestartSec=2s +UMask=0077 +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +LockPersonality=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +ReadWritePaths={{AUTHORITY_ROOT}} {{EVIDENCE_ROOT}} {{RUNTIME_ROOT}} {{AGENT_RUN_OUTBOX_PATH}} +UnsetEnvironment=NODE_OPTIONS NODE_PATH LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT LD_DEBUG LD_PROFILE GLIBC_TUNABLES GCONV_PATH PRIVATE_KEY ANTHROPIC_API_KEY OPENAI_API_KEY CDP_API_KEY_ID CDP_API_KEY_SECRET CDP_WALLET_SECRET CDP_WALLET_NAME WALLET_KERNEL_BASE_SEPOLIA_RPC_URL + +[Install] +# Service is socket-triggered and deliberately static. diff --git a/spikes/pi-wielder/operator-console/app.mjs b/spikes/pi-wielder/operator-console/app.mjs new file mode 100644 index 0000000..50ced6b --- /dev/null +++ b/spikes/pi-wielder/operator-console/app.mjs @@ -0,0 +1,458 @@ +const viewNames = Object.freeze(['overview', 'policies', 'approvals', 'receipts']); +const launchMatch = /^#launch=([A-Za-z0-9_-]{43})$/.exec(window.location.hash); +let launchToken = launchMatch?.[1] ?? null; +history.replaceState(null, '', `${window.location.pathname}${window.location.search}`); + +let csrfToken = null; +let validatedPolicy = null; + +const byId = (id) => document.getElementById(id); + +function canonicalIdForPath(value) { + if (typeof value !== 'string' + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(value)) { + throw new TypeError('Operator identifier is outside the canonical path grammar'); + } + return value; +} + +function canonicalJson(value, ancestors = new Set()) { + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number' && Number.isSafeInteger(value) && !Object.is(value, -0)) { + return String(value); + } + if (!value || typeof value !== 'object' || ancestors.has(value)) { + throw new TypeError('Operator request must contain inert canonical JSON data'); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype + || Object.keys(value).length !== value.length) { + throw new TypeError('Operator request array is not dense canonical data'); + } + return `[${value.map((element) => canonicalJson(element, ancestors)).join(',')}]`; + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError('Operator request object is not canonical data'); + } + const fields = []; + for (const key of Reflect.ownKeys(value).sort()) { + if (typeof key !== 'string') throw new TypeError('Operator request key is invalid'); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError('Operator request object must contain only data fields'); + } + fields.push(`${JSON.stringify(key)}:${canonicalJson(descriptor.value, ancestors)}`); + } + return `{${fields.join(',')}}`; + } finally { + ancestors.delete(value); + } +} + +function setMessage(message, state = 'neutral') { + const element = byId('console-message'); + element.textContent = message; + element.dataset.state = state; +} + +function setText(id, value) { + byId(id).textContent = value === null || value === undefined || value === '' ? '—' : String(value); +} + +function addRecord(container, label, value) { + const row = document.createElement('div'); + const key = document.createElement('span'); + const output = document.createElement('strong'); + key.textContent = label; + output.textContent = value === null || value === undefined ? '—' : String(value); + row.append(key, output); + container.append(row); +} + +function clear(element) { + while (element.firstChild) element.firstChild.remove(); +} + +function renderRecord(id, record, preferred = []) { + const container = byId(id); + clear(container); + if (!record || typeof record !== 'object') { + addRecord(container, 'State', 'No record'); + return; + } + const keys = preferred.length > 0 + ? preferred.filter((key) => Object.hasOwn(record, key)) + : Object.keys(record).slice(0, 12); + for (const key of keys) { + const value = record[key]; + addRecord(container, key, value && typeof value === 'object' ? JSON.stringify(value) : value); + } +} + +async function api(path, { method = 'GET', body } = {}) { + const headers = { accept: 'application/json' }; + if (body !== undefined) headers['content-type'] = 'application/json'; + if (method !== 'GET') { + if (!csrfToken) throw new Error('LOCAL_SESSION_REQUIRED'); + headers['x-csrf-token'] = csrfToken; + } + const response = await fetch(path, { + method, + headers, + body: body === undefined ? undefined : canonicalJson(body), + credentials: 'same-origin', + redirect: 'error', + referrerPolicy: 'no-referrer', + }); + if (response.status === 204) return null; + const value = await response.json(); + if (!response.ok || value?.ok !== true || !Object.hasOwn(value, 'data')) { + throw new Error(value?.error?.code ?? 'OPERATOR_REQUEST_FAILED'); + } + return value.data; +} + +async function exchangeLaunch() { + if (!launchToken) return false; + const body = canonicalJson({ launchToken }); + launchToken = null; + const response = await fetch('/operator/v1/session', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + credentials: 'same-origin', + redirect: 'error', + referrerPolicy: 'no-referrer', + }); + if (response.status !== 204) throw new Error('LAUNCH_EXCHANGE_FAILED'); + csrfToken = response.headers.get('x-csrf-token'); + if (!/^[A-Za-z0-9_-]{43}$/.test(csrfToken ?? '')) throw new Error('CSRF_MISSING'); + return true; +} + +function renderOverview(value) { + setText('wallet-network', value.wallet?.network); + setText('wallet-address', value.wallet?.address); + setText('agent-state', value.agent?.state); + setText('spend-gate', value.health?.admission); + setText('session-ceiling', value.budget?.sessionMaxAtomic); + setText('available-budget', value.budget?.availableAtomic); + setText('reserved-budget', value.budget?.reservedAtomic); + setText('unresolved-budget', value.budget?.unresolvedAtomic); + renderRecord('enrollment-summary', value.agent); + if (value.agent?.state === 'active' + && value.agent.agentInstanceId && value.agent.enrollmentHash) { + byId('enrollment-summary').append(actionButton('Revoke agent access', async () => { + if (!window.confirm('Revoke this agent credential now? Existing unresolved value remains blocked for operator recovery.')) return; + try { + await api(`/operator/v1/agents/${canonicalIdForPath(value.agent.agentInstanceId)}/revoke`, { + method: 'POST', + body: { expectedEnrollmentHash: value.agent.enrollmentHash }, + }); + await loadOverview(); + setMessage('Agent access revoked. Reconcile and close retained sessions before replacement.', 'ready'); + } catch { + setMessage('Agent revocation failed. Refresh the enrollment hash and try again.', 'error'); + } + })); + } + renderRecord('health-summary', value.health); + const feed = byId('evidence-feed'); + clear(feed); + for (const event of value.recentEvidence ?? []) { + const item = document.createElement('li'); + const time = document.createElement('time'); + const summary = document.createElement('span'); + time.textContent = event.at ?? '—'; + summary.textContent = event.summary ?? event.state ?? 'Wallet event'; + item.append(time, summary); + feed.append(item); + } +} + +function renderPolicies(value) { + renderRecord('active-policy', value.active, ['id', 'hash', 'network', 'wallet', 'defaultAction']); + const history = byId('policy-history'); + clear(history); + for (const policy of value.history ?? []) { + addRecord(history, policy.id ?? 'Policy', policy.hash ?? '—'); + } + const sessions = byId('policy-sessions'); + clear(sessions); + const targetPolicyHash = value.active?.hash ?? value.active?.policyHash; + for (const session of value.sessions ?? value.blockedSessions ?? []) { + const card = document.createElement('article'); + card.className = 'decision-card'; + const title = document.createElement('h3'); + title.textContent = `${session.state ?? 'Session'} · ${session.id ?? '—'}`; + card.append(title); + for (const key of ['policyVersionId', 'state', 'sessionHash', 'reservedAtomic', 'unresolvedAtomic']) { + if (Object.hasOwn(session, key)) addRecord(card, key, session[key]); + } + const actions = document.createElement('div'); + actions.className = 'action-row'; + if (session.state === 'policy_blocked' && targetPolicyHash && session.sessionHash) { + actions.append(actionButton('Transition to active policy', async () => { + if (!window.confirm('Transition this blocked session to the displayed active policy after all blockers have been checked?')) return; + try { + await api(`/operator/v1/sessions/${canonicalIdForPath(session.id)}/transition-policy`, { + method: 'POST', + body: { targetPolicyHash, expectedSessionHash: session.sessionHash }, + }); + await loadPolicies(); + setMessage('Session transitioned to the active policy.', 'ready'); + } catch { + setMessage('Session transition failed. Refresh its hash and blockers.', 'error'); + } + })); + } + if (session.sessionHash) { + actions.append(actionButton('Close session', async () => { + if (!window.confirm('Close this session? No replacement session is created automatically.')) return; + try { + await api(`/operator/v1/sessions/${canonicalIdForPath(session.id)}/close`, { + method: 'POST', body: { expectedSessionHash: session.sessionHash }, + }); + await loadPolicies(); + setMessage('Session closed. Agent access now requires explicit reconfiguration.', 'ready'); + } catch { + setMessage('Session close is blocked. Resolve reserved or unresolved value first.', 'error'); + } + })); + } + card.append(actions); + sessions.append(card); + } +} + +function actionButton(label, action) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.addEventListener('click', action); + return button; +} + +function renderApprovals(value) { + const list = byId('approval-list'); + clear(list); + for (const approval of value.items ?? value.approvals ?? []) { + const card = document.createElement('article'); + card.className = 'decision-card'; + const title = document.createElement('h3'); + title.textContent = `${approval.purposeLabel ?? 'Spend request'} · ${approval.amountAtomic ?? '—'}`; + card.append(title); + for (const key of ['sellerOrigin', 'resourcePath', 'requestHash', 'purposeLabel', 'amountAtomic', 'wallet', 'reasonCode', 'expiresAt', 'intentHash']) { + if (Object.hasOwn(approval, key)) addRecord(card, key, approval[key]); + } + const actions = document.createElement('div'); + actions.className = 'action-row'; + actions.append( + actionButton('Approve once', async () => { + if (!window.confirm(`Approve request ${approval.requestHash} to ${approval.sellerOrigin}${approval.resourcePath} for at most ${approval.amountAtomic}?`)) return; + await api(`/operator/v1/approvals/${canonicalIdForPath(approval.id)}/approve`, { + method: 'POST', body: { expectedIntentHash: approval.intentHash }, + }); + await loadApprovals(); + }), + actionButton('Deny', async () => { + if (!window.confirm('Deny this exact request? The agent must submit a new request to try again.')) return; + await api(`/operator/v1/approvals/${canonicalIdForPath(approval.id)}/deny`, { + method: 'POST', + body: { expectedIntentHash: approval.intentHash, reasonCode: 'OPERATOR_DENIED' }, + }); + await loadApprovals(); + }), + ); + card.append(actions); + list.append(card); + } +} + +function renderReceipts(value) { + const list = byId('receipt-list'); + clear(list); + for (const receipt of value.items ?? value.receipts ?? []) { + const card = document.createElement('article'); + card.className = 'decision-card'; + const title = document.createElement('h3'); + title.textContent = `${receipt.terminalState ?? 'Unresolved'} · ${receipt.id ?? 'Receipt'}`; + card.append(title); + const reconciliation = receipt.reconciliation ?? receipt; + for (const key of ['hash', 'sellerOrigin', 'chargedAtomic', 'terminalState', 'intentHash', 'caseHash', 'reasonCode']) { + if (Object.hasOwn(receipt, key)) addRecord(card, key === 'caseHash' ? 'case hash' : key, receipt[key]); + } + const localBinding = reconciliation.localBinding ?? reconciliation.binding ?? {}; + for (const key of [ + 'resourcePath', + 'requestHash', + 'wallet', + 'policyVersionId', + 'policyHash', + 'amountAtomic', + 'authorizationNonce', + 'transactionId', + 'originalTransactionId', + 'refundSource', + ]) { + const displayValue = Object.hasOwn(localBinding, key) + ? localBinding[key] + : reconciliation[key]; + if (displayValue !== undefined) addRecord(card, key, displayValue); + } + const kind = reconciliation.kind; + const intentId = reconciliation.intentId; + const expectedIntentHash = reconciliation.intentHash ?? receipt.intentHash; + const expectedCaseHash = reconciliation.caseHash ?? receipt.caseHash; + if (['payment', 'execution', 'refund-observation'].includes(kind) + && intentId && expectedIntentHash && expectedCaseHash) { + const actions = document.createElement('div'); + actions.className = 'action-row'; + let transactionInput = null; + if (kind === 'payment' || kind === 'refund-observation') { + transactionInput = document.createElement('input'); + transactionInput.type = 'text'; + transactionInput.maxLength = 66; + transactionInput.autocomplete = 'off'; + transactionInput.spellcheck = false; + transactionInput.placeholder = kind === 'payment' + ? 'Optional payment transaction ID' + : 'Required refund transaction ID'; + transactionInput.setAttribute('aria-label', transactionInput.placeholder); + actions.append(transactionInput); + } + actions.append(actionButton('Reconcile displayed case', async () => { + if (!window.confirm(`Reconcile only the displayed ${kind} case hash?`)) return; + const body = { expectedIntentHash, expectedCaseHash }; + const candidate = transactionInput?.value ?? ''; + if (kind === 'payment' && candidate !== '') body.paymentTransactionId = candidate; + if (kind === 'refund-observation') body.refundTransactionId = candidate; + try { + await api(`/operator/v1/reconciliations/${canonicalIdForPath(intentId)}/${kind}`, { + method: 'POST', body, + }); + await loadReceipts(); + setMessage('Reconciliation observation completed. Review the revised signed receipt.', 'ready'); + } catch { + setMessage('Reconciliation did not resolve. Refresh the case before another observation.', 'error'); + } + })); + if ((kind === 'payment' || kind === 'refund-observation') + && reconciliation.candidate?.state === 'pending') { + actions.append(actionButton('Abandon candidate', async () => { + if (!window.confirm('Abandon this candidate? The hold remains and only a fresh case hash can name a replacement.')) return; + try { + await api(`/operator/v1/reconciliations/${canonicalIdForPath(intentId)}/${kind}/abandon-candidate`, { + method: 'POST', body: { expectedIntentHash, expectedCaseHash }, + }); + await loadReceipts(); + setMessage('Candidate abandoned. The value hold remains under a fresh case hash.', 'ready'); + } catch { + setMessage('Candidate abandonment failed. Refresh the case hash.', 'error'); + } + })); + } + card.append(actions); + } + list.append(card); + } +} + +async function loadOverview() { renderOverview(await api('/operator/v1/overview')); } +async function loadPolicies() { renderPolicies(await api('/operator/v1/policies')); } +async function loadApprovals() { renderApprovals(await api('/operator/v1/approvals')); } +async function loadReceipts() { renderReceipts(await api('/operator/v1/receipts')); } + +const loaders = Object.freeze({ + overview: loadOverview, + policies: loadPolicies, + approvals: loadApprovals, + receipts: loadReceipts, +}); + +async function showView(name) { + if (!viewNames.includes(name)) return; + for (const candidate of viewNames) { + const selected = candidate === name; + byId(`view-${candidate}`).hidden = !selected; + byId(`view-${candidate}`).classList.toggle('is-active', selected); + document.querySelector(`[data-view="${candidate}"]`).classList.toggle('is-active', selected); + } + try { + await loaders[name](); + setMessage(`${name[0].toUpperCase()}${name.slice(1)} is current.`, 'ready'); + } catch (error) { + setMessage(error.message === 'OPERATOR_UNAUTHORIZED' + ? 'This local session ended. Open a fresh console launch link.' + : `Could not refresh ${name}.`, 'error'); + } +} + +for (const button of document.querySelectorAll('[data-view]')) { + button.addEventListener('click', () => showView(button.dataset.view)); +} +for (const button of document.querySelectorAll('[data-refresh]')) { + button.addEventListener('click', () => showView(button.dataset.refresh)); +} + +byId('validate-policy').addEventListener('click', async () => { + const file = byId('policy-file').files?.[0]; + if (!file || file.size > 65_536) { + setMessage('Choose one policy JSON file no larger than 64 KiB.', 'error'); + return; + } + try { + const document = JSON.parse(await file.text()); + validatedPolicy = await api('/operator/v1/policies/validate', { + method: 'POST', body: { document }, + }); + renderRecord('policy-validation', validatedPolicy, ['policyHash', 'policy']); + byId('apply-policy').disabled = false; + setMessage('Policy validated. Confirm the displayed hash before applying.', 'ready'); + } catch { + validatedPolicy = null; + byId('apply-policy').disabled = true; + setMessage('Policy validation failed. Check the closed policy schema.', 'error'); + } +}); + +byId('apply-policy').addEventListener('click', async () => { + if (!validatedPolicy) return; + const file = byId('policy-file').files?.[0]; + if (!file) return; + try { + const document = JSON.parse(await file.text()); + await api('/operator/v1/policies/apply', { + method: 'POST', body: { document, expectedPolicyHash: validatedPolicy.policyHash }, + }); + validatedPolicy = null; + byId('apply-policy').disabled = true; + await loadPolicies(); + setMessage('Policy applied. Blocked sessions remain visibly blocked.', 'ready'); + } catch { + setMessage('Policy apply failed. Revalidate the current file and wallet.', 'error'); + } +}); + +byId('end-session').addEventListener('click', async () => { + try { + await api('/operator/v1/session', { method: 'DELETE' }); + } finally { + csrfToken = null; + setMessage('Local session ended. Open a fresh console launch link.', 'neutral'); + } +}); + +try { + if (await exchangeLaunch()) { + byId('authority-status').textContent = 'Local session active'; + await showView('overview'); + } +} catch { + csrfToken = null; + setMessage('The console launch link is invalid or expired. Request a fresh link.', 'error'); +} diff --git a/spikes/pi-wielder/operator-console/index.html b/spikes/pi-wielder/operator-console/index.html new file mode 100644 index 0000000..37674fb --- /dev/null +++ b/spikes/pi-wielder/operator-console/index.html @@ -0,0 +1,112 @@ + + + + + + + Wallet Kernel + + + +
+
+
+

Agent spend control

+

Wallet Kernel

+
+
+ + Local session required +
+
+ + + +
+
+ Open a fresh console launch link to establish this local session. +
+ +
+
+

01 / Position

Overview

+ +
+
+
Session ceiling
+
Available
+
Reserved
+
Unresolved
+
+
+

Enrollment and revocation

+

Kernel health

+
+
+ + + + + + +
+ + +
+ + + diff --git a/spikes/pi-wielder/operator-console/styles.css b/spikes/pi-wielder/operator-console/styles.css new file mode 100644 index 0000000..c1ab329 --- /dev/null +++ b/spikes/pi-wielder/operator-console/styles.css @@ -0,0 +1,267 @@ +:root { + --ledger-blue: #172554; + --ledger-blue-deep: #0d1735; + --porcelain: #f4f7f8; + --paper: #ffffff; + --graphite: #252a34; + --muted: #687386; + --signal: #d85332; + --settled: #147d73; + --line: #cbd5df; + --soft-blue: #dce7f2; + font-family: "Avenir Next", Avenir, "Segoe UI", sans-serif; + color: var(--graphite); + background: var(--porcelain); + font-synthesis: none; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--porcelain); +} + +button, +input { font: inherit; } + +button { cursor: pointer; } + +button:focus-visible, +input:focus-visible { + outline: 3px solid var(--signal); + outline-offset: 3px; +} + +.shell { + min-height: 100vh; + display: grid; + grid-template-columns: minmax(220px, 0.72fr) minmax(520px, 2.4fr) minmax(210px, 0.7fr); + grid-template-rows: auto 1fr; +} + +.masthead { + grid-column: 1 / -1; + min-height: 112px; + padding: 24px 30px 20px; + color: var(--paper); + background: var(--ledger-blue-deep); + display: flex; + align-items: flex-end; + justify-content: space-between; + border-bottom: 5px solid var(--signal); +} + +.eyebrow, +.section-code, +.spine-label { + margin: 0 0 6px; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-family: "Arial Narrow", "Avenir Next Condensed", sans-serif; + font-size: clamp(2.25rem, 6vw, 4.8rem); + font-stretch: condensed; + font-weight: 900; + letter-spacing: -0.055em; + line-height: 0.82; +} + +.authority-stamp { + display: flex; + gap: 9px; + align-items: center; + padding: 10px 13px; + border: 1px solid #67749a; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.72rem; + letter-spacing: 0.04em; +} + +.status-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--signal); +} + +.command-spine { + padding: 26px 20px; + color: var(--paper); + background: var(--ledger-blue); + border-right: 1px solid #6575a3; +} + +.command-spine dl { margin: 30px 0 36px; } +.command-spine dl div { padding: 11px 0; border-bottom: 1px solid #465784; } +.command-spine dt { color: #aab6d3; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; } +.command-spine dd { margin: 4px 0 0; overflow-wrap: anywhere; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.78rem; } +.command-spine nav { display: grid; gap: 7px; } + +.nav-button, +.quiet-action { + width: 100%; + padding: 12px 13px; + color: var(--paper); + text-align: left; + border: 1px solid transparent; + background: transparent; +} + +.nav-button::before { content: "○"; margin-right: 10px; color: #93a4ce; } +.nav-button:hover { border-color: #7181aa; } +.nav-button.is-active { color: var(--ledger-blue-deep); background: var(--paper); } +.nav-button.is-active::before { content: "●"; color: var(--signal); } +.quiet-action { margin-top: 30px; border-color: #7181aa; color: #d7deef; } + +main { + min-width: 0; + padding: clamp(24px, 4vw, 58px); + background-color: var(--porcelain); + background-image: linear-gradient(#dce3e8 1px, transparent 1px), linear-gradient(90deg, #dce3e8 1px, transparent 1px); + background-size: 24px 24px; +} + +.console-message { + margin-bottom: 22px; + padding: 11px 14px; + color: var(--ledger-blue); + border-left: 4px solid var(--ledger-blue); + background: var(--paper); + box-shadow: 0 5px 18px rgb(13 23 53 / 7%); +} +.console-message[data-state="error"] { border-color: var(--signal); color: #8b2f1c; } +.console-message[data-state="ready"] { border-color: var(--settled); color: #0d625a; } + +.view[hidden] { display: none; } +.view.is-active { animation: view-in 180ms ease-out both; } + +.view-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 28px; +} + +.section-code { color: var(--signal); } +h2 { margin: 0; color: var(--ledger-blue-deep); font-size: clamp(2rem, 4vw, 3.5rem); line-height: 0.95; letter-spacing: -0.045em; } +h3 { margin: 0 0 16px; color: var(--ledger-blue-deep); font-size: 1rem; letter-spacing: 0.01em; } + +.refresh-action, +.action-row button, +#validate-policy, +#apply-policy { + padding: 10px 14px; + color: var(--paper); + border: 1px solid var(--ledger-blue); + background: var(--ledger-blue); + box-shadow: 3px 3px 0 var(--soft-blue); +} + +button:disabled { cursor: not-allowed; opacity: 0.45; } + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 10px; + margin-bottom: 22px; +} + +.metric-grid article, +.panel, +.decision-card { + border: 1px solid var(--line); + background: var(--paper); + box-shadow: 0 8px 22px rgb(13 23 53 / 6%); +} + +.metric-grid article { padding: 19px; border-top: 4px solid var(--settled); } +.metric-grid .warning-metric { border-top-color: var(--signal); } +.metric-grid span { display: block; color: var(--muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; } +.metric-grid strong { display: block; margin-top: 9px; color: var(--ledger-blue-deep); font-family: "SFMono-Regular", Consolas, monospace; font-size: 1.2rem; } + +.panel-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 14px; } +.panel, +.decision-card { padding: 21px; } +.supporting-copy { max-width: 68ch; color: var(--muted); line-height: 1.55; } +.record-list { display: grid; gap: 1px; } +.record-list div, +.decision-card > div:not(.action-row) { display: flex; justify-content: space-between; gap: 20px; padding: 8px 0; border-bottom: 1px solid #e6ebef; } +.record-list span, +.decision-card > div > span { color: var(--muted); font-size: 0.75rem; } +.record-list strong, +.decision-card > div > strong { max-width: 70%; overflow-wrap: anywhere; text-align: right; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.74rem; } + +.file-control { display: grid; gap: 8px; margin: 18px 0; color: var(--muted); font-size: 0.76rem; font-weight: 700; } +.file-control input { width: 100%; padding: 10px; border: 1px dashed var(--ledger-blue); background: var(--porcelain); } +.action-row { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 15px; } +.card-stack { display: grid; gap: 12px; } + +.receipt-tape { + position: relative; + padding: 26px 19px 50px; + color: #27313c; + background: #eef1e7; + border-left: 1px solid #bbc2b3; + font-family: "SFMono-Regular", Consolas, monospace; +} + +.receipt-tape::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 14px; + content: ""; + background: repeating-linear-gradient(135deg, transparent 0 7px, var(--porcelain) 7px 14px); +} + +.tape-heading { display: flex; justify-content: space-between; gap: 10px; padding-bottom: 13px; border-bottom: 2px solid #27313c; font-size: 0.7rem; font-weight: 800; text-transform: uppercase; } +.receipt-tape ol { margin: 18px 0 0; padding: 0; list-style: none; } +.receipt-tape li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px dashed #9ea697; font-size: 0.72rem; line-height: 1.4; } +.receipt-tape time { color: #6f786b; } + +@keyframes view-in { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (max-width: 980px) { + .shell { grid-template-columns: 210px minmax(0, 1fr); } + .receipt-tape { grid-column: 1 / -1; border-top: 1px solid #bbc2b3; border-left: 0; } + .receipt-tape ol { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +} + +@media (max-width: 700px) { + .shell { display: block; } + .masthead { min-height: 100px; padding: 20px; } + .authority-stamp { display: none; } + .command-spine { padding: 16px; } + .command-spine dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 15px 0; } + .command-spine nav { grid-template-columns: repeat(4, minmax(0, 1fr)); overflow-x: auto; } + .nav-button { padding: 10px 8px; font-size: 0.76rem; text-align: center; } + .nav-button::before { display: block; margin: 0 0 3px; } + .quiet-action { margin-top: 12px; text-align: center; } + main { padding: 24px 16px 36px; } + .view-heading { align-items: flex-start; flex-direction: column; } + .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .panel-grid { grid-template-columns: 1fr; } + .receipt-tape ol { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/spikes/pi-wielder/package.json b/spikes/pi-wielder/package.json index f4e86a6..be0c15f 100644 --- a/spikes/pi-wielder/package.json +++ b/spikes/pi-wielder/package.json @@ -5,17 +5,26 @@ "description": "Pi-Wielder spike: one wallet pays per-call x402 for model inference and hosted Skill Invocations; the Collar journal is authoritative and the Wielder keeps a signed-receipt view. Testnet-only (Base Sepolia); fully offline in mock mode.", "type": "module", "scripts": { - "test": "node --test tests/*.test.mjs", + "test": "node --test --test-concurrency=1 tests/*.test.mjs", "test:journal": "node --test tests/invocation-journal.test.mjs", "test:collar": "node --test tests/collar-failure.test.mjs tests/x402-lifecycle.test.mjs", "test:proxy": "node --test tests/proxy-trust.test.mjs", "test:policy": "node --test tests/payment-policy.test.mjs", "test:payment": "node --test tests/payment-policy.test.mjs tests/paying-fetch.test.mjs tests/seller-payment-response.test.mjs", "test:economics": "node --test tests/execution-economics.test.mjs tests/artifact-boundary.test.mjs tests/collar-cogs.test.mjs", + "test:systemd": "node --test tests/systemd-units.test.mjs tests/release-integrity.test.mjs tests/agent-isolation.test.mjs", + "test:kernel": "node --test --test-concurrency=1 tests/kernel-*.test.mjs tests/wallet-*.test.mjs tests/eip3009-exact.test.mjs tests/x402-v2-transport.test.mjs tests/base-sepolia-observer.test.mjs tests/seller-evidence-resolver.test.mjs tests/config.test.mjs tests/projection-exporter.test.mjs tests/agent-*.test.mjs tests/operator-*.test.mjs tests/spend-control-proxy.test.mjs tests/control-plane.test.mjs", "e2e": "MOCK_LLM=1 node e2e.mjs", + "e2e:spend-control": "node spend-control-e2e.mjs", + "verify:spend-control": "npm test && npm run e2e && npm run e2e:spend-control", + "evidence:offline": "node scripts/run-evidence.mjs --mode offline-deterministic", + "evidence:verify": "node scripts/verify-evidence.mjs", + "verify:no-secrets": "node scripts/verify-no-tracked-secrets.mjs", "collar": "node src/collar.mjs", "gateway": "node src/gateway.mjs", - "proxy": "node src/proxy.mjs" + "proxy": "node src/proxy.mjs", + "control-plane": "node src/control-plane.mjs", + "operator": "node src/operator/cli.mjs" }, "license": "MIT", "dependencies": { diff --git a/spikes/pi-wielder/pi-extension/agent.env.example b/spikes/pi-wielder/pi-extension/agent.env.example new file mode 100644 index 0000000..05df2fc --- /dev/null +++ b/spikes/pi-wielder/pi-extension/agent.env.example @@ -0,0 +1,6 @@ +WALLET_KERNEL_ORIGIN= +WALLET_KERNEL_AGENT_CREDENTIAL_FILE= +WALLET_KERNEL_PROVIDER_NAME= +WALLET_KERNEL_MODEL_NAME= +WALLET_KERNEL_MODEL_ROUTE= +WALLET_KERNEL_SKILL_ROUTE= diff --git a/spikes/pi-wielder/pi-extension/x402.ts b/spikes/pi-wielder/pi-extension/x402.ts index cc0727d..95c9074 100644 --- a/spikes/pi-wielder/pi-extension/x402.ts +++ b/spikes/pi-wielder/pi-extension/x402.ts @@ -1,139 +1,644 @@ -// pi-extension/x402.ts — makes Pi (@earendil-works/pi-coding-agent, v0.80.x) -// a Wielder without teaching it anything about payments. -// -// Install: copy this file into the project's `.pi/extensions/` (or -// `~/.pi/agent/extensions/`), run the paying proxy (`npm run proxy` plus -// collar + gateway, see RUNBOOK.md), then `/reload` inside pi. -// -// Note the shape of this extension: it points Pi at a localhost baseUrl and -// adds one HTTP tool and one display command. There is ZERO payment, wallet, -// or chain code here — Pi has no custom-fetch/retry hook, and it doesn't need -// one, because the paying proxy (src/proxy.mjs) answers every 402 upstream. -// That is ADR-0008: the Wielder is a wallet, not a harness. (Same pattern -// BlockRun's ClawRouter uses for OpenClaw on port 8402.) -// -// Written against the documented pi extension API (registerProvider / -// registerTool / registerCommand); exercised manually in the live demo — pi -// may not be installed in this environment, so nothing in the build depends -// on this file compiling. - -const PROXY = process.env.PI_WIELDER_PROXY ?? "http://localhost:8402"; -const HOSTED_SKILL_ID = "optimizing-claude-code-prompts"; -const HOSTED_SKILL_PATH = `/invoke/${encodeURIComponent(HOSTED_SKILL_ID)}`; - -const displayUsdc = (amountAtomic: string) => { - const padded = BigInt(amountAtomic).toString().padStart(7, "0"); - const value = `${padded.slice(0, -6)}.${padded.slice(-6)}`.replace(/0+$/, "").replace(/\.$/, ""); - return `$${value}`; -}; - -type SignedInvocationReceipt = { - receipt: { - quote: { amountAtomic: string }; - payment: { state: "settled" | "refunded"; txHash: string }; - execution: { state: "succeeded" | "failed" | "cancelled" }; - accounting: { - allocationState: "finalized" | "pending_cogs_reconciliation"; - protocolFeeAtomic?: string; - holderCredits: { recipientId: string; amountAtomic: string }[]; - ancestorCredits: { recipientId: string; amountAtomic: string }[]; - }; +// Pi client for the Wallet Kernel agent boundary. Pi knows one local bearer and +// fixed route names; it never receives wallet, payment, approval, or Spend +// Session authority. + +import fs from "node:fs"; +import crypto from "node:crypto"; +import path from "node:path"; + +import type { + AgentToolResult, + AgentToolUpdateCallback, + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; + +const DEFAULT_ORIGIN = "http://127.0.0.1:8402"; +const DEFAULT_PROVIDER_NAME = "wallet-kernel"; +const DEFAULT_MODEL_NAME = "wallet-kernel-model"; +const DEFAULT_MODEL_ROUTE = "example-model"; +const DEFAULT_SKILL_ROUTE = "example-skill"; +const MAXIMUM_CREDENTIAL_BYTES = 256; +const MAXIMUM_TOOL_INPUT_BYTES = 262_144; +const MAXIMUM_RESPONSE_BYTES = 1_048_576; +const APPROVAL_WAIT_PREFERENCE = "wait=300"; +const TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const INSTANCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/; +const CREDENTIAL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const RECEIPT_ID_PATTERN = /^[A-Za-z0-9:_-]{1,128}$/; +const HASH_PATTERN = /^[0-9a-f]{64}$/; +const REASON_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; +const ATOMIC_PATTERN = /^(0|[1-9][0-9]{0,77})$/; +const PURPOSE_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/; +const EXPIRY_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const TOOL_CALL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +type PiEnvironment = Record; +type PiCredential = Readonly<{ + agentInstanceId: string; + schemaVersion: 1; + token: string; +}>; +type FileSystem = Pick; +type InvokeSkillDetails = Readonly<{ + boundaryStatus: "returned" | "rejected" | "unavailable"; +}>; + +class PiBoundaryError extends Error { + code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "PiBoundaryError"; + this.code = code; + } +} + +function fail(code: string, message: string): never { + throw new PiBoundaryError(code, message); +} + +function exactLoopbackOrigin(value: string | undefined): string { + const candidate = value === undefined || value === "" ? DEFAULT_ORIGIN : value; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + fail("PI_KERNEL_ORIGIN_INVALID", "Wallet Kernel origin is invalid"); + } + if (parsed.protocol !== "http:" + || (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "[::1]") + || parsed.port === "" + || parsed.username !== "" + || parsed.password !== "" + || parsed.pathname !== "/" + || parsed.search !== "" + || parsed.hash !== "" + || candidate !== parsed.origin) { + fail("PI_KERNEL_ORIGIN_INVALID", "Wallet Kernel origin must be one exact loopback origin"); + } + return candidate; +} + +function boundedToken(value: string | undefined, fallback: string, label: string): string { + const candidate = value === undefined || value === "" ? fallback : value; + if (!TOKEN_PATTERN.test(candidate)) { + fail("PI_KERNEL_TOKEN_INVALID", `${label} must be one bounded token`); + } + return candidate; +} + +function canonicalBase64url(value: unknown, bytes: number, pattern: RegExp): value is string { + if (typeof value !== "string" || !pattern.test(value)) return false; + const decoded = Buffer.from(value, "base64url"); + const valid = decoded.length === bytes && decoded.toString("base64url") === value; + decoded.fill(0); + return valid; +} + +function validateCredential(value: unknown, text?: string): PiCredential { + if (!value || typeof value !== "object" || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) { + fail("PI_AGENT_CREDENTIAL_SCHEMA", "Agent credential must be one object"); + } + const record = value as Record; + const keys = Object.keys(record); + if (keys.length !== 3 + || keys[0] !== "agentInstanceId" + || keys[1] !== "schemaVersion" + || keys[2] !== "token" + || Reflect.ownKeys(record).length !== 3 + || record.schemaVersion !== 1 + || !canonicalBase64url(record.agentInstanceId, 16, INSTANCE_PATTERN) + || !canonicalBase64url(record.token, 32, CREDENTIAL_TOKEN_PATTERN)) { + fail("PI_AGENT_CREDENTIAL_SCHEMA", "Agent credential does not match the closed schema"); + } + const credential = Object.freeze({ + agentInstanceId: record.agentInstanceId, + schemaVersion: 1 as const, + token: record.token, + }); + const canonical = `{"agentInstanceId":"${credential.agentInstanceId}","schemaVersion":1,"token":"${credential.token}"}\n`; + if (text !== undefined && text !== canonical) { + fail("PI_AGENT_CREDENTIAL_SCHEMA", "Agent credential bytes are not canonical"); + } + return credential; +} + +function stableFileIdentity(stat: fs.BigIntStats) { + return Object.freeze({ + device: stat.dev, + inode: stat.ino, + mode: stat.mode & 0o7777n, + uid: stat.uid, + nlink: stat.nlink, + size: stat.size, + }); +} + +export function readPiAgentCredential({ + filePath, + fileSystem = fs, + getuid = process.getuid, +}: { + filePath: string; + fileSystem?: FileSystem; + getuid?: () => number; +}): PiCredential { + const uid = typeof getuid === "function" ? getuid() : 0; + if (!Number.isSafeInteger(uid) || uid <= 0) { + fail("PI_AGENT_IDENTITY_INVALID", "Pi must run as one non-root OS identity"); + } + if (typeof filePath !== "string" || !path.isAbsolute(filePath) + || path.resolve(filePath) !== filePath) { + fail("PI_AGENT_CREDENTIAL_PATH", "Agent credential path must be canonical and absolute"); + } + const noFollow = fileSystem.constants.O_NOFOLLOW; + if (!Number.isInteger(noFollow) || noFollow === 0) { + fail("PI_AGENT_CREDENTIAL_OPEN", "O_NOFOLLOW is unavailable"); + } + + let descriptor: number; + try { + descriptor = fileSystem.openSync( + filePath, + fileSystem.constants.O_RDONLY | noFollow, + ); + } catch { + fail("PI_AGENT_CREDENTIAL_OPEN", "Agent credential could not be opened safely"); + } + + const bytes = Buffer.alloc(MAXIMUM_CREDENTIAL_BYTES + 1); + try { + let before: fs.BigIntStats; + try { + before = fileSystem.fstatSync(descriptor, { bigint: true }) as fs.BigIntStats; + } catch { + fail("PI_AGENT_CREDENTIAL_AUTHORITY", "Agent credential metadata is unavailable"); + } + if (!before.isFile() || before.uid !== BigInt(uid) + || (before.mode & 0o7777n) !== 0o600n + || before.nlink !== 1n + || before.size <= 0n + || before.size > BigInt(MAXIMUM_CREDENTIAL_BYTES)) { + fail("PI_AGENT_CREDENTIAL_AUTHORITY", "Agent credential authority is invalid"); + } + + let offset = 0; + while (offset < bytes.length) { + let count: number; + try { + count = fileSystem.readSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset, + ); + } catch { + fail("PI_AGENT_CREDENTIAL_READ", "Agent credential could not be read"); + } + if (count === 0) break; + offset += count; + } + if (offset !== Number(before.size) || offset > MAXIMUM_CREDENTIAL_BYTES) { + fail("PI_AGENT_CREDENTIAL_SCHEMA", "Agent credential size changed during read"); + } + + let after: fs.BigIntStats; + try { + after = fileSystem.fstatSync(descriptor, { bigint: true }) as fs.BigIntStats; + } catch { + fail("PI_AGENT_CREDENTIAL_AUTHORITY", "Agent credential metadata changed"); + } + const beforeIdentity = stableFileIdentity(before); + const afterIdentity = stableFileIdentity(after); + if (Object.keys(beforeIdentity).some( + (key) => beforeIdentity[key as keyof typeof beforeIdentity] + !== afterIdentity[key as keyof typeof afterIdentity], + )) { + fail("PI_AGENT_CREDENTIAL_AUTHORITY", "Agent credential inode changed during read"); + } + + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, offset)); + } catch { + fail("PI_AGENT_CREDENTIAL_SCHEMA", "Agent credential is not UTF-8"); + } + let parsed: unknown; + try { + parsed = JSON.parse(text.slice(0, -1)); + } catch { + fail("PI_AGENT_CREDENTIAL_SCHEMA", "Agent credential is not canonical JSON"); + } + return validateCredential(parsed, text); + } finally { + bytes.fill(0); + try { + fileSystem.closeSync(descriptor); + } catch { + // The held credential bytes have already been zeroed. A failed close is + // terminal for activation and must not trigger a reopen. + fail("PI_AGENT_CREDENTIAL_CLOSE", "Agent credential descriptor did not close"); + } + } +} + +export function loadPiExtensionConfiguration({ + env = process.env, + readCredential = ({ filePath }: { filePath: string }) => readPiAgentCredential({ filePath }), +}: { + env?: PiEnvironment; + readCredential?: (input: { filePath: string }) => PiCredential; +} = {}) { + // Validate every attacker-steerable network value before the bearer enters + // memory. In particular, a hostile origin cannot cause a credential read. + const origin = exactLoopbackOrigin(env.WALLET_KERNEL_ORIGIN); + const providerName = boundedToken( + env.WALLET_KERNEL_PROVIDER_NAME, + DEFAULT_PROVIDER_NAME, + "provider name", + ); + const modelName = boundedToken( + env.WALLET_KERNEL_MODEL_NAME, + DEFAULT_MODEL_NAME, + "model name", + ); + const modelRoute = boundedToken( + env.WALLET_KERNEL_MODEL_ROUTE, + DEFAULT_MODEL_ROUTE, + "model route", + ); + const skillRoute = boundedToken( + env.WALLET_KERNEL_SKILL_ROUTE, + DEFAULT_SKILL_ROUTE, + "Skill route", + ); + const credentialPath = env.WALLET_KERNEL_AGENT_CREDENTIAL_FILE; + if (typeof credentialPath !== "string" || credentialPath === "" + || !path.isAbsolute(credentialPath) + || path.resolve(credentialPath) !== credentialPath) { + fail("PI_AGENT_CREDENTIAL_PATH", "Agent credential path must be canonical and absolute"); + } + if (typeof readCredential !== "function") { + fail("PI_AGENT_CREDENTIAL_READ", "Agent credential reader is invalid"); + } + const credential = validateCredential(readCredential({ filePath: credentialPath })); + return Object.freeze({ + origin, + providerName, + modelName, + modelRoute, + skillRoute, + credential, + }); +} + +function safeReason(value: unknown): string { + return typeof value === "string" && REASON_PATTERN.test(value) + ? value + : "UNAVAILABLE"; +} + +function compactReceipt(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return "receipt unavailable"; + } + const receipt = value as Record; + if (typeof receipt.id !== "string" || !RECEIPT_ID_PATTERN.test(receipt.id) + || typeof receipt.hash !== "string" || !HASH_PATTERN.test(receipt.hash)) { + return "receipt unavailable"; + } + return `receipt ${receipt.id} · sha256:${receipt.hash.slice(0, 12)}…`; +} + +function safeOrigin(value: unknown): string { + if (typeof value !== "string") return "seller unavailable"; + try { + const parsed = new URL(value); + const loopback = parsed.protocol === "http:" + && (parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]") + && parsed.port !== ""; + if ((parsed.protocol !== "https:" && !loopback) + || parsed.username !== "" || parsed.password !== "" + || parsed.pathname !== "/" || parsed.search !== "" || parsed.hash !== "" + || parsed.origin !== value) { + return "seller unavailable"; + } + return value; + } catch { + return "seller unavailable"; + } +} + +function completedResource(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const resource = value as Record; + if (typeof resource.body === "string") return resource.body; + if (resource.body && typeof resource.body === "object" && !Array.isArray(resource.body)) { + const body = resource.body as Record; + if (typeof body.output === "string") return body.output; + try { + return JSON.stringify(body); + } catch { + return null; + } + } + return null; +} + +function completedSummary(receiptValue: unknown): string { + const compact = compactReceipt(receiptValue); + if (!receiptValue || typeof receiptValue !== "object" || Array.isArray(receiptValue)) { + return compact; + } + const receipt = receiptValue as Record; + const charged = typeof receipt.chargedAtomic === "string" && ATOMIC_PATTERN.test(receipt.chargedAtomic) + ? receipt.chargedAtomic + : "unavailable"; + const remaining = typeof receipt.remainingSessionAtomic === "string" + && ATOMIC_PATTERN.test(receipt.remainingSessionAtomic) + ? receipt.remainingSessionAtomic + : "unavailable"; + const transaction = typeof receipt.transactionPrefix === "string" + && /^0x[0-9a-f]{1,64}$/.test(receipt.transactionPrefix) + ? receipt.transactionPrefix + : "unavailable"; + return `${compact} · charged ${charged} atomic · remaining ${remaining} atomic · tx ${transaction}`; +} + +function completedReplaySummary(outcome: Record): string | null { + if (outcome.terminalStatus !== "completed" + || typeof outcome.requestId !== "string" + || !RECEIPT_ID_PATTERN.test(outcome.requestId) + || !outcome.projections || typeof outcome.projections !== "object" + || Array.isArray(outcome.projections) + || !outcome.receipt || typeof outcome.receipt !== "object" + || Array.isArray(outcome.receipt)) { + return null; + } + const projections = outcome.projections as Record; + const receipt = outcome.receipt as Record; + if (typeof receipt.id !== "string" || !RECEIPT_ID_PATTERN.test(receipt.id) + || projections.request !== `/agent/v1/intents/${outcome.requestId}` + || projections.receipt !== `/agent/v1/receipts/${receipt.id}`) { + return null; + } + return `Completed replay: the charge is already recorded, provider output was not retained, and retrying this same call key will not spend again. Inspect ${projections.request} and ${projections.receipt}. ${compactReceipt(receipt)}`; +} + +export function renderWalletKernelOutcome(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return "Wallet Kernel returned an unsupported outcome."; + } + const outcome = value as Record; + const receipt = compactReceipt(outcome.receipt); + const reason = safeReason(outcome.reasonCode); + switch (outcome.status) { + case "completed": { + const resource = completedResource(outcome.resource); + if (resource === null) return "Wallet Kernel returned an unsupported outcome."; + return `${resource}\n\n[completed · ${completedSummary(outcome.receipt)}]`; + } + case "completed_replay": + return completedReplaySummary(outcome) + ?? "Wallet Kernel returned an unsupported outcome."; + case "payment_approval_required": { + if (!outcome.approval || typeof outcome.approval !== "object" + || Array.isArray(outcome.approval)) { + return "Wallet Kernel returned an unsupported outcome."; + } + const approval = outcome.approval as Record; + const seller = safeOrigin(approval.sellerOrigin); + const amount = typeof approval.amountAtomic === "string" + && ATOMIC_PATTERN.test(approval.amountAtomic) + ? approval.amountAtomic + : "unavailable"; + const purpose = typeof approval.purposeLabel === "string" + && PURPOSE_PATTERN.test(approval.purposeLabel) + ? approval.purposeLabel + : "purpose unavailable"; + const expiresAt = typeof approval.expiresAt === "string" + && EXPIRY_PATTERN.test(approval.expiresAt) + && Number.isFinite(Date.parse(approval.expiresAt)) + ? approval.expiresAt + : "expiry unavailable"; + return `Approval required: ${seller} · ${amount} atomic · ${purpose} · expires ${expiresAt}. Retry the same tool call after an operator decision.`; + } + case "payment_denied": + return `Payment denied: ${reason} · ${receipt}`; + case "payment_rejected": + return `Payment rejected or expired: ${reason} · ${receipt}`; + case "payment_failed": + return `Payment failed before settlement: ${reason} · ${receipt}`; + case "payment_unresolved": + return `Payment unresolved: ${reason} · ${receipt}`; + case "upstream_failed": + return `Upstream failed before payment: ${reason} · ${receipt}`; + case "execution_failed": + return `Execution failed after settlement: ${reason} · ${receipt}`; + case "execution_unknown": + return `Execution outcome unknown after settlement: ${reason} · ${receipt}`; + case "refunded": + return `Refunded: ${reason} · ${receipt}`; + default: + return "Wallet Kernel returned an unsupported outcome."; + } +} + +async function readBoundedOutcome(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + if (!/^application\/json(?:;[ \t]*charset=[A-Za-z0-9._-]+)?$/i.test(contentType)) { + fail("PI_KERNEL_RESPONSE_INVALID", "Wallet Kernel response is not JSON"); + } + const text = await response.text(); + if (Buffer.byteLength(text, "utf8") > MAXIMUM_RESPONSE_BYTES) { + fail("PI_KERNEL_RESPONSE_INVALID", "Wallet Kernel response is oversized"); + } + try { + return JSON.parse(text); + } catch { + fail("PI_KERNEL_RESPONSE_INVALID", "Wallet Kernel response is malformed"); + } +} + +function agentToolText( + text: string, + boundaryStatus: InvokeSkillDetails["boundaryStatus"], +): AgentToolResult { + return { + content: [{ type: "text", text }], + details: { boundaryStatus }, }; - receiptHash: string; - signature: string; - algorithm: "Ed25519"; - keyId: string; -}; - -// Minimal structural type for the documented extension surface, so this file -// stands alone without pi's type package. -type Pi = { - registerProvider(name: string, config: Record): void; - registerTool(tool: Record): void; - registerCommand(name: string, command: Record): void; - on?(event: string, handler: (...args: unknown[]) => unknown): void; -}; - -export default function activate(pi: Pi) { - // --- one provider, two model families, one paying wallet behind it ------- - // Everything Pi sends to these models 402-pays per call through the proxy. - pi.registerProvider("x402", { - baseUrl: `${PROXY}/v1`, - api: "openai-completions", // the proxy/gateway speak OpenAI chat-completions - // pi requires an apiKey field when models are defined; the paying proxy - // ignores Authorization entirely — payment IS the credential (ADR-0008). - apiKey: "x402-payment-is-the-credential", +} + +function toolAgentCallId(toolCallId: string): string | null { + if (!TOOL_CALL_ID_PATTERN.test(toolCallId)) return null; + return crypto.createHash("sha256") + .update("wallet-kernel.pi-tool-call.v1\0", "utf8") + .update(toolCallId, "utf8") + .digest("base64url"); +} + +function randomAgentCallId(): string { + const bytes = crypto.randomBytes(32); + try { + return bytes.toString("base64url"); + } finally { + bytes.fill(0); + } +} + +function exactHeader(headers: Record, name: string): string | null { + const matches = Object.entries(headers) + .filter(([key]) => key.toLowerCase() === name) + .map(([, value]) => value); + return matches.length === 1 && typeof matches[0] === "string" ? matches[0] : null; +} + +function replaceHeader( + headers: Record, + name: string, + value: string, +) { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === name) delete headers[key]; + } + headers[name] = value; +} + +function throwIfAborted(signal: AbortSignal | undefined) { + if (signal?.aborted) { + throw new DOMException("Wallet Kernel call aborted", "AbortError"); + } +} + +export default function activate( + pi: ExtensionAPI, + options: { + env?: PiEnvironment; + fetchFn?: typeof fetch; + readCredential?: (input: { filePath: string }) => PiCredential; + } = {}, +) { + const config = loadPiExtensionConfiguration({ + env: options.env, + readCredential: options.readCredential, + }); + const fetchFn = options.fetchFn ?? globalThis.fetch; + if (!pi || typeof pi.registerProvider !== "function" + || typeof pi.registerTool !== "function" || typeof pi.on !== "function" + || typeof fetchFn !== "function") { + fail("PI_EXTENSION_API_INVALID", "Pi extension API is unavailable"); + } + const headers = Object.freeze({ + Authorization: `WalletKernelAgent ${config.credential.token}`, + "Content-Type": "application/json", + Prefer: APPROVAL_WAIT_PREFERENCE, + }); + const walletAuthorization = headers.Authorization; + let pendingModelCallId: string | null = null; + + pi.on("before_provider_headers", (event) => { + if (exactHeader(event.headers, "authorization") !== walletAuthorization) return; + pendingModelCallId ??= randomAgentCallId(); + replaceHeader(event.headers, "Prefer", APPROVAL_WAIT_PREFERENCE); + replaceHeader(event.headers, "x-agent-call-id", pendingModelCallId); + }); + pi.on("message_end", (event) => { + const message = event.message; + if (pendingModelCallId !== null + && message.role === "assistant" + && message.provider === config.providerName + && message.model === config.modelName + && message.stopReason !== "error" + && message.stopReason !== "aborted") { + pendingModelCallId = null; + } + }); + pi.on("agent_settled", () => { + pendingModelCallId = null; + }); + + pi.registerProvider(config.providerName, { + baseUrl: `${config.origin}/agent/v1/openai/${config.modelRoute}`, + apiKey: "wallet-kernel-local", + api: "openai-completions", + authHeader: false, + headers, models: [ { - id: "claude-sonnet-4-6", - name: "claude via x402 (pay-per-call, Base Sepolia)", + id: config.modelName, + name: config.modelName, reasoning: false, input: ["text"], - // pi tracks per-token cost; ours is flat per-call and lands on the - // /ledger receipt view — zeros here so pi's meter doesn't double-count. - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - }, - { - id: "gpt-5.2", - name: "gpt via x402 (pay-per-call, Base Sepolia)", - reasoning: false, - input: ["text"], - // pi tracks per-token cost; ours is flat per-call and lands on the - // /ledger receipt view — zeros here so pi's meter doesn't double-count. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 8_192, + compat: { sendSessionAffinityHeaders: false }, }, ], }); - // --- the second asset class: a paid, hosted skill as a Pi tool ----------- pi.registerTool({ name: "invoke_skill", - description: - "Invoke the hosted, x402-paid skill 'optimizing-claude-code-prompts'. " + - "Send a rough prompt/request as `input`; returns the optimized prompt. " + - "Costs testnet USDC per call; payment and the pinned Collar receipt view " + - "are handled by the local paying proxy.", + label: "Invoke Skill", + description: "Invoke the fixed Skill route through the local Wallet Kernel.", parameters: { type: "object", properties: { - input: { type: "string", description: "The rough request to optimize" }, + input: { type: "string", description: "Input for the configured Skill" }, }, required: ["input"], + additionalProperties: false, }, - async execute(args: { input: string }) { - const res = await fetch(`${PROXY}${HOSTED_SKILL_PATH}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ input: args.input }), + async execute( + toolCallId: string, + params: { input: string }, + signal: AbortSignal | undefined, + _onUpdate: AgentToolUpdateCallback | undefined, + _ctx: ExtensionContext, + ): Promise> { + const agentCallId = typeof toolCallId === "string" + ? toolAgentCallId(toolCallId) + : null; + if (agentCallId === null + || !params || typeof params !== "object" || Array.isArray(params) + || Reflect.ownKeys(params).length !== 1 + || typeof params.input !== "string" + || Buffer.byteLength(params.input, "utf8") > MAXIMUM_TOOL_INPUT_BYTES) { + return agentToolText("invoke_skill call rejected.", "rejected"); + } + const requestHeaders = Object.freeze({ + ...headers, + "x-agent-call-id": agentCallId, }); - if (!res.ok) return `invoke_skill failed (HTTP ${res.status})`; - const { output, receipt } = (await res.json()) as { - output: string; - receipt: SignedInvocationReceipt; - }; - const invocation = receipt.receipt; - const accounting = invocation.accounting; - const claims = accounting.allocationState === "finalized" && accounting.protocolFeeAtomic - ? [ - ...accounting.holderCredits.map((credit) => `${credit.recipientId} ${displayUsdc(credit.amountAtomic)}`), - ...accounting.ancestorCredits.map((credit) => `${credit.recipientId} ${displayUsdc(credit.amountAtomic)}`), - `treasury ${displayUsdc(accounting.protocolFeeAtomic)}`, - ].join(" / ") - : "full gross held for accounting reconciliation"; - return `${output}\n\n[${invocation.execution.state} · paid ${displayUsdc(invocation.quote.amountAtomic)} · tx ${invocation.payment.txHash.slice(0, 10)}… · ${claims} · receipt ${receipt.receiptHash.slice(0, 10)}…]`; - }, - }); - - // --- /ledger: the session-local receipt view rendered by the proxy ------- - pi.registerCommand("ledger", { - description: "Show this session's local x402 receipt view (inference + Skills)", - async handler() { - const res = await fetch(`${PROXY}/ledger`); - return res.ok ? await res.text() : `ledger unavailable (HTTP ${res.status}) — is the proxy running?`; + for (let attempt = 0; attempt < 2; attempt += 1) { + throwIfAborted(signal); + try { + const response = await fetchFn( + `${config.origin}/agent/v1/invoke/${config.skillRoute}`, + { + method: "POST", + headers: requestHeaders, + body: JSON.stringify({ input: params.input }), + signal, + }, + ); + const outcome = await readBoundedOutcome(response); + return agentToolText(renderWalletKernelOutcome(outcome), "returned"); + } catch { + throwIfAborted(signal); + if (attempt === 1) { + return agentToolText( + "Wallet Kernel unavailable after one same-key retry.", + "unavailable", + ); + } + } + } + return agentToolText("Wallet Kernel unavailable after one same-key retry.", "unavailable"); }, }); } diff --git a/spikes/pi-wielder/routes/base-sepolia.example.json b/spikes/pi-wielder/routes/base-sepolia.example.json new file mode 100644 index 0000000..814c8ee --- /dev/null +++ b/spikes/pi-wielder/routes/base-sepolia.example.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "routes": [ + { + "id": "example-model", + "kind": "openai-chat", + "method": "POST", + "upstreamUrl": "https://seller.example/paid/chat/completions", + "resourceDescription": "Wallet Kernel example model route", + "resourceMimeType": "application/json", + "purposeLabel": "model.infer", + "requestContentTypes": ["application/json"], + "maximumRequestBytes": 262144, + "maximumResponseBytes": 1048576 + }, + { + "id": "example-skill", + "kind": "tool", + "method": "POST", + "upstreamUrl": "https://seller.example/paid/skill", + "resourceDescription": "Wallet Kernel example Skill route", + "resourceMimeType": "application/json", + "purposeLabel": "skill.invoke", + "requestContentTypes": ["application/json"], + "maximumRequestBytes": 262144, + "maximumResponseBytes": 1048576 + } + ] +} diff --git a/spikes/pi-wielder/scripts/agent-isolation-probe-worker.mjs b/spikes/pi-wielder/scripts/agent-isolation-probe-worker.mjs new file mode 100644 index 0000000..a847e86 --- /dev/null +++ b/spikes/pi-wielder/scripts/agent-isolation-probe-worker.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const PROTECTED_READ_FIELDS = Object.freeze([ + 'authorityDirectory', 'database', 'operatorToken', 'receiptKey', 'kernelEnvironment', +]); +const WRITE_FIELDS = Object.freeze([ + 'releaseTreeWrite', 'dependencyTreeWrite', 'serviceArtifactsWrite', + 'kernelEnvironmentParentWrite', +]); + +function fail(message) { throw new Error(message); } + +function positive(value, label) { + if (typeof value !== 'string' || !/^[1-9][0-9]*$/.test(value) + || !Number.isSafeInteger(Number(value)) || String(Number(value)) !== value) { + fail(`${label} is invalid`); + } + return Number(value); +} + +export function dropToAgentIdentity({ uid, gid, processApi = process }) { + if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) { + fail('Agent identity is invalid'); + } + processApi.setgroups([]); + processApi.setgid(gid); + processApi.setuid(uid); + if (processApi.getuid() !== uid || processApi.geteuid?.() !== uid + || processApi.getgid() !== gid || processApi.getegid?.() !== gid + || processApi.getgroups().some((group) => group !== gid)) { + fail('Agent identity drop did not remove privilege and supplementary groups'); + } +} + +function denialCode(operation) { + try { + operation(); + return 'UNEXPECTED_SUCCESS'; + } catch (error) { + return error?.code === 'EPERM' ? 'EPERM' : error?.code === 'EACCES' ? 'EACCES' : 'UNEXPECTED_ERROR'; + } +} + +function readProbe(target) { + let descriptor; + try { + descriptor = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + fs.readSync(descriptor, Buffer.alloc(1), 0, 1, 0); + return 'READABLE'; + } catch (error) { + if (error?.code === 'EPERM') return 'EPERM'; + if (error?.code === 'EACCES') return 'EACCES'; + return 'UNEXPECTED_ERROR'; + } finally { if (descriptor !== undefined) fs.closeSync(descriptor); } +} + +export function runProbeRequest(request) { + if (!request || typeof request !== 'object' || Array.isArray(request) + || Reflect.ownKeys(request).length !== 3 + || !['credentialPath', 'protectedReadPaths', 'writePaths'].every((key) => Object.hasOwn(request, key))) { + fail('isolation worker request fields are invalid'); + } + if (typeof request.credentialPath !== 'string' || !path.isAbsolute(request.credentialPath)) { + fail('credential path is invalid'); + } + const protectedPaths = request.protectedReadPaths; + const writePaths = request.writePaths; + if (!protectedPaths || !writePaths || typeof protectedPaths !== 'object' + || typeof writePaths !== 'object' + || Reflect.ownKeys(protectedPaths).length !== PROTECTED_READ_FIELDS.length + || Reflect.ownKeys(writePaths).length !== WRITE_FIELDS.length) { + fail('isolation worker probe maps are invalid'); + } + const result = {}; + for (const field of PROTECTED_READ_FIELDS) { + if (!Object.hasOwn(protectedPaths, field) || typeof protectedPaths[field] !== 'string' + || !path.isAbsolute(protectedPaths[field])) fail('protected read path is invalid'); + result[field] = readProbe(protectedPaths[field]); + } + result.agentCredential = readProbe(request.credentialPath); + for (const field of WRITE_FIELDS) { + if (!Object.hasOwn(writePaths, field) || typeof writePaths[field] !== 'string' + || !path.isAbsolute(writePaths[field])) fail('write probe path is invalid'); + const target = writePaths[field]; + result[field] = denialCode(() => { + const descriptor = fs.openSync(target, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o600); + fs.closeSync(descriptor); + }); + } + return Object.freeze(result); +} + +async function direct() { + const argv = process.argv.slice(2); + if (argv.length !== 4 || argv[0] !== '--agent-uid' || argv[2] !== '--agent-gid') { + fail('isolation worker arguments are invalid'); + } + const uid = positive(argv[1], 'Agent UID'); + const gid = positive(argv[3], 'Agent GID'); + if (Reflect.ownKeys(process.env).some((name) => name !== 'NODE_CHANNEL_FD')) { + fail('isolation worker inherited an unrecognized environment field'); + } + dropToAgentIdentity({ uid, gid }); + process.send?.({ type: 'ready', pid: process.pid }); + let handled = false; + process.on('message', (request) => { + if (handled) process.exit(1); + handled = true; + try { + process.send?.({ type: 'result', probeResults: runProbeRequest(request) }); + process.exit(0); + } catch { + process.send?.({ type: 'failed', code: 'ISOLATION_PROBE_FAILED' }); + process.exit(1); + } + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + direct().catch(() => { process.exitCode = 1; }); +} diff --git a/spikes/pi-wielder/scripts/build-release-manifest.mjs b/spikes/pi-wielder/scripts/build-release-manifest.mjs new file mode 100644 index 0000000..6870047 --- /dev/null +++ b/spikes/pi-wielder/scripts/build-release-manifest.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { canonicalJson } from '../src/kernel/canonical.mjs'; +import { buildReleaseManifest } from '../src/kernel/release-integrity.mjs'; + +function readCanonicalInput(filePath) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath) || path.resolve(filePath) !== filePath) { + throw new Error('manifest build input path must be canonical and absolute'); + } + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.nlink !== 1 || stat.size <= 0 || stat.size > 64 * 1024) { + throw new Error('manifest build input must be one bounded regular file'); + } + const bytes = fs.readFileSync(descriptor); + const value = JSON.parse(bytes.toString('utf8')); + if (!bytes.equals(Buffer.from(`${canonicalJson(value)}\n`))) { + throw new Error('manifest build input must be canonical JSON plus one newline'); + } + return value; + } finally { fs.closeSync(descriptor); } +} + +export function writeReleaseManifestExclusive({ manifestPath, manifest }) { + if (typeof manifestPath !== 'string' || !path.isAbsolute(manifestPath) + || path.resolve(manifestPath) !== manifestPath) { + throw new Error('release manifest output path must be canonical and absolute'); + } + const bytes = Buffer.from(`${canonicalJson(manifest)}\n`); + const descriptor = fs.openSync(manifestPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o644); + try { + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + } finally { fs.closeSync(descriptor); } + const parent = fs.openSync(path.dirname(manifestPath), + fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW); + try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); } + return bytes.length; +} + +export function runBuildReleaseManifest({ argv, stdout = process.stdout, stderr = process.stderr }) { + try { + if (!Array.isArray(argv) || argv.length !== 2 || argv[0] !== '--input') { + throw new Error('usage: build-release-manifest.mjs --input ABSOLUTE_CANONICAL_JSON'); + } + if (process.getuid?.() !== 0 && process.env.WALLET_KERNEL_DETERMINISTIC_BUILD !== '1') { + throw new Error('live release manifest creation requires root'); + } + const input = readCanonicalInput(argv[1]); + const manifest = buildReleaseManifest(input); + writeReleaseManifestExclusive({ manifestPath: input.manifestPath, manifest }); + stdout.write(`${manifest.releaseTreeHash}\n`); + return 0; + } catch (error) { + stderr.write(`release manifest build failed: ${error?.code ?? 'ERROR'}\n`); + return 1; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = runBuildReleaseManifest({ argv: process.argv.slice(2) }); +} diff --git a/spikes/pi-wielder/scripts/inspect-systemd-effective.mjs b/spikes/pi-wielder/scripts/inspect-systemd-effective.mjs new file mode 100644 index 0000000..eff6e9a --- /dev/null +++ b/spikes/pi-wielder/scripts/inspect-systemd-effective.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { canonicalJson, exactRecord, frozenCopy, KernelError, sha256 } from '../src/kernel/canonical.mjs'; + +const MAXIMUM_OUTPUT = 64 * 1024; +const EFFECTIVE_DOMAIN = 'wallet-kernel/systemd-effective/v1\0'; +export const COMMON_PROPERTIES = Object.freeze([ + 'Id', 'LoadState', 'FragmentPath', 'DropInPaths', 'NeedDaemonReload', 'Transient', + 'UnitFileState', +]); +export const SERVICE_PROPERTIES = Object.freeze([ + ...COMMON_PROPERTIES, 'User', 'Group', 'SupplementaryGroups', 'Environment', + 'EnvironmentFiles', 'PassEnvironment', + 'ExecStartPreEx', 'ExecStartEx', 'Restart', 'RestartUSec', 'RestartPreventExitStatus', + 'UMask', 'NoNewPrivileges', + 'CapabilityBoundingSet', 'AmbientCapabilities', 'ProtectSystem', 'ProtectHome', + 'PrivateTmp', 'PrivateDevices', 'ProtectKernelTunables', 'ProtectKernelModules', + 'ProtectControlGroups', 'LockPersonality', 'RestrictAddressFamilies', 'ReadWritePaths', + 'UnsetEnvironment', 'Requires', 'After', +]); +export const SOCKET_PROPERTIES = Object.freeze([ + ...COMMON_PROPERTIES, 'Listen', 'Accept', 'Service', 'FileDescriptorName', 'ReusePort', +]); + +function fail(code, message, cause) { + throw new KernelError(code, message, cause ? { cause } : undefined); +} + +function sortedSet(value) { + if (typeof value !== 'string' || /[\r\n\0]/.test(value)) fail('SYSTEMD_EFFECTIVE', 'systemd set is malformed'); + return value === '' ? [] : value.trim().split(/\s+/).sort(); +} + +export function parseSystemctlShow(output, properties, maximumBytes = MAXIMUM_OUTPUT) { + if (typeof output !== 'string' || Buffer.byteLength(output) > maximumBytes || output.includes('\0')) { + fail('SYSTEMD_OUTPUT', 'systemctl output is not bounded text'); + } + if (!Array.isArray(properties) || new Set(properties).size !== properties.length) { + fail('SYSTEMD_OUTPUT', 'systemctl property request is invalid'); + } + const allowed = new Set(properties); + const result = {}; + for (const line of output.split('\n')) { + if (line === '') continue; + const split = line.indexOf('='); + if (split < 1) fail('SYSTEMD_OUTPUT', 'systemctl output contains a malformed line'); + const key = line.slice(0, split); + if (!allowed.has(key)) fail('SYSTEMD_OUTPUT', 'systemctl output contains an unknown property'); + if (Object.hasOwn(result, key)) fail('SYSTEMD_OUTPUT', 'systemctl output contains a duplicate property'); + result[key] = line.slice(split + 1); + } + if (properties.some((property) => !Object.hasOwn(result, property))) { + fail('SYSTEMD_OUTPUT', 'systemctl output is missing a requested property'); + } + return result; +} + +function parseExec(value, label) { + if (typeof value !== 'string' || value.length > 8192 || /[\r\n\0]/.test(value)) { + fail('SYSTEMD_EXEC', `${label} is malformed`); + } + const match = /^\{ path=([^ ;]+) ; argv\[\]=([^;]+?) ; flags=([^;]*?) ; \}$/.exec(value); + if (!match) fail('SYSTEMD_EXEC', `${label} has an unsupported structure`); + const executable = match[1]; + const argv = match[2].trim().split(/\s+/); + const flags = match[3].trim() === '' ? [] : match[3].trim().split(/\s+/).sort(); + if (!path.isAbsolute(executable) || argv[0] !== executable + || argv.some((token) => token === '' || /[\r\n\0]/.test(token))) { + fail('SYSTEMD_EXEC', `${label} executable or argv is invalid`); + } + return { executable, argv, flags }; +} + +function requireValue(actual, expected, label) { + if (actual !== expected) fail('SYSTEMD_EFFECTIVE', `${label} differs from the rendered contract`); +} + +export function validateEffectiveProjection({ service, socket, expected }) { + const serviceData = exactRecord(service, SERVICE_PROPERTIES, [], + 'SYSTEMD_EFFECTIVE', 'effective service'); + const socketData = exactRecord(socket, SOCKET_PROPERTIES, [], + 'SYSTEMD_EFFECTIVE', 'effective socket'); + const expectedData = exactRecord(expected, [ + 'kernelUid', 'kernelGid', 'releaseRoot', 'nodePath', 'environmentPath', + 'servicePath', 'socketPath', 'readWritePaths', + ], [], 'SYSTEMD_EFFECTIVE', 'expected systemd configuration'); + for (const [data, unitId, fragment, state] of [ + [serviceData, 'wallet-kernel.service', expectedData.servicePath, 'static'], + [socketData, 'wallet-kernel-console.socket', expectedData.socketPath, 'enabled'], + ]) { + requireValue(data.Id, unitId, `${unitId} Id`); + requireValue(data.LoadState, 'loaded', `${unitId} LoadState`); + requireValue(data.FragmentPath, fragment, `${unitId} FragmentPath`); + requireValue(data.DropInPaths, '', `${unitId} DropInPaths`); + requireValue(data.NeedDaemonReload, 'no', `${unitId} NeedDaemonReload`); + requireValue(data.Transient, 'no', `${unitId} Transient`); + requireValue(data.UnitFileState, state, `${unitId} UnitFileState`); + } + const preflight = parseExec(serviceData.ExecStartPreEx, 'ExecStartPreEx'); + const main = parseExec(serviceData.ExecStartEx, 'ExecStartEx'); + const expectedPreflight = [ + expectedData.nodePath, `${expectedData.releaseRoot}/scripts/preflight-live-deployment.mjs`, + '--release-manifest', `${expectedData.releaseRoot}/manifest.json`, + '--kernel-uid', expectedData.kernelUid, '--kernel-gid', expectedData.kernelGid, + ]; + const expectedMain = [expectedData.nodePath, `${expectedData.releaseRoot}/src/control-plane.mjs`]; + if (canonicalJson(preflight.argv) !== canonicalJson(expectedPreflight) + || canonicalJson(preflight.flags) !== canonicalJson(['privileged']) + || canonicalJson(main.argv) !== canonicalJson(expectedMain) + || main.flags.length !== 0) { + fail('SYSTEMD_EXEC', 'loaded executable argv or flags differ from the rendered contract'); + } + const scalar = { + User: expectedData.kernelUid, Group: expectedData.kernelGid, SupplementaryGroups: '', + Environment: `WALLET_KERNEL_ENV_FILE=${expectedData.environmentPath}`, + EnvironmentFiles: '', PassEnvironment: '', + Restart: 'no', RestartUSec: '2s', RestartPreventExitStatus: '', + UMask: '0077', NoNewPrivileges: 'yes', CapabilityBoundingSet: '', AmbientCapabilities: '', + ProtectSystem: 'strict', ProtectHome: 'yes', PrivateTmp: 'yes', PrivateDevices: 'yes', + ProtectKernelTunables: 'yes', ProtectKernelModules: 'yes', ProtectControlGroups: 'yes', + LockPersonality: 'yes', Accept: 'no', Service: 'wallet-kernel.service', + FileDescriptorName: 'wallet-kernel-console', ReusePort: 'no', + }; + for (const [field, expectedValue] of Object.entries(scalar)) { + const target = Object.hasOwn(serviceData, field) ? serviceData : socketData; + requireValue(target[field], expectedValue, field); + } + const sets = { + RestrictAddressFamilies: ['AF_INET', 'AF_INET6', 'AF_UNIX'], + ReadWritePaths: [...expectedData.readWritePaths].sort(), + UnsetEnvironment: [ + 'NODE_OPTIONS', 'NODE_PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', 'LD_DEBUG', + 'LD_PROFILE', 'GLIBC_TUNABLES', 'GCONV_PATH', 'PRIVATE_KEY', 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', 'CDP_API_KEY_ID', 'CDP_API_KEY_SECRET', 'CDP_WALLET_SECRET', + 'CDP_WALLET_NAME', 'WALLET_KERNEL_BASE_SEPOLIA_RPC_URL', + ].sort(), + Requires: ['wallet-kernel-console.socket'], + After: ['network-online.target', 'wallet-kernel-console.socket'].sort(), + }; + for (const [field, expectedSet] of Object.entries(sets)) { + if (canonicalJson(sortedSet(serviceData[field])) !== canonicalJson(expectedSet)) { + fail('SYSTEMD_EFFECTIVE', `${field} differs from the rendered contract`); + } + } + if (!/^127\.0\.0\.1:8405 \(Stream\)$/.test(socketData.Listen)) { + fail('SYSTEMD_EFFECTIVE', 'socket must contain exactly one loopback stream'); + } + const normalized = { + service: { ...serviceData, ExecStartPreEx: preflight, ExecStartEx: main, + RestrictAddressFamilies: sets.RestrictAddressFamilies, + ReadWritePaths: sets.ReadWritePaths, UnsetEnvironment: sets.UnsetEnvironment, + Requires: sets.Requires, After: sets.After }, + socket: socketData, + }; + return Object.freeze({ + service: frozenCopy(serviceData), socket: frozenCopy(socketData), + normalized: frozenCopy(normalized), + effectiveConfigHash: sha256(`${EFFECTIVE_DOMAIN}${canonicalJson(normalized)}`), + }); +} + +function assertSystemctl(systemctlPath) { + if (systemctlPath !== '/usr/bin/systemctl') fail('SYSTEMD_BINARY', 'systemctl path must equal /usr/bin/systemctl'); + const stat = fs.lstatSync(systemctlPath, { bigint: true }); + if (!stat.isFile() || stat.isSymbolicLink() || Number(stat.uid) !== 0 + || (Number(stat.mode & 0o7777n) & 0o022) !== 0) { + fail('SYSTEMD_BINARY', 'systemctl must be an immutable root-owned regular file'); + } + const bytes = fs.readFileSync(systemctlPath); + return { + executablePathHash: sha256(`wallet-kernel/absolute-path/v1\0${systemctlPath}`), + executableSha256: sha256(bytes), + }; +} + +function show(systemctlPath, unit, properties) { + const property = properties.join(','); + const output = execFileSync(systemctlPath, [ + 'show', '--all', '--no-pager', `--property=${property}`, unit, + ], { encoding: 'utf8', maxBuffer: MAXIMUM_OUTPUT, env: { PATH: '/usr/bin:/bin' } }); + return parseSystemctlShow(output, properties); +} + +export async function inspectEffectiveSystemd(input) { + if (input?.integration === true && Object.keys(input).length === 1) { + // Used only as a guard in the optional test; a real inspection requires exact expected data. + fail('SYSTEMD_INTEGRATION_FIXTURE', 'live systemd inspection requires installed fixture paths and expected configuration'); + } + const options = exactRecord(input, ['expected'], ['systemctlPath'], + 'SYSTEMD_INSPECT_INPUT', 'systemd inspection input'); + const systemctlPath = options.systemctlPath ?? '/usr/bin/systemctl'; + const binary = assertSystemctl(systemctlPath); + const service = show(systemctlPath, 'wallet-kernel.service', SERVICE_PROPERTIES); + const socket = show(systemctlPath, 'wallet-kernel-console.socket', SOCKET_PROPERTIES); + const projection = validateEffectiveProjection({ service, socket, expected: options.expected }); + const manager = execFileSync(systemctlPath, [ + 'show', '--all', '--no-pager', '--property=Version', '--', '-.mount', + ], { encoding: 'utf8', maxBuffer: 4096, env: { PATH: '/usr/bin:/bin' } }); + const managerVersion = parseSystemctlShow(manager, ['Version'], 4096).Version; + const client = execFileSync(systemctlPath, ['--version'], { + encoding: 'utf8', maxBuffer: 4096, env: { PATH: '/usr/bin:/bin' }, + }).split('\n')[0]; + if (!managerVersion || managerVersion.length > 256 || !client || client.length > 256) { + fail('SYSTEMD_VERSION', 'systemd version metadata is invalid'); + } + return Object.freeze({ + platform: process.platform, + managerVersion, + systemctlVersion: client, + systemctlExecutablePathHash: binary.executablePathHash, + systemctlExecutableSha256: binary.executableSha256, + effectiveConfigHash: projection.effectiveConfigHash, + projection: projection.normalized, + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stderr.write('inspect-systemd-effective.mjs must be invoked through the privileged installer with an exact configuration\n'); + process.exitCode = 2; +} diff --git a/spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs b/spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs new file mode 100644 index 0000000..b01d6c2 --- /dev/null +++ b/spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs @@ -0,0 +1,2267 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +import { keccak256, toBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { runAgentCredentialCli } from '../../src/agent/credential-cli.mjs'; +import { canonicalJson, sha256 } from '../../src/kernel/canonical.mjs'; +import { verifySignedReceipt } from '../../src/kernel/receipt-signing.mjs'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); +const FIXTURES = path.join(ROOT, 'tests', 'fixtures'); +const PRELOAD = path.join(FIXTURES, 'loopback-only-preload.cjs'); +const MODEL_PROCESS = path.join(FIXTURES, 'pi-model-process.mjs'); +const SELLER_PROCESS = path.join(FIXTURES, 'x402-v2-seller-process.mjs'); +const CONTROL_PROCESS = path.join(FIXTURES, 'control-plane-process.mjs'); +const PI_PROCESS = path.join(FIXTURES, 'pi-client-process.mjs'); +const REPOSITORY_PI = path.join(ROOT, 'node_modules', '.bin', 'pi'); +const REPOSITORY_PI_TARGET = path.join( + ROOT, + 'node_modules', + '@earendil-works', + 'pi-coding-agent', + 'dist', + 'cli.js', +); + +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const CHILD_DEADLINE_MS = 30_000; +const CHILD_GRACE_MS = 2_000; +const MAXIMUM_OUTPUT_BYTES = 1_048_576; + +export const SPEND_CONTROL_PROCESS_INVARIANT_IDS = Object.freeze([ + 'allowed-payment-settles-once', + 'policy-denials-never-sign', + 'approval-survives-restart', + 'denial-and-expiry-never-sign', + 'changed-challenge-terminalizes-approval', + 'settled-http-failures-commit-and-block', + 'body-loss-execution-reconciliation', + 'pre-settlement-loss-holds-budget', + 'post-signature-ambiguity-is-unresolved', + 'trusted-settlement-needs-execution-evidence', + 'refund-releases-only-after-full-proof', + 'fresh-process-verifies-authority', + 'pi-carries-no-authority-headers', + 'all-egress-is-loopback', + 'unauthorized-calls-fail-before-body', + 'credential-reattaches-session', + 'tighter-policy-requires-guarded-transition', + 'revocation-recovery-and-replacement', +]); +const INVARIANT_IDS = SPEND_CONTROL_PROCESS_INVARIANT_IDS; +export const SPEND_CONTROL_PROCESS_CHILD_NAMES = Object.freeze([ + 'model', + 'seller', + 'bootstrap', + 'control-initial', + 'control-restarted', + 'pi-tool-approval', + 'pi-model-approval', + 'control-recovery', + 'bootstrap-replacement', + 'control-replacement', + 'control-verifier', +]); + +const buyerAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-deterministic-adapter-test-only')), +); +const WALLET_ADDRESS = buyerAccount.address.toLowerCase(); + +function fail(code) { + const error = new Error(code); + error.code = code; + throw error; +} + +function canonicalAbsolute(value, code, { file = false, directory = false } = {}) { + if (typeof value !== 'string' || !path.isAbsolute(value) + || path.resolve(value) !== value || value.includes('\0')) fail(code); + const real = fs.realpathSync(value); + if (real !== value) fail(code); + const stat = fs.lstatSync(value, { bigint: true }); + if (stat.isSymbolicLink() || (file && !stat.isFile()) || (directory && !stat.isDirectory())) { + fail(code); + } + return value; +} + +function validateAuthorityDirectory(value) { + const directory = canonicalAbsolute(value, 'PROCESS_AUTHORITY_INVALID', { directory: true }); + const stat = fs.lstatSync(directory, { bigint: true }); + if (stat.uid !== BigInt(process.getuid()) || (stat.mode & 0o7777n) !== 0o700n + || fs.readdirSync(directory).length !== 0) { + fail('PROCESS_AUTHORITY_INVALID'); + } + return directory; +} + +function validateNodeExecutable(value) { + const executable = canonicalAbsolute(value, 'PROCESS_NODE_INVALID', { file: true }); + fs.accessSync(executable, fs.constants.X_OK); + return executable; +} + +function validatePiExecutable(value) { + if (typeof value !== 'string' || !path.isAbsolute(value) + || path.resolve(value) !== value || value.includes('\0')) fail('PROCESS_PI_INVALID'); + const link = fs.lstatSync(value, { bigint: true }); + if (!link.isSymbolicLink() + || fs.realpathSync(value) !== fs.realpathSync(REPOSITORY_PI_TARGET) + || value !== REPOSITORY_PI) { + fail('PROCESS_PI_INVALID'); + } + return value; +} + +function createDirectory(filePath, mode) { + fs.mkdirSync(filePath, { mode }); + fs.chmodSync(filePath, mode); +} + +function createEmptyFile(filePath) { + fs.writeFileSync(filePath, '', { flag: 'wx', mode: 0o600 }); + fs.chmodSync(filePath, 0o600); +} + +function writeCanonicalFile(filePath, value) { + fs.writeFileSync(filePath, `${canonicalJson(value)}\n`, { flag: 'wx', mode: 0o600 }); + fs.chmodSync(filePath, 0o600); +} + +function replaceCanonicalFile(filePath, value) { + const descriptor = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(descriptor, { bigint: true }); + if (!stat.isFile() || stat.uid !== BigInt(process.getuid()) + || (stat.mode & 0o7777n) !== 0o600n || stat.nlink !== 1n) { + fail('PROCESS_AUTHORITY_INVALID'); + } + const bytes = Buffer.from(`${canonicalJson(value)}\n`, 'utf8'); + try { + fs.ftruncateSync(descriptor, 0); + fs.writeSync(descriptor, bytes, 0, bytes.length, 0); + fs.fsyncSync(descriptor); + } finally { + bytes.fill(0); + } + } finally { + fs.closeSync(descriptor); + } +} + +function delay(milliseconds) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, milliseconds); + timer.unref(); + }); +} + +function readJsonFile(filePath, fallback = null) { + const text = fs.readFileSync(filePath, 'utf8'); + if (text.length === 0) return fallback; + try { + return JSON.parse(text); + } catch { + fail('PROCESS_STATE_INVALID'); + } +} + +function boundedOutput(stream, onOverflow) { + const chunks = []; + let length = 0; + stream.on('data', (value) => { + const bytes = Buffer.from(value); + length += bytes.length; + if (length > MAXIMUM_OUTPUT_BYTES) { + onOverflow(); + return; + } + chunks.push(bytes); + }); + return () => { + const bytes = Buffer.concat(chunks); + const hash = sha256(bytes); + bytes.fill(0); + for (const chunk of chunks) chunk.fill(0); + return hash; + }; +} + +function killGroup(child, signal) { + if (!child.pid) return; + try { + process.kill(-child.pid, signal); + } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } +} + +function startChild({ name, nodeExecutable, script, argv = [], env }) { + const child = spawn(nodeExecutable, [script, ...argv], { + cwd: ROOT, + detached: true, + env, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + const messages = []; + const listeners = new Set(); + let deadlineExpired = false; + let outputExceeded = false; + let exitRecord = null; + const notify = () => { + for (const listener of [...listeners]) listener(); + }; + child.on('message', (message) => { + if (message && typeof message === 'object') messages.push(message); + notify(); + }); + const overflow = () => { + if (outputExceeded) return; + outputExceeded = true; + killGroup(child, 'SIGTERM'); + }; + const stdoutHash = boundedOutput(child.stdout, overflow); + const stderrHash = boundedOutput(child.stderr, overflow); + const exited = new Promise((resolve) => { + child.once('error', () => { + exitRecord = Object.freeze({ code: 1, signal: null }); + notify(); + resolve(exitRecord); + }); + child.once('exit', (code, signal) => { + exitRecord = Object.freeze({ code: code ?? 1, signal }); + notify(); + resolve(exitRecord); + }); + }); + let graceTimer = null; + const deadlineTimer = setTimeout(() => { + deadlineExpired = true; + killGroup(child, 'SIGTERM'); + graceTimer = setTimeout(() => killGroup(child, 'SIGKILL'), CHILD_GRACE_MS); + graceTimer.unref(); + notify(); + }, CHILD_DEADLINE_MS); + deadlineTimer.unref(); + + const waitMessage = (predicate) => new Promise((resolve, reject) => { + let timer; + const inspect = () => { + const fatal = messages.find((message) => message.type === 'fatal'); + if (fatal) { + cleanup(); + reject(Object.assign(new Error(fatal.code), { code: fatal.code })); + return; + } + const found = messages.find(predicate); + if (found) { + cleanup(); + resolve(found); + return; + } + if (deadlineExpired) { + cleanup(); + reject(Object.assign(new Error('PROCESS_DEADLINE'), { code: 'PROCESS_DEADLINE' })); + return; + } + if (outputExceeded || exitRecord !== null) { + cleanup(); + reject(Object.assign(new Error('PROCESS_CHILD_FAILED'), { code: 'PROCESS_CHILD_FAILED' })); + } + }; + const cleanup = () => { + clearTimeout(timer); + listeners.delete(inspect); + }; + listeners.add(inspect); + timer = setTimeout(() => { + cleanup(); + reject(Object.assign(new Error('PROCESS_DEADLINE'), { code: 'PROCESS_DEADLINE' })); + }, CHILD_DEADLINE_MS); + timer.unref(); + inspect(); + }); + + const waitExit = async () => { + const result = await exited; + clearTimeout(deadlineTimer); + clearTimeout(graceTimer); + stdoutHash(); + stderrHash(); + return result; + }; + + const stop = async () => { + if (exitRecord === null) { + try { + if (child.connected) child.send({ type: 'shutdown' }); + else killGroup(child, 'SIGTERM'); + } catch { + killGroup(child, 'SIGTERM'); + } + } + const grace = setTimeout(() => killGroup(child, 'SIGKILL'), CHILD_GRACE_MS); + grace.unref(); + const result = await waitExit(); + clearTimeout(grace); + return result; + }; + + return Object.freeze({ name, child, waitMessage, waitExit, stop, exited }); +} + +function childEnvironment(nodeExecutable, preload, egressLog, additions = {}) { + return Object.freeze({ + LANG: 'C.UTF-8', + PATH: path.dirname(nodeExecutable), + NODE_OPTIONS: `--require=${preload}`, + WALLET_KERNEL_EGRESS_LOG_FILE: egressLog, + ...additions, + }); +} + +function route(id, kind, upstreamUrl, resourceDescription, purposeLabel) { + return Object.freeze({ + id, + kind, + method: 'POST', + upstreamUrl, + resourceDescription, + resourceMimeType: 'application/json', + purposeLabel, + requestContentTypes: Object.freeze(['application/json']), + maximumRequestBytes: 262_144, + maximumResponseBytes: 1_048_576, + }); +} + +function routeDocument(sellerOrigin) { + const scenarios = [ + 'untrusted', 'over-budget', 'approval', 'changed-challenge', 'settled-302', 'settled-404', + 'settled-500', 'body-loss', 'pre-header-loss', 'trusted-settlement', 'delayed', + 'second-402', 'malformed-settlement', 'success-false', 'explicit-rejection', + ]; + return Object.freeze({ + schemaVersion: 1, + routes: Object.freeze([ + route( + 'example-model', + 'openai-chat', + `${sellerOrigin}/paid/chat/completions`, + 'Wallet Kernel e2e model route', + 'model.infer', + ), + route( + 'free-model', + 'openai-chat', + `${sellerOrigin}/paid/scenario/free-model`, + 'Wallet Kernel e2e free-model route', + 'model.infer', + ), + route( + 'approval-model', + 'openai-chat', + `${sellerOrigin}/paid/scenario/approval-model`, + 'Wallet Kernel e2e approval-model route', + 'model.infer', + ), + route( + 'example-skill', + 'tool', + `${sellerOrigin}/paid/skill`, + 'Wallet Kernel e2e Skill route', + 'skill.invoke', + ), + ...scenarios.map((scenario) => { + const upstreamScenario = scenario === 'body-loss' ? 'delivery-loss' : scenario; + return route( + scenario, + 'tool', + `${sellerOrigin}/paid/scenario/${upstreamScenario}`, + `Wallet Kernel e2e ${upstreamScenario} route`, + `scenario.${upstreamScenario.replaceAll('-', '_')}`, + ); + }), + ]), + }); +} + +function policyDocument(seller) { + return Object.freeze({ + schemaVersion: 1, + network: NETWORK, + asset: ASSET, + wallet: WALLET_ADDRESS, + methods: Object.freeze(['POST']), + sellers: Object.freeze([Object.freeze({ + origin: seller.origin, + pathPrefixes: Object.freeze(['/paid/']), + payTo: PAY_TO, + evidencePath: '/.well-known/wallet-kernel/evidence', + executionSigner: seller.executionSigner, + refundSigner: seller.refundSigner, + refundSource: seller.refundSource, + perRequestMaxAtomic: '500000', + autoApproveAtomic: '100000', + humanApproveAtomic: '500000', + sellerSessionMaxAtomic: '5000000', + })]), + sessionMaxAtomic: '5000000', + rolling24hMaxAtomic: '10000000', + challengeMaxAgeMs: 60_000, + approvalTtlMs: 1_500, + maxPendingApprovals: 20, + defaultAction: 'deny', + }); +} + +async function responseProjection(response) { + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > 2_097_152) fail('PROCESS_RESPONSE_TOO_LARGE'); + try { + return Object.freeze({ status: response.status, value: JSON.parse(bytes.toString('utf8')) }); + } catch { + return Object.freeze({ status: response.status, value: null }); + } finally { + bytes.fill(0); + } +} + +async function boundedFetch(url, init = {}) { + try { + return await responseProjection(await fetch(url, { + ...init, + redirect: 'manual', + credentials: 'omit', + signal: AbortSignal.timeout(8_000), + })); + } catch (error) { + if (error?.code === 'PROCESS_RESPONSE_TOO_LARGE') throw error; + return Object.freeze({ status: 0, value: null }); + } +} + +function newAgentCallId() { + return crypto.randomBytes(32).toString('base64url'); +} + +function agentRequest( + origin, + token, + routeId, + value, + headers = {}, + agentCallId = newAgentCallId(), +) { + const body = canonicalJson(value); + return boundedFetch(`${origin}/agent/v1/invoke/${routeId}`, { + method: 'POST', + headers: { + authorization: `WalletKernelAgent ${token}`, + 'content-type': 'application/json', + 'x-agent-call-id': agentCallId, + ...headers, + }, + body, + }); +} + +async function operatorRequest(origin, token, pathname, { method = 'GET', value } = {}) { + const headers = { authorization: `Bearer ${token}` }; + const init = { method, headers }; + if (value !== undefined) { + headers['content-type'] = 'application/json'; + init.body = canonicalJson(value); + } + return await boundedFetch(`${origin}${pathname}`, init); +} + +function secureToken(filePath, field = null) { + const stat = fs.lstatSync(filePath, { bigint: true }); + if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== BigInt(process.getuid()) + || (stat.mode & 0o7777n) !== 0o600n || stat.nlink !== 1n || stat.size > 4_096n) { + fail('PROCESS_CREDENTIAL_INVALID'); + } + const text = fs.readFileSync(filePath, 'utf8'); + const value = field === null ? text.trim() : JSON.parse(text)[field]; + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(value)) { + fail('PROCESS_CREDENTIAL_INVALID'); + } + return value; +} + +function signerCount(filePath) { + return readJsonFile(filePath, { signerCalls: 0 })?.signerCalls ?? 0; +} + +function sellerState(filePath) { + return readJsonFile(filePath, { + paidRequestCount: 0, + paymentSignatureCount: 0, + duplicatePaymentSignatureCount: 0, + forbiddenForwardedHeaderCount: 0, + transactionIds: [], + }); +} + +function modelState(filePath) { + return readJsonFile(filePath, { + requestCount: 0, + forbiddenAuthorityHeaderCount: 0, + toolResultObserved: false, + }); +} + +function verificationForProjection(bundle, receiptKey, receipts) { + if (!bundle || typeof bundle !== 'object' || bundle.algorithm !== 'Ed25519' + || bundle.keyId !== receiptKey?.keyId || bundle.publicKeyPem !== receiptKey?.publicKeyPem) { + return false; + } + const unsigned = { + schemaVersion: bundle.schemaVersion, + domain: bundle.domain, + projection: bundle.projection, + algorithm: bundle.algorithm, + keyId: bundle.keyId, + publicKeyPem: bundle.publicKeyPem, + }; + const expected = sha256(canonicalJson(unsigned)); + if (bundle.projectionHash !== expected) return false; + let publicKey; + let signature; + try { + publicKey = crypto.createPublicKey(bundle.publicKeyPem); + signature = Buffer.from(bundle.signature, 'base64'); + } catch { + return false; + } + return signature.length === 64 + && crypto.verify(null, Buffer.from(expected.slice(7), 'hex'), publicKey, signature) + && receipts.every((receipt) => verifySignedReceipt(receipt, receiptKey)); +} + +function normalizedEvidenceEvents(events, receipts) { + const receiptsByHash = new Map(receipts.map((receipt) => [receipt.receiptHash, receipt])); + const canonicalTransactionEvents = new Set([ + 'budget.committed', + 'budget.payment_resolved', + 'refund.confirmed', + ]); + const projectedTransactions = new Set(); + return events.map((event) => { + const receipt = event.kind === 'receipt.issued' + ? receiptsByHash.get(event.receiptHash) ?? null + : null; + const transactionId = canonicalTransactionEvents.has(event.kind) + ? event.transactionId + : null; + if (transactionId !== null && projectedTransactions.has(transactionId)) { + fail('PROCESS_TRANSACTION_REUSE'); + } + if (transactionId !== null) projectedTransactions.add(transactionId); + return Object.freeze({ + sequence: Number(event.id), + eventType: event.kind, + entityHash: event.hash, + decision: new Set(['allow', 'approval_required', 'deny']).has(event.decision) + ? event.decision + : null, + amountAtomic: event.amountAtomic, + transactionId, + receiptHash: receipt?.receiptHash ?? null, + receiptSignature: receipt?.signature ?? null, + }); + }); +} + +function egressAttempts(files) { + let count = 0; + for (const filePath of files) { + const text = fs.readFileSync(filePath, 'utf8'); + if (text.length === 0) continue; + const lines = text.trim().split('\n'); + for (const line of lines) { + try { + const record = JSON.parse(line); + if (typeof record.destination !== 'string' || typeof record.operation !== 'string') { + count += 1; + } else { + count += 1; + } + } catch { + count += 1; + } + } + } + return count; +} + +function observationHash(id, facts) { + return sha256(canonicalJson({ domain: 'wallet-kernel.process-observation.v1', id, facts })); +} + +function makeInvariantResults(observations) { + return Object.freeze(INVARIANT_IDS.map((id) => { + const entry = observations.get(id) ?? Object.freeze({ passed: false, facts: ['not_exercised'] }); + return Object.freeze({ + id, + passed: entry.passed === true, + facts: entry.facts, + evidenceHash: observationHash(id, entry.facts), + }); + })); +} + +function recordObservation(observations, id, passed, facts) { + observations.set(id, Object.freeze({ passed: passed === true, facts: Object.freeze(facts) })); +} + +export async function runSpendControlProcessAcceptance({ + authorityDirectory, + piExecutable, + nodeExecutable = process.execPath, +}) { + const authorityParent = validateAuthorityDirectory(authorityDirectory); + const node = validateNodeExecutable(nodeExecutable); + validatePiExecutable(piExecutable); + canonicalAbsolute(PRELOAD, 'PROCESS_FIXTURE_INVALID', { file: true }); + for (const script of [MODEL_PROCESS, SELLER_PROCESS, CONTROL_PROCESS, PI_PROCESS]) { + canonicalAbsolute(script, 'PROCESS_FIXTURE_INVALID', { file: true }); + } + + const artifacts = path.join(authorityParent, 'spend-control-process-artifacts'); + createDirectory(artifacts, 0o700); + const privateDirectory = path.join(artifacts, 'agent-private'); + const enrollmentDirectory = path.join(artifacts, 'enrollment-handoff'); + const replacementPrivateDirectory = path.join(artifacts, 'replacement-agent-private'); + const replacementEnrollmentDirectory = path.join( + artifacts, + 'replacement-enrollment-handoff', + ); + const piDirectory = path.join(artifacts, 'pi-home'); + createDirectory(privateDirectory, 0o700); + createDirectory(enrollmentDirectory, 0o755); + createDirectory(replacementPrivateDirectory, 0o700); + createDirectory(replacementEnrollmentDirectory, 0o755); + createDirectory(piDirectory, 0o700); + + const credentialPath = path.join(privateDirectory, 'agent.json'); + const enrollmentPath = path.join(enrollmentDirectory, 'agent-enrollment.json'); + const replacementCredentialPath = path.join(replacementPrivateDirectory, 'agent.json'); + const replacementEnrollmentPath = path.join( + replacementEnrollmentDirectory, + 'agent-enrollment.json', + ); + const modelStatePath = path.join(artifacts, 'model-state.json'); + const sellerStatePath = path.join(artifacts, 'seller-state.json'); + const kernelStatePath = path.join(artifacts, 'kernel-state.json'); + const databasePath = path.join(artifacts, 'authority.sqlite'); + const receiptKeyPath = path.join(artifacts, 'receipt-key.pem'); + const operatorTokenPath = path.join(artifacts, 'operator-token'); + const policyPath = path.join(artifacts, 'policy.json'); + const routePath = path.join(artifacts, 'routes.json'); + const configPath = path.join(artifacts, 'process-config.json'); + for (const filePath of [modelStatePath, sellerStatePath, kernelStatePath]) { + createEmptyFile(filePath); + } + + const childNames = SPEND_CONTROL_PROCESS_CHILD_NAMES; + const egressLogs = Object.fromEntries(childNames.map((name) => { + const filePath = path.join(artifacts, `egress-${name}.jsonl`); + createEmptyFile(filePath); + return [name, filePath]; + })); + const processExitCodes = Object.fromEntries(childNames.map((name) => [name, null])); + const children = []; + const observations = new Map(); + let activeControl = null; + let finalOverview = null; + let finalProjection = null; + let sessionProjections = Object.freeze([]); + let authorityReceipts = Object.freeze([]); + let allSessionProjectionsVerified = false; + let allAuthorityReceiptsVerified = false; + let readyReceiptKey = null; + let piResult = Object.freeze({ + exitCode: 1, + piVersion: '0.80.6', + outputObserved: 'missing', + }); + let piApprovalResume = Object.freeze({ + tool: null, + model: null, + }); + + const start = (options) => { + const managed = startChild(options); + children.push(managed); + return managed; + }; + const stopOne = async (managed) => { + if (!managed) return; + const exit = await managed.stop(); + processExitCodes[managed.name] = exit.code; + if (activeControl === managed) activeControl = null; + }; + const cleanup = async () => { + fs.rmSync(artifacts, { recursive: true, force: true }); + }; + + try { + runAgentCredentialCli({ + argv: ['init', '--credential', credentialPath, '--enrollment', enrollmentPath], + writeStdout() {}, + dependencies: { + pathTrust: Object.freeze({ + mode: 'deterministic', + trustedAncestor: artifacts, + agentUid: process.getuid(), + }), + }, + }); + + const model = start({ + name: 'model', + nodeExecutable: node, + script: MODEL_PROCESS, + env: childEnvironment(node, PRELOAD, egressLogs.model, { + WALLET_KERNEL_FIXTURE_STATE_FILE: modelStatePath, + }), + }); + const modelReady = await model.waitMessage((message) => message.type === 'ready'); + + const seller = start({ + name: 'seller', + nodeExecutable: node, + script: SELLER_PROCESS, + env: childEnvironment(node, PRELOAD, egressLogs.seller, { + WALLET_KERNEL_FIXTURE_STATE_FILE: sellerStatePath, + WALLET_KERNEL_FIXTURE_MODEL_ORIGIN: modelReady.origin, + }), + }); + const sellerReady = await seller.waitMessage((message) => message.type === 'ready'); + + const policy = policyDocument(sellerReady); + const routes = routeDocument(sellerReady.origin); + writeCanonicalFile(policyPath, policy); + writeCanonicalFile(routePath, routes); + writeCanonicalFile(configPath, Object.freeze({ + schemaVersion: 1, + authorityDirectory: artifacts, + databasePath, + receiptKeyPath, + operatorTokenPath, + policyPath, + routePath, + kernelStatePath, + expectedAgentUid: process.getuid(), + expectedAgentGid: process.getgid(), + sellerOrigin: sellerReady.origin, + })); + + const bootstrapChild = start({ + name: 'bootstrap', + nodeExecutable: node, + script: CONTROL_PROCESS, + argv: ['--bootstrap', '--config', configPath, '--enrollment', enrollmentPath], + env: childEnvironment(node, PRELOAD, egressLogs.bootstrap), + }); + const bootstrapReady = await bootstrapChild.waitMessage( + (message) => message.type === 'bootstrap-complete', + ); + const bootstrapExit = await bootstrapChild.waitExit(); + processExitCodes.bootstrap = bootstrapExit.code; + if (bootstrapExit.code !== 0) fail('PROCESS_BOOTSTRAP_FAILED'); + + const agentToken = secureToken(credentialPath, 'token'); + const operatorToken = secureToken(operatorTokenPath); + + const launchControl = async (name) => { + const child = start({ + name, + nodeExecutable: node, + script: CONTROL_PROCESS, + argv: ['--serve', '--config', configPath], + env: childEnvironment(node, PRELOAD, egressLogs[name]), + }); + activeControl = child; + const ready = await child.waitMessage((message) => message.type === 'ready'); + readyReceiptKey = ready.receiptPublicKey; + return Object.freeze({ child, ready }); + }; + + let running = await launchControl('control-initial'); + const initialSigner = signerCount(kernelStatePath); + const initialSeller = sellerState(sellerStatePath); + const noAuth = await boundedFetch( + `${running.ready.agentOrigin}/agent/v1/invoke/example-skill`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: canonicalJson({ input: 'unauthorized' }), + }, + ); + const wrongAuth = await agentRequest( + running.ready.agentOrigin, + 'A'.repeat(43), + 'example-skill', + { input: 'wrong-agent' }, + ); + recordObservation( + observations, + 'unauthorized-calls-fail-before-body', + noAuth.status === 401 && wrongAuth.status === 401 + && signerCount(kernelStatePath) === initialSigner + && sellerState(sellerStatePath).requestCount === initialSeller.requestCount, + [noAuth.status, wrongAuth.status, signerCount(kernelStatePath) - initialSigner], + ); + + const denialSigner = signerCount(kernelStatePath); + const untrusted = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'untrusted', + { input: 'untrusted' }, + ); + const overBudget = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'over-budget', + { input: 'over-budget' }, + ); + recordObservation( + observations, + 'policy-denials-never-sign', + untrusted.status === 403 && overBudget.status === 403 + && signerCount(kernelStatePath) === denialSigner, + [untrusted.status, overBudget.status, signerCount(kernelStatePath) - denialSigner], + ); + + const allowedSigner = signerCount(kernelStatePath); + const allowedSeller = sellerState(sellerStatePath); + const allowed = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'example-skill', + { input: 'allowed' }, + ); + const allowedSellerAfter = sellerState(sellerStatePath); + const overviewAfterAllowed = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/overview', + ); + const allowedReceipts = overviewAfterAllowed.value?.data?.receipts ?? []; + recordObservation( + observations, + 'allowed-payment-settles-once', + allowed.status === 200 + && signerCount(kernelStatePath) === allowedSigner + 1 + && allowedSellerAfter.paidRequestCount === allowedSeller.paidRequestCount + 1 + && allowedSellerAfter.paymentSignatureCount === allowedSeller.paymentSignatureCount + 1 + && allowedSellerAfter.duplicatePaymentSignatureCount + === allowedSeller.duplicatePaymentSignatureCount + && allowedReceipts.length >= 1 + && allowedReceipts.every((receipt) => verifySignedReceipt(receipt, readyReceiptKey)), + [ + allowed.status, + signerCount(kernelStatePath) - allowedSigner, + allowedSellerAfter.paidRequestCount - allowedSeller.paidRequestCount, + allowedReceipts.length, + overviewAfterAllowed.status, + overviewAfterAllowed.value?.error?.code ?? null, + ], + ); + + const approvalPayload = Object.freeze({ input: 'approval' }); + const approvalCallId = newAgentCallId(); + const approvalSigner = signerCount(kernelStatePath); + const approval = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'approval', + approvalPayload, + {}, + approvalCallId, + ); + const overviewBeforeRestart = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/overview', + ); + const originalSession = overviewBeforeRestart.value?.data?.sessions?.[0] ?? null; + const pendingBeforeRestart = overviewBeforeRestart.value?.data?.approvals?.find( + (entry) => entry.decision === 'pending', + ) ?? overviewBeforeRestart.value?.data?.approvals?.at(-1) ?? null; + await stopOne(running.child); + + running = await launchControl('control-restarted'); + const overviewAfterRestart = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/overview', + ); + const restartedSession = overviewAfterRestart.value?.data?.sessions?.[0] ?? null; + const pendingAfterRestart = overviewAfterRestart.value?.data?.approvals?.find( + (entry) => entry.approvalId === pendingBeforeRestart?.approvalId, + ) ?? null; + recordObservation( + observations, + 'credential-reattaches-session', + approval.status === 409 + && typeof originalSession?.id === 'string' + && typeof originalSession?.sessionHash === 'string' + && originalSession.id === restartedSession?.id + && originalSession?.sessionHash === restartedSession?.sessionHash, + [approval.status, + overviewBeforeRestart.status, overviewBeforeRestart.value?.error?.code ?? null, + overviewAfterRestart.status, overviewAfterRestart.value?.error?.code ?? null, + originalSession?.sessionHash ?? null, restartedSession?.sessionHash ?? null], + ); + + let approvedStatus = 0; + let retryStatus = 0; + if (pendingAfterRestart) { + const approved = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/approvals/${pendingAfterRestart.approvalId}/approve`, + { + method: 'POST', + value: { expectedIntentHash: pendingAfterRestart.intentHash }, + }, + ); + approvedStatus = approved.status; + const retried = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'approval', + approvalPayload, + {}, + approvalCallId, + ); + retryStatus = retried.status; + } + recordObservation( + observations, + 'approval-survives-restart', + approval.status === 409 && pendingAfterRestart !== null + && approvedStatus === 200 && retryStatus === 200 + && signerCount(kernelStatePath) === approvalSigner + 1, + [approval.status, pendingAfterRestart !== null, approvedStatus, retryStatus, + signerCount(kernelStatePath) - approvalSigner], + ); + + const pendingIds = async () => { + const overview = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/overview', + ); + const approvals = overview.value?.data?.approvals ?? []; + return Object.freeze({ + overview, + approvals, + pending: approvals.filter((entry) => entry.decision === 'pending'), + }); + }; + const newPendingApproval = async (beforeIds) => { + const state = await pendingIds(); + return Object.freeze({ + ...state, + approval: state.pending.find((entry) => !beforeIds.has(entry.approvalId)) ?? null, + }); + }; + + const denialBaseline = await pendingIds(); + const denialSignerCount = signerCount(kernelStatePath); + const denialRequest = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'approval', + { input: 'operator-denial' }, + ); + const denialPending = await newPendingApproval( + new Set(denialBaseline.approvals.map((entry) => entry.approvalId)), + ); + let denialResponse = Object.freeze({ status: 0, value: null }); + if (denialPending.approval) { + denialResponse = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/approvals/${denialPending.approval.approvalId}/deny`, + { + method: 'POST', + value: { + expectedIntentHash: denialPending.approval.intentHash, + reasonCode: 'OPERATOR_DENIED', + }, + }, + ); + } + + const changedBaseline = await pendingIds(); + const changedSignerCount = signerCount(kernelStatePath); + const changedPayload = Object.freeze({ input: 'changed-challenge' }); + const changedCallId = newAgentCallId(); + const changedRequest = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'changed-challenge', + changedPayload, + {}, + changedCallId, + ); + const changedPending = await newPendingApproval( + new Set(changedBaseline.approvals.map((entry) => entry.approvalId)), + ); + let changedApprove = Object.freeze({ status: 0, value: null }); + let changedRetry = Object.freeze({ status: 0, value: null }); + if (changedPending.approval) { + changedApprove = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/approvals/${changedPending.approval.approvalId}/approve`, + { + method: 'POST', + value: { expectedIntentHash: changedPending.approval.intentHash }, + }, + ); + changedRetry = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'changed-challenge', + changedPayload, + {}, + changedCallId, + ); + } + const changedAfter = await pendingIds(); + const oldChanged = changedAfter.approvals.find( + (entry) => entry.approvalId === changedPending.approval?.approvalId, + ) ?? null; + const replacementApproval = changedAfter.pending.find( + (entry) => entry.approvalId !== changedPending.approval?.approvalId, + ) ?? null; + recordObservation( + observations, + 'changed-challenge-terminalizes-approval', + changedRequest.status === 409 && changedApprove.status === 200 + && changedRetry.status === 403 && oldChanged?.decision === 'cancelled' + && replacementApproval !== null + && signerCount(kernelStatePath) === changedSignerCount, + [changedRequest.status, changedApprove.status, changedRetry.status, + oldChanged?.decision ?? null, replacementApproval !== null, + signerCount(kernelStatePath) - changedSignerCount], + ); + if (replacementApproval) { + await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/approvals/${replacementApproval.approvalId}/deny`, + { + method: 'POST', + value: { + expectedIntentHash: replacementApproval.intentHash, + reasonCode: 'OPERATOR_DENIED', + }, + }, + ); + } + + const expiryBaseline = await pendingIds(); + const expirySignerCount = signerCount(kernelStatePath); + const expiryPayload = Object.freeze({ input: 'approval-expiry' }); + const expiryCallId = newAgentCallId(); + const expiryRequest = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'approval', + expiryPayload, + {}, + expiryCallId, + ); + const expiryPending = await newPendingApproval( + new Set(expiryBaseline.approvals.map((entry) => entry.approvalId)), + ); + await delay(policy.approvalTtlMs + 100); + const expiryRetry = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'approval', + expiryPayload, + {}, + expiryCallId, + ); + const decisionsAfterExpiry = await pendingIds(); + const expiredApproval = decisionsAfterExpiry.approvals.find( + (entry) => entry.approvalId === expiryPending.approval?.approvalId, + ) ?? null; + recordObservation( + observations, + 'denial-and-expiry-never-sign', + denialRequest.status === 409 && denialResponse.status === 200 + && expiryRequest.status === 409 && expiryRetry.status >= 400 + && expiredApproval?.decision === 'expired' + && signerCount(kernelStatePath) === denialSignerCount + && signerCount(kernelStatePath) === expirySignerCount, + [denialRequest.status, denialResponse.status, expiryRequest.status, expiryRetry.status, + expiredApproval?.decision ?? null, signerCount(kernelStatePath) - denialSignerCount], + ); + + const policyBefore = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/overview', + ); + const sessionBeforePolicy = policyBefore.value?.data?.sessions?.find( + (entry) => entry.state === 'open', + ) ?? null; + const tighterPolicy = Object.freeze({ + ...policy, + sellers: Object.freeze(policy.sellers.map((entry) => Object.freeze({ + ...entry, + sellerSessionMaxAtomic: '4500000', + }))), + sessionMaxAtomic: '4500000', + rolling24hMaxAtomic: '9000000', + }); + const tighterHash = sha256(canonicalJson(tighterPolicy)); + const policyApply = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/policies/apply', + { + method: 'POST', + value: { document: tighterPolicy, expectedPolicyHash: tighterHash }, + }, + ); + const policyBlocked = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/overview', + ); + const blockedSession = policyBlocked.value?.data?.sessions?.find( + (entry) => entry.id === sessionBeforePolicy?.id, + ) ?? null; + const blockedSigner = signerCount(kernelStatePath); + const blockedAgentRequest = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'example-skill', + { input: 'blocked-by-new-policy' }, + ); + let policyTransition = Object.freeze({ status: 0, value: null }); + if (blockedSession) { + policyTransition = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/sessions/${blockedSession.id}/transition-policy`, + { + method: 'POST', + value: { + targetPolicyHash: tighterHash, + expectedSessionHash: blockedSession.sessionHash, + }, + }, + ); + } + const policyAfter = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/overview', + ); + const transitionedSession = policyAfter.value?.data?.sessions?.find( + (entry) => entry.id === sessionBeforePolicy?.id, + ) ?? null; + const replacementPolicySession = policyAfter.value?.data?.sessions?.find( + (entry) => entry.id !== sessionBeforePolicy?.id && entry.state === 'open', + ) ?? null; + recordObservation( + observations, + 'tighter-policy-requires-guarded-transition', + policyApply.status === 200 && blockedSession?.state === 'policy_blocked' + && blockedAgentRequest.status !== 200 + && signerCount(kernelStatePath) === blockedSigner + && policyTransition.status === 200 && transitionedSession?.state === 'closed' + && replacementPolicySession?.state === 'open' + && replacementPolicySession?.policyVersionId !== sessionBeforePolicy?.policyVersionId + && policyAfter.value?.data?.policyVersion?.policyHash === tighterHash, + [policyApply.status, blockedSession?.state ?? null, blockedAgentRequest.status, + signerCount(kernelStatePath) - blockedSigner, policyTransition.status, + transitionedSession?.state ?? null, replacementPolicySession?.state ?? null, + policyAfter.value?.data?.policyVersion?.policyHash ?? null], + ); + if (policyApply.status === 200) replaceCanonicalFile(policyPath, tighterPolicy); + + const processOverview = async () => { + const response = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/overview', + ); + const data = response.value?.data ?? null; + return Object.freeze({ + response, + data, + projection: data?.projection?.projection ?? null, + }); + }; + const caseFor = (data, kind, intentId = null) => ( + (data?.reconciliations ?? []).findLast((entry) => ( + entry.kind === kind && (intentId === null || entry.intentId === intentId) + )) ?? null + ); + const receiptFor = (data, intentId) => ( + (data?.receipts ?? []).find((entry) => entry.intentId === intentId) ?? null + ); + const reconcileExecutionCase = async (reconciliationCase) => { + if (!reconciliationCase) { + return Object.freeze({ + response: Object.freeze({ status: 0, value: null }), + after: await processOverview(), + }); + } + const response = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/reconciliations/${reconciliationCase.intentId}/execution`, + { + method: 'POST', + value: { + expectedIntentHash: reconciliationCase.intentHash, + expectedCaseHash: reconciliationCase.caseHash, + }, + }, + ); + return Object.freeze({ response, after: await processOverview() }); + }; + const reconcilePaymentThenExecution = async (paymentCase, paymentTransactionId) => { + if (!paymentCase) { + return Object.freeze({ + payment: Object.freeze({ status: 0, value: null }), + intermediate: await processOverview(), + executionCase: null, + execution: Object.freeze({ status: 0, value: null }), + after: await processOverview(), + }); + } + const payment = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/reconciliations/${paymentCase.intentId}/payment`, + { + method: 'POST', + value: { + expectedIntentHash: paymentCase.intentHash, + expectedCaseHash: paymentCase.caseHash, + paymentTransactionId, + }, + }, + ); + const intermediate = await processOverview(); + const executionCase = caseFor(intermediate.data, 'execution', paymentCase.intentId); + const executionResult = await reconcileExecutionCase(executionCase); + return Object.freeze({ + payment, + intermediate, + executionCase, + execution: executionResult.response, + after: executionResult.after, + }); + }; + + const publicTransactions = async () => await boundedFetch( + `${sellerReady.origin}/fixture/v1/public-transactions`, + ); + const reconcileRefundCase = async (reconciliationCase, refundTransactionId) => { + if (!reconciliationCase) return Object.freeze({ status: 0, value: null }); + return await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/reconciliations/${reconciliationCase.intentId}/refund-observation`, + { + method: 'POST', + value: { + expectedIntentHash: reconciliationCase.intentHash, + expectedCaseHash: reconciliationCase.caseHash, + refundTransactionId, + }, + }, + ); + }; + + const httpFailureResults = []; + let wrongRefundEvidence = null; + for (const [routeId, expectedStatus] of [ + ['settled-302', 502], + ['settled-404', 404], + ['settled-500', 500], + ]) { + const beforeSigner = signerCount(kernelStatePath); + const beforeSeller = sellerState(sellerStatePath); + const response = await agentRequest( + running.ready.agentOrigin, + agentToken, + routeId, + { input: routeId }, + ); + const afterSeller = sellerState(sellerStatePath); + const paymentTransactionId = afterSeller.transactionIds.at(-1) ?? null; + const blocked = await processOverview(); + const refundCase = caseFor(blocked.data, 'refund-observation'); + const initialReceipt = receiptFor(blocked.data, refundCase?.intentId); + const blockProbeSigner = signerCount(kernelStatePath); + const blockProbe = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'example-skill', + { input: `blocked-${routeId}` }, + ); + const mappingResponse = await publicTransactions(); + const mapping = mappingResponse.value?.payments?.find( + (entry) => entry.paymentTransactionId === paymentTransactionId, + ) ?? null; + let currentCase = refundCase; + let wrong = Object.freeze({ status: 0, value: null }); + let afterWrong = null; + let wrongReceipt = null; + if (routeId === 'settled-302' && mappingResponse.value?.wrongRefundTransactionId) { + wrong = await reconcileRefundCase( + currentCase, + mappingResponse.value.wrongRefundTransactionId, + ); + afterWrong = await processOverview(); + wrongReceipt = receiptFor(afterWrong.data, refundCase?.intentId); + currentCase = caseFor(afterWrong.data, 'refund-observation', refundCase?.intentId); + } + const correct = await reconcileRefundCase(currentCase, mapping?.refundTransactionId ?? null); + const after = await processOverview(); + const finalReceipt = receiptFor(after.data, refundCase?.intentId); + let exactReplay = Object.freeze({ status: 0, value: null }); + let afterExactReplay = null; + let replayReceipt = null; + if (routeId === 'settled-302') { + exactReplay = await reconcileRefundCase( + currentCase, + mapping?.refundTransactionId ?? null, + ); + afterExactReplay = await processOverview(); + replayReceipt = receiptFor(afterExactReplay.data, refundCase?.intentId); + } + const result = Object.freeze({ + routeId, + expectedStatus, + beforeSigner, + beforeSeller, + response, + afterSeller, + paymentTransactionId, + blocked, + refundCase, + initialReceipt, + blockProbeSigner, + blockProbe, + mappingResponse, + mapping, + wrong, + afterWrong, + wrongReceipt, + correct, + after, + finalReceipt, + exactReplay, + afterExactReplay, + replayReceipt, + }); + if (routeId === 'settled-302') wrongRefundEvidence = result; + httpFailureResults.push(result); + } + recordObservation( + observations, + 'settled-http-failures-commit-and-block', + httpFailureResults.length === 3 && httpFailureResults.every((entry) => ( + entry.response.status === entry.expectedStatus + && entry.refundCase !== null + && entry.initialReceipt?.receipt?.payment?.state === 'settled' + && entry.initialReceipt?.receipt?.execution?.state === 'failed' + && entry.initialReceipt?.receipt?.budget?.disposition === 'committed' + && entry.initialReceipt?.receipt?.refund?.state === 'pending' + && entry.blocked.projection?.blockers?.walletBlocked === true + && entry.blockProbe.status !== 200 + && entry.blockProbeSigner === entry.beforeSigner + 1 + && entry.afterSeller.paidRequestCount === entry.beforeSeller.paidRequestCount + 1 + && entry.afterSeller.duplicatePaymentSignatureCount + === entry.beforeSeller.duplicatePaymentSignatureCount + && entry.correct.status === 200 + && entry.finalReceipt?.receipt?.outcome?.status === 'refunded' + && entry.finalReceipt?.receipt?.budget?.disposition === 'released' + && entry.after.projection?.blockers?.walletBlocked === false + )), + httpFailureResults.flatMap((entry) => [ + entry.routeId, + entry.response.status, + entry.refundCase !== null, + entry.initialReceipt?.receipt?.budget?.disposition ?? null, + entry.initialReceipt?.receipt?.refund?.state ?? null, + entry.blockProbe.status, + entry.afterSeller.duplicatePaymentSignatureCount + - entry.beforeSeller.duplicatePaymentSignatureCount, + entry.correct.status, + entry.finalReceipt?.receipt?.budget?.disposition ?? null, + ]), + ); + recordObservation( + observations, + 'refund-releases-only-after-full-proof', + wrongRefundEvidence !== null + && wrongRefundEvidence.wrong.status === 200 + && wrongRefundEvidence.wrongReceipt?.receipt?.refund?.state === 'rejected' + && wrongRefundEvidence.wrongReceipt?.receipt?.budget?.disposition === 'committed' + && wrongRefundEvidence.afterWrong?.projection?.blockers?.walletBlocked === true + && wrongRefundEvidence.correct.status === 200 + && wrongRefundEvidence.finalReceipt?.receipt?.refund?.state === 'confirmed' + && wrongRefundEvidence.finalReceipt?.receipt?.budget?.disposition === 'released' + && wrongRefundEvidence.finalReceipt?.revision + === wrongRefundEvidence.initialReceipt?.revision + 2 + && wrongRefundEvidence.finalReceipt?.supersedesReceiptHash + === wrongRefundEvidence.wrongReceipt?.receiptHash + && wrongRefundEvidence.after.projection?.blockers?.walletBlocked === false + && wrongRefundEvidence.exactReplay.status === 200 + && wrongRefundEvidence.replayReceipt?.receiptHash + === wrongRefundEvidence.finalReceipt?.receiptHash + && wrongRefundEvidence.replayReceipt?.revision + === wrongRefundEvidence.finalReceipt?.revision + && wrongRefundEvidence.afterExactReplay?.data?.reconciliations?.length + === wrongRefundEvidence.after?.data?.reconciliations?.length + && wrongRefundEvidence.afterExactReplay?.projection?.blockers?.walletBlocked === false, + [wrongRefundEvidence?.wrong.status ?? null, + wrongRefundEvidence?.wrongReceipt?.receipt?.refund?.state ?? null, + wrongRefundEvidence?.wrongReceipt?.receipt?.budget?.disposition ?? null, + wrongRefundEvidence?.afterWrong?.projection?.blockers?.walletBlocked ?? null, + wrongRefundEvidence?.correct.status ?? null, + wrongRefundEvidence?.finalReceipt?.receipt?.refund?.state ?? null, + wrongRefundEvidence?.finalReceipt?.receipt?.budget?.disposition ?? null, + wrongRefundEvidence?.initialReceipt?.revision ?? null, + wrongRefundEvidence?.wrongReceipt?.revision ?? null, + wrongRefundEvidence?.finalReceipt?.revision ?? null, + wrongRefundEvidence?.exactReplay.status ?? null, + wrongRefundEvidence?.replayReceipt?.revision ?? null, + wrongRefundEvidence?.replayReceipt?.receiptHash + === wrongRefundEvidence?.finalReceipt?.receiptHash], + ); + + const bodyLossSigner = signerCount(kernelStatePath); + const bodyLossSeller = sellerState(sellerStatePath); + const bodyLoss = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'body-loss', + { input: 'body-loss' }, + ); + const bodyLossBlocked = await processOverview(); + const bodyLossCase = caseFor(bodyLossBlocked.data, 'execution'); + const bodyLossReceipt = receiptFor(bodyLossBlocked.data, bodyLossCase?.intentId); + const bodyLossBlockProbe = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'example-skill', + { input: 'blocked-after-body-loss' }, + ); + const bodyLossResolved = await reconcileExecutionCase(bodyLossCase); + const bodyLossRevised = receiptFor(bodyLossResolved.after.data, bodyLossCase?.intentId); + recordObservation( + observations, + 'body-loss-execution-reconciliation', + bodyLoss.status === 502 && bodyLossCase !== null + && bodyLossReceipt?.receipt?.payment?.state === 'settled' + && bodyLossReceipt?.receipt?.execution?.state === 'unknown' + && bodyLossReceipt?.receipt?.budget?.disposition === 'committed' + && bodyLossBlocked.projection?.blockers?.walletBlocked === true + && bodyLossBlockProbe.status !== 200 + && signerCount(kernelStatePath) === bodyLossSigner + 1 + && sellerState(sellerStatePath).paidRequestCount === bodyLossSeller.paidRequestCount + 1 + && bodyLossResolved.response.status === 200 + && bodyLossRevised?.revision === bodyLossReceipt.revision + 1 + && bodyLossRevised?.supersedesReceiptHash === bodyLossReceipt.receiptHash + && caseFor(bodyLossResolved.after.data, 'execution', bodyLossCase.intentId) === null + && bodyLossResolved.after.projection?.blockers?.walletBlocked === false, + [bodyLoss.status, bodyLossCase !== null, bodyLossReceipt?.receipt?.payment?.state ?? null, + bodyLossReceipt?.receipt?.execution?.state ?? null, bodyLossBlockProbe.status, + signerCount(kernelStatePath) - bodyLossSigner, bodyLossResolved.response.status, + bodyLossReceipt?.revision ?? null, bodyLossRevised?.revision ?? null, + bodyLossResolved.after.projection?.blockers?.walletBlocked ?? null], + ); + + const runLostPaidResponse = async (routeId, label) => { + const beforeSigner = signerCount(kernelStatePath); + const beforeSeller = sellerState(sellerStatePath); + const response = await agentRequest( + running.ready.agentOrigin, + agentToken, + routeId, + { input: label }, + ); + const afterSeller = sellerState(sellerStatePath); + const transactionId = afterSeller.transactionIds.at(-1) ?? null; + const blocked = await processOverview(); + const paymentCase = caseFor(blocked.data, 'payment'); + const initialReceipt = receiptFor(blocked.data, paymentCase?.intentId); + const blockProbeSigner = signerCount(kernelStatePath); + const blockProbe = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'example-skill', + { input: `blocked-${label}` }, + ); + const resolved = await reconcilePaymentThenExecution(paymentCase, transactionId); + const intermediateReceipt = receiptFor(resolved.intermediate.data, paymentCase?.intentId); + const finalReceipt = receiptFor(resolved.after.data, paymentCase?.intentId); + return Object.freeze({ + beforeSigner, + beforeSeller, + response, + afterSeller, + transactionId, + blocked, + paymentCase, + initialReceipt, + blockProbeSigner, + blockProbe, + resolved, + intermediateReceipt, + finalReceipt, + }); + }; + + const preHeaderLoss = await runLostPaidResponse('pre-header-loss', 'pre-header-loss'); + recordObservation( + observations, + 'pre-settlement-loss-holds-budget', + preHeaderLoss.response.status === 503 && preHeaderLoss.paymentCase !== null + && preHeaderLoss.initialReceipt?.receipt?.payment?.state === 'unresolved' + && preHeaderLoss.initialReceipt?.receipt?.budget?.disposition === 'unresolved' + && preHeaderLoss.blocked.projection?.blockers?.walletBlocked === true + && preHeaderLoss.blockProbe.status !== 200 + && preHeaderLoss.blockProbeSigner === preHeaderLoss.beforeSigner + 1 + && signerCount(kernelStatePath) >= preHeaderLoss.blockProbeSigner + && preHeaderLoss.afterSeller.duplicatePaymentSignatureCount + === preHeaderLoss.beforeSeller.duplicatePaymentSignatureCount + && preHeaderLoss.resolved.payment.status === 200 + && preHeaderLoss.resolved.execution.status === 200, + [preHeaderLoss.response.status, preHeaderLoss.paymentCase !== null, + preHeaderLoss.initialReceipt?.receipt?.payment?.state ?? null, + preHeaderLoss.initialReceipt?.receipt?.budget?.disposition ?? null, + preHeaderLoss.blockProbe.status, + preHeaderLoss.afterSeller.duplicatePaymentSignatureCount + - preHeaderLoss.beforeSeller.duplicatePaymentSignatureCount, + preHeaderLoss.resolved.payment.status, preHeaderLoss.resolved.execution.status], + ); + + const ambiguousResults = []; + for (const routeId of ['second-402', 'success-false']) { + ambiguousResults.push(await runLostPaidResponse(routeId, routeId)); + } + recordObservation( + observations, + 'post-signature-ambiguity-is-unresolved', + ambiguousResults.length === 2 && ambiguousResults.every((entry) => ( + entry.response.status === 503 + && entry.paymentCase !== null + && entry.initialReceipt?.receipt?.outcome?.status === 'payment_unresolved' + && entry.initialReceipt?.receipt?.payment?.state === 'unresolved' + && entry.initialReceipt?.receipt?.budget?.disposition === 'unresolved' + && entry.afterSeller.duplicatePaymentSignatureCount + === entry.beforeSeller.duplicatePaymentSignatureCount + && entry.resolved.payment.status === 200 + && entry.resolved.execution.status === 200 + )), + ambiguousResults.flatMap((entry) => [ + entry.response.status, + entry.initialReceipt?.receipt?.outcome?.status ?? null, + entry.initialReceipt?.receipt?.payment?.state ?? null, + entry.afterSeller.duplicatePaymentSignatureCount + - entry.beforeSeller.duplicatePaymentSignatureCount, + entry.resolved.payment.status, + entry.resolved.execution.status, + ]), + ); + + const trustedSettlement = await runLostPaidResponse( + 'trusted-settlement', + 'trusted-settlement', + ); + recordObservation( + observations, + 'trusted-settlement-needs-execution-evidence', + trustedSettlement.response.status === 503 && trustedSettlement.paymentCase !== null + && trustedSettlement.resolved.payment.status === 200 + && trustedSettlement.intermediateReceipt?.receipt?.payment?.state === 'settled' + && trustedSettlement.intermediateReceipt?.receipt?.execution?.state === 'unknown' + && trustedSettlement.intermediateReceipt?.receipt?.budget?.disposition === 'committed' + && trustedSettlement.resolved.intermediate.projection?.blockers?.walletBlocked === true + && trustedSettlement.resolved.execution.status === 200 + && trustedSettlement.finalReceipt?.receipt?.execution?.state === 'succeeded' + && trustedSettlement.resolved.after.projection?.blockers?.walletBlocked === false + && trustedSettlement.afterSeller.duplicatePaymentSignatureCount + === trustedSettlement.beforeSeller.duplicatePaymentSignatureCount, + [trustedSettlement.response.status, trustedSettlement.paymentCase !== null, + trustedSettlement.resolved.payment.status, + trustedSettlement.intermediateReceipt?.receipt?.payment?.state ?? null, + trustedSettlement.intermediateReceipt?.receipt?.execution?.state ?? null, + trustedSettlement.resolved.intermediate.projection?.blockers?.walletBlocked ?? null, + trustedSettlement.resolved.execution.status, + trustedSettlement.finalReceipt?.receipt?.execution?.state ?? null], + ); + + const waitForFreshPendingApproval = async (knownApprovalIds) => { + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const overview = await processOverview(); + const pending = (overview.data?.approvals ?? []).find((entry) => ( + entry.decision === 'pending' && !knownApprovalIds.has(entry.approvalId) + )) ?? null; + if (pending !== null) return Object.freeze({ overview, pending }); + await delay(20); + } + return Object.freeze({ overview: null, pending: null }); + }; + const runPinnedPiApproval = async ({ + name, + modelRoute, + skillRoute, + modelRequestsBeforeApproval, + modelRequestsAfterCompletion, + }) => { + const baseline = await processOverview(); + const knownApprovalIds = new Set( + (baseline.data?.approvals ?? []).map(({ approvalId }) => approvalId), + ); + const beforeSigner = signerCount(kernelStatePath); + const beforeSeller = sellerState(sellerStatePath); + const beforeModel = modelState(modelStatePath); + const pi = start({ + name, + nodeExecutable: node, + script: PI_PROCESS, + env: childEnvironment(node, PRELOAD, egressLogs[name], { + WALLET_KERNEL_FIXTURE_PI_DIRECTORY: piDirectory, + WALLET_KERNEL_AGENT_CREDENTIAL_FILE: credentialPath, + WALLET_KERNEL_FIXTURE_PRELOAD: PRELOAD, + WALLET_KERNEL_ORIGIN: running.ready.agentOrigin, + WALLET_KERNEL_PROVIDER_NAME: 'wallet-kernel-e2e', + WALLET_KERNEL_MODEL_NAME: 'scripted-local', + WALLET_KERNEL_MODEL_ROUTE: modelRoute, + WALLET_KERNEL_SKILL_ROUTE: skillRoute, + }), + }); + let exited = false; + void pi.exited.then(() => { exited = true; }); + const observed = await waitForFreshPendingApproval(knownApprovalIds); + await delay(150); + const modelWhilePending = modelState(modelStatePath); + const originalRequestHeld = observed.pending !== null + && exited === false + && modelWhilePending.requestCount + === beforeModel.requestCount + modelRequestsBeforeApproval; + let operatorApprovalStatus = 0; + if (observed.pending !== null) { + const approved = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/approvals/${observed.pending.approvalId}/approve`, + { + method: 'POST', + value: { expectedIntentHash: observed.pending.intentHash }, + }, + ); + operatorApprovalStatus = approved.status; + } + let result; + try { + result = await pi.waitMessage((message) => message.type === 'result'); + } catch { + result = Object.freeze({ + exitCode: 1, + piVersion: '0.80.6', + outputObserved: 'missing', + }); + } + const exit = await pi.waitExit(); + processExitCodes[name] = exit.code; + const afterSeller = sellerState(sellerStatePath); + const afterModel = modelState(modelStatePath); + const publicResult = Object.freeze({ + pendingObserved: observed.pending !== null, + originalRequestHeld, + operatorApprovalStatus, + signerDelta: signerCount(kernelStatePath) - beforeSigner, + paidRequestDelta: afterSeller.paidRequestCount - beforeSeller.paidRequestCount, + duplicatePaymentSignatureDelta: afterSeller.duplicatePaymentSignatureCount + - beforeSeller.duplicatePaymentSignatureCount, + outputObserved: result.outputObserved ?? 'missing', + processExitCode: exit.code, + }); + return Object.freeze({ + publicResult, + modelRequestDelta: afterModel.requestCount - beforeModel.requestCount, + expectedModelRequestDelta: modelRequestsAfterCompletion, + result, + }); + }; + + const piToolApproval = await runPinnedPiApproval({ + name: 'pi-tool-approval', + modelRoute: 'free-model', + skillRoute: 'approval', + modelRequestsBeforeApproval: 1, + modelRequestsAfterCompletion: 2, + }); + const piModelApproval = await runPinnedPiApproval({ + name: 'pi-model-approval', + modelRoute: 'approval-model', + skillRoute: 'example-skill', + modelRequestsBeforeApproval: 0, + modelRequestsAfterCompletion: 1, + }); + piApprovalResume = Object.freeze({ + tool: piToolApproval.publicResult, + model: piModelApproval.publicResult, + }); + const piApprovalProved = [piToolApproval, piModelApproval].every((entry) => ( + entry.publicResult.pendingObserved === true + && entry.publicResult.originalRequestHeld === true + && entry.publicResult.operatorApprovalStatus === 200 + && entry.publicResult.signerDelta === 1 + && entry.publicResult.paidRequestDelta === 1 + && entry.publicResult.duplicatePaymentSignatureDelta === 0 + && entry.publicResult.outputObserved === 'PI_WALLET_OK' + && entry.publicResult.processExitCode === 0 + && entry.modelRequestDelta === entry.expectedModelRequestDelta + )); + const approvalObservation = observations.get('approval-survives-restart'); + recordObservation( + observations, + 'approval-survives-restart', + approvalObservation?.passed === true && piApprovalProved, + [ + ...(approvalObservation?.facts ?? ['not_exercised']), + ...Object.values(piApprovalResume).flatMap((entry) => [ + entry.pendingObserved, + entry.originalRequestHeld, + entry.operatorApprovalStatus, + entry.signerDelta, + entry.paidRequestDelta, + entry.duplicatePaymentSignatureDelta, + entry.outputObserved, + entry.processExitCode, + ]), + piToolApproval.modelRequestDelta, + piModelApproval.modelRequestDelta, + ], + ); + piResult = Object.freeze({ + exitCode: piApprovalProved ? 0 : 1, + piVersion: '0.80.6', + outputObserved: piApprovalProved ? 'PI_WALLET_OK' : 'missing', + }); + + const historyOverview = await processOverview(); + const historicalReceipts = (historyOverview.data?.receipts ?? []).map((receipt) => ( + Object.freeze({ + intentId: receipt.intentId, + receiptHash: receipt.receiptHash, + disposition: receipt.receipt?.budget?.disposition ?? null, + }) + )); + const historicalEvents = (historyOverview.data?.events ?? []).map((event) => ( + Object.freeze({ id: event.id, hash: event.hash }) + )); + const historySessionCount = historyOverview.data?.sessions?.length ?? 0; + const originalDescriptor = readJsonFile(enrollmentPath); + const originalDescriptorHash = sha256(canonicalJson(originalDescriptor)); + + const recoveryAmbiguitySigner = signerCount(kernelStatePath); + const recoveryAmbiguitySeller = sellerState(sellerStatePath); + const recoveryAmbiguity = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'pre-header-loss', + { input: 'retained-for-recovery' }, + ); + const recoveryAmbiguitySellerAfter = sellerState(sellerStatePath); + const recoveryTransactionId = recoveryAmbiguitySellerAfter.transactionIds.at(-1) ?? null; + const recoveryBlocked = await processOverview(); + const retainedSession = recoveryBlocked.data?.sessions?.find( + (entry) => entry.state === 'open', + ) ?? null; + const retainedPaymentCase = caseFor(recoveryBlocked.data, 'payment'); + const retainedInitialReceipt = receiptFor( + recoveryBlocked.data, + retainedPaymentCase?.intentId, + ); + const signerAfterAmbiguity = signerCount(kernelStatePath); + + const revoke = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/agents/${originalDescriptor?.agentInstanceId}/revoke`, + { + method: 'POST', + value: { expectedEnrollmentHash: originalDescriptorHash }, + }, + ); + const signerAfterRevocation = signerCount(kernelStatePath); + const sellerAfterRevocation = sellerState(sellerStatePath); + const revokedOldToken = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'example-skill', + { input: 'revoked-token' }, + ); + const signerAfterRevokedToken = signerCount(kernelStatePath); + const sellerAfterRevokedToken = sellerState(sellerStatePath); + + await stopOne(running.child); + running = await launchControl('control-recovery'); + const recoveryOverview = await processOverview(); + const recoverySession = recoveryOverview.data?.sessions?.find( + (entry) => entry.id === retainedSession?.id, + ) ?? null; + const recoverySessionCount = recoveryOverview.data?.sessions?.length ?? 0; + const recoverySignerBeforeWork = signerCount(kernelStatePath); + const recoveryOldToken = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'example-skill', + { input: 'recovery-only-token' }, + ); + const recoveryPaymentCase = caseFor( + recoveryOverview.data, + 'payment', + retainedPaymentCase?.intentId, + ); + const recoveryResolved = await reconcilePaymentThenExecution( + recoveryPaymentCase, + recoveryTransactionId, + ); + const recoveryIntermediateReceipt = receiptFor( + recoveryResolved.intermediate.data, + retainedPaymentCase?.intentId, + ); + const recoveryFinalReceipt = receiptFor( + recoveryResolved.after.data, + retainedPaymentCase?.intentId, + ); + const closableSession = recoveryResolved.after.data?.sessions?.find( + (entry) => entry.id === retainedSession?.id, + ) ?? null; + const recoveryClose = closableSession === null + ? Object.freeze({ status: 0, value: null }) + : await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/sessions/${closableSession.id}/close`, + { + method: 'POST', + value: { expectedSessionHash: closableSession.sessionHash }, + }, + ); + const recoveryAfterClose = await processOverview(); + const closedRetainedSession = recoveryAfterClose.data?.sessions?.find( + (entry) => entry.id === retainedSession?.id, + ) ?? null; + const recoverySignerAfterWork = signerCount(kernelStatePath); + const recoverySellerAfterWork = sellerState(sellerStatePath); + await stopOne(running.child); + + runAgentCredentialCli({ + argv: [ + 'init', + '--credential', + replacementCredentialPath, + '--enrollment', + replacementEnrollmentPath, + ], + writeStdout() {}, + dependencies: { + pathTrust: Object.freeze({ + mode: 'deterministic', + trustedAncestor: artifacts, + agentUid: process.getuid(), + }), + }, + }); + const replacementDescriptor = readJsonFile(replacementEnrollmentPath); + const replacementDescriptorHash = sha256(canonicalJson(replacementDescriptor)); + const replacementToken = secureToken(replacementCredentialPath, 'token'); + const replacementBootstrap = start({ + name: 'bootstrap-replacement', + nodeExecutable: node, + script: CONTROL_PROCESS, + argv: [ + '--bootstrap', + '--config', + configPath, + '--enrollment', + replacementEnrollmentPath, + ], + env: childEnvironment(node, PRELOAD, egressLogs['bootstrap-replacement']), + }); + const replacementBootstrapReady = await replacementBootstrap.waitMessage( + (message) => message.type === 'bootstrap-complete', + ); + const replacementBootstrapExit = await replacementBootstrap.waitExit(); + processExitCodes['bootstrap-replacement'] = replacementBootstrapExit.code; + if (replacementBootstrapExit.code !== 0) fail('PROCESS_REPLACEMENT_BOOTSTRAP_FAILED'); + + running = await launchControl('control-replacement'); + const replacementBefore = await processOverview(); + const replacementSession = replacementBefore.data?.sessions?.find( + (entry) => entry.state === 'open', + ) ?? null; + const replacementSignerBefore = signerCount(kernelStatePath); + const replacementOldToken = await agentRequest( + running.ready.agentOrigin, + agentToken, + 'example-skill', + { input: 'superseded-token' }, + ); + const replacementSignerAfterOldToken = signerCount(kernelStatePath); + const replacementSellerBefore = sellerState(sellerStatePath); + const replacementRequest = await agentRequest( + running.ready.agentOrigin, + replacementToken, + 'example-skill', + { input: 'replacement-enrollment' }, + ); + const replacementAfter = await processOverview(); + const replacementSellerAfter = sellerState(sellerStatePath); + const replacementSignerAfter = signerCount(kernelStatePath); + + await stopOne(running.child); + running = await launchControl('control-verifier'); + finalOverview = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + '/operator/v1/overview', + ); + const finalData = finalOverview.value?.data ?? null; + const finalSession = finalData?.sessions?.find((entry) => entry.state === 'open') ?? null; + const finalSessions = Array.isArray(finalData?.sessions) ? finalData.sessions : []; + const exportedSessions = []; + for (const session of finalSessions) { + const exported = await operatorRequest( + running.ready.operatorOrigin, + operatorToken, + `/operator/v1/exports/${session.id}`, + ); + exportedSessions.push(Object.freeze({ + sessionId: session.id, + status: exported.status, + bundle: exported.value?.data ?? null, + })); + } + finalProjection = exportedSessions.find(({ sessionId }) => ( + sessionId === finalSession?.id + ))?.bundle ?? null; + sessionProjections = Object.freeze(exportedSessions + .filter(({ status, bundle }) => status === 200 && bundle !== null) + .map(({ bundle }) => bundle) + .sort((left, right) => ( + left.projection.sessionHash.localeCompare(right.projection.sessionHash) + ))); + const authorityReceiptByHash = new Map(); + for (const bundle of sessionProjections) { + for (const receipt of bundle.projection?.signedReceipts ?? []) { + authorityReceiptByHash.set(receipt.receiptHash, receipt); + } + } + authorityReceipts = Object.freeze([...authorityReceiptByHash.values()].sort( + (left, right) => left.intentId.localeCompare(right.intentId) + || left.revision - right.revision + || left.id.localeCompare(right.id), + )); + allSessionProjectionsVerified = finalSessions.length > 0 + && exportedSessions.length === finalSessions.length + && exportedSessions.every(({ status, bundle }) => { + const sessionReceipts = bundle?.projection?.signedReceipts ?? []; + return status === 200 + && verificationForProjection(bundle, readyReceiptKey, sessionReceipts); + }); + allAuthorityReceiptsVerified = authorityReceipts.length > 0 + && authorityReceipts.every((receipt) => verifySignedReceipt(receipt, readyReceiptKey)); + const receipts = finalProjection?.projection?.signedReceipts ?? []; + const projectionVerified = verificationForProjection( + finalProjection, + readyReceiptKey, + receipts, + ); + const finalReceiptByIntent = new Map( + (finalData?.receipts ?? []).map((receipt) => [receipt.intentId, receipt]), + ); + const finalEventById = new Map( + (finalData?.events ?? []).map((event) => [event.id, event]), + ); + const historyPreserved = historicalReceipts.every((historical) => { + const current = finalReceiptByIntent.get(historical.intentId); + return current?.receiptHash === historical.receiptHash + && (current.receipt?.budget?.disposition ?? null) === historical.disposition; + }) && historicalEvents.every((historical) => ( + finalEventById.get(historical.id)?.hash === historical.hash + )); + const finalOpenSessions = (finalData?.sessions ?? []).filter( + (entry) => entry.state === 'open', + ); + const finalRetainedSession = finalData?.sessions?.find( + (entry) => entry.id === retainedSession?.id, + ) ?? null; + recordObservation( + observations, + 'revocation-recovery-and-replacement', + historyOverview.response.status === 200 + && bootstrapReady.descriptorHash === originalDescriptorHash + && recoveryAmbiguity.status === 503 + && retainedPaymentCase !== null + && retainedInitialReceipt?.receipt?.payment?.state === 'unresolved' + && retainedInitialReceipt?.receipt?.budget?.disposition === 'unresolved' + && recoveryBlocked.projection?.blockers?.walletBlocked === true + && signerAfterAmbiguity === recoveryAmbiguitySigner + 1 + && recoveryAmbiguitySellerAfter.paidRequestCount + === recoveryAmbiguitySeller.paidRequestCount + 1 + && revoke.status === 200 + && signerAfterRevocation === signerAfterAmbiguity + && revokedOldToken.status !== 200 + && signerAfterRevokedToken === signerAfterRevocation + && sellerAfterRevokedToken.paidRequestCount === sellerAfterRevocation.paidRequestCount + && recoveryOverview.response.status === 200 + && recoverySession?.state === 'open' + && recoverySessionCount === historySessionCount + && recoveryOldToken.status === 503 + && recoveryOldToken.value?.error?.code === 'AGENT_ENROLLMENT_REQUIRED' + && recoveryResolved.payment.status === 200 + && recoveryIntermediateReceipt?.receipt?.payment?.state === 'settled' + && recoveryIntermediateReceipt?.receipt?.execution?.state === 'unknown' + && recoveryIntermediateReceipt?.receipt?.budget?.disposition === 'committed' + && recoveryResolved.intermediate.projection?.blockers?.walletBlocked === true + && recoveryResolved.execution.status === 200 + && recoveryFinalReceipt?.receipt?.execution?.state === 'succeeded' + && recoveryResolved.after.projection?.blockers?.walletBlocked === false + && recoveryClose.status === 200 + && closedRetainedSession?.state === 'closed' + && recoverySignerAfterWork === recoverySignerBeforeWork + && recoverySellerAfterWork.paidRequestCount === sellerAfterRevocation.paidRequestCount + && replacementBootstrapReady.descriptorHash === replacementDescriptorHash + && replacementBootstrapReady.policyHash === tighterHash + && replacementDescriptor?.agentInstanceId !== originalDescriptor?.agentInstanceId + && replacementDescriptorHash !== originalDescriptorHash + && replacementBefore.response.status === 200 + && replacementSession?.state === 'open' + && replacementSession?.policyVersionId === retainedSession?.policyVersionId + && replacementBefore.data?.policyVersion?.policyHash === tighterHash + && replacementBefore.projection?.agentEnrollment?.enrollmentHash + === replacementDescriptorHash + && replacementBefore.projection?.isolation?.status === 'simulated' + && replacementBefore.projection?.isolation?.preflightDigest === null + && replacementOldToken.status !== 200 + && replacementSignerAfterOldToken === replacementSignerBefore + && replacementRequest.status === 200 + && replacementSignerAfter === replacementSignerBefore + 1 + && replacementSellerAfter.paidRequestCount + === replacementSellerBefore.paidRequestCount + 1 + && finalOverview.status === 200 + && finalOpenSessions.length === 1 + && finalSession?.id === replacementSession?.id + && finalSession?.sessionHash === replacementSession?.sessionHash + && finalSession?.policyVersionId === retainedSession?.policyVersionId + && finalRetainedSession?.state === 'closed' + && (finalData?.sessions?.length ?? 0) === historySessionCount + 1 + && finalData?.policyVersion?.policyHash === tighterHash + && historyPreserved, + [recoveryAmbiguity.status, retainedPaymentCase !== null, + retainedInitialReceipt?.receipt?.budget?.disposition ?? null, + signerAfterAmbiguity - recoveryAmbiguitySigner, + revoke.status, revokedOldToken.status, + recoveryOverview.response.status, recoverySession?.state ?? null, + recoverySessionCount - historySessionCount, + recoveryOldToken.status, recoveryOldToken.value?.error?.code ?? null, + recoveryResolved.payment.status, recoveryResolved.execution.status, + recoveryFinalReceipt?.receipt?.execution?.state ?? null, + recoveryClose.status, closedRetainedSession?.state ?? null, + recoverySignerAfterWork - recoverySignerBeforeWork, + replacementBootstrapReady.descriptorHash === replacementDescriptorHash, + replacementDescriptorHash !== originalDescriptorHash, + replacementSession?.state ?? null, + replacementBefore.projection?.isolation?.status ?? null, + replacementOldToken.status, replacementRequest.status, + replacementSignerAfter - replacementSignerBefore, + finalOpenSessions.length, + finalSession?.id === replacementSession?.id, + finalRetainedSession?.state ?? null, + (finalData?.sessions?.length ?? 0) - historySessionCount, + historyPreserved], + ); + recordObservation( + observations, + 'fresh-process-verifies-authority', + finalOverview.status === 200 + && finalProjection !== null + && projectionVerified + && sessionProjections.length === finalSessions.length + && allSessionProjectionsVerified + && allAuthorityReceiptsVerified, + [finalOverview.status, finalOverview.value?.error?.code ?? null, + finalProjection !== null, projectionVerified, + sessionProjections.length, finalSessions.length, + allSessionProjectionsVerified, authorityReceipts.length, + allAuthorityReceiptsVerified], + ); + } finally { + for (const managed of [...children].reverse()) { + if (processExitCodes[managed.name] !== null) continue; + try { + const exit = await managed.stop(); + processExitCodes[managed.name] = exit.code; + } catch { + processExitCodes[managed.name] = 1; + } + } + } + + const finalData = finalOverview?.value?.data ?? {}; + const receipts = Array.isArray(finalProjection?.projection?.signedReceipts) + ? finalProjection.projection.signedReceipts + : []; + const events = Array.isArray(finalData.events) + ? normalizedEvidenceEvents(finalData.events, authorityReceipts) + : []; + const finalSellerState = sellerState(sellerStatePath); + const finalModelState = modelState(modelStatePath); + const exactChildProcessSet = canonicalJson(Object.keys(processExitCodes)) + === canonicalJson(SPEND_CONTROL_PROCESS_CHILD_NAMES); + const allChildProcessesExitedCleanly = exactChildProcessSet + && SPEND_CONTROL_PROCESS_CHILD_NAMES.every((name) => processExitCodes[name] === 0); + const freshProcessObservation = observations.get('fresh-process-verifies-authority'); + recordObservation( + observations, + 'fresh-process-verifies-authority', + freshProcessObservation?.passed === true && allChildProcessesExitedCleanly, + [ + ...(freshProcessObservation?.facts ?? ['not_exercised']), + exactChildProcessSet, + ...SPEND_CONTROL_PROCESS_CHILD_NAMES.map((name) => processExitCodes[name]), + ], + ); + const rawSettlementTransactionIds = Object.freeze( + Array.isArray(finalSellerState.transactionIds) + ? [...finalSellerState.transactionIds] + : [], + ); + const uniqueSettlementTransactionCount = new Set(rawSettlementTransactionIds).size; + const settlementTransactionsAreUnique = rawSettlementTransactionIds.length > 0 + && uniqueSettlementTransactionCount === rawSettlementTransactionIds.length; + const allowedPaymentObservation = observations.get('allowed-payment-settles-once'); + recordObservation( + observations, + 'allowed-payment-settles-once', + allowedPaymentObservation?.passed === true + && settlementTransactionsAreUnique + && rawSettlementTransactionIds.length === finalSellerState.paidRequestCount + && rawSettlementTransactionIds.length === finalSellerState.paymentSignatureCount + && finalSellerState.duplicatePaymentSignatureCount === 0, + [ + ...(allowedPaymentObservation?.facts ?? ['not_exercised']), + rawSettlementTransactionIds.length, + uniqueSettlementTransactionCount, + finalSellerState.paidRequestCount, + finalSellerState.paymentSignatureCount, + finalSellerState.duplicatePaymentSignatureCount, + ], + ); + const nonLoopbackEgressAttempts = egressAttempts(Object.values(egressLogs)); + recordObservation( + observations, + 'pi-carries-no-authority-headers', + piResult.exitCode === 0 + && piResult.outputObserved === 'PI_WALLET_OK' + && processExitCodes['pi-tool-approval'] === 0 + && processExitCodes['pi-model-approval'] === 0 + && finalModelState.forbiddenAuthorityHeaderCount === 0 + && finalSellerState.forbiddenForwardedHeaderCount === 0, + [piResult.exitCode, piResult.outputObserved, + processExitCodes['pi-tool-approval'], processExitCodes['pi-model-approval'], + finalModelState.forbiddenAuthorityHeaderCount, + finalSellerState.forbiddenForwardedHeaderCount], + ); + recordObservation( + observations, + 'all-egress-is-loopback', + nonLoopbackEgressAttempts === 0, + [nonLoopbackEgressAttempts], + ); + + const invariants = makeInvariantResults(observations); + const passed = invariants.filter((entry) => entry.passed).length; + const transactionIds = events + .filter(({ transactionId }) => transactionId !== null) + .map(({ transactionId }) => transactionId); + const summary = Object.freeze({ + mode: 'offline-deterministic', + piVersion: '0.80.6', + x402Version: 2, + network: NETWORK, + isolation: 'simulated', + tests: INVARIANT_IDS.length, + passed, + liveCdp: 'not-run', + testnetTransaction: 'not-run', + }); + const freshVerification = Object.freeze({ + authorityEventChain: finalOverview?.status === 200 && allSessionProjectionsVerified, + projection: allSessionProjectionsVerified, + receipts: allAuthorityReceiptsVerified, + }); + return Object.freeze({ + summary, + evidenceInput: Object.freeze({ + acceptance: Object.freeze({ + invariants, + processExitCodes: Object.freeze({ ...processExitCodes }), + transactionIds: Object.freeze(transactionIds), + rawSettlementTransactionIds, + nonLoopbackEgressAttempts, + forbiddenPiAuthorityHeaderCount: + finalModelState.forbiddenAuthorityHeaderCount + + finalSellerState.forbiddenForwardedHeaderCount, + piOutputObserved: piResult.outputObserved, + piApprovalResume, + }), + sessionProjections, + authorityReceipts, + events: Object.freeze(events), + receiptPublicKeys: Object.freeze(readyReceiptKey ? [readyReceiptKey] : []), + identityBindings: Object.freeze({ + kernel: Object.freeze({ uid: String(process.getuid()), gid: String(process.getgid()) }), + agent: Object.freeze({ uid: String(process.getuid()), gid: String(process.getgid()) }), + }), + privilegedReport: null, + freshVerification, + policyHash: sha256(canonicalJson(readJsonFile(policyPath))), + routeMapHash: sha256(canonicalJson(readJsonFile(routePath))), + configHash: sha256(canonicalJson({ mode: 'deterministic', network: NETWORK })), + wallet: Object.freeze({ + provider: 'deterministic', + walletIdHash: sha256('wallet-process-fixture'), + address: WALLET_ADDRESS, + }), + }), + cleanup: Object.freeze(cleanup), + }); +} diff --git a/spikes/pi-wielder/scripts/preflight-agent-isolation.mjs b/spikes/pi-wielder/scripts/preflight-agent-isolation.mjs new file mode 100644 index 0000000..953a39a --- /dev/null +++ b/spikes/pi-wielder/scripts/preflight-agent-isolation.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +import { fork } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { canonicalJson, canonicalTimestamp, exactRecord, sha256 } from '../src/kernel/canonical.mjs'; +import { + REQUIRED_ISOLATION_PROBE_RESULTS, + validateIsolationReportBytes, +} from '../src/agent/isolation-preflight.mjs'; + +const WORKER = fileURLToPath(new URL('./agent-isolation-probe-worker.mjs', import.meta.url)); +const HASH = /^sha256:[0-9a-f]{64}$/; +const CONFIG_FIELDS = Object.freeze([ + 'schemaVersion', 'enrollmentHash', 'kernelUid', 'kernelGid', 'agentUid', 'agentGid', + 'authorityMetadataHash', 'credentialMetadataHash', 'releaseManifestHash', + 'releaseTreeHash', 'nodeExecutableHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', 'environmentMetadataHash', 'credentialPath', + 'protectedReadPaths', 'writePaths', 'reportPath', +]); + +function positiveText(value, label) { + if (typeof value !== 'string' || !/^[1-9][0-9]*$/.test(value) + || !Number.isSafeInteger(Number(value)) || String(Number(value)) !== value) { + throw new Error(`${label} is invalid`); + } + return value; +} + +function hash(value, label) { + if (typeof value !== 'string' || !HASH.test(value)) throw new Error(`${label} is invalid`); + return value; +} + +function validateConfig(value) { + const config = exactRecord(value, CONFIG_FIELDS, [], + 'ISOLATION_PREFLIGHT_CONFIG', 'isolation preflight config'); + if (config.schemaVersion !== 1) throw new Error('isolation preflight schemaVersion must equal 1'); + for (const field of ['kernelUid', 'kernelGid', 'agentUid', 'agentGid']) positiveText(config[field], field); + if (config.kernelUid === config.agentUid) throw new Error('Kernel and Agent UIDs must differ'); + for (const field of [ + 'enrollmentHash', 'authorityMetadataHash', 'credentialMetadataHash', + 'releaseManifestHash', 'releaseTreeHash', 'nodeExecutableHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', 'environmentMetadataHash', + ]) hash(config[field], field); + for (const field of ['credentialPath', 'reportPath']) { + if (typeof config[field] !== 'string' || !path.isAbsolute(config[field]) + || path.resolve(config[field]) !== config[field]) throw new Error(`${field} is invalid`); + } + return config; +} + +export function buildIsolationReport({ config: value, probeResults, now }) { + const config = validateConfig(value); + const probes = exactRecord(probeResults, Object.keys(REQUIRED_ISOLATION_PROBE_RESULTS), [], + 'ISOLATION_PROBE_RESULTS', 'isolation probe results'); + for (const [name, expected] of Object.entries(REQUIRED_ISOLATION_PROBE_RESULTS)) { + if (probes[name] !== expected) throw new Error(`isolation probe ${name} failed closed`); + } + const probedAt = canonicalTimestamp(now(), 'isolation probe time'); + const expiresAt = new Date(Date.parse(probedAt) + 15 * 60 * 1000).toISOString(); + const report = Object.freeze({ + schemaVersion: 1, + enrollmentHash: config.enrollmentHash, + kernelUid: config.kernelUid, + kernelGid: config.kernelGid, + agentUid: config.agentUid, + agentGid: config.agentGid, + authorityMetadataHash: config.authorityMetadataHash, + credentialMetadataHash: config.credentialMetadataHash, + releaseManifestHash: config.releaseManifestHash, + releaseTreeHash: config.releaseTreeHash, + nodeExecutableHash: config.nodeExecutableHash, + serviceArtifactsHash: config.serviceArtifactsHash, + systemdEffectiveConfigHash: config.systemdEffectiveConfigHash, + environmentMetadataHash: config.environmentMetadataHash, + probeResults: probes, + probedAt, + expiresAt, + }); + const reportHash = sha256(canonicalJson(report)); + const reportBytes = Buffer.from(`${canonicalJson(report)}\n`); + validateIsolationReportBytes(reportBytes, { + expectedReportHash: reportHash, + expectedEnrollmentHash: config.enrollmentHash, + expectedKernelUid: config.kernelUid, + expectedKernelGid: config.kernelGid, + expectedReleaseManifestHash: config.releaseManifestHash, + now: () => probedAt, + }); + return Object.freeze({ report, reportHash, reportBytes }); +} + +function spawnProbe(config, spawnImpl = fork) { + return new Promise((resolve, reject) => { + const child = spawnImpl(WORKER, [ + '--agent-uid', config.agentUid, '--agent-gid', config.agentGid, + ], { execPath: process.execPath, env: {}, stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); + let settled = false; + let sent = false; + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('isolation probe worker timed out')); + }, 15_000); + const finish = (operation) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + operation(); + }; + child.on('error', (error) => finish(() => reject(error))); + child.on('message', (message) => { + if (message?.type === 'ready' && !sent) { + sent = true; + child.send({ + credentialPath: config.credentialPath, + protectedReadPaths: config.protectedReadPaths, + writePaths: config.writePaths, + }); + } else if (message?.type === 'result' && sent) { + finish(() => resolve(message.probeResults)); + } else if (message?.type === 'failed') { + finish(() => reject(new Error('isolation probe worker failed'))); + } + }); + child.on('exit', (code) => { + if (!settled) finish(() => reject(new Error(`isolation probe worker exited ${code}`))); + }); + }); +} + +function publishReport({ reportPath, reportBytes, kernelUid, kernelGid, chown = fs.fchownSync }) { + const parent = fs.lstatSync(path.dirname(reportPath)); + if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o077) !== 0) { + throw new Error('isolation report parent must be one private direct directory'); + } + const descriptor = fs.openSync(reportPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o600); + try { + chown(descriptor, Number(kernelUid), Number(kernelGid)); + fs.writeFileSync(descriptor, reportBytes); + fs.fsyncSync(descriptor); + } finally { fs.closeSync(descriptor); } + const directory = fs.openSync(path.dirname(reportPath), fs.constants.O_RDONLY | fs.constants.O_DIRECTORY); + try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); } +} + +export async function runPrivilegedAgentIsolationPreflight({ + config: value, now = () => new Date().toISOString(), spawnImpl = fork, chown = fs.fchownSync, +}) { + if (process.getuid?.() !== 0) throw new Error('privileged isolation preflight requires root'); + const config = validateConfig(value); + const probeResults = await spawnProbe(config, spawnImpl); + const built = buildIsolationReport({ config, probeResults, now }); + publishReport({ reportPath: config.reportPath, reportBytes: built.reportBytes, + kernelUid: config.kernelUid, kernelGid: config.kernelGid, chown }); + return Object.freeze({ reportHash: built.reportHash }); +} + +function readConfig(filePath) { + if (!path.isAbsolute(filePath)) throw new Error('preflight config path must be absolute'); + const bytes = fs.readFileSync(filePath); + const value = JSON.parse(bytes.toString('utf8')); + if (!bytes.equals(Buffer.from(`${canonicalJson(value)}\n`))) { + throw new Error('preflight config must be canonical JSON plus newline'); + } + return value; +} + +async function direct() { + if (process.argv.length !== 4 || process.argv[2] !== '--config') { + throw new Error('usage: preflight-agent-isolation.mjs --config ABSOLUTE_PATH'); + } + const result = await runPrivilegedAgentIsolationPreflight({ config: readConfig(process.argv[3]) }); + process.stdout.write(`${result.reportHash}\n`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + direct().catch((error) => { + process.stderr.write(`isolation preflight failed: ${error?.code ?? 'ERROR'}\n`); + process.exitCode = 1; + }); +} diff --git a/spikes/pi-wielder/scripts/preflight-live-deployment.mjs b/spikes/pi-wielder/scripts/preflight-live-deployment.mjs new file mode 100644 index 0000000..0a981c4 --- /dev/null +++ b/spikes/pi-wielder/scripts/preflight-live-deployment.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +import { fork } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { canonicalJson } from '../src/kernel/canonical.mjs'; +import { + assertClosedLoaderEnvironment, + validateReleaseManifest, + verifyReleaseIntegrity, +} from '../src/kernel/release-integrity.mjs'; + +const READER_RELATIVE = 'scripts/prelaunch-kernel-reader.mjs'; + +// This is a deliberate, machine-readable release gate. The reusable preflight +// function is implemented and tested, but the installed process still lacks the +// reviewed composition that supplies its public release/systemd/probe inputs, +// loads secrets only after the root phase, and constructs the live control-plane +// dependencies. The future @hono/node-server listener adapters must also set +// `overrideGlobalObjects: false`; otherwise native fetch responses stop matching +// the platform Response constructor used by x402 validation. Keep the systemd +// service fail-closed until every blocker is replaced by tested production +// composition and Linux lifecycle evidence. +export const LIVE_LAUNCH_GATE = Object.freeze({ + schemaVersion: 1, + status: 'blocked', + code: 'LIVE_LAUNCH_NOT_READY', + exitStatus: 78, + blockers: Object.freeze([ + 'LIVE_PREFLIGHT_COMPOSITION_REQUIRED', + 'CONTROL_PLANE_COMPOSITION_REQUIRED', + 'LIVE_SECRET_DELIVERY_COMPOSITION_REQUIRED', + 'LIVE_LISTENER_RESPONSE_COMPATIBILITY_REQUIRED', + 'LIVE_SYSTEMD_LIFECYCLE_EVIDENCE_REQUIRED', + ]), +}); + +export function parsePreflightArguments(argv) { + if (!Array.isArray(argv) || argv.length !== 6 + || argv[0] !== '--release-manifest' || argv[2] !== '--kernel-uid' + || argv[4] !== '--kernel-gid') { + throw new Error('live preflight arguments do not match the closed schema'); + } + const [manifestPath, uid, gid] = [argv[1], argv[3], argv[5]]; + if (!path.isAbsolute(manifestPath) || path.resolve(manifestPath) !== manifestPath + || !/^[1-9][0-9]*$/.test(uid) || !/^[1-9][0-9]*$/.test(gid) + || !Number.isSafeInteger(Number(uid)) || !Number.isSafeInteger(Number(gid))) { + throw new Error('live preflight arguments contain invalid values'); + } + return Object.freeze({ manifestPath, kernelUid: uid, kernelGid: gid }); +} + +function readManifestOnce(manifestPath) { + const descriptor = fs.openSync(manifestPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.nlink !== 1 || stat.size <= 0 || stat.size > 4 * 1024 * 1024) { + throw new Error('release manifest must be one bounded regular file'); + } + const bytes = fs.readFileSync(descriptor); + const value = JSON.parse(bytes.toString('utf8')); + if (!bytes.equals(Buffer.from(`${canonicalJson(value)}\n`))) { + throw new Error('release manifest must be canonical JSON plus newline'); + } + return validateReleaseManifest(value); + } finally { fs.closeSync(descriptor); } +} + +export function assertRootPreflightEnvironment(environment) { + const captured = assertClosedLoaderEnvironment(environment, { + allowedWalletKernelFields: ['WALLET_KERNEL_ENV_FILE'], + }); + const environmentPath = captured.WALLET_KERNEL_ENV_FILE; + if (typeof environmentPath !== 'string' || !path.isAbsolute(environmentPath) + || path.resolve(environmentPath) !== environmentPath || environmentPath.includes('\0')) { + throw new Error('root preflight environment file pointer is invalid'); + } + return captured; +} + +function readerRoundTrip({ readerPath, nodePath, kernelUid, kernelGid, request }) { + return new Promise((resolve, reject) => { + const child = fork(readerPath, [ + '--kernel-uid', kernelUid, '--kernel-gid', kernelGid, + ], { execPath: nodePath, env: {}, stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); + let nonce; + let sent = false; + let settled = false; + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('prelaunch reader timed out')); + }, 15_000); + const finish = (fn) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + fn(); + }; + child.on('error', (error) => finish(() => reject(error))); + child.on('message', (message) => { + if (message?.type === 'ready' && typeof message.nonce === 'string' && !sent) { + nonce = message.nonce; + sent = true; + child.send({ ...request, nonce, parentPid: process.pid, + kernelUid, kernelGid }); + } else if (message?.type === 'result' && sent && message.nonce === nonce) { + finish(() => resolve(message.result)); + } else if (message?.type === 'failed') { + finish(() => reject(new Error('prelaunch reader failed'))); + } + }); + child.on('exit', (code) => { + if (!settled) finish(() => reject(new Error(`prelaunch reader exited ${code}`))); + }); + }); +} + +export async function runPrivilegedLivePreflight({ + argv, environment, releasePaths, effectiveSystemd, readerRequest, spawnReader = readerRoundTrip, +}) { + if (process.getuid?.() !== 0) throw new Error('live deployment preflight requires root'); + const parsed = parsePreflightArguments(argv); + const closedEnvironment = assertRootPreflightEnvironment(environment); + if (closedEnvironment.WALLET_KERNEL_ENV_FILE !== releasePaths.environmentPath) { + throw new Error('root preflight environment pointer differs from the release input'); + } + const manifest = readManifestOnce(parsed.manifestPath); + if (manifest.kernelIdentity.uid !== parsed.kernelUid + || manifest.kernelIdentity.gid !== parsed.kernelGid) { + throw new Error('preflight numeric identity differs from the release manifest'); + } + const verified = verifyReleaseIntegrity({ + mode: 'cdp-testnet', releaseRoot: path.dirname(parsed.manifestPath), manifest, + expectedOwnerUid: 0, expectedKernelUid: parsed.kernelUid, expectedKernelGid: parsed.kernelGid, + nodePath: process.execPath, nodeVersion: process.version, + environmentPath: releasePaths.environmentPath, + serviceArtifactPaths: releasePaths.serviceArtifactPaths, + }); + if (effectiveSystemd.effectiveConfigHash !== manifest.systemd.effectiveConfigHash) { + throw new Error('fresh PID1 effective configuration differs from the release manifest'); + } + const request = { + releaseRoot: path.dirname(parsed.manifestPath), + releaseManifestHash: verified.releaseManifestHash, + authorityMetadataHash: readerRequest.authorityMetadataHash, + probeResults: readerRequest.probeResults, + databasePath: readerRequest.databasePath, + pathTrust: readerRequest.pathTrust, + isolationReportPath: readerRequest.isolationReportPath, + now: readerRequest.now, + }; + const result = await spawnReader({ + readerPath: path.join(path.dirname(parsed.manifestPath), READER_RELATIVE), + nodePath: process.execPath, kernelUid: parsed.kernelUid, kernelGid: parsed.kernelGid, + request, + }); + return Object.freeze({ status: result.status, preflightDigest: result.preflightDigest, + nonceHash: crypto.createHash('sha256').update(String(result.nonce ?? '')).digest('hex') }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stderr.write(`${canonicalJson(LIVE_LAUNCH_GATE)}\n`); + // sysexits(3) EX_CONFIG. The blocked unit pins Restart=no so an intentional + // preflight refusal cannot turn into a privileged restart storm. + process.exitCode = LIVE_LAUNCH_GATE.exitStatus; +} diff --git a/spikes/pi-wielder/scripts/prelaunch-kernel-reader.mjs b/spikes/pi-wielder/scripts/prelaunch-kernel-reader.mjs new file mode 100644 index 0000000..0549e2e --- /dev/null +++ b/spikes/pi-wielder/scripts/prelaunch-kernel-reader.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +// This file intentionally has built-in-only static imports. Project code may be +// dynamically imported only after the numeric identity drop has been verified. +import crypto from 'node:crypto'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const HASH = /^sha256:[0-9a-f]{64}$/; +const POSITIVE = /^[1-9][0-9]*$/; +const ALLOWED_REQUEST_FIELDS = Object.freeze([ + 'nonce', 'parentPid', 'kernelUid', 'kernelGid', 'releaseRoot', + 'releaseManifestHash', 'authorityMetadataHash', 'probeResults', 'databasePath', + 'pathTrust', 'isolationReportPath', 'now', +]); + +function fail(message) { throw new Error(message); } + +function identity(value, label) { + if (typeof value !== 'string' || !POSITIVE.test(value) + || !Number.isSafeInteger(Number(value)) || String(Number(value)) !== value) { + fail(`${label} must be canonical positive decimal text`); + } + return Number(value); +} + +export function parseReaderArguments(argv) { + if (!Array.isArray(argv) || argv.length !== 4 + || argv[0] !== '--kernel-uid' || argv[2] !== '--kernel-gid') { + fail('prelaunch reader arguments do not match the closed schema'); + } + return Object.freeze({ + kernelUid: identity(argv[1], 'Kernel UID'), + kernelGid: identity(argv[3], 'Kernel GID'), + }); +} + +export function assertReaderEnvironment(environment) { + const names = Reflect.ownKeys(environment); + // NODE_CHANNEL_FD is created by Node for the one intentional IPC descriptor. + if (names.some((name) => typeof name !== 'string' || name !== 'NODE_CHANNEL_FD')) { + fail('prelaunch reader inherited an unrecognized environment field'); + } +} + +export function dropToKernelIdentity({ uid, gid, processApi = process }) { + if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) { + fail('prelaunch reader target identity is invalid'); + } + if (typeof processApi.setgroups !== 'function' || typeof processApi.setgid !== 'function' + || typeof processApi.setuid !== 'function') fail('prelaunch reader requires POSIX identity controls'); + processApi.setgroups([]); + processApi.setgid(gid); + processApi.setuid(uid); + const groups = processApi.getgroups(); + const supplementary = groups.filter((group) => group !== gid); + if (processApi.getuid() !== uid || processApi.geteuid?.() !== uid + || processApi.getgid() !== gid || processApi.getegid?.() !== gid + || supplementary.length !== 0) { + fail('prelaunch reader did not drop to the exact empty-group Kernel identity'); + } + return Object.freeze({ uid, gid }); +} + +export function validateReaderRequest(value, { nonce, parentPid, uid, gid }) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype + || Reflect.ownKeys(value).length !== ALLOWED_REQUEST_FIELDS.length + || ALLOWED_REQUEST_FIELDS.some((field) => !Object.hasOwn(value, field))) { + fail('prelaunch reader request fields do not match the closed schema'); + } + for (const field of ALLOWED_REQUEST_FIELDS) { + const descriptor = Object.getOwnPropertyDescriptor(value, field); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail('prelaunch reader request must contain only enumerable data fields'); + } + } + if (value.nonce !== nonce || value.parentPid !== parentPid + || value.kernelUid !== String(uid) || value.kernelGid !== String(gid) + || typeof value.releaseRoot !== 'string' || !path.isAbsolute(value.releaseRoot) + || !HASH.test(value.releaseManifestHash) || !HASH.test(value.authorityMetadataHash) + || !value.probeResults || typeof value.probeResults !== 'object' + || typeof value.databasePath !== 'string' || !path.isAbsolute(value.databasePath) + || typeof value.isolationReportPath !== 'string' || !path.isAbsolute(value.isolationReportPath) + || !value.pathTrust || typeof value.pathTrust !== 'object' + || typeof value.now !== 'string' || new Date(value.now).toISOString() !== value.now) { + fail('prelaunch reader request binding is invalid'); + } + const forbidden = /(?:database|token|credential|secret|key|listener|descriptor|socket)/i; + const visit = (item) => { + if (!item || typeof item !== 'object') return; + for (const [key, child] of Object.entries(item)) { + if (forbidden.test(key)) fail('prelaunch reader request names forbidden authority or secret data'); + visit(child); + } + }; + visit(value.probeResults); + return value; +} + +async function auditAuthorityAfterDrop(request) { + const [sqlite, lockModule, storage, isolation, canonical] = await Promise.all([ + import('node:sqlite'), + import('../src/kernel/authority-lock.mjs'), + import('../src/kernel/secure-storage.mjs'), + import('../src/agent/isolation-preflight.mjs'), + import('../src/kernel/canonical.mjs'), + ]); + const pathTrust = Object.freeze({ ...request.pathTrust }); + const authority = lockModule.acquireAuthorityLock({ + databasePath: request.databasePath, role: 'prelaunch', pathTrust, + }); + let database; + try { + database = new sqlite.DatabaseSync(request.databasePath, { readOnly: true, readBigInts: true }); + database.exec('PRAGMA query_only = ON; PRAGMA foreign_keys = ON; PRAGMA trusted_schema = OFF;'); + const enrollments = database.prepare("SELECT * FROM agent_enrollments WHERE state = 'active'").all(); + const attestations = database.prepare( + "SELECT * FROM isolation_attestations WHERE state = 'current'", + ).all(); + if (enrollments.length > 1 || attestations.length > 1) { + fail('prelaunch authority contains ambiguous active identity state'); + } + if (enrollments.length === 0) { + if (attestations.length !== 0) fail('prelaunch recovery state retains a current attestation'); + const data = { status: 'recovery_only', releaseManifestHash: request.releaseManifestHash, + authorityMetadataHash: request.authorityMetadataHash }; + return Object.freeze({ ...data, + preflightDigest: canonical.sha256(`wallet-kernel/prelaunch-result/v1\0${canonical.canonicalJson(data)}`) }); + } + if (attestations.length !== 1) fail('active enrollment requires one current isolation attestation'); + const enrollment = enrollments[0]; + const row = attestations[0]; + const reportBytes = storage.readPrivateInputFile( + request.isolationReportPath, 'Wallet Kernel isolation report', + { maximumBytes: 16 * 1024, pathTrust }, + ); + let validated; + try { + validated = isolation.validateIsolationReportBytes(reportBytes, { + expectedReportHash: row.report_hash, + expectedEnrollmentHash: enrollment.enrollment_hash, + expectedKernelUid: request.kernelUid, + expectedKernelGid: request.kernelGid, + expectedReleaseManifestHash: request.releaseManifestHash, + expectedAuthorityMetadataHash: request.authorityMetadataHash, + now: () => request.now, + }); + } finally { reportBytes.fill(0); } + if (row.enrollment_hash !== enrollment.enrollment_hash + || row.report_json !== canonical.canonicalJson(validated.report) + || row.probed_at !== validated.report.probedAt || row.expires_at !== validated.report.expiresAt + || canonical.canonicalJson(validated.report.probeResults) + !== canonical.canonicalJson(request.probeResults)) { + fail('prelaunch isolation authority differs from the fresh privileged probe'); + } + const data = { + status: 'verified', enrollmentHash: enrollment.enrollment_hash, + reportHash: row.report_hash, releaseManifestHash: request.releaseManifestHash, + authorityMetadataHash: request.authorityMetadataHash, + }; + return Object.freeze({ ...data, + preflightDigest: canonical.sha256(`wallet-kernel/prelaunch-result/v1\0${canonical.canonicalJson(data)}`) }); + } finally { + database?.close(); + authority.close(); + } +} + +export async function runDroppedReader({ argv, environment, processApi = process, dynamicAudit }) { + const { kernelUid, kernelGid } = parseReaderArguments(argv); + assertReaderEnvironment(environment); + dropToKernelIdentity({ uid: kernelUid, gid: kernelGid, processApi }); + if (typeof dynamicAudit !== 'function') fail('prelaunch reader requires a post-drop audit'); + return dynamicAudit(); +} + +async function direct() { + const parsed = parseReaderArguments(process.argv.slice(2)); + assertReaderEnvironment(process.env); + dropToKernelIdentity({ uid: parsed.kernelUid, gid: parsed.kernelGid }); + const nonce = crypto.randomBytes(32).toString('base64url'); + process.send?.({ type: 'ready', nonce, pid: process.pid }); + let handled = false; + process.on('message', async (request) => { + if (handled) process.exit(1); + handled = true; + try { + validateReaderRequest(request, { + nonce, parentPid: process.ppid, uid: parsed.kernelUid, gid: parsed.kernelGid, + }); + const result = await auditAuthorityAfterDrop(request); + process.send?.({ type: 'result', nonce, result }); + process.exit(0); + } catch { + process.send?.({ type: 'failed', nonce, code: 'PRELAUNCH_READER_FAILED' }); + process.exit(1); + } + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + direct().catch(() => { process.exitCode = 1; }); +} diff --git a/spikes/pi-wielder/scripts/render-systemd-units.mjs b/spikes/pi-wielder/scripts/render-systemd-units.mjs new file mode 100644 index 0000000..4f87cbc --- /dev/null +++ b/spikes/pi-wielder/scripts/render-systemd-units.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { exactRecord, frozenCopy, KernelError, sha256 } from '../src/kernel/canonical.mjs'; + +const SERVICE_TEMPLATE = fileURLToPath(new URL('../deploy/systemd/wallet-kernel.service', import.meta.url)); +const SOCKET_TEMPLATE = fileURLToPath(new URL('../deploy/systemd/wallet-kernel-console.socket', import.meta.url)); +const POSITIVE = /^[1-9][0-9]*$/; +const INPUT_FIELDS = Object.freeze([ + 'schemaVersion', 'kernelUid', 'kernelGid', 'agentUid', 'agentGid', 'releaseRoot', + 'nodePath', 'environmentPath', 'authorityRoot', 'evidenceRoot', 'runtimeRoot', + 'agentRunOutboxPath', 'enrollmentInboxPath', 'serviceOutputPath', 'socketOutputPath', +]); + +function fail(code, message, cause) { + throw new KernelError(code, message, cause ? { cause } : undefined); +} + +function identity(value, label) { + if (typeof value !== 'string' || !POSITIVE.test(value) + || !Number.isSafeInteger(Number(value)) || String(Number(value)) !== value) { + fail('SYSTEMD_RENDER_IDENTITY', `${label} must be canonical positive numeric text`); + } + return value; +} + +function absoluteToken(value, label) { + if (typeof value !== 'string' || !path.isAbsolute(value) || path.resolve(value) !== value + || /[\s\0'"`$;&|<>\\]/.test(value)) { + fail('SYSTEMD_RENDER_PATH', `${label} must be one canonical absolute systemd token`); + } + return value; +} + +function assertDirectPath(value, label, { directory = false, regular = false } = {}) { + absoluteToken(value, label); + let stat; + try { + stat = fs.lstatSync(value); + } catch (cause) { + fail('SYSTEMD_RENDER_PATH', `${label} must already exist`, cause); + } + if (stat.isSymbolicLink() || (directory && !stat.isDirectory()) || (regular && !stat.isFile())) { + fail('SYSTEMD_RENDER_PATH', `${label} has the wrong filesystem type`); + } + return value; +} + +function closedInput(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail('SYSTEMD_RENDER_INPUT', 'systemd render input must be one plain object'); + } + const allowed = new Set([...INPUT_FIELDS, 'install', 'expectedOwnerUid']); + const keys = Reflect.ownKeys(value); + if (INPUT_FIELDS.some((field) => !Object.hasOwn(value, field)) + || keys.some((field) => typeof field !== 'string' || !allowed.has(field))) { + fail('SYSTEMD_RENDER_INPUT', 'systemd render input fields do not match the closed schema'); + } + const base = exactRecord(Object.fromEntries(INPUT_FIELDS.map((field) => [field, value[field]])), + INPUT_FIELDS, [], 'SYSTEMD_RENDER_INPUT', 'systemd render input'); + return Object.freeze({ + ...base, + install: value.install === undefined ? false : value.install, + expectedOwnerUid: value.expectedOwnerUid === undefined ? 0 : value.expectedOwnerUid, + }); +} + +function readEnvironmentNames(environmentPath) { + const bytes = fs.readFileSync(environmentPath); + if (bytes.length > 64 * 1024 || bytes.includes(0)) { + fail('SYSTEMD_RENDER_ENVIRONMENT', 'Kernel environment file is not bounded text'); + } + const text = bytes.toString('utf8'); + for (const line of text.split('\n')) { + if (line === '' || line.startsWith('#')) continue; + const equals = line.indexOf('='); + if (equals < 1) fail('SYSTEMD_RENDER_ENVIRONMENT', 'Kernel environment line is malformed'); + const name = line.slice(0, equals); + if (!/^[A-Z][A-Z0-9_]*$/.test(name) + || name === 'NODE_OPTIONS' || name === 'NODE_PATH' || name.startsWith('LD_') + || name.startsWith('DYLD_') || name === 'GCONV_PATH' || name === 'GLIBC_TUNABLES') { + fail('SYSTEMD_RENDER_ENVIRONMENT', 'Kernel environment contains a loader-control field'); + } + } +} + +function substitute(template, replacements) { + let output = template; + for (const [marker, value] of Object.entries(replacements)) { + output = output.replaceAll(`{{${marker}}}`, value); + } + if (/\{\{[A-Z0-9_]+\}\}/.test(output)) { + fail('SYSTEMD_RENDER_TEMPLATE', 'systemd template contains an unresolved marker'); + } + return Buffer.from(output, 'utf8'); +} + +function exclusiveInstall(filePath, bytes, expectedOwnerUid) { + const parent = path.dirname(filePath); + const parentStat = fs.lstatSync(parent); + if (!parentStat.isDirectory() || parentStat.isSymbolicLink() + || parentStat.uid !== expectedOwnerUid || (parentStat.mode & 0o022) !== 0) { + fail('SYSTEMD_INSTALL_PATH', 'unit install parent must be expected-owner and immutable to group/other'); + } + let descriptor; + try { + descriptor = fs.openSync(filePath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o644); + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + } catch (cause) { + fail('SYSTEMD_INSTALL_FAILED', 'unit output already exists or could not be installed', cause); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + const directory = fs.openSync(parent, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY); + try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); } +} + +export function renderSystemdUnits(value) { + const input = closedInput(value); + if (input.schemaVersion !== 1 || typeof input.install !== 'boolean' + || !Number.isSafeInteger(input.expectedOwnerUid) || input.expectedOwnerUid < 0) { + fail('SYSTEMD_RENDER_INPUT', 'systemd render input values are invalid'); + } + for (const [field, label] of [ + ['kernelUid', 'Kernel UID'], ['kernelGid', 'Kernel GID'], + ['agentUid', 'Agent UID'], ['agentGid', 'Agent GID'], + ]) identity(input[field], label); + if (input.kernelUid === input.agentUid) fail('SYSTEMD_RENDER_IDENTITY', 'Kernel and Agent UIDs must differ'); + assertDirectPath(input.releaseRoot, 'release root', { directory: true }); + if (path.basename(input.releaseRoot) === 'current') { + fail('SYSTEMD_RENDER_PATH', 'release root must be immutable and version-addressed'); + } + assertDirectPath(input.nodePath, 'Node executable', { regular: true }); + assertDirectPath(input.environmentPath, 'environment file', { regular: true }); + readEnvironmentNames(input.environmentPath); + for (const field of [ + 'authorityRoot', 'evidenceRoot', 'runtimeRoot', 'agentRunOutboxPath', 'enrollmentInboxPath', + ]) assertDirectPath(input[field], field, { directory: true }); + for (const field of ['serviceOutputPath', 'socketOutputPath']) { + absoluteToken(input[field], field); + assertDirectPath(path.dirname(input[field]), `${field} parent`, { directory: true }); + } + const directional = [input.authorityRoot, input.evidenceRoot, input.runtimeRoot, + input.agentRunOutboxPath, input.enrollmentInboxPath]; + if (new Set(directional).size !== directional.length + || directional.some((candidate) => candidate === input.releaseRoot + || candidate.startsWith(`${input.releaseRoot}${path.sep}`))) { + fail('SYSTEMD_RENDER_PATH', 'writable and directional roots must be distinct and outside the release'); + } + const replacements = { + KERNEL_UID: input.kernelUid, KERNEL_GID: input.kernelGid, + RELEASE_ROOT: input.releaseRoot, NODE_PATH: input.nodePath, + ENVIRONMENT_PATH: input.environmentPath, AUTHORITY_ROOT: input.authorityRoot, + EVIDENCE_ROOT: input.evidenceRoot, RUNTIME_ROOT: input.runtimeRoot, + AGENT_RUN_OUTBOX_PATH: input.agentRunOutboxPath, + }; + const serviceBytes = substitute(fs.readFileSync(SERVICE_TEMPLATE, 'utf8'), replacements); + const socketBytes = substitute(fs.readFileSync(SOCKET_TEMPLATE, 'utf8'), replacements); + if (input.install) { + exclusiveInstall(input.serviceOutputPath, serviceBytes, input.expectedOwnerUid); + try { + exclusiveInstall(input.socketOutputPath, socketBytes, input.expectedOwnerUid); + } catch (cause) { + // The first file remains visible and auditable; privileged install cleanup owns removal. + throw cause; + } + } + const expectedEffectiveConfig = frozenCopy({ + kernelUid: input.kernelUid, kernelGid: input.kernelGid, + releaseRoot: input.releaseRoot, nodePath: input.nodePath, + environmentPath: input.environmentPath, + servicePath: input.serviceOutputPath, socketPath: input.socketOutputPath, + readWritePaths: [input.authorityRoot, input.evidenceRoot, input.runtimeRoot, + input.agentRunOutboxPath], + }); + return Object.freeze({ + serviceBytes, socketBytes, + service: Object.freeze({ path: input.serviceOutputPath, sha256: sha256(serviceBytes) }), + socket: Object.freeze({ path: input.socketOutputPath, sha256: sha256(socketBytes) }), + expectedEffectiveConfig, + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stderr.write('render-systemd-units.mjs is a library entrypoint; invoke it through the privileged installer\n'); + process.exitCode = 2; +} diff --git a/spikes/pi-wielder/scripts/run-evidence.mjs b/spikes/pi-wielder/scripts/run-evidence.mjs new file mode 100644 index 0000000..4d20624 --- /dev/null +++ b/spikes/pi-wielder/scripts/run-evidence.mjs @@ -0,0 +1,507 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + buildEvidenceBundle, + EvidenceError, + verifyEvidenceBundle, +} from '../src/evidence-bundle.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { + runSpendControlProcessAcceptance, + SPEND_CONTROL_PROCESS_CHILD_NAMES, + SPEND_CONTROL_PROCESS_INVARIANT_IDS, +} from './lib/spend-control-process-runner.mjs'; + +const PACKAGE_ROOT = path.resolve(import.meta.dirname, '..'); +const REPOSITORY_ROOT = path.resolve(PACKAGE_ROOT, '../..'); +const PI_EXECUTABLE = path.join(PACKAGE_ROOT, 'node_modules', '.bin', 'pi'); +const BASE_SEPOLIA = 'eip155:84532'; +const BASE_SEPOLIA_USDC = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const RAW_HASH = /^[0-9a-f]{64}$/u; +const PREFIXED_HASH = /^sha256:[0-9a-f]{64}$/u; +const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; + +export class EvidenceRunnerError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = 'EvidenceRunnerError'; + this.code = code; + } +} + +function fail(code, message, cause) { + throw new EvidenceRunnerError(code, message, cause ? { cause } : undefined); +} + +function canonicalAbsolute(value, code, label) { + if (typeof value !== 'string' || !path.isAbsolute(value) + || path.resolve(value) !== value || value.includes('\0')) { + fail(code, `${label} must be one canonical absolute path`); + } + return value; +} + +function pathIsInside(candidate, parent) { + const relative = path.relative(parent, candidate); + return relative === '' + || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function entryExists(destination, code, label) { + try { + fs.lstatSync(destination); + return true; + } catch (cause) { + if (cause?.code === 'ENOENT') return false; + fail(code, `${label} could not be inspected`, cause); + } +} + +function validateAbsentDestination(value, pathCode, existsCode, label) { + const destination = canonicalAbsolute(value, pathCode, label); + const parent = path.dirname(destination); + let realParent; + try { realParent = fs.realpathSync(parent); } catch (cause) { + fail(pathCode, `${label} parent must already exist`, cause); + } + let parentStat; + try { parentStat = fs.lstatSync(parent); } catch (cause) { + fail(pathCode, `${label} parent could not be inspected`, cause); + } + if (realParent !== parent || !parentStat.isDirectory() || parentStat.isSymbolicLink()) { + fail(pathCode, `${label} parent must be one real directory`); + } + if (entryExists(destination, pathCode, label)) { + fail(existsCode, `${label} must not already exist`); + } + return destination; +} + +function validateOfflineDestinations(outputDirectory, anchorOutput) { + const output = validateAbsentDestination( + outputDirectory, + 'EVIDENCE_OUTPUT_PATH', + 'EVIDENCE_OUTPUT_EXISTS', + 'evidence output', + ); + const anchor = validateAbsentDestination( + anchorOutput, + 'EVIDENCE_ANCHOR_PATH', + 'EVIDENCE_ANCHOR_EXISTS', + 'external anchor output', + ); + if (pathIsInside(anchor, output)) { + fail('EVIDENCE_ANCHOR_PATH', 'external anchor must be outside the evidence bundle'); + } + return Object.freeze({ outputDirectory: output, anchorOutput: anchor }); +} + +function fsyncDirectory(directory) { + const descriptor = fs.openSync( + directory, + fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY ?? 0) | (fs.constants.O_NOFOLLOW ?? 0), + ); + try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } +} + +function writeExternalAnchor(destination, manifestSha256) { + if (typeof manifestSha256 !== 'string' || !RAW_HASH.test(manifestSha256)) { + fail('EVIDENCE_ANCHOR_VALUE', 'external anchor must be one raw SHA-256 digest'); + } + let descriptor; + try { + descriptor = fs.openSync( + destination, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL + | (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n || (before.mode & 0o7777n) !== 0o600n + || before.uid !== BigInt(process.getuid())) { + fail('EVIDENCE_ANCHOR_AUTHORITY', 'external anchor file authority is invalid'); + } + fs.writeFileSync(descriptor, `${manifestSha256}\n`, { encoding: 'utf8' }); + fs.fsyncSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + const destinationStat = fs.lstatSync(destination, { bigint: true }); + if (destinationStat.isSymbolicLink() || after.dev !== before.dev || after.ino !== before.ino + || destinationStat.dev !== before.dev || destinationStat.ino !== before.ino + || after.nlink !== 1n || (after.mode & 0o7777n) !== 0o600n + || after.size !== 65n) { + fail('EVIDENCE_ANCHOR_AUTHORITY', 'external anchor changed while it was written'); + } + } catch (cause) { + if (cause instanceof EvidenceRunnerError) throw cause; + if (cause?.code === 'EEXIST' || cause?.code === 'ELOOP') { + fail('EVIDENCE_ANCHOR_EXISTS', 'external anchor output must not exist', cause); + } + fail('EVIDENCE_ANCHOR_WRITE', 'external anchor could not be written safely', cause); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + fsyncDirectory(path.dirname(destination)); +} + +function createAuthorityDirectory() { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'pi-wielder-evidence-authority-')), + ); + fs.chmodSync(directory, 0o700); + return directory; +} + +function readGitState() { + let commit; + let status; + try { + commit = execFileSync( + 'git', + ['-C', REPOSITORY_ROOT, 'rev-parse', 'HEAD'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim(); + status = execFileSync( + 'git', + ['-C', REPOSITORY_ROOT, 'status', '--porcelain=v1', '--untracked-files=normal'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ); + } catch (cause) { + fail('EVIDENCE_GIT_STATE', 'offline Git state could not be read', cause); + } + if (!COMMIT.test(commit)) fail('EVIDENCE_GIT_STATE', 'offline Git commit is invalid'); + return Object.freeze({ commit, dirty: status.length > 0 }); +} + +function validateAcceptanceOutput(result) { + if (!result || typeof result !== 'object' || Array.isArray(result) + || typeof result.cleanup !== 'function') { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance did not return its public result'); + } + const { summary, evidenceInput } = result; + if (!summary || typeof summary !== 'object' || Array.isArray(summary) + || summary.mode !== 'offline-deterministic' + || summary.piVersion !== '0.80.6' || summary.x402Version !== 2 + || summary.network !== BASE_SEPOLIA || summary.isolation !== 'simulated' + || summary.tests !== SPEND_CONTROL_PROCESS_INVARIANT_IDS.length + || summary.passed !== SPEND_CONTROL_PROCESS_INVARIANT_IDS.length + || summary.liveCdp !== 'not-run' + || summary.testnetTransaction !== 'not-run') { + fail('EVIDENCE_ACCEPTANCE_FAILED', 'offline process acceptance did not pass every gate'); + } + if (!evidenceInput || typeof evidenceInput !== 'object' || Array.isArray(evidenceInput) + || !Array.isArray(evidenceInput.acceptance?.invariants) + || evidenceInput.acceptance.invariants.length !== summary.tests + || evidenceInput.acceptance.invariants.some((item, index) => item?.passed !== true + || item.id !== SPEND_CONTROL_PROCESS_INVARIANT_IDS[index] + || !PREFIXED_HASH.test(item.evidenceHash ?? '')) + || evidenceInput.freshVerification?.authorityEventChain !== true + || evidenceInput.freshVerification?.projection !== true + || evidenceInput.freshVerification?.receipts !== true + || evidenceInput.privilegedReport !== null + || !Array.isArray(evidenceInput.events) || evidenceInput.events.length < 1 + || !Array.isArray(evidenceInput.receiptPublicKeys) + || evidenceInput.receiptPublicKeys.length < 1 + || evidenceInput.wallet?.provider !== 'deterministic' + || !PREFIXED_HASH.test(evidenceInput.wallet?.walletIdHash ?? '') + || !PREFIXED_HASH.test(evidenceInput.policyHash ?? '') + || !PREFIXED_HASH.test(evidenceInput.routeMapHash ?? '') + || !PREFIXED_HASH.test(evidenceInput.configHash ?? '')) { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance evidence is incomplete or unverified'); + } + const acceptance = evidenceInput.acceptance; + const exits = acceptance.processExitCodes; + if (!exits || typeof exits !== 'object' || Array.isArray(exits) + || canonicalJson(Object.keys(exits).sort()) + !== canonicalJson([...SPEND_CONTROL_PROCESS_CHILD_NAMES].sort()) + || SPEND_CONTROL_PROCESS_CHILD_NAMES.some((name) => exits[name] !== 0)) { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance child set did not exit cleanly'); + } + const rawSettlementIds = acceptance.rawSettlementTransactionIds; + const normalizedTransactionIds = acceptance.transactionIds; + if (!Array.isArray(rawSettlementIds) || rawSettlementIds.length === 0 + || !Array.isArray(normalizedTransactionIds) + || rawSettlementIds.some((value) => typeof value !== 'string' + || !/^0x[0-9a-f]{64}$/u.test(value)) + || normalizedTransactionIds.some((value) => typeof value !== 'string' + || !/^0x[0-9a-f]{64}$/u.test(value)) + || new Set(rawSettlementIds).size !== rawSettlementIds.length + || new Set(normalizedTransactionIds).size !== normalizedTransactionIds.length) { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance transaction authority was reused'); + } + const expectedPiApproval = canonicalJson({ + pendingObserved: true, + originalRequestHeld: true, + operatorApprovalStatus: 200, + signerDelta: 1, + paidRequestDelta: 1, + duplicatePaymentSignatureDelta: 0, + outputObserved: 'PI_WALLET_OK', + processExitCode: 0, + }); + const piApprovalResume = acceptance.piApprovalResume; + if (!piApprovalResume || typeof piApprovalResume !== 'object' + || Array.isArray(piApprovalResume) + || ['tool', 'model'].some((kind) => { + const entry = piApprovalResume[kind]; + return !entry || typeof entry !== 'object' || Array.isArray(entry) + || canonicalJson(entry) !== expectedPiApproval; + })) { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'pinned Pi approval resumption was not proven'); + } + const projections = evidenceInput.sessionProjections; + const authorityReceipts = evidenceInput.authorityReceipts; + if (!Array.isArray(projections) || projections.length === 0 + || !Array.isArray(authorityReceipts) || authorityReceipts.length === 0 + || projections.some((bundle) => !PREFIXED_HASH.test(bundle?.projectionHash ?? '') + || !PREFIXED_HASH.test(bundle?.projection?.sessionHash ?? '') + || bundle.projection.eventHeadHash !== projections[0]?.projection?.eventHeadHash + || !Array.isArray(bundle.projection.signedReceipts)) + || projections.some((bundle, index) => index > 0 + && projections[index - 1].projection.sessionHash + .localeCompare(bundle.projection.sessionHash) >= 0)) { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance projections are incomplete'); + } + const partition = projections.flatMap(({ projection }) => projection.signedReceipts); + const receiptOrder = (left, right) => left.intentId.localeCompare(right.intentId) + || left.revision - right.revision || left.id.localeCompare(right.id); + const sortedPartition = [...partition].sort(receiptOrder); + const sortedAuthorityReceipts = [...authorityReceipts].sort(receiptOrder); + if (new Set(partition.map(({ receiptHash }) => receiptHash)).size !== partition.length + || canonicalJson(sortedPartition) !== canonicalJson(sortedAuthorityReceipts)) { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance receipt partition is incomplete'); + } + const receiptEvents = evidenceInput.events.filter(({ eventType }) => ( + eventType === 'receipt.issued' + )); + const receiptByHash = new Map(authorityReceipts.map((receipt) => [receipt.receiptHash, receipt])); + if (receiptEvents.length !== authorityReceipts.length + || receiptEvents.some((event) => ( + receiptByHash.get(event.receiptHash)?.signature !== event.receiptSignature + ))) { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance receipt events are incomplete'); + } + return Object.freeze({ summary, evidenceInput, cleanup: result.cleanup }); +} + +function signedProjectionSetHash(signedProjections) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.signed-projection-set.v1', + signedProjections, + })); +} + +function identityHashes(identityBindings) { + const kernel = identityBindings?.kernel; + const agent = identityBindings?.agent; + if (!kernel || !agent) { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance omitted identity bindings'); + } + return Object.freeze({ + kernelIdentityHash: sha256(canonicalJson({ + domain: 'wallet-kernel.kernel-identity.v1', + kernelUid: kernel.uid, + kernelGid: kernel.gid, + })), + agentIdentityHash: sha256(canonicalJson({ + domain: 'wallet-kernel.agent-identity.v1', + agentUid: agent.uid, + agentGid: agent.gid, + })), + }); +} + +function offlineManifestInput({ evidenceInput, summary, git, createdAt }) { + const identities = identityHashes(evidenceInput.identityBindings); + return { + schemaVersion: 2, + createdAt, + mode: 'offline-deterministic', + git, + runtime: { nodeVersion: process.version, piVersion: summary.piVersion }, + protocol: { + x402Version: summary.x402Version, + network: summary.network, + asset: BASE_SEPOLIA_USDC, + }, + wallet: evidenceInput.wallet, + isolation: { + status: 'simulated', + preflightDigest: null, + ...identities, + }, + deployment: { + status: 'simulated', + releaseManifestDigest: null, + releaseTreeHash: null, + serviceArtifactsHash: null, + systemdEffectiveConfigHash: null, + }, + inputs: { + policyHash: evidenceInput.policyHash, + routeMapHash: evidenceInput.routeMapHash, + configHash: evidenceInput.configHash, + }, + source: { + authorityEventHeadHash: evidenceInput.sessionProjections[0].projection.eventHeadHash, + signedProjectionHash: signedProjectionSetHash(evidenceInput.sessionProjections), + receiptKeys: evidenceInput.receiptPublicKeys, + }, + status: { + liveCdp: 'not-run', + walletFunded: 'not-run', + testnetTransaction: 'not-run', + }, + identityBindings: evidenceInput.identityBindings, + privilegedReport: null, + signedProjections: evidenceInput.sessionProjections, + }; +} + +export function parseRunEvidenceArguments(argv) { + if (!Array.isArray(argv) || argv.length < 2 || argv.length % 2 !== 0) { + fail('EVIDENCE_RUN_ARGUMENTS', 'run-evidence requires explicit option/value pairs'); + } + const options = {}; + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!['--mode', '--output', '--anchor-output'].includes(name) + || Object.hasOwn(options, name) || typeof value !== 'string' || value.length === 0) { + fail('EVIDENCE_RUN_ARGUMENTS', 'run-evidence arguments are invalid or duplicated'); + } + options[name] = value; + } + if (options['--mode'] === 'base-sepolia-testnet') { + if (Object.keys(options).length !== 1) { + fail('EVIDENCE_RUN_ARGUMENTS', 'testnet mode does not accept developer output paths'); + } + return Object.freeze({ mode: 'base-sepolia-testnet' }); + } + if (options['--mode'] !== 'offline-deterministic' || Object.keys(options).length !== 3 + || !options['--output'] || !options['--anchor-output']) { + fail('EVIDENCE_RUN_ARGUMENTS', 'offline mode requires output and external anchor paths'); + } + return Object.freeze({ + mode: 'offline-deterministic', + outputDirectory: canonicalAbsolute( + path.resolve(options['--output']), + 'EVIDENCE_RUN_ARGUMENTS', + 'evidence output', + ), + anchorOutput: canonicalAbsolute( + path.resolve(options['--anchor-output']), + 'EVIDENCE_RUN_ARGUMENTS', + 'external anchor output', + ), + }); +} + +export async function runOfflineEvidence({ outputDirectory, anchorOutput }, dependencies = {}) { + const destinations = validateOfflineDestinations(outputDirectory, anchorOutput); + const runAcceptance = dependencies.runAcceptance ?? runSpendControlProcessAcceptance; + const buildBundle = dependencies.buildBundle ?? buildEvidenceBundle; + const verifyBundle = dependencies.verifyBundle ?? verifyEvidenceBundle; + const gitState = dependencies.gitState ?? readGitState; + const now = dependencies.now ?? (() => new Date().toISOString()); + const authorityDirectory = (dependencies.createAuthorityDirectory ?? createAuthorityDirectory)(); + const removeAuthorityDirectory = dependencies.removeAuthorityDirectory + ?? ((directory) => fs.rmSync(directory, { recursive: true, force: true })); + const piExecutable = dependencies.piExecutable ?? PI_EXECUTABLE; + const nodeExecutable = dependencies.nodeExecutable ?? fs.realpathSync(process.execPath); + let cleanup = async () => {}; + let primaryError = null; + try { + const raw = await runAcceptance({ authorityDirectory, piExecutable, nodeExecutable }); + if (typeof raw?.cleanup === 'function') cleanup = raw.cleanup; + const accepted = validateAcceptanceOutput(raw); + cleanup = accepted.cleanup; + const built = buildBundle({ + outputDirectory: destinations.outputDirectory, + manifestInput: offlineManifestInput({ + evidenceInput: accepted.evidenceInput, + summary: accepted.summary, + git: gitState(), + createdAt: now(), + }), + events: accepted.evidenceInput.events, + receipts: accepted.evidenceInput.authorityReceipts, + }); + if (!built || typeof built.manifestSha256 !== 'string' + || !RAW_HASH.test(built.manifestSha256)) { + fail('EVIDENCE_BUILD_RESULT', 'evidence builder did not return an external anchor'); + } + const verified = verifyBundle(destinations.outputDirectory, { + expectedManifestSha256: built.manifestSha256, + }); + if (verified?.valid !== true || verified.mode !== 'offline-deterministic' + || verified.manifestSha256 !== built.manifestSha256) { + fail('EVIDENCE_VERIFY_RESULT', 'fresh evidence verification did not match its anchor'); + } + writeExternalAnchor(destinations.anchorOutput, built.manifestSha256); + return verified; + } catch (error) { + primaryError = error; + throw error; + } finally { + let finalizationError = null; + try { await cleanup(); } catch (error) { finalizationError = error; } + try { removeAuthorityDirectory(authorityDirectory); } catch (error) { + if (finalizationError === null) finalizationError = error; + } + if (primaryError === null && finalizationError !== null) { + fail('EVIDENCE_CLEANUP', 'temporary process authority could not be removed', finalizationError); + } + } +} + +export async function runEvidence(options, dependencies = {}) { + if (options?.mode === 'offline-deterministic') { + return await runOfflineEvidence(options, dependencies); + } + if (options?.mode === 'base-sepolia-testnet') { + // No live Kernel-side orchestration API is available in this slice. Staying + // not-run here prevents credentials or a funded wallet from being mistaken + // for authorization and prevents construction of any real adapter. + fail( + 'EVIDENCE_TESTNET_NOT_RUN', + 'Base Sepolia evidence remains not-run until the privileged live runner is installed', + ); + } + fail('EVIDENCE_RUN_ARGUMENTS', 'evidence mode is invalid'); +} + +function publicError(error, mode = null) { + const code = error instanceof EvidenceRunnerError || error instanceof EvidenceError + || (typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{0,127}$/u.test(error.code)) + ? error.code + : 'EVIDENCE_RUN_INTERNAL'; + return canonicalJson({ + code, + mode: mode ?? 'unknown', + status: 'not-run', + valid: false, + }); +} + +export async function main(argv = process.argv.slice(2)) { + let options = null; + try { + options = parseRunEvidenceArguments(argv); + process.stdout.write(`${canonicalJson(await runEvidence(options))}\n`); + return 0; + } catch (error) { + process.stderr.write(`${publicError(error, options?.mode ?? null)}\n`); + return error?.code === 'EVIDENCE_RUN_ARGUMENTS' + || error?.code === 'EVIDENCE_TESTNET_NOT_RUN' ? 2 : 1; + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) process.exitCode = await main(); diff --git a/spikes/pi-wielder/scripts/run-testnet-agent.mjs b/spikes/pi-wielder/scripts/run-testnet-agent.mjs new file mode 100644 index 0000000..b8e6e84 --- /dev/null +++ b/spikes/pi-wielder/scripts/run-testnet-agent.mjs @@ -0,0 +1,717 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + canonicalAtomic, + canonicalJson, + canonicalTimestamp, + exactRecord, + frozenCopy, + sha256, +} from '../src/kernel/canonical.mjs'; + +const DOMAIN = 'wallet-kernel.testnet-agent-run.v1'; +const AGENT_CALL_DOMAIN = 'wallet-kernel.testnet-agent-call.v1\0'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const HASH = /^sha256:[0-9a-f]{64}$/u; +const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u; +const ADDRESS = /^0x[0-9a-f]{40}$/u; +const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const ROUTE_PATH = /^\/(?:[A-Za-z0-9._~!$&'()*+,;=:@%-]+\/?)*$/u; +const MAXIMUM_INTENT_LIFETIME_MS = 15 * 60 * 1_000; +const MAXIMUM_INTENT_BYTES = 65_536; +const MAXIMUM_CREDENTIAL_BYTES = 256; +const MAXIMUM_RESPONSE_BYTES = 1_048_576; +const REQUEST_TIMEOUT_MS = 30_000; +const INSTANCE = /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/u; +const CREDENTIAL_TOKEN = /^[A-Za-z0-9_-]{43}$/u; +const FORBIDDEN_AGENT_ENVIRONMENT = Object.freeze([ + 'CDP_API_KEY_ID', + 'CDP_API_KEY_SECRET', + 'CDP_WALLET_SECRET', + 'CDP_WALLET_NAME', + 'PRIVATE_KEY', + 'WALLET_PRIVATE_KEY', + 'WALLET_KERNEL_BASE_SEPOLIA_RPC_URL', + 'WALLET_KERNEL_OPERATOR_TOKEN_FILE', + 'WALLET_KERNEL_RECEIPT_KEY_FILE', +]); + +const INTENT_FIELDS = Object.freeze([ + 'schemaVersion', 'domain', 'runId', 'createdAt', 'expiresAt', 'network', 'asset', + 'gitCommit', 'deployment', 'walletAddress', 'policyHash', 'routeMapHash', + 'maximumTotalAtomic', 'kernelOrigin', 'kernelIdentity', 'agentIdentity', + 'credentialDigest', 'sellerRoutes', +]); +const DEPLOYMENT_FIELDS = Object.freeze([ + 'releaseManifestDigest', 'releaseTreeHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', +]); + +export class TestnetAgentRunnerError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = 'TestnetAgentRunnerError'; + this.code = code; + } +} + +function fail(code, message, cause) { + throw new TestnetAgentRunnerError(code, message, cause ? { cause } : undefined); +} + +function record(value, fields, code, label) { + try { + return exactRecord(value, fields, [], code, label); + } catch (cause) { + if (cause?.code === code) throw new TestnetAgentRunnerError(code, cause.message, { cause }); + return fail(code, `${label} fields do not match the closed schema`, cause); + } +} + +function canonicalIdentity(value, label) { + const identity = record(value, ['uid', 'gid'], 'TESTNET_RUN_INTENT_SCHEMA', label); + for (const field of ['uid', 'gid']) { + if (typeof identity[field] !== 'string' || !/^[1-9][0-9]*$/u.test(identity[field]) + || !Number.isSafeInteger(Number(identity[field]))) { + fail('TESTNET_RUN_INTENT_IDENTITY', `${label} must contain positive decimal identity text`); + } + } + return identity; +} + +function canonicalLoopbackOrigin(value) { + let parsed; + try { parsed = new URL(value); } catch { + return fail('TESTNET_RUN_INTENT_ORIGIN', 'Kernel origin must be one exact loopback origin'); + } + if (typeof value !== 'string' || parsed.protocol !== 'http:' + || parsed.hostname !== '127.0.0.1' || parsed.port === '' + || parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '' + || parsed.username !== '' || parsed.password !== '' || parsed.origin !== value) { + fail('TESTNET_RUN_INTENT_ORIGIN', 'Kernel origin must be one exact loopback origin'); + } + return value; +} + +function canonicalSellerOrigin(value) { + let parsed; + try { parsed = new URL(value); } catch { + return fail('TESTNET_RUN_INTENT_ROUTE', 'seller origin is invalid'); + } + if (typeof value !== 'string' || parsed.protocol !== 'https:' + || parsed.username !== '' || parsed.password !== '' || parsed.port !== '' + || parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '' + || parsed.origin !== value) { + fail('TESTNET_RUN_INTENT_ROUTE', 'seller origin must be one exact HTTPS origin'); + } + return value; +} + +function validateRoute(value, index) { + const route = record( + value, + ['routeId', 'kind', 'sellerOrigin', 'resourcePath', 'model'], + 'TESTNET_RUN_INTENT_SCHEMA', + `seller route ${index}`, + ); + if (typeof route.routeId !== 'string' || !TOKEN.test(route.routeId) + || !['openai-chat', 'tool'].includes(route.kind) + || typeof route.resourcePath !== 'string' || !ROUTE_PATH.test(route.resourcePath) + || route.resourcePath.includes('//') || route.resourcePath.includes('?') + || route.resourcePath.includes('#') + || ((route.kind === 'openai-chat') !== (typeof route.model === 'string')) + || (route.model !== null && !TOKEN.test(route.model))) { + fail('TESTNET_RUN_INTENT_ROUTE', 'seller route fields are invalid'); + } + canonicalSellerOrigin(route.sellerOrigin); + return route; +} + +export function validateTestnetRunIntent(value, { now = new Date().toISOString() } = {}) { + const intent = record( + value, + INTENT_FIELDS, + 'TESTNET_RUN_INTENT_SCHEMA', + 'testnet run intent', + ); + if (intent.schemaVersion !== 1 || intent.domain !== DOMAIN + || typeof intent.runId !== 'string' || !TOKEN.test(intent.runId) + || typeof intent.gitCommit !== 'string' || !COMMIT.test(intent.gitCommit) + || typeof intent.walletAddress !== 'string' || !ADDRESS.test(intent.walletAddress)) { + fail('TESTNET_RUN_INTENT_SCHEMA', 'testnet run intent fields are invalid'); + } + if (intent.network !== NETWORK || intent.asset !== ASSET) { + fail('TESTNET_RUN_INTENT_NETWORK', 'only canonical Base Sepolia USDC is permitted'); + } + try { + canonicalTimestamp(intent.createdAt, 'run intent createdAt'); + canonicalTimestamp(intent.expiresAt, 'run intent expiresAt'); + canonicalTimestamp(now, 'run intent validation time'); + } catch (cause) { + fail('TESTNET_RUN_INTENT_TIME', 'run intent timestamps are invalid', cause); + } + const created = Date.parse(intent.createdAt); + const expires = Date.parse(intent.expiresAt); + const observed = Date.parse(now); + if (expires <= created || expires - created > MAXIMUM_INTENT_LIFETIME_MS + || observed < created || observed >= expires) { + fail('TESTNET_RUN_INTENT_TIME', 'run intent is not currently valid'); + } + const deployment = record( + intent.deployment, + DEPLOYMENT_FIELDS, + 'TESTNET_RUN_INTENT_SCHEMA', + 'testnet deployment binding', + ); + for (const [label, hash] of Object.entries({ + ...deployment, + policyHash: intent.policyHash, + routeMapHash: intent.routeMapHash, + credentialDigest: intent.credentialDigest, + })) { + if (typeof hash !== 'string' || !HASH.test(hash)) { + fail('TESTNET_RUN_INTENT_SCHEMA', `${label} must be one canonical SHA-256 digest`); + } + } + let maximum; + try { maximum = canonicalAtomic(intent.maximumTotalAtomic, 'run maximum'); } catch (cause) { + fail('TESTNET_RUN_INTENT_SCHEMA', 'maximum total must be canonical atomic text', cause); + } + if (maximum.value <= 0n || intent.maximumTotalAtomic.length > 78) { + fail('TESTNET_RUN_INTENT_SCHEMA', 'maximum total must be positive bounded atomic text'); + } + canonicalLoopbackOrigin(intent.kernelOrigin); + const kernelIdentity = canonicalIdentity(intent.kernelIdentity, 'Kernel identity'); + const agentIdentity = canonicalIdentity(intent.agentIdentity, 'Agent identity'); + if (kernelIdentity.uid === agentIdentity.uid) { + fail('TESTNET_RUN_INTENT_IDENTITY', 'Kernel and Agent UIDs must be distinct'); + } + if (!Array.isArray(intent.sellerRoutes) || intent.sellerRoutes.length < 1 + || intent.sellerRoutes.length > 16) { + fail('TESTNET_RUN_INTENT_ROUTE', 'run intent must contain one bounded route list'); + } + const routes = intent.sellerRoutes.map(validateRoute); + if (new Set(routes.map(({ routeId }) => routeId)).size !== routes.length) { + fail('TESTNET_RUN_INTENT_ROUTE', 'run intent route IDs must be unique'); + } + return frozenCopy({ + ...intent, + deployment, + kernelIdentity, + agentIdentity, + sellerRoutes: routes, + }); +} + +export function testnetRunIntentDigest(intent) { + return sha256(canonicalJson(intent)); +} + +export function testnetAgentCallId(intentDigest, routeId) { + if (typeof intentDigest !== 'string' || !HASH.test(intentDigest) + || typeof routeId !== 'string' || !TOKEN.test(routeId)) { + fail('TESTNET_AGENT_REQUEST', 'Agent logical call binding is invalid'); + } + return crypto.createHash('sha256') + .update(AGENT_CALL_DOMAIN, 'utf8') + .update(intentDigest, 'utf8') + .update('\0', 'utf8') + .update(routeId, 'utf8') + .digest('base64url'); +} + +function canonicalAbsolute(value, code, label) { + if (typeof value !== 'string' || !path.isAbsolute(value) + || path.resolve(value) !== value || value.includes('\0')) { + fail(code, `${label} must be one canonical absolute path`); + } + return value; +} + +function fileIdentity(stat) { + return Object.freeze({ + dev: stat.dev, + ino: stat.ino, + uid: stat.uid, + gid: stat.gid, + mode: stat.mode & 0o7777n, + nlink: stat.nlink, + size: stat.size, + mtimeNs: stat.mtimeNs, + }); +} + +function sameFileIdentity(left, right) { + return Object.keys(left).every((key) => left[key] === right[key]); +} + +function readExactBytes(descriptor, size) { + const bytes = Buffer.alloc(Number(size)); + let offset = 0; + while (offset < bytes.length) { + const count = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count <= 0) fail('TESTNET_RUN_INTENT_FILE', 'run intent was truncated during read'); + offset += count; + } + const overflow = Buffer.alloc(1); + try { + if (fs.readSync(descriptor, overflow, 0, 1, offset) !== 0) { + fail('TESTNET_RUN_INTENT_FILE', 'run intent grew during read'); + } + } finally { + overflow.fill(0); + } + return bytes; +} + +function parseCanonicalIntent(bytes, now) { + let text; + try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch (cause) { + fail('TESTNET_RUN_INTENT_FILE', 'run intent is not canonical UTF-8', cause); + } + if (!text.endsWith('\n') || text.slice(0, -1).includes('\n') || text.includes('\0')) { + fail('TESTNET_RUN_INTENT_FILE', 'run intent must contain canonical JSON plus one newline'); + } + let value; + try { value = JSON.parse(text.slice(0, -1)); } catch (cause) { + fail('TESTNET_RUN_INTENT_FILE', 'run intent is not JSON', cause); + } + const intent = validateTestnetRunIntent(value, { now }); + if (text !== `${canonicalJson(intent)}\n`) { + fail('TESTNET_RUN_INTENT_FILE', 'run intent bytes are not canonical JSON'); + } + return intent; +} + +export function readTestnetRunIntent({ + filePath, + outboxPath, + expectedDigest, + now = new Date().toISOString(), +}) { + const destination = canonicalAbsolute( + filePath, + 'TESTNET_RUN_INTENT_PATH', + 'run intent path', + ); + const outbox = canonicalAbsolute( + outboxPath, + 'TESTNET_RUN_INTENT_PATH', + 'Agent run outbox', + ); + if (path.dirname(destination) !== outbox || path.basename(destination) === '') { + fail('TESTNET_RUN_INTENT_PATH', 'run intent must be one direct child of the configured outbox'); + } + let actualOutbox; + try { actualOutbox = fs.realpathSync(outbox); } catch (cause) { + fail('TESTNET_RUN_INTENT_PATH', 'Agent run outbox does not exist', cause); + } + if (actualOutbox !== outbox || typeof expectedDigest !== 'string' || !HASH.test(expectedDigest)) { + fail('TESTNET_RUN_INTENT_PATH', 'run intent path or confirmation digest is invalid'); + } + + let parentDescriptor; + let descriptor; + let bytes; + try { + parentDescriptor = fs.openSync( + outbox, + fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY ?? 0) | (fs.constants.O_NOFOLLOW ?? 0), + ); + const parentBeforeStat = fs.fstatSync(parentDescriptor, { bigint: true }); + const parentBefore = fileIdentity(parentBeforeStat); + if (!parentBeforeStat.isDirectory() || parentBefore.mode !== 0o755n) { + fail('TESTNET_RUN_INTENT_AUTHORITY', 'Agent run outbox authority is invalid'); + } + + descriptor = fs.openSync( + destination, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const beforeStat = fs.fstatSync(descriptor, { bigint: true }); + const before = fileIdentity(beforeStat); + if (!beforeStat.isFile() || before.mode !== 0o644n || before.nlink !== 1n + || before.size < 1n || before.size > BigInt(MAXIMUM_INTENT_BYTES)) { + fail('TESTNET_RUN_INTENT_AUTHORITY', 'run intent file authority is invalid'); + } + bytes = readExactBytes(descriptor, before.size); + const after = fileIdentity(fs.fstatSync(descriptor, { bigint: true })); + const pathStat = fs.lstatSync(destination, { bigint: true }); + const parentAfter = fileIdentity(fs.fstatSync(parentDescriptor, { bigint: true })); + const parentPathStat = fs.lstatSync(outbox, { bigint: true }); + if (!sameFileIdentity(before, after) || pathStat.isSymbolicLink() + || !sameFileIdentity(before, fileIdentity(pathStat)) + || parentPathStat.isSymbolicLink() || !parentPathStat.isDirectory() + || !sameFileIdentity(parentBefore, parentAfter) + || !sameFileIdentity(parentBefore, fileIdentity(parentPathStat))) { + fail('TESTNET_RUN_INTENT_AUTHORITY', 'run intent authority changed during read'); + } + const intent = parseCanonicalIntent(bytes, now); + // The declared Kernel owner is not self-authenticating: expectedDigest is the + // separately confirmed trust anchor over these exact canonical intent bytes. + // Only after that binding is checked do we accept the declaration as the UID/GID + // authority against which the already-open file and parent are compared. + const intentDigest = testnetRunIntentDigest(intent); + if (intentDigest !== expectedDigest) { + fail('TESTNET_RUN_INTENT_CONFIRMATION', 'run intent confirmation does not match'); + } + const kernelUid = BigInt(intent.kernelIdentity.uid); + const kernelGid = BigInt(intent.kernelIdentity.gid); + if (before.uid !== kernelUid || before.gid !== kernelGid + || parentBefore.uid !== kernelUid || parentBefore.gid !== kernelGid) { + fail('TESTNET_RUN_INTENT_AUTHORITY', 'run intent is not owned by the declared Kernel'); + } + return frozenCopy({ intent, intentDigest }); + } catch (cause) { + if (cause instanceof TestnetAgentRunnerError) throw cause; + fail('TESTNET_RUN_INTENT_FILE', 'run intent could not be read safely', cause); + } finally { + bytes?.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + if (parentDescriptor !== undefined) fs.closeSync(parentDescriptor); + } +} + +function parseCredential(bytes) { + let text; + try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch (cause) { + fail('TESTNET_AGENT_CREDENTIAL', 'Agent credential is not canonical UTF-8', cause); + } + if (!text.endsWith('\n') || text.slice(0, -1).includes('\n') || text.includes('\0')) { + fail('TESTNET_AGENT_CREDENTIAL', 'Agent credential must contain one canonical line'); + } + let value; + try { value = JSON.parse(text.slice(0, -1)); } catch (cause) { + fail('TESTNET_AGENT_CREDENTIAL', 'Agent credential is not JSON', cause); + } + const credential = record( + value, + ['agentInstanceId', 'schemaVersion', 'token'], + 'TESTNET_AGENT_CREDENTIAL', + 'Agent credential', + ); + if (credential.schemaVersion !== 1 || !INSTANCE.test(credential.agentInstanceId) + || !CREDENTIAL_TOKEN.test(credential.token) + || `${canonicalJson(credential)}\n` !== text) { + fail('TESTNET_AGENT_CREDENTIAL', 'Agent credential schema or encoding is invalid'); + } + const instanceBytes = Buffer.from(credential.agentInstanceId, 'base64url'); + const tokenBytes = Buffer.from(credential.token, 'base64url'); + try { + if (instanceBytes.length !== 16 + || instanceBytes.toString('base64url') !== credential.agentInstanceId + || tokenBytes.length !== 32 + || tokenBytes.toString('base64url') !== credential.token) { + fail('TESTNET_AGENT_CREDENTIAL', 'Agent credential opaque values are invalid'); + } + return { credential: frozenCopy(credential), credentialDigest: sha256(tokenBytes) }; + } finally { + instanceBytes.fill(0); + tokenBytes.fill(0); + } +} + +export function readTestnetAgentCredential({ + filePath, + expectedDigest, + expectedAgentUid, + expectedAgentGid, +}) { + const destination = canonicalAbsolute( + filePath, + 'TESTNET_AGENT_CREDENTIAL_PATH', + 'Agent credential path', + ); + if (typeof expectedDigest !== 'string' || !HASH.test(expectedDigest) + || !Number.isSafeInteger(expectedAgentUid) || expectedAgentUid <= 0 + || !Number.isSafeInteger(expectedAgentGid) || expectedAgentGid <= 0) { + fail('TESTNET_AGENT_CREDENTIAL_PATH', 'Agent credential authority input is invalid'); + } + const parent = path.dirname(destination); + let actualParent; + try { actualParent = fs.realpathSync(parent); } catch (cause) { + fail('TESTNET_AGENT_CREDENTIAL_PATH', 'Agent credential parent does not exist', cause); + } + if (actualParent !== parent) { + fail('TESTNET_AGENT_CREDENTIAL_PATH', 'Agent credential parent must not traverse symlinks'); + } + + let parentDescriptor; + let descriptor; + let bytes; + try { + parentDescriptor = fs.openSync( + parent, + fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY ?? 0) | (fs.constants.O_NOFOLLOW ?? 0), + ); + const parentBeforeStat = fs.fstatSync(parentDescriptor, { bigint: true }); + const parentBefore = fileIdentity(parentBeforeStat); + if (!parentBeforeStat.isDirectory() || parentBefore.uid !== BigInt(expectedAgentUid) + || parentBefore.gid !== BigInt(expectedAgentGid) || parentBefore.mode !== 0o700n) { + fail('TESTNET_AGENT_CREDENTIAL_AUTHORITY', 'Agent credential parent authority is invalid'); + } + descriptor = fs.openSync( + destination, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const beforeStat = fs.fstatSync(descriptor, { bigint: true }); + const before = fileIdentity(beforeStat); + if (!beforeStat.isFile() || before.uid !== BigInt(expectedAgentUid) + || before.gid !== BigInt(expectedAgentGid) || before.mode !== 0o600n + || before.nlink !== 1n || before.size < 1n + || before.size > BigInt(MAXIMUM_CREDENTIAL_BYTES)) { + fail('TESTNET_AGENT_CREDENTIAL_AUTHORITY', 'Agent credential file authority is invalid'); + } + bytes = readExactBytes(descriptor, before.size); + const after = fileIdentity(fs.fstatSync(descriptor, { bigint: true })); + const pathStat = fs.lstatSync(destination, { bigint: true }); + const parentAfter = fileIdentity(fs.fstatSync(parentDescriptor, { bigint: true })); + const parentPathStat = fs.lstatSync(parent, { bigint: true }); + if (!sameFileIdentity(before, after) || pathStat.isSymbolicLink() + || !sameFileIdentity(before, fileIdentity(pathStat)) + || parentPathStat.isSymbolicLink() || !parentPathStat.isDirectory() + || !sameFileIdentity(parentBefore, parentAfter) + || !sameFileIdentity(parentBefore, fileIdentity(parentPathStat))) { + fail('TESTNET_AGENT_CREDENTIAL_AUTHORITY', 'Agent credential authority changed during read'); + } + const parsed = parseCredential(bytes); + if (parsed.credentialDigest !== expectedDigest) { + fail('TESTNET_AGENT_CREDENTIAL_BINDING', 'Agent credential does not match the run intent'); + } + return parsed.credential; + } catch (cause) { + if (cause instanceof TestnetAgentRunnerError) throw cause; + fail('TESTNET_AGENT_CREDENTIAL', 'Agent credential could not be read safely', cause); + } finally { + bytes?.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + if (parentDescriptor !== undefined) fs.closeSync(parentDescriptor); + } +} + +export function parseTestnetAgentArguments(argv) { + if (!Array.isArray(argv) || argv.length !== 4 + || argv[0] !== '--run-intent' || argv[2] !== '--confirm-sha256') { + fail( + 'TESTNET_AGENT_ARGUMENTS', + 'usage: run-testnet-agent --run-intent ABSOLUTE_PATH --confirm-sha256 sha256:HEX', + ); + } + const runIntentPath = canonicalAbsolute( + argv[1], + 'TESTNET_AGENT_ARGUMENTS', + 'run intent argument', + ); + if (typeof argv[3] !== 'string' || !HASH.test(argv[3])) { + fail('TESTNET_AGENT_ARGUMENTS', 'confirmation must be one canonical SHA-256 digest'); + } + return Object.freeze({ runIntentPath, confirmationDigest: argv[3] }); +} + +function environmentPath(environment, name) { + const value = environment?.[name]; + return canonicalAbsolute(value, 'TESTNET_AGENT_ENVIRONMENT', name); +} + +function assertAgentEnvironment(environment) { + for (const name of FORBIDDEN_AGENT_ENVIRONMENT) { + if (typeof environment?.[name] === 'string' && environment[name] !== '') { + fail('TESTNET_AGENT_ENVIRONMENT', `Agent process must not receive ${name}`); + } + } +} + +function assertSeparatedAuthorityPaths(outboxPath, credentialPath) { + const credentialParent = path.dirname(credentialPath); + const relativeCredential = path.relative(outboxPath, credentialParent); + const relativeOutbox = path.relative(credentialParent, outboxPath); + if (outboxPath === credentialParent + || (!relativeCredential.startsWith('..') && !path.isAbsolute(relativeCredential)) + || (!relativeOutbox.startsWith('..') && !path.isAbsolute(relativeOutbox))) { + fail( + 'TESTNET_AGENT_ENVIRONMENT', + 'run-intent outbox and Agent credential authority must be separate', + ); + } +} + +function assertAgentIdentity({ intent, platform, getuid, getgid, getgroups }) { + if (platform !== 'linux' || typeof getuid !== 'function' || typeof getgid !== 'function' + || typeof getgroups !== 'function') { + fail('TESTNET_AGENT_IDENTITY', 'testnet Agent must run under one dedicated Linux identity'); + } + const uid = getuid(); + const gid = getgid(); + const groups = getgroups(); + const expectedUid = Number(intent.agentIdentity.uid); + const expectedGid = Number(intent.agentIdentity.gid); + if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0 + || uid !== expectedUid || gid !== expectedGid + || uid === Number(intent.kernelIdentity.uid) + || !Array.isArray(groups) || groups.some((group) => group !== gid)) { + fail('TESTNET_AGENT_IDENTITY', 'running process does not match the isolated Agent identity'); + } + return Object.freeze({ uid, gid }); +} + +async function boundedResponseBytes(response) { + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > MAXIMUM_RESPONSE_BYTES) { + bytes.fill(0); + fail('TESTNET_AGENT_RESPONSE', 'Kernel response exceeded the public response limit'); + } + return bytes; +} + +export async function requestTestnetAgentRoute({ + origin, + token, + route, + agentCallId, + fetchFn = globalThis.fetch, +}) { + canonicalLoopbackOrigin(origin); + validateRoute(route, 0); + if (typeof token !== 'string' || !CREDENTIAL_TOKEN.test(token) + || typeof agentCallId !== 'string' || !CREDENTIAL_TOKEN.test(agentCallId) + || typeof fetchFn !== 'function') { + fail('TESTNET_AGENT_REQUEST', 'Agent route request inputs are invalid'); + } + const routeSegment = encodeURIComponent(route.routeId); + const pathname = route.kind === 'openai-chat' + ? `/agent/v1/openai/${routeSegment}/chat/completions` + : `/agent/v1/invoke/${routeSegment}`; + const value = route.kind === 'openai-chat' + ? { + messages: [{ content: 'Reply exactly WALLET_KERNEL_TESTNET_OK.', role: 'user' }], + model: route.model, + stream: false, + } + : { input: 'wallet-kernel-testnet-acceptance' }; + + let response; + try { + response = await fetchFn(`${origin}${pathname}`, { + method: 'POST', + headers: { + authorization: `WalletKernelAgent ${token}`, + 'content-type': 'application/json', + 'x-agent-call-id': agentCallId, + }, + body: canonicalJson(value), + credentials: 'omit', + redirect: 'manual', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (cause) { + fail('TESTNET_AGENT_REQUEST', 'Kernel route request failed', cause); + } + + if (response.status !== 200 + || response.headers.get('content-type')?.toLowerCase() !== 'application/json') { + fail('TESTNET_AGENT_RESPONSE', 'Kernel route response was not canonical JSON success'); + } + let bytes; + try { + bytes = await boundedResponseBytes(response); + let value; + try { value = JSON.parse(bytes.toString('utf8')); } catch (cause) { + fail('TESTNET_AGENT_RESPONSE', 'Kernel route response body was not JSON', cause); + } + if (value === null || typeof value !== 'object' || Array.isArray(value) + || value.status !== 'completed') { + fail('TESTNET_AGENT_RESPONSE', 'Kernel route did not complete'); + } + return Object.freeze({ httpStatus: 200, outcome: 'completed' }); + } finally { + bytes?.fill(0); + } +} + +export async function runTestnetAgent({ + argv = process.argv.slice(2), + environment = process.env, + now = () => new Date().toISOString(), + platform = process.platform, + getuid = process.getuid, + getgid = process.getgid, + getgroups = process.getgroups, + readRunIntent = readTestnetRunIntent, + readCredential = readTestnetAgentCredential, + requestRoute = requestTestnetAgentRoute, +} = {}) { + assertAgentEnvironment(environment); + const { runIntentPath, confirmationDigest } = parseTestnetAgentArguments(argv); + const outboxPath = environmentPath( + environment, + 'WALLET_KERNEL_AGENT_RUN_OUTBOX', + ); + const credentialPath = environmentPath( + environment, + 'WALLET_KERNEL_AGENT_CREDENTIAL_FILE', + ); + assertSeparatedAuthorityPaths(outboxPath, credentialPath); + const { intent, intentDigest } = readRunIntent({ + filePath: runIntentPath, + outboxPath, + expectedDigest: confirmationDigest, + now: now(), + }); + const { uid, gid } = assertAgentIdentity({ intent, platform, getuid, getgid, getgroups }); + const credential = readCredential({ + filePath: credentialPath, + expectedDigest: intent.credentialDigest, + expectedAgentUid: uid, + expectedAgentGid: gid, + }); + + const routes = []; + for (const route of intent.sellerRoutes) { + const outcome = await requestRoute({ + origin: intent.kernelOrigin, + route, + token: credential.token, + agentCallId: testnetAgentCallId(intentDigest, route.routeId), + }); + routes.push(Object.freeze({ + routeId: route.routeId, + kind: route.kind, + httpStatus: outcome.httpStatus, + outcome: outcome.outcome, + })); + } + return frozenCopy({ + status: 'completed', + mode: 'base-sepolia-testnet', + runId: intent.runId, + intentDigest, + routeCount: routes.length, + routes, + }); +} + +function publicFailure(error) { + const code = error instanceof TestnetAgentRunnerError + ? error.code + : 'TESTNET_AGENT_UNEXPECTED'; + return canonicalJson({ code, mode: 'base-sepolia-testnet', status: 'not-run' }); +} + +export async function main() { + try { + process.stdout.write(`${canonicalJson(await runTestnetAgent())}\n`); + return 0; + } catch (error) { + process.stderr.write(`${publicFailure(error)}\n`); + return 2; + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) process.exitCode = await main(); diff --git a/spikes/pi-wielder/scripts/verify-evidence.mjs b/spikes/pi-wielder/scripts/verify-evidence.mjs new file mode 100644 index 0000000..88903c4 --- /dev/null +++ b/spikes/pi-wielder/scripts/verify-evidence.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { canonicalJson } from '../src/kernel/canonical.mjs'; +import { + EvidenceError, + verifyEvidenceBundle, +} from '../src/evidence-bundle.mjs'; + +function failArguments() { + const error = new Error( + 'usage: verify-evidence.mjs DIRECTORY --expect-manifest-sha256 64-lowercase-hex', + ); + error.code = 'EVIDENCE_CLI_ARGUMENTS'; + throw error; +} + +export function parseVerifyEvidenceArguments(argv) { + if (!Array.isArray(argv) || argv.length !== 3 || argv[1] !== '--expect-manifest-sha256' + || typeof argv[0] !== 'string' || argv[0].length === 0 + || typeof argv[2] !== 'string' || !/^[0-9a-f]{64}$/.test(argv[2])) { + failArguments(); + } + return Object.freeze({ + directory: path.resolve(argv[0]), + expectedManifestSha256: argv[2], + }); +} + +export function main(argv = process.argv.slice(2)) { + try { + const options = parseVerifyEvidenceArguments(argv); + const result = verifyEvidenceBundle(options.directory, options); + process.stdout.write(`${canonicalJson(result)}\n`); + return 0; + } catch (error) { + const code = error instanceof EvidenceError || typeof error?.code === 'string' + ? error.code + : 'EVIDENCE_INTERNAL'; + process.stderr.write(`${canonicalJson({ valid: false, code })}\n`); + return code === 'EVIDENCE_CLI_ARGUMENTS' ? 2 : 1; + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) process.exitCode = main(); diff --git a/spikes/pi-wielder/scripts/verify-no-tracked-secrets.mjs b/spikes/pi-wielder/scripts/verify-no-tracked-secrets.mjs new file mode 100644 index 0000000..716514d --- /dev/null +++ b/spikes/pi-wielder/scripts/verify-no-tracked-secrets.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { canonicalJson } from '../src/kernel/canonical.mjs'; + +const MAXIMUM_TRACKED_FILE_BYTES = 32 * 1024 * 1024; +const MAXIMUM_SECRET_FILE_BYTES = 256 * 1024; +const SECRET_NAME = /(?:SECRET|TOKEN|PASSWORD|PASSPHRASE|PRIVATE_KEY|API_KEY|CREDENTIAL)/i; +const NON_VALUE_NAME = /(?:_FILE|_PATH|_DIRECTORY|_DIR|_ROOT|_URL|_ENDPOINT)$/i; +const PRIVATE_KEY_ENCODING = /-----BEGIN (?:RSA |EC |OPENSSH |ENCRYPTED )?PRIVATE KEY-----[\s\S]{1,262144}-----END (?:RSA |EC |OPENSSH |ENCRYPTED )?PRIVATE KEY-----/; +const CONTEXTUAL_HEX_PRIVATE_KEY = /(?:private[_-]?key|wallet[_-]?secret)[^\r\n]{0,64}(?:0x)?[0-9a-fA-F]{64}/i; +const SECRET_FILE_ENVIRONMENT = Object.freeze([ + ['WALLET_KERNEL_RECEIPT_KEY_FILE', 'WALLET_KERNEL_RECEIPT_KEY_FILE'], + ['WALLET_KERNEL_OPERATOR_TOKEN_FILE', 'WALLET_KERNEL_OPERATOR_TOKEN_FILE'], +]); + +class SecretScanError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = 'SecretScanError'; + this.code = code; + } +} + +function fail(code, message, cause) { + throw new SecretScanError(code, message, cause ? { cause } : undefined); +} + +function parseArguments(argv) { + if (!Array.isArray(argv)) fail('SECRET_SCAN_ARGUMENTS', 'arguments must be one array'); + if (argv.length === 0) return Object.freeze({ agentCredential: null }); + if (argv.length !== 2 || argv[0] !== '--agent-credential' + || typeof argv[1] !== 'string' || argv[1].length === 0) { + fail('SECRET_SCAN_ARGUMENTS', 'usage: verify-no-tracked-secrets.mjs [--agent-credential FILE]'); + } + if (!path.isAbsolute(argv[1]) || path.resolve(argv[1]) !== argv[1]) { + fail('SECRET_SCAN_ARGUMENTS', 'agent credential path must be canonical and absolute'); + } + return Object.freeze({ agentCredential: argv[1] }); +} + +function gitTrackedPaths(cwd) { + const result = spawnSync('git', ['ls-files', '-z'], { + cwd, + encoding: null, + maxBuffer: 16 * 1024 * 1024, + env: { PATH: process.env.PATH ?? '' }, + }); + if (result.status !== 0 || !Buffer.isBuffer(result.stdout)) { + fail('SECRET_SCAN_GIT', 'git ls-files -z failed'); + } + const bytes = result.stdout; + if (bytes.length === 0) return []; + if (bytes.at(-1) !== 0) fail('SECRET_SCAN_GIT', 'git ls-files output was not NUL terminated'); + const paths = bytes.subarray(0, -1).toString('utf8').split('\0'); + if (paths.some((item) => item.length === 0 || item.includes('\0') || path.isAbsolute(item) + || item.split(/[\\/]/).includes('..'))) { + fail('SECRET_SCAN_GIT', 'git returned a noncanonical tracked path'); + } + return paths; +} + +function gitRepositoryRoot(cwd) { + const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { + cwd, + encoding: 'utf8', + maxBuffer: 4096, + env: { PATH: process.env.PATH ?? '' }, + }); + if (result.status !== 0 || typeof result.stdout !== 'string' + || !result.stdout.endsWith('\n') || result.stdout.slice(0, -1).includes('\n') + || result.stdout.includes('\0')) { + fail('SECRET_SCAN_GIT', 'git repository root discovery failed'); + } + const root = result.stdout.slice(0, -1); + if (!path.isAbsolute(root) || path.resolve(root) !== root) { + fail('SECRET_SCAN_GIT', 'git repository root is not canonical'); + } + let actual; + try { actual = fs.realpathSync(root); } catch (cause) { + fail('SECRET_SCAN_GIT', 'git repository root does not exist', cause); + } + if (actual !== root || !fs.lstatSync(root).isDirectory()) { + fail('SECRET_SCAN_GIT', 'git repository root is not one direct directory'); + } + return root; +} + +function trackedFilenameIsSecret(relativePath) { + const basename = path.posix.basename(relativePath.replaceAll('\\', '/')).toLowerCase(); + if (basename.startsWith('.env') && !/(?:example|sample|template)$/.test(basename)) return true; + if (/\.(?:pem|key|p12|pfx|sqlite|sqlite3|db)$/.test(basename)) return true; + return /^(?:operator[-_.]?token|receipt[-_.]?key|agent[-_.]?credential|local[-_.]?enrollment)(?:\.(?:json|txt|secret|token|key|pem))?$/.test(basename) + || /^(?:kernel|authority|wallet-kernel)(?:[-_.][a-z0-9-]+)*\.(?:sqlite|sqlite3|db)$/.test(basename); +} + +function readTrackedBytes(cwd, relativePath) { + const destination = path.resolve(cwd, relativePath); + const prefix = `${path.resolve(cwd)}${path.sep}`; + if (!destination.startsWith(prefix)) fail('SECRET_SCAN_PATH', 'tracked path escaped the repository'); + let stat; + try { stat = fs.lstatSync(destination); } catch (cause) { + fail('SECRET_SCAN_PATH', `tracked path could not be inspected: ${relativePath}`, cause); + } + if (stat.isSymbolicLink()) { + return Buffer.from(fs.readlinkSync(destination), 'utf8'); + } + if (!stat.isFile() || stat.size > MAXIMUM_TRACKED_FILE_BYTES) { + fail('SECRET_SCAN_PATH', `tracked path is not a bounded regular file: ${relativePath}`); + } + let descriptor; + try { + descriptor = fs.openSync(destination, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + const before = fs.fstatSync(descriptor); + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor); + if (!before.isFile() || before.nlink !== 1 || before.size !== bytes.length + || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) { + fail('SECRET_SCAN_PATH', `tracked path changed while read: ${relativePath}`); + } + return bytes; + } catch (cause) { + if (cause instanceof SecretScanError) throw cause; + fail('SECRET_SCAN_PATH', `tracked path could not be read safely: ${relativePath}`, cause); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function readOwnerOnlySecret(filePath, label) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath) + || path.resolve(filePath) !== filePath || filePath.includes('\0')) { + fail('SECRET_SCAN_AUTHORITY', `${label} must be one canonical absolute path`); + } + let descriptor; + try { + descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + const before = fs.fstatSync(descriptor); + const currentUid = typeof process.getuid === 'function' ? process.getuid() : before.uid; + if (!before.isFile() || before.nlink !== 1 || before.uid !== currentUid + || (before.mode & 0o777) !== 0o600 || before.size < 8 + || before.size > MAXIMUM_SECRET_FILE_BYTES) { + fail('SECRET_SCAN_AUTHORITY', `${label} is not an owner-only bounded regular file`); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) { + fail('SECRET_SCAN_AUTHORITY', `${label} changed while read`); + } + return bytes; + } catch (cause) { + if (cause instanceof SecretScanError) throw cause; + fail('SECRET_SCAN_AUTHORITY', `${label} could not be read safely`, cause); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function candidateVariants(bytes) { + const variants = [Buffer.from(bytes)]; + if (bytes.at(-1) === 0x0a) { + const withoutLf = bytes.subarray(0, -1); + const trimmed = withoutLf.at(-1) === 0x0d ? withoutLf.subarray(0, -1) : withoutLf; + if (trimmed.length >= 8) variants.push(Buffer.from(trimmed)); + } + const unique = new Map(variants.filter((item) => item.length >= 8) + .map((item) => [item.toString('base64'), item])); + return [...unique.values()]; +} + +function environmentCandidates(environment) { + const candidates = []; + for (const name of Object.keys(environment).sort()) { + const value = environment[name]; + if (!SECRET_NAME.test(name) || NON_VALUE_NAME.test(name) + || typeof value !== 'string' || Buffer.byteLength(value, 'utf8') < 8) continue; + candidates.push({ name, values: [Buffer.from(value, 'utf8')] }); + } + return candidates; +} + +function fileCandidates(environment, agentCredential) { + const candidates = []; + for (const [name, environmentName] of SECRET_FILE_ENVIRONMENT) { + const configured = environment[environmentName]; + if (configured === undefined || configured === '') continue; + candidates.push({ + name, + values: candidateVariants(readOwnerOnlySecret(configured, name)), + }); + } + if (agentCredential !== null) { + candidates.push({ + name: 'AGENT_CREDENTIAL_FILE', + values: candidateVariants(readOwnerOnlySecret(agentCredential, 'AGENT_CREDENTIAL_FILE')), + }); + } + return candidates; +} + +function scanPrivateKeyEncoding(bytes) { + const text = bytes.toString('utf8'); + return PRIVATE_KEY_ENCODING.test(text) || CONTEXTUAL_HEX_PRIVATE_KEY.test(text); +} + +export function scanTrackedSecrets({ cwd, environment, agentCredential = null }) { + if (typeof cwd !== 'string' || !path.isAbsolute(cwd) + || !environment || typeof environment !== 'object' || Array.isArray(environment)) { + fail('SECRET_SCAN_INPUT', 'secret scan requires an absolute repository and explicit environment'); + } + const trackedPaths = gitTrackedPaths(cwd); + const tracked = trackedPaths.map((relativePath) => ({ + path: relativePath, + bytes: readTrackedBytes(cwd, relativePath), + })); + const findings = []; + for (const file of tracked) { + if (trackedFilenameIsSecret(file.path)) { + findings.push({ name: 'TRACKED_SECRET_FILENAME', path: file.path }); + } + if (scanPrivateKeyEncoding(file.bytes)) { + findings.push({ name: 'PRIVATE_KEY_ENCODING', path: file.path }); + } + } + const candidates = [ + ...environmentCandidates(environment), + ...fileCandidates(environment, agentCredential), + ]; + for (const candidate of candidates) { + for (const file of tracked) { + if (candidate.values.some((value) => file.bytes.indexOf(value) !== -1)) { + findings.push({ name: candidate.name, path: file.path }); + } + } + } + const unique = [...new Map(findings.map((finding) => [ + `${finding.name}\0${finding.path}`, + finding, + ])).values()].sort((left, right) => left.name.localeCompare(right.name) + || left.path.localeCompare(right.path)); + return Object.freeze({ scannedFiles: tracked.length, findings: Object.freeze(unique) }); +} + +export function main(argv = process.argv.slice(2)) { + let options; + try { + options = parseArguments(argv); + const repositoryRoot = gitRepositoryRoot(fs.realpathSync(process.cwd())); + const result = scanTrackedSecrets({ + cwd: repositoryRoot, + environment: process.env, + agentCredential: options.agentCredential, + }); + if (result.findings.length === 0) { + process.stdout.write(`${canonicalJson({ scannedFiles: result.scannedFiles, valid: true })}\n`); + return 0; + } + for (const finding of result.findings) { + process.stderr.write(`${canonicalJson(finding)}\n`); + } + return 1; + } catch (error) { + const code = error instanceof SecretScanError ? error.code : 'SECRET_SCAN_INTERNAL'; + process.stderr.write(`${canonicalJson({ valid: false, code })}\n`); + return 2; + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) process.exitCode = main(); diff --git a/spikes/pi-wielder/spend-control-e2e.mjs b/spikes/pi-wielder/spend-control-e2e.mjs new file mode 100644 index 0000000..299c0d5 --- /dev/null +++ b/spikes/pi-wielder/spend-control-e2e.mjs @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { canonicalJson } from './src/kernel/canonical.mjs'; +import { runSpendControlProcessAcceptance } from './scripts/lib/spend-control-process-runner.mjs'; + +const authorityDirectory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-spend-control-')), +); +fs.chmodSync(authorityDirectory, 0o700); + +let cleanup = async () => {}; +try { + const result = await runSpendControlProcessAcceptance({ + authorityDirectory, + piExecutable: path.resolve(import.meta.dirname, 'node_modules', '.bin', 'pi'), + }); + cleanup = result.cleanup; + process.stdout.write(`${canonicalJson(result.summary)}\n`); + if (result.summary.tests !== 18 || result.summary.passed !== 18) process.exitCode = 1; +} catch (error) { + const code = typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{0,127}$/u.test(error.code) + ? error.code + : 'SPEND_CONTROL_PROCESS_FAILED'; + process.stderr.write(`${code}\n`); + process.exitCode = 1; +} finally { + try { await cleanup(); } catch {} + fs.rmSync(authorityDirectory, { recursive: true, force: true }); +} diff --git a/spikes/pi-wielder/src/adapters/base-sepolia-observer.mjs b/spikes/pi-wielder/src/adapters/base-sepolia-observer.mjs new file mode 100644 index 0000000..fd0085b --- /dev/null +++ b/spikes/pi-wielder/src/adapters/base-sepolia-observer.mjs @@ -0,0 +1,1047 @@ +import { types as utilTypes } from 'node:util'; + +import { + decodeEventLog, + encodeEventTopics, + parseAbi, +} from 'viem'; + +import { + canonicalAtomic, + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from '../kernel/canonical.mjs'; +import { validatePolicyDocument } from '../kernel/policy-engine.mjs'; + +const CHAIN_ID = 84_532; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const MAX_UINT256 = (1n << 256n) - 1n; +const SHA256 = /^sha256:[0-9a-f]{64}$/; +const ADDRESS = /^0x[0-9a-f]{40}$/; +const PROVIDER_ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const EVM_WORD = /^0x[0-9a-f]{64}$/; +const PROVIDER_WORD = /^0x[0-9a-fA-F]{64}$/; +const HEX_BYTES = /^0x(?:[0-9a-fA-F]{2})*$/; +const ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i; + +const PAYMENT_BINDING_FIELDS = Object.freeze([ + 'schemaVersion', + 'domain', + 'intentId', + 'intentHash', + 'challengeHash', + 'quoteId', + 'network', + 'asset', + 'payer', + 'payee', + 'amountAtomic', + 'nonce', + 'validAfter', + 'validBefore', + 'paymentPayloadHash', + 'paymentHeaderHash', + 'localAttemptHash', + 'caseHash', + 'candidate', +]); +const REFUND_BINDING_FIELDS = Object.freeze([ + 'schemaVersion', + 'domain', + 'intentId', + 'intentHash', + 'policyVersion', + 'seller', + 'resourcePath', + 'network', + 'sellerOrigin', + 'originalTransactionId', + 'refundTransactionId', + 'asset', + 'originalPayer', + 'originalPayee', + 'refundSource', + 'refundSigner', + 'amountAtomic', + 'localRefundBindingHash', + 'refundId', + 'caseHash', +]); +const CLIENT_METHODS = Object.freeze([ + 'getChainId', + 'getBlockNumber', + 'getBlock', + 'getTransactionReceipt', + 'readContract', +]); +const PAYMENT_UNKNOWN_REASONS = new Set([ + 'RPC_RECEIPT_MISSING', + 'RPC_CONFIRMATIONS_INSUFFICIENT', + 'RPC_PROVIDER_UNAVAILABLE', + 'RPC_REORG_DETECTED', + 'RPC_EVIDENCE_INVALID', + 'AUTHORIZATION_ALREADY_USED', + 'AUTHORIZATION_NOT_EXPIRED', +]); +const REFUND_UNKNOWN_REASONS = new Set([ + 'RPC_RECEIPT_MISSING', + 'RPC_CONFIRMATIONS_INSUFFICIENT', + 'RPC_PROVIDER_UNAVAILABLE', + 'RPC_REORG_DETECTED', + 'RPC_EVIDENCE_INVALID', +]); + +const USDC_ABI = frozenCopy(parseAbi([ + 'function name() view returns (string)', + 'function version() view returns (string)', + 'function decimals() view returns (uint8)', + 'function balanceOf(address owner) view returns (uint256)', + 'function authorizationState(address authorizer, bytes32 nonce) view returns (bool)', + 'event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)', + 'event Transfer(address indexed from, address indexed to, uint256 value)', +])); +const [AUTHORIZATION_USED_TOPIC] = encodeEventTopics({ + abi: USDC_ABI, + eventName: 'AuthorizationUsed', +}); +const [TRANSFER_TOPIC] = encodeEventTopics({ + abi: USDC_ABI, + eventName: 'Transfer', +}); + +class EvidenceFault extends Error { + constructor(reasonCode) { + super(reasonCode); + this.name = 'EvidenceFault'; + this.reasonCode = reasonCode; + } +} + +function failBinding() { + throw new KernelError( + 'OBSERVER_BINDING', + 'observer input must be one valid persisted Kernel binding', + ); +} + +function evidenceFault(reasonCode) { + throw new EvidenceFault(reasonCode); +} + +function exactFunctionConfiguration(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) + || utilTypes.isProxy(input) || Object.getPrototypeOf(input) !== Object.prototype) { + throw new KernelError('OBSERVER_CONFIG', 'observer configuration is invalid'); + } + const allowed = new Set(['publicClient', 'now', 'minimumConfirmations']); + const keys = Reflect.ownKeys(input); + if (!Object.hasOwn(input, 'publicClient') + || !Object.hasOwn(input, 'now') + || keys.some((key) => typeof key !== 'string' || !allowed.has(key))) { + throw new KernelError('OBSERVER_CONFIG', 'observer configuration is invalid'); + } + const values = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new KernelError('OBSERVER_CONFIG', 'observer configuration is invalid'); + } + values[key] = descriptor.value; + } + return values; +} + +function captureClient(value) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) { + throw new KernelError('OBSERVER_CONFIG', 'read-only public client is invalid'); + } + const captured = {}; + for (const name of CLIENT_METHODS) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor || !Object.hasOwn(descriptor, 'value') + || typeof descriptor.value !== 'function' + || utilTypes.isProxy(descriptor.value)) { + throw new KernelError('OBSERVER_CONFIG', 'read-only public client is invalid'); + } + const method = descriptor.value; + captured[name] = (...args) => Reflect.apply(method, value, args); + } + return Object.freeze(captured); +} + +function captureClock(value) { + if (typeof value !== 'function' || utilTypes.isProxy(value)) { + throw new KernelError('OBSERVER_CONFIG', 'observer clock is invalid'); + } + return () => Reflect.apply(value, undefined, []); +} + +function canonicalClock(clock) { + try { + return canonicalTimestamp(clock(), 'observer clock'); + } catch { + return evidenceFault('RPC_PROVIDER_UNAVAILABLE'); + } +} + +function hash(value) { + if (typeof value !== 'string' || !SHA256.test(value)) failBinding(); + return value; +} + +function address(value) { + if (typeof value !== 'string' || !ADDRESS.test(value)) failBinding(); + return value; +} + +function transactionId(value) { + if (typeof value !== 'string' || !EVM_WORD.test(value)) failBinding(); + return value; +} + +function atomic(value, { positive = false } = {}) { + let parsed; + try { + if (typeof value !== 'string' || value.length > 78) failBinding(); + parsed = canonicalAtomic(value, 'observer atomic value'); + } catch { + return failBinding(); + } + if (parsed.value > MAX_UINT256 || (positive && parsed.value === 0n)) failBinding(); + return parsed; +} + +function token(value) { + try { + return canonicalToken(value, 'observer token'); + } catch { + return failBinding(); + } +} + +function timestamp(value) { + try { + return canonicalTimestamp(value, 'observer timestamp'); + } catch { + return failBinding(); + } +} + +function validatePaymentBinding(value) { + try { + const binding = exactRecord( + value, + PAYMENT_BINDING_FIELDS, + [], + 'OBSERVER_BINDING', + 'payment observation binding', + ); + if (binding.schemaVersion !== 1 + || binding.domain !== 'wallet-kernel.payment-observation.v1' + || binding.network !== NETWORK + || binding.asset !== ASSET) failBinding(); + token(binding.intentId); + hash(binding.intentHash); + hash(binding.challengeHash); + hash(binding.quoteId); + address(binding.payer); + address(binding.payee); + const amount = atomic(binding.amountAtomic, { positive: true }); + transactionId(binding.nonce); + const validAfter = atomic(binding.validAfter); + const validBefore = atomic(binding.validBefore); + if (validBefore.value <= validAfter.value) failBinding(); + hash(binding.paymentPayloadHash); + hash(binding.paymentHeaderHash); + hash(binding.localAttemptHash); + hash(binding.caseHash); + + if (binding.candidate !== null) { + const candidate = exactRecord( + binding.candidate, + ['id', 'transactionId', 'state', 'createdAt'], + [], + 'OBSERVER_BINDING', + 'payment candidate', + ); + token(candidate.id); + transactionId(candidate.transactionId); + timestamp(candidate.createdAt); + if (candidate.state !== 'pending') failBinding(); + binding.candidate = candidate; + } + + const expectedAttemptHash = sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-attempt-binding.v1', + intentHash: binding.intentHash, + challengeHash: binding.challengeHash, + quoteId: binding.quoteId, + paymentPayloadHash: binding.paymentPayloadHash, + paymentHeaderHash: binding.paymentHeaderHash, + network: binding.network, + payer: binding.payer, + payee: binding.payee, + asset: binding.asset, + amountAtomic: amount.text, + nonce: binding.nonce, + validAfter: validAfter.text, + validBefore: validBefore.text, + })); + if (binding.localAttemptHash !== expectedAttemptHash) failBinding(); + return frozenCopy(binding); + } catch { + return failBinding(); + } +} + +function canonicalResourcePath(value, origin) { + if (typeof value !== 'string' || value.length === 0 || value.length > 2_048 + || !value.startsWith('/') || value.startsWith('//') + || value.includes('?') || value.includes('#') || value.includes('\\') + || ENCODED_PATH_SEPARATOR.test(value)) failBinding(); + let parsed; + try { + parsed = new URL(value, `${origin}/`); + } catch { + return failBinding(); + } + if (parsed.origin !== origin || parsed.pathname !== value + || parsed.search !== '' || parsed.hash !== '') failBinding(); + return value; +} + +function validateRefundBinding(value) { + try { + const binding = exactRecord( + value, + REFUND_BINDING_FIELDS, + [], + 'OBSERVER_BINDING', + 'refund observation binding', + ); + if (binding.schemaVersion !== 1 + || binding.domain !== 'wallet-kernel.refund-observation.v1' + || binding.network !== NETWORK + || binding.asset !== ASSET) failBinding(); + token(binding.intentId); + hash(binding.intentHash); + const version = exactRecord( + binding.policyVersion, + ['id', 'hash', 'policy'], + [], + 'OBSERVER_BINDING', + 'refund policy version', + ); + token(version.id); + hash(version.hash); + const policy = validatePolicyDocument(version.policy); + if (canonicalJson(policy) !== canonicalJson(version.policy) + || sha256(canonicalJson(policy)) !== version.hash) failBinding(); + + if (typeof binding.sellerOrigin !== 'string') failBinding(); + const selectedSeller = policy.sellers.find((entry) => entry.origin === binding.sellerOrigin); + canonicalResourcePath(binding.resourcePath, binding.sellerOrigin); + if (!selectedSeller + || !selectedSeller.pathPrefixes.some((prefix) => binding.resourcePath.startsWith(prefix)) + || canonicalJson(selectedSeller) !== canonicalJson(binding.seller)) failBinding(); + + transactionId(binding.originalTransactionId); + transactionId(binding.refundTransactionId); + if (binding.originalTransactionId === binding.refundTransactionId) failBinding(); + address(binding.originalPayer); + address(binding.originalPayee); + address(binding.refundSource); + address(binding.refundSigner); + const amount = atomic(binding.amountAtomic, { positive: true }); + if (binding.network !== policy.network + || binding.asset !== policy.asset + || binding.originalPayer !== policy.wallet + || binding.sellerOrigin !== selectedSeller.origin + || binding.originalPayee !== selectedSeller.payTo + || binding.refundSource !== selectedSeller.refundSource + || binding.refundSigner !== selectedSeller.refundSigner + || amount.value > BigInt(selectedSeller.perRequestMaxAtomic)) failBinding(); + hash(binding.localRefundBindingHash); + token(binding.refundId); + hash(binding.caseHash); + + const expectedRefundHash = sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-binding.v1', + intentHash: binding.intentHash, + originalTransactionId: binding.originalTransactionId, + refundTransactionId: binding.refundTransactionId, + network: binding.network, + sellerOrigin: binding.sellerOrigin, + asset: binding.asset, + originalPayer: binding.originalPayer, + originalPayee: binding.originalPayee, + refundSource: binding.refundSource, + refundSigner: binding.refundSigner, + amountAtomic: amount.text, + })); + if (binding.localRefundBindingHash !== expectedRefundHash) failBinding(); + binding.policyVersion = version; + return frozenCopy(binding); + } catch { + return failBinding(); + } +} + +function ordinaryDataRecord(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (typeof key !== 'string' || !descriptor?.enumerable + || !Object.hasOwn(descriptor, 'value')) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + } + return value; +} + +function own(value, name, optional = false) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) { + if (optional) return undefined; + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + return descriptor.value; +} + +function denseArray(value, maximum = 10_000) { + if (!Array.isArray(value) || utilTypes.isProxy(value) + || Object.getPrototypeOf(value) !== Array.prototype) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + const length = Object.getOwnPropertyDescriptor(value, 'length'); + if (!length || length.enumerable || !Object.hasOwn(length, 'value') + || !Number.isSafeInteger(length.value) || length.value > maximum + || Reflect.ownKeys(value).length !== length.value + 1) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + const result = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + result.push(descriptor.value); + } + return result; +} + +function uint256(value) { + if (typeof value !== 'bigint' || value < 0n || value > MAX_UINT256) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + return value; +} + +function blockNumber(value) { + return uint256(value); +} + +function providerWord(value) { + if (typeof value !== 'string' || !PROVIDER_WORD.test(value)) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + return value.toLowerCase(); +} + +function providerAddress(value) { + if (typeof value !== 'string' || !PROVIDER_ADDRESS.test(value)) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + return value.toLowerCase(); +} + +function parseBlock(value) { + const record = ordinaryDataRecord(value); + return Object.freeze({ + number: blockNumber(own(record, 'number')), + hash: providerWord(own(record, 'hash')), + timestamp: uint256(own(record, 'timestamp')), + }); +} + +function sameBlock(left, right) { + return left.number === right.number + && left.hash === right.hash + && left.timestamp === right.timestamp; +} + +function parseHead(value) { + return blockNumber(value); +} + +function parseReceipt(value, expectedTransactionId) { + const record = ordinaryDataRecord(value); + const parsed = Object.freeze({ + transactionId: providerWord(own(record, 'transactionHash')), + blockHash: providerWord(own(record, 'blockHash')), + blockNumber: blockNumber(own(record, 'blockNumber')), + status: own(record, 'status'), + logs: denseArray(own(record, 'logs'), 10_000), + }); + if (parsed.transactionId !== expectedTransactionId + || !new Set(['success', 'reverted']).has(parsed.status)) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + return parsed; +} + +function parseLog(value, receipt, seenIndexes) { + const record = ordinaryDataRecord(value); + const topics = denseArray(own(record, 'topics'), 4); + const parsed = { + address: providerAddress(own(record, 'address')), + blockHash: providerWord(own(record, 'blockHash')), + blockNumber: blockNumber(own(record, 'blockNumber')), + data: own(record, 'data'), + logIndex: own(record, 'logIndex'), + removed: own(record, 'removed', true), + topics, + transactionId: providerWord(own(record, 'transactionHash')), + }; + if (!Number.isSafeInteger(parsed.logIndex) || parsed.logIndex < 0 + || (parsed.removed !== undefined && parsed.removed !== false) + || typeof parsed.data !== 'string' || !HEX_BYTES.test(parsed.data) + || topics.some((topic) => typeof topic !== 'string' || !PROVIDER_WORD.test(topic))) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + if (parsed.blockHash !== receipt.blockHash + || parsed.blockNumber !== receipt.blockNumber) { + return evidenceFault('RPC_REORG_DETECTED'); + } + if (parsed.transactionId !== receipt.transactionId || seenIndexes.has(parsed.logIndex)) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + seenIndexes.add(parsed.logIndex); + return parsed; +} + +function decodeRelevantLog(log, eventName) { + try { + if ((eventName === 'AuthorizationUsed' && log.data !== '0x') + || (eventName === 'Transfer' && !/^0x[0-9a-fA-F]{64}$/.test(log.data))) { + evidenceFault('RPC_EVIDENCE_INVALID'); + } + return decodeEventLog({ + abi: USDC_ABI, + eventName, + data: log.data, + topics: log.topics, + strict: true, + }); + } catch { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } +} + +function matchingPaymentLogs(receipt, binding) { + const seenIndexes = new Set(); + const transfers = []; + const authorizations = []; + for (const value of receipt.logs) { + const log = parseLog(value, receipt, seenIndexes); + if (log.address !== ASSET || log.topics.length === 0) continue; + const topic = log.topics[0].toLowerCase(); + if (topic === TRANSFER_TOPIC) { + const decoded = decodeRelevantLog(log, 'Transfer'); + if (providerAddress(decoded.args.from) === binding.payer + && providerAddress(decoded.args.to) === binding.payee + && uint256(decoded.args.value).toString() === binding.amountAtomic) { + transfers.push(log); + } + } else if (topic === AUTHORIZATION_USED_TOPIC) { + const decoded = decodeRelevantLog(log, 'AuthorizationUsed'); + if (providerAddress(decoded.args.authorizer) === binding.payer + && providerWord(decoded.args.nonce) === binding.nonce) { + authorizations.push(log); + } + } + } + if (transfers.length > 1 || authorizations.length > 1) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + return Object.freeze({ + transfer: transfers[0] ?? null, + authorization: authorizations[0] ?? null, + }); +} + +function matchingRefundLog(receipt, binding) { + const seenIndexes = new Set(); + const transfers = []; + for (const value of receipt.logs) { + const log = parseLog(value, receipt, seenIndexes); + if (log.address !== ASSET || log.topics.length === 0 + || log.topics[0].toLowerCase() !== TRANSFER_TOPIC) continue; + const decoded = decodeRelevantLog(log, 'Transfer'); + if (providerAddress(decoded.args.from) === binding.refundSource + && providerAddress(decoded.args.to) === binding.originalPayer + && uint256(decoded.args.value).toString() === binding.amountAtomic) { + transfers.push(log); + } + } + if (transfers.length > 1) return evidenceFault('RPC_EVIDENCE_INVALID'); + return transfers[0] ?? null; +} + +function confirmations(head, includedAt) { + if (head < includedAt) return evidenceFault('RPC_EVIDENCE_INVALID'); + const value = head - includedAt + 1n; + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } + return Number(value); +} + +function blockNotFuture(value, observedAt) { + if (value.timestamp * 1_000n > BigInt(Date.parse(observedAt))) { + return evidenceFault('RPC_EVIDENCE_INVALID'); + } +} + +function isMissingReceiptError(error) { + try { + return error?.name === 'TransactionReceiptNotFoundError' + || error?.name === 'TransactionNotFoundError'; + } catch { + return false; + } +} + +function unknown(reasonCode, allowed) { + if (!allowed.has(reasonCode)) { + throw new KernelError('OBSERVER_INTERNAL', 'observer unknown reason is not allowlisted'); + } + return frozenCopy({ kind: 'unknown', reasonCode }); +} + +function rejection(kind, binding, confirmed, reasonCode, observedAt) { + return frozenCopy({ + kind: `${kind}_candidate_rejected`, + rejectionProof: { + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: kind === 'payment' + ? binding.candidate.transactionId + : binding.refundTransactionId, + blockHash: confirmed.receipt.blockHash, + blockNumber: confirmed.receipt.blockNumber.toString(), + transactionStatus: confirmed.receipt.status, + confirmations: confirmed.confirmations, + reasonCode, + observedAt, + }, + }); +} + +export function createBaseSepoliaObserver(input) { + if (arguments.length !== 1) { + throw new KernelError('OBSERVER_CONFIG', 'observer configuration is invalid'); + } + const configuration = exactFunctionConfiguration(input); + const client = captureClient(configuration.publicClient); + const clock = captureClock(configuration.now); + const minimumConfirmations = Object.hasOwn(configuration, 'minimumConfirmations') + ? configuration.minimumConfirmations + : 2; + if (!Number.isSafeInteger(minimumConfirmations) + || minimumConfirmations < 1 || minimumConfirmations > 1_000) { + throw new KernelError('OBSERVER_CONFIG', 'minimum confirmation depth is invalid'); + } + + const loadConfirmedReceipt = async (candidateTransactionId, observedAt) => { + let rawReceipt; + try { + rawReceipt = await client.getTransactionReceipt({ hash: candidateTransactionId }); + } catch (error) { + if (isMissingReceiptError(error)) return Object.freeze({ state: 'missing' }); + return evidenceFault('RPC_PROVIDER_UNAVAILABLE'); + } + if (rawReceipt === null || rawReceipt === undefined) { + return Object.freeze({ state: 'missing' }); + } + const receipt = parseReceipt(rawReceipt, candidateTransactionId); + let head; + try { + head = parseHead(await client.getBlockNumber()); + } catch (error) { + if (error instanceof EvidenceFault) throw error; + return evidenceFault('RPC_PROVIDER_UNAVAILABLE'); + } + const depth = confirmations(head, receipt.blockNumber); + if (depth < minimumConfirmations) { + return Object.freeze({ state: 'insufficient', head }); + } + let includedBlock; + try { + includedBlock = parseBlock(await client.getBlock({ blockNumber: receipt.blockNumber })); + } catch (error) { + if (error instanceof EvidenceFault) throw error; + return evidenceFault('RPC_PROVIDER_UNAVAILABLE'); + } + if (includedBlock.number !== receipt.blockNumber + || includedBlock.hash !== receipt.blockHash) { + return evidenceFault('RPC_REORG_DETECTED'); + } + blockNotFuture(includedBlock, observedAt); + return Object.freeze({ + state: 'confirmed', + receipt, + confirmations: depth, + }); + }; + + const observeUnusedAuthorization = async (binding, observedAt, fallbackReason, knownHead) => { + let head = knownHead; + if (head === undefined) { + try { + head = parseHead(await client.getBlockNumber()); + } catch (error) { + return unknown( + error instanceof EvidenceFault ? error.reasonCode : 'RPC_PROVIDER_UNAVAILABLE', + PAYMENT_UNKNOWN_REASONS, + ); + } + } + const offset = BigInt(minimumConfirmations - 1); + if (head < offset) { + return unknown( + fallbackReason ?? 'RPC_CONFIRMATIONS_INSUFFICIENT', + PAYMENT_UNKNOWN_REASONS, + ); + } + const safeNumber = head - offset; + let firstRaw; + try { + firstRaw = await client.getBlock({ blockNumber: safeNumber }); + } catch { + return unknown('RPC_PROVIDER_UNAVAILABLE', PAYMENT_UNKNOWN_REASONS); + } + if (firstRaw === null || firstRaw === undefined) { + return unknown(fallbackReason ?? 'RPC_EVIDENCE_INVALID', PAYMENT_UNKNOWN_REASONS); + } + let first; + try { + first = parseBlock(firstRaw); + if (first.number !== safeNumber) evidenceFault('RPC_EVIDENCE_INVALID'); + blockNotFuture(first, observedAt); + } catch (error) { + return unknown( + error instanceof EvidenceFault ? error.reasonCode : 'RPC_EVIDENCE_INVALID', + PAYMENT_UNKNOWN_REASONS, + ); + } + if (first.timestamp < BigInt(binding.validBefore)) { + return unknown('AUTHORIZATION_NOT_EXPIRED', PAYMENT_UNKNOWN_REASONS); + } + + let state; + try { + state = await client.readContract({ + address: ASSET, + abi: USDC_ABI, + functionName: 'authorizationState', + args: [binding.payer, binding.nonce], + blockNumber: safeNumber, + }); + } catch { + return unknown('RPC_PROVIDER_UNAVAILABLE', PAYMENT_UNKNOWN_REASONS); + } + if (typeof state !== 'boolean') { + return unknown('RPC_EVIDENCE_INVALID', PAYMENT_UNKNOWN_REASONS); + } + + let second; + try { + second = parseBlock(await client.getBlock({ blockNumber: safeNumber })); + } catch (error) { + return unknown( + error instanceof EvidenceFault ? error.reasonCode : 'RPC_PROVIDER_UNAVAILABLE', + PAYMENT_UNKNOWN_REASONS, + ); + } + if (!sameBlock(first, second)) { + return unknown('RPC_REORG_DETECTED', PAYMENT_UNKNOWN_REASONS); + } + if (state === true) { + return unknown('AUTHORIZATION_ALREADY_USED', PAYMENT_UNKNOWN_REASONS); + } + return frozenCopy({ + kind: 'authorization_unused_after_expiry', + network: NETWORK, + asset: ASSET, + payer: binding.payer, + nonce: binding.nonce, + validBefore: binding.validBefore, + authorizationState: false, + observedBlockNumber: first.number.toString(), + observedBlockHash: first.hash, + observedBlockTimestamp: first.timestamp.toString(), + confirmations: minimumConfirmations, + }); + }; + + const preflight = async function preflight() { + if (arguments.length !== 0) { + throw new KernelError('OBSERVER_INPUT', 'preflight accepts no caller input'); + } + try { + if (await client.getChainId() !== CHAIN_ID) evidenceFault('RPC_EVIDENCE_INVALID'); + const head = parseHead(await client.getBlockNumber()); + const first = parseBlock(await client.getBlock({ blockNumber: head })); + if (first.number !== head) evidenceFault('RPC_EVIDENCE_INVALID'); + const name = await client.readContract({ + address: ASSET, + abi: USDC_ABI, + functionName: 'name', + blockNumber: head, + }); + const version = await client.readContract({ + address: ASSET, + abi: USDC_ABI, + functionName: 'version', + blockNumber: head, + }); + const decimals = await client.readContract({ + address: ASSET, + abi: USDC_ABI, + functionName: 'decimals', + blockNumber: head, + }); + const second = parseBlock(await client.getBlock({ blockNumber: head })); + if (!sameBlock(first, second) + || name !== 'USDC' || version !== '2' || decimals !== 6) { + evidenceFault('RPC_EVIDENCE_INVALID'); + } + return frozenCopy({ + network: NETWORK, + asset: ASSET, + eip712Name: name, + eip712Version: version, + decimals, + blockNumber: first.number.toString(), + blockHash: first.hash, + }); + } catch { + throw new KernelError('OBSERVER_PREFLIGHT', 'Base Sepolia observer preflight failed'); + } + }; + + const fundingStatus = async function fundingStatus(value) { + if (arguments.length !== 1) { + throw new KernelError('OBSERVER_INPUT', 'funding status input is invalid'); + } + let request; + try { + request = exactRecord( + value, + ['walletAddress', 'requiredAtomic'], + [], + 'OBSERVER_INPUT', + 'funding status request', + ); + if (typeof request.walletAddress !== 'string' || !ADDRESS.test(request.walletAddress)) { + throw new KernelError('OBSERVER_INPUT', 'funding wallet address is invalid'); + } + if (typeof request.requiredAtomic !== 'string' || request.requiredAtomic.length > 78) { + throw new KernelError('OBSERVER_INPUT', 'required funding is invalid'); + } + const required = canonicalAtomic(request.requiredAtomic, 'required funding'); + if (required.value === 0n || required.value > MAX_UINT256) { + throw new KernelError('OBSERVER_INPUT', 'required funding is invalid'); + } + request.requiredAtomic = required.text; + } catch { + throw new KernelError('OBSERVER_INPUT', 'funding status input is invalid'); + } + + try { + const observedAt = canonicalClock(clock); + const head = parseHead(await client.getBlockNumber()); + const first = parseBlock(await client.getBlock({ blockNumber: head })); + if (first.number !== head) evidenceFault('RPC_EVIDENCE_INVALID'); + const rawBalance = await client.readContract({ + address: ASSET, + abi: USDC_ABI, + functionName: 'balanceOf', + args: [request.walletAddress], + blockNumber: head, + }); + const balance = uint256(rawBalance); + const second = parseBlock(await client.getBlock({ blockNumber: head })); + if (!sameBlock(first, second)) evidenceFault('RPC_REORG_DETECTED'); + blockNotFuture(first, observedAt); + return frozenCopy({ + walletAddress: request.walletAddress, + asset: ASSET, + balanceAtomic: balance.toString(), + requiredAtomic: request.requiredAtomic, + status: balance >= BigInt(request.requiredAtomic) ? 'sufficient' : 'insufficient', + blockNumber: head.toString(), + blockHash: first.hash, + observedAt, + }); + } catch { + throw new KernelError('OBSERVER_UNAVAILABLE', 'funding status is unavailable'); + } + }; + + const observePayment = async function observePayment(value) { + if (arguments.length !== 1) failBinding(); + const binding = validatePaymentBinding(value); + let observedAt; + try { + observedAt = canonicalClock(clock); + } catch (error) { + return unknown(error.reasonCode, PAYMENT_UNKNOWN_REASONS); + } + + if (binding.candidate === null) { + return observeUnusedAuthorization(binding, observedAt, undefined, undefined); + } + + let candidate; + try { + candidate = await loadConfirmedReceipt(binding.candidate.transactionId, observedAt); + } catch (error) { + return unknown( + error instanceof EvidenceFault ? error.reasonCode : 'RPC_PROVIDER_UNAVAILABLE', + PAYMENT_UNKNOWN_REASONS, + ); + } + if (candidate.state === 'missing') { + return observeUnusedAuthorization( + binding, + observedAt, + 'RPC_RECEIPT_MISSING', + undefined, + ); + } + if (candidate.state === 'insufficient') { + return observeUnusedAuthorization( + binding, + observedAt, + 'RPC_CONFIRMATIONS_INSUFFICIENT', + candidate.head, + ); + } + if (candidate.receipt.status === 'reverted') { + return rejection('payment', binding, candidate, 'TRANSACTION_REVERTED', observedAt); + } + + let logs; + try { + logs = matchingPaymentLogs(candidate.receipt, binding); + } catch (error) { + return unknown( + error instanceof EvidenceFault ? error.reasonCode : 'RPC_EVIDENCE_INVALID', + PAYMENT_UNKNOWN_REASONS, + ); + } + if (!logs.transfer || !logs.authorization) { + return rejection('payment', binding, candidate, 'EXACT_TRANSFER_ABSENT', observedAt); + } + return frozenCopy({ + kind: 'settled_transfer', + rpcTransferProof: { + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: binding.candidate.transactionId, + blockHash: candidate.receipt.blockHash, + blockNumber: candidate.receipt.blockNumber.toString(), + transactionStatus: 'success', + confirmations: candidate.confirmations, + transferLogIndex: logs.transfer.logIndex, + authorizationLogIndex: logs.authorization.logIndex, + tokenContract: ASSET, + from: binding.payer, + to: binding.payee, + valueAtomic: binding.amountAtomic, + authorizationNonce: binding.nonce, + observedAt, + }, + }); + }; + + const observeRefund = async function observeRefund(value) { + if (arguments.length !== 1) failBinding(); + const binding = validateRefundBinding(value); + let observedAt; + try { + observedAt = canonicalClock(clock); + } catch (error) { + return unknown(error.reasonCode, REFUND_UNKNOWN_REASONS); + } + let candidate; + try { + candidate = await loadConfirmedReceipt(binding.refundTransactionId, observedAt); + } catch (error) { + return unknown( + error instanceof EvidenceFault ? error.reasonCode : 'RPC_PROVIDER_UNAVAILABLE', + REFUND_UNKNOWN_REASONS, + ); + } + if (candidate.state === 'missing') { + return unknown('RPC_RECEIPT_MISSING', REFUND_UNKNOWN_REASONS); + } + if (candidate.state === 'insufficient') { + return unknown('RPC_CONFIRMATIONS_INSUFFICIENT', REFUND_UNKNOWN_REASONS); + } + if (candidate.receipt.status === 'reverted') { + return rejection('refund', binding, candidate, 'TRANSACTION_REVERTED', observedAt); + } + + let transfer; + try { + transfer = matchingRefundLog(candidate.receipt, binding); + } catch (error) { + return unknown( + error instanceof EvidenceFault ? error.reasonCode : 'RPC_EVIDENCE_INVALID', + REFUND_UNKNOWN_REASONS, + ); + } + if (!transfer) { + return rejection('refund', binding, candidate, 'EXACT_TRANSFER_ABSENT', observedAt); + } + return frozenCopy({ + kind: 'refund_transfer_confirmed', + rpcTransferProof: { + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: binding.refundTransactionId, + blockHash: candidate.receipt.blockHash, + blockNumber: candidate.receipt.blockNumber.toString(), + transactionStatus: 'success', + confirmations: candidate.confirmations, + transferLogIndex: transfer.logIndex, + tokenContract: ASSET, + from: binding.refundSource, + to: binding.originalPayer, + valueAtomic: binding.amountAtomic, + observedAt, + }, + }); + }; + + return Object.freeze({ + preflight, + fundingStatus, + observePayment, + observeRefund, + }); +} diff --git a/spikes/pi-wielder/src/adapters/cdp-wallet-adapter.mjs b/spikes/pi-wielder/src/adapters/cdp-wallet-adapter.mjs new file mode 100644 index 0000000..bac835e --- /dev/null +++ b/spikes/pi-wielder/src/adapters/cdp-wallet-adapter.mjs @@ -0,0 +1,164 @@ +import { getAddress } from 'viem'; + +import { + assertPermitMatchesPayment, + createDeadlineRunner, + executeAuthorizedSigning, + validateWalletIdentity, +} from './wallet-adapter-contract.mjs'; +import { buildEip3009Exact } from './eip3009-exact.mjs'; +import { canonicalToken, KernelError } from '../kernel/canonical.mjs'; + +const BASE_SEPOLIA_CAIP2 = 'eip155:84532'; + +function configurationFailure() { + throw new KernelError( + 'CDP_WALLET_ADAPTER_CONFIG', + 'CDP wallet adapter configuration is invalid', + ); +} + +function captureClient(cdpClient) { + try { + if (!cdpClient || (typeof cdpClient !== 'object' && typeof cdpClient !== 'function')) { + return configurationFailure(); + } + const evm = cdpClient.evm; + if (!evm || (typeof evm !== 'object' && typeof evm !== 'function')) { + return configurationFailure(); + } + const getAccount = evm.getAccount; + if (typeof getAccount !== 'function') return configurationFailure(); + return Object.freeze({ evm, getAccount }); + } catch { + return configurationFailure(); + } +} + +function callable(value) { + if (typeof value !== 'function') return configurationFailure(); + return value; +} + +function positiveMilliseconds(value) { + if (!Number.isSafeInteger(value) || value < 1) return configurationFailure(); + return value; +} + +/** + * Live-shaped CDP Wallet Adapter. + * + * The adapter can retrieve only an already-provisioned named account and can + * sign only the Kernel-constructed EIP-3009 authorization. Account creation, + * arbitrary signing, transaction sending, and provider handles are absent from + * its public surface. + */ +export function createCdpWalletAdapter({ + cdpClient, + walletName, + verifyAndConsume, + runWithDeadline = createDeadlineRunner(), + preSignTimeoutMs = 5_000, + signerTimeoutMs = 15_000, + nowMs = Date.now, +}) { + const client = captureClient(cdpClient); + let normalizedWalletName; + try { + normalizedWalletName = canonicalToken(walletName, 'CDP wallet name'); + } catch { + return configurationFailure(); + } + const consumePermit = callable(verifyAndConsume); + const deadline = callable(runWithDeadline); + const signingClock = callable(nowMs); + const identityTimeoutMs = positiveMilliseconds(preSignTimeoutMs); + const signingTimeoutMs = positiveMilliseconds(signerTimeoutMs); + + let accountPromise; + const account = () => { + accountPromise ??= Promise.resolve().then(() => Reflect.apply( + client.getAccount, + client.evm, + [{ name: normalizedWalletName }], + )); + return accountPromise; + }; + + const publicIdentity = async () => { + const value = await account(); + return validateWalletIdentity({ + provider: 'coinbase-cdp', + walletId: normalizedWalletName, + address: value.address, + network: BASE_SEPOLIA_CAIP2, + }); + }; + + return Object.freeze({ + async walletIdentity() { + const identity = await deadline({ + phase: 'wallet_identity', + timeoutMs: identityTimeoutMs, + operation: publicIdentity, + }); + return structuredClone(identity); + }, + + async signX402Exact(authorizedPermit, paymentRequired) { + return await executeAuthorizedSigning({ + runWithDeadline: deadline, + preSignTimeoutMs: identityTimeoutMs, + signerTimeoutMs: signingTimeoutMs, + prepare: async () => { + const sampledNowMs = signingClock(); + if (!Number.isSafeInteger(sampledNowMs) || sampledNowMs < 0) { + throw new KernelError( + 'CDP_WALLET_CLOCK', + 'CDP wallet signing clock returned an invalid value', + ); + } + const issuedBinding = consumePermit(authorizedPermit); + const binding = assertPermitMatchesPayment( + issuedBinding, + paymentRequired, + undefined, + sampledNowMs, + ); + const value = await account(); + const identity = validateWalletIdentity({ + provider: 'coinbase-cdp', + walletId: normalizedWalletName, + address: value.address, + network: BASE_SEPOLIA_CAIP2, + }); + if (getAddress(identity.address) !== getAddress(binding.walletAddress)) { + throw new KernelError( + 'CDP_WALLET_IDENTITY_MISMATCH', + 'CDP wallet identity does not match the AuthorizedPermit', + ); + } + const signTypedData = value.signTypedData; + if (typeof signTypedData !== 'function') { + throw new KernelError( + 'CDP_WALLET_SIGNER', + 'CDP wallet account does not expose typed-data signing', + ); + } + return Object.freeze({ + exact: buildEip3009Exact({ + binding, + paymentRequired, + nowMs: sampledNowMs, + }), + invoke: (typedData) => Reflect.apply(signTypedData, value, [typedData]), + }); + }, + invokeSigner: ({ exact, invoke }) => invoke(exact.typedData), + finalize: async ({ exact }, signature) => Object.freeze({ + paymentPayload: await exact.assemble(signature), + }), + }); + }, + }); +} diff --git a/spikes/pi-wielder/src/adapters/deterministic-wallet-adapter.mjs b/spikes/pi-wielder/src/adapters/deterministic-wallet-adapter.mjs new file mode 100644 index 0000000..8c9f7cd --- /dev/null +++ b/spikes/pi-wielder/src/adapters/deterministic-wallet-adapter.mjs @@ -0,0 +1,63 @@ +import { getAddress } from 'viem'; + +import { + assertPermitMatchesPayment, + createDeadlineRunner, + executeAuthorizedSigning, + validateWalletIdentity, +} from './wallet-adapter-contract.mjs'; +import { buildEip3009Exact } from './eip3009-exact.mjs'; + +/** + * Offline Wallet Adapter used only by deterministic tests and evidence runs. + * Mode selection belongs to the process composition root; this adapter has no + * environment, provider SDK, transport, or network surface. + */ +export function createDeterministicWalletAdapter({ + identity, + verifyAndConsume, + signTypedData, + runWithDeadline = createDeadlineRunner(), + preSignTimeoutMs = 5_000, + signerTimeoutMs = 15_000, + nowMs = Date.now, +}) { + const normalizedIdentity = validateWalletIdentity(identity); + + return Object.freeze({ + async walletIdentity() { + return structuredClone(normalizedIdentity); + }, + + async signX402Exact(authorizedPermit, paymentRequired) { + return await executeAuthorizedSigning({ + runWithDeadline, + preSignTimeoutMs, + signerTimeoutMs, + prepare: async () => { + if (typeof nowMs !== 'function') throw new Error('wallet signing clock is invalid'); + const signingNowMs = nowMs(); + if (!Number.isSafeInteger(signingNowMs) || signingNowMs < 0) { + throw new Error('wallet signing clock returned an invalid value'); + } + const issuedBinding = verifyAndConsume(authorizedPermit); + const binding = assertPermitMatchesPayment( + issuedBinding, + paymentRequired, + undefined, + ); + if (getAddress(normalizedIdentity.address) !== getAddress(binding.walletAddress)) { + throw new Error('wallet identity mismatch'); + } + return Object.freeze({ + exact: buildEip3009Exact({ binding, paymentRequired, nowMs: signingNowMs }), + }); + }, + invokeSigner: ({ exact }) => signTypedData(exact.typedData), + finalize: async ({ exact }, signature) => Object.freeze({ + paymentPayload: await exact.assemble(signature), + }), + }); + }, + }); +} diff --git a/spikes/pi-wielder/src/adapters/eip3009-exact.mjs b/spikes/pi-wielder/src/adapters/eip3009-exact.mjs new file mode 100644 index 0000000..95f77d8 --- /dev/null +++ b/spikes/pi-wielder/src/adapters/eip3009-exact.mjs @@ -0,0 +1,91 @@ +import { authorizationTypes } from '@x402/evm'; +import { getAddress } from 'viem'; + +import { exactRecord, frozenCopy } from '../kernel/canonical.mjs'; +import { projectPaymentRequired } from '../kernel/policy-engine.mjs'; +import { + assertPermitMatchesPayment, + validatePaymentPayload, +} from './wallet-adapter-contract.mjs'; + +export const BASE_SEPOLIA_CAIP2 = 'eip155:84532'; +export const BASE_SEPOLIA_USDC = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +export const BASE_SEPOLIA_USDC_EIP712_NAME = 'USDC'; +export const BASE_SEPOLIA_USDC_EIP712_VERSION = '2'; + +const BASE_SEPOLIA_CHAIN_ID = 84532; + +export function buildEip3009Exact(value) { + const request = exactRecord( + value, + ['binding', 'paymentRequired', 'nowMs'], + [], + 'WALLET_BINDING', + 'EIP-3009 construction request', + ); + const binding = assertPermitMatchesPayment( + request.binding, + request.paymentRequired, + request.binding.acceptedIndex, + request.nowMs, + ); + const projection = projectPaymentRequired(request.paymentRequired); + const accepted = projection.accepts[binding.acceptedIndex]; + const normalizedPaymentRequired = frozenCopy({ + x402Version: 2, + resource: { + url: binding.requestUrl, + description: binding.resourceDescription, + mimeType: binding.resourceMimeType, + }, + accepts: projection.accepts, + }); + const authorization = frozenCopy({ + from: binding.walletAddress, + to: binding.payTo, + value: binding.amountAtomic, + validAfter: binding.validAfter, + validBefore: binding.validBefore, + nonce: binding.nonce, + }); + const typedData = frozenCopy({ + domain: { + name: BASE_SEPOLIA_USDC_EIP712_NAME, + version: BASE_SEPOLIA_USDC_EIP712_VERSION, + chainId: BASE_SEPOLIA_CHAIN_ID, + verifyingContract: getAddress(BASE_SEPOLIA_USDC), + }, + types: authorizationTypes, + primaryType: 'TransferWithAuthorization', + message: { + from: getAddress(authorization.from), + to: getAddress(authorization.to), + value: BigInt(authorization.value), + validAfter: BigInt(authorization.validAfter), + validBefore: BigInt(authorization.validBefore), + nonce: authorization.nonce, + }, + }); + + return Object.freeze({ + typedData, + async assemble(signature) { + return await validatePaymentPayload({ + paymentPayload: { + x402Version: 2, + resource: { + url: binding.requestUrl, + description: binding.resourceDescription, + mimeType: binding.resourceMimeType, + }, + accepted, + payload: { signature, authorization }, + }, + binding, + paymentRequired: normalizedPaymentRequired, + typedData, + nowMs: request.nowMs, + }); + }, + }); +} diff --git a/spikes/pi-wielder/src/adapters/seller-evidence-resolver.mjs b/spikes/pi-wielder/src/adapters/seller-evidence-resolver.mjs new file mode 100644 index 0000000..9124a72 --- /dev/null +++ b/spikes/pi-wielder/src/adapters/seller-evidence-resolver.mjs @@ -0,0 +1,701 @@ +import { types as utilTypes } from 'node:util'; + +import { recoverMessageAddress } from 'viem'; + +import { + canonicalAtomic, + canonicalEvmHash, + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from '../kernel/canonical.mjs'; +import { validatePolicyDocument } from '../kernel/policy-engine.mjs'; +import { + cancelResponseBody, + readBodyBytes, + RuntimeBoundaryError, + withWallClockDeadline, +} from '../runtime-boundaries.mjs'; + +const MODES = new Set(['deterministic', 'cdp-testnet']); +const HASH = /^sha256:[0-9a-f]{64}$/; +const ADDRESS = /^0x[0-9a-f]{40}$/; +const REASON = /^[A-Z][A-Z0-9_]{0,79}$/; +const SIGNATURE = /^0x[0-9a-f]{130}$/; +const ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i; +const MAX_TIMEOUT_MS = 5_000; +const MAX_RESPONSE_BYTES = 16_384; +const MAX_ATTESTATION_LIFETIME_MS = 15 * 60 * 1_000; + +const UNKNOWN_REASONS = new Set([ + 'SELLER_EVIDENCE_BINDING_INVALID', + 'SELLER_EVIDENCE_ENDPOINT_INVALID', + 'SELLER_EVIDENCE_FETCH_FAILED', + 'SELLER_EVIDENCE_TIMEOUT', + 'SELLER_EVIDENCE_REDIRECT', + 'SELLER_EVIDENCE_HTTP_STATUS', + 'SELLER_EVIDENCE_CONTENT_TYPE', + 'SELLER_EVIDENCE_TOO_LARGE', + 'SELLER_EVIDENCE_RESPONSE_INVALID', + 'SELLER_EVIDENCE_JSON_INVALID', + 'SELLER_EVIDENCE_ATTESTATION_INVALID', + 'SELLER_EVIDENCE_ATTESTATION_MISMATCH', + 'SELLER_EVIDENCE_TIME_INVALID', + 'SELLER_EVIDENCE_SIGNATURE_INVALID', +]); + +const EXECUTION_BINDING_FIELDS = Object.freeze([ + 'schemaVersion', + 'domain', + 'intentId', + 'intentHash', + 'policyVersion', + 'seller', + 'resourcePath', + 'network', + 'sellerOrigin', + 'transactionId', + 'executionSigner', + 'persistedHttpStatus', + 'persistedResponseHash', + 'resolutionReasonCode', + 'caseHash', +]); +const REFUND_BINDING_FIELDS = Object.freeze([ + 'schemaVersion', + 'domain', + 'intentId', + 'intentHash', + 'policyVersion', + 'seller', + 'resourcePath', + 'network', + 'sellerOrigin', + 'originalTransactionId', + 'refundTransactionId', + 'asset', + 'originalPayer', + 'originalPayee', + 'refundSource', + 'refundSigner', + 'amountAtomic', + 'localRefundBindingHash', + 'refundId', + 'caseHash', +]); +const EXECUTION_ATTESTATION_FIELDS = Object.freeze([ + 'schemaVersion', + 'domain', + 'network', + 'sellerOrigin', + 'intentHash', + 'transactionId', + 'outcome', + 'httpStatus', + 'responseHash', + 'issuedAt', + 'expiresAt', + 'signer', + 'signature', +]); +const REFUND_ATTESTATION_FIELDS = Object.freeze([ + 'schemaVersion', + 'domain', + 'network', + 'sellerOrigin', + 'intentHash', + 'originalTransactionId', + 'refundTransactionId', + 'asset', + 'originalPayer', + 'originalPayee', + 'refundSource', + 'amountAtomic', + 'issuedAt', + 'expiresAt', + 'signer', + 'signature', +]); + +class SellerEvidenceFault extends Error { + constructor(reasonCode) { + super('seller evidence is unavailable'); + this.name = 'SellerEvidenceFault'; + this.reasonCode = reasonCode; + } +} + +function fault(reasonCode) { + if (!UNKNOWN_REASONS.has(reasonCode)) { + throw new TypeError('seller evidence fault reason is not allowlisted'); + } + throw new SellerEvidenceFault(reasonCode); +} + +function configError() { + throw new KernelError( + 'SELLER_EVIDENCE_CONFIG', + 'seller evidence resolver configuration is invalid', + ); +} + +function closedShallowRecord(value, required) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + return configError(); + } + const keys = Reflect.ownKeys(value); + const allowed = new Set(required); + if (keys.length !== required.length + || required.some((field) => !Object.hasOwn(value, field)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key))) { + return configError(); + } + const result = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) return configError(); + result[key] = descriptor.value; + } + return result; +} + +function positiveLimit(value, maximum) { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) return configError(); + return value; +} + +function unknown(reasonCode) { + if (!UNKNOWN_REASONS.has(reasonCode)) { + throw new TypeError('unknown seller evidence reason is not allowlisted'); + } + return Object.freeze({ kind: 'unknown', reasonCode }); +} + +function canonicalHash(value) { + if (typeof value !== 'string' || !HASH.test(value)) { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + return value; +} + +function canonicalAddress(value) { + if (typeof value !== 'string' || !ADDRESS.test(value)) { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + return value; +} + +function canonicalTransaction(value) { + let normalized; + try { + normalized = canonicalEvmHash(value, 'transaction ID'); + } catch { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + if (normalized !== value) fault('SELLER_EVIDENCE_BINDING_INVALID'); + return value; +} + +function canonicalResourcePath(value, origin) { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 2_048 + || !value.startsWith('/') || value.startsWith('//') + || value.includes('?') || value.includes('#') || value.includes('\\') + || ENCODED_PATH_SEPARATOR.test(value)) { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + let parsed; + try { + parsed = new URL(value, `${origin}/`); + } catch { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + if (parsed.origin !== origin || parsed.pathname !== value + || parsed.search !== '' || parsed.hash !== '') { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + return value; +} + +function closedBinding(value, fields, label) { + try { + return exactRecord(value, fields, [], 'SELLER_EVIDENCE_BINDING_INVALID', label); + } catch { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } +} + +function policyAuthority(binding) { + let version; + try { + version = exactRecord( + binding.policyVersion, + ['id', 'hash', 'policy'], + [], + 'SELLER_EVIDENCE_BINDING_INVALID', + 'policy version', + ); + canonicalToken(version.id, 'policy version ID'); + } catch { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + canonicalHash(version.hash); + + let policy; + let policyMatches; + try { + policy = validatePolicyDocument(version.policy); + policyMatches = canonicalJson(version.policy) === canonicalJson(policy); + } catch { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + if (!policyMatches || sha256(canonicalJson(policy)) !== version.hash) { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + canonicalResourcePath(binding.resourcePath, binding.sellerOrigin); + const seller = policy.sellers.find((candidate) => ( + candidate.origin === binding.sellerOrigin + && candidate.pathPrefixes.some((prefix) => binding.resourcePath.startsWith(prefix)) + )); + if (!seller) fault('SELLER_EVIDENCE_BINDING_INVALID'); + let sellerMatches = false; + try { + sellerMatches = canonicalJson(seller) === canonicalJson(binding.seller); + } catch { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + if (!sellerMatches) fault('SELLER_EVIDENCE_BINDING_INVALID'); + return Object.freeze({ policy, seller }); +} + +function executionAuthority(value) { + const binding = closedBinding(value, EXECUTION_BINDING_FIELDS, 'execution binding'); + const { policy, seller } = policyAuthority(binding); + if (binding.schemaVersion !== 1 + || binding.domain !== 'wallet-kernel.execution-observation.v1' + || binding.network !== policy.network + || binding.sellerOrigin !== seller.origin + || binding.executionSigner !== seller.executionSigner + || typeof binding.resolutionReasonCode !== 'string' + || !REASON.test(binding.resolutionReasonCode)) { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + try { + canonicalToken(binding.intentId, 'intent ID'); + } catch { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + canonicalHash(binding.intentHash); + canonicalTransaction(binding.transactionId); + canonicalAddress(binding.executionSigner); + canonicalHash(binding.caseHash); + if (binding.persistedHttpStatus !== null + && (!Number.isSafeInteger(binding.persistedHttpStatus) + || binding.persistedHttpStatus < 100 + || binding.persistedHttpStatus > 599)) { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + if (binding.persistedResponseHash !== null) canonicalHash(binding.persistedResponseHash); + return Object.freeze({ binding, seller }); +} + +function refundBindingHash(binding) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-binding.v1', + intentHash: binding.intentHash, + originalTransactionId: binding.originalTransactionId, + refundTransactionId: binding.refundTransactionId, + network: binding.network, + sellerOrigin: binding.sellerOrigin, + asset: binding.asset, + originalPayer: binding.originalPayer, + originalPayee: binding.originalPayee, + refundSource: binding.refundSource, + refundSigner: binding.refundSigner, + amountAtomic: binding.amountAtomic, + })); +} + +function refundAuthority(value) { + const binding = closedBinding(value, REFUND_BINDING_FIELDS, 'refund binding'); + const { policy, seller } = policyAuthority(binding); + if (binding.schemaVersion !== 1 + || binding.domain !== 'wallet-kernel.refund-observation.v1' + || binding.network !== policy.network + || binding.asset !== policy.asset + || binding.sellerOrigin !== seller.origin + || binding.originalPayer !== policy.wallet + || binding.originalPayee !== seller.payTo + || binding.refundSource !== seller.refundSource + || binding.refundSigner !== seller.refundSigner) { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + try { + canonicalToken(binding.intentId, 'intent ID'); + canonicalToken(binding.refundId, 'refund ID'); + } catch { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + canonicalHash(binding.intentHash); + canonicalTransaction(binding.originalTransactionId); + canonicalTransaction(binding.refundTransactionId); + canonicalAddress(binding.originalPayer); + canonicalAddress(binding.originalPayee); + canonicalAddress(binding.refundSource); + canonicalAddress(binding.refundSigner); + let amount; + try { + amount = canonicalAtomic(binding.amountAtomic, 'refund amount'); + } catch { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + if (amount.value < 1n + || amount.value > canonicalAtomic(seller.perRequestMaxAtomic, 'seller limit').value) { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + canonicalHash(binding.localRefundBindingHash); + canonicalHash(binding.caseHash); + if (refundBindingHash(binding) !== binding.localRefundBindingHash) { + fault('SELLER_EVIDENCE_BINDING_INVALID'); + } + return Object.freeze({ binding, seller }); +} + +function literalLoopbackHttp(origin, parsed) { + if (parsed.protocol !== 'http:' || !origin.startsWith('http://')) return false; + const authority = origin.slice('http://'.length); + return /^(?:127\.0\.0\.1|\[::1\])(?::[1-9][0-9]{0,4})?$/.test(authority) + && parsed.origin === origin; +} + +function endpointFor(seller, mode) { + let origin; + try { + origin = new URL(seller.origin); + } catch { + fault('SELLER_EVIDENCE_ENDPOINT_INVALID'); + } + if (origin.origin !== seller.origin || origin.username !== '' || origin.password !== '' + || origin.pathname !== '/' || origin.search !== '' || origin.hash !== '' + || (mode === 'cdp-testnet' && origin.protocol !== 'https:') + || (mode === 'deterministic' && origin.protocol !== 'https:' + && !literalLoopbackHttp(seller.origin, origin))) { + fault('SELLER_EVIDENCE_ENDPOINT_INVALID'); + } + let endpoint; + try { + endpoint = new URL(seller.evidencePath, `${seller.origin}/`); + } catch { + fault('SELLER_EVIDENCE_ENDPOINT_INVALID'); + } + const expected = `${seller.origin}${seller.evidencePath}`; + if (endpoint.href !== expected || endpoint.origin !== seller.origin + || endpoint.pathname !== seller.evidencePath + || endpoint.search !== '' || endpoint.hash !== '') { + fault('SELLER_EVIDENCE_ENDPOINT_INVALID'); + } + return expected; +} + +function closedAttestation(value, fields, label) { + try { + return exactRecord( + value, + fields, + [], + 'SELLER_EVIDENCE_ATTESTATION_INVALID', + label, + ); + } catch { + fault('SELLER_EVIDENCE_ATTESTATION_INVALID'); + } +} + +function validateWindow(attestation, now) { + let issuedAt; + let expiresAt; + let observedAt; + try { + issuedAt = canonicalTimestamp(attestation.issuedAt, 'attestation issuedAt'); + expiresAt = canonicalTimestamp(attestation.expiresAt, 'attestation expiresAt'); + observedAt = canonicalTimestamp(now(), 'attestation observation time'); + } catch { + fault('SELLER_EVIDENCE_TIME_INVALID'); + } + if (Date.parse(expiresAt) <= Date.parse(issuedAt) + || Date.parse(expiresAt) - Date.parse(issuedAt) > MAX_ATTESTATION_LIFETIME_MS + || Date.parse(observedAt) < Date.parse(issuedAt) + || Date.parse(observedAt) >= Date.parse(expiresAt)) { + fault('SELLER_EVIDENCE_TIME_INVALID'); + } +} + +async function verifySignature(attestation, policySigner) { + if (typeof attestation.signer !== 'string' || !ADDRESS.test(attestation.signer) + || attestation.signer !== policySigner + || typeof attestation.signature !== 'string' + || !SIGNATURE.test(attestation.signature)) { + fault('SELLER_EVIDENCE_SIGNATURE_INVALID'); + } + const { signature, ...unsigned } = attestation; + let recovered; + try { + recovered = await recoverMessageAddress({ + message: { raw: Buffer.from(canonicalJson(unsigned), 'utf8') }, + signature, + }); + } catch { + fault('SELLER_EVIDENCE_SIGNATURE_INVALID'); + } + if (recovered.toLowerCase() !== policySigner) { + fault('SELLER_EVIDENCE_SIGNATURE_INVALID'); + } + return frozenCopy(unsigned); +} + +function canonicalAttestationHash(value) { + if (value !== null && (typeof value !== 'string' || !HASH.test(value))) { + fault('SELLER_EVIDENCE_ATTESTATION_INVALID'); + } + return value; +} + +async function executionAttestation(value, binding, now) { + const attestation = closedAttestation( + value, + EXECUTION_ATTESTATION_FIELDS, + 'execution attestation', + ); + if (attestation.schemaVersion !== 1) fault('SELLER_EVIDENCE_ATTESTATION_INVALID'); + if (attestation.domain !== 'wallet-kernel.execution.v1' + || attestation.network !== binding.network + || attestation.sellerOrigin !== binding.sellerOrigin + || attestation.intentHash !== binding.intentHash + || attestation.transactionId !== binding.transactionId) { + fault('SELLER_EVIDENCE_ATTESTATION_MISMATCH'); + } + if (!new Set(['succeeded', 'failed']).has(attestation.outcome) + || !Number.isSafeInteger(attestation.httpStatus) + || attestation.httpStatus < 100 || attestation.httpStatus > 599 + || (attestation.outcome === 'succeeded' + && (attestation.httpStatus < 200 || attestation.httpStatus > 299)) + || (attestation.outcome === 'failed' && attestation.httpStatus < 400)) { + fault('SELLER_EVIDENCE_ATTESTATION_INVALID'); + } + canonicalAttestationHash(attestation.responseHash); + if ((binding.persistedHttpStatus !== null + && attestation.httpStatus !== binding.persistedHttpStatus) + || (binding.persistedResponseHash !== null + && attestation.responseHash !== binding.persistedResponseHash)) { + fault('SELLER_EVIDENCE_ATTESTATION_MISMATCH'); + } + validateWindow(attestation, now); + return await verifySignature(attestation, binding.executionSigner); +} + +async function refundAttestation(value, binding, now) { + const attestation = closedAttestation( + value, + REFUND_ATTESTATION_FIELDS, + 'refund attestation', + ); + if (attestation.schemaVersion !== 1) fault('SELLER_EVIDENCE_ATTESTATION_INVALID'); + if (attestation.domain !== 'wallet-kernel.refund.v1' + || attestation.network !== binding.network + || attestation.sellerOrigin !== binding.sellerOrigin + || attestation.intentHash !== binding.intentHash + || attestation.originalTransactionId !== binding.originalTransactionId + || attestation.refundTransactionId !== binding.refundTransactionId + || attestation.asset !== binding.asset + || attestation.originalPayer !== binding.originalPayer + || attestation.originalPayee !== binding.originalPayee + || attestation.refundSource !== binding.refundSource + || attestation.amountAtomic !== binding.amountAtomic) { + fault('SELLER_EVIDENCE_ATTESTATION_MISMATCH'); + } + validateWindow(attestation, now); + return await verifySignature(attestation, binding.refundSigner); +} + +function validateResponseSurface(value) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Object.getPrototypeOf(value) !== Response.prototype + || Reflect.ownKeys(value).length !== 0) { + fault('SELLER_EVIDENCE_RESPONSE_INVALID'); + } + if (value.redirected || (value.status >= 300 && value.status <= 399)) { + cancelResponseBody(value, new Error('seller evidence redirect rejected')); + fault('SELLER_EVIDENCE_REDIRECT'); + } + if (value.status !== 200) { + cancelResponseBody(value, new Error('seller evidence HTTP status rejected')); + fault('SELLER_EVIDENCE_HTTP_STATUS'); + } + const contentType = value.headers.get('content-type')?.toLowerCase(); + if (contentType !== 'application/json' + && contentType !== 'application/json; charset=utf-8') { + cancelResponseBody(value, new Error('seller evidence content type rejected')); + fault('SELLER_EVIDENCE_CONTENT_TYPE'); + } + return value; +} + +function parseJson(bytes) { + if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + fault('SELLER_EVIDENCE_JSON_INVALID'); + } + let text; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + fault('SELLER_EVIDENCE_JSON_INVALID'); + } + try { + return JSON.parse(text); + } catch { + fault('SELLER_EVIDENCE_JSON_INVALID'); + } +} + +function mapBoundaryError(error) { + if (error instanceof SellerEvidenceFault) return unknown(error.reasonCode); + if (error instanceof RuntimeBoundaryError) { + if (error.code === 'SELLER_EVIDENCE_TIMEOUT') { + return unknown('SELLER_EVIDENCE_TIMEOUT'); + } + if (error.code === 'SELLER_EVIDENCE_TOO_LARGE') { + return unknown('SELLER_EVIDENCE_TOO_LARGE'); + } + if (error.code === 'SELLER_EVIDENCE_RESPONSE_INVALID') { + return unknown('SELLER_EVIDENCE_RESPONSE_INVALID'); + } + } + return unknown('SELLER_EVIDENCE_FETCH_FAILED'); +} + +export function createSellerEvidenceResolver(value) { + const config = closedShallowRecord(value, ['fetchImpl', 'mode', 'now', 'limits']); + if (typeof config.fetchImpl !== 'function' || utilTypes.isProxy(config.fetchImpl) + || typeof config.now !== 'function' || utilTypes.isProxy(config.now) + || !MODES.has(config.mode)) { + return configError(); + } + const rawLimits = closedShallowRecord( + config.limits, + ['requestTimeoutMs', 'maximumResponseBytes'], + ); + const limits = Object.freeze({ + requestTimeoutMs: positiveLimit(rawLimits.requestTimeoutMs, MAX_TIMEOUT_MS), + maximumResponseBytes: positiveLimit(rawLimits.maximumResponseBytes, MAX_RESPONSE_BYTES), + }); + const fetchImpl = config.fetchImpl; + const mode = config.mode; + const now = config.now; + + const request = async ({ authority, kind, body, endpoint }) => { + try { + return await withWallClockDeadline({ + timeoutMs: limits.requestTimeoutMs, + timeoutCode: 'SELLER_EVIDENCE_TIMEOUT', + timeoutMessage: 'seller evidence request timed out', + abortedCode: 'SELLER_EVIDENCE_TIMEOUT', + abortedMessage: 'seller evidence request was aborted', + }, async (signal) => { + let response; + try { + response = await fetchImpl(endpoint, { + method: 'POST', + redirect: 'manual', + credentials: 'omit', + cache: 'no-store', + referrerPolicy: 'no-referrer', + signal, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: canonicalJson(body), + }); + } catch { + if (signal.aborted) fault('SELLER_EVIDENCE_TIMEOUT'); + fault('SELLER_EVIDENCE_FETCH_FAILED'); + } + validateResponseSurface(response); + const bytes = await readBodyBytes(response, { + maxBytes: limits.maximumResponseBytes, + tooLargeCode: 'SELLER_EVIDENCE_TOO_LARGE', + tooLargeMessage: 'seller evidence response is too large', + readErrorCode: 'SELLER_EVIDENCE_RESPONSE_INVALID', + readErrorMessage: 'seller evidence response body is invalid', + signal, + }); + const parsed = parseJson(bytes); + const attestation = kind === 'execution' + ? await executionAttestation(parsed, authority.binding, now) + : await refundAttestation(parsed, authority.binding, now); + return frozenCopy({ + kind: kind === 'execution' ? 'execution_attested' : 'refund_attested', + attestation, + attestationHash: sha256(canonicalJson(attestation)), + }); + }); + } catch (error) { + return mapBoundaryError(error); + } + }; + + return Object.freeze({ + async observeExecution(persistedBinding) { + let authority; + let endpoint; + try { + authority = executionAuthority(persistedBinding); + endpoint = endpointFor(authority.seller, mode); + } catch (error) { + if (error instanceof SellerEvidenceFault) return unknown(error.reasonCode); + return unknown('SELLER_EVIDENCE_BINDING_INVALID'); + } + return await request({ + authority, + endpoint, + kind: 'execution', + body: { + schemaVersion: 1, + kind: 'execution', + sellerOrigin: authority.binding.sellerOrigin, + intentHash: authority.binding.intentHash, + transactionId: authority.binding.transactionId, + }, + }); + }, + async observeRefund(persistedBinding) { + let authority; + let endpoint; + try { + authority = refundAuthority(persistedBinding); + endpoint = endpointFor(authority.seller, mode); + } catch (error) { + if (error instanceof SellerEvidenceFault) return unknown(error.reasonCode); + return unknown('SELLER_EVIDENCE_BINDING_INVALID'); + } + return await request({ + authority, + endpoint, + kind: 'refund', + body: { + schemaVersion: 1, + kind: 'refund', + sellerOrigin: authority.binding.sellerOrigin, + intentHash: authority.binding.intentHash, + originalTransactionId: authority.binding.originalTransactionId, + refundTransactionId: authority.binding.refundTransactionId, + }, + }); + }, + }); +} diff --git a/spikes/pi-wielder/src/adapters/wallet-adapter-contract.mjs b/spikes/pi-wielder/src/adapters/wallet-adapter-contract.mjs new file mode 100644 index 0000000..613e582 --- /dev/null +++ b/spikes/pi-wielder/src/adapters/wallet-adapter-contract.mjs @@ -0,0 +1,643 @@ +import { authorizationTypes } from '@x402/evm'; +import { getAddress, recoverTypedDataAddress } from 'viem'; +import { types as utilTypes } from 'node:util'; + +import { + canonicalAtomic, + canonicalJson, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from '../kernel/canonical.mjs'; +import { projectPaymentRequired } from '../kernel/policy-engine.mjs'; + +const DEADLINE_CODES = Object.freeze({ + wallet_identity: 'WALLET_IDENTITY_TIMEOUT', + 'pre-sign': 'WALLET_PRE_SIGN_TIMEOUT', + signer: 'WALLET_SIGNER_TIMEOUT', +}); +const OPERATION_FAILURE_CODES = Object.freeze({ + wallet_identity: 'WALLET_IDENTITY_OPERATION_FAILED', + 'pre-sign': 'WALLET_PRE_SIGN_OPERATION_FAILED', + signer: 'WALLET_SIGNER_OPERATION_FAILED', +}); +const BASE_SEPOLIA_CAIP2 = 'eip155:84532'; +const BASE_SEPOLIA_USDC = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const LOWERCASE_ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const NONCE_PATTERN = /^0x[0-9a-f]{64}$/; +const MAX_UINT256 = (1n << 256n) - 1n; +const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'); +const SECP256K1_HALF_N = BigInt( + '0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0', +); +const SIGNING_BINDING_FIELDS = Object.freeze([ + 'intentId', + 'intentHash', + 'challengeHash', + 'quoteId', + 'acceptedIndex', + 'requestUrl', + 'resourceDescription', + 'resourceMimeType', + 'scheme', + 'network', + 'asset', + 'walletAddress', + 'payTo', + 'amountAtomic', + 'validAfter', + 'validBefore', + 'nonce', + 'policyVersionId', +]); + +function identityFailure(message) { + throw new KernelError('WALLET_IDENTITY', message); +} + +function bindingFailure(message) { + throw new KernelError('WALLET_BINDING', message); +} + +function paymentPayloadFailure(message) { + throw new KernelError('WALLET_PAYMENT_PAYLOAD', message); +} + +function inertRecord(value, required, optional, code, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) { + throw new KernelError(code, `${label} must be one plain object`); + } + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(value); + if (required.some((key) => !Object.hasOwn(value, key)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key))) { + throw new KernelError(code, `${label} fields do not match the closed schema`); + } + const result = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new KernelError(code, `${label} fields must be enumerable data properties`); + } + result[key] = descriptor.value; + } + return Object.freeze(result); +} + +function plainCallable(value, code, label) { + if (typeof value !== 'function' || utilTypes.isProxy(value)) { + throw new KernelError(code, `${label} must be one non-proxy function`); + } + return value; +} + +function positiveDeadline(value, code, label) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new KernelError(code, `${label} must be positive safe-integer milliseconds`); + } + return value; +} + +function signingHash(value) { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + bindingFailure('wallet signing hashes must be canonical SHA-256 values'); + } + return value; +} + +function signingAddress(value, label) { + if (typeof value !== 'string' || !LOWERCASE_ADDRESS_PATTERN.test(value) + || value === ZERO_ADDRESS) { + bindingFailure(`${label} must be one nonzero canonical lowercase EVM address`); + } + return value; +} + +function signingUint256(value, label, { positive = false } = {}) { + let atomic; + try { + atomic = canonicalAtomic(value, label); + } catch { + return bindingFailure(`${label} must be canonical uint256 text`); + } + if (atomic.value > MAX_UINT256 || (positive && atomic.value === 0n)) { + bindingFailure(`${label} is outside the permitted uint256 range`); + } + return atomic.text; +} + +function canonicalEoaSignature(value) { + if (typeof value !== 'string' || !/^0x[0-9a-fA-F]{130}$/.test(value)) { + paymentPayloadFailure('signed payment requires one canonical EOA signature'); + } + const r = BigInt(`0x${value.slice(2, 66)}`); + const s = BigInt(`0x${value.slice(66, 130)}`); + const v = Number.parseInt(value.slice(130, 132), 16); + if (r === 0n || r >= SECP256K1_N + || s === 0n || s > SECP256K1_HALF_N + || (v !== 27 && v !== 28)) { + paymentPayloadFailure('signed payment requires one canonical EOA signature'); + } + return value; +} + +export function assertPermitMatchesPayment(value, paymentRequired, acceptedIndex, nowMs) { + try { + const binding = exactRecord( + value, + SIGNING_BINDING_FIELDS, + [], + 'WALLET_BINDING', + 'wallet signing binding', + ); + if (!Number.isSafeInteger(binding.acceptedIndex) || binding.acceptedIndex < 0 + || (acceptedIndex !== undefined && acceptedIndex !== binding.acceptedIndex)) { + bindingFailure('accepted index does not match the AuthorizedPermit'); + } + const selectedIndex = binding.acceptedIndex; + + const projection = projectPaymentRequired(paymentRequired); + if (projection.x402Version !== 2 || selectedIndex >= projection.accepts.length) { + bindingFailure('AuthorizedPermit does not select one x402 v2 payment requirement'); + } + const accepted = projection.accepts[selectedIndex]; + const challengeHash = signingHash(binding.challengeHash); + const quoteId = signingHash(binding.quoteId); + if (challengeHash !== sha256(canonicalJson(projection)) + || quoteId !== sha256(canonicalJson({ challengeHash, acceptedIndex: selectedIndex }))) { + bindingFailure('AuthorizedPermit does not match the canonical payment challenge'); + } + + let intentId; + let policyVersionId; + try { + intentId = canonicalToken(binding.intentId, 'wallet signing intent ID'); + policyVersionId = canonicalToken(binding.policyVersionId, 'wallet signing policy version ID'); + } catch { + return bindingFailure('AuthorizedPermit identity fields are not canonical tokens'); + } + const amountAtomic = signingUint256(binding.amountAtomic, 'wallet signing amount', { + positive: true, + }); + const validBefore = signingUint256(binding.validBefore, 'authorization validBefore', { + positive: true, + }); + const walletAddress = signingAddress(binding.walletAddress, 'wallet address'); + const payTo = signingAddress(binding.payTo, 'payment recipient'); + const asset = signingAddress(binding.asset, 'payment asset'); + if (typeof binding.nonce !== 'string' || !NONCE_PATTERN.test(binding.nonce)) { + bindingFailure('authorization nonce must be one canonical lowercase bytes32 value'); + } + + if (accepted.scheme !== 'exact' + || accepted.network !== BASE_SEPOLIA_CAIP2 + || accepted.asset !== BASE_SEPOLIA_USDC + || accepted.extra.name !== 'USDC' + || accepted.extra.version !== '2' + || (Object.hasOwn(accepted.extra, 'assetTransferMethod') + && accepted.extra.assetTransferMethod !== 'eip3009') + || binding.scheme !== accepted.scheme + || binding.network !== accepted.network + || asset !== accepted.asset + || payTo !== accepted.payTo + || amountAtomic !== accepted.amount + || binding.requestUrl === '' + || sha256(binding.requestUrl) !== projection.resource.urlHash + || binding.resourceDescription !== projection.resource.description + || binding.resourceMimeType !== projection.resource.mimeType + || binding.validAfter !== '0') { + bindingFailure('AuthorizedPermit fields do not exactly match the selected payment'); + } + if (nowMs !== undefined) { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + bindingFailure('wallet signing time must be nonnegative safe-integer milliseconds'); + } + const nowSeconds = BigInt(Math.floor(nowMs / 1_000)); + const validBeforeSeconds = BigInt(validBefore); + const protocolDeadlineSeconds = nowSeconds + BigInt(accepted.maxTimeoutSeconds); + if (validBeforeSeconds <= nowSeconds || validBeforeSeconds > protocolDeadlineSeconds) { + bindingFailure('authorization validity is expired or exceeds the selected payment timeout'); + } + } + + return frozenCopy({ + intentId, + intentHash: signingHash(binding.intentHash), + challengeHash, + quoteId, + acceptedIndex: selectedIndex, + requestUrl: binding.requestUrl, + resourceDescription: binding.resourceDescription, + resourceMimeType: binding.resourceMimeType, + scheme: 'exact', + network: BASE_SEPOLIA_CAIP2, + asset, + walletAddress, + payTo, + amountAtomic, + validAfter: '0', + validBefore, + nonce: binding.nonce, + policyVersionId, + }); + } catch (error) { + if (error instanceof KernelError && error.code === 'WALLET_BINDING') throw error; + return bindingFailure('AuthorizedPermit or payment challenge failed closed validation'); + } +} + +function validateTypedData(value, binding) { + const typedData = exactRecord( + value, + ['domain', 'types', 'primaryType', 'message'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'EIP-3009 typed data', + ); + const domain = exactRecord( + typedData.domain, + ['name', 'version', 'chainId', 'verifyingContract'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'EIP-712 domain', + ); + const message = exactRecord( + typedData.message, + ['from', 'to', 'value', 'validAfter', 'validBefore', 'nonce'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'EIP-3009 message', + ); + const types = exactRecord( + typedData.types, + ['TransferWithAuthorization'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'EIP-3009 authorization types', + ); + if (JSON.stringify(types) !== JSON.stringify(authorizationTypes) + || typedData.primaryType !== 'TransferWithAuthorization' + || domain.name !== 'USDC' + || domain.version !== '2' + || domain.chainId !== 84532 + || domain.verifyingContract !== getAddress(binding.asset) + || message.from !== getAddress(binding.walletAddress) + || message.to !== getAddress(binding.payTo) + || message.value !== BigInt(binding.amountAtomic) + || message.validAfter !== BigInt(binding.validAfter) + || message.validBefore !== BigInt(binding.validBefore) + || message.nonce !== binding.nonce) { + paymentPayloadFailure('typed data does not exactly encode the AuthorizedPermit'); + } + return frozenCopy({ domain, types, primaryType: typedData.primaryType, message }); +} + +function validateAcceptedPayload(value, expected) { + const accepted = exactRecord( + value, + ['scheme', 'network', 'asset', 'amount', 'payTo', 'maxTimeoutSeconds', 'extra'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'x402 accepted requirement', + ); + const extra = exactRecord( + accepted.extra, + ['name', 'version'], + ['assetTransferMethod'], + 'WALLET_PAYMENT_PAYLOAD', + 'x402 accepted requirement extra', + ); + const normalized = { ...accepted, extra }; + if (canonicalJson(normalized) !== canonicalJson(expected)) { + paymentPayloadFailure('signed payment changed the selected accepted requirement'); + } + return normalized; +} + +export async function validatePaymentPayload(value) { + try { + const request = exactRecord( + value, + ['paymentPayload', 'binding', 'paymentRequired', 'typedData'], + ['nowMs'], + 'WALLET_PAYMENT_PAYLOAD', + 'payment-payload validation request', + ); + const paymentPayload = request.paymentPayload; + const bindingValue = request.binding; + const paymentRequired = request.paymentRequired; + const typedDataValue = request.typedData; + const binding = assertPermitMatchesPayment( + bindingValue, + paymentRequired, + undefined, + request.nowMs, + ); + const projection = projectPaymentRequired(paymentRequired); + const expectedAccepted = projection.accepts[binding.acceptedIndex]; + const payment = exactRecord( + paymentPayload, + ['x402Version', 'resource', 'accepted', 'payload'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'x402 payment payload', + ); + const resource = exactRecord( + payment.resource, + ['url', 'description', 'mimeType'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'x402 payment resource', + ); + const accepted = validateAcceptedPayload(payment.accepted, expectedAccepted); + const payload = exactRecord( + payment.payload, + ['signature', 'authorization'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'x402 exact payload', + ); + const authorization = exactRecord( + payload.authorization, + ['from', 'to', 'value', 'validAfter', 'validBefore', 'nonce'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'EIP-3009 authorization', + ); + const typedData = validateTypedData(typedDataValue, binding); + + if (payment.x402Version !== 2 + || resource.url !== binding.requestUrl + || resource.description !== binding.resourceDescription + || resource.mimeType !== binding.resourceMimeType + || authorization.from !== binding.walletAddress + || authorization.to !== binding.payTo + || authorization.value !== binding.amountAtomic + || authorization.validAfter !== binding.validAfter + || authorization.validBefore !== binding.validBefore + || authorization.nonce !== binding.nonce + || typeof payload.signature !== 'string') { + paymentPayloadFailure('signed payment does not exactly match the AuthorizedPermit'); + } + const signature = canonicalEoaSignature(payload.signature); + + let recovered; + try { + recovered = await recoverTypedDataAddress({ ...typedData, signature }); + } catch { + return paymentPayloadFailure('signed payment contains an invalid EIP-3009 signature'); + } + if (getAddress(recovered) !== getAddress(binding.walletAddress)) { + paymentPayloadFailure('signed payment was not produced by the authorized wallet'); + } + + return frozenCopy({ + x402Version: 2, + resource, + accepted, + payload: { signature, authorization }, + }); + } catch (error) { + if (error instanceof KernelError && error.code === 'WALLET_PAYMENT_PAYLOAD') throw error; + return paymentPayloadFailure('signed payment failed closed validation'); + } +} + +export function validateWalletIdentity(value) { + let identity; + try { + identity = exactRecord( + value, + ['provider', 'walletId', 'address', 'network'], + [], + 'WALLET_IDENTITY', + 'wallet identity', + ); + } catch (error) { + if (error instanceof KernelError) identityFailure(error.message); + throw error; + } + + let provider; + let walletId; + let normalizedAddress; + try { + provider = canonicalToken(identity.provider, 'wallet provider'); + walletId = canonicalToken(identity.walletId, 'wallet ID'); + normalizedAddress = getAddress(identity.address); + } catch { + return identityFailure('wallet identity contains invalid provider, wallet ID, or address'); + } + if (normalizedAddress.toLowerCase() === ZERO_ADDRESS || identity.network !== BASE_SEPOLIA_CAIP2) { + identityFailure('wallet identity must name one nonzero Base Sepolia account'); + } + return frozenCopy({ + provider, + walletId, + address: normalizedAddress.toLowerCase(), + network: BASE_SEPOLIA_CAIP2, + }); +} + +export class WalletSigningError extends KernelError { + #exactConstruction; + + #stableCode; + + #stableSignatureMayExist; + + constructor(code, message, { signatureMayExist }) { + super(code, message); + this.#exactConstruction = new.target === WalletSigningError; + this.#stableCode = code; + this.#stableSignatureMayExist = signatureMayExist; + this.name = 'WalletSigningError'; + this.signatureMayExist = signatureMayExist; + } + + static isExact(value, code, signatureMayExist) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) return false; + try { + return Object.getPrototypeOf(value) === WalletSigningError.prototype + && value.#exactConstruction + && value.#stableCode === code + && value.#stableSignatureMayExist === signatureMayExist; + } catch { + return false; + } + } +} + +Object.freeze(WalletSigningError); + +export function createDeadlineRunner(options = {}) { + const config = inertRecord( + options, + [], + ['setTimeoutImpl', 'clearTimeoutImpl'], + 'WALLET_DEADLINE_CONFIG', + 'wallet deadline configuration', + ); + const setTimeoutImpl = plainCallable( + Object.hasOwn(config, 'setTimeoutImpl') ? config.setTimeoutImpl : setTimeout, + 'WALLET_DEADLINE_CONFIG', + 'setTimeout implementation', + ); + const clearTimeoutImpl = plainCallable( + Object.hasOwn(config, 'clearTimeoutImpl') ? config.clearTimeoutImpl : clearTimeout, + 'WALLET_DEADLINE_CONFIG', + 'clearTimeout implementation', + ); + + return async function runWithDeadline(value) { + const request = inertRecord( + value, + ['phase', 'timeoutMs', 'operation'], + [], + 'WALLET_DEADLINE_REQUEST', + 'wallet deadline request', + ); + const phase = request.phase; + const timeoutMs = request.timeoutMs; + const operation = plainCallable( + request.operation, + 'WALLET_DEADLINE_REQUEST', + 'wallet deadline operation', + ); + if (!Object.hasOwn(DEADLINE_CODES, phase) + || !Object.hasOwn(OPERATION_FAILURE_CODES, phase)) { + throw new KernelError('WALLET_DEADLINE_PHASE', 'unknown wallet deadline phase'); + } + const timeoutCode = DEADLINE_CODES[phase]; + positiveDeadline(timeoutMs, 'WALLET_DEADLINE_TIMEOUT', 'wallet deadline'); + + let timer; + let deadlineFired = false; + let deadlineError; + const deadline = new Promise((_, reject) => { + const rejectOnce = (error) => { + if (deadlineFired) return; + deadlineFired = true; + deadlineError = error; + reject(error); + }; + const expire = () => { + rejectOnce(new KernelError( + timeoutCode, + `${phase} wallet operation exceeded its deadline`, + )); + }; + try { + timer = setTimeoutImpl(expire, timeoutMs); + } catch { + rejectOnce(new KernelError( + 'WALLET_DEADLINE_SETUP_FAILED', + 'wallet deadline could not be armed', + )); + } + }); + const operationResult = Promise.resolve().then(() => ( + deadlineFired ? new Promise(() => {}) : operation() + )); + + try { + return await Promise.race([operationResult, deadline]); + } catch (error) { + if (error === deadlineError) throw error; + throw new KernelError( + OPERATION_FAILURE_CODES[phase], + `${phase} wallet operation failed`, + ); + } finally { + try { + clearTimeoutImpl(timer); + } catch { + // Timer cleanup cannot be allowed to suppress the operation's settled outcome. + } + } + }; +} + +export async function executeAuthorizedSigning(value) { + let request; + let prepared; + try { + request = inertRecord( + value, + ['prepare', 'invokeSigner', 'finalize'], + ['runWithDeadline', 'preSignTimeoutMs', 'signerTimeoutMs'], + 'WALLET_SIGNING_REQUEST', + 'authorized-signing request', + ); + const prepare = plainCallable(request.prepare, 'WALLET_SIGNING_REQUEST', 'prepare callback'); + plainCallable(request.invokeSigner, 'WALLET_SIGNING_REQUEST', 'signer callback'); + plainCallable(request.finalize, 'WALLET_SIGNING_REQUEST', 'finalize callback'); + const runWithDeadline = plainCallable( + Object.hasOwn(request, 'runWithDeadline') + ? request.runWithDeadline + : createDeadlineRunner(), + 'WALLET_SIGNING_REQUEST', + 'deadline runner', + ); + const preSignTimeoutMs = positiveDeadline( + Object.hasOwn(request, 'preSignTimeoutMs') ? request.preSignTimeoutMs : 5_000, + 'WALLET_SIGNING_REQUEST', + 'pre-sign timeout', + ); + positiveDeadline( + Object.hasOwn(request, 'signerTimeoutMs') ? request.signerTimeoutMs : 15_000, + 'WALLET_SIGNING_REQUEST', + 'signer timeout', + ); + request = Object.freeze({ + ...request, + prepare, + runWithDeadline, + preSignTimeoutMs, + signerTimeoutMs: Object.hasOwn(request, 'signerTimeoutMs') + ? request.signerTimeoutMs + : 15_000, + }); + prepared = await runWithDeadline({ + phase: 'pre-sign', + timeoutMs: preSignTimeoutMs, + operation: prepare, + }); + } catch { + throw new WalletSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'wallet request was rejected before signing', + { signatureMayExist: false }, + ); + } + + try { + let signerEntered = false; + return await request.runWithDeadline({ + phase: 'signer', + timeoutMs: request.signerTimeoutMs, + operation: async () => { + if (signerEntered) { + throw new KernelError( + 'WALLET_SIGNER_REENTRY', + 'wallet signer operation is strictly one-shot', + ); + } + signerEntered = true; + const signature = await request.invokeSigner(prepared); + return await request.finalize(prepared, signature); + }, + }); + } catch { + throw new WalletSigningError( + 'WALLET_SIGNATURE_AMBIGUOUS', + 'wallet signing outcome is ambiguous', + { signatureMayExist: true }, + ); + } +} diff --git a/spikes/pi-wielder/src/adapters/x402-v2-transport.mjs b/spikes/pi-wielder/src/adapters/x402-v2-transport.mjs new file mode 100644 index 0000000..03ace83 --- /dev/null +++ b/spikes/pi-wielder/src/adapters/x402-v2-transport.mjs @@ -0,0 +1,836 @@ +import { types as utilTypes } from 'node:util'; + +import { + decodePaymentRequiredHeader, + decodePaymentResponseHeader, + encodePaymentSignatureHeader, +} from '@x402/core/http'; + +import { + canonicalAtomic, + canonicalEvmHash, + canonicalJson, + frozenCopy, + sha256, +} from '../kernel/canonical.mjs'; +import { + cancelResponseBody, + readBodyBytes, + RuntimeBoundaryError, + withWallClockDeadline, +} from '../runtime-boundaries.mjs'; + +const MODES = new Set(['cdp-testnet', 'deterministic']); +const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/; +const ASCII = /^[\x00-\x7f]*$/; +const HASH = /^sha256:[0-9a-f]{64}$/; +const LOWERCASE_ADDRESS = /^0x[0-9a-f]{40}$/; +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const HTTP_METHOD = /^[A-Z][A-Z0-9-]{0,31}$/; +const PROTOCOL_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/; +const MIME_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/; +const MAX_REQUEST_BODY_BYTES = 1_048_576; +const MAX_HEADER_COUNT = 100; +const MAX_HEADER_VALUE_BYTES = 8_192; +const MAX_TEXT_BYTES = 2_048; +const MAX_ACCEPTS = 100; +const MAX_CLASSIFIER_HEADER_BYTES = 16_384; +const FORBIDDEN_REQUEST_HEADERS = new Set([ + 'authorization', + 'connection', + 'content-length', + 'cookie', + 'cookie2', + 'host', + 'payment-required', + 'payment-response', + 'payment-signature', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'x-payment', + 'x-payment-required', + 'x-payment-response', +]); + +export class X402TransportError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = 'X402TransportError'; + this.code = code; + } +} + +function fail(code, message, cause) { + throw new X402TransportError(code, message, cause === undefined ? undefined : { cause }); +} + +function plainFunction(value, code, label) { + if (typeof value !== 'function' || utilTypes.isProxy(value)) { + fail(code, `${label} must be one non-proxy function`); + } + return value; +} + +function closedRecord(value, required, optional, code, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail(code, `${label} must be one plain object`); + } + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(value); + if (required.some((key) => !Object.hasOwn(value, key)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key))) { + fail(code, `${label} fields do not match the closed schema`); + } + const result = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail(code, `${label} must contain only enumerable data fields`); + } + result[key] = descriptor.value; + } + return result; +} + +function positiveSafeInteger(value, code, label) { + if (!Number.isSafeInteger(value) || value < 1) { + fail(code, `${label} must be one positive safe integer`); + } + return value; +} + +function boundedText(value, code, label, maximum = MAX_TEXT_BYTES) { + if (typeof value !== 'string' || value.length === 0 + || Buffer.byteLength(value, 'utf8') > maximum + || /[\x00-\x1f\x7f]/.test(value)) { + fail(code, `${label} must be one nonempty bounded string`); + } + return value; +} + +function protocolToken(value, code, label) { + if (typeof value !== 'string' || !PROTOCOL_TOKEN.test(value)) { + fail(code, `${label} must be one bounded protocol token`); + } + return value; +} + +function safeFrozenCopy(value, code, label) { + try { + canonicalJson(value); + return frozenCopy(value); + } catch (error) { + fail(code, `${label} must contain only inert canonical data`, error); + } +} + +function canonicalUrl(value, mode, code = 'REQUEST_SCHEMA') { + if (typeof value !== 'string' || value.length === 0 || value.length > 8_192) { + fail(code, 'request URL must be one bounded absolute URL'); + } + let parsed; + try { + parsed = new URL(value); + } catch (error) { + fail(code, 'request URL must be one bounded absolute URL', error); + } + const loopback = parsed.protocol === 'http:' + && (parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]'); + if ((parsed.protocol !== 'https:' && !(mode === 'deterministic' && loopback)) + || parsed.username !== '' + || parsed.password !== '' + || parsed.hash !== '' + || parsed.href !== value) { + fail(code, 'request URL is outside the selected transport mode'); + } + return Object.freeze({ href: parsed.href, origin: parsed.origin }); +} + +function copyBody(value) { + if (utilTypes.isProxy(value)) fail('REQUEST_SCHEMA', 'request body must be inert bytes'); + const buffer = Buffer.isBuffer(value) && Object.getPrototypeOf(value) === Buffer.prototype; + const uint8 = value instanceof Uint8Array + && Object.getPrototypeOf(value) === Uint8Array.prototype; + if ((!buffer && !uint8) || value.buffer instanceof SharedArrayBuffer + || value.byteLength > MAX_REQUEST_BODY_BYTES) { + fail('REQUEST_SCHEMA', 'request body must be bounded inert bytes'); + } + return Buffer.from(value); +} + +function canonicalRequestHeaders(value) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail('REQUEST_SCHEMA', 'request headers must be one plain object'); + } + const keys = Reflect.ownKeys(value); + if (keys.length > MAX_HEADER_COUNT) fail('REQUEST_SCHEMA', 'too many request headers'); + const normalized = new Map(); + for (const key of keys) { + if (typeof key !== 'string' || !HEADER_NAME.test(key)) { + fail('REQUEST_SCHEMA', 'request header name is invalid'); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail('REQUEST_SCHEMA', 'request headers must contain only data fields'); + } + const name = key.toLowerCase(); + if (normalized.has(name) || FORBIDDEN_REQUEST_HEADERS.has(name)) { + fail('REQUEST_SCHEMA', 'request header is duplicate, credential-bearing, or reserved'); + } + const headerValue = descriptor.value; + if (typeof headerValue !== 'string' || headerValue.length === 0 + || Buffer.byteLength(headerValue, 'utf8') > MAX_HEADER_VALUE_BYTES + || /[\x00-\x08\x0a-\x1f\x7f]/.test(headerValue)) { + fail('REQUEST_SCHEMA', 'request header value is invalid'); + } + normalized.set(name, headerValue); + } + return Object.freeze(Object.fromEntries([...normalized.entries()].sort(([left], [right]) => ( + left < right ? -1 : left > right ? 1 : 0 + )))); +} + +function captureRequest(value, mode) { + const request = closedRecord( + value, + ['requestUrl', 'method', 'headers', 'bodyBytes'], + [], + 'REQUEST_SCHEMA', + 'transport request', + ); + if (typeof request.method !== 'string' || !HTTP_METHOD.test(request.method)) { + fail('REQUEST_SCHEMA', 'request method must be one canonical uppercase HTTP token'); + } + const url = canonicalUrl(request.requestUrl, mode); + return Object.freeze({ + requestUrl: url.href, + method: request.method, + headers: canonicalRequestHeaders(request.headers), + bodyBytes: copyBody(request.bodyBytes), + }); +} + +function fetchInit(snapshot, signal, paymentHeader = null) { + const headers = new Headers(snapshot.headers); + if (paymentHeader !== null) headers.set('PAYMENT-SIGNATURE', paymentHeader); + const init = { + method: snapshot.method, + headers, + redirect: 'manual', + credentials: 'omit', + signal, + }; + if (snapshot.method !== 'GET' && snapshot.method !== 'HEAD') { + init.body = Buffer.from(snapshot.bodyBytes); + } else if (snapshot.bodyBytes.byteLength !== 0) { + fail('REQUEST_SCHEMA', `${snapshot.method} requests may not carry a body`); + } + return init; +} + +function assertResponse(value, code) { + if (!(value instanceof Response)) fail(code, 'fetch must return one real Response'); + return value; +} + +function isRedirect(status) { + return status >= 300 && status <= 399; +} + +function headerValue(response, name, maximumBytes, prefix) { + const value = response.headers.get(name); + if (value === null) fail(`${prefix}_MISSING`, `${name} header is required`); + if (typeof value !== 'string') { + fail(`${prefix}_MALFORMED`, `${name} must be one primitive string`); + } + if (value.length > maximumBytes) { + fail(`${prefix}_TOO_LARGE`, `${name} exceeds its byte ceiling`); + } + if (!ASCII.test(value)) { + fail(`${prefix}_MALFORMED`, `${name} must contain only ASCII bytes`); + } + if (Buffer.byteLength(value, 'ascii') > maximumBytes) { + fail(`${prefix}_TOO_LARGE`, `${name} exceeds its byte ceiling`); + } + if (value.includes(',')) fail(`${prefix}_DUPLICATE`, `${name} header must occur exactly once`); + return value; +} + +function base64HeaderStatus(value, maximumBytes) { + if (typeof value !== 'string') return 'invalid'; + if (value.length > maximumBytes) return 'too_large'; + if (!ASCII.test(value)) return 'invalid'; + if (Buffer.byteLength(value, 'ascii') > maximumBytes) return 'too_large'; + if (value.length === 0 || !BASE64.test(value)) return 'invalid'; + try { + return Buffer.from(value, 'base64').toString('base64') === value + ? 'canonical' + : 'invalid'; + } catch { + return 'invalid'; + } +} + +function validateExtra(value, code, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail(code, `${label} must be one plain object`); + } + return safeFrozenCopy(value, code, label); +} + +function validatePaymentRequired(value, expectedUrl, mode) { + const payment = closedRecord( + value, + ['x402Version', 'resource', 'accepts'], + ['error'], + 'PAYMENT_REQUIRED_SCHEMA', + 'PAYMENT-REQUIRED value', + ); + if (payment.x402Version !== 2) { + fail('PAYMENT_REQUIRED_SCHEMA', 'only x402Version 2 is accepted'); + } + if (Object.hasOwn(payment, 'error')) { + boundedText(payment.error, 'PAYMENT_REQUIRED_SCHEMA', 'payment error'); + } + const resource = closedRecord( + payment.resource, + ['url', 'description', 'mimeType'], + [], + 'PAYMENT_REQUIRED_SCHEMA', + 'payment resource', + ); + const resourceUrl = canonicalUrl(resource.url, mode, 'PAYMENT_REQUIRED_SCHEMA'); + boundedText(resource.description, 'PAYMENT_REQUIRED_SCHEMA', 'resource description', 1_024); + boundedText(resource.mimeType, 'PAYMENT_REQUIRED_SCHEMA', 'resource MIME type', 200); + if (!MIME_TYPE.test(resource.mimeType)) { + fail('PAYMENT_REQUIRED_SCHEMA', 'resource MIME type is not canonical'); + } + if (resourceUrl.href !== expectedUrl) { + fail('RESOURCE_URL_MISMATCH', 'payment resource URL differs from the unpaid request'); + } + if (!Array.isArray(payment.accepts) || Object.getPrototypeOf(payment.accepts) !== Array.prototype + || payment.accepts.length < 1 || payment.accepts.length > MAX_ACCEPTS) { + fail('PAYMENT_REQUIRED_SCHEMA', 'accepts must be one nonempty bounded array'); + } + const accepts = payment.accepts.map((candidateValue) => { + const candidate = closedRecord(candidateValue, [ + 'scheme', + 'network', + 'asset', + 'amount', + 'payTo', + 'maxTimeoutSeconds', + 'extra', + ], [], 'PAYMENT_REQUIRED_SCHEMA', 'payment requirement'); + protocolToken(candidate.scheme, 'PAYMENT_REQUIRED_SCHEMA', 'payment requirement scheme'); + protocolToken(candidate.network, 'PAYMENT_REQUIRED_SCHEMA', 'payment requirement network'); + for (const [key, maximum] of [['asset', 1_024], ['payTo', 1_024]]) { + boundedText(candidate[key], 'PAYMENT_REQUIRED_SCHEMA', `payment requirement ${key}`, maximum); + } + if (typeof candidate.amount !== 'string' || candidate.amount.length > 100) { + fail('PAYMENT_REQUIRED_SCHEMA', 'payment requirement amount must be bounded text'); + } + let amount; + try { + amount = canonicalAtomic(candidate.amount, 'payment requirement amount'); + } catch (error) { + fail('PAYMENT_REQUIRED_SCHEMA', 'payment requirement amount must be canonical atomic text', error); + } + if (amount.value <= 0n) { + fail('PAYMENT_REQUIRED_SCHEMA', 'payment requirement amount must be positive and bounded'); + } + positiveSafeInteger( + candidate.maxTimeoutSeconds, + 'PAYMENT_REQUIRED_SCHEMA', + 'payment requirement maxTimeoutSeconds', + ); + return { + scheme: candidate.scheme, + network: candidate.network, + asset: candidate.asset, + amount: amount.text, + payTo: candidate.payTo, + maxTimeoutSeconds: candidate.maxTimeoutSeconds, + extra: validateExtra(candidate.extra, 'PAYMENT_REQUIRED_SCHEMA', 'payment requirement extra'), + }; + }); + return frozenCopy({ + x402Version: 2, + ...(Object.hasOwn(payment, 'error') ? { error: payment.error } : {}), + resource: { + url: resourceUrl.href, + description: resource.description, + mimeType: resource.mimeType, + }, + accepts, + }); +} + +function decodeChallenge(rawHeader, expectedUrl, mode, maximumBytes) { + const status = base64HeaderStatus(rawHeader, maximumBytes); + if (status === 'too_large') { + fail('PAYMENT_REQUIRED_TOO_LARGE', 'PAYMENT-REQUIRED exceeds its byte ceiling'); + } + if (status !== 'canonical') { + fail('PAYMENT_REQUIRED_MALFORMED', 'PAYMENT-REQUIRED is not canonical base64 JSON'); + } + let decoded; + try { + decoded = decodePaymentRequiredHeader(rawHeader); + } catch (error) { + fail('PAYMENT_REQUIRED_MALFORMED', 'PAYMENT-REQUIRED is not canonical base64 JSON', error); + } + return validatePaymentRequired(decoded, expectedUrl, mode); +} + +function validateBinding(value) { + const binding = closedRecord(value, [ + 'network', + 'walletAddress', + 'amountAtomic', + 'paymentHash', + ], [], 'SETTLEMENT_BINDING', 'settlement binding'); + protocolToken(binding.network, 'SETTLEMENT_BINDING', 'settlement network'); + if (typeof binding.walletAddress !== 'string' + || !LOWERCASE_ADDRESS.test(binding.walletAddress)) { + fail('SETTLEMENT_BINDING', 'settlement wallet must be one canonical lowercase EVM address'); + } + if (typeof binding.amountAtomic !== 'string' || binding.amountAtomic.length > 100) { + fail('SETTLEMENT_BINDING', 'settlement amount must be bounded canonical text'); + } + let amount; + try { + amount = canonicalAtomic(binding.amountAtomic, 'settlement amount'); + } catch (error) { + fail('SETTLEMENT_BINDING', 'settlement amount must be canonical atomic text', error); + } + if (amount.value <= 0n || typeof binding.paymentHash !== 'string' + || !HASH.test(binding.paymentHash)) { + fail('SETTLEMENT_BINDING', 'settlement binding amount or payment hash is invalid'); + } + return Object.freeze({ + network: binding.network, + walletAddress: binding.walletAddress, + amountAtomic: amount.text, + paymentHash: binding.paymentHash, + }); +} + +function unresolved(reasonCode) { + return Object.freeze({ kind: 'unresolved', reasonCode }); +} + +function normalizePayer(value) { + if (typeof value !== 'string' || !ADDRESS.test(value)) return null; + return value.toLowerCase(); +} + +export function classifyX402PaymentResponse(value) { + let input; + let binding; + try { + input = closedRecord( + value, + ['rawHeader', 'decoded', 'binding'], + [], + 'SETTLEMENT_CLASSIFIER', + 'settlement classifier input', + ); + binding = validateBinding(input.binding); + } catch { + return unresolved('SETTLEMENT_BINDING_INVALID'); + } + if (base64HeaderStatus(input.rawHeader, MAX_CLASSIFIER_HEADER_BYTES) !== 'canonical') { + return unresolved('SETTLEMENT_HEADER_INVALID'); + } + let headerDecoded; + try { + headerDecoded = decodePaymentResponseHeader(input.rawHeader); + } catch { + return unresolved('SETTLEMENT_HEADER_INVALID'); + } + try { + if (canonicalJson(headerDecoded) !== canonicalJson(input.decoded)) { + return unresolved('SETTLEMENT_DECODE_MISMATCH'); + } + } catch { + return unresolved('SETTLEMENT_SCHEMA_INVALID'); + } + + let decoded; + try { + decoded = closedRecord(input.decoded, [ + 'success', + 'transaction', + 'network', + ], [ + 'payer', + 'amount', + 'errorReason', + 'errorMessage', + 'extensions', + 'extra', + ], 'SETTLEMENT_SCHEMA', 'PAYMENT-RESPONSE value'); + } catch { + return unresolved('SETTLEMENT_SCHEMA_INVALID'); + } + if (typeof decoded.success !== 'boolean' + || typeof decoded.transaction !== 'string' + || typeof decoded.network !== 'string' + || decoded.network.length === 0 + || Buffer.byteLength(decoded.network, 'utf8') > 200) { + return unresolved('SETTLEMENT_SCHEMA_INVALID'); + } + for (const field of ['payer', 'amount', 'errorReason', 'errorMessage']) { + if (Object.hasOwn(decoded, field) && typeof decoded[field] !== 'string') { + return unresolved('SETTLEMENT_SCHEMA_INVALID'); + } + } + for (const field of ['extensions', 'extra']) { + if (Object.hasOwn(decoded, field)) { + try { + validateExtra(decoded[field], 'SETTLEMENT_SCHEMA', `settlement ${field}`); + } catch { + return unresolved('SETTLEMENT_SCHEMA_INVALID'); + } + } + } + if (!decoded.success) return unresolved('SETTLEMENT_REPORTED_FAILURE'); + if (Object.hasOwn(decoded, 'errorReason') || Object.hasOwn(decoded, 'errorMessage')) { + return unresolved('SETTLEMENT_SUCCESS_HAS_ERROR'); + } + if (decoded.network !== binding.network) return unresolved('SETTLEMENT_NETWORK_MISMATCH'); + if (!Object.hasOwn(decoded, 'payer')) return unresolved('SETTLEMENT_PAYER_MISSING'); + const payer = normalizePayer(decoded.payer); + if (payer === null) return unresolved('SETTLEMENT_PAYER_INVALID'); + if (payer !== binding.walletAddress) return unresolved('SETTLEMENT_PAYER_MISMATCH'); + let transaction; + try { + transaction = canonicalEvmHash(decoded.transaction, 'settlement transaction'); + } catch { + return unresolved('SETTLEMENT_TRANSACTION_INVALID'); + } + let amountAtomic; + if (Object.hasOwn(decoded, 'amount')) { + try { + amountAtomic = canonicalAtomic(decoded.amount, 'settlement amount').text; + } catch { + return unresolved('SETTLEMENT_AMOUNT_INVALID'); + } + if (amountAtomic !== binding.amountAtomic) return unresolved('SETTLEMENT_AMOUNT_MISMATCH'); + } + const settlement = frozenCopy({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from(input.rawHeader, 'ascii')), + success: true, + transaction, + network: decoded.network, + payer, + ...(amountAtomic === undefined ? {} : { amountAtomic }), + paymentHash: binding.paymentHash, + }); + return Object.freeze({ kind: 'settled', settlement }); +} + +function ambiguous(reasonCode) { + return Object.freeze({ kind: 'paid_response_ambiguous', reasonCode }); +} + +function settledResult(settlement, status, body, executionState, deliveryReason) { + return Object.freeze({ + kind: 'settled_response', + settlement, + status, + body, + executionState, + ...(deliveryReason === undefined ? {} : { deliveryReason }), + }); +} + +function decodeSettlementHeader(response, maximumBytes, binding) { + let rawHeader; + try { + rawHeader = headerValue( + response, + 'PAYMENT-RESPONSE', + maximumBytes, + 'PAYMENT_RESPONSE', + ); + } catch (error) { + if (error instanceof X402TransportError) return ambiguous(error.code); + return ambiguous('PAYMENT_RESPONSE_MALFORMED'); + } + const status = base64HeaderStatus(rawHeader, maximumBytes); + if (status === 'too_large') return ambiguous('PAYMENT_RESPONSE_TOO_LARGE'); + if (status !== 'canonical') return ambiguous('PAYMENT_RESPONSE_MALFORMED'); + let decoded; + try { + decoded = decodePaymentResponseHeader(rawHeader); + } catch { + return ambiguous('PAYMENT_RESPONSE_MALFORMED'); + } + const classified = classifyX402PaymentResponse({ rawHeader, decoded, binding }); + return classified.kind === 'settled' + ? classified + : ambiguous(classified.reasonCode); +} + +function boundaryOptions(requestTimeoutMs, prefix) { + return { + timeoutMs: requestTimeoutMs, + timeoutCode: `${prefix}_TIMEOUT`, + timeoutMessage: `${prefix.toLowerCase().replaceAll('_', ' ')} timed out`, + abortedCode: `${prefix}_ABORTED`, + abortedMessage: `${prefix.toLowerCase().replaceAll('_', ' ')} was aborted`, + }; +} + +function bodyOptions(maximumResponseBytes, signal) { + return { + maxBytes: maximumResponseBytes, + tooLargeCode: 'BODY_TOO_LARGE', + tooLargeMessage: 'response body exceeds its byte ceiling', + readErrorCode: 'BODY_READ_FAILED', + readErrorMessage: 'response body could not be delivered', + signal, + }; +} + +function mapUnpaidFailure(error) { + if (error instanceof X402TransportError) return error; + if (error instanceof RuntimeBoundaryError) { + return new X402TransportError(error.code, error.message, { cause: error }); + } + return new X402TransportError('UNPAID_FETCH_FAILED', 'unpaid request failed', { cause: error }); +} + +function mapPaidFailure(error, trustedSettlement, status) { + if (trustedSettlement !== null && status >= 200 && status <= 299) { + if (error instanceof RuntimeBoundaryError && error.code === 'PAID_RESPONSE_TIMEOUT') { + return settledResult(trustedSettlement, status, null, 'unknown', 'BODY_TIMEOUT'); + } + if (error instanceof RuntimeBoundaryError && error.code === 'BODY_TOO_LARGE') { + return settledResult(trustedSettlement, status, null, 'unknown', 'BODY_TOO_LARGE'); + } + return settledResult(trustedSettlement, status, null, 'unknown', 'BODY_READ_FAILED'); + } + if (error instanceof RuntimeBoundaryError && error.code === 'PAID_RESPONSE_TIMEOUT') { + return ambiguous('PAID_RESPONSE_TIMEOUT'); + } + return ambiguous('PAID_FETCH_FAILED'); +} + +export function createX402V2Transport(value) { + const config = closedRecord( + value, + ['fetchImpl', 'mode', 'limits'], + [], + 'TRANSPORT_CONFIG', + 'x402 transport configuration', + ); + const fetchImpl = plainFunction(config.fetchImpl, 'TRANSPORT_CONFIG', 'fetchImpl'); + if (!MODES.has(config.mode)) fail('TRANSPORT_CONFIG', 'transport mode is unsupported'); + const rawLimits = closedRecord(config.limits, [ + 'requestTimeoutMs', + 'maximumResponseBytes', + 'maximumPaymentHeaderBytes', + ], [], 'TRANSPORT_CONFIG', 'x402 transport limits'); + const limits = Object.freeze({ + requestTimeoutMs: positiveSafeInteger( + rawLimits.requestTimeoutMs, + 'TRANSPORT_CONFIG', + 'requestTimeoutMs', + ), + maximumResponseBytes: positiveSafeInteger( + rawLimits.maximumResponseBytes, + 'TRANSPORT_CONFIG', + 'maximumResponseBytes', + ), + maximumPaymentHeaderBytes: positiveSafeInteger( + rawLimits.maximumPaymentHeaderBytes, + 'TRANSPORT_CONFIG', + 'maximumPaymentHeaderBytes', + ), + }); + const requests = new WeakMap(); + + const probe = async (request) => { + if (!request || typeof request !== 'object' || utilTypes.isProxy(request)) { + fail('REQUEST_SCHEMA', 'transport request must be one plain object'); + } + const previous = requests.get(request); + if (previous?.phase === 'probing' || previous?.phase === 'retrying') { + fail('REQUEST_IN_FLIGHT', 'request already has a transport call in flight'); + } + const snapshot = captureRequest(request, config.mode); + const state = { snapshot, phase: 'probing' }; + requests.set(request, state); + try { + const result = await withWallClockDeadline( + boundaryOptions(limits.requestTimeoutMs, 'UNPAID_RESPONSE'), + async (signal) => { + const response = assertResponse( + await fetchImpl(snapshot.requestUrl, fetchInit(snapshot, signal)), + 'UNPAID_RESPONSE_SHAPE', + ); + if (isRedirect(response.status)) { + cancelResponseBody(response, new Error('redirect forbidden')); + fail('REDIRECT_FORBIDDEN', 'unpaid redirect responses are forbidden'); + } + if (response.status === 402) { + let rawHeader; + try { + rawHeader = headerValue( + response, + 'PAYMENT-REQUIRED', + limits.maximumPaymentHeaderBytes, + 'PAYMENT_REQUIRED', + ); + } catch (error) { + cancelResponseBody(response, error); + throw error; + } + await readBodyBytes(response, { + ...bodyOptions(limits.maximumResponseBytes, signal), + tooLargeCode: 'RESPONSE_TOO_LARGE', + tooLargeMessage: '402 response body exceeds its byte ceiling', + readErrorCode: 'RESPONSE_READ_FAILED', + readErrorMessage: '402 response body could not be read', + }); + const paymentRequired = decodeChallenge( + rawHeader, + snapshot.requestUrl, + config.mode, + limits.maximumPaymentHeaderBytes, + ); + return Object.freeze({ kind: 'payment_required', paymentRequired }); + } + const body = await readBodyBytes( + response, + bodyOptions(limits.maximumResponseBytes, signal), + ); + return Object.freeze({ kind: 'response', status: response.status, body }); + }, + ); + state.phase = result.kind === 'payment_required' ? 'challenged' : 'complete'; + return result; + } catch (error) { + state.phase = 'failed'; + throw mapUnpaidFailure(error); + } + }; + + const encodePayment = (paymentPayload) => { + try { + canonicalJson(paymentPayload); + } catch (error) { + fail('PAYMENT_PAYLOAD_SCHEMA', 'payment payload must be inert canonical data', error); + } + let encoded; + try { + encoded = encodePaymentSignatureHeader(paymentPayload); + } catch (error) { + fail('PAYMENT_PAYLOAD_SCHEMA', 'payment payload cannot be encoded', error); + } + const status = base64HeaderStatus(encoded, limits.maximumPaymentHeaderBytes); + if (status === 'too_large') { + fail('PAYMENT_SIGNATURE_TOO_LARGE', 'PAYMENT-SIGNATURE exceeds byte ceiling'); + } + if (status !== 'canonical') { + fail('PAYMENT_PAYLOAD_SCHEMA', 'official payment codec produced a noncanonical header'); + } + return encoded; + }; + + const retryPaid = async (valueForRetry) => { + const retry = closedRecord( + valueForRetry, + ['request', 'paymentHeader', 'binding'], + [], + 'PAID_RETRY_SCHEMA', + 'paid retry request', + ); + const state = retry.request && typeof retry.request === 'object' + ? requests.get(retry.request) + : undefined; + if (!state) fail('REQUEST_NOT_PROBED', 'paid retry requires the exact probed request'); + if (state.phase === 'retrying' || state.phase === 'retried') { + fail('REQUEST_ALREADY_RETRIED', 'paid retry already ran'); + } + if (state.phase !== 'challenged') { + fail('REQUEST_NOT_PROBED', 'paid retry requires a successful payment challenge'); + } + if (base64HeaderStatus( + retry.paymentHeader, + limits.maximumPaymentHeaderBytes, + ) !== 'canonical') { + fail('PAYMENT_HEADER_SCHEMA', 'paid retry header must be bounded canonical base64'); + } + const normalizedBinding = validateBinding(retry.binding); + if (sha256(Buffer.from(retry.paymentHeader, 'ascii')) !== normalizedBinding.paymentHash) { + fail('PAYMENT_HASH_MISMATCH', 'paid retry header differs from the persisted payment hash'); + } + state.phase = 'retrying'; + let trustedSettlement = null; + let status = null; + try { + const result = await withWallClockDeadline( + boundaryOptions(limits.requestTimeoutMs, 'PAID_RESPONSE'), + async (signal) => { + const response = assertResponse( + await fetchImpl( + state.snapshot.requestUrl, + fetchInit(state.snapshot, signal, retry.paymentHeader), + ), + 'PAID_RESPONSE_SHAPE', + ); + status = response.status; + if (response.status === 402) { + cancelResponseBody(response, new Error('second 402 retains the payment hold')); + return ambiguous('SECOND_PAYMENT_REQUIRED'); + } + const classified = decodeSettlementHeader( + response, + limits.maximumPaymentHeaderBytes, + normalizedBinding, + ); + if (classified.kind !== 'settled') { + cancelResponseBody(response, new Error(classified.reasonCode)); + return classified; + } + trustedSettlement = classified.settlement; + if (response.status < 200 || response.status > 299) { + cancelResponseBody(response, new Error('settled non-2xx response')); + return settledResult( + trustedSettlement, + response.status, + null, + 'failed', + 'HTTP_STATUS_FAILURE', + ); + } + const body = await readBodyBytes( + response, + bodyOptions(limits.maximumResponseBytes, signal), + ); + return settledResult(trustedSettlement, response.status, body, 'succeeded'); + }, + ); + state.phase = 'retried'; + return result; + } catch (error) { + state.phase = 'retried'; + return mapPaidFailure(error, trustedSettlement, status); + } + }; + + return Object.freeze({ probe, encodePayment, retryPaid }); +} diff --git a/spikes/pi-wielder/src/agent/auth.mjs b/spikes/pi-wielder/src/agent/auth.mjs new file mode 100644 index 0000000..285b870 --- /dev/null +++ b/spikes/pi-wielder/src/agent/auth.mjs @@ -0,0 +1,413 @@ +import crypto from 'node:crypto'; +import { types as utilTypes } from 'node:util'; + +import { + canonicalJson, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from '../kernel/canonical.mjs'; +import { validatePolicyDocument } from '../kernel/policy-engine.mjs'; + +const INSTANCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const FORWARDED_HEADERS = Object.freeze([ + 'forwarded', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', +]); +const OPTION_FIELDS = Object.freeze([ + 'store', + 'intents', + 'walletIdentity', + 'activePolicy', + 'kernelUid', + 'kernelGid', + 'expectedAgentUid', + 'expectedAgentGid', + 'mode', +]); + +const ACTIVE_SQL = `SELECT agent_instance_id, credential_digest, enrollment_hash, + agent_uid, agent_gid, state + FROM agent_enrollments + WHERE state = 'active' + ORDER BY agent_instance_id`; +const BINDING_SQL = `SELECT agent_session_bindings.id AS binding_id, + agent_session_bindings.agent_instance_id, + agent_session_bindings.credential_digest, + agent_session_bindings.enrollment_hash, + agent_session_bindings.session_id, + agent_session_bindings.state AS binding_state, + spend_sessions.state AS session_state + FROM agent_session_bindings + JOIN spend_sessions ON spend_sessions.id = agent_session_bindings.session_id + WHERE agent_session_bindings.enrollment_hash = ? + AND agent_session_bindings.state = 'open' + AND spend_sessions.state IN ('open','policy_blocked') + ORDER BY agent_session_bindings.id`; + +function fail(code, message) { + throw new KernelError(code, message); +} + +function unauthorized() { + fail('AGENT_UNAUTHORIZED', 'Agent authentication failed'); +} + +function exactOptions(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError('agent auth options must be one plain object'); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== OPTION_FIELDS.length + || OPTION_FIELDS.some((field) => !Object.hasOwn(value, field)) + || keys.some((key) => typeof key !== 'string' || !OPTION_FIELDS.includes(key))) { + throw new TypeError('agent auth options must contain the exact fields'); + } + const result = {}; + for (const field of OPTION_FIELDS) { + const descriptor = Object.getOwnPropertyDescriptor(value, field); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError('agent auth options must contain only data fields'); + } + result[field] = descriptor.value; + } + return result; +} + +function captureMethod(value, name, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) { + throw new TypeError(`${label} is invalid`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor || !Object.hasOwn(descriptor, 'value') + || typeof descriptor.value !== 'function' || utilTypes.isProxy(descriptor.value)) { + throw new TypeError(`${label} must expose ${name} as one data method`); + } + return (...args) => Reflect.apply(descriptor.value, value, args); +} + +function identity(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + fail('AGENT_IDENTITY', `${label} must be one positive safe integer`); + } + return value; +} + +function identityText(value, label) { + if (typeof value !== 'string' || !/^[1-9][0-9]*$/.test(value) + || !Number.isSafeInteger(Number(value)) || String(Number(value)) !== value) { + fail('AGENT_AUTHORITY_CORRUPTION', `${label} is invalid`); + } + return value; +} + +function hash(value, label) { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + fail('AGENT_AUTHORITY_CORRUPTION', `${label} is invalid`); + } + return value; +} + +function instanceId(value) { + if (typeof value !== 'string' || !INSTANCE_PATTERN.test(value)) { + fail('AGENT_AUTHORITY_CORRUPTION', 'agent instance ID is invalid'); + } + const bytes = Buffer.from(value, 'base64url'); + if (bytes.length !== 16 || bytes.toString('base64url') !== value) { + bytes.fill(0); + fail('AGENT_AUTHORITY_CORRUPTION', 'agent instance ID is invalid'); + } + bytes.fill(0); + return value; +} + +function exactStoreRow(value, fields, code, label) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value)) { + fail(code, `${label} must be one database row`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== null && prototype !== Object.prototype) { + fail(code, `${label} must be one database row`); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(value); + if (keys.length !== fields.length + || fields.some((field) => !Object.hasOwn(descriptors, field)) + || keys.some((key) => typeof key !== 'string' || !fields.includes(key)) + || keys.some((key) => !descriptors[key]?.enumerable + || !Object.hasOwn(descriptors[key], 'value'))) { + fail(code, `${label} fields do not match the closed database schema`); + } + return exactRecord( + Object.fromEntries(fields.map((field) => [field, descriptors[field].value])), + fields, + [], + code, + label, + ); +} + +function activeEnrollmentRow(value) { + let row; + try { + row = exactStoreRow(value, [ + 'agent_instance_id', + 'credential_digest', + 'enrollment_hash', + 'agent_uid', + 'agent_gid', + 'state', + ], 'AGENT_AUTHORITY_CORRUPTION', 'active enrollment row'); + } catch (error) { + if (error instanceof KernelError) throw error; + fail('AGENT_AUTHORITY_CORRUPTION', 'active enrollment row is invalid'); + } + if (row.state !== 'active') fail('AGENT_AUTHORITY_CORRUPTION', 'active enrollment state changed'); + const enrollment = { + agentInstanceId: instanceId(row.agent_instance_id), + credentialDigest: hash(row.credential_digest, 'credential digest'), + enrollmentHash: hash(row.enrollment_hash, 'enrollment hash'), + agentUid: identityText(row.agent_uid, 'agent UID'), + agentGid: identityText(row.agent_gid, 'agent GID'), + }; + const expectedEnrollmentHash = sha256(canonicalJson({ + schemaVersion: 1, + agentInstanceId: enrollment.agentInstanceId, + credentialDigest: enrollment.credentialDigest, + agentUid: enrollment.agentUid, + agentGid: enrollment.agentGid, + })); + if (expectedEnrollmentHash !== enrollment.enrollmentHash) { + fail('AGENT_AUTHORITY_CORRUPTION', 'active enrollment binding changed'); + } + return frozenCopy(enrollment); +} + +function readActive(readAll) { + let rows; + try { + rows = readAll(ACTIVE_SQL); + } catch (error) { + if (error instanceof KernelError) throw error; + fail('AGENT_AUTHORITY_UNAVAILABLE', 'Agent enrollment authority is unavailable'); + } + if (!Array.isArray(rows)) fail('AGENT_AUTHORITY_CORRUPTION', 'active enrollment query is invalid'); + if (rows.length > 1) { + fail('AGENT_ENROLLMENT_AMBIGUOUS', 'Multiple active agent enrollments exist'); + } + return rows.length === 0 ? null : activeEnrollmentRow(rows[0]); +} + +function assertConfiguredEnrollment(enrollment, expectedAgentUid, expectedAgentGid) { + if (enrollment !== null + && (enrollment.agentUid !== String(expectedAgentUid) + || enrollment.agentGid !== String(expectedAgentGid))) { + fail('AGENT_IDENTITY_MISMATCH', 'Active enrollment differs from configured agent identity'); + } +} + +function persistedDigestBytes(value) { + return Buffer.from(value.slice('sha256:'.length), 'hex'); +} + +function credentialDigest(value) { + const decoded = Buffer.from(value, 'base64url'); + try { + if (decoded.length !== 32 || decoded.toString('base64url') !== value) return null; + return crypto.createHash('sha256').update(decoded).digest(); + } finally { + decoded.fill(0); + } +} + +function rejectChannelCredentials(request) { + if (request.headers.has('cookie') + || FORWARDED_HEADERS.some((name) => request.headers.has(name))) unauthorized(); + let parsed; + try { + parsed = new URL(request.url); + } catch { + unauthorized(); + } + if (parsed.search !== '' || parsed.hash !== '') unauthorized(); +} + +function validateWalletIdentity(value) { + const identityValue = exactRecord( + value, + ['network', 'address'], + [], + 'AGENT_CONFIGURATION', + 'wallet identity', + ); + if (typeof identityValue.network !== 'string' + || typeof identityValue.address !== 'string' + || !ADDRESS_PATTERN.test(identityValue.address)) { + fail('AGENT_CONFIGURATION', 'wallet identity is invalid'); + } + return frozenCopy(identityValue); +} + +function validateActivePolicy(value, walletIdentity) { + const version = exactRecord( + value, + ['id', 'hash', 'policy'], + [], + 'AGENT_CONFIGURATION', + 'active PolicyVersion', + ); + let policy; + try { + policy = validatePolicyDocument(version.policy); + } catch { + fail('AGENT_CONFIGURATION', 'active PolicyVersion is invalid'); + } + if (typeof version.id !== 'string' || version.id.length === 0 + || !HASH_PATTERN.test(version.hash) + || canonicalJson(version.policy) !== canonicalJson(policy) + || sha256(canonicalJson(policy)) !== version.hash + || policy.network !== walletIdentity.network + || policy.wallet !== walletIdentity.address) { + fail('AGENT_CONFIGURATION', 'active PolicyVersion authority does not match the wallet'); + } + return frozenCopy({ id: version.id, hash: version.hash, policy }); +} + +function validatePrincipal(value) { + return frozenCopy(exactRecord(value, [ + 'agentInstanceId', + 'credentialDigest', + 'enrollmentHash', + 'agentUid', + 'agentGid', + ], [], 'AGENT_UNAUTHORIZED', 'authenticated agent')); +} + +function validateBinding(value, enrollment) { + const row = exactStoreRow(value, [ + 'binding_id', + 'agent_instance_id', + 'credential_digest', + 'enrollment_hash', + 'session_id', + 'binding_state', + 'session_state', + ], 'SESSION_AUTHORITY_AMBIGUOUS', 'agent session binding'); + if (row.binding_state !== 'open' + || (row.session_state !== 'open' && row.session_state !== 'policy_blocked')) { + fail('AGENT_SESSION_UNAVAILABLE', 'Agent has no active Spend Session'); + } + if (row.agent_instance_id !== enrollment.agentInstanceId + || row.credential_digest !== enrollment.credentialDigest + || row.enrollment_hash !== enrollment.enrollmentHash + || typeof row.binding_id !== 'string' || row.binding_id.length === 0 + || typeof row.session_id !== 'string' || row.session_id.length === 0) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'Agent Spend Session binding is ambiguous'); + } + return row; +} + +export function createAgentAuth(options) { + const captured = exactOptions(options); + const readAll = captureMethod(captured.store, 'readAll', 'agent auth store'); + const getSession = captureMethod(captured.intents, 'getSession', 'intent repository'); + const kernelUid = identity(captured.kernelUid, 'kernel UID'); + const kernelGid = identity(captured.kernelGid, 'kernel GID'); + const expectedAgentUid = identity(captured.expectedAgentUid, 'agent UID'); + const expectedAgentGid = identity(captured.expectedAgentGid, 'agent GID'); + if (captured.mode !== 'deterministic' && captured.mode !== 'cdp-testnet') { + fail('AGENT_CONFIGURATION', 'agent authentication mode is invalid'); + } + if (captured.mode === 'cdp-testnet' && expectedAgentUid === kernelUid) { + fail('AGENT_IDENTITY_NOT_ISOLATED', 'live agent UID must differ from kernel UID'); + } + if (captured.mode === 'deterministic' + && (expectedAgentUid !== kernelUid || expectedAgentGid !== kernelGid)) { + fail('AGENT_DETERMINISTIC_FIXTURE', 'deterministic auth requires one same-identity fixture'); + } + const walletIdentity = validateWalletIdentity(captured.walletIdentity); + validateActivePolicy(captured.activePolicy, walletIdentity); + assertConfiguredEnrollment(readActive(readAll), expectedAgentUid, expectedAgentGid); + + function authenticate(request) { + if (!(request instanceof Request)) unauthorized(); + rejectChannelCredentials(request); + const enrollment = readActive(readAll); + if (enrollment === null) { + fail('AGENT_ENROLLMENT_REQUIRED', 'An active agent enrollment is required'); + } + assertConfiguredEnrollment(enrollment, expectedAgentUid, expectedAgentGid); + const authorization = request.headers.get('authorization'); + const match = typeof authorization === 'string' + ? /^WalletKernelAgent ([A-Za-z0-9_-]{43})$/.exec(authorization) + : null; + const actual = match ? credentialDigest(match[1]) : null; + const expected = persistedDigestBytes(enrollment.credentialDigest); + const candidate = actual ?? Buffer.alloc(32); + let valid = false; + try { + valid = crypto.timingSafeEqual(candidate, expected); + } finally { + candidate.fill(0); + expected.fill(0); + } + if (!match || actual === null || !valid) unauthorized(); + return enrollment; + } + + function resolveBoundSession(value) { + const principal = validatePrincipal(value); + const enrollment = readActive(readAll); + if (enrollment === null) { + fail('AGENT_ENROLLMENT_REQUIRED', 'An active agent enrollment is required'); + } + assertConfiguredEnrollment(enrollment, expectedAgentUid, expectedAgentGid); + if (canonicalJson(principal) !== canonicalJson(enrollment)) unauthorized(); + let rows; + try { + rows = readAll(BINDING_SQL, [enrollment.enrollmentHash]); + } catch (error) { + if (error instanceof KernelError) throw error; + fail('AGENT_AUTHORITY_UNAVAILABLE', 'Agent session authority is unavailable'); + } + if (!Array.isArray(rows)) fail('SESSION_AUTHORITY_AMBIGUOUS', 'Agent session query is invalid'); + if (rows.length === 0) fail('AGENT_SESSION_UNAVAILABLE', 'Agent has no active Spend Session'); + if (rows.length !== 1) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'Agent has multiple active Spend Sessions'); + } + const binding = validateBinding(rows[0], enrollment); + let session; + try { + session = getSession(binding.session_id); + } catch (error) { + if (error instanceof KernelError) throw error; + fail('AGENT_AUTHORITY_UNAVAILABLE', 'Agent Spend Session is unavailable'); + } + if (!session || typeof session !== 'object' + || session.id !== binding.session_id + || session.agentInstanceId !== enrollment.agentInstanceId + || session.enrollmentHash !== enrollment.enrollmentHash + || session.walletAddress !== walletIdentity.address + || session.state !== binding.session_state) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'Agent Spend Session authority changed'); + } + if (session.state === 'policy_blocked') { + fail('POLICY_TRANSITION_REQUIRED', 'Agent Spend Session requires a guarded policy transition'); + } + // The repository lookup is the dynamic authority boundary: it validates an + // open session against the currently active PolicyVersion. Capturing the + // constructor-time policy ID here would strand a safely transitioned + // replacement until process restart. + return frozenCopy(session); + } + + return Object.freeze({ authenticate, resolveBoundSession }); +} diff --git a/spikes/pi-wielder/src/agent/credential-cli.mjs b/spikes/pi-wielder/src/agent/credential-cli.mjs new file mode 100644 index 0000000..9f271eb --- /dev/null +++ b/spikes/pi-wielder/src/agent/credential-cli.mjs @@ -0,0 +1,106 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { types as utilTypes } from 'node:util'; + +import { + createAgentEnrollmentDescriptor, + loadOrCreateAgentCredential, + publishAgentEnrollmentDescriptor, +} from './credential.mjs'; + +const DESCRIPTOR_HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; + +function fail(code, message) { + const error = new Error(message); + error.code = code; + throw error; +} + +function plainRecord(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail('AGENT_CREDENTIAL_CLI_USAGE', `${label} must be one plain object`); + } + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (typeof key !== 'string' || !descriptor?.enumerable + || !Object.hasOwn(descriptor, 'value')) { + fail('AGENT_CREDENTIAL_CLI_USAGE', `${label} must contain only data fields`); + } + } + return value; +} + +function parseArguments(argv) { + if (!Array.isArray(argv) || argv.length !== 5 + || argv[0] !== 'init' + || argv[1] !== '--credential' + || argv[3] !== '--enrollment' + || typeof argv[2] !== 'string' + || typeof argv[4] !== 'string' + || argv[2].length === 0 + || argv[4].length === 0) { + fail( + 'AGENT_CREDENTIAL_CLI_USAGE', + 'usage: credential-cli.mjs init --credential FILE --enrollment FILE', + ); + } + return Object.freeze({ + credentialPath: argv[2], + enrollmentPath: argv[4], + }); +} + +export function runAgentCredentialCli(value) { + const options = plainRecord(value, 'credential CLI options'); + const keys = Reflect.ownKeys(options); + if (!Object.hasOwn(options, 'argv') + || keys.some((key) => !['argv', 'writeStdout', 'dependencies'].includes(key))) { + fail('AGENT_CREDENTIAL_CLI_USAGE', 'credential CLI options are invalid'); + } + const parsed = parseArguments(options.argv); + const writeStdout = options.writeStdout ?? ((bytes) => process.stdout.write(bytes)); + if (typeof writeStdout !== 'function' || utilTypes.isProxy(writeStdout)) { + fail('AGENT_CREDENTIAL_CLI_USAGE', 'stdout writer must be one function'); + } + const dependencies = plainRecord(options.dependencies ?? {}, 'credential CLI dependencies'); + if (Reflect.ownKeys(dependencies).some( + (key) => !['pathTrust', 'randomBytes'].includes(key), + )) { + fail('AGENT_CREDENTIAL_CLI_USAGE', 'credential CLI dependencies are invalid'); + } + + const credentialOptions = { filePath: parsed.credentialPath }; + if (Object.hasOwn(dependencies, 'pathTrust')) { + credentialOptions.pathTrust = dependencies.pathTrust; + } + if (Object.hasOwn(dependencies, 'randomBytes')) { + credentialOptions.randomBytes = dependencies.randomBytes; + } + const credential = loadOrCreateAgentCredential(credentialOptions); + const descriptor = createAgentEnrollmentDescriptor({ credential }); + const publicationOptions = { + filePath: parsed.enrollmentPath, + credentialPath: parsed.credentialPath, + descriptor, + }; + if (Object.hasOwn(dependencies, 'pathTrust')) { + publicationOptions.pathTrust = dependencies.pathTrust; + } + const publication = publishAgentEnrollmentDescriptor(publicationOptions); + if (!DESCRIPTOR_HASH_PATTERN.test(publication.descriptorHash)) { + fail('AGENT_DESCRIPTOR_HASH', 'descriptor publication returned an invalid hash'); + } + writeStdout(`${publication.descriptorHash}\n`); + return 0; +} + +if (process.argv[1] + && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + try { + runAgentCredentialCli({ argv: process.argv.slice(2) }); + } catch (error) { + process.stderr.write(`${typeof error?.code === 'string' ? error.code : 'AGENT_CREDENTIAL_INIT_FAILED'}\n`); + process.exitCode = 1; + } +} diff --git a/spikes/pi-wielder/src/agent/credential.mjs b/spikes/pi-wielder/src/agent/credential.mjs new file mode 100644 index 0000000..406d070 --- /dev/null +++ b/spikes/pi-wielder/src/agent/credential.mjs @@ -0,0 +1,352 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { types as utilTypes } from 'node:util'; + +import { + canonicalJson, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from '../kernel/canonical.mjs'; +import { loadOrInitializeAgentPrivateFile } from '../kernel/secure-storage.mjs'; +import { openAgentTrustedParent } from '../kernel/trusted-path.mjs'; + +const INSTANCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const NOFOLLOW = fs.constants.O_NOFOLLOW; + +function fail(code, message) { + throw new KernelError(code, message); +} + +function opaque(value, bytes, pattern, label) { + if (typeof value !== 'string' || !pattern.test(value)) { + fail('AGENT_CREDENTIAL_SCHEMA', `${label} is not canonical base64url`); + } + const decoded = Buffer.from(value, 'base64url'); + if (decoded.length !== bytes || decoded.toString('base64url') !== value) { + decoded.fill(0); + fail('AGENT_CREDENTIAL_SCHEMA', `${label} is not canonical base64url`); + } + return decoded; +} + +function randomOpaque(randomBytes, bytes, pattern, label, maximumAttempts = 1) { + for (let attempt = 0; attempt < maximumAttempts; attempt += 1) { + let value; + try { + value = Buffer.from(randomBytes(bytes)); + } catch { + fail('AGENT_CREDENTIAL_RANDOMNESS', `${label} randomness failed`); + } + if (value.length !== bytes) { + value.fill(0); + fail('AGENT_CREDENTIAL_RANDOMNESS', `${label} randomness returned the wrong size`); + } + const encoded = value.toString('base64url'); + value.fill(0); + if (pattern.test(encoded)) return encoded; + } + fail('AGENT_CREDENTIAL_RANDOMNESS', `${label} could not satisfy its durable token grammar`); +} + +function validateCredential(value) { + const credential = exactRecord( + value, + ['schemaVersion', 'agentInstanceId', 'token'], + [], + 'AGENT_CREDENTIAL_SCHEMA', + 'agent credential', + ); + if (credential.schemaVersion !== 1) { + fail('AGENT_CREDENTIAL_SCHEMA', 'agent credential schemaVersion must equal 1'); + } + const instanceBytes = opaque( + credential.agentInstanceId, + 16, + INSTANCE_PATTERN, + 'agent instance ID', + ); + const tokenBytes = opaque(credential.token, 32, TOKEN_PATTERN, 'agent token'); + instanceBytes.fill(0); + tokenBytes.fill(0); + return frozenCopy(credential); +} + +function parseCredentialBytes(bytes) { + let text; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + fail('AGENT_CREDENTIAL_SCHEMA', 'agent credential bytes are not canonical UTF-8'); + } + if (!text.endsWith('\n') || text.slice(0, -1).includes('\n')) { + fail('AGENT_CREDENTIAL_SCHEMA', 'agent credential must contain one canonical line'); + } + let parsed; + try { + parsed = JSON.parse(text.slice(0, -1)); + } catch { + fail('AGENT_CREDENTIAL_SCHEMA', 'agent credential is not canonical JSON'); + } + const credential = validateCredential(parsed); + if (`${canonicalJson(credential)}\n` !== text) { + fail('AGENT_CREDENTIAL_SCHEMA', 'agent credential bytes are not canonical JSON'); + } + return credential; +} + +function positiveIdentity(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + fail('AGENT_IDENTITY', `${label} must be one positive safe integer`); + } + return String(value); +} + +function exactOptions(value, fields, label, optional = []) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail('AGENT_CREDENTIAL_SCHEMA', `${label} must be one plain object`); + } + const keys = Reflect.ownKeys(value); + const allowed = [...fields, ...optional]; + if (keys.length < fields.length || keys.length > allowed.length + || fields.some((field) => !Object.hasOwn(value, field)) + || keys.some((key) => typeof key !== 'string' || !allowed.includes(key))) { + fail('AGENT_CREDENTIAL_SCHEMA', `${label} fields do not match the closed schema`); + } + const captured = {}; + for (const field of allowed) { + if (!Object.hasOwn(value, field)) continue; + const descriptor = Object.getOwnPropertyDescriptor(value, field); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail('AGENT_CREDENTIAL_SCHEMA', `${label} must contain only data fields`); + } + captured[field] = descriptor.value; + } + return captured; +} + +function derivedAgentPathTrust(filePath) { + if (process.platform !== 'linux' || typeof process.getuid !== 'function' + || process.getuid() <= 0) { + fail( + 'AGENT_CREDENTIAL_PATH', + 'live Agent authority derivation requires a non-root Linux Agent identity', + ); + } + return Object.freeze({ + mode: 'cdp-testnet', + trustedAncestor: path.parse(filePath).root, + agentUid: process.getuid(), + }); +} + +function selectedPathTrust(filePath, supplied) { + return supplied === undefined ? derivedAgentPathTrust(filePath) : supplied; +} + +export function loadOrCreateAgentCredential(value = {}) { + const captured = exactOptions( + value, + ['filePath'], + 'agent credential initialization', + ['pathTrust', 'randomBytes'], + ); + const randomBytes = captured.randomBytes ?? crypto.randomBytes; + if (typeof randomBytes !== 'function' || utilTypes.isProxy(randomBytes)) { + fail('AGENT_CREDENTIAL_RANDOMNESS', 'agent credential randomness must be one function'); + } + return loadOrInitializeAgentPrivateFile({ + filePath: captured.filePath, + pathTrust: selectedPathTrust(captured.filePath, captured.pathTrust), + label: 'Agent credential', + createBytes: () => { + const credential = Object.freeze({ + schemaVersion: 1, + agentInstanceId: randomOpaque( + randomBytes, + 16, + INSTANCE_PATTERN, + 'agent instance ID', + 128, + ), + token: randomOpaque(randomBytes, 32, TOKEN_PATTERN, 'agent token'), + }); + return Buffer.from(`${canonicalJson(credential)}\n`, 'utf8'); + }, + validateBytes: parseCredentialBytes, + randomBytes, + }); +} + +export function createAgentEnrollmentDescriptor(value) { + const captured = exactOptions( + value, + ['credential'], + 'agent descriptor input', + ); + const credential = validateCredential(captured.credential); + const tokenBytes = opaque(credential.token, 32, TOKEN_PATTERN, 'agent token'); + let credentialDigest; + try { + credentialDigest = sha256(tokenBytes); + } finally { + tokenBytes.fill(0); + } + return frozenCopy({ + schemaVersion: 1, + agentInstanceId: credential.agentInstanceId, + credentialDigest, + agentUid: positiveIdentity(process.getuid?.(), 'agent UID'), + agentGid: positiveIdentity(process.getgid?.(), 'agent GID'), + }); +} + +function validateDescriptor(value) { + const descriptor = exactRecord( + value, + ['schemaVersion', 'agentInstanceId', 'credentialDigest', 'agentUid', 'agentGid'], + [], + 'AGENT_DESCRIPTOR_SCHEMA', + 'agent enrollment descriptor', + ); + if (descriptor.schemaVersion !== 1 || !HASH_PATTERN.test(descriptor.credentialDigest)) { + fail('AGENT_DESCRIPTOR_SCHEMA', 'agent descriptor version or digest is invalid'); + } + const instanceBytes = opaque( + descriptor.agentInstanceId, + 16, + INSTANCE_PATTERN, + 'agent instance ID', + ); + instanceBytes.fill(0); + if (!/^[1-9][0-9]*$/.test(descriptor.agentUid) + || !/^[1-9][0-9]*$/.test(descriptor.agentGid) + || !Number.isSafeInteger(Number(descriptor.agentUid)) + || !Number.isSafeInteger(Number(descriptor.agentGid))) { + fail('AGENT_DESCRIPTOR_SCHEMA', 'agent descriptor identity is invalid'); + } + return frozenCopy(descriptor); +} + +function fileIdentity(stat) { + return Object.freeze({ + device: stat.dev.toString(10), + inode: stat.ino.toString(10), + uid: Number(stat.uid), + gid: Number(stat.gid), + mode: Number(stat.mode & 0o7777n), + nlink: stat.nlink.toString(10), + size: stat.size.toString(10), + }); +} + +function sameFileIdentity(left, right) { + return Object.keys(left).every((field) => left[field] === right[field]); +} + +function assertPublishedFile(stat, expectedBytes, descriptor) { + const expectedUid = process.getuid?.(); + const expectedGid = process.getgid?.(); + if (!stat.isFile() || Number(stat.uid) !== expectedUid || Number(stat.gid) !== expectedGid + || Number(stat.mode & 0o7777n) !== 0o644 || stat.nlink !== 1n + || stat.size !== BigInt(expectedBytes.length)) { + fail('AGENT_DESCRIPTOR_PATH', 'published enrollment descriptor authority changed'); + } + const observed = Buffer.alloc(expectedBytes.length); + try { + let offset = 0; + while (offset < observed.length) { + const count = fs.readSync(descriptor, observed, offset, observed.length - offset, offset); + if (count === 0) break; + offset += count; + } + if (offset !== observed.length || !observed.equals(expectedBytes)) { + fail('AGENT_DESCRIPTOR_PATH', 'published enrollment descriptor bytes changed'); + } + } finally { + observed.fill(0); + } +} + +export function publishAgentEnrollmentDescriptor(value) { + const captured = exactOptions( + value, + ['filePath', 'credentialPath', 'descriptor'], + 'agent descriptor publication', + ['pathTrust'], + ); + if (typeof captured.filePath !== 'string' || typeof captured.credentialPath !== 'string' + || !path.isAbsolute(captured.filePath) || !path.isAbsolute(captured.credentialPath) + || path.resolve(captured.filePath) !== captured.filePath + || path.resolve(captured.credentialPath) !== captured.credentialPath) { + fail('AGENT_DESCRIPTOR_PATH', 'agent handoff paths must be canonical and absolute'); + } + const targetParent = path.dirname(captured.filePath); + const credentialParent = path.dirname(captured.credentialPath); + if (targetParent === credentialParent + || targetParent.startsWith(`${credentialParent}${path.sep}`) + || credentialParent.startsWith(`${targetParent}${path.sep}`)) { + fail('AGENT_DESCRIPTOR_PATH', 'credential and enrollment parents must be distinct'); + } + const descriptor = validateDescriptor(captured.descriptor); + if (descriptor.agentUid !== String(process.getuid?.()) + || descriptor.agentGid !== String(process.getgid?.())) { + fail('AGENT_DESCRIPTOR_PATH', 'agent descriptor identity must equal the running Agent'); + } + const bytes = Buffer.from(`${canonicalJson(descriptor)}\n`, 'utf8'); + const credentialTrust = selectedPathTrust(captured.credentialPath, captured.pathTrust); + const enrollmentTrust = selectedPathTrust(captured.filePath, captured.pathTrust); + let credentialGuard; + let enrollmentGuard; + let descriptorFd; + try { + credentialGuard = openAgentTrustedParent({ + ...credentialTrust, + targetFile: captured.credentialPath, + terminalOwnerUid: process.getuid(), + terminalMode: 0o700, + role: 'agent-private', + }); + enrollmentGuard = openAgentTrustedParent({ + ...enrollmentTrust, + targetFile: captured.filePath, + terminalOwnerUid: process.getuid(), + terminalMode: 0o755, + role: 'agent-handoff', + }); + credentialGuard.revalidate(); + descriptorFd = enrollmentGuard.openLeaf( + fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, + 0o644, + ); + fs.writeFileSync(descriptorFd, bytes); + fs.fsyncSync(descriptorFd); + const createdIdentity = fileIdentity(fs.fstatSync(descriptorFd, { bigint: true })); + assertPublishedFile(fs.fstatSync(descriptorFd, { bigint: true }), bytes, descriptorFd); + enrollmentGuard.revalidate(); + enrollmentGuard.fsyncParent(); + fs.closeSync(descriptorFd); + descriptorFd = undefined; + descriptorFd = enrollmentGuard.openLeaf(fs.constants.O_RDONLY | NOFOLLOW); + const reopened = fs.fstatSync(descriptorFd, { bigint: true }); + assertPublishedFile(reopened, bytes, descriptorFd); + if (!sameFileIdentity(createdIdentity, fileIdentity(reopened))) { + fail('AGENT_DESCRIPTOR_PATH', 'published enrollment descriptor inode changed'); + } + credentialGuard.revalidate(); + } finally { + bytes.fill(0); + if (descriptorFd !== undefined) fs.closeSync(descriptorFd); + if (enrollmentGuard) enrollmentGuard.close(); + if (credentialGuard) credentialGuard.close(); + } + return Object.freeze({ + descriptor, + descriptorHash: sha256(canonicalJson(descriptor)), + }); +} diff --git a/spikes/pi-wielder/src/agent/isolation-preflight.mjs b/spikes/pi-wielder/src/agent/isolation-preflight.mjs new file mode 100644 index 0000000..e32974a --- /dev/null +++ b/spikes/pi-wielder/src/agent/isolation-preflight.mjs @@ -0,0 +1,341 @@ +import { + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from '../kernel/canonical.mjs'; + +const HASH = /^sha256:[0-9a-f]{64}$/; +const POSITIVE_DECIMAL = /^[1-9][0-9]*$/; +const REPORT_MAXIMUM_BYTES = 16 * 1024; +const METADATA_DOMAIN = 'wallet-kernel/isolation-metadata/v1\0'; +const PROBE_RESULTS = Object.freeze({ + authorityDirectory: 'EACCES', + database: 'EACCES', + operatorToken: 'EACCES', + receiptKey: 'EACCES', + kernelEnvironment: 'EACCES', + agentCredential: 'READABLE', + releaseTreeWrite: 'EACCES', + dependencyTreeWrite: 'EACCES', + serviceArtifactsWrite: 'EACCES', + kernelEnvironmentParentWrite: 'EACCES', +}); +const REPORT_FIELDS = Object.freeze([ + 'schemaVersion', 'enrollmentHash', 'kernelUid', 'kernelGid', 'agentUid', 'agentGid', + 'authorityMetadataHash', 'credentialMetadataHash', 'releaseManifestHash', + 'releaseTreeHash', 'nodeExecutableHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', 'environmentMetadataHash', 'probeResults', + 'probedAt', 'expiresAt', +]); + +function fail(code, message, cause) { + throw new KernelError(code, message, cause ? { cause } : undefined); +} + +function canonicalHash(value, label) { + if (typeof value !== 'string' || !HASH.test(value)) { + fail('ISOLATION_SCHEMA', `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function numericIdentity(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + fail('ISOLATION_IDENTITY', `${label} must be one nonzero safe integer`); + } + return value; +} + +function identityText(value, label) { + if (typeof value !== 'string' || !POSITIVE_DECIMAL.test(value)) { + fail('ISOLATION_IDENTITY', `${label} must be canonical nonzero decimal text`); + } + const numeric = Number(value); + if (!Number.isSafeInteger(numeric) || String(numeric) !== value) { + fail('ISOLATION_IDENTITY', `${label} must round-trip through one safe integer`); + } + return value; +} + +function validateProjection(value, label, expectedUid, expectedDirectoryMode, expectedLeafMode) { + const metadata = exactRecord(value, ['role', 'chain', 'leaf'], [], + 'ISOLATION_METADATA', `${label} metadata`); + if (typeof metadata.role !== 'string' || !/^[a-z][a-z-]{0,63}$/.test(metadata.role) + || !Array.isArray(metadata.chain) || metadata.chain.length < 1 || metadata.chain.length > 64) { + fail('ISOLATION_METADATA', `${label} metadata has an invalid role or chain`); + } + const parseItem = (item, itemLabel, depth, mode) => { + const projection = exactRecord(item, [ + 'role', 'depth', 'device', 'inode', 'uid', 'gid', 'mode', + ], [], 'ISOLATION_METADATA', itemLabel); + if (typeof projection.role !== 'string' || !/^[a-z][a-z-]{0,63}$/.test(projection.role) + || projection.depth !== depth + || typeof projection.device !== 'string' || !/^(0|[1-9][0-9]*)$/.test(projection.device) + || typeof projection.inode !== 'string' || !/^[1-9][0-9]*$/.test(projection.inode) + || projection.uid !== expectedUid || !Number.isSafeInteger(projection.gid) + || projection.gid < 0 || projection.mode !== mode) { + fail('ISOLATION_METADATA', `${itemLabel} ownership, order, or mode is invalid`); + } + return projection; + }; + const chain = metadata.chain.map((item, index) => parseItem( + item, `${label} ancestor`, index, expectedDirectoryMode, + )); + const leaf = parseItem(metadata.leaf, `${label} leaf`, chain.length, expectedLeafMode); + return Object.freeze({ role: metadata.role, chain, leaf }); +} + +export function hashIsolationMetadata(value) { + const metadata = exactRecord(value, ['role', 'chain', 'leaf'], [], + 'ISOLATION_METADATA', 'isolation metadata'); + return sha256(`${METADATA_DOMAIN}${canonicalJson(metadata)}`); +} + +export function validateIsolationMetadata(value) { + const input = exactRecord(value, [ + 'kernelUid', 'kernelGid', 'agentUid', 'agentGid', 'authority', 'credential', + 'authorityInsideCredential', 'credentialInsideAuthority', + ], [], 'ISOLATION_METADATA', 'isolation metadata input'); + const kernelUid = numericIdentity(input.kernelUid, 'Kernel UID'); + numericIdentity(input.kernelGid, 'Kernel GID'); + const agentUid = numericIdentity(input.agentUid, 'Agent UID'); + numericIdentity(input.agentGid, 'Agent GID'); + if (kernelUid === agentUid) fail('ISOLATION_IDENTITY', 'Kernel and Agent UIDs must be distinct'); + if (input.authorityInsideCredential !== false || input.credentialInsideAuthority !== false) { + fail('ISOLATION_PATH_OVERLAP', 'authority and credential trees must be disjoint'); + } + const authority = validateProjection(input.authority, 'authority', kernelUid, 0o700, 0o600); + const credential = validateProjection(input.credential, 'credential', agentUid, 0o700, 0o600); + const authorityNodes = new Set([ + ...authority.chain.map((item) => `${item.device}:${item.inode}`), + `${authority.leaf.device}:${authority.leaf.inode}`, + ]); + if ([...credential.chain, credential.leaf] + .some((item) => authorityNodes.has(`${item.device}:${item.inode}`))) { + fail('ISOLATION_PATH_OVERLAP', 'authority and credential metadata share an inode'); + } + return Object.freeze({ + kernelUid, kernelGid: input.kernelGid, agentUid, agentGid: input.agentGid, + authorityMetadataHash: hashIsolationMetadata(authority), + credentialMetadataHash: hashIsolationMetadata(credential), + }); +} + +function validateReport(value) { + const report = exactRecord(value, REPORT_FIELDS, [], + 'ISOLATION_SCHEMA', 'isolation report'); + if (report.schemaVersion !== 1) fail('ISOLATION_SCHEMA', 'isolation report schemaVersion must equal 1'); + canonicalHash(report.enrollmentHash, 'enrollment hash'); + for (const field of ['kernelUid', 'kernelGid', 'agentUid', 'agentGid']) { + identityText(report[field], field); + } + if (report.kernelUid === report.agentUid) { + fail('ISOLATION_IDENTITY', 'Kernel and Agent UIDs must be distinct'); + } + for (const field of [ + 'authorityMetadataHash', 'credentialMetadataHash', 'releaseManifestHash', + 'releaseTreeHash', 'nodeExecutableHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', 'environmentMetadataHash', + ]) canonicalHash(report[field], field); + const probes = exactRecord(report.probeResults, Object.keys(PROBE_RESULTS), [], + 'ISOLATION_SCHEMA', 'isolation probe results'); + for (const [name, expected] of Object.entries(PROBE_RESULTS)) { + if (probes[name] !== expected) { + fail('ISOLATION_PROBE_FAILED', `isolation probe ${name} did not prove the required result`); + } + } + canonicalTimestamp(report.probedAt, 'isolation probedAt'); + canonicalTimestamp(report.expiresAt, 'isolation expiresAt'); + const interval = Date.parse(report.expiresAt) - Date.parse(report.probedAt); + if (interval <= 0 || interval > 15 * 60 * 1000) { + fail('ISOLATION_TIME', 'isolation report interval must be positive and at most 15 minutes'); + } + return frozenCopy({ ...report, probeResults: probes }); +} + +export function validateIsolationReportBytes(bytes, options = {}) { + if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) { + fail('ISOLATION_BYTES', 'isolation report must be supplied as bytes'); + } + const copy = Buffer.from(bytes); + if (copy.length < 3 || copy.length > REPORT_MAXIMUM_BYTES || copy.at(-1) !== 0x0a + || copy.subarray(0, -1).includes(0x0a) || copy.includes(0x00)) { + fail('ISOLATION_BYTES', 'isolation report bytes are not bounded canonical JSON plus newline'); + } + let parsed; + try { + parsed = JSON.parse(copy.subarray(0, -1).toString('utf8')); + } catch (cause) { + fail('ISOLATION_BYTES', 'isolation report is not valid UTF-8 JSON', cause); + } + const report = validateReport(parsed); + const canonical = canonicalJson(report); + if (!copy.equals(Buffer.from(`${canonical}\n`, 'utf8'))) { + fail('ISOLATION_BYTES', 'isolation report is not canonical JSON plus one newline'); + } + const reportHash = sha256(canonical); + if (options.expectedReportHash !== undefined + && canonicalHash(options.expectedReportHash, 'expected report hash') !== reportHash) { + fail('ISOLATION_HASH_MISMATCH', 'isolation report hash does not match the confirmation'); + } + const bindings = [ + ['expectedEnrollmentHash', 'enrollmentHash'], + ['expectedKernelUid', 'kernelUid'], + ['expectedKernelGid', 'kernelGid'], + ['expectedReleaseManifestHash', 'releaseManifestHash'], + ['expectedAuthorityMetadataHash', 'authorityMetadataHash'], + ]; + for (const [option, field] of bindings) { + if (options[option] !== undefined && options[option] !== report[field]) { + fail('ISOLATION_BINDING_MISMATCH', `isolation report ${field} does not match the expected binding`); + } + } + if (typeof options.now === 'function') { + const now = canonicalTimestamp(options.now(), 'isolation validation time'); + if (Date.parse(now) < Date.parse(report.probedAt)) { + fail('ISOLATION_TIME', 'isolation report is not valid before probedAt'); + } + if (Date.parse(now) >= Date.parse(report.expiresAt)) { + fail('ISOLATION_EXPIRED', 'isolation report is expired'); + } + } + return Object.freeze({ report, reportHash }); +} + +function attestationResult(row) { + return frozenCopy({ + id: row.id, + reportHash: row.report_hash, + enrollmentHash: row.enrollment_hash, + authorityMetadataHash: JSON.parse(row.report_json).authorityMetadataHash, + releaseManifestHash: JSON.parse(row.report_json).releaseManifestHash, + probedAt: row.probed_at, + expiresAt: row.expires_at, + importedAt: row.imported_at, + state: row.state, + }); +} + +export function createIsolationAttestationRepository({ store, now, idFactory }) { + if (!store || typeof store.transaction !== 'function' || typeof store.within !== 'function') { + throw new TypeError('isolation attestation repository requires a Wallet Kernel store'); + } + if (typeof now !== 'function' || typeof idFactory !== 'function') { + throw new TypeError('isolation attestation repository requires clock and ID dependencies'); + } + + const importCurrent = ({ reportBytes, expectedReportHash, operatorIdHash }) => { + const importedAt = canonicalTimestamp(now(), 'isolation import time'); + canonicalHash(operatorIdHash, 'operator identity hash'); + const { report, reportHash } = validateIsolationReportBytes(reportBytes, { + expectedReportHash, + now: () => importedAt, + }); + return store.transaction((token) => store.within(token, ({ db, appendEvent }) => { + const active = db.prepare("SELECT * FROM agent_enrollments WHERE state = 'active'").all(); + if (active.length !== 1) { + fail('ISOLATION_ENROLLMENT', 'isolation import requires exactly one active enrollment'); + } + const enrollment = active[0]; + if (report.enrollmentHash !== enrollment.enrollment_hash + || report.agentUid !== enrollment.agent_uid + || report.agentGid !== enrollment.agent_gid) { + fail('ISOLATION_ENROLLMENT', 'isolation report does not match the active enrollment'); + } + const replay = db.prepare('SELECT * FROM isolation_attestations WHERE report_hash = ?') + .get(reportHash); + if (replay) { + if (replay.state !== 'current' || replay.enrollment_hash !== report.enrollmentHash + || replay.imported_by_operator_hash !== operatorIdHash + || replay.report_json !== canonicalJson(report)) { + fail('ISOLATION_REPLAY_CONFLICT', 'isolation report replay conflicts with stored authority'); + } + return attestationResult(replay); + } + const currentRows = db.prepare("SELECT * FROM isolation_attestations WHERE state = 'current'").all(); + if (currentRows.length > 1) fail('ISOLATION_CORRUPTION', 'multiple current isolation attestations exist'); + for (const previous of currentRows) { + const update = db.prepare(`UPDATE isolation_attestations + SET state = 'superseded', superseded_at = ? WHERE id = ? AND state = 'current'`) + .run(importedAt, previous.id); + if (update.changes !== 1n) fail('ISOLATION_STALE', 'current isolation attestation changed'); + appendEvent({ + entityType: 'isolation_attestation', entityId: previous.id, + eventType: 'isolation.attestation_superseded', + data: { + enrollmentHash: previous.enrollment_hash, + reportHash: previous.report_hash, + supersededAt: importedAt, + reasonCode: 'ATTESTATION_REPLACED', + }, + }); + } + const id = canonicalToken(idFactory(), 'isolation attestation ID'); + db.prepare(`INSERT INTO isolation_attestations + (id, report_hash, enrollment_hash, report_json, state, imported_by_operator_hash, + probed_at, expires_at, imported_at, superseded_at) + VALUES (?, ?, ?, ?, 'current', ?, ?, ?, ?, NULL)`).run( + id, reportHash, report.enrollmentHash, canonicalJson(report), operatorIdHash, + report.probedAt, report.expiresAt, importedAt, + ); + appendEvent({ + entityType: 'isolation_attestation', entityId: id, + eventType: 'isolation.attestation_imported', + data: { + reportHash, + enrollmentHash: report.enrollmentHash, + authorityMetadataHash: report.authorityMetadataHash, + releaseManifestHash: report.releaseManifestHash, + operatorIdHash, + probedAt: report.probedAt, + expiresAt: report.expiresAt, + importedAt, + }, + }); + return attestationResult(db.prepare('SELECT * FROM isolation_attestations WHERE id = ?').get(id)); + })); + }; + + const currentFor = ({ + enrollmentHash, authorityMetadataHash, releaseManifestHash, expectedReportHash, + }) => { + canonicalHash(enrollmentHash, 'enrollment hash'); + canonicalHash(authorityMetadataHash, 'authority metadata hash'); + canonicalHash(releaseManifestHash, 'release manifest hash'); + canonicalHash(expectedReportHash, 'expected report hash'); + const readAt = canonicalTimestamp(now(), 'isolation read time'); + return store.transaction((token) => store.within(token, ({ db }) => { + const rows = db.prepare("SELECT * FROM isolation_attestations WHERE state = 'current'").all(); + if (rows.length > 1) fail('ISOLATION_CORRUPTION', 'multiple current isolation attestations exist'); + if (rows.length === 0) return null; + const row = rows[0]; + let report; + try { + report = validateReport(JSON.parse(row.report_json)); + } catch (cause) { + fail('ISOLATION_CORRUPTION', 'stored isolation report is invalid', cause); + } + if (canonicalJson(report) !== row.report_json || sha256(row.report_json) !== row.report_hash + || report.enrollmentHash !== row.enrollment_hash + || row.report_hash !== expectedReportHash + || report.enrollmentHash !== enrollmentHash + || report.authorityMetadataHash !== authorityMetadataHash + || report.releaseManifestHash !== releaseManifestHash + || Date.parse(readAt) < Date.parse(report.probedAt) + || Date.parse(readAt) >= Date.parse(report.expiresAt)) { + return null; + } + const active = db.prepare("SELECT enrollment_hash FROM agent_enrollments WHERE state = 'active'").all(); + if (active.length !== 1 || active[0].enrollment_hash !== enrollmentHash) return null; + return attestationResult(row); + })); + }; + + return Object.freeze({ importCurrent, currentFor }); +} + +export const REQUIRED_ISOLATION_PROBE_RESULTS = PROBE_RESULTS; diff --git a/spikes/pi-wielder/src/config.mjs b/spikes/pi-wielder/src/config.mjs new file mode 100644 index 0000000..b681f93 --- /dev/null +++ b/spikes/pi-wielder/src/config.mjs @@ -0,0 +1,865 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { types as utilTypes } from 'node:util'; + +import { + KernelError, + canonicalJson, + exactRecord, + frozenCopy, +} from './kernel/canonical.mjs'; + +export const CONTROL_PLANE_MODES = Object.freeze(['deterministic', 'cdp-testnet']); + +const MODE_SET = new Set(CONTROL_PLANE_MODES); +const KNOWN_PLATFORMS = new Set([ + 'aix', + 'android', + 'cygwin', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'netbsd', + 'openbsd', + 'sunos', + 'win32', +]); + +const WALLET_KERNEL_ENVIRONMENT = new Set([ + 'WALLET_KERNEL_MODE', + 'WALLET_KERNEL_DB_FILE', + 'WALLET_KERNEL_RECEIPT_KEY_FILE', + 'WALLET_KERNEL_OPERATOR_TOKEN_FILE', + 'WALLET_KERNEL_TRUSTED_ANCESTOR', + 'WALLET_KERNEL_EXPECTED_AGENT_UID', + 'WALLET_KERNEL_EXPECTED_AGENT_GID', + 'WALLET_KERNEL_POLICY_FILE', + 'WALLET_KERNEL_ROUTE_FILE', + 'WALLET_KERNEL_PORT', + 'WALLET_KERNEL_OPERATOR_PORT', + 'WALLET_KERNEL_OPERATOR_SOCKET_FILE', + 'WALLET_KERNEL_ENROLLMENT_INBOX', + 'WALLET_KERNEL_AGENT_RUN_OUTBOX', + 'WALLET_KERNEL_RELEASE_ROOT', + 'WALLET_KERNEL_RELEASE_MANIFEST', + 'WALLET_KERNEL_SERVICE_DEFINITION_FILE', + 'WALLET_KERNEL_SOCKET_DEFINITION_FILE', + 'WALLET_KERNEL_ENV_FILE', + 'WALLET_KERNEL_EVIDENCE_ROOT', + 'WALLET_KERNEL_ISOLATION_REPORT_FILE', + 'WALLET_KERNEL_BASE_SEPOLIA_RPC_URL', +]); + +const LIVE_LOADER_KEYS = new Set([ + 'NODE_OPTIONS', + 'NODE_PATH', + 'GCONV_PATH', + 'GLIBC_TUNABLES', +]); + +const REQUIRED_CORE_PATHS = Object.freeze([ + 'WALLET_KERNEL_DB_FILE', + 'WALLET_KERNEL_RECEIPT_KEY_FILE', + 'WALLET_KERNEL_OPERATOR_TOKEN_FILE', + 'WALLET_KERNEL_POLICY_FILE', + 'WALLET_KERNEL_ROUTE_FILE', +]); + +const LIVE_PATHS = Object.freeze([ + 'WALLET_KERNEL_OPERATOR_SOCKET_FILE', + 'WALLET_KERNEL_ENROLLMENT_INBOX', + 'WALLET_KERNEL_AGENT_RUN_OUTBOX', + 'WALLET_KERNEL_RELEASE_ROOT', + 'WALLET_KERNEL_RELEASE_MANIFEST', + 'WALLET_KERNEL_SERVICE_DEFINITION_FILE', + 'WALLET_KERNEL_SOCKET_DEFINITION_FILE', + 'WALLET_KERNEL_ENV_FILE', + 'WALLET_KERNEL_EVIDENCE_ROOT', + 'WALLET_KERNEL_ISOLATION_REPORT_FILE', +]); + +const ROUTE_FIELDS = Object.freeze([ + 'id', + 'kind', + 'method', + 'upstreamUrl', + 'resourceDescription', + 'resourceMimeType', + 'purposeLabel', + 'requestContentTypes', + 'maximumRequestBytes', + 'maximumResponseBytes', +]); +const ROUTE_KINDS = new Set(['openai-chat', 'tool']); +const MAXIMUM_ROUTES = 64; +const MAXIMUM_ROUTE_DOCUMENT_BYTES = 65_536; +const MAXIMUM_ROUTE_ID_BYTES = 64; +const MAXIMUM_ROUTE_URL_BYTES = 2_048; +const MAXIMUM_ROUTE_DESCRIPTION_BYTES = 256; +const MAXIMUM_PURPOSE_LABEL_BYTES = 64; +const MAXIMUM_REQUEST_BYTES = 262_144; +const MAXIMUM_RESPONSE_BYTES = 1_048_576; + +function fail(code, message) { + throw new KernelError(code, message); +} + +function isOwnDataDescriptor(descriptor) { + return descriptor?.enumerable === true && Object.hasOwn(descriptor, 'value'); +} + +function captureClosedCall(input, required, optional, code, label) { + if (!input || typeof input !== 'object' || utilTypes.isProxy(input) + || Array.isArray(input) + || (Object.getPrototypeOf(input) !== Object.prototype + && Object.getPrototypeOf(input) !== null)) { + fail(code, `${label} must be one plain object`); + } + const descriptors = Object.getOwnPropertyDescriptors(input); + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(input); + if (required.some((key) => !Object.hasOwn(descriptors, key)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key)) + || keys.some((key) => !isOwnDataDescriptor(descriptors[key]))) { + fail(code, `${label} fields do not match the closed schema`); + } + return Object.fromEntries(keys.map((key) => [key, descriptors[key].value])); +} + +function captureEnvironment(env) { + if (!env || typeof env !== 'object' || utilTypes.isProxy(env)) { + fail('CONFIG_ENV', 'configuration environment must be an explicit non-proxy object'); + } + const descriptors = Object.getOwnPropertyDescriptors(env); + const keys = Reflect.ownKeys(env); + if (keys.some((key) => typeof key !== 'string')) { + fail('CONFIG_ENV', 'configuration environment may contain only string keys'); + } + for (const key of keys) { + if (!isOwnDataDescriptor(descriptors[key])) { + fail('CONFIG_ENV', 'configuration environment fields must be enumerable data properties'); + } + if (key.startsWith('WALLET_KERNEL_') && !WALLET_KERNEL_ENVIRONMENT.has(key)) { + fail('CONFIG_ENV_UNKNOWN', 'configuration environment contains an unknown WALLET_KERNEL field'); + } + } + return descriptors; +} + +function capturedValue(descriptors, key) { + return descriptors[key]?.value; +} + +function requireString(descriptors, key, code = 'CONFIG_VALUE') { + const value = capturedValue(descriptors, key); + if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) { + fail(code, `${key} must be one non-empty string`); + } + return value; +} + +function optionalString(descriptors, key) { + const value = capturedValue(descriptors, key); + if (value === undefined || value === '') return null; + if (typeof value !== 'string' || value.includes('\0')) { + fail('CONFIG_VALUE', `${key} must be one string when present`); + } + return value; +} + +function canonicalPositiveInteger(value, label, code = 'CONFIG_IDENTITY') { + if (typeof value !== 'string' || !/^[1-9][0-9]*$/.test(value)) { + fail(code, `${label} must be one canonical positive decimal integer`); + } + const number = Number(value); + if (!Number.isSafeInteger(number) || String(number) !== value) { + fail(code, `${label} must be one canonical positive safe integer`); + } + return number; +} + +function injectedIdentity(value, label, { positive }) { + if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0)) { + fail('CONFIG_IDENTITY', `${label} must be a ${positive ? 'positive' : 'non-negative'} safe integer`); + } + return value; +} + +function port(descriptors, key, fallback) { + const value = capturedValue(descriptors, key) ?? fallback; + return canonicalPositiveInteger(value, key, 'CONFIG_PORT') <= 65_535 + ? Number(value) + : fail('CONFIG_PORT', `${key} must be at most 65535`); +} + +function canonicalAbsolutePath(value, label) { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0') + || !path.isAbsolute(value) || path.resolve(value) !== value) { + fail('CONFIG_PATH', `${label} must be one canonical absolute path`); + } + if (value !== path.parse(value).root && value.endsWith(path.sep)) { + fail('CONFIG_PATH', `${label} must not contain an empty trailing component`); + } + return value; +} + +function isInside(parent, child) { + const relative = path.relative(parent, child); + return relative === '' + || (relative !== '..' && !path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`)); +} + +function statMode(stat) { + return Number(stat.mode & 0o7777n); +} + +function descriptorStat(location, expectedType, label) { + let descriptor; + try { + const directoryFlag = expectedType === 'directory' ? fs.constants.O_DIRECTORY : 0; + descriptor = fs.openSync( + location, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | directoryFlag, + ); + const stat = fs.fstatSync(descriptor, { bigint: true }); + if (expectedType === 'directory' && !stat.isDirectory()) { + fail('CONFIG_PATH', `${label} must be a directory`); + } + if (expectedType === 'file' && !stat.isFile()) { + fail('CONFIG_PATH', `${label} must be a regular file`); + } + const pathStat = fs.lstatSync(location, { bigint: true }); + if (pathStat.isSymbolicLink() || pathStat.dev !== stat.dev || pathStat.ino !== stat.ino) { + fail('CONFIG_PATH', `${label} may not be a symlink or change during inspection`); + } + return Object.freeze({ + uid: Number(stat.uid), + gid: Number(stat.gid), + mode: statMode(stat), + nlink: Number(stat.nlink), + }); + } catch (error) { + if (error instanceof KernelError) throw error; + const code = error?.code === 'ELOOP' || error?.code === 'ENOTDIR' + ? 'CONFIG_PATH' + : 'CONFIG_PATH_IO'; + fail(code, `${label} failed read-only descriptor inspection`); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function pathChain(ancestor, target) { + if (!isInside(ancestor, target)) { + fail('CONFIG_PATH', 'every configured live path must be beneath the trusted ancestor'); + } + const relative = path.relative(ancestor, target); + const chain = [ancestor]; + if (relative !== '') { + for (const component of relative.split(path.sep)) { + chain.push(path.join(chain.at(-1), component)); + } + } + return chain; +} + +function inspectPath({ + value, + label, + trustedAncestor, + checkoutRoot, + mode, + kernelUid, + expectedAgentUid, + type, + requireLeaf, + terminalOwner, + exactTerminalMode, + exactTerminalLinks, + parentOwner, + exactParentMode, +}) { + const target = canonicalAbsolutePath(value, label); + if (target === trustedAncestor) { + fail('CONFIG_PATH', `${label} must name a descendant below the trusted ancestor`); + } + if (isInside(checkoutRoot, target)) { + fail('CONFIG_PATH', `${label} must be outside the checkout`); + } + const chain = pathChain(trustedAncestor, target); + let terminalExists = false; + let terminalPathStat; + try { + terminalPathStat = fs.lstatSync(target, { bigint: true }); + if (terminalPathStat.isSymbolicLink()) { + fail('CONFIG_PATH', `${label} may not be a symlink`); + } + terminalExists = true; + } catch (error) { + if (error instanceof KernelError) throw error; + if (error?.code !== 'ENOENT') { + fail('CONFIG_PATH_IO', `${label} failed read-only metadata inspection`); + } + } + if (requireLeaf && !terminalExists) { + fail('CONFIG_PATH_IO', `${label} must already exist`); + } + const socketPath = type === 'socket-path'; + const inspectThrough = terminalExists && !socketPath ? chain.length : chain.length - 1; + if (inspectThrough < 1) { + fail('CONFIG_PATH', `${label} must name a child below the trusted ancestor`); + } + + for (let index = 0; index < inspectThrough; index += 1) { + const location = chain[index]; + const terminal = terminalExists && index === chain.length - 1; + const expectedType = terminal ? type : 'directory'; + const metadata = descriptorStat(location, expectedType, label); + if ((metadata.mode & 0o022) !== 0) { + fail('CONFIG_PATH_MODE', `${label} has a group/other-writable path component`); + } + if (mode === 'cdp-testnet') { + if (![0, kernelUid, expectedAgentUid].includes(metadata.uid)) { + fail('CONFIG_PATH_OWNER', `${label} has a path component outside the pinned identities`); + } + if (index === 0 && metadata.uid !== 0) { + fail('CONFIG_PATH_OWNER', 'live trusted ancestor must be root-owned'); + } + } + const targetParent = index === chain.length - 2; + if (targetParent) { + if (parentOwner === 'kernel' && mode === 'cdp-testnet' && metadata.uid !== kernelUid) { + fail('CONFIG_PATH_OWNER', `${label} parent must be Kernel-owned`); + } + if (parentOwner === 'root' && metadata.uid !== 0) { + fail('CONFIG_PATH_OWNER', `${label} parent must be root-owned`); + } + if (exactParentMode !== undefined && metadata.mode !== exactParentMode) { + fail('CONFIG_PATH_MODE', `${label} parent does not have its exact required mode`); + } + } + if (terminal) { + if (terminalOwner === 'root' && metadata.uid !== 0) { + fail('CONFIG_PATH_OWNER', `${label} must be root-owned`); + } + if (terminalOwner === 'kernel' && mode === 'cdp-testnet' && metadata.uid !== kernelUid) { + fail('CONFIG_PATH_OWNER', `${label} must be Kernel-owned`); + } + if (terminalOwner === 'agent' && mode === 'cdp-testnet' && metadata.uid !== expectedAgentUid) { + fail('CONFIG_PATH_OWNER', `${label} must be agent-owned`); + } + if (exactTerminalMode !== undefined && metadata.mode !== exactTerminalMode) { + fail('CONFIG_PATH_MODE', `${label} does not have its exact required mode`); + } + if (exactTerminalLinks !== undefined && metadata.nlink !== exactTerminalLinks) { + fail('CONFIG_PATH', `${label} does not have its exact required link count`); + } + } + } + if (socketPath && terminalExists) { + const metadata = { + uid: Number(terminalPathStat.uid), + mode: statMode(terminalPathStat), + }; + if (!terminalPathStat.isSocket()) { + fail('CONFIG_PATH', `${label} must be a Unix-domain socket when it exists`); + } + if ((metadata.mode & 0o177) !== 0) { + fail('CONFIG_PATH_MODE', `${label} must be owner-only when it exists`); + } + if (mode === 'cdp-testnet' && metadata.uid !== kernelUid) { + fail('CONFIG_PATH_OWNER', `${label} must be Kernel-owned when it exists`); + } + } + return target; +} + +function validateCheckoutRoot(value) { + const checkoutRoot = canonicalAbsolutePath(value, 'checkout root'); + let real; + try { + real = fs.realpathSync(checkoutRoot); + } catch { + fail('CONFIG_PATH_IO', 'checkout root must be an existing directory'); + } + if (real !== checkoutRoot) { + fail('CONFIG_PATH', 'checkout root must be its canonical non-symlink path'); + } + descriptorStat(checkoutRoot, 'directory', 'checkout root'); + return checkoutRoot; +} + +function validateRpcPresence(descriptors) { + const raw = requireString(descriptors, 'WALLET_KERNEL_BASE_SEPOLIA_RPC_URL', 'CONFIG_RPC'); + let parsed; + try { + parsed = new URL(raw); + } catch { + fail('CONFIG_RPC', 'Base Sepolia observation endpoint must be one valid HTTPS URL'); + } + if (parsed.protocol !== 'https:' || parsed.hostname.length === 0 + || parsed.username !== '' || parsed.password !== '') { + fail('CONFIG_RPC', 'Base Sepolia observation endpoint must be HTTPS and credential-free'); + } + return true; +} + +function validateCredentialPresence(descriptors) { + for (const key of ['CDP_API_KEY_ID', 'CDP_API_KEY_SECRET', 'CDP_WALLET_SECRET']) { + const value = capturedValue(descriptors, key); + if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) { + fail('CONFIG_CREDENTIALS', 'all three CDP credential variables must be present in cdp-testnet mode'); + } + } + return true; +} + +function validateWalletName(descriptors) { + const value = capturedValue(descriptors, 'CDP_WALLET_NAME'); + if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { + fail('CONFIG_WALLET', 'CDP wallet name must be one bounded canonical public name'); + } + return value; +} + +function validateMode(value, code = 'CONFIG_MODE') { + if (!MODE_SET.has(value)) fail(code, 'mode must be deterministic or cdp-testnet'); + return value; +} + +function liveLoaderPreflight(descriptors) { + for (const key of Object.keys(descriptors)) { + if (LIVE_LOADER_KEYS.has(key) || key.startsWith('LD_') || key.startsWith('DYLD_')) { + fail('CONFIG_LOADER_ENV', 'live environment contains a forbidden loader control'); + } + } +} + +function pathValue(descriptors, key, required) { + return required + ? requireString(descriptors, key, 'CONFIG_PATH') + : optionalString(descriptors, key); +} + +export function loadControlPlaneConfig(input) { + const request = captureClosedCall( + input, + ['env', 'checkoutRoot'], + ['uid', 'gid', 'platform'], + 'CONFIG_SCHEMA', + 'configuration request', + ); + const env = request.env; + const checkoutRootInput = request.checkoutRoot; + const uid = request.uid === undefined + ? (typeof process.getuid === 'function' ? process.getuid() : undefined) + : request.uid; + const gid = request.gid === undefined + ? (typeof process.getgid === 'function' ? process.getgid() : undefined) + : request.gid; + const platform = request.platform === undefined ? process.platform : request.platform; + const descriptors = captureEnvironment(env); + const mode = validateMode(capturedValue(descriptors, 'WALLET_KERNEL_MODE')); + if (!KNOWN_PLATFORMS.has(platform) || (mode === 'cdp-testnet' && platform !== 'linux')) { + fail('CONFIG_PLATFORM', 'platform must be a known Node platform and cdp-testnet requires Linux'); + } + + const live = mode === 'cdp-testnet'; + const kernelUid = injectedIdentity(uid, 'Kernel UID', { positive: live }); + injectedIdentity(gid, 'Kernel GID', { positive: live }); + const expectedAgentUid = canonicalPositiveInteger( + requireString(descriptors, 'WALLET_KERNEL_EXPECTED_AGENT_UID', 'CONFIG_IDENTITY'), + 'expected Agent UID', + ); + const expectedAgentGid = canonicalPositiveInteger( + requireString(descriptors, 'WALLET_KERNEL_EXPECTED_AGENT_GID', 'CONFIG_IDENTITY'), + 'expected Agent GID', + ); + if (live && expectedAgentUid === kernelUid) { + fail('CONFIG_IDENTITY', 'live Agent UID must differ from the Kernel UID'); + } + if (live) liveLoaderPreflight(descriptors); + + const agentPort = port(descriptors, 'WALLET_KERNEL_PORT', '8402'); + const operatorPort = port(descriptors, 'WALLET_KERNEL_OPERATOR_PORT', '8405'); + if (agentPort === operatorPort) { + fail('CONFIG_PORT', 'agent and operator ports must be distinct'); + } + if (live && operatorPort !== 8405) { + fail('CONFIG_ACTIVATION', 'live operator console requires exact socket activation on port 8405'); + } + + const checkoutRoot = validateCheckoutRoot(checkoutRootInput); + const trustedAncestor = canonicalAbsolutePath( + requireString(descriptors, 'WALLET_KERNEL_TRUSTED_ANCESTOR', 'CONFIG_PATH'), + 'WALLET_KERNEL_TRUSTED_ANCESTOR', + ); + if (isInside(checkoutRoot, trustedAncestor)) { + fail('CONFIG_PATH', 'trusted ancestor must be outside the checkout'); + } + const trustedMetadata = descriptorStat( + trustedAncestor, + 'directory', + 'WALLET_KERNEL_TRUSTED_ANCESTOR', + ); + if ((trustedMetadata.mode & 0o022) !== 0) { + fail('CONFIG_PATH_MODE', 'trusted ancestor may not be group/other writable, including sticky roots'); + } + if (live && trustedMetadata.uid !== 0) { + fail('CONFIG_PATH_OWNER', 'live trusted ancestor must be root-owned'); + } + + const rawPaths = Object.fromEntries(REQUIRED_CORE_PATHS.map((key) => [ + key, + pathValue(descriptors, key, true), + ])); + for (const key of LIVE_PATHS) { + rawPaths[key] = pathValue(descriptors, key, live); + } + + const common = { + trustedAncestor, + checkoutRoot, + mode, + kernelUid, + expectedAgentUid, + }; + const inspect = (key, options) => { + const value = rawPaths[key]; + if (value === null) return null; + return inspectPath({ value, label: key, ...common, ...options }); + }; + + const databasePath = inspect('WALLET_KERNEL_DB_FILE', { + type: 'file', requireLeaf: false, terminalOwner: 'kernel', exactTerminalMode: 0o600, + }); + const receiptKeyPath = inspect('WALLET_KERNEL_RECEIPT_KEY_FILE', { + type: 'file', requireLeaf: false, terminalOwner: 'kernel', exactTerminalMode: 0o600, + }); + const operatorTokenPath = inspect('WALLET_KERNEL_OPERATOR_TOKEN_FILE', { + type: 'file', requireLeaf: false, terminalOwner: 'kernel', exactTerminalMode: 0o600, + }); + const policyPath = inspect('WALLET_KERNEL_POLICY_FILE', { + type: 'file', requireLeaf: true, + terminalOwner: live ? 'root' : undefined, + exactTerminalLinks: live ? 1 : undefined, + }); + const routePath = inspect('WALLET_KERNEL_ROUTE_FILE', { + type: 'file', requireLeaf: true, + terminalOwner: live ? 'root' : undefined, + exactTerminalLinks: live ? 1 : undefined, + }); + const operatorSocketPath = inspect('WALLET_KERNEL_OPERATOR_SOCKET_FILE', { + type: 'socket-path', + requireLeaf: false, + parentOwner: live ? 'kernel' : undefined, + exactParentMode: live ? 0o700 : undefined, + }); + const enrollmentInboxPath = inspect('WALLET_KERNEL_ENROLLMENT_INBOX', { + type: 'directory', requireLeaf: live, exactTerminalMode: live ? 0o755 : undefined, + }); + const agentRunOutboxPath = inspect('WALLET_KERNEL_AGENT_RUN_OUTBOX', { + type: 'directory', requireLeaf: live, exactTerminalMode: live ? 0o755 : undefined, + }); + const releaseRoot = inspect('WALLET_KERNEL_RELEASE_ROOT', { + type: 'directory', requireLeaf: live, terminalOwner: live ? 'root' : undefined, + }); + const releaseManifestPath = inspect('WALLET_KERNEL_RELEASE_MANIFEST', { + type: 'file', requireLeaf: live, terminalOwner: live ? 'root' : undefined, + }); + const serviceDefinitionPath = inspect('WALLET_KERNEL_SERVICE_DEFINITION_FILE', { + type: 'file', requireLeaf: live, terminalOwner: live ? 'root' : undefined, + }); + const socketDefinitionPath = inspect('WALLET_KERNEL_SOCKET_DEFINITION_FILE', { + type: 'file', requireLeaf: live, terminalOwner: live ? 'root' : undefined, + }); + const environmentFilePath = inspect('WALLET_KERNEL_ENV_FILE', { + type: 'file', requireLeaf: live, terminalOwner: live ? 'root' : undefined, + }); + const evidenceRoot = inspect('WALLET_KERNEL_EVIDENCE_ROOT', { + type: 'directory', requireLeaf: live, + }); + const isolationReportPath = inspect('WALLET_KERNEL_ISOLATION_REPORT_FILE', { + type: 'file', requireLeaf: live, exactTerminalMode: live ? 0o600 : undefined, + }); + + if (live && (!isInside(releaseRoot, policyPath) || policyPath === releaseRoot + || !isInside(releaseRoot, routePath) || routePath === releaseRoot)) { + fail( + 'CONFIG_RELEASE_BOUNDARY', + 'live PolicyVersion seed and route authority must be files inside the verified release root', + ); + } + + const configuredPaths = [ + databasePath, + receiptKeyPath, + operatorTokenPath, + policyPath, + routePath, + operatorSocketPath, + enrollmentInboxPath, + agentRunOutboxPath, + releaseRoot, + releaseManifestPath, + serviceDefinitionPath, + socketDefinitionPath, + environmentFilePath, + evidenceRoot, + isolationReportPath, + ].filter((value) => value !== null); + if (new Set(configuredPaths).size !== configuredPaths.length) { + fail('CONFIG_PATH_COLLISION', 'configured filesystem roles must use distinct paths'); + } + + let cdpWalletName = null; + let credentialsPresent = false; + if (live) { + credentialsPresent = validateCredentialPresence(descriptors); + cdpWalletName = validateWalletName(descriptors); + validateRpcPresence(descriptors); + } + const assertCredentialPresence = Object.freeze(function assertCredentialPresence() { + if (live && !credentialsPresent) { + fail('CONFIG_CREDENTIALS', 'all three CDP credential variables must be present in cdp-testnet mode'); + } + return undefined; + }); + + const publicConfig = Object.freeze({ + mode, + agentHost: '127.0.0.1', + agentPort, + operatorAdminTransport: live ? 'unix' : 'loopback-demo', + operatorSocketPath: live ? operatorSocketPath : null, + operatorConsoleTransport: live ? 'socket-activated-loopback' : 'loopback-demo', + operatorConsoleActivationName: live ? 'wallet-kernel-console' : null, + operatorHost: '127.0.0.1', + operatorPort, + databasePath, + policyPath, + routePath, + receiptKeyPath, + operatorTokenPath, + enrollmentInboxPath, + agentRunOutboxPath, + trustedAncestor: live ? trustedAncestor : null, + releaseRoot: live ? releaseRoot : null, + releaseManifestPath: live ? releaseManifestPath : null, + serviceDefinitionPath: live ? serviceDefinitionPath : null, + socketDefinitionPath: live ? socketDefinitionPath : null, + environmentFilePath: live ? environmentFilePath : null, + evidenceRoot: live ? evidenceRoot : null, + isolationReportPath: live ? isolationReportPath : null, + expectedAgentUid, + expectedAgentGid, + cdpWalletName, + network: 'eip155:84532', + observer: live ? 'base-sepolia-read-only' : 'deterministic', + }); + return Object.freeze({ publicConfig, assertCredentialPresence }); +} + +export function readBoundedRouteDocument(filePath) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath) + || path.resolve(filePath) !== filePath || filePath.includes('\0')) { + fail('ROUTE_FILE', 'route document path must be one canonical absolute path'); + } + let descriptor; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n || before.size < 1n + || before.size > BigInt(MAXIMUM_ROUTE_DOCUMENT_BYTES)) { + fail('ROUTE_FILE', 'route document must be one bounded single-link regular file'); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + for (const field of [ + 'dev', 'ino', 'mode', 'nlink', 'uid', 'gid', 'size', 'mtimeNs', 'ctimeNs', + ]) { + if (before[field] !== after[field]) { + fail('ROUTE_FILE', 'route document changed while it was being read'); + } + } + if (BigInt(bytes.length) !== before.size) { + fail('ROUTE_FILE', 'route document length changed while it was being read'); + } + try { + return JSON.parse(bytes.toString('utf8')); + } catch { + fail('ROUTE_FILE', 'route document is not valid JSON'); + } + } catch (error) { + if (error instanceof KernelError) throw error; + fail('ROUTE_FILE', 'route document failed descriptor-safe reading'); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function canonicalRouteUrl(value, mode) { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAXIMUM_ROUTE_URL_BYTES + || value.includes('?') || value.includes('#')) { + fail('ROUTE_URL', 'route upstream URL must be bounded, queryless, and fragment-free'); + } + let parsed; + try { + parsed = new URL(value); + } catch { + fail('ROUTE_URL', 'route upstream URL must be absolute'); + } + if (parsed.toString() !== value || parsed.username !== '' || parsed.password !== '' + || parsed.pathname.length === 0 || parsed.pathname.startsWith('//') + || /%2f|%5c/i.test(parsed.pathname) || parsed.pathname.includes('\\')) { + fail('ROUTE_URL', 'route upstream URL must be canonical and credential-free'); + } + if (parsed.protocol === 'https:') return value; + const literalLoopback = parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]'; + if (mode !== 'deterministic' || parsed.protocol !== 'http:' || !literalLoopback) { + fail('ROUTE_URL', 'route upstream URL must be HTTPS or deterministic literal loopback HTTP'); + } + return value; +} + +function validateRouteId(value) { + if (typeof value !== 'string' + || Buffer.byteLength(value, 'utf8') > MAXIMUM_ROUTE_ID_BYTES + || !/^[a-z0-9][a-z0-9._-]*$/.test(value)) { + fail('ROUTE_ID', 'route ID must be one bounded canonical lower-case token'); + } + return value; +} + +function boundedPublicText(value, maximumBytes, label) { + if (typeof value !== 'string' || value.length === 0 + || Buffer.byteLength(value, 'utf8') > maximumBytes + || /[\u0000-\u001f\u007f]/u.test(value) + || value.trim() !== value) { + fail('ROUTE_METADATA', `${label} must be bounded public text`); + } + return value; +} + +function boundedRouteBytes(value, maximum, label) { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + fail('ROUTE_BYTES', `${label} must be a positive safe integer within the hard ceiling`); + } + return value; +} + +export function validateRouteMap(input) { + const request = exactRecord( + input, + ['document', 'mode'], + [], + 'ROUTE_SCHEMA', + 'route-map validation request', + ); + const { document, mode } = request; + validateMode(mode, 'ROUTE_MODE'); + let captured; + try { + captured = exactRecord( + document, + ['schemaVersion', 'routes'], + [], + 'ROUTE_SCHEMA', + 'route map', + ); + } catch (error) { + if (error instanceof KernelError && error.code === 'ROUTE_SCHEMA') throw error; + throw new KernelError('ROUTE_SCHEMA', 'route map must contain only closed canonical data'); + } + if (captured.schemaVersion !== 1 || !Array.isArray(captured.routes) + || captured.routes.length < 1) { + fail('ROUTE_SCHEMA', 'route map must have schemaVersion 1 and at least one route'); + } + if (captured.routes.length > MAXIMUM_ROUTES) { + fail('ROUTE_LIMIT', 'route map exceeds the maximum route count'); + } + const ids = new Set(); + const normalized = captured.routes.map((input, index) => { + const entry = exactRecord( + input, + ROUTE_FIELDS, + [], + 'ROUTE_SCHEMA', + `route ${index}`, + ); + const id = validateRouteId(entry.id); + if (ids.has(id)) fail('ROUTE_DUPLICATE', 'route IDs must be unique'); + ids.add(id); + if (!ROUTE_KINDS.has(entry.kind)) { + fail('ROUTE_KIND', 'route kind must be openai-chat or tool'); + } + if (entry.method !== 'POST') fail('ROUTE_METHOD', 'route method must be exact POST'); + if (entry.resourceMimeType !== 'application/json' + || !Array.isArray(entry.requestContentTypes) + || entry.requestContentTypes.length !== 1 + || entry.requestContentTypes[0] !== 'application/json') { + fail('ROUTE_CONTENT_TYPE', 'route content types must be exact application/json'); + } + const purposeLabel = boundedPublicText( + entry.purposeLabel, + MAXIMUM_PURPOSE_LABEL_BYTES, + 'route purpose label', + ); + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(purposeLabel)) { + fail('ROUTE_METADATA', 'route purpose label must be one canonical token'); + } + return frozenCopy({ + id, + kind: entry.kind, + method: entry.method, + upstreamUrl: canonicalRouteUrl(entry.upstreamUrl, mode), + resourceDescription: boundedPublicText( + entry.resourceDescription, + MAXIMUM_ROUTE_DESCRIPTION_BYTES, + 'route resource description', + ), + resourceMimeType: entry.resourceMimeType, + purposeLabel, + requestContentTypes: ['application/json'], + maximumRequestBytes: boundedRouteBytes( + entry.maximumRequestBytes, + MAXIMUM_REQUEST_BYTES, + 'route maximum request bytes', + ), + maximumResponseBytes: boundedRouteBytes( + entry.maximumResponseBytes, + MAXIMUM_RESPONSE_BYTES, + 'route maximum response bytes', + ), + }); + }); + + try { + const normalizedDocument = { schemaVersion: 1, routes: normalized }; + if (Buffer.byteLength(canonicalJson(normalizedDocument), 'utf8') + > MAXIMUM_ROUTE_DOCUMENT_BYTES) { + fail('ROUTE_LIMIT', 'route map exceeds the canonical document byte ceiling'); + } + } catch (error) { + if (error instanceof KernelError && error.code === 'ROUTE_LIMIT') throw error; + fail('ROUTE_SCHEMA', 'route map must contain only canonical JSON data'); + } + + const byId = new Map(normalized.map((entry) => [entry.id, entry])); + const get = Object.freeze(function get(routeId) { + if (arguments.length !== 1) { + fail('ROUTE_LOOKUP', 'route lookup accepts exactly one route ID'); + } + if (typeof routeId !== 'string') return null; + return byId.get(routeId) ?? null; + }); + return Object.freeze({ + schemaVersion: 1, + routes: Object.freeze(normalized), + get, + }); +} diff --git a/spikes/pi-wielder/src/control-plane.mjs b/spikes/pi-wielder/src/control-plane.mjs new file mode 100644 index 0000000..594620d --- /dev/null +++ b/spikes/pi-wielder/src/control-plane.mjs @@ -0,0 +1,1372 @@ +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { types as utilTypes } from 'node:util'; + +import { Hono } from 'hono'; + +import { createAgentAuth } from './agent/auth.mjs'; +import { + loadControlPlaneConfig, + readBoundedRouteDocument, + validateRouteMap, +} from './config.mjs'; +import { + canonicalJson, + KernelError, + sha256, +} from './kernel/canonical.mjs'; +import { createAuthorityMutationCoordinator } from './kernel/authority-mutation-coordinator.mjs'; +import { validatePolicyDocument } from './kernel/policy-engine.mjs'; +import { createReconciler } from './kernel/recovery.mjs'; +import { createWalletKernel } from './kernel/wallet-kernel.mjs'; +import { + createOperatorApp, + projectOperatorPublicResult, +} from './operator/api.mjs'; +import { createOperatorConsoleApp } from './operator/console.mjs'; +import { createSpendControlProxy } from './spend-control-proxy.mjs'; + +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const INSTANCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/; +const MODES = new Set(['deterministic', 'cdp-testnet']); +const OPERATOR_READ_NAMES = Object.freeze([ + 'overview', + 'listPolicies', + 'walletIdentity', + 'listApprovals', + 'listReceipts', + 'getReceipt', + 'exportSession', + 'receiptPublicKey', +]); +const DEPENDENCY_FIELDS = new Set([ + 'checkoutRoot', + 'deterministicEndpoints', + 'loadConfig', + 'readRouteDocument', + 'verifyRelease', + 'acquireAuthorityLock', + 'openAuthority', + 'recoverAuthority', + 'createAuthorityMutationCoordinator', + 'createWalletKernel', + 'createReconciler', + 'createAgentAuth', + 'createSpendControlProxy', + 'createOperatorApp', + 'createOperatorConsoleApp', + 'assertLiveAdmission', + 'listenOperatorAdmin', + 'listenOperatorConsole', + 'listenAgent', + 'prepareStartupReport', + 'publishReady', + 'scheduleShutdown', +]); +const AUTHORITY_FIELDS = Object.freeze([ + 'activePolicy', + 'activeEnrollment', + 'bindingsForEnrollment', + 'walletIdentity', + 'operatorAuth', + 'operatorReads', + 'agentAuthDependencies', + 'createKernelDependencies', + 'reconcilerDependencies', + 'recoveryDependencies', + 'recoverySessionCloser', + 'close', +]); +const OPTIONAL_AUTHORITY_FIELDS = Object.freeze(['waitForUnsignedWork']); +const RESERVED_FACTORY_DEPENDENCIES = new Set([ + 'authorityMutationCoordinator', + 'markAuthorityUnhealthy', +]); +const INTERNALS = new WeakMap(); + +function fail(code, message, cause) { + throw new KernelError(code, message, cause === undefined ? undefined : { cause }); +} + +function isPlainRecord(value) { + return Boolean(value) + && typeof value === 'object' + && !Array.isArray(value) + && !utilTypes.isProxy(value) + && (Object.getPrototypeOf(value) === Object.prototype + || Object.getPrototypeOf(value) === null); +} + +function captureRecord(value, label, { allowed, required = [] } = {}) { + if (!isPlainRecord(value)) throw new TypeError(`${label} must be one plain object`); + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string') + || (allowed && keys.some((key) => !allowed.has(key))) + || required.some((key) => !Object.hasOwn(descriptors, key))) { + throw new TypeError(`${label} has an invalid shape`); + } + const result = {}; + for (const key of keys) { + const descriptor = descriptors[key]; + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError(`${label} must contain only enumerable data fields`); + } + result[key] = descriptor.value; + } + return result; +} + +function ordinaryFunction(value, label, { optional = false } = {}) { + if (optional && value === undefined) return null; + if (typeof value !== 'function' || utilTypes.isProxy(value)) { + throw new TypeError(`${label} must be one non-proxy function`); + } + return value; +} + +function ownData(value, field, code, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) { + fail(code, `${label} is invalid`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, field); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail(code, `${label} is invalid`); + } + return descriptor.value; +} + +function canonicalHash(value, code, label) { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + fail(code, `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalToken(value, code, label, pattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/) { + if (typeof value !== 'string' || !pattern.test(value)) { + fail(code, `${label} is invalid`); + } + return value; +} + +function captureCanonicalTokenArray(value, code, label, maximumLength) { + if (!Array.isArray(value) || utilTypes.isProxy(value) + || Object.getPrototypeOf(value) !== Array.prototype) { + fail(code, `${label} must be one ordinary array`); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const lengthDescriptor = descriptors.length; + if (!lengthDescriptor || !Object.hasOwn(lengthDescriptor, 'value') + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 || lengthDescriptor.value > maximumLength) { + fail(code, `${label} length is invalid`); + } + const length = lengthDescriptor.value; + const keys = Reflect.ownKeys(descriptors); + if (keys.some((key) => typeof key !== 'string') || keys.length !== length + 1) { + fail(code, `${label} must be one dense data-only array`); + } + const captured = []; + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail(code, `${label} must be one dense data-only array`); + } + captured.push(canonicalToken( + descriptor.value, + code, + `${label} entry`, + )); + } + if (new Set(captured).size !== captured.length) { + fail(code, `${label} entries must be unique`); + } + return Object.freeze(captured); +} + +function captureOptions(value) { + const input = value === undefined ? {} : captureRecord(value, 'control plane options', { + allowed: new Set(['env', 'dependencies']), + }); + const env = Object.hasOwn(input, 'env') ? input.env : process.env; + if (!env || typeof env !== 'object' || utilTypes.isProxy(env)) { + throw new TypeError('control plane environment must be one non-proxy object'); + } + const supplied = Object.hasOwn(input, 'dependencies') ? input.dependencies : {}; + const captured = captureRecord(supplied, 'control plane dependencies', { + allowed: DEPENDENCY_FIELDS, + }); + return Object.freeze({ env, dependencies: captured }); +} + +function defaultCheckoutRoot() { + const sourceDirectory = path.dirname(fileURLToPath(import.meta.url)); + const packageRoot = path.resolve(sourceDirectory, '..'); + // A source checkout nests this package under `spikes/pi-wielder`; an installed + // immutable release places `src/` directly below the release root. Keep the + // exclusion boundary exact in either layout instead of broadening it to an + // arbitrary ancestor such as `/`. + return path.basename(packageRoot) === 'pi-wielder' + && path.basename(path.dirname(packageRoot)) === 'spikes' + ? path.resolve(packageRoot, '../..') + : packageRoot; +} + +function defaultsFor(input) { + const dependency = (name, fallback) => Object.hasOwn(input, name) ? input[name] : fallback; + const missing = (name) => async () => { + throw new TypeError(`control plane dependency ${name} is required`); + }; + const checkoutRoot = dependency('checkoutRoot', defaultCheckoutRoot()); + if (typeof checkoutRoot !== 'string' || !path.isAbsolute(checkoutRoot)) { + throw new TypeError('control plane checkoutRoot must be absolute'); + } + const functions = { + loadConfig: dependency('loadConfig', loadControlPlaneConfig), + readRouteDocument: dependency('readRouteDocument', readBoundedRouteDocument), + verifyRelease: dependency('verifyRelease', missing('verifyRelease')), + acquireAuthorityLock: dependency( + 'acquireAuthorityLock', + missing('acquireAuthorityLock'), + ), + openAuthority: dependency('openAuthority', missing('openAuthority')), + recoverAuthority: dependency('recoverAuthority', missing('recoverAuthority')), + createAuthorityMutationCoordinator: dependency( + 'createAuthorityMutationCoordinator', + createAuthorityMutationCoordinator, + ), + createWalletKernel: dependency('createWalletKernel', createWalletKernel), + createReconciler: dependency('createReconciler', createReconciler), + createAgentAuth: dependency('createAgentAuth', createAgentAuth), + createSpendControlProxy: dependency('createSpendControlProxy', createSpendControlProxy), + createOperatorApp: dependency('createOperatorApp', createOperatorApp), + createOperatorConsoleApp: dependency( + 'createOperatorConsoleApp', + createOperatorConsoleApp, + ), + assertLiveAdmission: dependency('assertLiveAdmission', missing('assertLiveAdmission')), + listenOperatorAdmin: dependency('listenOperatorAdmin', missing('listenOperatorAdmin')), + listenOperatorConsole: dependency( + 'listenOperatorConsole', + missing('listenOperatorConsole'), + ), + listenAgent: dependency('listenAgent', missing('listenAgent')), + prepareStartupReport: dependency('prepareStartupReport', async () => undefined), + publishReady: dependency('publishReady', async () => undefined), + scheduleShutdown: dependency('scheduleShutdown', (operation) => queueMicrotask(operation)), + }; + for (const [name, value] of Object.entries(functions)) ordinaryFunction(value, name); + return Object.freeze({ + checkoutRoot, + deterministicEndpoints: Object.hasOwn(input, 'deterministicEndpoints') + ? input.deterministicEndpoints + : null, + ...functions, + }); +} + +function configuredModeHint(env) { + const descriptor = Object.getOwnPropertyDescriptor(env, 'WALLET_KERNEL_MODE'); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) return null; + return typeof descriptor.value === 'string' ? descriptor.value : null; +} + +function normalizeLiveRelease(value) { + const fields = [ + 'releaseManifestHash', + 'releaseTreeHash', + 'nodeExecutableHash', + 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', + 'environmentMetadataHash', + ]; + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) { + fail('RELEASE_VERIFY_INPUT', 'live release verification result is invalid'); + } + const projection = {}; + for (const field of fields) { + const descriptor = Object.getOwnPropertyDescriptor(value, field); + if (field === 'releaseManifestHash' && !descriptor) { + fail('RELEASE_VERIFY_INPUT', 'live release verification returned no manifest hash'); + } + if (!descriptor) continue; + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail('RELEASE_VERIFY_INPUT', 'live release verification result is not inert data'); + } + projection[field] = canonicalHash( + descriptor.value, + 'RELEASE_VERIFY_INPUT', + field, + ); + } + return Object.freeze({ deployment: 'verified', ...projection }); +} + +function validateLoadedConfig(value) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) { + throw new TypeError('control plane configuration result is invalid'); + } + const publicConfig = ownData( + value, + 'publicConfig', + 'CONTROL_PLANE_CONFIG', + 'control plane public configuration', + ); + const assertCredentialPresence = ownData( + value, + 'assertCredentialPresence', + 'CONTROL_PLANE_CONFIG', + 'control plane credential gate', + ); + ordinaryFunction(assertCredentialPresence, 'control plane credential gate'); + if (!publicConfig || typeof publicConfig !== 'object' || utilTypes.isProxy(publicConfig) + || !MODES.has(publicConfig.mode) + || publicConfig.agentHost !== '127.0.0.1' + || publicConfig.operatorHost !== '127.0.0.1' + || !Number.isSafeInteger(publicConfig.agentPort) + || publicConfig.agentPort < 1 || publicConfig.agentPort > 65_535 + || !Number.isSafeInteger(publicConfig.operatorPort) + || publicConfig.operatorPort < 1 || publicConfig.operatorPort > 65_535 + || publicConfig.agentPort === publicConfig.operatorPort) { + fail('CONTROL_PLANE_CONFIG', 'control plane public configuration is invalid'); + } + return Object.freeze({ publicConfig, assertCredentialPresence }); +} + +function positivePort(value, label) { + if (!Number.isSafeInteger(value) || value < 1 || value > 65_535) { + fail('CONTROL_PLANE_CONFIG', `${label} must be a nonzero TCP port`); + } + return value; +} + +function endpointsFor(config, injected) { + if (injected === null) { + return Object.freeze({ + agentHost: config.agentHost, + agentPort: config.agentPort, + operatorHost: config.operatorHost, + operatorPort: config.operatorPort, + }); + } + if (config.mode !== 'deterministic') { + fail('CONTROL_PLANE_CONFIG', 'listener endpoint injection is deterministic-only'); + } + const captured = captureRecord(injected, 'deterministic listener endpoints', { + allowed: new Set(['agentHost', 'agentPort', 'operatorHost', 'operatorPort']), + required: ['agentHost', 'agentPort', 'operatorHost', 'operatorPort'], + }); + if (captured.agentHost !== '127.0.0.1' || captured.operatorHost !== '127.0.0.1') { + fail('CONTROL_PLANE_CONFIG', 'deterministic listeners must use literal loopback'); + } + const agentPort = positivePort(captured.agentPort, 'agent port'); + const operatorPort = positivePort(captured.operatorPort, 'operator port'); + if (agentPort === operatorPort) { + fail('CONTROL_PLANE_CONFIG', 'agent and operator listeners must be distinct'); + } + return Object.freeze({ + agentHost: captured.agentHost, + agentPort, + operatorHost: captured.operatorHost, + operatorPort, + }); +} + +function captureDependencyBag(value, label) { + const captured = captureRecord(value, label); + if (Reflect.ownKeys(captured).some((key) => RESERVED_FACTORY_DEPENDENCIES.has(key))) { + fail( + 'CONTROL_PLANE_AUTHORITY_INJECTION', + `${label} may not supply an authority coordinator or fail-stop hook`, + ); + } + return captured; +} + +function captureFunctionSurface(value, names, label) { + const captured = captureRecord(value, label, { + allowed: new Set(names), + required: names, + }); + const surface = {}; + for (const name of names) { + const fn = ordinaryFunction(captured[name], `${label}.${name}`); + surface[name] = (...args) => Reflect.apply(fn, value, args); + } + return Object.freeze(surface); +} + +function captureAuthority(value) { + const allowed = new Set([...AUTHORITY_FIELDS, ...OPTIONAL_AUTHORITY_FIELDS]); + const captured = captureRecord(value, 'control plane authority', { + allowed, + required: AUTHORITY_FIELDS, + }); + for (const name of [ + 'activePolicy', + 'activeEnrollment', + 'bindingsForEnrollment', + 'walletIdentity', + 'createKernelDependencies', + 'recoverySessionCloser', + 'close', + ]) ordinaryFunction(captured[name], `control plane authority.${name}`); + if (Object.hasOwn(captured, 'waitForUnsignedWork')) { + ordinaryFunction(captured.waitForUnsignedWork, 'control plane authority.waitForUnsignedWork'); + } + if (!captured.operatorAuth || typeof captured.operatorAuth !== 'object' + || utilTypes.isProxy(captured.operatorAuth)) { + fail('CONTROL_PLANE_DEPENDENCY', 'operator authentication facade is invalid'); + } + let operatorReads; + try { + operatorReads = captureFunctionSurface( + captured.operatorReads, + OPERATOR_READ_NAMES, + 'operator read services', + ); + } catch (cause) { + fail( + 'CONTROL_PLANE_DEPENDENCY', + 'operator read services must be one exact narrow function facade', + cause, + ); + } + return Object.freeze({ + ...captured, + operatorReads, + agentAuthDependencies: captureDependencyBag( + captured.agentAuthDependencies, + 'agent authentication dependencies', + ), + reconcilerDependencies: captureDependencyBag( + captured.reconcilerDependencies, + 'reconciler dependencies', + ), + recoveryDependencies: captureDependencyBag( + captured.recoveryDependencies, + 'recovery dependencies', + ), + }); +} + +function validateWalletIdentity(value, config) { + const network = ownData(value, 'network', 'CONTROL_PLANE_WALLET', 'wallet identity'); + const address = ownData(value, 'address', 'CONTROL_PLANE_WALLET', 'wallet identity'); + if (network !== config.network || typeof address !== 'string' + || !ADDRESS_PATTERN.test(address)) { + fail('CONTROL_PLANE_WALLET', 'wallet identity differs from the closed configuration'); + } + return Object.freeze({ network, address }); +} + +function validatePolicyVersion(value, walletIdentity) { + const id = ownData(value, 'id', 'POLICY_CORRUPTION', 'active PolicyVersion'); + const hash = ownData(value, 'hash', 'POLICY_CORRUPTION', 'active PolicyVersion'); + const document = ownData(value, 'policy', 'POLICY_CORRUPTION', 'active PolicyVersion'); + canonicalToken(id, 'POLICY_CORRUPTION', 'active PolicyVersion ID'); + canonicalHash(hash, 'POLICY_CORRUPTION', 'active PolicyVersion hash'); + let policy; + try { + policy = validatePolicyDocument(document); + } catch (cause) { + fail('POLICY_CORRUPTION', 'active PolicyVersion document is invalid', cause); + } + if (canonicalJson(policy) !== canonicalJson(document) + || sha256(canonicalJson(policy)) !== hash + || policy.wallet !== walletIdentity.address + || policy.network !== walletIdentity.network) { + fail('POLICY_CORRUPTION', 'active PolicyVersion differs from wallet authority'); + } + return Object.freeze({ id, hash, policy }); +} + +function validateEnrollment(value, config) { + if (value === null) return null; + const agentInstanceId = ownData( + value, + 'agentInstanceId', + 'AGENT_ENROLLMENT_CORRUPTION', + 'active enrollment', + ); + const credentialDigest = ownData( + value, + 'credentialDigest', + 'AGENT_ENROLLMENT_CORRUPTION', + 'active enrollment', + ); + const enrollmentHash = ownData( + value, + 'enrollmentHash', + 'AGENT_ENROLLMENT_CORRUPTION', + 'active enrollment', + ); + const agentUid = ownData( + value, + 'agentUid', + 'AGENT_ENROLLMENT_CORRUPTION', + 'active enrollment', + ); + const agentGid = ownData( + value, + 'agentGid', + 'AGENT_ENROLLMENT_CORRUPTION', + 'active enrollment', + ); + canonicalToken( + agentInstanceId, + 'AGENT_ENROLLMENT_CORRUPTION', + 'active agent instance ID', + INSTANCE_PATTERN, + ); + canonicalHash(credentialDigest, 'AGENT_ENROLLMENT_CORRUPTION', 'credential digest'); + canonicalHash(enrollmentHash, 'AGENT_ENROLLMENT_CORRUPTION', 'enrollment hash'); + if (agentUid !== String(config.expectedAgentUid) + || agentGid !== String(config.expectedAgentGid) + || (Object.hasOwn(value, 'state') && value.state !== 'active')) { + fail('AGENT_IDENTITY_MISMATCH', 'active enrollment differs from configured identity'); + } + return Object.freeze({ + agentInstanceId, + credentialDigest, + enrollmentHash, + agentUid, + agentGid, + }); +} + +function assertRoutePolicyBindings(routes, activePolicy, mode) { + for (const route of routes.routes) { + const parsed = new URL(route.upstreamUrl); + const sellers = activePolicy.policy.sellers.filter((seller) => seller.origin === parsed.origin); + if (sellers.length !== 1 + || !activePolicy.policy.methods.includes(route.method) + || !sellers[0].pathPrefixes.some((prefix) => parsed.pathname.startsWith(prefix))) { + fail( + 'ROUTE_POLICY_MISMATCH', + 'configured route is not authorized by one exact active PolicyVersion seller binding', + ); + } + } + if (mode === 'cdp-testnet' + && activePolicy.policy.sellers.some((seller) => ( + new URL(seller.origin).protocol !== 'https:' + || new URL(seller.evidencePath, `${seller.origin}/`).protocol !== 'https:' + ))) { + fail('ROUTE_POLICY_MISMATCH', 'live policy sellers and evidence must use HTTPS'); + } +} + +function captureBinding(value, enrollment, walletIdentity, activePolicy) { + const fields = captureRecord(value, 'agent session binding', { + allowed: new Set([ + 'bindingId', 'agentInstanceId', 'credentialDigest', 'enrollmentHash', 'state', 'session', + ]), + required: [ + 'bindingId', 'agentInstanceId', 'credentialDigest', 'enrollmentHash', 'state', 'session', + ], + }); + const sessionValue = fields.session; + if (!sessionValue || typeof sessionValue !== 'object' || utilTypes.isProxy(sessionValue)) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'agent binding references no Spend Session'); + } + const sessionFields = Object.fromEntries([ + 'id', 'agentInstanceId', 'enrollmentHash', 'walletAddress', 'policyVersionId', 'state', + ].map((name) => [name, ownData( + sessionValue, + name, + 'SESSION_AUTHORITY_AMBIGUOUS', + 'bound Spend Session', + )])); + if (typeof fields.bindingId !== 'string' || fields.bindingId.length === 0 + || fields.state !== 'open' + || fields.agentInstanceId !== enrollment.agentInstanceId + || fields.credentialDigest !== enrollment.credentialDigest + || fields.enrollmentHash !== enrollment.enrollmentHash + || sessionFields.agentInstanceId !== enrollment.agentInstanceId + || sessionFields.enrollmentHash !== enrollment.enrollmentHash + || sessionFields.walletAddress !== walletIdentity.address + || !['open', 'policy_blocked'].includes(sessionFields.state)) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'agent Spend Session binding is inconsistent'); + } + if (sessionFields.state === 'open' && sessionFields.policyVersionId !== activePolicy.id) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'open binding is pinned to a non-active policy'); + } + if (sessionFields.state === 'policy_blocked' + && sessionFields.policyVersionId === activePolicy.id) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'blocked binding is already pinned to active policy'); + } + return Object.freeze({ ...fields, session: sessionValue }); +} + +function validateBindings(value, enrollment, walletIdentity, activePolicy) { + if (!Array.isArray(value) || value.length > 1) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'agent has multiple candidate Spend Sessions'); + } + if (value.length === 0) return null; + return captureBinding(value[0], enrollment, walletIdentity, activePolicy); +} + +function recoveryOnlyAgentApp() { + const app = new Hono({ strict: true }); + app.all('/agent/v1/*', (context) => { + context.header('Cache-Control', 'no-store'); + context.header('X-Content-Type-Options', 'nosniff'); + return context.json({ + error: { + code: 'AGENT_ENROLLMENT_REQUIRED', + message: 'Agent enrollment is required', + }, + }, 503); + }); + app.notFound((context) => context.json({ + error: { + code: 'AGENT_ROUTE_NOT_FOUND', + message: 'Agent route does not exist', + }, + }, 404, { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })); + return app; +} + +function ensureFacade(value, methods, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) { + throw new TypeError(`${label} is invalid`); + } + for (const method of methods) { + ordinaryFunction(ownData(value, method, 'CONTROL_PLANE_DEPENDENCY', label), `${label}.${method}`); + } + return value; +} + +function forbiddenRecoveryOperation() { + throw new KernelError( + 'RECOVERY_ONLY_OPERATION_FORBIDDEN', + 'operation is forbidden while the Wallet Kernel is recovery-only', + ); +} + +function transitionRequest(value) { + const request = captureRecord(value, 'session policy transition service request', { + allowed: new Set(['sessionId', 'targetPolicyHash', 'expectedSessionHash']), + required: ['sessionId', 'targetPolicyHash', 'expectedSessionHash'], + }); + canonicalToken(request.sessionId, 'SESSION_TRANSITION_SCHEMA', 'session ID'); + canonicalHash(request.targetPolicyHash, 'SESSION_TRANSITION_SCHEMA', 'target policy hash'); + canonicalHash(request.expectedSessionHash, 'SESSION_TRANSITION_SCHEMA', 'session hash'); + return request; +} + +function publicPolicyApplyResult(value, expectedPolicyHash) { + const resultFields = ['policyVersion', 'blockedSessionIds', 'idempotent']; + const result = captureRecord(value, 'policy mutation result', { + allowed: new Set(resultFields), + required: resultFields, + }); + const versionFields = [ + 'id', 'schemaVersion', 'policy', 'canonicalJson', 'hash', 'predecessorHash', 'appliedAt', + ]; + const version = captureRecord(result.policyVersion, 'applied PolicyVersion', { + allowed: new Set(versionFields), + required: versionFields, + }); + const policy = validatePolicyDocument(version.policy); + const canonical = canonicalJson(policy); + const blockedSessionIds = captureCanonicalTokenArray( + result.blockedSessionIds, + 'POLICY_CORRUPTION', + 'blocked Spend Session IDs', + 10_000, + ); + const appliedMilliseconds = typeof version.appliedAt === 'string' + ? Date.parse(version.appliedAt) + : Number.NaN; + canonicalToken(version.id, 'POLICY_CORRUPTION', 'PolicyVersion ID'); + if (version.schemaVersion !== policy.schemaVersion + || version.canonicalJson !== canonical + || version.hash !== expectedPolicyHash + || version.hash !== sha256(canonical) + || (version.predecessorHash !== null + && (typeof version.predecessorHash !== 'string' + || !HASH_PATTERN.test(version.predecessorHash))) + || !Number.isFinite(appliedMilliseconds) + || new Date(appliedMilliseconds).toISOString() !== version.appliedAt + || typeof result.idempotent !== 'boolean') { + fail('POLICY_CORRUPTION', 'policy mutation returned a corrupt public projection'); + } + return Object.freeze({ + policyVersion: Object.freeze({ + versionId: version.id, + policy, + policyHash: version.hash, + predecessorHash: version.predecessorHash, + createdAt: version.appliedAt, + active: true, + }), + blockedSessionIds, + idempotent: result.idempotent, + }); +} + +function publicApprovalResult(value, expectedOperatorIdHash) { + const fields = [ + 'approvalId', + 'intentId', + 'decision', + 'operatorIdHash', + 'intentHash', + 'challengeHash', + 'quoteId', + 'acceptedIndex', + 'amountCeilingAtomic', + 'walletAddress', + 'policyVersionId', + 'expiresAt', + 'reasonCode', + 'decidedAt', + 'consumedAt', + ]; + const approval = captureRecord(value, 'approval mutation result', { + allowed: new Set(fields), + required: fields, + }); + if (approval.operatorIdHash !== expectedOperatorIdHash) { + fail('APPROVAL_CORRUPTION', 'approval mutation changed its operator authority'); + } + return Object.freeze({ + approvalId: approval.approvalId, + intentId: approval.intentId, + decision: approval.decision, + intentHash: approval.intentHash, + challengeHash: approval.challengeHash, + quoteId: approval.quoteId, + acceptedIndex: approval.acceptedIndex, + amountAtomic: approval.amountCeilingAtomic, + walletAddress: approval.walletAddress, + policyVersionId: approval.policyVersionId, + expiresAt: approval.expiresAt, + reasonCode: approval.reasonCode, + recordedAt: approval.decidedAt, + consumedAt: approval.consumedAt, + }); +} + +function publicRevocationResult(value, expected) { + const result = captureRecord(value, 'agent revocation result', { + allowed: new Set(['enrollment', 'boundSessionIds']), + required: ['enrollment', 'boundSessionIds'], + }); + const fields = [ + 'agentInstanceId', + 'credentialDigest', + 'enrollmentHash', + 'agentUid', + 'agentGid', + 'state', + 'enrolledByOperatorHash', + 'enrolledAt', + 'revokedByOperatorHash', + 'revokedAt', + 'isolation', + ]; + const enrollment = captureRecord(result.enrollment, 'revoked agent enrollment', { + allowed: new Set(fields), + required: fields, + }); + const sessions = captureCanonicalTokenArray( + result.boundSessionIds, + 'AGENT_ENROLLMENT_CORRUPTION', + 'revoked enrollment bound Spend Session IDs', + 10_000, + ); + const enrolledAt = typeof enrollment.enrolledAt === 'string' + ? Date.parse(enrollment.enrolledAt) + : Number.NaN; + const revokedAt = typeof enrollment.revokedAt === 'string' + ? Date.parse(enrollment.revokedAt) + : Number.NaN; + canonicalToken( + enrollment.agentInstanceId, + 'AGENT_ENROLLMENT_CORRUPTION', + 'revoked Agent instance ID', + INSTANCE_PATTERN, + ); + for (const [hash, label] of [ + [enrollment.credentialDigest, 'revoked credential digest'], + [enrollment.enrollmentHash, 'revoked enrollment hash'], + [enrollment.enrolledByOperatorHash, 'enrollment operator hash'], + [enrollment.revokedByOperatorHash, 'revocation operator hash'], + ]) canonicalHash(hash, 'AGENT_ENROLLMENT_CORRUPTION', label); + if (enrollment.agentInstanceId !== expected.agentInstanceId + || enrollment.enrollmentHash !== expected.expectedEnrollmentHash + || enrollment.revokedByOperatorHash !== expected.operatorIdHash + || !/^[1-9][0-9]*$/.test(enrollment.agentUid) + || !/^[1-9][0-9]*$/.test(enrollment.agentGid) + || !Number.isSafeInteger(Number(enrollment.agentUid)) + || !Number.isSafeInteger(Number(enrollment.agentGid)) + || enrollment.state !== 'revoked' + || !new Set(['simulated', 'pending_verification']).has(enrollment.isolation) + || !Number.isFinite(enrolledAt) + || new Date(enrolledAt).toISOString() !== enrollment.enrolledAt + || !Number.isFinite(revokedAt) + || new Date(revokedAt).toISOString() !== enrollment.revokedAt + || revokedAt < enrolledAt) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'agent revocation returned a corrupt public projection'); + } + return Object.freeze({ + agentEnrollment: Object.freeze({ + agentInstanceId: enrollment.agentInstanceId, + enrollmentHash: enrollment.enrollmentHash, + agentUid: enrollment.agentUid, + agentGid: enrollment.agentGid, + state: enrollment.state, + isolation: enrollment.isolation, + enrolledAt: enrollment.enrolledAt, + revokedAt: enrollment.revokedAt, + }), + sessions, + }); +} + +function publicClosedSessionResult(value, expectedSessionId) { + const result = captureRecord(value, 'guarded session close result', { + allowed: new Set(['closedSession']), + required: ['closedSession'], + }); + const fields = [ + 'id', 'adapterId', 'agentInstanceId', 'enrollmentHash', 'walletAddress', + 'policyVersionId', 'state', 'createdAt', 'closedAt', 'sessionHash', + ]; + const session = captureRecord(result.closedSession, 'closed Spend Session', { + allowed: new Set(fields), + required: fields, + }); + canonicalToken(session.id, 'SESSION_AUTHORITY_AMBIGUOUS', 'closed Spend Session ID'); + canonicalToken(session.adapterId, 'SESSION_AUTHORITY_AMBIGUOUS', 'closed adapter ID'); + canonicalToken( + session.agentInstanceId, + 'SESSION_AUTHORITY_AMBIGUOUS', + 'closed Agent instance ID', + INSTANCE_PATTERN, + ); + canonicalHash(session.enrollmentHash, 'SESSION_AUTHORITY_AMBIGUOUS', 'enrollment hash'); + canonicalToken( + session.policyVersionId, + 'SESSION_AUTHORITY_AMBIGUOUS', + 'closed PolicyVersion ID', + ); + canonicalHash(session.sessionHash, 'SESSION_AUTHORITY_AMBIGUOUS', 'closed session hash'); + const createdAt = typeof session.createdAt === 'string' ? Date.parse(session.createdAt) : NaN; + const closedAt = typeof session.closedAt === 'string' ? Date.parse(session.closedAt) : NaN; + if (session.id !== expectedSessionId + || typeof session.walletAddress !== 'string' + || !ADDRESS_PATTERN.test(session.walletAddress) + || session.state !== 'closed' + || !Number.isFinite(createdAt) + || new Date(createdAt).toISOString() !== session.createdAt + || !Number.isFinite(closedAt) + || new Date(closedAt).toISOString() !== session.closedAt + || closedAt < createdAt) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'guarded close returned a corrupt public projection'); + } + return Object.freeze({ session: Object.freeze(session) }); +} + +function createOperatorServices({ mode, authority, kernel, reconciler, walletIdentity }) { + const read = authority.operatorReads; + const forbidden = async () => forbiddenRecoveryOperation(); + const normal = mode === 'normal'; + return Object.freeze({ + overview: read.overview, + listPolicies: normal ? read.listPolicies : forbidden, + walletIdentity: read.walletIdentity, + applyPolicy: normal ? async (input) => publicPolicyApplyResult( + await kernel.applyPolicy(input), + input.expectedPolicyHash, + ) : forbidden, + revokeAgent: normal ? async (input) => publicRevocationResult( + await kernel.revokeAgent(input), + input, + ) : forbidden, + transitionSessionPolicy: normal ? async (input) => { + const request = transitionRequest(input); + const target = validatePolicyVersion(authority.activePolicy(), walletIdentity); + if (target.hash !== request.targetPolicyHash) { + fail('POLICY_NOT_ACTIVE', 'target policy hash is not the active PolicyVersion'); + } + return await kernel.transitionSessionPolicy(Object.freeze({ + sessionId: request.sessionId, + targetPolicyVersionId: target.id, + expectedSessionHash: request.expectedSessionHash, + })); + } : forbidden, + closeSession: normal + ? async (input) => publicClosedSessionResult( + await kernel.closeSession(input), + input.sessionId, + ) + : async (input) => publicClosedSessionResult( + await authority.recoverySessionCloser(input), + input.sessionId, + ), + listApprovals: normal ? read.listApprovals : forbidden, + approvePending: normal ? async (input) => publicApprovalResult( + await kernel.approvePending(input), + input.operatorIdHash, + ) : forbidden, + denyPending: normal ? (input) => kernel.denyPending(input) : forbidden, + listReceipts: read.listReceipts, + getReceipt: read.getReceipt, + reconcilePayment: (input) => reconciler.reconcilePayment(input), + reconcileExecution: (input) => reconciler.reconcileExecution(input), + reconcileRefundObservation: (input) => reconciler.observeRefund(input), + abandonCandidate: (input) => reconciler.abandonCandidate(input), + exportSession: read.exportSession, + receiptPublicKey: read.receiptPublicKey, + }); +} + +function operatorOrigin(endpoints) { + return `http://${endpoints.operatorHost}:${endpoints.operatorPort}`; +} + +function createOperatorApplications({ dependencies, config, endpoints, authority, services }) { + const origin = operatorOrigin(endpoints); + const common = { + auth: authority.operatorAuth, + services, + bodyLimits: Object.freeze({ jsonBytes: 1_048_576 }), + mode: config.mode, + origin, + }; + if (config.mode === 'deterministic') { + const api = dependencies.createOperatorApp({ ...common, transport: 'loopback-demo' }); + return Object.freeze({ + admin: null, + console: dependencies.createOperatorConsoleApp({ operatorApp: api }), + }); + } + const admin = dependencies.createOperatorApp({ ...common, transport: 'unix' }); + const consoleApi = dependencies.createOperatorApp({ + ...common, + transport: 'socket-activated-loopback', + }); + return Object.freeze({ + admin, + console: dependencies.createOperatorConsoleApp({ operatorApp: consoleApi }), + }); +} + +function healthProjection(state) { + return Object.freeze({ + mode: state.compositionMode, + admission: state.gate, + reasonCode: state.reasonCode, + deployment: state.deployment, + isolation: state.isolation, + sessionState: state.sessionState, + }); +} + +async function closeResource(resource) { + if (!resource) return; + const close = resource.close; + if (typeof close !== 'function' || utilTypes.isProxy(close)) { + throw new TypeError('control plane lifecycle resource must expose close()'); + } + await Reflect.apply(close, resource, []); +} + +async function closeCreatedAuthority(authority, lock) { + let firstError = null; + for (const resource of [authority, lock]) { + try { + await closeResource(resource); + } catch (error) { + firstError ??= error; + } + } + if (firstError) throw firstError; +} + +export async function createControlPlane(options = undefined) { + const captured = captureOptions(options); + const dependencies = defaultsFor(captured.dependencies); + const modeHint = configuredModeHint(captured.env); + let release = null; + if (modeHint === 'cdp-testnet') { + release = await dependencies.verifyRelease(Object.freeze({ + env: captured.env, + checkoutRoot: dependencies.checkoutRoot, + })); + } + const loaded = validateLoadedConfig(dependencies.loadConfig({ + env: captured.env, + checkoutRoot: dependencies.checkoutRoot, + })); + const config = loaded.publicConfig; + if (modeHint !== null && modeHint !== config.mode) { + fail('CONTROL_PLANE_CONFIG', 'early mode and validated configuration disagree'); + } + if (config.mode === 'cdp-testnet' && release === null) { + release = await dependencies.verifyRelease(Object.freeze({ + env: captured.env, + checkoutRoot: dependencies.checkoutRoot, + })); + } + if (config.mode === 'deterministic') { + release = Object.freeze({ deployment: 'simulated', releaseManifestHash: null }); + } else { + release = normalizeLiveRelease(release); + } + const endpoints = endpointsFor(config, dependencies.deterministicEndpoints); + const routeDocument = await dependencies.readRouteDocument(config.routePath); + const routes = validateRouteMap({ document: routeDocument, mode: config.mode }); + + let lock = null; + let authority = null; + let publicPlane = null; + const state = { + gate: 'booting', + reasonCode: null, + compositionMode: null, + deployment: config.mode === 'deterministic' + ? 'simulated' + : 'verified', + isolation: config.mode === 'deterministic' ? 'simulated' : null, + sessionState: null, + closePromise: null, + scheduled: false, + constructed: false, + }; + + const scheduleClose = () => { + if (!state.constructed || state.scheduled || !publicPlane) return; + state.scheduled = true; + dependencies.scheduleShutdown(() => { + void publicPlane.close().catch(() => undefined); + }); + }; + const markAuthorityUnhealthy = (reason) => { + if (state.gate === 'closed') return; + state.reasonCode = typeof reason === 'string' && /^[A-Z][A-Z0-9_]{0,127}$/.test(reason) + ? reason + : 'AUTHORITY_UNHEALTHY'; + state.gate = 'closed'; + scheduleClose(); + }; + const assertAdmissionOpen = () => { + if (state.gate !== 'open') { + throw new KernelError( + state.reasonCode ?? 'AUTHORITY_UNHEALTHY', + 'Wallet authority admission is closed', + ); + } + }; + + try { + lock = await dependencies.acquireAuthorityLock(Object.freeze({ + config, + role: 'kernel', + })); + ensureFacade(lock, ['close'], 'authority lock'); + if (config.mode === 'cdp-testnet') loaded.assertCredentialPresence(); + authority = captureAuthority(await dependencies.openAuthority(Object.freeze({ + config, + env: captured.env, + routes, + }))); + await dependencies.recoverAuthority(authority.recoveryDependencies); + + const walletIdentity = validateWalletIdentity(authority.walletIdentity(), config); + const activePolicy = validatePolicyVersion(authority.activePolicy(), walletIdentity); + assertRoutePolicyBindings(routes, activePolicy, config.mode); + const enrollment = validateEnrollment(authority.activeEnrollment(), config); + + if (config.mode === 'cdp-testnet' && enrollment !== null) { + const admission = await dependencies.assertLiveAdmission(Object.freeze({ + config, + release, + enrollment, + walletIdentity, + activePolicy, + })); + if (!admission || admission.isolation !== 'verified' + || admission.observer !== 'verified') { + fail('AGENT_IDENTITY_NOT_ISOLATED', 'live agent admission is not verified'); + } + state.isolation = 'verified'; + } + + state.gate = 'open'; + const authorityMutationCoordinator = dependencies.createAuthorityMutationCoordinator({ + assertAdmissionOpen, + markAuthorityUnhealthy, + }); + ensureFacade( + authorityMutationCoordinator, + ['runExclusive'], + 'authority mutation coordinator', + ); + const reconciler = ensureFacade(dependencies.createReconciler({ + ...authority.reconcilerDependencies, + authorityMutationCoordinator, + markAuthorityUnhealthy, + }), [ + 'reconcilePayment', 'reconcileExecution', 'observeRefund', 'abandonCandidate', + ], 'reconciler'); + + let kernel = null; + let kernelDependencies = null; + let agentApp; + if (enrollment === null) { + state.compositionMode = 'recovery_only'; + state.sessionState = null; + agentApp = recoveryOnlyAgentApp(); + } else { + state.compositionMode = 'normal'; + kernelDependencies = captureDependencyBag( + await authority.createKernelDependencies(Object.freeze({ + config, + release, + enrollment, + walletIdentity, + activePolicy, + routes, + })), + 'Wallet Kernel dependencies', + ); + kernel = ensureFacade(dependencies.createWalletKernel({ + ...kernelDependencies, + authorityMutationCoordinator, + markAuthorityUnhealthy, + }), [ + 'openOrResumeSession', 'applyPolicy', 'revokeAgent', 'transitionSessionPolicy', + 'closeSession', 'approvePending', 'denyPending', 'expireDueApprovals', 'execute', + 'status', 'statusByRequestId', 'receiptById', + ], 'Wallet Kernel'); + const candidates = authority.bindingsForEnrollment(Object.freeze({ + agentInstanceId: enrollment.agentInstanceId, + enrollmentHash: enrollment.enrollmentHash, + })); + const current = validateBindings(candidates, enrollment, walletIdentity, activePolicy); + let admittedSession; + if (current === null || current.session.state === 'open') { + admittedSession = await kernel.openOrResumeSession(Object.freeze({ + agentInstanceId: enrollment.agentInstanceId, + walletAddress: walletIdentity.address, + policyVersionId: activePolicy.id, + })); + if (!admittedSession || typeof admittedSession !== 'object' + || utilTypes.isProxy(admittedSession) + || (current !== null && admittedSession.id !== current.session.id) + || admittedSession.agentInstanceId !== enrollment.agentInstanceId + || admittedSession.enrollmentHash !== enrollment.enrollmentHash + || admittedSession.walletAddress !== walletIdentity.address + || admittedSession.policyVersionId !== activePolicy.id + || admittedSession.state !== 'open') { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'session open/resume returned different authority'); + } + } else { + admittedSession = current.session; + } + state.sessionState = admittedSession.state; + assertAdmissionOpen(); + const agentAuth = dependencies.createAgentAuth({ + store: authority.agentAuthDependencies.store, + intents: authority.agentAuthDependencies.intents, + walletIdentity, + activePolicy, + kernelUid: typeof process.getuid === 'function' ? process.getuid() : 1, + kernelGid: typeof process.getgid === 'function' ? process.getgid() : 1, + expectedAgentUid: config.expectedAgentUid, + expectedAgentGid: config.expectedAgentGid, + mode: config.mode, + }); + ensureFacade(agentAuth, ['authenticate', 'resolveBoundSession'], 'agent auth'); + agentApp = dependencies.createSpendControlProxy({ + agentAuth, + kernel, + routes, + maximumRequestBytes: Math.max(...routes.routes.map( + (route) => route.maximumRequestBytes, + )), + }); + } + if (!agentApp || typeof agentApp.fetch !== 'function') { + throw new TypeError('agent application must expose fetch()'); + } + + const services = createOperatorServices({ + mode: state.compositionMode, + authority, + kernel, + reconciler, + walletIdentity, + }); + const operatorApps = createOperatorApplications({ + dependencies, + config, + endpoints, + authority, + services, + }); + + const apps = Object.freeze({ + agent: agentApp, + operatorAdmin: operatorApps.admin, + operatorConsole: operatorApps.console, + }); + publicPlane = Object.freeze({ + apps, + health: Object.freeze(() => healthProjection(state)), + close: Object.freeze(async () => { + if (state.closePromise) return await state.closePromise; + state.closePromise = (async () => { + if (state.gate !== 'closed') { + state.gate = 'closed'; + state.reasonCode ??= 'CONTROL_PLANE_SHUTDOWN'; + } + const internals = INTERNALS.get(publicPlane); + let firstError = null; + try { + if (authority.waitForUnsignedWork) await authority.waitForUnsignedWork(); + } catch (error) { + firstError ??= error; + } + for (const resource of [ + internals?.listeners.agent, + internals?.listeners.console, + internals?.listeners.admin, + authority, + lock, + ]) { + try { + await closeResource(resource); + } catch (error) { + firstError ??= error; + } + } + if (firstError) throw firstError; + })(); + return await state.closePromise; + }), + }); + INTERNALS.set(publicPlane, { + config, + dependencies, + endpoints, + state, + walletIdentity, + operatorReads: authority.operatorReads, + listeners: { admin: null, console: null, agent: null }, + startState: 'created', + }); + state.constructed = true; + if (state.gate === 'closed') scheduleClose(); + return publicPlane; + } catch (error) { + if (state.gate !== 'closed') { + state.gate = 'closed'; + state.reasonCode = error instanceof KernelError ? error.code : 'CONTROL_PLANE_STARTUP'; + } + try { + await closeCreatedAuthority(authority, lock); + } catch { + // Preserve the startup cause; neither cleanup error nor provider text is public. + } + throw error; + } +} + +export async function startControlPlane(options = undefined) { + const plane = await createControlPlane(options); + const internals = INTERNALS.get(plane); + if (!internals || internals.startState !== 'created') { + await plane.close(); + fail('CONTROL_PLANE_STATE', 'control plane cannot be started twice'); + } + internals.startState = 'starting'; + const { + config, + dependencies, + endpoints, + state, + walletIdentity, + operatorReads, + listeners, + } = internals; + try { + if (config.mode === 'cdp-testnet') { + listeners.admin = await dependencies.listenOperatorAdmin(Object.freeze({ + app: plane.apps.operatorAdmin, + socketPath: config.operatorSocketPath, + })); + ensureFacade(listeners.admin, ['close'], 'operator admin listener'); + listeners.console = await dependencies.listenOperatorConsole(Object.freeze({ + app: plane.apps.operatorConsole, + activationName: config.operatorConsoleActivationName, + host: endpoints.operatorHost, + port: endpoints.operatorPort, + })); + ensureFacade(listeners.console, ['close'], 'operator console listener'); + } else { + listeners.console = await dependencies.listenOperatorConsole(Object.freeze({ + app: plane.apps.operatorConsole, + host: endpoints.operatorHost, + port: endpoints.operatorPort, + })); + ensureFacade(listeners.console, ['close'], 'operator console listener'); + } + const receiptPublicKey = projectOperatorPublicResult( + await operatorReads.receiptPublicKey({}), + ); + const readiness = Object.freeze({ + agentOrigin: `http://${endpoints.agentHost}:${endpoints.agentPort}`, + operatorOrigin: operatorOrigin(endpoints), + walletAddress: walletIdentity.address, + receiptPublicKey, + }); + await dependencies.prepareStartupReport(Object.freeze({ + ...readiness, + deployment: state.deployment, + isolation: state.isolation, + mode: state.compositionMode, + })); + if (state.gate !== 'open') { + throw new KernelError( + state.reasonCode ?? 'AUTHORITY_UNHEALTHY', + 'Wallet authority admission closed during listener startup', + ); + } + listeners.agent = await dependencies.listenAgent(Object.freeze({ + app: plane.apps.agent, + host: endpoints.agentHost, + port: endpoints.agentPort, + })); + ensureFacade(listeners.agent, ['close'], 'agent listener'); + await dependencies.publishReady(Object.freeze({ type: 'ready', ...readiness })); + internals.startState = 'started'; + return plane; + } catch (error) { + internals.startState = 'failed'; + if (state.gate !== 'closed') { + state.gate = 'closed'; + state.reasonCode = error instanceof KernelError ? error.code : 'CONTROL_PLANE_LISTENER'; + } + try { await plane.close(); } catch {} + throw error; + } +} + +// The production service must install an explicit authority/release/listener +// composition. Silently exiting zero here would make systemd report a healthy +// daemon while no wallet authority exists. Process acceptance imports the +// functions above and supplies its deterministic-only dependency graph. +const directlyExecuted = typeof process.argv[1] === 'string' + && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +if (directlyExecuted) { + process.stderr.write('CONTROL_PLANE_COMPOSITION_REQUIRED\n'); + process.exitCode = 1; +} diff --git a/spikes/pi-wielder/src/evidence-bundle.mjs b/spikes/pi-wielder/src/evidence-bundle.mjs new file mode 100644 index 0000000..3e39324 --- /dev/null +++ b/spikes/pi-wielder/src/evidence-bundle.mjs @@ -0,0 +1,1370 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + canonicalAtomic, + canonicalJson, + canonicalTimestamp, + exactRecord, + sha256, +} from './kernel/canonical.mjs'; +import { + receiptKeyId, + verifySignedReceipt, +} from './kernel/receipt-signing.mjs'; + +const BASE_SEPOLIA = 'eip155:84532'; +const BASE_SEPOLIA_USDC = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const BUNDLE_FILES = Object.freeze([ + 'README.md', + 'events.jsonl', + 'manifest.json', + 'report.md', + 'summary.json', +]); +const LISTED_FILES = Object.freeze(BUNDLE_FILES.filter((name) => name !== 'manifest.json')); +const PREFIXED_HASH = /^sha256:[0-9a-f]{64}$/; +const RAW_HASH = /^[0-9a-f]{64}$/; +const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const ADDRESS = /^0x[0-9a-f]{40}$/; +const TRANSACTION = /^0x[0-9a-f]{64}$/; +const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/; +const EVENT_TYPE = /^[a-z][a-z0-9_.-]{0,127}$/; +const UID_GID = /^(?:0|[1-9][0-9]*)$/; +const SIGNATURE_BYTES = 64; +const MAXIMUM_FILE_BYTES = 16 * 1024 * 1024; +const NORMALIZED_EVENT_DOMAIN = 'wallet-kernel.normalized-evidence-event.v1'; +const KERNEL_IDENTITY_DOMAIN = 'wallet-kernel.kernel-identity.v1'; +const AGENT_IDENTITY_DOMAIN = 'wallet-kernel.agent-identity.v1'; +const PROJECTION_DOMAIN = 'wallet-kernel.projection-export.v1'; +const PROJECTION_SET_DOMAIN = 'wallet-kernel.signed-projection-set.v1'; +const SESSION_IDENTITY_DOMAIN = 'wallet-kernel.session-identity.v1'; +const SUMMARY_DOMAIN = 'wallet-kernel.evidence-summary.v2'; +const EVIDENCE_SCHEMA_VERSION = 2; +const DEPLOYMENT_DIGEST_FIELDS = Object.freeze([ + 'releaseManifestDigest', + 'releaseTreeHash', + 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', +]); +const DECISIONS = new Set(['allow', 'approval_required', 'deny']); +const EXPECTED_PROBES = Object.freeze({ + authorityDirectory: 'EACCES', + database: 'EACCES', + operatorToken: 'EACCES', + receiptKey: 'EACCES', + kernelEnvironment: 'EACCES', + agentCredential: 'READABLE', + releaseTreeWrite: 'EACCES', + dependencyTreeWrite: 'EACCES', + serviceArtifactsWrite: 'EACCES', + kernelEnvironmentParentWrite: 'EACCES', +}); + +export class EvidenceError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = 'EvidenceError'; + this.code = code; + } +} + +function fail(code, message, cause) { + throw new EvidenceError(code, message, cause ? { cause } : undefined); +} + +function capture(value, required, optional, code, label) { + try { + return exactRecord(value, required, optional, code, label); + } catch (cause) { + if (cause?.code === code) throw new EvidenceError(code, cause.message, { cause }); + fail(code, `${label} fields do not match the closed schema`, cause); + } +} + +function prefixedHash(value, label, code = 'EVIDENCE_SCHEMA') { + if (typeof value !== 'string' || !PREFIXED_HASH.test(value)) { + fail(code, `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function rawHash(value, label, code = 'EVIDENCE_SCHEMA') { + if (typeof value !== 'string' || !RAW_HASH.test(value)) { + fail(code, `${label} must be 64 lowercase hexadecimal characters`); + } + return value; +} + +function timestamp(value, label, code = 'EVIDENCE_SCHEMA') { + try { + return canonicalTimestamp(value, label); + } catch (cause) { + fail(code, `${label} must be one canonical timestamp`, cause); + } +} + +function atomic(value, label, code = 'EVIDENCE_SCHEMA') { + try { + return canonicalAtomic(value, label); + } catch (cause) { + fail(code, `${label} must be canonical atomic text`, cause); + } +} + +function identity(value, label) { + if (typeof value !== 'string' || !UID_GID.test(value)) { + fail('EVIDENCE_IDENTITY', `${label} must be canonical nonnegative decimal text`); + } + const number = Number(value); + if (!Number.isSafeInteger(number) || String(number) !== value) { + fail('EVIDENCE_IDENTITY', `${label} must round-trip through one safe integer`); + } + return value; +} + +function canonicalSignature(value, label, code) { + if (typeof value !== 'string') fail(code, `${label} must be canonical Ed25519 bytes`); + let bytes; + try { + bytes = Buffer.from(value, 'base64'); + } catch (cause) { + fail(code, `${label} must be canonical Ed25519 bytes`, cause); + } + if (bytes.length !== SIGNATURE_BYTES || bytes.toString('base64') !== value) { + fail(code, `${label} must be canonical Ed25519 bytes`); + } + return bytes; +} + +function rawSha256(bytes) { + return crypto.createHash('sha256').update(bytes).digest('hex'); +} + +function isPlainData(value, ancestors = new Set()) { + if (value === null || ['string', 'boolean', 'number'].includes(typeof value)) return true; + if (!value || typeof value !== 'object' || ancestors.has(value)) return false; + const expectedPrototype = Array.isArray(value) ? Array.prototype : Object.prototype; + if (Object.getPrototypeOf(value) !== expectedPrototype) return false; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) return false; + ancestors.add(value); + try { + if (Array.isArray(value)) { + const length = descriptors.length; + if (!length || length.enumerable || !Object.hasOwn(length, 'value') + || keys.length !== value.length + 1) return false; + for (let index = 0; index < value.length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') + || !isPlainData(descriptor.value, ancestors)) return false; + } + return true; + } + return keys.every((key) => { + const descriptor = descriptors[key]; + return descriptor?.enumerable && Object.hasOwn(descriptor, 'value') + && isPlainData(descriptor.value, ancestors); + }); + } finally { + ancestors.delete(value); + } +} + +function forbiddenField(name, fieldPath) { + const compact = name.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (compact.endsWith('hash') || compact.endsWith('digest')) return false; + if (compact === 'signature') { + return !/(?:^\$|\.signedProjections\.[0-9]+|\.signedReceipts\.[0-9]+|\.receipts\.[0-9]+)\.signature$/.test(fieldPath); + } + return [ + 'body', 'requestbody', 'responsebody', 'rawbody', 'prompt', 'prompttext', + 'rawrequest', 'rawresponse', 'paymentpayload', 'paymentheader', + 'paymentsignature', 'authorization', 'payload', 'header', 'content', + 'rawdata', 'datajson', 'metadatajson', 'rawevidence', + 'agentcredential', 'agenttoken', + 'operatortoken', 'operatoridentity', 'providerexception', 'providererror', + 'operatorid', 'operatorname', 'operatoruid', 'operatorgid', + 'uid', 'gid', 'kerneluid', 'kernelgid', 'agentuid', 'agentgid', + 'exception', 'stack', 'filepath', 'localpath', 'privatekey', + ].includes(compact); +} + +function assertSanitized(value, valuePath = '$', ancestors = new Set()) { + if (typeof value === 'string') { + if (Buffer.byteLength(value, 'utf8') > 65_536 + || /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(value) + || /(?:^|[\s"'(=])(?:file:\/\/|\/(?:Users|home|private|tmp|var|etc|opt|root|proc|sys|dev)\/|[A-Za-z]:\\)/i.test(value) + || /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}={0,2}\b/i.test(value) + || /\bPAYMENT-SIGNATURE\s*:/i.test(value) + || (/^[A-Za-z0-9_-]{40,256}$/.test(value) && /[g-zG-Z_-]/.test(value) + && !/^0x[0-9a-f]{40,64}$/.test(value) + && !/(?:\.signature|\.receiptSignature|\.publicKeyPem)$/.test(valuePath))) { + fail('EVIDENCE_SANITIZATION', 'evidence contains forbidden source material'); + } + return; + } + if (value === null || ['boolean', 'number'].includes(typeof value)) return; + if (!value || typeof value !== 'object' || ancestors.has(value)) { + fail('EVIDENCE_SANITIZATION', 'evidence must be an acyclic plain data graph'); + } + ancestors.add(value); + try { + for (const [key, child] of Object.entries(value)) { + const fieldPath = `${valuePath}.${key}`; + if (forbiddenField(key, fieldPath)) { + fail('EVIDENCE_SANITIZATION', `evidence field ${valuePath}.${key} is forbidden`); + } + assertSanitized(child, fieldPath, ancestors); + } + } finally { + ancestors.delete(value); + } +} + +function parsePublicKey(value, label) { + const key = capture( + value, + ['keyId', 'algorithm', 'publicKeyPem'], + [], + 'EVIDENCE_KEY_SCHEMA', + label, + ); + if (key.algorithm !== 'Ed25519' || typeof key.publicKeyPem !== 'string') { + fail('EVIDENCE_KEY_SCHEMA', `${label} must contain one Ed25519 public key`); + } + let publicKey; + try { + publicKey = crypto.createPublicKey(key.publicKeyPem); + } catch (cause) { + fail('EVIDENCE_KEY_SCHEMA', `${label} public key is invalid`, cause); + } + const canonicalPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); + if (publicKey.asymmetricKeyType !== 'ed25519' + || canonicalPem !== key.publicKeyPem + || receiptKeyId(publicKey) !== key.keyId) { + fail('EVIDENCE_KEY_SCHEMA', `${label} key ID or encoding is invalid`); + } + return Object.freeze({ ...key, publicKey }); +} + +function normalizeKeys(value) { + if (!Array.isArray(value) || value.length === 0 || value.length > 32) { + fail('EVIDENCE_KEY_SCHEMA', 'receipt keys must be one bounded nonempty array'); + } + const parsed = value.map((item, index) => parsePublicKey(item, `receipt key ${index}`)); + parsed.sort((left, right) => left.keyId.localeCompare(right.keyId)); + if (new Set(parsed.map(({ keyId }) => keyId)).size !== parsed.length) { + fail('EVIDENCE_KEY_SCHEMA', 'receipt key IDs must be unique'); + } + return parsed; +} + +function publicKeysOnly(keys) { + return keys.map(({ keyId, algorithm, publicKeyPem }) => ({ keyId, algorithm, publicKeyPem })); +} + +function validateReceiptRecord(value, keyMap) { + const record = capture(value, [ + 'id', 'intentId', 'revision', 'receipt', 'receiptHash', 'signature', 'algorithm', + 'keyId', 'supersedesReceiptHash', 'createdAt', + ], [], 'EVIDENCE_RECEIPT_SCHEMA', 'signed receipt'); + if (typeof record.id !== 'string' || !TOKEN.test(record.id) + || typeof record.intentId !== 'string' || !TOKEN.test(record.intentId) + || !Number.isSafeInteger(record.revision) || record.revision < 1 + || record.algorithm !== 'Ed25519' + || (record.supersedesReceiptHash !== null + && (typeof record.supersedesReceiptHash !== 'string' + || !RAW_HASH.test(record.supersedesReceiptHash)))) { + fail('EVIDENCE_RECEIPT_SCHEMA', 'signed receipt fields are invalid'); + } + rawHash(record.receiptHash, 'signed receipt hash', 'EVIDENCE_RECEIPT_SCHEMA'); + canonicalSignature(record.signature, 'signed receipt signature', 'EVIDENCE_RECEIPT_SIGNATURE'); + timestamp(record.createdAt, 'signed receipt createdAt', 'EVIDENCE_RECEIPT_SCHEMA'); + if (!isPlainData(record.receipt)) { + fail('EVIDENCE_RECEIPT_SCHEMA', 'signed receipt projection is not plain data'); + } + const receipt = record.receipt; + if (receipt.receiptId !== record.id || receipt.revision !== record.revision + || receipt.supersedesReceiptHash !== record.supersedesReceiptHash + || receipt.intent?.id !== record.intentId) { + fail('EVIDENCE_RECEIPT_REVISION', 'signed receipt envelope and projection disagree'); + } + const key = keyMap.get(record.keyId); + if (!key || !verifySignedReceipt(record, key)) { + fail('EVIDENCE_RECEIPT_SIGNATURE', 'signed receipt did not verify against the manifest key'); + } + assertSanitized(record); + return record; +} + +function validateReceipts(value, keys) { + if (!Array.isArray(value) || value.length > 10_000) { + fail('EVIDENCE_RECEIPT_SCHEMA', 'signed receipts must be one bounded array'); + } + const keyMap = new Map(keys.map((key) => [key.keyId, key])); + const records = value.map((record) => validateReceiptRecord(record, keyMap)); + records.sort((left, right) => left.intentId.localeCompare(right.intentId) + || left.revision - right.revision || left.id.localeCompare(right.id)); + const seenIds = new Set(); + const seenHashes = new Set(); + const previousByIntent = new Map(); + for (const record of records) { + if (seenIds.has(record.id) || seenHashes.has(record.receiptHash)) { + fail('EVIDENCE_RECEIPT_REVISION', 'signed receipt IDs and hashes must be unique'); + } + const previous = previousByIntent.get(record.intentId) ?? null; + if ((previous === null && (record.revision !== 1 || record.supersedesReceiptHash !== null)) + || (previous !== null && (record.revision !== previous.revision + 1 + || record.supersedesReceiptHash !== previous.receiptHash))) { + fail('EVIDENCE_RECEIPT_REVISION', 'signed receipt revision requires its exact predecessor'); + } + seenIds.add(record.id); + seenHashes.add(record.receiptHash); + previousByIntent.set(record.intentId, record); + } + return records; +} + +function latestReceiptFinancialMetrics(receipts) { + const latestByIntent = new Map(); + for (const receipt of receipts) latestByIntent.set(receipt.intentId, receipt); + let settledGross = 0n; + let confirmedRefund = 0n; + let unresolvedExposure = 0n; + for (const { receipt } of latestByIntent.values()) { + if (receipt.payment?.state === 'settled') { + settledGross += atomic( + receipt.payment.amountAtomic, + 'latest receipt settled amount', + 'EVIDENCE_FINANCIAL_METRICS', + ).value; + } + if (receipt.refund?.state === 'confirmed') { + confirmedRefund += atomic( + receipt.refund.amountAtomic, + 'latest receipt confirmed refund amount', + 'EVIDENCE_FINANCIAL_METRICS', + ).value; + } + if (receipt.budget?.disposition === 'unresolved') { + unresolvedExposure += atomic( + receipt.budget.amountAtomic, + 'latest receipt unresolved exposure', + 'EVIDENCE_FINANCIAL_METRICS', + ).value; + } + } + return { + latestReceiptSettledGrossAtomic: settledGross.toString(10), + latestReceiptConfirmedRefundAtomic: confirmedRefund.toString(10), + latestReceiptUnresolvedExposureAtomic: unresolvedExposure.toString(10), + }; +} + +function verifyProjection(value, manifest, keys) { + const bundle = capture(value, [ + 'schemaVersion', 'domain', 'projection', 'algorithm', 'keyId', 'publicKeyPem', + 'projectionHash', 'signature', + ], [], 'EVIDENCE_PROJECTION_SCHEMA', 'signed authority projection'); + if (bundle.schemaVersion !== 1 || bundle.domain !== PROJECTION_DOMAIN + || bundle.algorithm !== 'Ed25519' || !isPlainData(bundle.projection)) { + fail('EVIDENCE_PROJECTION_SCHEMA', 'signed authority projection fields are invalid'); + } + assertSanitized(bundle); + const key = keys.find(({ keyId }) => keyId === bundle.keyId); + if (!key || bundle.publicKeyPem !== key.publicKeyPem) { + fail('EVIDENCE_PROJECTION_SIGNATURE', 'projection signer is not anchored by the manifest'); + } + const unsigned = { + schemaVersion: bundle.schemaVersion, + domain: bundle.domain, + projection: bundle.projection, + algorithm: bundle.algorithm, + keyId: bundle.keyId, + publicKeyPem: bundle.publicKeyPem, + }; + const projectionHash = sha256(canonicalJson(unsigned)); + const signature = canonicalSignature( + bundle.signature, + 'authority projection signature', + 'EVIDENCE_PROJECTION_SIGNATURE', + ); + if (bundle.projectionHash !== projectionHash + || !crypto.verify( + null, + Buffer.from(projectionHash.slice('sha256:'.length), 'hex'), + key.publicKey, + signature, + )) { + fail('EVIDENCE_PROJECTION_SIGNATURE', 'signed authority projection is invalid'); + } + const projection = bundle.projection; + timestamp(projection.issuedAt, 'projection issuedAt', 'EVIDENCE_PROJECTION_SCHEMA'); + if (Date.parse(projection.issuedAt) > Date.parse(manifest.createdAt) + || projection.eventHeadHash !== manifest.source.authorityEventHeadHash + || projection.wallet?.address !== manifest.wallet.address + || projection.policies?.activePolicyHash !== manifest.inputs.policyHash + || projection.agentEnrollment?.identityHash !== manifest.isolation.agentIdentityHash + || projection.isolation?.status !== manifest.isolation.status + || projection.isolation?.preflightDigest !== manifest.isolation.preflightDigest) { + fail('EVIDENCE_PROJECTION_BINDING', 'signed projection does not bind the evidence manifest'); + } + prefixedHash( + projection.sessionHash, + 'projection session hash', + 'EVIDENCE_PROJECTION_BINDING', + ); + const projectionReceipts = validateReceipts(projection.signedReceipts, keys); + if (canonicalJson(projection.signedReceipts) !== canonicalJson(projectionReceipts)) { + fail('EVIDENCE_PROJECTION_BINDING', 'projection receipts are not canonically ordered'); + } + assertSanitized(bundle); + return Object.freeze({ bundle, receipts: projectionReceipts }); +} + +function projectionSetHash(projections) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: PROJECTION_SET_DOMAIN, + signedProjections: projections, + })); +} + +function receiptSessionHash(receipt) { + const sessionId = receipt?.receipt?.intent?.sessionId; + if (typeof sessionId !== 'string' || !TOKEN.test(sessionId)) { + fail('EVIDENCE_PROJECTION_PARTITION', 'receipt session ownership is invalid'); + } + return sha256(canonicalJson({ domain: SESSION_IDENTITY_DOMAIN, sessionId })); +} + +function verifyProjectionSet(value, manifest, keys, receipts) { + if (!Array.isArray(value) || value.length === 0 || value.length > 10_000) { + fail('EVIDENCE_PROJECTION_SCHEMA', 'signed projections must be one bounded nonempty array'); + } + const verified = value.map((projection) => verifyProjection(projection, manifest, keys)); + const sorted = [...verified].sort((left, right) => ( + left.bundle.projection.sessionHash.localeCompare(right.bundle.projection.sessionHash) + )); + if (canonicalJson(verified.map(({ bundle }) => bundle)) + !== canonicalJson(sorted.map(({ bundle }) => bundle))) { + fail('EVIDENCE_PROJECTION_BINDING', 'signed projections are not canonically ordered'); + } + const sessionHashes = sorted.map(({ bundle }) => bundle.projection.sessionHash); + const projectionHashes = sorted.map(({ bundle }) => bundle.projectionHash); + if (new Set(sessionHashes).size !== sorted.length + || new Set(projectionHashes).size !== sorted.length) { + fail('EVIDENCE_PROJECTION_PARTITION', 'signed projection identities must be unique'); + } + const bundles = sorted.map(({ bundle }) => bundle); + if (manifest.source.signedProjectionHash !== projectionSetHash(bundles)) { + fail('EVIDENCE_PROJECTION_SIGNATURE', 'signed projection aggregate is invalid'); + } + const partition = []; + const seenReceiptHashes = new Set(); + for (const { bundle, receipts: projectionReceipts } of sorted) { + for (const receipt of projectionReceipts) { + if (receiptSessionHash(receipt) !== bundle.projection.sessionHash + || seenReceiptHashes.has(receipt.receiptHash)) { + fail('EVIDENCE_PROJECTION_PARTITION', 'receipt does not belong to exactly one session projection'); + } + seenReceiptHashes.add(receipt.receiptHash); + partition.push(receipt); + } + } + const canonicalPartition = validateReceipts(partition, keys); + if (canonicalJson(canonicalPartition) !== canonicalJson(receipts)) { + fail('EVIDENCE_PROJECTION_PARTITION', 'session projections do not exactly cover authority receipts'); + } + const usedKeyIds = new Set([ + ...bundles.map(({ keyId }) => keyId), + ...receipts.map(({ keyId }) => keyId), + ]); + if (usedKeyIds.size !== keys.length || keys.some(({ keyId }) => !usedKeyIds.has(keyId))) { + fail('EVIDENCE_PROJECTION_BINDING', 'manifest receipt keys must be the exact used key set'); + } + return bundles; +} + +function identityHash(role, pair) { + const record = capture(pair, ['uid', 'gid'], [], 'EVIDENCE_IDENTITY', `${role} identity`); + const uid = identity(record.uid, `${role} UID`); + const gid = identity(record.gid, `${role} GID`); + const domain = role === 'kernel' ? KERNEL_IDENTITY_DOMAIN : AGENT_IDENTITY_DOMAIN; + const names = role === 'kernel' + ? { kernelUid: uid, kernelGid: gid } + : { agentUid: uid, agentGid: gid }; + return { hash: sha256(canonicalJson({ domain, ...names })), uid, gid }; +} + +function validateGit(value, mode) { + const git = capture(value, ['commit', 'dirty'], [], 'EVIDENCE_SCHEMA', 'Git evidence'); + if (typeof git.commit !== 'string' || !COMMIT.test(git.commit) || typeof git.dirty !== 'boolean') { + fail('EVIDENCE_SCHEMA', 'Git evidence is invalid'); + } + if (mode === 'base-sepolia-testnet' && git.dirty !== false) { + fail('EVIDENCE_MODE_GATE', 'testnet evidence requires one clean attested release'); + } + return git; +} + +function validateRuntime(value, mode) { + const runtime = capture( + value, + ['nodeVersion', 'piVersion'], + [], + 'EVIDENCE_SCHEMA', + 'runtime evidence', + ); + if (typeof runtime.nodeVersion !== 'string' || !/^v[0-9]+\.[0-9]+\.[0-9]+$/.test(runtime.nodeVersion) + || runtime.piVersion !== '0.80.6') { + fail('EVIDENCE_SCHEMA', 'runtime versions are invalid'); + } + if (mode === 'base-sepolia-testnet' && runtime.nodeVersion !== 'v24.18.1') { + fail('EVIDENCE_MODE_GATE', 'testnet evidence requires the attested Node v24.18.1 runtime'); + } + return runtime; +} + +function validateProtocol(value) { + const protocol = capture(value, [ + 'x402Version', 'network', 'asset', + ], [], 'EVIDENCE_SCHEMA', 'protocol evidence'); + if (protocol.x402Version !== 2 || protocol.network !== BASE_SEPOLIA + || protocol.asset !== BASE_SEPOLIA_USDC) { + fail('EVIDENCE_SCHEMA', 'only x402 v2 and canonical Base Sepolia USDC are supported'); + } + return protocol; +} + +function validateWallet(value, mode) { + const wallet = capture( + value, + ['provider', 'walletIdHash', 'address'], + [], + 'EVIDENCE_SCHEMA', + 'wallet evidence', + ); + if (typeof wallet.provider !== 'string' || !TOKEN.test(wallet.provider)) { + fail('EVIDENCE_SCHEMA', 'wallet provider is invalid'); + } + prefixedHash(wallet.walletIdHash, 'wallet ID hash'); + if (typeof wallet.address !== 'string' || !ADDRESS.test(wallet.address)) { + fail('EVIDENCE_SCHEMA', 'wallet address must be one canonical lowercase EVM address'); + } + if ((mode === 'offline-deterministic' && wallet.provider !== 'deterministic') + || (mode === 'base-sepolia-testnet' && wallet.provider !== 'cdp')) { + fail('EVIDENCE_MODE_GATE', 'wallet provider does not match evidence mode'); + } + return wallet; +} + +function validateIsolation(value, identities) { + const isolation = capture(value, [ + 'status', 'preflightDigest', 'kernelIdentityHash', 'agentIdentityHash', + ], [], 'EVIDENCE_SCHEMA', 'isolation evidence'); + if (!['simulated', 'enforced'].includes(isolation.status) + || (isolation.preflightDigest !== null + && (typeof isolation.preflightDigest !== 'string' + || !PREFIXED_HASH.test(isolation.preflightDigest)))) { + fail('EVIDENCE_SCHEMA', 'isolation status or digest is invalid'); + } + prefixedHash(isolation.kernelIdentityHash, 'Kernel identity hash'); + prefixedHash(isolation.agentIdentityHash, 'Agent identity hash'); + if (isolation.kernelIdentityHash !== identities.kernel.hash + || isolation.agentIdentityHash !== identities.agent.hash) { + fail('EVIDENCE_IDENTITY', 'identity hashes do not match the pinned UID and GID pairs'); + } + if (isolation.kernelIdentityHash === isolation.agentIdentityHash) { + fail('EVIDENCE_IDENTITY', 'Kernel and Agent evidence identities must be domain-separated'); + } + return isolation; +} + +function validateDeployment(value) { + const deployment = capture(value, [ + 'status', 'releaseManifestDigest', 'releaseTreeHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', + ], [], 'EVIDENCE_SCHEMA', 'deployment evidence'); + if (!['simulated', 'enforced'].includes(deployment.status)) { + fail('EVIDENCE_SCHEMA', 'deployment status is invalid'); + } + for (const name of [ + 'releaseManifestDigest', 'releaseTreeHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', + ]) { + if (deployment[name] !== null) prefixedHash(deployment[name], `deployment ${name}`); + } + return deployment; +} + +function validateInputs(value) { + const inputs = capture( + value, + ['policyHash', 'routeMapHash', 'configHash'], + [], + 'EVIDENCE_SCHEMA', + 'evidence inputs', + ); + for (const [name, digest] of Object.entries(inputs)) prefixedHash(digest, name); + return inputs; +} + +function validateStatus(value) { + const status = capture(value, [ + 'liveCdp', 'walletFunded', 'testnetTransaction', + ], [], 'EVIDENCE_SCHEMA', 'evidence status'); + if (!['not-run', 'passed'].includes(status.liveCdp) + || !['not-run', 'sufficient'].includes(status.walletFunded) + || !['not-run', 'settled'].includes(status.testnetTransaction)) { + fail('EVIDENCE_SCHEMA', 'evidence status values are invalid'); + } + return status; +} + +function validatePrivilegedReport(value, context) { + const report = capture(value, [ + 'schemaVersion', 'enrollmentHash', 'kernelUid', 'kernelGid', 'agentUid', 'agentGid', + 'authorityMetadataHash', 'credentialMetadataHash', 'releaseManifestHash', + 'releaseTreeHash', 'nodeExecutableHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', 'environmentMetadataHash', 'probeResults', + 'probedAt', 'expiresAt', + ], [], 'EVIDENCE_PREFLIGHT', 'privileged isolation report'); + if (report.schemaVersion !== 1) fail('EVIDENCE_PREFLIGHT', 'privileged report schema is invalid'); + for (const name of ['kernelUid', 'kernelGid', 'agentUid', 'agentGid']) { + identity(report[name], `privileged report ${name}`); + } + for (const name of [ + 'enrollmentHash', 'authorityMetadataHash', 'credentialMetadataHash', + 'releaseManifestHash', 'releaseTreeHash', 'nodeExecutableHash', + 'serviceArtifactsHash', 'systemdEffectiveConfigHash', 'environmentMetadataHash', + ]) prefixedHash(report[name], `privileged report ${name}`, 'EVIDENCE_PREFLIGHT'); + const probes = capture( + report.probeResults, + Object.keys(EXPECTED_PROBES), + [], + 'EVIDENCE_PREFLIGHT', + 'privileged isolation probes', + ); + if (Object.entries(EXPECTED_PROBES).some(([name, result]) => probes[name] !== result)) { + fail('EVIDENCE_PREFLIGHT', 'privileged isolation probes are not enforced'); + } + const probedAt = timestamp(report.probedAt, 'privileged report probedAt', 'EVIDENCE_PREFLIGHT'); + const expiresAt = timestamp(report.expiresAt, 'privileged report expiresAt', 'EVIDENCE_PREFLIGHT'); + const lifetime = Date.parse(expiresAt) - Date.parse(probedAt); + if (lifetime <= 0 || lifetime > 15 * 60 * 1_000 + || Date.parse(context.createdAt) < Date.parse(probedAt) + || Date.parse(context.createdAt) >= Date.parse(expiresAt) + || report.kernelUid !== context.identities.kernel.uid + || report.kernelGid !== context.identities.kernel.gid + || report.agentUid !== context.identities.agent.uid + || report.agentGid !== context.identities.agent.gid + || sha256(canonicalJson(report)) !== context.isolation.preflightDigest + || report.releaseManifestHash !== context.deployment.releaseManifestDigest + || report.releaseTreeHash !== context.deployment.releaseTreeHash + || report.serviceArtifactsHash !== context.deployment.serviceArtifactsHash + || report.systemdEffectiveConfigHash !== context.deployment.systemdEffectiveConfigHash) { + fail('EVIDENCE_PREFLIGHT', 'privileged report is expired or disagrees with deployment evidence'); + } + return { + preflightDigest: context.isolation.preflightDigest, + enrollmentHash: report.enrollmentHash, + probedAt, + expiresAt, + releaseManifestDigest: report.releaseManifestHash, + releaseTreeHash: report.releaseTreeHash, + serviceArtifactsHash: report.serviceArtifactsHash, + systemdEffectiveConfigHash: report.systemdEffectiveConfigHash, + }; +} + +function validateMode(context, privilegedReport) { + const nullDeployment = DEPLOYMENT_DIGEST_FIELDS + .every((name) => context.deployment[name] === null); + if (context.mode === 'offline-deterministic') { + if (context.isolation.status !== 'simulated' || context.isolation.preflightDigest !== null + || context.deployment.status !== 'simulated' || !nullDeployment + || privilegedReport !== null + || Object.values(context.status).some((state) => state !== 'not-run')) { + fail('EVIDENCE_MODE_GATE', 'offline evidence must keep isolation, deployment, and live status simulated'); + } + return null; + } + if (context.isolation.status !== 'enforced' || context.isolation.preflightDigest === null + || context.deployment.status !== 'enforced' || nullDeployment + || DEPLOYMENT_DIGEST_FIELDS.some((name) => context.deployment[name] === null) + || context.status.liveCdp !== 'passed' + || context.status.walletFunded !== 'sufficient' + || context.status.testnetTransaction !== 'settled' + || privilegedReport === null) { + fail('EVIDENCE_MODE_GATE', 'testnet evidence requires every isolation and deployment gate'); + } + return validatePrivilegedReport(privilegedReport, context); +} + +function validateManifestInput(value) { + const input = capture(value, [ + 'schemaVersion', 'createdAt', 'mode', 'git', 'runtime', 'protocol', 'wallet', + 'isolation', 'deployment', 'inputs', 'source', 'status', 'identityBindings', + 'privilegedReport', 'signedProjections', + ], [], 'EVIDENCE_BUILD_INPUT', 'evidence manifest input'); + if (input.schemaVersion !== EVIDENCE_SCHEMA_VERSION + || !['offline-deterministic', 'base-sepolia-testnet'].includes(input.mode)) { + fail('EVIDENCE_SCHEMA', 'evidence schema version or mode is invalid'); + } + const createdAt = timestamp(input.createdAt, 'evidence createdAt'); + const identityBindings = capture(input.identityBindings, [ + 'kernel', 'agent', + ], [], 'EVIDENCE_IDENTITY', 'identity bindings'); + const identities = { + kernel: identityHash('kernel', identityBindings.kernel), + agent: identityHash('agent', identityBindings.agent), + }; + const git = validateGit(input.git, input.mode); + const runtime = validateRuntime(input.runtime, input.mode); + const protocol = validateProtocol(input.protocol); + const wallet = validateWallet(input.wallet, input.mode); + const isolation = validateIsolation(input.isolation, identities); + const deployment = validateDeployment(input.deployment); + const inputs = validateInputs(input.inputs); + const source = capture(input.source, [ + 'authorityEventHeadHash', 'signedProjectionHash', 'receiptKeys', + ], [], 'EVIDENCE_SCHEMA', 'evidence source'); + prefixedHash(source.authorityEventHeadHash, 'authority event head hash'); + prefixedHash(source.signedProjectionHash, 'signed projection hash'); + const keys = normalizeKeys(source.receiptKeys); + const status = validateStatus(input.status); + const context = { + mode: input.mode, + createdAt, + identities, + isolation, + deployment, + status, + }; + const isolationAttestation = validateMode(context, input.privilegedReport); + const manifest = { + schemaVersion: EVIDENCE_SCHEMA_VERSION, + createdAt, + mode: input.mode, + git, + runtime, + protocol, + wallet, + isolation, + deployment, + inputs, + source: { + authorityEventHeadHash: source.authorityEventHeadHash, + signedProjectionHash: source.signedProjectionHash, + receiptKeys: publicKeysOnly(keys), + }, + status, + }; + assertSanitized(manifest); + return { manifest, keys, isolationAttestation, signedProjections: input.signedProjections }; +} + +function normalizeSourceEvent(value) { + const event = capture(value, [ + 'sequence', 'eventType', 'entityHash', 'decision', 'amountAtomic', + 'transactionId', 'receiptHash', 'receiptSignature', + ], [], 'EVIDENCE_EVENT_SCHEMA', 'normalized source event'); + if (!Number.isSafeInteger(event.sequence) || event.sequence < 1 + || typeof event.eventType !== 'string' || !EVENT_TYPE.test(event.eventType)) { + fail('EVIDENCE_EVENT_SCHEMA', 'normalized event sequence or type is invalid'); + } + prefixedHash(event.entityHash, 'normalized event entity hash', 'EVIDENCE_EVENT_SCHEMA'); + if (event.decision !== null && !DECISIONS.has(event.decision)) { + fail('EVIDENCE_EVENT_SCHEMA', 'normalized event decision is invalid'); + } + if (event.amountAtomic !== null) atomic( + event.amountAtomic, + 'normalized event amount', + 'EVIDENCE_EVENT_SCHEMA', + ); + if (event.transactionId !== null + && (typeof event.transactionId !== 'string' || !TRANSACTION.test(event.transactionId))) { + fail('EVIDENCE_EVENT_SCHEMA', 'normalized event transaction is invalid'); + } + const hasReceipt = event.receiptHash !== null || event.receiptSignature !== null; + if (hasReceipt !== (event.receiptHash !== null && event.receiptSignature !== null) + || hasReceipt !== (event.eventType === 'receipt.issued')) { + fail('EVIDENCE_EVENT_SCHEMA', 'receipt evidence must appear only on receipt issuance'); + } + if (hasReceipt) { + rawHash(event.receiptHash, 'normalized receipt hash', 'EVIDENCE_EVENT_SCHEMA'); + canonicalSignature( + event.receiptSignature, + 'normalized receipt signature', + 'EVIDENCE_EVENT_SCHEMA', + ); + } + assertSanitized(event); + return event; +} + +function buildNormalizedEvents(value, receipts) { + if (!Array.isArray(value) || value.length === 0 || value.length > 100_000) { + fail('EVIDENCE_EVENT_SCHEMA', 'normalized source events must be one bounded nonempty array'); + } + const source = value.map(normalizeSourceEvent).sort((left, right) => left.sequence - right.sequence); + if (source.some((event, index) => event.sequence !== index + 1)) { + fail('EVIDENCE_EVENT_SCHEMA', 'normalized source event sequences must be contiguous from one'); + } + let previousHash = null; + const events = source.map((event) => { + const unsigned = { + schemaVersion: 1, + domain: NORMALIZED_EVENT_DOMAIN, + ...event, + previousHash, + }; + const eventHash = sha256(canonicalJson(unsigned)); + previousHash = eventHash; + return { ...event, previousHash: unsigned.previousHash, eventHash }; + }); + verifyReceiptEventParity(events, receipts); + return events; +} + +function verifyReceiptEventParity(events, receipts) { + const byHash = new Map(receipts.map((receipt) => [receipt.receiptHash, receipt])); + const seen = new Set(); + for (const event of events) { + if (event.receiptHash === null) continue; + const receipt = byHash.get(event.receiptHash); + if (!receipt || receipt.signature !== event.receiptSignature || seen.has(event.receiptHash)) { + fail('EVIDENCE_RECEIPT_EVENT', 'normalized receipt event disagrees with signed receipts'); + } + seen.add(event.receiptHash); + } + if (seen.size !== receipts.length) { + fail('EVIDENCE_RECEIPT_EVENT', 'each signed receipt requires one normalized issuance event'); + } +} + +function replayEvents(events) { + let previousHash = null; + let previousSequence = 0; + let decisions = 0; + const transactions = []; + const transactionSet = new Set(); + for (const event of events) { + const normalized = capture(event, [ + 'sequence', 'eventType', 'entityHash', 'decision', 'amountAtomic', + 'transactionId', 'receiptHash', 'receiptSignature', 'previousHash', 'eventHash', + ], [], 'EVIDENCE_EVENT_CHAIN', 'stored normalized event'); + const source = normalizeSourceEvent({ + sequence: normalized.sequence, + eventType: normalized.eventType, + entityHash: normalized.entityHash, + decision: normalized.decision, + amountAtomic: normalized.amountAtomic, + transactionId: normalized.transactionId, + receiptHash: normalized.receiptHash, + receiptSignature: normalized.receiptSignature, + }); + const expectedHash = sha256(canonicalJson({ + schemaVersion: 1, + domain: NORMALIZED_EVENT_DOMAIN, + ...source, + previousHash, + })); + if (source.sequence !== previousSequence + 1 || normalized.previousHash !== previousHash + || normalized.eventHash !== expectedHash) { + fail('EVIDENCE_EVENT_CHAIN', 'normalized evidence chain is invalid'); + } + previousSequence = source.sequence; + previousHash = expectedHash; + if (source.decision !== null) decisions += 1; + if (source.transactionId !== null) { + if (transactionSet.has(source.transactionId)) { + fail('EVIDENCE_TRANSACTION_REUSE', 'normalized transaction IDs must be unique'); + } + transactionSet.add(source.transactionId); + transactions.push(source.transactionId); + } + } + return { + eventCount: events.length, + decisionCount: decisions, + transactionCount: transactions.length, + transactionIds: transactions, + normalizedEvidenceHeadHash: previousHash, + }; +} + +function summaryFor({ manifest, events, receipts, signedProjections, isolationAttestation }) { + const replay = replayEvents(events); + return { + schemaVersion: EVIDENCE_SCHEMA_VERSION, + domain: SUMMARY_DOMAIN, + ...replay, + ...latestReceiptFinancialMetrics(receipts), + authorityEventHeadHash: manifest.source.authorityEventHeadHash, + signedProjections, + receipts, + isolationAttestation, + }; +} + +function reportFor(manifest, summary) { + return [ + '# Wallet Kernel Evidence Report', + '', + `- Mode: ${manifest.mode}`, + `- Events replayed: ${summary.eventCount}`, + `- Decisions: ${summary.decisionCount}`, + `- Settled gross from latest receipts (atomic USDC): ${summary.latestReceiptSettledGrossAtomic}`, + `- Confirmed refunds from latest receipts (atomic USDC): ${summary.latestReceiptConfirmedRefundAtomic}`, + `- Unresolved exposure from latest receipts (atomic USDC): ${summary.latestReceiptUnresolvedExposureAtomic}`, + `- Unique public transactions: ${summary.transactionCount}`, + `- Signed receipts: ${summary.receipts.length}`, + `- Live CDP: ${manifest.status.liveCdp}`, + `- Wallet funded: ${manifest.status.walletFunded}`, + `- Testnet transaction: ${manifest.status.testnetTransaction}`, + '', + 'Financial metrics sum the latest signed receipt revision for each intent. Settled gross includes payments later refunded; confirmed refunds are reported separately.', + '', + 'This report replays the normalized public evidence chain. It does not recompute the private SQLite authority chain from redacted evidence.', + '', + ].join('\n'); +} + +function readmeFor() { + return [ + '# Wallet Kernel Evidence Bundle', + '', + 'This directory is a closed, sanitized evidence bundle.', + 'Verification requires the manifest SHA-256 supplied through an out-of-band channel.', + 'The embedded manifest is never accepted as its own trust anchor.', + '', + ].join('\n'); +} + +function canonicalBytes(value) { + return Buffer.from(`${canonicalJson(value)}\n`, 'utf8'); +} + +function fsyncDirectory(directory) { + const descriptor = fs.openSync(directory, fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY ?? 0)); + try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } +} + +function writeExclusiveFile(destination, bytes) { + const descriptor = fs.openSync( + destination, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL + | (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + try { + fs.writeFileSync(descriptor, bytes); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function canonicalOutputPath(outputDirectory) { + if (typeof outputDirectory !== 'string' || !path.isAbsolute(outputDirectory) + || path.resolve(outputDirectory) !== outputDirectory || outputDirectory.includes('\0')) { + fail('EVIDENCE_OUTPUT_PATH', 'evidence output directory must be one canonical absolute path'); + } + const parent = path.dirname(outputDirectory); + let actualParent; + try { actualParent = fs.realpathSync(parent); } catch (cause) { + fail('EVIDENCE_OUTPUT_PATH', 'evidence output parent must already exist', cause); + } + if (actualParent !== parent) { + fail('EVIDENCE_OUTPUT_PATH', 'evidence output parent must not traverse symlinks'); + } + return outputDirectory; +} + +function assemble(input) { + const validated = validateManifestInput(input.manifestInput); + const receipts = validateReceipts(input.receipts, validated.keys); + const projections = verifyProjectionSet( + validated.signedProjections, + validated.manifest, + validated.keys, + receipts, + ); + if (validated.isolationAttestation !== null + && !projections.some(({ projection }) => ( + projection.agentEnrollment?.state === 'active' + && projection.agentEnrollment?.enrollmentHash + === validated.isolationAttestation.enrollmentHash + ))) { + fail('EVIDENCE_PREFLIGHT', 'privileged report does not match the signed enrollment'); + } + const events = buildNormalizedEvents(input.events, receipts); + const summary = summaryFor({ + manifest: validated.manifest, + events, + receipts, + signedProjections: projections, + isolationAttestation: validated.isolationAttestation, + }); + if (validated.manifest.mode === 'base-sepolia-testnet' + && summary.transactionCount < 1) { + fail('EVIDENCE_MODE_GATE', 'settled testnet evidence requires a public transaction ID'); + } + assertSanitized(summary); + const fileBytes = new Map([ + ['events.jsonl', Buffer.from(`${events.map(canonicalJson).join('\n')}\n`, 'utf8')], + ['summary.json', canonicalBytes(summary)], + ['report.md', Buffer.from(reportFor(validated.manifest, summary), 'utf8')], + ['README.md', Buffer.from(readmeFor(), 'utf8')], + ]); + const files = [...fileBytes].map(([filePath, bytes]) => ({ + path: filePath, + sha256: rawSha256(bytes), + bytes: bytes.length, + })).sort((left, right) => left.path.localeCompare(right.path)); + const manifest = { ...validated.manifest, files }; + assertSanitized(manifest); + const manifestBytes = canonicalBytes(manifest); + return { fileBytes, manifestBytes }; +} + +export function buildEvidenceBundle({ outputDirectory, manifestInput, events, receipts }) { + const destination = canonicalOutputPath(outputDirectory); + if (fs.existsSync(destination)) { + fail('EVIDENCE_OUTPUT_EXISTS', 'evidence output directory already exists'); + } + const assembled = assemble({ manifestInput, events, receipts }); + let created = false; + try { + fs.mkdirSync(destination, { mode: 0o700 }); + created = true; + fs.chmodSync(destination, 0o700); + for (const filename of LISTED_FILES) { + writeExclusiveFile(path.join(destination, filename), assembled.fileBytes.get(filename)); + } + writeExclusiveFile(path.join(destination, 'manifest.json'), assembled.manifestBytes); + fsyncDirectory(destination); + fsyncDirectory(path.dirname(destination)); + } catch (cause) { + if (created) fs.rmSync(destination, { force: true, recursive: true }); + if (cause instanceof EvidenceError) throw cause; + fail('EVIDENCE_WRITE', 'evidence bundle could not be written atomically', cause); + } + return Object.freeze({ manifestSha256: rawSha256(assembled.manifestBytes) }); +} + +function canonicalBundleDirectory(directory) { + if (typeof directory !== 'string' || !path.isAbsolute(directory) + || path.resolve(directory) !== directory || directory.includes('\0')) { + fail('EVIDENCE_DIRECTORY', 'evidence directory must be one canonical absolute path'); + } + let actual; + try { actual = fs.realpathSync(directory); } catch (cause) { + fail('EVIDENCE_DIRECTORY', 'evidence directory does not exist', cause); + } + const stat = fs.lstatSync(directory); + if (actual !== directory || !stat.isDirectory() || stat.isSymbolicLink()) { + fail('EVIDENCE_DIRECTORY', 'evidence directory must not traverse a symlink'); + } + return directory; +} + +function readRegularFile(directory, filename) { + const destination = path.join(directory, filename); + let descriptor; + try { + descriptor = fs.openSync(destination, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.nlink !== 1 || stat.size < 1 || stat.size > MAXIMUM_FILE_BYTES) { + fail('EVIDENCE_FILE_TYPE', 'evidence files must be bounded single-link regular files'); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor); + if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size) { + fail('EVIDENCE_FILE_CHANGED', 'evidence file changed while it was read'); + } + return bytes; + } catch (cause) { + if (cause instanceof EvidenceError) throw cause; + fail('EVIDENCE_FILE_TYPE', 'evidence file could not be read safely', cause); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function parseCanonicalJsonFile(bytes, label, code) { + if (bytes.at(-1) !== 0x0a || bytes.subarray(0, -1).includes(0x0a) || bytes.includes(0x00)) { + fail(code, `${label} must contain canonical JSON plus one newline`); + } + let parsed; + try { parsed = JSON.parse(bytes.subarray(0, -1).toString('utf8')); } catch (cause) { + fail(code, `${label} is not JSON`, cause); + } + if (!isPlainData(parsed) || !bytes.equals(canonicalBytes(parsed))) { + fail(code, `${label} is not canonical JSON`); + } + return parsed; +} + +function validateStoredManifest(value) { + const manifest = capture(value, [ + 'schemaVersion', 'createdAt', 'mode', 'git', 'runtime', 'protocol', 'wallet', + 'isolation', 'deployment', 'inputs', 'source', 'files', 'status', + ], [], 'EVIDENCE_MANIFEST_SCHEMA', 'evidence manifest'); + if (manifest.schemaVersion !== EVIDENCE_SCHEMA_VERSION + || !['offline-deterministic', 'base-sepolia-testnet'].includes(manifest.mode)) { + fail('EVIDENCE_MANIFEST_SCHEMA', 'evidence manifest version or mode is invalid'); + } + manifest.createdAt = timestamp( + manifest.createdAt, + 'manifest createdAt', + 'EVIDENCE_MANIFEST_SCHEMA', + ); + manifest.git = validateGit(manifest.git, manifest.mode); + manifest.runtime = validateRuntime(manifest.runtime, manifest.mode); + manifest.protocol = validateProtocol(manifest.protocol); + manifest.wallet = validateWallet(manifest.wallet, manifest.mode); + const isolation = capture(manifest.isolation, [ + 'status', 'preflightDigest', 'kernelIdentityHash', 'agentIdentityHash', + ], [], 'EVIDENCE_MANIFEST_SCHEMA', 'manifest isolation'); + if (!['simulated', 'enforced'].includes(isolation.status) + || (isolation.preflightDigest !== null && !PREFIXED_HASH.test(isolation.preflightDigest))) { + fail('EVIDENCE_MANIFEST_SCHEMA', 'manifest isolation is invalid'); + } + prefixedHash(isolation.kernelIdentityHash, 'Kernel identity hash', 'EVIDENCE_MANIFEST_SCHEMA'); + prefixedHash(isolation.agentIdentityHash, 'Agent identity hash', 'EVIDENCE_MANIFEST_SCHEMA'); + if (isolation.kernelIdentityHash === isolation.agentIdentityHash) { + fail('EVIDENCE_MANIFEST_SCHEMA', 'manifest identities are not role-separated'); + } + manifest.isolation = isolation; + manifest.deployment = validateDeployment(manifest.deployment); + manifest.inputs = validateInputs(manifest.inputs); + const source = capture(manifest.source, [ + 'authorityEventHeadHash', 'signedProjectionHash', 'receiptKeys', + ], [], 'EVIDENCE_MANIFEST_SCHEMA', 'manifest source'); + prefixedHash(source.authorityEventHeadHash, 'authority event head', 'EVIDENCE_MANIFEST_SCHEMA'); + prefixedHash(source.signedProjectionHash, 'signed projection', 'EVIDENCE_MANIFEST_SCHEMA'); + const keys = normalizeKeys(source.receiptKeys); + if (canonicalJson(source.receiptKeys) !== canonicalJson(publicKeysOnly(keys))) { + fail('EVIDENCE_MANIFEST_SCHEMA', 'manifest receipt keys are not canonically ordered'); + } + manifest.source = { ...source, receiptKeys: publicKeysOnly(keys) }; + manifest.status = validateStatus(manifest.status); + if (!Array.isArray(manifest.files) || manifest.files.length !== LISTED_FILES.length) { + fail('EVIDENCE_MANIFEST_SCHEMA', 'manifest must list exactly four non-manifest files'); + } + manifest.files = manifest.files.map((entry) => { + const file = capture(entry, ['path', 'sha256', 'bytes'], [], + 'EVIDENCE_MANIFEST_SCHEMA', 'manifest file'); + rawHash(file.sha256, 'manifest file digest', 'EVIDENCE_MANIFEST_SCHEMA'); + if (!LISTED_FILES.includes(file.path) || !Number.isSafeInteger(file.bytes) + || file.bytes < 1 || file.bytes > MAXIMUM_FILE_BYTES) { + fail('EVIDENCE_MANIFEST_SCHEMA', 'manifest file entry is invalid'); + } + return file; + }); + const paths = manifest.files.map(({ path: filePath }) => filePath); + if (new Set(paths).size !== paths.length + || paths.some((filePath, index) => index > 0 + && manifest.files[index - 1].path.localeCompare(filePath) >= 0) + || [...paths].sort().join('\0') !== [...LISTED_FILES].sort().join('\0')) { + fail('EVIDENCE_MANIFEST_SCHEMA', 'manifest files are not the exact canonical set'); + } + assertSanitized(manifest); + return { manifest, keys }; +} + +function parseEvents(bytes) { + if (bytes.at(-1) !== 0x0a || bytes.includes(0x00)) { + fail('EVIDENCE_EVENT_CHAIN', 'events must be canonical JSONL with a final newline'); + } + const lines = bytes.subarray(0, -1).toString('utf8').split('\n'); + if (lines.length === 0 || lines.some((line) => line.length === 0)) { + fail('EVIDENCE_EVENT_CHAIN', 'events JSONL must contain nonempty records'); + } + return lines.map((line) => { + let event; + try { event = JSON.parse(line); } catch (cause) { + fail('EVIDENCE_EVENT_CHAIN', 'events JSONL contains invalid JSON', cause); + } + if (!isPlainData(event) || canonicalJson(event) !== line) { + fail('EVIDENCE_EVENT_CHAIN', 'events JSONL contains noncanonical JSON'); + } + return event; + }); +} + +function validateStoredSummary(value, manifest, replay, keys) { + const summary = capture(value, [ + 'schemaVersion', 'domain', 'eventCount', 'decisionCount', + 'latestReceiptSettledGrossAtomic', 'latestReceiptConfirmedRefundAtomic', + 'latestReceiptUnresolvedExposureAtomic', + 'transactionCount', 'transactionIds', 'normalizedEvidenceHeadHash', + 'authorityEventHeadHash', 'signedProjections', 'receipts', 'isolationAttestation', + ], [], 'EVIDENCE_SUMMARY_SCHEMA', 'evidence summary'); + if (summary.schemaVersion !== EVIDENCE_SCHEMA_VERSION || summary.domain !== SUMMARY_DOMAIN + || !Number.isSafeInteger(summary.eventCount) || summary.eventCount < 1 + || !Number.isSafeInteger(summary.decisionCount) || summary.decisionCount < 0 + || !Number.isSafeInteger(summary.transactionCount) || summary.transactionCount < 0 + || !Array.isArray(summary.transactionIds)) { + fail('EVIDENCE_SUMMARY_SCHEMA', 'evidence summary counters are invalid'); + } + for (const name of [ + 'latestReceiptSettledGrossAtomic', + 'latestReceiptConfirmedRefundAtomic', + 'latestReceiptUnresolvedExposureAtomic', + ]) atomic(summary[name], `summary ${name}`, 'EVIDENCE_SUMMARY_SCHEMA'); + prefixedHash( + summary.normalizedEvidenceHeadHash, + 'normalized evidence head', + 'EVIDENCE_SUMMARY_SCHEMA', + ); + prefixedHash(summary.authorityEventHeadHash, 'authority event head', 'EVIDENCE_SUMMARY_SCHEMA'); + if (canonicalJson({ + eventCount: summary.eventCount, + decisionCount: summary.decisionCount, + transactionCount: summary.transactionCount, + transactionIds: summary.transactionIds, + normalizedEvidenceHeadHash: summary.normalizedEvidenceHeadHash, + }) !== canonicalJson(replay) + || summary.authorityEventHeadHash !== manifest.source.authorityEventHeadHash) { + fail('EVIDENCE_SUMMARY_MISMATCH', 'summary disagrees with replayed normalized events'); + } + const receipts = validateReceipts(summary.receipts, keys); + if (canonicalJson(latestReceiptFinancialMetrics(receipts)) !== canonicalJson({ + latestReceiptSettledGrossAtomic: summary.latestReceiptSettledGrossAtomic, + latestReceiptConfirmedRefundAtomic: summary.latestReceiptConfirmedRefundAtomic, + latestReceiptUnresolvedExposureAtomic: summary.latestReceiptUnresolvedExposureAtomic, + })) { + fail('EVIDENCE_SUMMARY_MISMATCH', 'summary financial metrics disagree with latest signed receipts'); + } + const projections = verifyProjectionSet(summary.signedProjections, manifest, keys, receipts); + validateStoredAttestation(summary.isolationAttestation, manifest, projections); + assertSanitized(summary); + return { summary, receipts }; +} + +function validateStoredAttestation(value, manifest, projections) { + if (manifest.mode === 'offline-deterministic') { + if (manifest.isolation.status !== 'simulated' || manifest.isolation.preflightDigest !== null + || manifest.deployment.status !== 'simulated' + || DEPLOYMENT_DIGEST_FIELDS.some((name) => manifest.deployment[name] !== null) + || Object.values(manifest.status).some((item) => item !== 'not-run') + || value !== null) { + fail('EVIDENCE_MODE_GATE', 'offline bundle claims a live isolation or deployment result'); + } + return; + } + const attestation = capture(value, [ + 'preflightDigest', 'enrollmentHash', 'probedAt', 'expiresAt', 'releaseManifestDigest', + 'releaseTreeHash', 'serviceArtifactsHash', 'systemdEffectiveConfigHash', + ], [], 'EVIDENCE_PREFLIGHT', 'sanitized privileged attestation'); + for (const name of [ + 'preflightDigest', 'enrollmentHash', 'releaseManifestDigest', 'releaseTreeHash', + 'serviceArtifactsHash', 'systemdEffectiveConfigHash', + ]) prefixedHash(attestation[name], `attestation ${name}`, 'EVIDENCE_PREFLIGHT'); + const probedAt = timestamp(attestation.probedAt, 'attestation probedAt', 'EVIDENCE_PREFLIGHT'); + const expiresAt = timestamp(attestation.expiresAt, 'attestation expiresAt', 'EVIDENCE_PREFLIGHT'); + if (manifest.isolation.status !== 'enforced' + || manifest.deployment.status !== 'enforced' + || manifest.status.liveCdp !== 'passed' + || manifest.status.walletFunded !== 'sufficient' + || manifest.status.testnetTransaction !== 'settled' + || attestation.preflightDigest !== manifest.isolation.preflightDigest + || !projections.some(({ projection }) => ( + projection.agentEnrollment?.state === 'active' + && attestation.enrollmentHash === projection.agentEnrollment?.enrollmentHash + )) + || attestation.releaseManifestDigest !== manifest.deployment.releaseManifestDigest + || attestation.releaseTreeHash !== manifest.deployment.releaseTreeHash + || attestation.serviceArtifactsHash !== manifest.deployment.serviceArtifactsHash + || attestation.systemdEffectiveConfigHash !== manifest.deployment.systemdEffectiveConfigHash + || Date.parse(expiresAt) <= Date.parse(probedAt) + || Date.parse(expiresAt) - Date.parse(probedAt) > 15 * 60 * 1_000 + || Date.parse(manifest.createdAt) < Date.parse(probedAt) + || Date.parse(manifest.createdAt) >= Date.parse(expiresAt) + || projections.some(({ projection }) => ( + Date.parse(projection.issuedAt) < Date.parse(probedAt) + || Date.parse(projection.issuedAt) >= Date.parse(expiresAt) + ))) { + fail('EVIDENCE_MODE_GATE', 'testnet bundle lacks a matching unexpired privileged attestation'); + } +} + +function expectedTextFiles(manifest, summary) { + return new Map([ + ['README.md', Buffer.from(readmeFor(), 'utf8')], + ['report.md', Buffer.from(reportFor(manifest, summary), 'utf8')], + ]); +} + +export function verifyEvidenceBundle(outputDirectory, options = {}) { + const expected = options?.expectedManifestSha256; + if (typeof expected !== 'string' || !RAW_HASH.test(expected)) { + fail('EVIDENCE_EXTERNAL_ANCHOR', 'a canonical external manifest SHA-256 is required'); + } + const directory = canonicalBundleDirectory(outputDirectory); + const manifestBytes = readRegularFile(directory, 'manifest.json'); + const actualManifestSha256 = rawSha256(manifestBytes); + if (actualManifestSha256 !== expected) { + fail('EVIDENCE_EXTERNAL_ANCHOR', 'manifest bytes do not match the external trust anchor'); + } + const storedNames = fs.readdirSync(directory).sort(); + if (storedNames.join('\0') !== [...BUNDLE_FILES].sort().join('\0')) { + fail('EVIDENCE_FILE_SET', 'evidence directory must contain exactly five files'); + } + const parsedManifest = parseCanonicalJsonFile( + manifestBytes, + 'manifest', + 'EVIDENCE_MANIFEST_SCHEMA', + ); + const { manifest, keys } = validateStoredManifest(parsedManifest); + const bytesByName = new Map(); + for (const entry of manifest.files) { + const bytes = readRegularFile(directory, entry.path); + if (bytes.length !== entry.bytes || rawSha256(bytes) !== entry.sha256) { + fail('EVIDENCE_FILE_HASH', 'listed evidence file does not match its manifest hash'); + } + bytesByName.set(entry.path, bytes); + } + const events = parseEvents(bytesByName.get('events.jsonl')); + const replay = replayEvents(events); + const parsedSummary = parseCanonicalJsonFile( + bytesByName.get('summary.json'), + 'summary', + 'EVIDENCE_SUMMARY_SCHEMA', + ); + const { summary, receipts } = validateStoredSummary(parsedSummary, manifest, replay, keys); + verifyReceiptEventParity(events, receipts); + for (const [filename, expectedBytes] of expectedTextFiles(manifest, summary)) { + if (!bytesByName.get(filename).equals(expectedBytes)) { + fail('EVIDENCE_REPORT_MISMATCH', 'generated evidence documentation is not recomputable'); + } + } + if (manifest.mode === 'base-sepolia-testnet' && replay.transactionCount < 1) { + fail('EVIDENCE_MODE_GATE', 'settled testnet evidence requires a public transaction ID'); + } + for (const value of [manifest, events, summary]) assertSanitized(value); + return Object.freeze({ + valid: true, + mode: manifest.mode, + manifestSha256: actualManifestSha256, + authorityEventHeadHash: manifest.source.authorityEventHeadHash, + normalizedEvidenceHeadHash: replay.normalizedEvidenceHeadHash, + eventCount: replay.eventCount, + decisionCount: replay.decisionCount, + latestReceiptSettledGrossAtomic: summary.latestReceiptSettledGrossAtomic, + latestReceiptConfirmedRefundAtomic: summary.latestReceiptConfirmedRefundAtomic, + latestReceiptUnresolvedExposureAtomic: summary.latestReceiptUnresolvedExposureAtomic, + transactionCount: replay.transactionCount, + receiptCount: receipts.length, + liveCdp: manifest.status.liveCdp, + walletFunded: manifest.status.walletFunded, + testnetTransaction: manifest.status.testnetTransaction, + }); +} diff --git a/spikes/pi-wielder/src/invocation-journal.mjs b/spikes/pi-wielder/src/invocation-journal.mjs index ac2edb6..cbdea34 100644 --- a/spikes/pi-wielder/src/invocation-journal.mjs +++ b/spikes/pi-wielder/src/invocation-journal.mjs @@ -5,6 +5,21 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { assertExecutionQuote } from './execution-economics.mjs'; +import { + canonicalJson, + createReceiptSigner, + loadOrCreateReceiptSigner, + receiptKeyId, + verifySignedReceipt, +} from './kernel/receipt-signing.mjs'; + +export { + canonicalJson, + createReceiptSigner, + loadOrCreateReceiptSigner, + receiptKeyId, + verifySignedReceipt, +}; const TERMINAL_EXECUTION = new Set(['succeeded', 'failed', 'cancelled']); const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../', import.meta.url))); @@ -12,15 +27,6 @@ const LEASE_ID = /^[0-9a-f]{32}$/; const waitCell = new Int32Array(new SharedArrayBuffer(4)); const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0; -function canonicalize(value) { - if (Array.isArray(value)) return value.map(canonicalize); - if (value && typeof value === 'object') { - return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); - } - return value; -} - -export const canonicalJson = (value) => JSON.stringify(canonicalize(value)); const same = (left, right) => canonicalJson(left) === canonicalJson(right); const copy = (value) => structuredClone(value); @@ -253,13 +259,6 @@ function withLease(lockPath, operation, hooks = {}) { } } -export function receiptKeyId(publicKey) { - const keyObject = publicKey?.type === 'public' ? publicKey : crypto.createPublicKey(publicKey); - return `sha256:${crypto.createHash('sha256') - .update(keyObject.export({ type: 'spki', format: 'der' })) - .digest('hex')}`; -} - function normalizeReceiptSigner(signer, { requirePersistent = false } = {}) { if (!signer || typeof signer !== 'object' || typeof signer.signHash !== 'function') { throw new Error('receipt signer must provide signHash'); @@ -304,67 +303,6 @@ function verifyHashSignature(hashHex, signature, publicKey) { } } -export function createReceiptSigner(keys = {}, { persistent = false } = {}) { - const pair = keys.privateKey && keys.publicKey - ? { privateKey: keys.privateKey, publicKey: keys.publicKey } - : crypto.generateKeyPairSync('ed25519'); - const publicKeyPem = pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); - const keyId = receiptKeyId(pair.publicKey); - return normalizeReceiptSigner({ - algorithm: 'Ed25519', - publicKeyPem, - keyId, - persistent, - signHash(hashHex) { - return crypto.sign(null, Buffer.from(hashHex, 'hex'), pair.privateKey).toString('base64'); - }, - }); -} - -export function loadOrCreateReceiptSigner(keyPath) { - const canonicalKeyPath = safePersistentPath(keyPath, 'persistent receipt key'); - return withLease(`${canonicalKeyPath}.lock`, () => { - let privateKey; - if (fs.existsSync(canonicalKeyPath)) { - privateKey = crypto.createPrivateKey(readPrivateFile(canonicalKeyPath, 'persistent receipt key')); - } else { - const pair = crypto.generateKeyPairSync('ed25519'); - privateKey = pair.privateKey; - const temporary = `${canonicalKeyPath}.${process.pid}.${crypto.randomUUID()}.tmp`; - const descriptor = fs.openSync( - temporary, - fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW, - 0o600, - ); - try { - writeAll(descriptor, Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' }))); - fs.fsyncSync(descriptor); - } finally { - fs.closeSync(descriptor); - } - fs.renameSync(temporary, canonicalKeyPath); - fsyncDirectory(path.dirname(canonicalKeyPath)); - } - return createReceiptSigner( - { privateKey, publicKey: crypto.createPublicKey(privateKey) }, - { persistent: true }, - ); - }); -} - -export function verifySignedReceipt(bundle, { publicKeyPem, keyId }) { - try { - if (bundle?.algorithm !== 'Ed25519' || bundle.keyId !== keyId) return false; - const expectedHash = crypto.createHash('sha256').update(canonicalJson(bundle.receipt)).digest('hex'); - if (bundle.receiptHash !== expectedHash) return false; - const publicKey = crypto.createPublicKey(publicKeyPem); - return publicKey.asymmetricKeyType === 'ed25519' - && verifyHashSignature(expectedHash, bundle.signature, publicKey); - } catch { - return false; - } -} - const EVENT_DATA_KEYS = Object.freeze({ 'invocation.requested': ['invocationId', 'mode', 'skill', 'requestHash', 'creatorId', 'beneficiaryId'], 'payment.offered': ['quote'], diff --git a/spikes/pi-wielder/src/kernel/agent-enrollment.mjs b/spikes/pi-wielder/src/kernel/agent-enrollment.mjs new file mode 100644 index 0000000..79088d9 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/agent-enrollment.mjs @@ -0,0 +1,522 @@ +import { + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; + +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const INSTANCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/; + +function fail(code, message) { + throw new KernelError(code, message); +} + +function canonicalHash(value, label, code = 'AGENT_ENROLLMENT_SCHEMA') { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + fail(code, `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalInstanceId(value) { + if (typeof value !== 'string' || !INSTANCE_ID_PATTERN.test(value)) { + fail('AGENT_INSTANCE_ID', 'agent instance ID must be one canonical 16-byte identifier'); + } + let decoded; + try { + decoded = Buffer.from(value, 'base64url'); + } catch { + fail('AGENT_INSTANCE_ID', 'agent instance ID must be one canonical 16-byte identifier'); + } + if (decoded.length !== 16 || decoded.toString('base64url') !== value) { + fail('AGENT_INSTANCE_ID', 'agent instance ID must be one canonical 16-byte identifier'); + } + return value; +} + +function canonicalIdentityText(value, label) { + if (typeof value !== 'string' || !/^[1-9][0-9]*$/.test(value)) { + fail('AGENT_IDENTITY', `${label} must be canonical nonzero decimal text`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || String(parsed) !== value) { + fail('AGENT_IDENTITY', `${label} must round-trip through one safe integer`); + } + return Object.freeze({ text: value, value: parsed }); +} + +function positiveSafeIdentity(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + fail('AGENT_IDENTITY', `${label} must be one positive safe integer`); + } + return value; +} + +function validateDescriptor(value) { + const descriptor = exactRecord(value, [ + 'schemaVersion', + 'agentInstanceId', + 'credentialDigest', + 'agentUid', + 'agentGid', + ], [], 'AGENT_DESCRIPTOR_SCHEMA', 'agent enrollment descriptor'); + if (descriptor.schemaVersion !== 1) { + fail('AGENT_DESCRIPTOR_VERSION', 'agent descriptor schemaVersion must equal 1'); + } + const agentUid = canonicalIdentityText(descriptor.agentUid, 'agent UID'); + const agentGid = canonicalIdentityText(descriptor.agentGid, 'agent GID'); + return Object.freeze({ + schemaVersion: 1, + agentInstanceId: canonicalInstanceId(descriptor.agentInstanceId), + credentialDigest: canonicalHash( + descriptor.credentialDigest, + 'agent credential digest', + 'AGENT_CREDENTIAL_DIGEST', + ), + agentUid: agentUid.text, + agentGid: agentGid.text, + }); +} + +function rowToEnrollment(row) { + if (!row) return null; + try { + const descriptor = validateDescriptor({ + schemaVersion: 1, + agentInstanceId: row.agent_instance_id, + credentialDigest: row.credential_digest, + agentUid: row.agent_uid, + agentGid: row.agent_gid, + }); + const enrollmentHash = canonicalHash(row.enrollment_hash, 'persisted enrollment hash'); + if (sha256(canonicalJson(descriptor)) !== enrollmentHash) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted enrollment hash changed'); + } + const enrolledByOperatorHash = canonicalHash( + row.enrolled_by_operator_hash, + 'persisted enrollment operator hash', + ); + const enrolledAt = canonicalTimestamp(row.enrolled_at, 'persisted enrollment timestamp'); + const active = row.state === 'active'; + const revoked = row.state === 'revoked'; + if ((!active && !revoked) + || (active && (row.revoked_by_operator_hash !== null || row.revoked_at !== null)) + || (revoked && (row.revoked_by_operator_hash === null || row.revoked_at === null))) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted enrollment lifecycle is invalid'); + } + const revokedByOperatorHash = active + ? null + : canonicalHash(row.revoked_by_operator_hash, 'persisted revocation operator hash'); + const revokedAt = active + ? null + : canonicalTimestamp(row.revoked_at, 'persisted revocation timestamp'); + if (revokedAt !== null && Date.parse(revokedAt) < Date.parse(enrolledAt)) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted revocation predates enrollment'); + } + return frozenCopy({ + agentInstanceId: descriptor.agentInstanceId, + credentialDigest: descriptor.credentialDigest, + enrollmentHash, + agentUid: descriptor.agentUid, + agentGid: descriptor.agentGid, + state: row.state, + enrolledByOperatorHash, + enrolledAt, + revokedByOperatorHash, + revokedAt, + }); + } catch (error) { + if (error instanceof KernelError && error.code !== 'AGENT_ENROLLMENT_CORRUPTION') { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted enrollment row is invalid'); + } + throw error; + } +} + +function enrollmentIsolation(enrollment, eventRows) { + if (!enrollment) return null; + if (eventRows.length !== 1) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'enrollment must have exactly one creation event'); + } + let data; + try { + data = JSON.parse(eventRows[0].data_json); + data = exactRecord(data, [ + 'enrollmentHash', + 'credentialDigest', + 'agentUid', + 'agentGid', + 'operatorIdHash', + 'isolation', + 'enrolledAt', + ], [], 'AGENT_ENROLLMENT_CORRUPTION', 'enrollment creation event'); + if (canonicalJson(data) !== eventRows[0].data_json + || canonicalHash(data.enrollmentHash, 'event enrollment hash') + !== enrollment.enrollmentHash + || canonicalHash(data.credentialDigest, 'event credential digest') + !== enrollment.credentialDigest + || canonicalIdentityText(data.agentUid, 'event agent UID').text !== enrollment.agentUid + || canonicalIdentityText(data.agentGid, 'event agent GID').text !== enrollment.agentGid + || canonicalHash(data.operatorIdHash, 'event operator hash') + !== enrollment.enrolledByOperatorHash + || canonicalTimestamp(data.enrolledAt, 'event enrolledAt') !== enrollment.enrolledAt + || (data.isolation !== 'simulated' && data.isolation !== 'pending_verification')) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'enrollment creation event changed'); + } + } catch (error) { + if (error instanceof KernelError && error.code === 'AGENT_ENROLLMENT_CORRUPTION') throw error; + fail('AGENT_ENROLLMENT_CORRUPTION', 'enrollment isolation label changed'); + } + return data.isolation; +} + +function withIsolation(enrollment, isolation) { + if (!enrollment) return null; + return frozenCopy({ ...enrollment, isolation }); +} + +export function createAgentEnrollmentRepository({ store, now }) { + if (!store || typeof store.transaction !== 'function' || typeof store.within !== 'function') { + throw new TypeError('agent enrollment repository requires a Wallet Kernel store'); + } + if (typeof now !== 'function') throw new TypeError('agent enrollment repository requires a clock'); + + const get = (agentInstanceId) => { + const canonicalId = canonicalInstanceId(agentInstanceId); + const enrollment = rowToEnrollment(store.readOne( + 'SELECT * FROM agent_enrollments WHERE agent_instance_id = ?', + [canonicalId], + )); + if (!enrollment) return null; + const eventRows = store.readAll(`SELECT data_json FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`, ['agent_enrollment', canonicalId, 'agent.enrolled']); + return withIsolation(enrollment, enrollmentIsolation(enrollment, eventRows)); + }; + + const active = () => store.transaction((token) => store.within(token, ({ db }) => { + const rows = db.prepare("SELECT * FROM agent_enrollments WHERE state = 'active'").all(); + if (rows.length > 1) fail('AGENT_ENROLLMENT_AMBIGUOUS', 'multiple active enrollments exist'); + const enrollment = rowToEnrollment(rows[0]); + const currentAttestations = db.prepare( + "SELECT enrollment_hash FROM isolation_attestations WHERE state = 'current'", + ).all(); + if (currentAttestations.length > 1 + || currentAttestations.some((row) => row.enrollment_hash !== enrollment?.enrollmentHash)) { + fail( + 'AGENT_ENROLLMENT_CORRUPTION', + 'current isolation attestation is not bound to the active enrollment', + ); + } + if (!enrollment) return null; + const eventRows = db.prepare(`SELECT data_json FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`).all( + 'agent_enrollment', + enrollment.agentInstanceId, + 'agent.enrolled', + ); + return withIsolation(enrollment, enrollmentIsolation(enrollment, eventRows)); + })); + + const enroll = (input) => { + const record = exactRecord(input, [ + 'descriptor', + 'expectedDescriptorHash', + 'operatorIdHash', + 'mode', + 'kernelUid', + 'kernelGid', + 'expectedAgentUid', + 'expectedAgentGid', + ], [], 'AGENT_ENROLLMENT_SCHEMA', 'agent enrollment'); + const descriptor = validateDescriptor(record.descriptor); + const expectedDescriptorHash = canonicalHash( + record.expectedDescriptorHash, + 'expected descriptor hash', + 'AGENT_DESCRIPTOR_HASH', + ); + const enrollmentHash = sha256(canonicalJson(descriptor)); + if (enrollmentHash !== expectedDescriptorHash) { + fail('AGENT_DESCRIPTOR_HASH', 'agent descriptor hash does not match canonical descriptor'); + } + const operatorIdHash = canonicalHash(record.operatorIdHash, 'operator ID hash'); + if (record.mode !== 'cdp-testnet' && record.mode !== 'deterministic') { + fail('AGENT_ENROLLMENT_MODE', 'agent enrollment mode is invalid'); + } + const kernelUid = positiveSafeIdentity(record.kernelUid, 'kernel UID'); + const kernelGid = positiveSafeIdentity(record.kernelGid, 'kernel GID'); + const expectedAgentUid = positiveSafeIdentity(record.expectedAgentUid, 'expected agent UID'); + const expectedAgentGid = positiveSafeIdentity(record.expectedAgentGid, 'expected agent GID'); + if (Number(descriptor.agentUid) !== expectedAgentUid + || Number(descriptor.agentGid) !== expectedAgentGid) { + fail('AGENT_IDENTITY_MISMATCH', 'descriptor identity differs from configured agent identity'); + } + const agentUid = Number(descriptor.agentUid); + const agentGid = Number(descriptor.agentGid); + if (record.mode === 'cdp-testnet' && agentUid === kernelUid) { + fail('AGENT_IDENTITY_NOT_ISOLATED', 'live agent UID must differ from kernel UID'); + } + if (record.mode === 'deterministic' + && (agentUid !== kernelUid || agentGid !== kernelGid)) { + fail( + 'AGENT_DETERMINISTIC_FIXTURE', + 'deterministic enrollment requires one explicit same-identity fixture', + ); + } + const isolation = record.mode === 'deterministic' ? 'simulated' : 'pending_verification'; + + return store.transaction((token) => store.within(token, ({ db, appendEvent }) => { + const existingRows = db.prepare('SELECT * FROM agent_enrollments ORDER BY rowid') + .all().map(rowToEnrollment); + const currentAttestations = db.prepare(`SELECT * FROM isolation_attestations + WHERE state = 'current' ORDER BY id`).all(); + if (currentAttestations.length > 1) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'multiple current isolation attestations exist'); + } + const activeEnrollment = existingRows.find((row) => row.state === 'active') ?? null; + if (currentAttestations.some( + (row) => row.enrollment_hash !== activeEnrollment?.enrollmentHash, + )) { + fail( + 'AGENT_ENROLLMENT_CORRUPTION', + 'current isolation attestation is not bound to the active enrollment', + ); + } + const exact = existingRows.find((row) => row.agentInstanceId === descriptor.agentInstanceId + && row.credentialDigest === descriptor.credentialDigest + && row.enrollmentHash === enrollmentHash + && row.agentUid === descriptor.agentUid + && row.agentGid === descriptor.agentGid + && row.state === 'active'); + if (exact) { + const eventRows = db.prepare(`SELECT data_json FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`).all( + 'agent_enrollment', + exact.agentInstanceId, + 'agent.enrolled', + ); + const persistedIsolation = enrollmentIsolation(exact, eventRows); + if (persistedIsolation !== isolation) { + fail( + 'AGENT_ENROLLMENT_CONFLICT', + 'an enrollment cannot change its persisted isolation classification', + ); + } + return withIsolation(exact, persistedIsolation); + } + if (existingRows.some((row) => row.state === 'active')) { + fail('AGENT_ENROLLMENT_CONFLICT', 'a different active agent enrollment already exists'); + } + const historicalSame = existingRows.find((row) => row.agentInstanceId + === descriptor.agentInstanceId + || row.credentialDigest === descriptor.credentialDigest + || row.enrollmentHash === enrollmentHash); + if (historicalSame?.state === 'revoked' + && historicalSame.agentInstanceId === descriptor.agentInstanceId + && historicalSame.credentialDigest === descriptor.credentialDigest + && historicalSame.enrollmentHash === enrollmentHash) { + fail('AGENT_REVOKED', 'a revoked enrollment epoch cannot be reactivated'); + } + if (historicalSame) { + fail('AGENT_ENROLLMENT_CONFLICT', 'enrollment identity reuses historical authority'); + } + const revokedBindings = db.prepare(`SELECT agent_session_bindings.session_id, + agent_session_bindings.state AS binding_state, + agent_session_bindings.closed_at AS binding_closed_at, + spend_sessions.state AS session_state, + spend_sessions.closed_at AS session_closed_at + FROM agent_session_bindings + JOIN agent_enrollments + ON agent_enrollments.enrollment_hash = agent_session_bindings.enrollment_hash + JOIN spend_sessions + ON spend_sessions.id = agent_session_bindings.session_id + WHERE agent_enrollments.state = 'revoked' + ORDER BY agent_session_bindings.session_id`).all(); + for (const binding of revokedBindings) { + const bindingClosed = binding.binding_state === 'closed'; + const sessionClosed = binding.session_state === 'closed'; + if (bindingClosed !== sessionClosed) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'revoked session and binding state disagree'); + } + if (!bindingClosed) { + fail( + 'AGENT_ENROLLMENT_BOUND', + 'replacement enrollment requires every revoked binding to be safely closed', + ); + } + let bindingClosedAt; + let sessionClosedAt; + try { + bindingClosedAt = canonicalTimestamp( + binding.binding_closed_at, + 'binding closedAt', + ); + sessionClosedAt = canonicalTimestamp(binding.session_closed_at, 'session closedAt'); + } catch { + fail('AGENT_ENROLLMENT_CORRUPTION', 'closed authority pair has invalid timestamps'); + } + if (bindingClosedAt !== sessionClosedAt) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'closed authority pair timestamps disagree'); + } + } + const enrolledAt = canonicalTimestamp(now(), 'agent enrolledAt'); + db.prepare(`INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, + state, enrolled_by_operator_hash, enrolled_at) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`).run( + descriptor.agentInstanceId, + descriptor.credentialDigest, + enrollmentHash, + descriptor.agentUid, + descriptor.agentGid, + operatorIdHash, + enrolledAt, + ); + appendEvent({ + entityType: 'agent_enrollment', + entityId: descriptor.agentInstanceId, + eventType: 'agent.enrolled', + data: { + enrollmentHash, + credentialDigest: descriptor.credentialDigest, + agentUid: descriptor.agentUid, + agentGid: descriptor.agentGid, + operatorIdHash, + isolation, + enrolledAt, + }, + }); + return withIsolation(rowToEnrollment(db.prepare( + 'SELECT * FROM agent_enrollments WHERE agent_instance_id = ?', + ).get(descriptor.agentInstanceId)), isolation); + })); + }; + + const revoke = (input) => { + const record = exactRecord(input, [ + 'agentInstanceId', + 'expectedEnrollmentHash', + 'operatorIdHash', + ], [], 'AGENT_REVOCATION_SCHEMA', 'agent revocation'); + const agentInstanceId = canonicalInstanceId(record.agentInstanceId); + const expectedEnrollmentHash = canonicalHash( + record.expectedEnrollmentHash, + 'expected enrollment hash', + 'AGENT_ENROLLMENT_STALE', + ); + const operatorIdHash = canonicalHash(record.operatorIdHash, 'operator ID hash'); + + return store.transaction((token) => store.within(token, ({ db, appendEvent }) => { + const current = rowToEnrollment(db.prepare( + 'SELECT * FROM agent_enrollments WHERE agent_instance_id = ?', + ).get(agentInstanceId)); + if (!current || current.state !== 'active') { + fail('AGENT_REVOKED', 'agent enrollment is not active'); + } + if (current.enrollmentHash !== expectedEnrollmentHash) { + fail('AGENT_ENROLLMENT_STALE', 'active enrollment hash differs from confirmation'); + } + const attestations = db.prepare(`SELECT * FROM isolation_attestations + WHERE state = 'current' ORDER BY id`).all(); + if (attestations.length > 1 + || attestations.some((attestation) => attestation.enrollment_hash + !== expectedEnrollmentHash)) { + fail( + 'AGENT_ENROLLMENT_CORRUPTION', + 'current isolation attestation is not unique for the active enrollment', + ); + } + for (const attestation of attestations) { + try { + canonicalToken(attestation.id, 'isolation attestation ID'); + canonicalHash(attestation.report_hash, 'isolation report hash'); + canonicalHash(attestation.imported_by_operator_hash, 'attestation operator hash'); + canonicalTimestamp(attestation.probed_at, 'attestation probedAt'); + canonicalTimestamp(attestation.expires_at, 'attestation expiresAt'); + canonicalTimestamp(attestation.imported_at, 'attestation importedAt'); + const report = JSON.parse(attestation.report_json); + if (canonicalJson(report) !== attestation.report_json + || attestation.superseded_at !== null) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'current isolation attestation is invalid'); + } + } catch (error) { + if (error instanceof KernelError && error.code === 'AGENT_ENROLLMENT_CORRUPTION') { + throw error; + } + fail('AGENT_ENROLLMENT_CORRUPTION', 'current isolation attestation is invalid'); + } + } + const boundSessionIds = db.prepare(`SELECT session_id + FROM agent_session_bindings + WHERE enrollment_hash = ? AND state = 'open' + ORDER BY session_id`).all(expectedEnrollmentHash).map((row) => row.session_id); + const revokedAt = canonicalTimestamp(now(), 'agent revokedAt'); + if (Date.parse(revokedAt) < Date.parse(current.enrolledAt)) { + fail('AGENT_ENROLLMENT_TIME', 'agent revocation cannot predate enrollment'); + } + const update = db.prepare(`UPDATE agent_enrollments + SET state = 'revoked', revoked_by_operator_hash = ?, revoked_at = ? + WHERE agent_instance_id = ? AND enrollment_hash = ? AND state = 'active'`).run( + operatorIdHash, + revokedAt, + agentInstanceId, + expectedEnrollmentHash, + ); + if (update.changes !== 1n) { + fail('AGENT_ENROLLMENT_STALE', 'active enrollment changed during revocation'); + } + for (const attestation of attestations) { + const superseded = db.prepare(`UPDATE isolation_attestations + SET state = 'superseded', superseded_at = ? + WHERE id = ? AND enrollment_hash = ? AND state = 'current'`).run( + revokedAt, + attestation.id, + expectedEnrollmentHash, + ); + if (superseded.changes !== 1n) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'isolation attestation changed during revocation'); + } + appendEvent({ + entityType: 'isolation_attestation', + entityId: attestation.id, + eventType: 'isolation.attestation_superseded', + data: { + enrollmentHash: expectedEnrollmentHash, + reportHash: attestation.report_hash, + supersededAt: revokedAt, + reasonCode: 'AGENT_REVOKED', + }, + }); + } + appendEvent({ + entityType: 'agent_enrollment', + entityId: agentInstanceId, + eventType: 'agent.revoked', + data: { + enrollmentHash: expectedEnrollmentHash, + operatorIdHash, + boundSessionIds, + revokedAt, + }, + }); + const revoked = rowToEnrollment(db.prepare( + 'SELECT * FROM agent_enrollments WHERE agent_instance_id = ?', + ).get(agentInstanceId)); + const creationEvents = db.prepare(`SELECT data_json FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`).all('agent_enrollment', agentInstanceId, 'agent.enrolled'); + return frozenCopy({ + enrollment: withIsolation(revoked, enrollmentIsolation(revoked, creationEvents)), + boundSessionIds, + }); + })); + }; + + return Object.freeze({ enroll, active, get, revoke }); +} diff --git a/spikes/pi-wielder/src/kernel/approval-queue.mjs b/spikes/pi-wielder/src/kernel/approval-queue.mjs new file mode 100644 index 0000000..a44c2ef --- /dev/null +++ b/spikes/pi-wielder/src/kernel/approval-queue.mjs @@ -0,0 +1,1343 @@ +import { types as utilTypes } from 'node:util'; + +import { + canonicalAtomic, + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; +import { + validateChallengeProjection, + validatePolicyDocument, +} from './policy-engine.mjs'; + +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const OPEN_DECISIONS = new Set(['pending', 'approved']); +const DECISIONS = new Set([ + 'pending', + 'approved', + 'denied', + 'expired', + 'cancelled', + 'consumed', +]); +const CANCEL_REASONS = new Set([ + 'POLICY_SUPERSEDED', + 'SESSION_CLOSED', + 'APPROVAL_CHALLENGE_CHANGED', +]); + +function fail(code, message) { + throw new KernelError(code, message); +} + +function canonicalHash(value, label, code = 'APPROVAL_SCHEMA') { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + fail(code, `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalAddress(value, label, code = 'APPROVAL_SCHEMA') { + if (typeof value !== 'string' || !ADDRESS_PATTERN.test(value)) { + fail(code, `${label} must be one canonical lower-case EVM address`); + } + return value; +} + +function boundedToken(value, label, code = 'APPROVAL_SCHEMA') { + try { + return canonicalToken(value, label); + } catch (error) { + if (error instanceof KernelError) fail(code, `${label} must be one bounded canonical token`); + throw error; + } +} + +function canonicalIndex(value, label, code = 'APPROVAL_SCHEMA') { + const normalized = typeof value === 'bigint' ? Number(value) : value; + if (!Number.isSafeInteger(normalized) || normalized < 0 + || (typeof value === 'bigint' && BigInt(normalized) !== value)) { + fail(code, `${label} must be one nonnegative safe integer`); + } + return normalized; +} + +function canonicalLimit(value) { + if (!Number.isSafeInteger(value) || value < 1 || value > 1_000) { + fail('APPROVAL_LIST_SCHEMA', 'approval list limit must be an integer from 1 through 1000'); + } + return value; +} + +function timestamp(value, label, code = 'APPROVAL_SCHEMA') { + try { + return canonicalTimestamp(value, label); + } catch (error) { + if (error instanceof KernelError) fail(code, `${label} must be a canonical timestamp`); + throw error; + } +} + +function nowTimestamp(now, label = 'approval transition time') { + return timestamp(now(), label, 'APPROVAL_TIME'); +} + +function parsePolicyRow(row) { + if (!row) fail('APPROVAL_CORRUPTION', 'approval PolicyVersion is missing'); + try { + const id = canonicalToken(row.id, 'approval PolicyVersion ID'); + const schemaVersion = canonicalIndex(row.schema_version, 'policy schema version'); + const policy = validatePolicyDocument(JSON.parse(row.canonical_json)); + const canonical = canonicalJson(policy); + if (schemaVersion !== policy.schemaVersion + || canonical !== row.canonical_json + || sha256(canonical) !== row.policy_hash) { + fail('APPROVAL_CORRUPTION', 'approval PolicyVersion bytes or hash changed'); + } + canonicalTimestamp(row.applied_at, 'approval PolicyVersion appliedAt'); + return Object.freeze({ id, policy }); + } catch (error) { + if (error instanceof KernelError && error.code === 'APPROVAL_CORRUPTION') throw error; + fail('APPROVAL_CORRUPTION', 'approval PolicyVersion is invalid'); + } +} + +function parseChallengeProjection(bytes, expectedHash) { + try { + const projection = validateChallengeProjection(JSON.parse(bytes)); + const canonical = canonicalJson(projection); + if (canonical !== bytes || sha256(canonical) !== expectedHash) { + fail('APPROVAL_CORRUPTION', 'approval challenge projection binding changed'); + } + return projection; + } catch (error) { + if (error instanceof KernelError && error.code === 'APPROVAL_CORRUPTION') throw error; + fail('APPROVAL_CORRUPTION', 'approval challenge projection is invalid'); + } +} + +function loadAuthority(db, intentId) { + const intent = db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(intentId); + if (!intent) return null; + const decision = db.prepare( + 'SELECT * FROM policy_decisions WHERE intent_id = ?', + ).get(intentId); + const session = db.prepare( + 'SELECT * FROM spend_sessions WHERE id = ?', + ).get(intent.session_id); + const enrollment = db.prepare( + 'SELECT state FROM agent_enrollments WHERE enrollment_hash = ?', + ).get(intent.enrollment_hash); + if (!decision || !session || !enrollment) { + fail('APPROVAL_CORRUPTION', 'approval authority graph is incomplete'); + } + const policyVersion = parsePolicyRow(db.prepare( + 'SELECT * FROM policy_versions WHERE id = ?', + ).get(decision.policy_version_id)); + + let canonicalIntentId; + let sessionId; + let intentHash; + let intentChallengeHash; + let decisionChallengeHash; + let quoteId; + let amountCeilingAtomic; + let intentWallet; + let sessionWallet; + let policyVersionId; + let acceptedIndex; + let challengeReceivedAt; + let decidedAt; + try { + canonicalIntentId = canonicalToken(intent.id, 'approval intent ID'); + sessionId = canonicalToken(intent.session_id, 'approval session ID'); + intentHash = canonicalHash(intent.intent_hash, 'approval intent hash', 'APPROVAL_CORRUPTION'); + intentChallengeHash = canonicalHash( + intent.challenge_hash, + 'approval intent challenge hash', + 'APPROVAL_CORRUPTION', + ); + decisionChallengeHash = canonicalHash( + decision.challenge_hash, + 'approval decision challenge hash', + 'APPROVAL_CORRUPTION', + ); + quoteId = canonicalHash(decision.quote_id, 'approval quote ID', 'APPROVAL_CORRUPTION'); + amountCeilingAtomic = canonicalAtomic( + decision.amount_ceiling_atomic, + 'approval amount ceiling', + ).text; + intentWallet = canonicalAddress( + intent.wallet_address, + 'approval intent wallet', + 'APPROVAL_CORRUPTION', + ); + sessionWallet = canonicalAddress( + session.wallet_address, + 'approval session wallet', + 'APPROVAL_CORRUPTION', + ); + policyVersionId = canonicalToken( + decision.policy_version_id, + 'approval policy version ID', + ); + acceptedIndex = canonicalIndex( + decision.accepted_index, + 'approval accepted index', + 'APPROVAL_CORRUPTION', + ); + challengeReceivedAt = canonicalTimestamp( + intent.challenge_received_at, + 'approval challenge receivedAt', + ); + decidedAt = canonicalTimestamp(decision.decided_at, 'approval PolicyDecision decidedAt'); + } catch (error) { + if (error instanceof KernelError && error.code === 'APPROVAL_CORRUPTION') throw error; + fail('APPROVAL_CORRUPTION', 'approval authority fields are invalid'); + } + + if (canonicalIntentId !== intentId + || decision.intent_id !== intentId + || decision.decision !== 'approval_required' + || decision.reason_code !== 'HUMAN_APPROVAL_REQUIRED' + || intentChallengeHash !== decisionChallengeHash + || session.policy_version_id !== policyVersionId + || policyVersion.id !== policyVersionId + || intentWallet !== sessionWallet + || intentWallet !== policyVersion.policy.wallet + || quoteId !== sha256(canonicalJson({ + challengeHash: decisionChallengeHash, + acceptedIndex, + }))) { + fail('APPROVAL_CORRUPTION', 'approval authority bindings disagree'); + } + + const projection = parseChallengeProjection( + intent.challenge_projection_json, + intentChallengeHash, + ); + let requestUrlHash; + try { + requestUrlHash = canonicalHash( + intent.request_url_hash, + 'approval request URL hash', + 'APPROVAL_CORRUPTION', + ); + } catch (error) { + if (error instanceof KernelError && error.code === 'APPROVAL_CORRUPTION') throw error; + fail('APPROVAL_CORRUPTION', 'approval request binding is invalid'); + } + const seller = policyVersion.policy.sellers.find( + (entry) => entry.origin === intent.seller_origin, + ) ?? null; + const compatible = projection.accepts.filter((candidate) => candidate.scheme === 'exact' + && candidate.network === policyVersion.policy.network + && candidate.asset === policyVersion.policy.asset + && candidate.payTo === seller?.payTo + && candidate.extra.name === 'USDC' + && candidate.extra.version === '2' + && (!Object.hasOwn(candidate.extra, 'assetTransferMethod') + || candidate.extra.assetTransferMethod === 'eip3009')); + if (acceptedIndex >= projection.accepts.length + || compatible.length !== 1 + || projection.accepts.indexOf(compatible[0]) !== acceptedIndex + || projection.accepts[acceptedIndex].amount !== amountCeilingAtomic + || projection.x402Version !== 2 + || projection.resource.urlHash !== requestUrlHash + || !seller + || !seller.pathPrefixes.some((prefix) => intent.resource_path.startsWith(prefix)) + || !policyVersion.policy.methods.includes(intent.method)) { + fail('APPROVAL_CORRUPTION', 'approval selected offer binding changed'); + } + + const challengeDeadline = Date.parse(challengeReceivedAt) + + policyVersion.policy.challengeMaxAgeMs; + const approvalDeadline = Date.parse(decidedAt) + policyVersion.policy.approvalTtlMs; + if (!Number.isSafeInteger(challengeDeadline) || !Number.isSafeInteger(approvalDeadline) + || Date.parse(decidedAt) < Date.parse(challengeReceivedAt)) { + fail('APPROVAL_CORRUPTION', 'approval authority time binding is invalid'); + } + const expiresAt = new Date(Math.min(challengeDeadline, approvalDeadline)).toISOString(); + const activePolicyId = db.prepare( + 'SELECT value FROM metadata WHERE key = ?', + ).get('active_policy_id')?.value ?? null; + + return Object.freeze({ + activePolicyId, + binding: Object.freeze({ + intentId, + intentHash, + challengeHash: decisionChallengeHash, + quoteId, + amountCeilingAtomic, + walletAddress: intentWallet, + policyVersionId, + acceptedIndex, + expiresAt, + }), + challengeReceivedAt, + decidedAt, + enrollmentState: enrollment.state, + intentState: intent.state, + policy: policyVersion.policy, + sessionId, + sessionState: session.state, + }); +} + +function requestInput(value) { + const input = exactRecord(value, [ + 'intentId', + 'intentHash', + 'challengeHash', + 'quoteId', + 'amountCeilingAtomic', + 'walletAddress', + 'policyVersionId', + 'acceptedIndex', + ], [], 'APPROVAL_SCHEMA', 'approval request'); + return Object.freeze({ + intentId: boundedToken(input.intentId, 'approval intent ID'), + intentHash: canonicalHash(input.intentHash, 'approval intent hash'), + challengeHash: canonicalHash(input.challengeHash, 'approval challenge hash'), + quoteId: canonicalHash(input.quoteId, 'approval quote ID'), + amountCeilingAtomic: canonicalAtomic( + input.amountCeilingAtomic, + 'approval amount ceiling', + ).text, + walletAddress: canonicalAddress(input.walletAddress, 'approval wallet'), + policyVersionId: boundedToken(input.policyVersionId, 'approval policy version ID'), + acceptedIndex: canonicalIndex(input.acceptedIndex, 'approval accepted index'), + }); +} + +function consumptionInput(value) { + const input = exactRecord(value, [ + 'intentId', + 'intentHash', + 'challengeHash', + 'quoteId', + 'amountCeilingAtomic', + 'walletAddress', + 'policyVersionId', + 'acceptedIndex', + 'expiresAt', + ], [], 'APPROVAL_SCHEMA', 'approval consumption binding'); + return Object.freeze({ + ...requestInput(Object.fromEntries( + Object.entries(input).filter(([key]) => key !== 'expiresAt'), + )), + expiresAt: timestamp(input.expiresAt, 'approval expiresAt'), + }); +} + +function recordBinding(record) { + return Object.freeze({ + intentId: record.intentId, + intentHash: record.intentHash, + challengeHash: record.challengeHash, + quoteId: record.quoteId, + amountCeilingAtomic: record.amountCeilingAtomic, + walletAddress: record.walletAddress, + policyVersionId: record.policyVersionId, + acceptedIndex: record.acceptedIndex, + expiresAt: record.expiresAt, + }); +} + +function approvalEventRows(db, approvalId) { + return db.prepare(`SELECT current_event.*, + (SELECT prior_event.event_hash FROM events AS prior_event + WHERE prior_event.sequence < current_event.sequence + ORDER BY prior_event.sequence DESC LIMIT 1) AS actual_previous_hash + FROM events AS current_event + WHERE current_event.entity_type = 'approval' AND current_event.entity_id = ? + ORDER BY current_event.sequence`).all(approvalId); +} + +function eventData(row, requiredFields, label) { + let data; + try { + data = JSON.parse(row.data_json); + if (canonicalJson(data) !== row.data_json) { + fail('APPROVAL_CORRUPTION', `${label} data is not canonical`); + } + return exactRecord( + data, + requiredFields, + [], + 'APPROVAL_CORRUPTION', + label, + ); + } catch (error) { + if (error instanceof KernelError && error.code === 'APPROVAL_CORRUPTION') throw error; + fail('APPROVAL_CORRUPTION', `${label} data is invalid`); + } +} + +function approvalEventEnvelope(row, approvalId) { + try { + const sequence = canonicalIndex( + row.sequence, + 'approval event sequence', + 'APPROVAL_CORRUPTION', + ); + if (sequence < 1 + || row.entity_type !== 'approval' + || boundedToken( + row.entity_id, + 'approval event entity ID', + 'APPROVAL_CORRUPTION', + ) !== approvalId + || !new Set([ + 'approval.requested', + 'approval.approved', + 'approval.denied', + 'approval.expired', + 'approval.cancelled', + 'approval.consumed', + ]).has(row.event_type)) { + fail('APPROVAL_CORRUPTION', 'approval event envelope is invalid'); + } + const createdAt = timestamp( + row.created_at, + 'approval event createdAt', + 'APPROVAL_CORRUPTION', + ); + const previousHash = row.previous_hash === null + ? null + : canonicalHash( + row.previous_hash, + 'approval event previous hash', + 'APPROVAL_CORRUPTION', + ); + const actualPreviousHash = row.actual_previous_hash === null + ? null + : canonicalHash( + row.actual_previous_hash, + 'approval event actual previous hash', + 'APPROVAL_CORRUPTION', + ); + const persistedHash = canonicalHash( + row.event_hash, + 'approval event hash', + 'APPROVAL_CORRUPTION', + ); + const data = JSON.parse(row.data_json); + const expectedHash = sha256(canonicalJson({ + entityType: 'approval', + entityId: approvalId, + eventType: row.event_type, + data, + previousHash, + createdAt, + })); + if (previousHash !== actualPreviousHash || persistedHash !== expectedHash) { + fail('APPROVAL_CORRUPTION', 'approval event is not on the append-only event chain'); + } + return Object.freeze({ createdAt, eventType: row.event_type, row }); + } catch (error) { + if (error instanceof KernelError && error.code === 'APPROVAL_CORRUPTION') throw error; + fail('APPROVAL_CORRUPTION', 'approval event envelope is invalid'); + } +} + +function exactApprovalEventBinding(data, record) { + const eventBinding = Object.freeze({ + intentId: boundedToken( + data.intentId, + 'approval event intent ID', + 'APPROVAL_CORRUPTION', + ), + intentHash: canonicalHash( + data.intentHash, + 'approval event intent hash', + 'APPROVAL_CORRUPTION', + ), + }); + if (eventBinding.intentId !== record.intentId + || eventBinding.intentHash !== record.intentHash) { + fail('APPROVAL_CORRUPTION', 'approval event differs from its SpendIntent authority'); + } + return eventBinding; +} + +function approvalLifecycleEvent(envelope, record) { + const { eventType, row, createdAt } = envelope; + let data; + let at; + let previousDecision = null; + if (eventType === 'approval.requested') { + data = eventData(row, [ + 'intentId', + 'intentHash', + 'challengeHash', + 'quoteId', + 'amountCeilingAtomic', + 'walletAddress', + 'policyVersionId', + 'acceptedIndex', + 'expiresAt', + 'requestedAt', + ], 'approval.requested event'); + const binding = Object.freeze({ + intentId: boundedToken( + data.intentId, + 'approval request event intent ID', + 'APPROVAL_CORRUPTION', + ), + intentHash: canonicalHash( + data.intentHash, + 'approval request event intent hash', + 'APPROVAL_CORRUPTION', + ), + challengeHash: canonicalHash( + data.challengeHash, + 'approval request event challenge hash', + 'APPROVAL_CORRUPTION', + ), + quoteId: canonicalHash( + data.quoteId, + 'approval request event quote ID', + 'APPROVAL_CORRUPTION', + ), + amountCeilingAtomic: canonicalAtomic( + data.amountCeilingAtomic, + 'approval request event amount ceiling', + ).text, + walletAddress: canonicalAddress( + data.walletAddress, + 'approval request event wallet', + 'APPROVAL_CORRUPTION', + ), + policyVersionId: boundedToken( + data.policyVersionId, + 'approval request event PolicyVersion ID', + 'APPROVAL_CORRUPTION', + ), + acceptedIndex: canonicalIndex( + data.acceptedIndex, + 'approval request event accepted index', + 'APPROVAL_CORRUPTION', + ), + expiresAt: timestamp( + data.expiresAt, + 'approval request event expiresAt', + 'APPROVAL_CORRUPTION', + ), + }); + if (canonicalJson(binding) !== canonicalJson(recordBinding(record))) { + fail('APPROVAL_CORRUPTION', 'approval.requested event binding changed'); + } + at = timestamp( + data.requestedAt, + 'approval request event requestedAt', + 'APPROVAL_CORRUPTION', + ); + } else if (eventType === 'approval.approved') { + data = eventData(row, [ + 'intentId', 'intentHash', 'operatorIdHash', 'approvedAt', + ], 'approval.approved event'); + exactApprovalEventBinding(data, record); + const operatorIdHash = canonicalHash( + data.operatorIdHash, + 'approval event operator hash', + 'APPROVAL_CORRUPTION', + ); + if (operatorIdHash !== record.operatorIdHash) { + fail('APPROVAL_CORRUPTION', 'approval.approved operator binding changed'); + } + at = timestamp(data.approvedAt, 'approval event approvedAt', 'APPROVAL_CORRUPTION'); + } else if (eventType === 'approval.denied') { + data = eventData(row, [ + 'intentId', 'intentHash', 'operatorIdHash', 'reasonCode', 'deniedAt', + ], 'approval.denied event'); + exactApprovalEventBinding(data, record); + const operatorIdHash = canonicalHash( + data.operatorIdHash, + 'approval denial event operator hash', + 'APPROVAL_CORRUPTION', + ); + const reasonCode = boundedToken( + data.reasonCode, + 'approval denial event reason', + 'APPROVAL_CORRUPTION', + ); + if (operatorIdHash !== record.operatorIdHash || reasonCode !== record.reasonCode) { + fail('APPROVAL_CORRUPTION', 'approval.denied event binding changed'); + } + at = timestamp(data.deniedAt, 'approval event deniedAt', 'APPROVAL_CORRUPTION'); + } else if (eventType === 'approval.expired') { + data = eventData(row, [ + 'intentId', 'intentHash', 'previousDecision', 'reasonCode', 'expiredAt', + ], 'approval.expired event'); + exactApprovalEventBinding(data, record); + previousDecision = data.previousDecision; + if (!OPEN_DECISIONS.has(previousDecision) + || data.reasonCode !== 'APPROVAL_EXPIRED' + || record.reasonCode !== 'APPROVAL_EXPIRED') { + fail('APPROVAL_CORRUPTION', 'approval.expired event binding changed'); + } + at = timestamp(data.expiredAt, 'approval event expiredAt', 'APPROVAL_CORRUPTION'); + } else if (eventType === 'approval.cancelled') { + data = eventData(row, [ + 'intentId', 'intentHash', 'previousDecision', 'reasonCode', 'cancelledAt', + ], 'approval.cancelled event'); + exactApprovalEventBinding(data, record); + previousDecision = data.previousDecision; + if (!OPEN_DECISIONS.has(previousDecision) + || !CANCEL_REASONS.has(data.reasonCode) + || data.reasonCode !== record.reasonCode) { + fail('APPROVAL_CORRUPTION', 'approval.cancelled event binding changed'); + } + at = timestamp(data.cancelledAt, 'approval event cancelledAt', 'APPROVAL_CORRUPTION'); + } else { + data = eventData(row, [ + 'intentId', 'intentHash', 'consumedAt', + ], 'approval.consumed event'); + exactApprovalEventBinding(data, record); + at = timestamp(data.consumedAt, 'approval event consumedAt', 'APPROVAL_CORRUPTION'); + } + if (Date.parse(at) > Date.parse(createdAt)) { + fail('APPROVAL_CORRUPTION', 'approval transition event predates its recorded fact'); + } + return Object.freeze({ at, createdAt, eventType, previousDecision }); +} + +function validateApprovalLifecycle(db, authority, record) { + try { + const events = approvalEventRows(db, record.approvalId).map( + (row) => approvalLifecycleEvent( + approvalEventEnvelope(row, record.approvalId), + record, + ), + ); + const terminal = events.at(-1) ?? null; + let expectedTypes; + if (record.decision === 'pending') { + expectedTypes = ['approval.requested']; + } else if (record.decision === 'approved') { + expectedTypes = ['approval.requested', 'approval.approved']; + } else if (record.decision === 'denied') { + expectedTypes = ['approval.requested', 'approval.denied']; + } else if (record.decision === 'consumed') { + expectedTypes = ['approval.requested', 'approval.approved', 'approval.consumed']; + } else if (terminal?.previousDecision === 'pending') { + expectedTypes = ['approval.requested', `approval.${record.decision}`]; + } else if (terminal?.previousDecision === 'approved') { + expectedTypes = [ + 'approval.requested', + 'approval.approved', + `approval.${record.decision}`, + ]; + } else { + fail('APPROVAL_CORRUPTION', 'approval terminal event has no legal predecessor'); + } + if (canonicalJson(events.map((event) => event.eventType)) !== canonicalJson(expectedTypes)) { + fail('APPROVAL_CORRUPTION', 'approval event lifecycle is missing, duplicated, or reordered'); + } + + const requestedAt = events[0].at; + if (Date.parse(requestedAt) < Date.parse(authority.decidedAt) + || Date.parse(requestedAt) >= Date.parse(record.expiresAt)) { + fail('APPROVAL_CORRUPTION', 'approval request chronology is invalid'); + } + let predecessorAt = requestedAt; + let predecessorCreatedAt = events[0].createdAt; + for (const event of events.slice(1)) { + if (Date.parse(event.at) < Date.parse(predecessorAt) + || Date.parse(event.createdAt) < Date.parse(predecessorCreatedAt)) { + fail('APPROVAL_CORRUPTION', 'approval event chronology regressed'); + } + predecessorAt = event.at; + predecessorCreatedAt = event.createdAt; + } + + const approved = events.find((event) => event.eventType === 'approval.approved') ?? null; + if (record.decision === 'approved' || record.decision === 'consumed') { + if (approved?.at !== record.decidedAt) { + fail('APPROVAL_CORRUPTION', 'approval row differs from its approval event'); + } + } else if (record.decision !== 'pending' && terminal?.at !== record.decidedAt) { + fail('APPROVAL_CORRUPTION', 'approval row differs from its terminal event'); + } + if (record.decision === 'consumed' && terminal?.at !== record.consumedAt) { + fail('APPROVAL_CORRUPTION', 'consumed approval differs from its event'); + } + if (approved && Date.parse(approved.at) >= Date.parse(record.expiresAt)) { + fail('APPROVAL_CORRUPTION', 'approval event occurred after immutable expiry'); + } + if (record.decision === 'denied' && Date.parse(terminal.at) >= Date.parse(record.expiresAt)) { + fail('APPROVAL_CORRUPTION', 'approval denial occurred after immutable expiry'); + } + if (record.decision === 'consumed' + && Date.parse(record.consumedAt) >= Date.parse(record.expiresAt)) { + fail('APPROVAL_CORRUPTION', 'approval consumption occurred after immutable expiry'); + } + if (record.decision === 'expired' + && Date.parse(terminal.at) < Date.parse(record.expiresAt)) { + fail('APPROVAL_CORRUPTION', 'approval expired before its immutable deadline'); + } + if ((record.decision === 'expired' || record.decision === 'cancelled')) { + if (terminal.previousDecision === 'pending' && record.operatorIdHash !== null) { + fail('APPROVAL_CORRUPTION', 'pending approval terminal event gained an operator'); + } + if (terminal.previousDecision === 'approved' + && (!approved || record.operatorIdHash === null)) { + fail('APPROVAL_CORRUPTION', 'approved terminal event lost its operator authority'); + } + } + return Object.freeze({ + approvedAt: approved?.at ?? null, + lastTransitionAt: predecessorAt, + requestedAt, + }); + } catch (error) { + if (error instanceof KernelError && error.code === 'APPROVAL_CORRUPTION') throw error; + fail('APPROVAL_CORRUPTION', 'persisted approval lifecycle events are invalid'); + } +} + +function rowToRecord(row) { + if (!row) return null; + try { + const decision = row.decision; + if (!DECISIONS.has(decision)) { + fail('APPROVAL_CORRUPTION', 'persisted approval decision is invalid'); + } + const operatorIdHash = row.operator_id_hash === null + ? null + : canonicalHash(row.operator_id_hash, 'approval operator hash', 'APPROVAL_CORRUPTION'); + const reasonCode = row.reason_code === null + ? null + : boundedToken(row.reason_code, 'approval reason code', 'APPROVAL_CORRUPTION'); + const decidedAt = row.decided_at === null + ? null + : timestamp(row.decided_at, 'approval decidedAt', 'APPROVAL_CORRUPTION'); + const consumedAt = row.consumed_at === null + ? null + : timestamp(row.consumed_at, 'approval consumedAt', 'APPROVAL_CORRUPTION'); + const expiresAt = timestamp(row.expires_at, 'approval expiresAt', 'APPROVAL_CORRUPTION'); + + const lifecycleValid = (decision === 'pending' + && operatorIdHash === null && reasonCode === null + && decidedAt === null && consumedAt === null) + || (decision === 'approved' + && operatorIdHash !== null && reasonCode === null + && decidedAt !== null && consumedAt === null) + || (decision === 'denied' + && operatorIdHash !== null && reasonCode !== null + && decidedAt !== null && consumedAt === null) + || (decision === 'expired' + && reasonCode === 'APPROVAL_EXPIRED' + && decidedAt !== null && consumedAt === null) + || (decision === 'cancelled' + && CANCEL_REASONS.has(reasonCode) + && decidedAt !== null && consumedAt === null) + || (decision === 'consumed' + && operatorIdHash !== null && reasonCode === null + && decidedAt !== null && consumedAt !== null); + if (!lifecycleValid + || (consumedAt !== null && Date.parse(consumedAt) < Date.parse(decidedAt))) { + fail('APPROVAL_CORRUPTION', 'persisted approval lifecycle is invalid'); + } + + return frozenCopy({ + approvalId: canonicalToken(row.id, 'approval ID'), + intentId: canonicalToken(row.intent_id, 'approval intent ID'), + decision, + operatorIdHash, + intentHash: canonicalHash(row.intent_hash, 'approval intent hash'), + challengeHash: canonicalHash(row.challenge_hash, 'approval challenge hash'), + quoteId: canonicalHash(row.quote_id, 'approval quote ID'), + acceptedIndex: canonicalIndex( + row.accepted_index, + 'approval accepted index', + 'APPROVAL_CORRUPTION', + ), + amountCeilingAtomic: canonicalAtomic( + row.amount_ceiling_atomic, + 'approval amount ceiling', + ).text, + walletAddress: canonicalAddress( + row.wallet_address, + 'approval wallet', + 'APPROVAL_CORRUPTION', + ), + policyVersionId: canonicalToken(row.policy_version_id, 'approval policy version ID'), + expiresAt, + reasonCode, + decidedAt, + consumedAt, + }); + } catch (error) { + if (error instanceof KernelError && error.code === 'APPROVAL_CORRUPTION') throw error; + fail('APPROVAL_CORRUPTION', 'persisted approval row is invalid'); + } +} + +function validatedRecord(db, row) { + const record = rowToRecord(row); + if (!record) return null; + const authority = loadAuthority(db, record.intentId); + if (!authority + || canonicalJson(recordBinding(record)) !== canonicalJson(authority.binding) + || record.policyVersionId !== authority.binding.policyVersionId + || (record.decidedAt !== null && Date.parse(record.decidedAt) < Date.parse(authority.decidedAt)) + || (record.decision === 'approved' && Date.parse(record.decidedAt) >= Date.parse(record.expiresAt)) + || (record.decision === 'consumed' && Date.parse(record.consumedAt) >= Date.parse(record.expiresAt))) { + fail('APPROVAL_CORRUPTION', 'persisted approval differs from its immutable authority'); + } + const lifecycle = validateApprovalLifecycle(db, authority, record); + return Object.freeze({ authority, lifecycle, record }); +} + +function exactBindingOrFail(left, right) { + if (canonicalJson(left) !== canonicalJson(right)) { + fail('APPROVAL_BINDING_MISMATCH', 'approval binding differs from immutable spend authority'); + } +} + +function approvalById(db, approvalId) { + return db.prepare('SELECT * FROM approvals WHERE id = ?').get(approvalId); +} + +function approvalByIntent(db, intentId) { + return db.prepare('SELECT * FROM approvals WHERE intent_id = ?').get(intentId); +} + +function transitionTime(now, authority, label, predecessorAt = authority.decidedAt) { + const at = nowTimestamp(now, label); + if (Date.parse(at) < Date.parse(authority.decidedAt) + || Date.parse(at) < Date.parse(predecessorAt)) { + fail('APPROVAL_TIME', 'approval transition predates its authoritative predecessor'); + } + return at; +} + +export function createApprovalQueue({ store, idFactory, now }) { + if (!store || typeof store.transaction !== 'function' || typeof store.within !== 'function') { + throw new TypeError('approval queue requires a Wallet Kernel store'); + } + if (typeof idFactory !== 'function' || utilTypes.isProxy(idFactory)) { + throw new TypeError('approval queue requires an ID factory'); + } + if (typeof now !== 'function' || utilTypes.isProxy(now)) { + throw new TypeError('approval queue requires a clock'); + } + + const requestInTransaction = (token, value) => store.within( + token, + ({ db, appendEvent }) => { + const requested = requestInput(value); + const authority = loadAuthority(db, requested.intentId); + if (!authority) { + fail('APPROVAL_BINDING_MISMATCH', 'approval SpendIntent does not exist'); + } + exactBindingOrFail( + { ...requested, expiresAt: authority.binding.expiresAt }, + authority.binding, + ); + + const existingRow = approvalByIntent(db, requested.intentId); + if (existingRow) return validatedRecord(db, existingRow).record; + if (authority.intentState !== 'challenged' + || authority.sessionState !== 'open' + || authority.enrollmentState !== 'active' + || authority.activePolicyId !== authority.binding.policyVersionId) { + fail('APPROVAL_AUTHORITY_INACTIVE', 'new approval authority is not active'); + } + + const requestedAt = transitionTime(now, authority, 'approval requestedAt'); + if (Date.parse(requestedAt) >= Date.parse(authority.binding.expiresAt)) { + fail('APPROVAL_EXPIRED', 'approval authority is already expired'); + } + const pending = db.prepare( + "SELECT COUNT(*) AS count FROM approvals WHERE decision = 'pending'", + ).get().count; + if (BigInt(pending) >= BigInt(authority.policy.maxPendingApprovals)) { + fail('APPROVAL_CAPACITY', 'pending approval capacity is exhausted'); + } + + const approvalId = boundedToken( + idFactory('approval'), + 'approval ID', + 'ID_FACTORY', + ); + if (approvalById(db, approvalId)) { + fail('APPROVAL_ID_CONFLICT', 'approval ID is already bound'); + } + const binding = authority.binding; + db.prepare(`INSERT INTO approvals + (id, intent_id, decision, intent_hash, challenge_hash, quote_id, + accepted_index, amount_ceiling_atomic, wallet_address, + policy_version_id, expires_at) + VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)`).run( + approvalId, + binding.intentId, + binding.intentHash, + binding.challengeHash, + binding.quoteId, + binding.acceptedIndex, + binding.amountCeilingAtomic, + binding.walletAddress, + binding.policyVersionId, + binding.expiresAt, + ); + appendEvent({ + entityType: 'approval', + entityId: approvalId, + eventType: 'approval.requested', + data: { + ...binding, + requestedAt, + }, + }); + return validatedRecord(db, approvalById(db, approvalId)).record; + }, + ); + + const request = (binding) => store.transaction( + (token) => requestInTransaction(token, binding), + ); + + const get = (approvalId) => { + const id = canonicalToken(approvalId, 'approval ID'); + return store.transaction((token) => store.within(token, ({ db }) => { + const row = approvalById(db, id); + return row ? validatedRecord(db, row).record : null; + })); + }; + + const list = (value) => { + const input = exactRecord( + value, + ['limit'], + ['state'], + 'APPROVAL_LIST_SCHEMA', + 'approval list query', + ); + const limit = canonicalLimit(input.limit); + const state = Object.hasOwn(input, 'state') ? input.state : null; + if (state !== null && !DECISIONS.has(state)) { + fail('APPROVAL_LIST_SCHEMA', 'approval list state is invalid'); + } + return store.transaction((token) => store.within(token, ({ db }) => { + const rows = state === null + ? db.prepare('SELECT * FROM approvals ORDER BY expires_at, id LIMIT ?').all(limit) + : db.prepare(`SELECT * FROM approvals + WHERE decision = ? ORDER BY expires_at, id LIMIT ?`).all(state, limit); + return Object.freeze(rows.map((row) => validatedRecord(db, row).record)); + })); + }; + + const approve = (value) => store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => { + const input = exactRecord(value, [ + 'approvalId', + 'expectedIntentHash', + 'operatorIdHash', + ], [], 'APPROVAL_DECISION_SCHEMA', 'approval decision'); + const approvalId = boundedToken( + input.approvalId, + 'approval ID', + 'APPROVAL_DECISION_SCHEMA', + ); + const expectedIntentHash = canonicalHash( + input.expectedIntentHash, + 'expected approval intent hash', + 'APPROVAL_DECISION_SCHEMA', + ); + const operatorIdHash = canonicalHash( + input.operatorIdHash, + 'approval operator hash', + 'APPROVAL_DECISION_SCHEMA', + ); + const currentRow = approvalById(db, approvalId); + if (!currentRow) fail('APPROVAL_UNKNOWN', 'approval does not exist'); + const current = validatedRecord(db, currentRow); + if (current.record.intentHash !== expectedIntentHash) { + fail('APPROVAL_BINDING_MISMATCH', 'displayed intent hash is stale'); + } + if (current.record.decision !== 'pending') { + fail('APPROVAL_STATE_CONFLICT', 'approval is no longer pending'); + } + const approvedAt = transitionTime( + now, + current.authority, + 'approval approvedAt', + current.lifecycle.lastTransitionAt, + ); + if (Date.parse(approvedAt) >= Date.parse(current.record.expiresAt)) { + fail('APPROVAL_EXPIRED', 'approval expired before the operator decision'); + } + const changed = db.prepare(`UPDATE approvals + SET decision = 'approved', operator_id_hash = ?, decided_at = ? + WHERE id = ? AND intent_hash = ? AND decision = 'pending' AND expires_at > ?`).run( + operatorIdHash, + approvedAt, + approvalId, + expectedIntentHash, + approvedAt, + ); + if (changed.changes !== 1n) { + fail('APPROVAL_STATE_CONFLICT', 'approval decision lost its conditional update'); + } + appendEvent({ + entityType: 'approval', + entityId: approvalId, + eventType: 'approval.approved', + data: { + intentId: current.record.intentId, + intentHash: expectedIntentHash, + operatorIdHash, + approvedAt, + }, + }); + return validatedRecord(db, approvalById(db, approvalId)).record; + }, + )); + + const listDue = (value) => { + const input = exactRecord( + value, + ['at', 'limit'], + [], + 'APPROVAL_LIST_SCHEMA', + 'approval due query', + ); + const at = timestamp(input.at, 'approval due time', 'APPROVAL_LIST_SCHEMA'); + const limit = canonicalLimit(input.limit); + return store.transaction((token) => store.within(token, ({ db }) => { + const rows = db.prepare(`SELECT approvals.* FROM approvals + JOIN spend_intents ON spend_intents.id = approvals.intent_id + WHERE (approvals.decision = 'pending' OR approvals.decision = 'approved') + AND spend_intents.state = 'approval_pending' + AND spend_intents.retry_matchable = 1 + AND approvals.expires_at <= ? + ORDER BY approvals.expires_at, approvals.id LIMIT ?`).all(at, limit); + return Object.freeze(rows.map((row) => { + const record = validatedRecord(db, row).record; + return Object.freeze({ + approvalId: record.approvalId, + intentId: record.intentId, + intentHash: record.intentHash, + }); + })); + })); + }; + + const findRetryable = (value) => { + const input = exactRecord(value, [ + 'sessionId', + 'intentHash', + ], [], 'APPROVAL_RETRY_SCHEMA', 'approval retry lookup'); + const sessionId = boundedToken( + input.sessionId, + 'approval retry session ID', + 'APPROVAL_RETRY_SCHEMA', + ); + const intentHash = canonicalHash( + input.intentHash, + 'approval retry intent hash', + 'APPROVAL_RETRY_SCHEMA', + ); + return store.transaction((token) => store.within(token, ({ db }) => { + const rows = db.prepare(`SELECT approvals.* FROM approvals + JOIN spend_intents ON spend_intents.id = approvals.intent_id + WHERE spend_intents.session_id = ? AND spend_intents.intent_hash = ? + AND (approvals.decision = 'pending' OR approvals.decision = 'approved') + AND spend_intents.state = 'approval_pending' + AND spend_intents.retry_matchable = 1 + ORDER BY approvals.id`).all(sessionId, intentHash); + if (rows.length > 1) fail('APPROVAL_CORRUPTION', 'retry approval authority is ambiguous'); + return rows.length === 0 ? null : validatedRecord(db, rows[0]).record; + })); + }; + + const expireLoaded = ({ db, appendEvent }, current, at) => { + if (!OPEN_DECISIONS.has(current.record.decision)) return null; + if (Date.parse(at) < Date.parse(current.record.expiresAt)) { + fail('APPROVAL_NOT_DUE', 'approval has not reached its immutable expiry'); + } + if (Date.parse(at) < Date.parse(current.authority.decidedAt) + || Date.parse(at) < Date.parse(current.lifecycle.lastTransitionAt)) { + fail('APPROVAL_TIME', 'approval expiry predates its authoritative predecessor'); + } + const previousDecision = current.record.decision; + const changed = db.prepare(`UPDATE approvals + SET decision = 'expired', reason_code = 'APPROVAL_EXPIRED', decided_at = ? + WHERE id = ? AND intent_id = ? AND intent_hash = ? + AND decision = ? AND expires_at <= ?`).run( + at, + current.record.approvalId, + current.record.intentId, + current.record.intentHash, + previousDecision, + at, + ); + if (changed.changes !== 1n) { + fail('APPROVAL_STATE_CONFLICT', 'approval expiry lost its conditional update'); + } + appendEvent({ + entityType: 'approval', + entityId: current.record.approvalId, + eventType: 'approval.expired', + data: { + intentId: current.record.intentId, + intentHash: current.record.intentHash, + previousDecision, + reasonCode: 'APPROVAL_EXPIRED', + expiredAt: at, + }, + }); + return validatedRecord(db, approvalById(db, current.record.approvalId)).record; + }; + + const consumeForInTransaction = (token, value) => store.within( + token, + ({ db, appendEvent }) => { + const binding = consumptionInput(value); + const row = approvalByIntent(db, binding.intentId); + if (!row) { + fail('APPROVAL_BINDING_MISMATCH', 'approval does not exist for the exact SpendIntent'); + } + const current = validatedRecord(db, row); + exactBindingOrFail(binding, current.authority.binding); + if (current.record.decision !== 'approved') return null; + const consumedAt = transitionTime( + now, + current.authority, + 'approval consumedAt', + current.lifecycle.lastTransitionAt, + ); + if (Date.parse(consumedAt) >= Date.parse(current.record.expiresAt)) { + expireLoaded({ db, appendEvent }, current, consumedAt); + return null; + } + if (Date.parse(consumedAt) < Date.parse(current.record.decidedAt)) { + fail('APPROVAL_TIME', 'approval consumption predates operator approval'); + } + const changed = db.prepare(`UPDATE approvals + SET decision = 'consumed', consumed_at = ? + WHERE id = ? AND intent_id = ? AND intent_hash = ? + AND decision = 'approved' AND expires_at > ?`).run( + consumedAt, + current.record.approvalId, + current.record.intentId, + current.record.intentHash, + consumedAt, + ); + if (changed.changes !== 1n) { + fail('APPROVAL_STATE_CONFLICT', 'approval consumption lost its conditional update'); + } + appendEvent({ + entityType: 'approval', + entityId: current.record.approvalId, + eventType: 'approval.consumed', + data: { + intentId: current.record.intentId, + intentHash: current.record.intentHash, + consumedAt, + }, + }); + return validatedRecord(db, approvalById(db, current.record.approvalId)).record; + }, + ); + + const denyForIntentInTransaction = (token, value) => store.within( + token, + ({ db, appendEvent }) => { + const input = exactRecord(value, [ + 'approvalId', + 'intentId', + 'expectedIntentHash', + 'operatorIdHash', + 'reasonCode', + ], [], 'APPROVAL_DECISION_SCHEMA', 'approval denial'); + const approvalId = boundedToken( + input.approvalId, + 'approval ID', + 'APPROVAL_DECISION_SCHEMA', + ); + const intentId = boundedToken( + input.intentId, + 'approval intent ID', + 'APPROVAL_DECISION_SCHEMA', + ); + const expectedIntentHash = canonicalHash( + input.expectedIntentHash, + 'expected approval intent hash', + 'APPROVAL_DECISION_SCHEMA', + ); + const operatorIdHash = canonicalHash( + input.operatorIdHash, + 'approval operator hash', + 'APPROVAL_DECISION_SCHEMA', + ); + const reasonCode = boundedToken( + input.reasonCode, + 'approval denial reason', + 'APPROVAL_DECISION_SCHEMA', + ); + const row = approvalById(db, approvalId); + if (!row) fail('APPROVAL_UNKNOWN', 'approval does not exist'); + const current = validatedRecord(db, row); + if (current.record.intentId !== intentId + || current.record.intentHash !== expectedIntentHash) { + fail('APPROVAL_BINDING_MISMATCH', 'displayed approval binding is stale'); + } + if (current.record.decision !== 'pending') { + fail('APPROVAL_STATE_CONFLICT', 'only a pending approval can be denied'); + } + const deniedAt = transitionTime( + now, + current.authority, + 'approval deniedAt', + current.lifecycle.lastTransitionAt, + ); + if (Date.parse(deniedAt) >= Date.parse(current.record.expiresAt)) { + fail('APPROVAL_EXPIRED', 'approval expired before denial'); + } + const changed = db.prepare(`UPDATE approvals + SET decision = 'denied', operator_id_hash = ?, reason_code = ?, decided_at = ? + WHERE id = ? AND intent_id = ? AND intent_hash = ? + AND decision = 'pending' AND expires_at > ?`).run( + operatorIdHash, + reasonCode, + deniedAt, + approvalId, + intentId, + expectedIntentHash, + deniedAt, + ); + if (changed.changes !== 1n) { + fail('APPROVAL_STATE_CONFLICT', 'approval denial lost its conditional update'); + } + appendEvent({ + entityType: 'approval', + entityId: approvalId, + eventType: 'approval.denied', + data: { + intentId, + intentHash: expectedIntentHash, + operatorIdHash, + reasonCode, + deniedAt, + }, + }); + return validatedRecord(db, approvalById(db, approvalId)).record; + }, + ); + + const expireForIntentInTransaction = (token, value) => store.within( + token, + ({ db, appendEvent }) => { + const input = exactRecord(value, [ + 'approvalId', + 'intentId', + 'expectedIntentHash', + 'at', + ], [], 'APPROVAL_EXPIRY_SCHEMA', 'approval expiry'); + const approvalId = boundedToken( + input.approvalId, + 'approval ID', + 'APPROVAL_EXPIRY_SCHEMA', + ); + const intentId = boundedToken( + input.intentId, + 'approval intent ID', + 'APPROVAL_EXPIRY_SCHEMA', + ); + const expectedIntentHash = canonicalHash( + input.expectedIntentHash, + 'expected approval intent hash', + 'APPROVAL_EXPIRY_SCHEMA', + ); + const at = timestamp(input.at, 'approval expiry time', 'APPROVAL_EXPIRY_SCHEMA'); + const row = approvalById(db, approvalId); + if (!row) fail('APPROVAL_UNKNOWN', 'approval does not exist'); + const current = validatedRecord(db, row); + if (current.record.intentId !== intentId + || current.record.intentHash !== expectedIntentHash) { + fail('APPROVAL_BINDING_MISMATCH', 'displayed approval binding is stale'); + } + return expireLoaded({ db, appendEvent }, current, at); + }, + ); + + const cancelForIntentInTransaction = (token, value) => store.within( + token, + ({ db, appendEvent }) => { + const input = exactRecord(value, [ + 'intentId', + 'reasonCode', + ], [], 'APPROVAL_CANCEL_SCHEMA', 'approval cancellation'); + const intentId = boundedToken( + input.intentId, + 'approval intent ID', + 'APPROVAL_CANCEL_SCHEMA', + ); + if (!CANCEL_REASONS.has(input.reasonCode)) { + fail('APPROVAL_CANCEL_REASON', 'approval cancellation reason is not allowed'); + } + const row = approvalByIntent(db, intentId); + if (!row) return null; + const current = validatedRecord(db, row); + if (!OPEN_DECISIONS.has(current.record.decision)) return null; + const cancelledAt = transitionTime( + now, + current.authority, + 'approval cancelledAt', + current.lifecycle.lastTransitionAt, + ); + const changed = db.prepare(`UPDATE approvals + SET decision = 'cancelled', reason_code = ?, decided_at = ? + WHERE id = ? AND intent_id = ? AND intent_hash = ? AND decision = ?`).run( + input.reasonCode, + cancelledAt, + current.record.approvalId, + intentId, + current.record.intentHash, + current.record.decision, + ); + if (changed.changes !== 1n) { + fail('APPROVAL_STATE_CONFLICT', 'approval cancellation lost its conditional update'); + } + appendEvent({ + entityType: 'approval', + entityId: current.record.approvalId, + eventType: 'approval.cancelled', + data: { + intentId, + intentHash: current.record.intentHash, + previousDecision: current.record.decision, + reasonCode: input.reasonCode, + cancelledAt, + }, + }); + return validatedRecord(db, approvalById(db, current.record.approvalId)).record; + }, + ); + + return Object.freeze({ + request, + requestInTransaction, + get, + list, + approve, + listDue, + findRetryable, + consumeForInTransaction, + denyForIntentInTransaction, + expireForIntentInTransaction, + cancelForIntentInTransaction, + }); +} diff --git a/spikes/pi-wielder/src/kernel/authority-mutation-coordinator.mjs b/spikes/pi-wielder/src/kernel/authority-mutation-coordinator.mjs new file mode 100644 index 0000000..d7c0dd8 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/authority-mutation-coordinator.mjs @@ -0,0 +1,116 @@ +import { types as utilTypes } from 'node:util'; + +import { KernelError } from './canonical.mjs'; + +const DEPENDENCY_NAMES = Object.freeze([ + 'assertAdmissionOpen', + 'markAuthorityUnhealthy', +]); + +function readDependencies(options) { + if (!options || typeof options !== 'object' || Array.isArray(options) + || utilTypes.isProxy(options) || Object.getPrototypeOf(options) !== Object.prototype) { + throw new TypeError('authority mutation coordinator options must be one plain object'); + } + + const keys = Reflect.ownKeys(options); + if (keys.length !== DEPENDENCY_NAMES.length + || keys.some((key) => typeof key !== 'string' || !DEPENDENCY_NAMES.includes(key))) { + throw new TypeError('authority mutation coordinator options have an invalid shape'); + } + + const dependencies = {}; + for (const name of DEPENDENCY_NAMES) { + const descriptor = Object.getOwnPropertyDescriptor(options, name); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') + || typeof descriptor.value !== 'function') { + throw new TypeError(`${name} must be an enumerable function data property`); + } + dependencies[name] = descriptor.value; + } + return dependencies; +} + +function isThenable(value) { + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) { + return false; + } + if (utilTypes.isPromise(value)) return true; + let subject = value; + while (subject !== null) { + // Never execute a caller-controlled proxy trap or `then` getter while + // deciding whether the synchronous callback escaped its lease. + if (utilTypes.isProxy(subject)) return true; + const descriptor = Object.getOwnPropertyDescriptor(subject, 'then'); + if (descriptor) { + if (!Object.hasOwn(descriptor, 'value')) return true; + return typeof descriptor.value === 'function'; + } + subject = Object.getPrototypeOf(subject); + } + return false; +} + +export function createAuthorityMutationCoordinator(options) { + if (arguments.length !== 1) { + throw new TypeError('createAuthorityMutationCoordinator requires exactly one options object'); + } + const { assertAdmissionOpen, markAuthorityUnhealthy } = readDependencies(options); + const queue = []; + let draining = false; + let asyncInvariantError = null; + + function drain() { + if (draining) return; + draining = true; + try { + while (queue.length > 0) { + const { operation, resolve, reject } = queue.shift(); + try { + assertAdmissionOpen(); + if (asyncInvariantError) throw asyncInvariantError; + const result = operation(); + if (isThenable(result)) { + // Keep an internal fail-closed latch as a backstop if the injected + // process-level marker is faulty and does not close admission. + asyncInvariantError = new KernelError( + 'AUTHORITY_COORDINATOR_ASYNC_CALLBACK', + 'authority mutation callback must finish synchronously', + ); + try { + markAuthorityUnhealthy('AUTHORITY_COORDINATOR_ASYNC_CALLBACK'); + } catch (cause) { + asyncInvariantError = new KernelError( + 'AUTHORITY_COORDINATOR_ASYNC_CALLBACK', + 'authority mutation callback escaped and its fail-stop hook failed', + { cause }, + ); + } + reject(asyncInvariantError); + } else { + resolve(result); + } + } catch (error) { + reject(error); + } + } + } finally { + draining = false; + } + } + + function runExclusive(operation) { + if (arguments.length !== 1 || typeof operation !== 'function') { + return Promise.reject(new TypeError('runExclusive requires exactly one function')); + } + + return new Promise((resolve, reject) => { + queue.push({ operation, resolve, reject }); + drain(); + }); + } + + return Object.freeze({ + runExclusive, + }); +} diff --git a/spikes/pi-wielder/src/kernel/authorized-permit.mjs b/spikes/pi-wielder/src/kernel/authorized-permit.mjs new file mode 100644 index 0000000..4750540 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/authorized-permit.mjs @@ -0,0 +1,373 @@ +import { types as utilTypes } from 'node:util'; + +import { + canonicalAtomic, + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; + +const BASE_SEPOLIA_CAIP2 = 'eip155:84532'; +const BASE_SEPOLIA_USDC = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const NONCE_PATTERN = /^0x[0-9a-f]{64}$/; +const MAX_UINT256 = (1n << 256n) - 1n; +const PristineWeakMap = WeakMap; +const PristineWeakSet = WeakSet; +const reflectApply = Reflect.apply; +const weakMapDelete = WeakMap.prototype.delete; +const weakMapGet = WeakMap.prototype.get; +const weakMapSet = WeakMap.prototype.set; +const weakSetAdd = WeakSet.prototype.add; +const weakSetHas = WeakSet.prototype.has; +const BINDING_FIELDS = Object.freeze([ + 'intentId', + 'intentHash', + 'challengeHash', + 'quoteId', + 'acceptedIndex', + 'requestUrl', + 'resourceDescription', + 'resourceMimeType', + 'scheme', + 'network', + 'asset', + 'walletAddress', + 'payTo', + 'amountAtomic', + 'validAfter', + 'validBefore', + 'nonce', + 'policyVersionId', +]); +const WINDOW_FIELDS = Object.freeze([ + 'nowMs', + 'challengeReceivedAtMs', + 'challengeMaxAgeMs', + 'approvalExpiresAt', + 'maxTimeoutSeconds', + 'randomBytes', +]); + +function fail(code, message) { + throw new KernelError(code, message); +} + +function permitBindingError(message) { + fail('PERMIT_BINDING', message); +} + +function canonicalHash(value, label) { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + permitBindingError(`${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalAddress(value, label) { + if (typeof value !== 'string' || !ADDRESS_PATTERN.test(value)) { + permitBindingError(`${label} must be one canonical lowercase EVM address`); + } + return value; +} + +function canonicalUint256(value, label, { positive = false } = {}) { + let atomic; + try { + atomic = canonicalAtomic(value, label); + } catch (error) { + if (error instanceof KernelError) { + return permitBindingError(`${label} must be canonical uint256 text`); + } + throw error; + } + if ((positive && atomic.value === 0n) || atomic.value > MAX_UINT256) { + permitBindingError(`${label} is outside the permitted uint256 range`); + } + return atomic.text; +} + +function isCanonicalLiteralLoopbackHttp(value, parsed) { + if (parsed.protocol !== 'http:' || !value.startsWith('http://')) return false; + const authority = value.slice('http://'.length).split(/[/?#]/u, 1)[0]; + return /^(?:127\.0\.0\.1|\[::1\])(?::[1-9][0-9]{0,4})?$/.test(authority) + && parsed.origin === `http://${authority}`; +} + +function canonicalRequestUrl(value) { + if (typeof value !== 'string' || value.length === 0 + || Buffer.byteLength(value, 'utf8') > 4_096) { + permitBindingError('request URL must be one bounded canonical URL'); + } + let parsed; + try { + parsed = new URL(value); + } catch { + return permitBindingError('request URL must be one absolute canonical URL'); + } + if ((parsed.protocol !== 'https:' && !isCanonicalLiteralLoopbackHttp(value, parsed)) + || parsed.username !== '' + || parsed.password !== '' + || parsed.hash !== '' + || parsed.origin === 'null' + || parsed.href !== value) { + permitBindingError('request URL must preserve one exact HTTPS or literal-loopback URL'); + } + return value; +} + +function canonicalIntentId(value) { + try { + return canonicalToken(value, 'permit intent ID'); + } catch (error) { + if (error instanceof KernelError) { + return permitBindingError('intent ID must be one bounded canonical token'); + } + throw error; + } +} + +function canonicalPolicyVersionId(value) { + try { + return canonicalToken(value, 'permit policy version ID'); + } catch (error) { + if (error instanceof KernelError) { + return permitBindingError('policy version ID must be one bounded canonical token'); + } + throw error; + } +} + +function boundedResourceDescription(value) { + if (typeof value !== 'string' + || value.length === 0 + || Buffer.byteLength(value, 'utf8') > 1_024 + || /[\x00-\x1f\x7f]/.test(value)) { + permitBindingError('resource description must be bounded canonical public text'); + } + return value; +} + +function canonicalResourceMimeType(value) { + if (typeof value !== 'string' + || Buffer.byteLength(value, 'utf8') > 200 + || !/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/.test( + value, + )) { + permitBindingError('resource MIME type must be bounded and canonical'); + } + return value; +} + +function validatePermitBinding(value) { + let binding; + try { + binding = exactRecord( + value, + BINDING_FIELDS, + [], + 'PERMIT_BINDING', + 'AuthorizedPermit binding', + ); + } catch (error) { + if (error instanceof KernelError) permitBindingError(error.message); + throw error; + } + + if (!Number.isSafeInteger(binding.acceptedIndex) || binding.acceptedIndex < 0) { + permitBindingError('accepted index must be one nonnegative safe integer'); + } + if (binding.scheme !== 'exact') { + permitBindingError('permit scheme must be exact'); + } + if (binding.network !== BASE_SEPOLIA_CAIP2) { + permitBindingError('permit network must be Base Sepolia'); + } + if (binding.asset !== BASE_SEPOLIA_USDC) { + permitBindingError('permit asset must be Base Sepolia test USDC'); + } + if (binding.validAfter !== '0') { + permitBindingError('authorization validAfter must be exactly zero'); + } + if (typeof binding.nonce !== 'string' || !NONCE_PATTERN.test(binding.nonce)) { + permitBindingError('authorization nonce must be one canonical lowercase bytes32 value'); + } + + const challengeHash = canonicalHash(binding.challengeHash, 'challenge hash'); + const quoteId = canonicalHash(binding.quoteId, 'quote ID'); + if (quoteId !== sha256(canonicalJson({ + challengeHash, + acceptedIndex: binding.acceptedIndex, + }))) { + permitBindingError('quote ID does not match the challenge selection'); + } + + return frozenCopy({ + intentId: canonicalIntentId(binding.intentId), + intentHash: canonicalHash(binding.intentHash, 'intent hash'), + challengeHash, + quoteId, + acceptedIndex: binding.acceptedIndex, + requestUrl: canonicalRequestUrl(binding.requestUrl), + resourceDescription: boundedResourceDescription(binding.resourceDescription), + resourceMimeType: canonicalResourceMimeType(binding.resourceMimeType), + scheme: 'exact', + network: BASE_SEPOLIA_CAIP2, + asset: BASE_SEPOLIA_USDC, + walletAddress: canonicalAddress(binding.walletAddress, 'wallet address'), + payTo: canonicalAddress(binding.payTo, 'payee address'), + amountAtomic: canonicalUint256(binding.amountAtomic, 'authorization amount', { + positive: true, + }), + validAfter: '0', + validBefore: canonicalUint256(binding.validBefore, 'authorization validBefore', { + positive: true, + }), + nonce: binding.nonce, + policyVersionId: canonicalPolicyVersionId(binding.policyVersionId), + }); +} + +function closedWindowInput(value) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail('AUTHORIZATION_WINDOW', 'authorization-window input must be one plain object'); + } + const keys = Reflect.ownKeys(value); + const allowed = new Set(WINDOW_FIELDS); + if (WINDOW_FIELDS.some((field) => !Object.hasOwn(value, field)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key))) { + fail('AUTHORIZATION_WINDOW', 'authorization-window input fields do not match the closed schema'); + } + const copy = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail('AUTHORIZATION_WINDOW', 'authorization-window fields must be enumerable data'); + } + copy[key] = descriptor.value; + } + return copy; +} + +function nonnegativeSafeMilliseconds(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + fail('AUTHORIZATION_WINDOW', `${label} must be nonnegative safe-integer milliseconds`); + } + return value; +} + +function positiveSafeInteger(value, label, maximum = Number.MAX_SAFE_INTEGER) { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + fail('AUTHORIZATION_WINDOW', `${label} must be one positive bounded safe integer`); + } + return value; +} + +function canonicalApprovalExpiry(value) { + if (value === null) return null; + try { + return canonicalTimestamp(value, 'approval expiry'); + } catch (error) { + if (error instanceof KernelError) { + return fail('AUTHORIZATION_WINDOW', 'approval expiry must be null or a canonical timestamp'); + } + throw error; + } +} + +function randomNonce(randomBytes) { + if (typeof randomBytes !== 'function') { + fail('AUTHORIZATION_WINDOW', 'randomBytes must be one injected function'); + } + const bytes = randomBytes(32); + if (utilTypes.isProxy(bytes)) { + fail('AUTHORIZATION_RANDOMNESS', 'randomBytes must return exactly 32 inert bytes'); + } + const isBuffer = Buffer.isBuffer(bytes) && Object.getPrototypeOf(bytes) === Buffer.prototype; + const isUint8 = bytes instanceof Uint8Array + && Object.getPrototypeOf(bytes) === Uint8Array.prototype; + if ((!isBuffer && !isUint8) + || bytes.buffer instanceof SharedArrayBuffer + || bytes.byteLength !== 32) { + fail('AUTHORIZATION_RANDOMNESS', 'randomBytes must return exactly 32 inert bytes'); + } + return `0x${Buffer.from(bytes).toString('hex')}`; +} + +export function deriveAuthorizationWindow(value) { + const input = closedWindowInput(value); + const nowMs = nonnegativeSafeMilliseconds(input.nowMs, 'current time'); + const challengeReceivedAtMs = nonnegativeSafeMilliseconds( + input.challengeReceivedAtMs, + 'challenge receipt time', + ); + const challengeMaxAgeMs = positiveSafeInteger( + input.challengeMaxAgeMs, + 'challenge maximum age', + ); + const maxTimeoutSeconds = positiveSafeInteger( + input.maxTimeoutSeconds, + 'protocol maximum timeout', + 3_600, + ); + const approvalExpiresAt = canonicalApprovalExpiry(input.approvalExpiresAt); + if (challengeReceivedAtMs > nowMs + || challengeReceivedAtMs > Number.MAX_SAFE_INTEGER - challengeMaxAgeMs) { + fail('AUTHORIZATION_WINDOW', 'challenge timing is not one safe elapsed window'); + } + + const nowSeconds = Math.floor(nowMs / 1_000); + const challengeDeadlineSeconds = Math.floor( + (challengeReceivedAtMs + challengeMaxAgeMs) / 1_000, + ); + const approvalDeadlineSeconds = approvalExpiresAt === null + ? challengeDeadlineSeconds + : Math.floor(Date.parse(approvalExpiresAt) / 1_000); + const validBefore = Math.min( + nowSeconds + maxTimeoutSeconds, + challengeDeadlineSeconds, + approvalDeadlineSeconds, + ); + if (!Number.isSafeInteger(validBefore) || validBefore <= nowSeconds) { + fail('AUTHORIZATION_WINDOW', 'authorization validity window is exhausted'); + } + + const nonce = randomNonce(input.randomBytes); + return frozenCopy({ + nonce, + validAfter: '0', + validBefore: String(validBefore), + }); +} + +export function createPermitAuthority() { + const live = new PristineWeakMap(); + const consumed = new PristineWeakSet(); + return Object.freeze({ + issue(binding) { + const validated = validatePermitBinding(binding); + const permit = Object.freeze({ + kind: 'AuthorizedPermit', + intentId: validated.intentId, + }); + reflectApply(weakMapSet, live, [permit, validated]); + return permit; + }, + verifyAndConsume(permit) { + if (reflectApply(weakSetHas, consumed, [permit])) { + throw new Error('AuthorizedPermit already consumed'); + } + const binding = reflectApply(weakMapGet, live, [permit]); + if (!binding) throw new Error('AuthorizedPermit is forged'); + reflectApply(weakMapDelete, live, [permit]); + reflectApply(weakSetAdd, consumed, [permit]); + return binding; + }, + }); +} diff --git a/spikes/pi-wielder/src/kernel/budget-ledger.mjs b/spikes/pi-wielder/src/kernel/budget-ledger.mjs new file mode 100644 index 0000000..342b96b --- /dev/null +++ b/spikes/pi-wielder/src/kernel/budget-ledger.mjs @@ -0,0 +1,3304 @@ +import { types as utilTypes } from 'node:util'; + +import { WalletSigningError } from '../adapters/wallet-adapter-contract.mjs'; +import { + canonicalAtomic, + canonicalEvmHash, + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; +import { + validateChallengeProjection, + validatePolicyDocument, +} from './policy-engine.mjs'; + +const DAY_MS = 24 * 60 * 60 * 1_000; +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const DECIMAL_PATTERN = /^(0|[1-9][0-9]*)$/; + +function fail(code, message) { + throw new KernelError(code, message); +} + +function closedInput(value, required, optional, code, label) { + return exactRecord(value, required, optional, code, label); +} + +function canonicalHash(value, label, code = 'BUDGET_CORRUPTION') { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + fail(code, `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalEvmHashFor(value, label, code) { + try { + return canonicalEvmHash(value, label); + } catch (error) { + if (error instanceof KernelError) fail(code, `${label} is invalid`); + throw error; + } +} + +function canonicalAddress(value, label, code = 'BUDGET_CORRUPTION') { + if (typeof value !== 'string' || !ADDRESS_PATTERN.test(value)) { + fail(code, `${label} must be one canonical lowercase EVM address`); + } + return value; +} + +function canonicalOrigin(value, label, code = 'BUDGET_SCHEMA') { + if (typeof value !== 'string' || value.length === 0 || value.length > 2_048) { + fail(code, `${label} must be one bounded canonical origin`); + } + let parsed; + try { + parsed = new URL(value); + } catch { + fail(code, `${label} must be one canonical origin`); + } + const loopback = parsed.protocol === 'http:' + && /^(?:127\.0\.0\.1|\[::1\])(?::[1-9][0-9]{0,4})?$/.test(parsed.host); + if ((parsed.protocol !== 'https:' && !loopback) + || parsed.username !== '' + || parsed.password !== '' + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '' + || parsed.origin !== value) { + fail(code, `${label} must be one canonical HTTPS or literal-loopback origin`); + } + return value; +} + +function canonicalPersistedTimestamp(value, label, code = 'BUDGET_CORRUPTION') { + try { + return canonicalTimestamp(value, label); + } catch (error) { + if (error instanceof KernelError) fail(code, `${label} is not canonical`); + throw error; + } +} + +function canonicalAtomicText(value, label, code = 'BUDGET_CORRUPTION') { + try { + return canonicalAtomic(value, label); + } catch (error) { + if (error instanceof KernelError) fail(code, `${label} is not canonical atomic text`); + throw error; + } +} + +function safeInteger(value, label, code = 'BUDGET_CORRUPTION') { + const number = typeof value === 'bigint' ? Number(value) : value; + let exact = false; + try { + exact = Number.isSafeInteger(number) && number >= 0 && BigInt(number) === BigInt(value); + } catch { + exact = false; + } + if (!exact) { + fail(code, `${label} must be one nonnegative safe integer`); + } + return number; +} + +function assertTransitionChronology(transitionAt, entries) { + const transition = canonicalPersistedTimestamp( + transitionAt, + 'authoritative transition time', + 'BUDGET_CORRUPTION', + ); + const transitionMilliseconds = Date.parse(transition); + for (const [label, value] of entries) { + if (value === null || value === undefined) continue; + const timestamp = canonicalPersistedTimestamp(value, label, 'BUDGET_CORRUPTION'); + if (Date.parse(timestamp) > transitionMilliseconds) { + fail('BUDGET_TIME', `${label} is later than the authoritative transition clock`); + } + } +} + +function attemptChronology(attempt) { + return [ + ['PaymentAttempt createdAt', attempt.created_at], + ['PaymentAttempt updatedAt', attempt.updated_at], + ['PaymentAttempt signingClaimedAt', attempt.signing_claimed_at], + ['PaymentAttempt signedAt', attempt.signed_at], + ['PaymentAttempt retryStartedAt', attempt.retry_started_at], + ['PaymentAttempt settledAt', attempt.settled_at], + ]; +} + +function parseCanonicalJson(value, label, code = 'BUDGET_CORRUPTION') { + if (typeof value !== 'string') fail(code, `${label} must be canonical JSON text`); + let parsed; + try { + parsed = JSON.parse(value); + } catch { + fail(code, `${label} is not valid JSON`); + } + if (canonicalJson(parsed) !== value) fail(code, `${label} is not canonical JSON`); + return parsed; +} + +function localAttemptBindingHash(authority, attempt) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-attempt-binding.v1', + intentHash: authority.intent.intent_hash, + challengeHash: authority.intent.challenge_hash, + quoteId: attempt.quote_id, + paymentPayloadHash: sha256(attempt.payment_payload_json), + paymentHeaderHash: attempt.payment_hash, + network: authority.policyVersion.policy.network, + payer: authority.session.wallet_address, + payee: authority.projection.selected.payTo, + asset: authority.policyVersion.policy.asset, + amountAtomic: authority.amount.text, + nonce: attempt.nonce, + validAfter: attempt.valid_after, + validBefore: attempt.valid_before, + })); +} + +function localRefundBindingHash(authority, attempt, refundTransactionId) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-binding.v1', + intentHash: authority.intent.intent_hash, + originalTransactionId: attempt.transaction_id, + refundTransactionId, + network: authority.policyVersion.policy.network, + sellerOrigin: authority.intent.seller_origin, + asset: authority.policyVersion.policy.asset, + originalPayer: authority.session.wallet_address, + originalPayee: authority.projection.selected.payTo, + refundSource: authority.projection.seller.refundSource, + refundSigner: authority.projection.seller.refundSigner, + amountAtomic: authority.amount.text, + })); +} + +function assertGloballyUniqueTransaction(db, transactionId, { + allowedPaymentIntentId = null, + allowedPaymentCandidateId = null, + allowedRefundId = null, +} = {}) { + const groups = [ + { + rows: db.prepare(`SELECT intent_id AS owner_id, transaction_id + FROM payment_attempts WHERE transaction_id IS NOT NULL`).all(), + allowedOwnerId: allowedPaymentIntentId, + label: 'persisted payment transaction', + }, + { + rows: db.prepare(`SELECT id AS owner_id, transaction_id + FROM payment_reconciliation_candidates`).all(), + allowedOwnerId: allowedPaymentCandidateId, + label: 'persisted payment candidate transaction', + }, + { + rows: db.prepare(`SELECT id AS owner_id, refund_transaction_id AS transaction_id + FROM refunds WHERE refund_transaction_id IS NOT NULL`).all(), + allowedOwnerId: allowedRefundId, + label: 'persisted refund transaction', + }, + ]; + for (const group of groups) { + for (const row of group.rows) { + const canonical = canonicalEvmHashFor( + row.transaction_id, + group.label, + 'TRANSACTION_BINDING_CORRUPTION', + ); + if (row.transaction_id !== canonical) { + fail('TRANSACTION_BINDING_CORRUPTION', `${group.label} is not canonical lowercase`); + } + if (canonical === transactionId && row.owner_id !== group.allowedOwnerId) { + fail('TRANSACTION_REUSED', 'transaction is already bound to another authority row'); + } + } + } +} + +function loadPolicyVersion(db, policyVersionId) { + const id = canonicalToken(policyVersionId, 'policy version ID'); + const row = db.prepare('SELECT * FROM policy_versions WHERE id = ?').get(id); + if (!row) fail('POLICY_DECISION_MISSING', 'PolicyVersion does not exist'); + const parsed = parseCanonicalJson(row.canonical_json, 'persisted policy JSON'); + let policy; + try { + policy = validatePolicyDocument(parsed); + } catch (error) { + if (error instanceof KernelError) fail('BUDGET_CORRUPTION', 'persisted policy is invalid'); + throw error; + } + const bytes = canonicalJson(policy); + const hash = sha256(bytes); + if (bytes !== row.canonical_json + || hash !== row.policy_hash + || Number(row.schema_version) !== policy.schemaVersion) { + fail('BUDGET_CORRUPTION', 'persisted PolicyVersion binding changed'); + } + if (row.predecessor_hash !== null) canonicalHash(row.predecessor_hash, 'policy predecessor'); + canonicalPersistedTimestamp(row.applied_at, 'policy appliedAt'); + return Object.freeze({ id, hash, policy }); +} + +function validateEnrollmentAndBinding(db, intent, session, { requireActive = true } = {}) { + const enrollment = db.prepare( + 'SELECT * FROM agent_enrollments WHERE enrollment_hash = ?', + ).get(intent.enrollment_hash); + if (!enrollment) fail('AGENT_ENROLLMENT_REQUIRED', 'Spend Intent enrollment is missing'); + if (enrollment.state !== 'active' && enrollment.state !== 'revoked') { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted enrollment state is invalid'); + } + if (requireActive && enrollment.state !== 'active') { + fail('AGENT_REVOKED', 'Spend Intent enrollment is revoked'); + } + const agentInstanceId = canonicalToken(enrollment.agent_instance_id, 'agent instance ID'); + const credentialDigest = canonicalHash(enrollment.credential_digest, 'credential digest'); + const enrollmentHash = canonicalHash(enrollment.enrollment_hash, 'enrollment hash'); + if (!/^[1-9][0-9]*$/.test(enrollment.agent_uid) + || !/^[1-9][0-9]*$/.test(enrollment.agent_gid) + || !Number.isSafeInteger(Number(enrollment.agent_uid)) + || !Number.isSafeInteger(Number(enrollment.agent_gid))) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted agent identity is invalid'); + } + const descriptor = { + schemaVersion: 1, + agentInstanceId, + credentialDigest, + agentUid: enrollment.agent_uid, + agentGid: enrollment.agent_gid, + }; + if (sha256(canonicalJson(descriptor)) !== enrollmentHash + || enrollmentHash !== intent.enrollment_hash) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted enrollment binding changed'); + } + canonicalHash(enrollment.enrolled_by_operator_hash, 'enrollment operator hash'); + const enrolledAt = canonicalPersistedTimestamp(enrollment.enrolled_at, 'enrollment time'); + if (enrollment.state === 'active') { + if (enrollment.revoked_by_operator_hash !== null || enrollment.revoked_at !== null) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'active enrollment has revocation fields'); + } + } else { + canonicalHash(enrollment.revoked_by_operator_hash, 'revocation operator hash'); + const revokedAt = canonicalPersistedTimestamp(enrollment.revoked_at, 'revocation time'); + if (Date.parse(revokedAt) < Date.parse(enrolledAt)) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'enrollment revocation predates enrollment'); + } + } + + const bindings = db.prepare( + 'SELECT * FROM agent_session_bindings WHERE session_id = ? ORDER BY rowid', + ).all(session.id); + if (bindings.length !== 1) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'Spend Session must have exactly one binding'); + } + const binding = bindings[0]; + const sessionIsClosed = session.state === 'closed'; + if (binding.state !== (sessionIsClosed ? 'closed' : 'open') + || binding.agent_instance_id !== agentInstanceId + || binding.credential_digest !== credentialDigest + || binding.enrollment_hash !== enrollmentHash + || session.adapter_id !== `pi:${agentInstanceId}`) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'Spend Session binding is not exact'); + } + canonicalToken(binding.id, 'session binding ID'); + const createdAt = canonicalPersistedTimestamp(binding.created_at, 'binding createdAt'); + const lastSeenAt = canonicalPersistedTimestamp(binding.last_seen_at, 'binding lastSeenAt'); + const sessionClosedAt = session.closed_at === null + ? null + : canonicalPersistedTimestamp(session.closed_at, 'session closedAt'); + const bindingClosedAt = binding.closed_at === null + ? null + : canonicalPersistedTimestamp(binding.closed_at, 'binding closedAt'); + if (createdAt !== session.created_at + || Date.parse(lastSeenAt) < Date.parse(createdAt) + || (sessionIsClosed && (sessionClosedAt === null + || bindingClosedAt !== sessionClosedAt || lastSeenAt !== sessionClosedAt)) + || (!sessionIsClosed && (sessionClosedAt !== null || bindingClosedAt !== null))) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'Spend Session binding time changed'); + } + return Object.freeze({ enrollmentHash, agentInstanceId }); +} + +function validateProjectionBinding(intent, decision, policyVersion) { + if (typeof intent.challenge_projection_json !== 'string') { + fail('POLICY_DECISION_CORRUPTION', 'authorized Spend Intent has no challenge projection'); + } + const parsed = parseCanonicalJson( + intent.challenge_projection_json, + 'Spend Intent challenge projection', + 'POLICY_DECISION_CORRUPTION', + ); + let projection; + try { + projection = validateChallengeProjection(parsed); + } catch (error) { + if (error instanceof KernelError) { + fail('POLICY_DECISION_CORRUPTION', 'Spend Intent challenge projection is invalid'); + } + throw error; + } + const projectionBytes = canonicalJson(projection); + const challengeHash = sha256(projectionBytes); + const acceptedIndex = safeInteger( + decision.accepted_index, + 'PolicyDecision accepted index', + 'POLICY_DECISION_CORRUPTION', + ); + const selected = projection.accepts[acceptedIndex]; + const seller = policyVersion.policy.sellers.find( + (candidate) => candidate.origin === intent.seller_origin, + ); + if (projectionBytes !== intent.challenge_projection_json + || challengeHash !== intent.challenge_hash + || challengeHash !== decision.challenge_hash + || decision.quote_id !== sha256(canonicalJson({ challengeHash, acceptedIndex })) + || !selected + || !seller + || projection.x402Version !== 2 + || selected.scheme !== 'exact' + || (Object.hasOwn(selected.extra, 'assetTransferMethod') + && selected.extra.assetTransferMethod !== 'eip3009') + || selected.extra.name !== 'USDC' + || selected.extra.version !== '2' + || selected.network !== policyVersion.policy.network + || selected.asset !== policyVersion.policy.asset + || selected.payTo !== seller.payTo + || selected.amount !== decision.amount_ceiling_atomic + || projection.resource.urlHash !== intent.request_url_hash + || !seller.pathPrefixes.some((prefix) => intent.resource_path.startsWith(prefix)) + || !policyVersion.policy.methods.includes(intent.method)) { + fail('POLICY_DECISION_CORRUPTION', 'PolicyDecision no longer binds its exact challenge'); + } + return Object.freeze({ projection, selected, seller, acceptedIndex, challengeHash }); +} + +function validateConsumedApproval(db, authority, at) { + const rows = db.prepare('SELECT * FROM approvals WHERE intent_id = ? ORDER BY rowid') + .all(authority.intent.id); + if (authority.decision.decision === 'allow') { + if (rows.length !== 0) { + fail('APPROVAL_BINDING_MISMATCH', 'automatic PolicyDecision cannot consume approval'); + } + return null; + } + if (rows.length !== 1) { + fail('APPROVAL_REQUIRED', 'approval-required PolicyDecision needs one exact approval'); + } + const approval = rows[0]; + const acceptedIndex = safeInteger( + approval.accepted_index, + 'approval accepted index', + 'APPROVAL_BINDING_MISMATCH', + ); + const expiresAt = canonicalPersistedTimestamp( + approval.expires_at, + 'approval expiresAt', + 'APPROVAL_BINDING_MISMATCH', + ); + const decidedAt = canonicalPersistedTimestamp( + approval.decided_at, + 'approval decidedAt', + 'APPROVAL_BINDING_MISMATCH', + ); + const consumedAt = canonicalPersistedTimestamp( + approval.consumed_at, + 'approval consumedAt', + 'APPROVAL_BINDING_MISMATCH', + ); + if (approval.decision !== 'consumed' + || approval.intent_hash !== authority.intent.intent_hash + || approval.challenge_hash !== authority.decision.challenge_hash + || approval.quote_id !== authority.decision.quote_id + || approval.amount_ceiling_atomic !== authority.decision.amount_ceiling_atomic + || approval.wallet_address !== authority.session.wallet_address + || approval.policy_version_id !== authority.policyVersion.id + || acceptedIndex !== authority.projection.acceptedIndex + || approval.operator_id_hash === null + || approval.reason_code !== null + || Date.parse(decidedAt) > Date.parse(consumedAt) + || Date.parse(consumedAt) >= Date.parse(expiresAt) + || Date.parse(at) < Date.parse(consumedAt) + || Date.parse(at) >= Date.parse(expiresAt)) { + fail('APPROVAL_BINDING_MISMATCH', 'consumed approval does not match exact spend authority'); + } + canonicalToken(approval.id, 'approval ID'); + canonicalHash( + approval.operator_id_hash, + 'approval operator hash', + 'APPROVAL_BINDING_MISMATCH', + ); + return approval; +} + +function loadReservationAuthority(db, intentId, { requireReservable = false, at } = {}) { + const id = canonicalToken(intentId, 'intent ID'); + const intent = db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(id); + if (!intent) fail('INTENT_UNKNOWN', 'Spend Intent does not exist'); + const session = db.prepare('SELECT * FROM spend_sessions WHERE id = ?').get(intent.session_id); + if (!session) fail('SESSION_UNKNOWN', 'Spend Session does not exist'); + const decision = db.prepare('SELECT * FROM policy_decisions WHERE intent_id = ?').get(id); + if (!decision) fail('POLICY_DECISION_MISSING', 'Spend Intent has no PolicyDecision'); + const policyVersion = loadPolicyVersion(db, decision.policy_version_id); + + canonicalToken(session.id, 'session ID'); + canonicalOrigin(intent.seller_origin, 'persisted seller origin', 'BUDGET_CORRUPTION'); + canonicalAddress(session.wallet_address, 'session wallet'); + canonicalAddress(intent.wallet_address, 'intent wallet'); + canonicalHash(intent.enrollment_hash, 'intent enrollment hash'); + canonicalHash(intent.intent_hash, 'intent hash'); + canonicalHash(intent.challenge_hash, 'intent challenge hash'); + canonicalPersistedTimestamp(intent.created_at, 'intent createdAt'); + canonicalPersistedTimestamp(intent.updated_at, 'intent updatedAt'); + const challengeReceivedAt = canonicalPersistedTimestamp( + intent.challenge_received_at, + 'intent challenge receivedAt', + ); + canonicalPersistedTimestamp(session.created_at, 'session createdAt'); + const decidedAt = canonicalPersistedTimestamp(decision.decided_at, 'decision decidedAt'); + const amount = canonicalAtomicText( + decision.amount_ceiling_atomic, + 'PolicyDecision amount ceiling', + 'POLICY_DECISION_CORRUPTION', + ); + if (requireReservable && (decision.decision === 'deny' || amount.value === 0n)) { + fail('POLICY_DENIED', 'denied or zero PolicyDecision cannot reserve money'); + } + if (decision.decision !== 'allow' && decision.decision !== 'approval_required') { + fail('POLICY_DECISION_CORRUPTION', 'PolicyDecision is not spend-authorizing'); + } + if ((decision.decision === 'allow' && decision.reason_code !== 'WITHIN_AUTO_LIMIT') + || (decision.decision === 'approval_required' + && decision.reason_code !== 'HUMAN_APPROVAL_REQUIRED')) { + fail('POLICY_DECISION_CORRUPTION', 'PolicyDecision reason is inconsistent'); + } + const activePolicyId = db.prepare( + 'SELECT value FROM metadata WHERE key = ?', + ).get('active_policy_id')?.value ?? null; + if (session.policy_version_id !== policyVersion.id + || decision.policy_version_id !== policyVersion.id + || session.wallet_address !== policyVersion.policy.wallet + || intent.wallet_address !== session.wallet_address) { + fail('POLICY_DECISION_CORRUPTION', 'Spend authority is not aligned to active policy'); + } + if (requireReservable && activePolicyId !== policyVersion.id) { + fail('POLICY_NOT_ACTIVE', 'reservation requires the active PolicyVersion'); + } + if (requireReservable && (intent.state !== 'authorized' + || session.state !== 'open' + || session.closed_at !== null)) { + fail('INTENT_STATE_CONFLICT', 'reservation requires authorized intent in open session'); + } + const enrollment = validateEnrollmentAndBinding(db, intent, session, { + requireActive: requireReservable, + }); + const projection = validateProjectionBinding(intent, decision, policyVersion); + const amountValue = amount.value; + const automatic = BigInt(projection.seller.autoApproveAtomic); + const human = BigInt(projection.seller.humanApproveAtomic); + const perRequest = BigInt(projection.seller.perRequestMaxAtomic); + if (Date.parse(decidedAt) < Date.parse(challengeReceivedAt) + || (decision.decision === 'allow' && amountValue > automatic) + || (decision.decision === 'approval_required' + && (amountValue <= automatic || amountValue > human || amountValue > perRequest))) { + fail('POLICY_DECISION_CORRUPTION', 'PolicyDecision exceeds immutable policy authority'); + } + if (requireReservable + && (Date.parse(at) < Date.parse(challengeReceivedAt) + || Date.parse(at) < Date.parse(decidedAt))) { + fail('BUDGET_TIME', 'reservation clock predates its challenge or PolicyDecision'); + } + if (requireReservable + && Date.parse(at) - Date.parse(challengeReceivedAt) + > policyVersion.policy.challengeMaxAgeMs) { + fail('CHALLENGE_EXPIRED', 'Spend Intent challenge expired before reservation'); + } + const authority = Object.freeze({ + intent, + session, + decision, + policyVersion, + projection, + enrollment, + amount, + }); + if (requireReservable) validateConsumedApproval(db, authority, at); + return authority; +} + +function validateBudgetRow(row) { + const intentId = canonicalToken(row.intent_id, 'budget intent ID'); + const sessionId = canonicalToken(row.session_id, 'budget session ID'); + const sellerOrigin = canonicalOrigin( + row.seller_origin, + 'budget seller origin', + 'BUDGET_CORRUPTION', + ); + const reserved = canonicalAtomicText(row.reserved_atomic, 'reserved amount'); + const committed = canonicalAtomicText(row.committed_atomic, 'committed amount'); + const released = canonicalAtomicText(row.released_atomic, 'released amount'); + const unresolved = canonicalAtomicText(row.unresolved_atomic, 'unresolved amount'); + const ceiling = canonicalAtomicText(row.decision_amount, 'decision amount'); + const updatedAt = canonicalPersistedTimestamp(row.updated_at, 'budget updatedAt'); + const committedAt = row.committed_at === null + ? null + : canonicalPersistedTimestamp(row.committed_at, 'budget committedAt'); + if (row.intent_session_id !== sessionId + || row.intent_seller_origin !== sellerOrigin + || row.intent_wallet_address !== row.session_wallet_address + || reserved.value + committed.value + released.value + unresolved.value !== ceiling.value + || ceiling.value <= 0n) { + fail('BUDGET_CORRUPTION', 'BudgetReservation authority or conservation changed'); + } + const dispositions = { + reserved: [ceiling.value, 0n, 0n, 0n], + committed: [0n, ceiling.value, 0n, 0n], + released: [0n, 0n, ceiling.value, 0n], + unresolved: [0n, 0n, 0n, ceiling.value], + }; + const expected = dispositions[row.state]; + if (!expected + || reserved.value !== expected[0] + || committed.value !== expected[1] + || released.value !== expected[2] + || unresolved.value !== expected[3] + || (row.state === 'committed' && committedAt === null) + || ((row.state === 'reserved' || row.state === 'unresolved') && committedAt !== null)) { + fail('BUDGET_CORRUPTION', 'BudgetReservation disposition is invalid'); + } + return Object.freeze({ + intentId, + sessionId, + sellerOrigin, + walletAddress: canonicalAddress(row.session_wallet_address, 'budget wallet'), + reserved, + committed, + released, + unresolved, + ceiling, + state: row.state, + committedAt, + updatedAt, + }); +} + +const BUDGET_ROWS_SQL = `SELECT budget_reservations.*, + spend_intents.session_id AS intent_session_id, + spend_intents.seller_origin AS intent_seller_origin, + spend_intents.wallet_address AS intent_wallet_address, + spend_sessions.wallet_address AS session_wallet_address, + policy_decisions.amount_ceiling_atomic AS decision_amount + FROM budget_reservations + LEFT JOIN spend_intents ON spend_intents.id = budget_reservations.intent_id + LEFT JOIN spend_sessions ON spend_sessions.id = budget_reservations.session_id + LEFT JOIN policy_decisions ON policy_decisions.intent_id = budget_reservations.intent_id`; + +function validateBudgetEventCompleteness(db) { + const eventEntities = db.prepare(`SELECT entity_id, event_type, COUNT(*) AS event_count + FROM events + WHERE entity_type = 'budget_reservation' + GROUP BY entity_id, event_type + ORDER BY entity_id, event_type`).all(); + const recognizedTypes = new Set([ + 'budget.reserved', + 'budget.committed', + 'budget.released', + 'budget.held_unresolved', + 'budget.payment_resolved', + 'budget.refund_confirmed', + ]); + const summaries = new Map(); + for (const event of eventEntities) { + let intentId; + try { + intentId = canonicalToken(event.entity_id, 'budget event intent ID'); + } catch (error) { + if (error instanceof KernelError) { + fail('BUDGET_CORRUPTION', 'budget event intent ID is invalid'); + } + throw error; + } + const count = safeInteger(event.event_count, 'budget event count'); + if (!recognizedTypes.has(event.event_type) || count !== 1) { + fail('BUDGET_CORRUPTION', 'budget event history is unknown or ambiguous'); + } + const summary = summaries.get(intentId) ?? { reservedEvents: 0 }; + if (event.event_type === 'budget.reserved') summary.reservedEvents += count; + summaries.set(intentId, summary); + } + const reservationCount = db.prepare(`SELECT COUNT(*) AS reservation_count + FROM budget_reservations WHERE intent_id = ?`); + for (const [intentId, summary] of summaries) { + const count = safeInteger( + reservationCount.get(intentId).reservation_count, + 'budget reservation count', + ); + if (summary.reservedEvents !== 1 || count !== 1) { + fail( + 'BUDGET_CORRUPTION', + 'budget event entity does not map to one complete BudgetReservation', + ); + } + } +} + +function validateHistoricalBudgetAuthority(db, row) { + let authority; + try { + authority = loadReservationAuthority(db, row.intentId); + } catch (error) { + if (error instanceof KernelError) { + fail('BUDGET_CORRUPTION', 'BudgetReservation historical authority is invalid'); + } + throw error; + } + if (authority.intent.id !== row.intentId + || authority.intent.session_id !== row.sessionId + || authority.session.id !== row.sessionId + || authority.intent.seller_origin !== row.sellerOrigin + || authority.intent.wallet_address !== row.walletAddress + || authority.session.wallet_address !== row.walletAddress + || authority.policyVersion.policy.wallet !== row.walletAddress + || authority.amount.text !== row.ceiling.text) { + fail('BUDGET_CORRUPTION', 'BudgetReservation substituted its historical authority'); + } + return authority; +} + +function validatedBudgetRows(db, where = '', parameters = []) { + validateBudgetEventCompleteness(db); + return db.prepare(`${BUDGET_ROWS_SQL} ${where} + ORDER BY budget_reservations.intent_id`).all(...parameters).map((raw) => { + const row = validateBudgetRow(raw); + const authority = validateHistoricalBudgetAuthority(db, row); + validateBudgetHistory(db, row, authority); + return row; + }); +} + +function walletBlockers(db, walletAddress, rows) { + const unresolvedBudget = rows.some((row) => row.state === 'unresolved'); + const executionResolution = db.prepare(`SELECT execution_resolutions.intent_id + FROM execution_resolutions + JOIN spend_intents ON spend_intents.id = execution_resolutions.intent_id + JOIN spend_sessions ON spend_sessions.id = spend_intents.session_id + WHERE spend_sessions.wallet_address = ? + AND execution_resolutions.state != 'resolved' + AND execution_resolutions.blocks_wallet = 1 + LIMIT 1`).get(walletAddress); + const refund = db.prepare(`SELECT refunds.id + FROM refunds + JOIN spend_intents ON spend_intents.id = refunds.intent_id + JOIN spend_sessions ON spend_sessions.id = spend_intents.session_id + WHERE spend_sessions.wallet_address = ? + AND refunds.state IN ('pending','unresolved') + LIMIT 1`).get(walletAddress); + return Object.freeze({ + unresolvedBudget, + resolutionRequired: Boolean(executionResolution || refund), + }); +} + +function snapshotImpl(db, { sessionId, sellerOrigin, at }) { + const session = db.prepare('SELECT * FROM spend_sessions WHERE id = ?').get(sessionId); + if (!session) fail('SESSION_UNKNOWN', 'Spend Session does not exist'); + const walletAddress = canonicalAddress(session.wallet_address, 'session wallet'); + const rows = validatedBudgetRows(db).filter((row) => row.walletAddress === walletAddress); + const exposure = (row) => row.reserved.value + row.committed.value + row.unresolved.value; + const sum = (values) => values.reduce((total, value) => total + value, 0n); + const sellerSessionExposure = sum(rows + .filter((row) => row.sessionId === sessionId && row.sellerOrigin === sellerOrigin) + .map(exposure)); + const sessionExposure = sum(rows + .filter((row) => row.sessionId === sessionId) + .map(exposure)); + const windowStart = Date.parse(at) - DAY_MS; + const rollingCommitted = sum(rows + .filter((row) => row.committed.value > 0n && Date.parse(row.committedAt) > windowStart) + .map((row) => row.committed.value)); + const rollingActive = sum(rows.map((row) => row.reserved.value + row.unresolved.value)); + const blockers = walletBlockers(db, walletAddress, rows); + return Object.freeze({ + public: frozenCopy({ + sellerSessionExposureAtomic: sellerSessionExposure.toString(), + sessionExposureAtomic: sessionExposure.toString(), + rolling24hExposureAtomic: (rollingCommitted + rollingActive).toString(), + walletBlocked: blockers.unresolvedBudget || blockers.resolutionRequired, + }), + blockers, + walletAddress, + rows, + }); +} + +function publicReservation(row) { + return frozenCopy({ + intentId: row.intentId, + sessionId: row.sessionId, + sellerOrigin: row.sellerOrigin, + reservedAtomic: row.reserved.text, + committedAtomic: row.committed.text, + releasedAtomic: row.released.text, + unresolvedAtomic: row.unresolved.text, + state: row.state, + committedAt: row.committedAt, + updatedAt: row.updatedAt, + }); +} + +function readBudgetRow(db, intentId) { + const rows = validatedBudgetRows( + db, + 'WHERE budget_reservations.intent_id = ?', + [intentId], + ); + if (rows.length > 1) fail('BUDGET_CORRUPTION', 'BudgetReservation authority is ambiguous'); + return rows[0] ?? null; +} + +function exactObject(value, required, optional, code, label) { + try { + return exactRecord(value, required, optional, code, label); + } catch (error) { + if (error instanceof KernelError && error.code !== code) { + fail(code, `${label} is invalid`); + } + throw error; + } +} + +function validatePaymentPayload(bytes, authority, attempt) { + const parsed = parseCanonicalJson(bytes, 'payment payload JSON', 'PAYMENT_ATTEMPT_CORRUPTION'); + const payment = exactObject( + parsed, + ['x402Version', 'resource', 'accepted', 'payload'], + [], + 'PAYMENT_ATTEMPT_CORRUPTION', + 'payment payload', + ); + const resource = exactObject( + payment.resource, + ['url', 'description', 'mimeType'], + [], + 'PAYMENT_ATTEMPT_CORRUPTION', + 'payment resource', + ); + const accepted = exactObject( + payment.accepted, + ['scheme', 'network', 'asset', 'amount', 'payTo', 'maxTimeoutSeconds', 'extra'], + [], + 'PAYMENT_ATTEMPT_CORRUPTION', + 'accepted payment requirement', + ); + const extra = exactObject( + accepted.extra, + ['name', 'version'], + ['assetTransferMethod'], + 'PAYMENT_ATTEMPT_CORRUPTION', + 'accepted payment extra', + ); + const payload = exactObject( + payment.payload, + ['signature', 'authorization'], + [], + 'PAYMENT_ATTEMPT_CORRUPTION', + 'payment payload body', + ); + const authorization = exactObject( + payload.authorization, + ['from', 'to', 'value', 'validAfter', 'validBefore', 'nonce'], + [], + 'PAYMENT_ATTEMPT_CORRUPTION', + 'payment authorization', + ); + let resourceUrl; + try { + resourceUrl = new URL(resource.url); + } catch { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'persisted payment resource URL is invalid'); + } + const selected = authority.projection.selected; + const validAfter = canonicalAtomicText( + authorization.validAfter, + 'authorization validAfter', + 'PAYMENT_ATTEMPT_CORRUPTION', + ); + const validBefore = canonicalAtomicText( + authorization.validBefore, + 'authorization validBefore', + 'PAYMENT_ATTEMPT_CORRUPTION', + ); + if (payment.x402Version !== 2 + || resourceUrl.href !== resource.url + || resourceUrl.origin !== authority.intent.seller_origin + || resourceUrl.pathname !== authority.intent.resource_path + || resourceUrl.username !== '' + || resourceUrl.password !== '' + || resourceUrl.hash !== '' + || sha256(resource.url) !== authority.intent.request_url_hash + || resource.description !== authority.projection.projection.resource.description + || resource.mimeType !== authority.projection.projection.resource.mimeType + || canonicalJson({ ...accepted, extra }) !== canonicalJson(selected) + || typeof payload.signature !== 'string' + || !/^0x[0-9a-fA-F]{130}$/.test(payload.signature) + || canonicalAddress( + authorization.from, + 'authorization payer', + 'PAYMENT_ATTEMPT_CORRUPTION', + ) !== authority.session.wallet_address + || canonicalAddress( + authorization.to, + 'authorization payee', + 'PAYMENT_ATTEMPT_CORRUPTION', + ) !== selected.payTo + || canonicalAtomicText( + authorization.value, + 'authorization value', + 'PAYMENT_ATTEMPT_CORRUPTION', + ).text !== authority.amount.text + || typeof authorization.nonce !== 'string' + || !/^0x[0-9a-f]{64}$/.test(authorization.nonce) + || authorization.nonce !== attempt.nonce + || validAfter.text !== attempt.valid_after + || validBefore.text !== attempt.valid_before + || validBefore.value <= validAfter.value) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'persisted payment payload binding changed'); + } + return frozenCopy(parsed); +} + +function validatePaymentAttempt(db, authority, allowedStates) { + const row = db.prepare('SELECT * FROM payment_attempts WHERE intent_id = ?') + .get(authority.intent.id); + if (!row) fail('PAYMENT_ATTEMPT_MISSING', 'Spend Intent has no PaymentAttempt'); + if (!allowedStates.has(row.state)) { + fail('PAYMENT_ATTEMPT_STATE', 'PaymentAttempt is not in the required state'); + } + canonicalToken(row.id, 'payment attempt ID'); + if (row.intent_id !== authority.intent.id + || row.payment_required_projection_json !== authority.intent.challenge_projection_json + || safeInteger( + row.accepted_index, + 'payment accepted index', + 'PAYMENT_ATTEMPT_CORRUPTION', + ) !== authority.projection.acceptedIndex + || row.quote_id !== authority.decision.quote_id) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'PaymentAttempt challenge binding changed'); + } + const createdAt = canonicalPersistedTimestamp( + row.created_at, + 'payment attempt createdAt', + 'PAYMENT_ATTEMPT_CORRUPTION', + ); + const updatedAt = canonicalPersistedTimestamp( + row.updated_at, + 'payment attempt updatedAt', + 'PAYMENT_ATTEMPT_CORRUPTION', + ); + if (Date.parse(updatedAt) < Date.parse(createdAt)) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'PaymentAttempt time regressed'); + } + + const claimFields = [ + row.nonce, + row.valid_after, + row.valid_before, + row.signing_claimed_at, + ]; + const signedPayloadFields = [ + row.payment_payload_json, + row.payment_header, + row.payment_hash, + row.signed_at, + ]; + const fullClaim = claimFields.every((value) => value !== null); + const noClaim = claimFields.every((value) => value === null); + const fullSignedPayload = signedPayloadFields.every((value) => value !== null); + const noSignedPayload = signedPayloadFields.every((value) => value === null); + if ((!fullClaim && !noClaim) || (!fullSignedPayload && !noSignedPayload)) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'PaymentAttempt authority fields are partial'); + } + if (fullSignedPayload && !fullClaim) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'PaymentAttempt signed bytes have no signing claim'); + } + if (row.state === 'reserved') { + if (!noClaim + || !noSignedPayload + || row.retry_started_at !== null + || row.settlement_json !== null + || row.transaction_id !== null + || row.settled_at !== null) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'reserved PaymentAttempt contains later fields'); + } + return Object.freeze({ row, paymentPayload: null }); + } + if (row.state === 'rejected' && noClaim && noSignedPayload) { + if (row.retry_started_at !== null + || row.settlement_json !== null + || row.transaction_id !== null + || row.settled_at !== null + || typeof row.reason_code !== 'string' + || !/^[A-Z][A-Z0-9_]{0,99}$/.test(row.reason_code)) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'unsigned rejected PaymentAttempt is inconsistent'); + } + return Object.freeze({ row, paymentPayload: null }); + } + if (!fullClaim) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'advanced PaymentAttempt has no complete signing claim'); + } + const signingClaimedAt = canonicalPersistedTimestamp( + row.signing_claimed_at, + 'payment signing claimedAt', + 'PAYMENT_ATTEMPT_CORRUPTION', + ); + if (Date.parse(signingClaimedAt) < Date.parse(createdAt) + || Date.parse(signingClaimedAt) > Date.parse(updatedAt)) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'PaymentAttempt signing chronology is invalid'); + } + if (!/^0x[0-9a-f]{64}$/.test(row.nonce) + || !DECIMAL_PATTERN.test(row.valid_after) + || !DECIMAL_PATTERN.test(row.valid_before) + || BigInt(row.valid_before) <= BigInt(row.valid_after)) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'authorization claim window is invalid'); + } + if (row.state === 'signing' && fullSignedPayload) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'signing PaymentAttempt already contains signed bytes'); + } + if (noSignedPayload + && new Set(['signing', 'unresolved', 'rejected']).has(row.state)) { + if (row.retry_started_at !== null + || row.settlement_json !== null + || row.transaction_id !== null + || row.settled_at !== null) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'claim-only PaymentAttempt contains signed aftermath'); + } + if (row.state === 'rejected' + && (typeof row.reason_code !== 'string' + || !/^[A-Z][A-Z0-9_]{0,99}$/.test(row.reason_code))) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'claim-only rejection has no stable reason'); + } + return Object.freeze({ row, paymentPayload: null }); + } + if (!fullSignedPayload + || typeof row.payment_header !== 'string' + || row.payment_header.length === 0 + || Buffer.byteLength(row.payment_header, 'utf8') > 16_384 + || /[^\x20-\x7e]/.test(row.payment_header)) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'signed PaymentAttempt header is invalid'); + } + canonicalHash(row.payment_hash, 'payment hash', 'PAYMENT_ATTEMPT_CORRUPTION'); + if (sha256(Buffer.from(row.payment_header, 'ascii')) !== row.payment_hash) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'payment hash does not match exact persisted header'); + } + const signedAt = canonicalPersistedTimestamp( + row.signed_at, + 'payment signedAt', + 'PAYMENT_ATTEMPT_CORRUPTION', + ); + if (Date.parse(signedAt) < Date.parse(signingClaimedAt) + || Date.parse(signedAt) > Date.parse(updatedAt)) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'PaymentAttempt signedAt chronology is invalid'); + } + const needsRetry = new Set(['retrying', 'settled']).has(row.state) + || (new Set(['unresolved', 'rejected']).has(row.state) + && row.retry_started_at !== null); + let retryStartedAt = null; + if (needsRetry) { + retryStartedAt = canonicalPersistedTimestamp( + row.retry_started_at, + 'payment retry startedAt', + 'PAYMENT_ATTEMPT_CORRUPTION', + ); + if (Date.parse(retryStartedAt) < Date.parse(signedAt) + || Date.parse(retryStartedAt) > Date.parse(updatedAt)) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'PaymentAttempt retry chronology is invalid'); + } + } else if (row.retry_started_at !== null) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'PaymentAttempt has an unexpected paid retry'); + } + const paymentPayload = validatePaymentPayload(row.payment_payload_json, authority, row); + if (row.state === 'settled') { + if (row.settlement_json === null || row.transaction_id === null || row.settled_at === null) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'settled PaymentAttempt is incomplete'); + } + parseCanonicalJson(row.settlement_json, 'payment settlement', 'PAYMENT_ATTEMPT_CORRUPTION'); + if (canonicalEvmHashFor( + row.transaction_id, + 'payment transaction ID', + 'PAYMENT_ATTEMPT_CORRUPTION', + ) !== row.transaction_id) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'payment transaction ID is not canonical lowercase'); + } + const settledAt = canonicalPersistedTimestamp( + row.settled_at, + 'payment settledAt', + 'PAYMENT_ATTEMPT_CORRUPTION', + ); + if (Date.parse(settledAt) < Date.parse(retryStartedAt) + || Date.parse(settledAt) > Date.parse(updatedAt) + || settledAt !== updatedAt) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'PaymentAttempt settlement chronology is invalid'); + } + if (row.reason_code !== null && row.reason_code !== 'TRUSTED_RECONCILIATION') { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'settled PaymentAttempt reason is invalid'); + } + } else if (row.settlement_json !== null + || row.transaction_id !== null + || row.settled_at !== null) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'unsettled PaymentAttempt contains settlement fields'); + } + if (row.state === 'rejected' + && (typeof row.reason_code !== 'string' + || !/^[A-Z][A-Z0-9_]{0,99}$/.test(row.reason_code))) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'rejected PaymentAttempt has no stable reason'); + } + return Object.freeze({ row, paymentPayload }); +} + +function validateSettlementEvidence(value) { + if (!Object.isFrozen(value)) { + fail('SETTLEMENT_EVIDENCE', 'settlement evidence must be a frozen classifier result'); + } + const record = closedInput(value, [ + 'source', + 'headerHash', + 'success', + 'transaction', + 'network', + 'payer', + 'paymentHash', + ], ['amountAtomic'], 'SETTLEMENT_EVIDENCE', 'settlement evidence'); + if (record.source !== 'x402-payment-response' || record.success !== true) { + fail('SETTLEMENT_EVIDENCE', 'settlement evidence is not a successful x402 response'); + } + const normalized = { + source: record.source, + headerHash: canonicalHash(record.headerHash, 'settlement header hash', 'SETTLEMENT_EVIDENCE'), + success: true, + transaction: canonicalEvmHash(record.transaction, 'settlement transaction'), + network: canonicalToken(record.network, 'settlement network'), + payer: canonicalAddress(record.payer, 'settlement payer', 'SETTLEMENT_EVIDENCE'), + ...(Object.hasOwn(record, 'amountAtomic') ? { + amountAtomic: canonicalAtomicText( + record.amountAtomic, + 'settlement amount', + 'SETTLEMENT_EVIDENCE', + ).text, + } : {}), + paymentHash: canonicalHash(record.paymentHash, 'settlement payment hash', 'SETTLEMENT_EVIDENCE'), + }; + return frozenCopy(normalized); +} + +function loadBudgetEvent(db, intentId, eventType) { + const rows = db.prepare(`SELECT data_json FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`).all('budget_reservation', intentId, eventType); + if (rows.length === 0) return null; + if (rows.length !== 1) fail('BUDGET_CORRUPTION', 'budget transition event is ambiguous'); + return parseCanonicalJson(rows[0].data_json, 'budget transition event'); +} + +function stableReason(value, label) { + if (typeof value !== 'string' || !/^[A-Z][A-Z0-9_]{0,99}$/.test(value)) { + fail('BUDGET_CORRUPTION', `${label} is not one stable reason code`); + } + return value; +} + +function isExactPreSignRejection(value) { + return WalletSigningError.isExact( + value, + 'WALLET_PRE_SIGN_REJECTED', + false, + ); +} + +function validateBudgetHistory(db, row, authority) { + const reserve = exactObject( + loadBudgetEvent(db, row.intentId, 'budget.reserved'), + [ + 'sessionId', + 'sellerOrigin', + 'amountAtomic', + 'previousState', + 'nextState', + 'updatedAt', + ], + [], + 'BUDGET_CORRUPTION', + 'budget reserve event', + ); + const reserveAt = canonicalPersistedTimestamp( + reserve.updatedAt, + 'budget reserve event time', + ); + if (canonicalToken(reserve.sessionId, 'budget reserve session ID') !== row.sessionId + || canonicalOrigin( + reserve.sellerOrigin, + 'budget reserve seller origin', + 'BUDGET_CORRUPTION', + ) !== row.sellerOrigin + || canonicalAtomicText(reserve.amountAtomic, 'budget reserve amount').text + !== row.ceiling.text + || reserve.previousState !== null + || reserve.nextState !== 'reserved' + || Date.parse(reserveAt) > Date.parse(row.updatedAt)) { + fail('BUDGET_CORRUPTION', 'budget reserve event no longer binds its reservation'); + } + + const rawCommit = loadBudgetEvent(db, row.intentId, 'budget.committed'); + const rawRelease = loadBudgetEvent(db, row.intentId, 'budget.released'); + const rawHold = loadBudgetEvent(db, row.intentId, 'budget.held_unresolved'); + const rawPaymentResolution = loadBudgetEvent(db, row.intentId, 'budget.payment_resolved'); + const rawRefund = loadBudgetEvent(db, row.intentId, 'budget.refund_confirmed'); + + const commit = rawCommit === null ? null : exactObject(rawCommit, [ + 'amountAtomic', + 'transactionId', + 'paymentHash', + 'headerHash', + 'previousState', + 'nextState', + 'committedAt', + ], [], 'BUDGET_CORRUPTION', 'budget commit event'); + if (commit) { + const transactionId = canonicalEvmHashFor( + commit.transactionId, + 'budget commit transaction', + 'BUDGET_CORRUPTION', + ); + if (commit.transactionId !== transactionId + || canonicalAtomicText(commit.amountAtomic, 'budget commit amount').text + !== row.ceiling.text + || canonicalHash(commit.paymentHash, 'budget commit payment hash') + !== commit.paymentHash + || canonicalHash(commit.headerHash, 'budget commit header hash') !== commit.headerHash + || commit.previousState !== 'reserved' + || commit.nextState !== 'committed') { + fail('BUDGET_CORRUPTION', 'budget commit event binding changed'); + } + canonicalPersistedTimestamp(commit.committedAt, 'budget commit event time'); + } + + const release = rawRelease === null ? null : exactObject(rawRelease, [ + 'amountAtomic', + 'reasonCode', + 'previousState', + 'nextState', + 'releasedAt', + ], [], 'BUDGET_CORRUPTION', 'budget release event'); + if (release) { + if (canonicalAtomicText(release.amountAtomic, 'budget release amount').text + !== row.ceiling.text + || stableReason(release.reasonCode, 'budget release reason') !== release.reasonCode + || release.previousState !== 'reserved' + || release.nextState !== 'released') { + fail('BUDGET_CORRUPTION', 'budget release event binding changed'); + } + canonicalPersistedTimestamp(release.releasedAt, 'budget release event time'); + const rawAttempt = db.prepare('SELECT * FROM payment_attempts WHERE intent_id = ?') + .get(row.intentId); + if (!rawAttempt && release.reasonCode === 'WALLET_PRE_SIGN_REJECTED') { + fail('BUDGET_CORRUPTION', 'typed pre-sign release lost its claim-only PaymentAttempt'); + } + if (rawAttempt) { + let attempt; + try { + attempt = validatePaymentAttempt(db, authority, new Set(['rejected'])).row; + } catch (error) { + if (error instanceof KernelError) { + fail('BUDGET_CORRUPTION', 'released PaymentAttempt history is invalid'); + } + throw error; + } + const hasClaimOnly = attempt.signing_claimed_at !== null + && attempt.payment_payload_json === null; + if (attempt.payment_payload_json !== null + || attempt.reason_code !== release.reasonCode + || attempt.updated_at !== release.releasedAt + || hasClaimOnly !== (release.reasonCode === 'WALLET_PRE_SIGN_REJECTED')) { + fail('BUDGET_CORRUPTION', 'released PaymentAttempt no longer binds its release event'); + } + } + } + + const hold = rawHold === null ? null : exactObject(rawHold, [ + 'amountAtomic', + 'reasonCode', + 'previousState', + 'nextState', + 'heldAt', + ], [], 'BUDGET_CORRUPTION', 'budget unresolved-hold event'); + if (hold) { + if (canonicalAtomicText(hold.amountAtomic, 'budget unresolved amount').text + !== row.ceiling.text + || stableReason(hold.reasonCode, 'budget unresolved reason') !== hold.reasonCode + || hold.previousState !== 'reserved' + || hold.nextState !== 'unresolved') { + fail('BUDGET_CORRUPTION', 'budget unresolved-hold event binding changed'); + } + canonicalPersistedTimestamp(hold.heldAt, 'budget unresolved event time'); + } + + let paymentResolution = null; + if (rawPaymentResolution !== null) { + if (rawPaymentResolution.outcome === 'settled') { + paymentResolution = exactObject(rawPaymentResolution, [ + 'evidenceId', + 'outcome', + 'transactionId', + 'amountAtomic', + 'previousState', + 'nextState', + 'buyerOutcomeRevision', + 'resolvedAt', + ], [], 'BUDGET_CORRUPTION', 'settled payment resolution event'); + const transactionId = canonicalEvmHashFor( + paymentResolution.transactionId, + 'resolved payment transaction', + 'BUDGET_CORRUPTION', + ); + if (paymentResolution.transactionId !== transactionId + || paymentResolution.previousState !== 'unresolved' + || paymentResolution.nextState !== 'committed') { + fail('BUDGET_CORRUPTION', 'settled payment resolution event binding changed'); + } + } else if (rawPaymentResolution.outcome === 'rejected') { + paymentResolution = exactObject(rawPaymentResolution, [ + 'evidenceId', + 'outcome', + 'amountAtomic', + 'previousState', + 'nextState', + 'buyerOutcomeRevision', + 'resolvedAt', + ], [], 'BUDGET_CORRUPTION', 'rejected payment resolution event'); + if (paymentResolution.previousState !== 'unresolved' + || paymentResolution.nextState !== 'released') { + fail('BUDGET_CORRUPTION', 'rejected payment resolution event binding changed'); + } + } else { + fail('BUDGET_CORRUPTION', 'budget payment resolution event outcome is invalid'); + } + canonicalToken(paymentResolution.evidenceId, 'payment resolution evidence ID'); + if (canonicalAtomicText( + paymentResolution.amountAtomic, + 'payment resolution amount', + ).text !== row.ceiling.text + || safeInteger( + paymentResolution.buyerOutcomeRevision, + 'payment resolution BuyerOutcome revision', + ) < 1) { + fail('BUDGET_CORRUPTION', 'payment resolution event authority changed'); + } + canonicalPersistedTimestamp( + paymentResolution.resolvedAt, + 'payment resolution event time', + ); + } + + const refund = rawRefund === null ? null : exactObject(rawRefund, [ + 'evidenceId', + 'originalTransactionId', + 'refundTransactionId', + 'amountAtomic', + 'previousState', + 'nextState', + 'buyerOutcomeRevision', + 'confirmedAt', + ], [], 'BUDGET_CORRUPTION', 'confirmed refund event'); + if (refund) { + const originalTransactionId = canonicalEvmHashFor( + refund.originalTransactionId, + 'refund event original transaction', + 'BUDGET_CORRUPTION', + ); + const refundTransactionId = canonicalEvmHashFor( + refund.refundTransactionId, + 'refund event transaction', + 'BUDGET_CORRUPTION', + ); + if (refund.originalTransactionId !== originalTransactionId + || refund.refundTransactionId !== refundTransactionId + || originalTransactionId === refundTransactionId + || canonicalAtomicText(refund.amountAtomic, 'refund event amount').text + !== row.ceiling.text + || canonicalToken(refund.evidenceId, 'refund event evidence ID') !== refund.evidenceId + || safeInteger(refund.buyerOutcomeRevision, 'refund BuyerOutcome revision') < 1 + || refund.previousState !== 'committed' + || refund.nextState !== 'released') { + fail('BUDGET_CORRUPTION', 'confirmed refund event binding changed'); + } + canonicalPersistedTimestamp(refund.confirmedAt, 'confirmed refund event time'); + } + + const noLaterEvents = (...events) => events.every((event) => event === null); + const directCommit = commit !== null && hold === null && paymentResolution === null; + const reconciledCommit = commit === null + && hold !== null + && paymentResolution?.outcome === 'settled'; + const assertCommittedOrigin = () => { + if (!directCommit && !reconciledCommit) { + fail('BUDGET_CORRUPTION', 'committed value has no unique authoritative origin'); + } + const committedAt = directCommit ? commit.committedAt : paymentResolution.resolvedAt; + if (row.committedAt !== committedAt) { + fail('BUDGET_CORRUPTION', 'budget committedAt differs from its transition event'); + } + const transactionId = directCommit + ? commit.transactionId + : paymentResolution.transactionId; + let attempt; + try { + attempt = validatePaymentAttempt(db, authority, new Set(['settled'])).row; + } catch (error) { + if (error instanceof KernelError) { + fail('BUDGET_CORRUPTION', 'committed PaymentAttempt history is invalid'); + } + throw error; + } + if (attempt.transaction_id !== transactionId + || attempt.settled_at !== committedAt + || attempt.settlement_json === null) { + fail('BUDGET_CORRUPTION', 'committed budget differs from its exact PaymentAttempt'); + } + parseCanonicalJson( + attempt.settlement_json, + 'committed payment settlement', + 'BUDGET_CORRUPTION', + ); + return committedAt; + }; + + if (row.state === 'reserved') { + if (!noLaterEvents(commit, release, hold, paymentResolution, refund) + || row.updatedAt !== reserveAt) { + fail('BUDGET_CORRUPTION', 'reserved budget history contains a later transition'); + } + const rawAttempt = db.prepare('SELECT intent_id FROM payment_attempts WHERE intent_id = ?') + .get(row.intentId); + if (rawAttempt) { + let attempt; + try { + attempt = validatePaymentAttempt( + db, + authority, + new Set(['reserved', 'signing', 'signed', 'retrying']), + ).row; + } catch (error) { + if (error instanceof KernelError) { + fail('BUDGET_CORRUPTION', 'reserved PaymentAttempt history is invalid'); + } + throw error; + } + const transitionAt = { + reserved: attempt.created_at, + signing: attempt.signing_claimed_at, + signed: attempt.signed_at, + retrying: attempt.retry_started_at, + }[attempt.state]; + if (Date.parse(attempt.created_at) < Date.parse(reserveAt) + || attempt.updated_at !== transitionAt + || attempt.reason_code !== null) { + fail('BUDGET_CORRUPTION', 'reserved PaymentAttempt chronology is invalid'); + } + } + } else if (row.state === 'unresolved') { + if (hold === null + || !noLaterEvents(commit, release, paymentResolution, refund) + || row.updatedAt !== hold.heldAt) { + fail('BUDGET_CORRUPTION', 'unresolved budget history is not exact'); + } + const rawAttempt = db.prepare('SELECT intent_id FROM payment_attempts WHERE intent_id = ?') + .get(row.intentId); + if (rawAttempt) { + let attempt; + try { + attempt = validatePaymentAttempt( + db, + authority, + new Set(['signing', 'signed', 'retrying', 'unresolved']), + ).row; + } catch (error) { + if (error instanceof KernelError) { + fail('BUDGET_CORRUPTION', 'unresolved PaymentAttempt history is invalid'); + } + throw error; + } + if (attempt.state === 'unresolved' && attempt.updated_at !== hold.heldAt) { + fail('BUDGET_CORRUPTION', 'unresolved PaymentAttempt detached from its hold event'); + } + } + } else if (row.state === 'committed') { + assertCommittedOrigin(); + if (!noLaterEvents(release, refund) + || row.updatedAt !== row.committedAt) { + fail('BUDGET_CORRUPTION', 'committed budget history is not exact'); + } + } else if (row.state === 'released' && release !== null) { + if (!noLaterEvents(commit, hold, paymentResolution, refund) + || row.committedAt !== null + || row.updatedAt !== release.releasedAt) { + fail('BUDGET_CORRUPTION', 'ordinary release history is not exact'); + } + } else if (row.state === 'released' && paymentResolution?.outcome === 'rejected') { + if (hold === null + || !noLaterEvents(commit, release, refund) + || row.committedAt !== null + || row.updatedAt !== paymentResolution.resolvedAt) { + fail('BUDGET_CORRUPTION', 'rejected payment release history is not exact'); + } + let attempt; + try { + attempt = validatePaymentAttempt(db, authority, new Set(['rejected'])).row; + } catch (error) { + if (error instanceof KernelError) { + fail('BUDGET_CORRUPTION', 'trusted-rejected PaymentAttempt history is invalid'); + } + throw error; + } + if (attempt.updated_at !== paymentResolution.resolvedAt) { + fail('BUDGET_CORRUPTION', 'trusted-rejected attempt detached from its resolution event'); + } + } else if (row.state === 'released' && refund !== null) { + assertCommittedOrigin(); + if (release !== null || row.updatedAt !== refund.confirmedAt) { + fail('BUDGET_CORRUPTION', 'refunded budget history is not exact'); + } + } else { + fail('BUDGET_CORRUPTION', 'BudgetReservation has no legal event history'); + } + + validateResolutionAndRefundHistory(db, row, refund); + return row; +} + +function validateResolutionAndRefundHistory(db, budget, refundEvent) { + const execution = db.prepare('SELECT * FROM execution_outcomes WHERE intent_id = ?') + .get(budget.intentId); + const resolution = db.prepare('SELECT * FROM execution_resolutions WHERE intent_id = ?') + .get(budget.intentId); + const refunds = db.prepare('SELECT * FROM refunds WHERE intent_id = ? ORDER BY rowid') + .all(budget.intentId); + + if (execution) { + if (!new Set(['succeeded', 'failed', 'unknown']).has(execution.state)) { + fail('BUDGET_CORRUPTION', 'execution outcome state is invalid'); + } + if (execution.http_status !== null) { + const status = safeInteger(execution.http_status, 'execution HTTP status'); + if (status < 100 || status > 599) { + fail('BUDGET_CORRUPTION', 'execution HTTP status is invalid'); + } + } + if (execution.response_hash !== null) { + canonicalHash(execution.response_hash, 'execution response hash'); + } + parseCanonicalJson(execution.metadata_json, 'execution metadata'); + canonicalPersistedTimestamp(execution.recorded_at, 'execution recordedAt'); + } + + if (resolution) { + stableReason(resolution.reason_code, 'execution resolution reason'); + const openedAt = canonicalPersistedTimestamp( + resolution.opened_at, + 'execution resolution openedAt', + ); + const blocksWallet = safeInteger(resolution.blocks_wallet, 'execution resolution blocker'); + if (!new Set(['refund_pending', 'reconciliation_required', 'resolved']) + .has(resolution.state) + || blocksWallet > 1) { + fail('BUDGET_CORRUPTION', 'execution resolution shape is invalid'); + } + if (resolution.state === 'resolved') { + const resolvedAt = canonicalPersistedTimestamp( + resolution.resolved_at, + 'execution resolution resolvedAt', + ); + if (blocksWallet !== 0 || Date.parse(resolvedAt) < Date.parse(openedAt)) { + fail('BUDGET_CORRUPTION', 'resolved execution case is inconsistent'); + } + } else if (blocksWallet !== 1 || resolution.resolved_at !== null) { + fail('BUDGET_CORRUPTION', 'open execution case is not wallet-blocking'); + } + } + + const attempt = db.prepare('SELECT transaction_id FROM payment_attempts WHERE intent_id = ?') + .get(budget.intentId); + const activeRefunds = []; + const confirmedRefunds = []; + for (const refund of refunds) { + canonicalToken(refund.id, 'refund ID'); + const originalTransactionId = canonicalEvmHashFor( + refund.original_transaction_id, + 'refund original transaction', + 'BUDGET_CORRUPTION', + ); + if (refund.original_transaction_id !== originalTransactionId + || refund.intent_id !== budget.intentId + || refund.amount_atomic !== budget.ceiling.text + || originalTransactionId !== attempt?.transaction_id + || !new Set(['pending', 'unresolved', 'abandoned', 'confirmed', 'rejected']) + .has(refund.state)) { + fail('BUDGET_CORRUPTION', 'refund row no longer binds the committed payment'); + } + const createdAt = canonicalPersistedTimestamp(refund.created_at, 'refund createdAt'); + const updatedAt = canonicalPersistedTimestamp(refund.updated_at, 'refund updatedAt'); + if (Date.parse(updatedAt) < Date.parse(createdAt)) { + fail('BUDGET_CORRUPTION', 'refund time regressed'); + } + if (refund.refund_transaction_id !== null) { + const refundTransactionId = canonicalEvmHashFor( + refund.refund_transaction_id, + 'refund transaction', + 'BUDGET_CORRUPTION', + ); + if (refund.refund_transaction_id !== refundTransactionId + || refundTransactionId === originalTransactionId) { + fail('BUDGET_CORRUPTION', 'refund transaction binding is invalid'); + } + } + if (refund.evidence_json !== null) { + parseCanonicalJson(refund.evidence_json, 'refund evidence'); + } + if (refund.state === 'pending' || refund.state === 'unresolved') { + activeRefunds.push(refund); + } + if (refund.state === 'confirmed') confirmedRefunds.push(refund); + } + + if ((execution === undefined) !== (resolution === undefined && refunds.length === 0)) { + if (!execution || (resolution && resolution.intent_id !== budget.intentId)) { + fail('BUDGET_CORRUPTION', 'execution/refund authority is incomplete'); + } + } + if (!execution && (resolution || refunds.length > 0)) { + fail('BUDGET_CORRUPTION', 'execution/refund authority has no execution outcome'); + } + if (execution?.state === 'failed' + && (!resolution || !new Set(['refund_pending', 'resolved']).has(resolution.state))) { + fail('BUDGET_CORRUPTION', 'failed execution has no refund resolution'); + } + if (execution?.state === 'unknown' + && resolution?.state !== 'reconciliation_required') { + fail('BUDGET_CORRUPTION', 'unknown execution has no reconciliation blocker'); + } + if (resolution?.state === 'refund_pending') { + const terminalHistoryOnly = refunds.length > 0 + && activeRefunds.length === 0 + && confirmedRefunds.length === 0 + && refunds.every((refund) => ( + (refund.state === 'abandoned' || refund.state === 'rejected') + && refund.refund_transaction_id !== null + )); + if (execution?.state !== 'failed' + || budget.state !== 'committed' + || (activeRefunds.length !== 1 && !terminalHistoryOnly) + || confirmedRefunds.length !== 0) { + fail('BUDGET_CORRUPTION', 'refund-pending execution case is inconsistent'); + } + } else if (resolution?.state === 'reconciliation_required') { + if (execution?.state !== 'unknown' + || budget.state !== 'committed' + || refunds.length !== 0) { + fail('BUDGET_CORRUPTION', 'execution reconciliation case is inconsistent'); + } + } else if (activeRefunds.length !== 0) { + fail('BUDGET_CORRUPTION', 'active refund has no refund-pending execution case'); + } + + if (refundEvent === null) { + if (resolution?.state === 'resolved') { + if (execution?.state === 'succeeded' + && budget.state === 'committed' + && refunds.length === 0 + && confirmedRefunds.length === 0) { + const reconciliations = db.prepare(`SELECT * FROM reconciliations + WHERE intent_id = ? AND kind = 'execution' AND outcome = 'execution_succeeded' + ORDER BY rowid`).all(budget.intentId); + if (reconciliations.length !== 1) { + fail( + 'BUDGET_CORRUPTION', + 'resolved successful execution lacks one trusted reconciliation', + ); + } + const reconciliation = reconciliations[0]; + const evidence = parseCanonicalJson( + reconciliation.evidence_json, + 'successful execution reconciliation evidence', + ); + const proof = exactObject(evidence, [ + 'kind', 'attestationHash', 'attestation', + ], [], 'BUDGET_CORRUPTION', 'successful execution reconciliation evidence'); + if (proof.kind !== 'execution_attested' + || canonicalHash( + proof.attestationHash, + 'successful execution attestation hash', + ) !== sha256(canonicalJson(proof.attestation)) + || reconciliation.recorded_at !== execution.recorded_at + || reconciliation.recorded_at !== resolution.resolved_at) { + fail( + 'BUDGET_CORRUPTION', + 'resolved successful execution reconciliation binding changed', + ); + } + return; + } + fail( + 'BUDGET_CORRUPTION', + 'resolved failed execution has no Task-5 confirmed-refund budget transition', + ); + } + if (confirmedRefunds.length !== 0) { + fail('BUDGET_CORRUPTION', 'confirmed refund has no conserved budget transition'); + } + return; + } + if (confirmedRefunds.length !== 1 + || execution?.state !== 'failed' + || resolution?.state !== 'resolved' + || Number(resolution.blocks_wallet) !== 0 + || resolution.resolved_at !== refundEvent.confirmedAt) { + fail('BUDGET_CORRUPTION', 'confirmed refund terminal authority is inconsistent'); + } + const refund = confirmedRefunds[0]; + if (refund.original_transaction_id !== refundEvent.originalTransactionId + || refund.refund_transaction_id !== refundEvent.refundTransactionId + || refund.updated_at !== refundEvent.confirmedAt + || refund.evidence_json === null) { + fail('BUDGET_CORRUPTION', 'confirmed refund differs from its budget event'); + } + const reconciliation = db.prepare('SELECT * FROM reconciliations WHERE id = ?') + .get(refundEvent.evidenceId); + if (!reconciliation + || reconciliation.intent_id !== budget.intentId + || reconciliation.kind !== 'refund' + || reconciliation.outcome !== 'refund_confirmed' + || reconciliation.evidence_json !== refund.evidence_json) { + fail('BUDGET_CORRUPTION', 'confirmed refund evidence authority changed'); + } +} + +function loadReconciliation(db, intentId, evidenceId, kind, outcome) { + const id = canonicalToken(evidenceId, 'reconciliation evidence ID'); + const row = db.prepare('SELECT * FROM reconciliations WHERE id = ?').get(id); + if (!row + || row.intent_id !== intentId + || row.kind !== kind + || row.outcome !== outcome) { + fail('RECONCILIATION_EVIDENCE_MISMATCH', 'reconciliation evidence does not match payment'); + } + const evidence = parseCanonicalJson( + row.evidence_json, + 'reconciliation evidence', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) { + fail('RECONCILIATION_EVIDENCE_MISMATCH', 'reconciliation evidence must be one object'); + } + canonicalHash( + row.operator_id_hash, + 'reconciliation operator hash', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + canonicalPersistedTimestamp( + row.recorded_at, + 'reconciliation recordedAt', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + return Object.freeze({ id, row, evidence }); +} + +function validateSettledTransferEvidence(evidence, authority, attempt, candidateTransactionId) { + const proof = exactObject(evidence, [ + 'kind', + 'transactionId', + 'rpcProofHash', + 'localAttemptHash', + ], [], 'RECONCILIATION_EVIDENCE_MISMATCH', 'settled-transfer proof'); + const transactionId = canonicalEvmHashFor( + proof.transactionId, + 'settled-transfer transaction ID', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + const rpcProofHash = canonicalHash( + proof.rpcProofHash, + 'settled-transfer RPC proof hash', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + const localHash = canonicalHash( + proof.localAttemptHash, + 'settled-transfer local attempt hash', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + if (proof.kind !== 'settled_transfer' + || proof.transactionId !== transactionId + || transactionId !== candidateTransactionId + || proof.rpcProofHash !== rpcProofHash + || proof.localAttemptHash !== localHash + || localHash !== localAttemptBindingHash(authority, attempt)) { + fail( + 'RECONCILIATION_EVIDENCE_MISMATCH', + 'settled-transfer proof does not bind the exact persisted attempt and candidate', + ); + } + return frozenCopy(proof); +} + +function validateConfirmedRefundEvidence( + evidence, + authority, + attempt, + refundTransactionId, +) { + const proof = exactObject(evidence, [ + 'kind', + 'originalTransactionId', + 'refundTransactionId', + 'attestationHash', + 'attestation', + 'rpcProofHash', + 'localRefundBindingHash', + ], [], 'REFUND_EVIDENCE_MISMATCH', 'confirmed-refund proof'); + const originalTransactionId = canonicalEvmHashFor( + proof.originalTransactionId, + 'refund proof original transaction ID', + 'REFUND_EVIDENCE_MISMATCH', + ); + const proofRefundTransactionId = canonicalEvmHashFor( + proof.refundTransactionId, + 'refund proof transaction ID', + 'REFUND_EVIDENCE_MISMATCH', + ); + const attestationHash = canonicalHash( + proof.attestationHash, + 'refund attestation hash', + 'REFUND_EVIDENCE_MISMATCH', + ); + const attestation = exactObject(proof.attestation, [ + 'schemaVersion', + 'domain', + 'network', + 'sellerOrigin', + 'intentHash', + 'originalTransactionId', + 'refundTransactionId', + 'asset', + 'originalPayer', + 'originalPayee', + 'refundSource', + 'amountAtomic', + 'issuedAt', + 'expiresAt', + 'signer', + ], [], 'REFUND_EVIDENCE_MISMATCH', 'refund attestation'); + const issuedAt = canonicalPersistedTimestamp( + attestation.issuedAt, + 'refund attestation issuedAt', + 'REFUND_EVIDENCE_MISMATCH', + ); + const expiresAt = canonicalPersistedTimestamp( + attestation.expiresAt, + 'refund attestation expiresAt', + 'REFUND_EVIDENCE_MISMATCH', + ); + const rpcProofHash = canonicalHash( + proof.rpcProofHash, + 'refund RPC proof hash', + 'REFUND_EVIDENCE_MISMATCH', + ); + const localHash = canonicalHash( + proof.localRefundBindingHash, + 'local refund binding hash', + 'REFUND_EVIDENCE_MISMATCH', + ); + if (proof.kind !== 'refund_attested_and_confirmed' + || proof.originalTransactionId !== originalTransactionId + || proof.refundTransactionId !== proofRefundTransactionId + || originalTransactionId !== attempt.transaction_id + || proofRefundTransactionId !== refundTransactionId + || proof.attestationHash !== attestationHash + || sha256(canonicalJson(attestation)) !== attestationHash + || attestation.schemaVersion !== 1 + || attestation.domain !== 'wallet-kernel.refund.v1' + || attestation.network !== authority.policyVersion.policy.network + || attestation.sellerOrigin !== authority.intent.seller_origin + || attestation.intentHash !== authority.intent.intent_hash + || attestation.originalTransactionId !== originalTransactionId + || attestation.refundTransactionId !== proofRefundTransactionId + || attestation.asset !== authority.policyVersion.policy.asset + || attestation.originalPayer !== authority.session.wallet_address + || attestation.originalPayee !== authority.projection.selected.payTo + || attestation.refundSource !== authority.projection.seller.refundSource + || attestation.amountAtomic !== authority.amount.text + || attestation.signer !== authority.projection.seller.refundSigner + || Date.parse(expiresAt) <= Date.parse(issuedAt) + || Date.parse(expiresAt) - Date.parse(issuedAt) > 15 * 60 * 1_000 + || proof.rpcProofHash !== rpcProofHash + || proof.localRefundBindingHash !== localHash + || localHash !== localRefundBindingHash(authority, attempt, refundTransactionId)) { + fail( + 'REFUND_EVIDENCE_MISMATCH', + 'confirmed-refund proof does not bind the exact payment, refund, and policy authority', + ); + } + return frozenCopy(proof); +} + +function validateUnusedAuthorizationEvidence( + evidence, + authority, + attempt, + reconciliationRecordedAt, + resolvedAt, +) { + const proof = exactObject(evidence, [ + 'kind', + 'network', + 'asset', + 'payer', + 'nonce', + 'validBefore', + 'authorizationState', + 'observedBlockNumber', + 'observedBlockHash', + 'observedBlockTimestamp', + 'confirmations', + ], [], 'RECONCILIATION_EVIDENCE_MISMATCH', 'unused-authorization proof'); + const observedBlockNumber = canonicalAtomicText( + proof.observedBlockNumber, + 'observed block number', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + const observedBlockTimestamp = canonicalAtomicText( + proof.observedBlockTimestamp, + 'observed block timestamp', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + const validBefore = canonicalAtomicText( + proof.validBefore, + 'authorization validBefore', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + const confirmations = safeInteger( + proof.confirmations, + 'reconciliation confirmations', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + const recordedAt = canonicalPersistedTimestamp( + reconciliationRecordedAt, + 'unused-authorization reconciliation recordedAt', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + const resolutionTime = canonicalPersistedTimestamp( + resolvedAt, + 'unused-authorization resolution time', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + const recordedEpochSeconds = BigInt(Math.floor(Date.parse(recordedAt) / 1_000)); + if (proof.kind !== 'authorization_unused_after_expiry' + || proof.network !== authority.policyVersion.policy.network + || canonicalAddress( + proof.asset, + 'unused-authorization asset', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ) !== authority.policyVersion.policy.asset + || canonicalAddress( + proof.payer, + 'unused-authorization payer', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ) !== authority.session.wallet_address + || typeof proof.nonce !== 'string' + || !/^0x[0-9a-f]{64}$/.test(proof.nonce) + || proof.nonce !== attempt.nonce + || validBefore.text !== attempt.valid_before + || proof.authorizationState !== false + || observedBlockNumber.value <= 0n + || canonicalEvmHashFor( + proof.observedBlockHash, + 'observed block hash', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ) + !== proof.observedBlockHash + || observedBlockTimestamp.value < validBefore.value + || observedBlockTimestamp.value > recordedEpochSeconds + || Date.parse(recordedAt) > Date.parse(resolutionTime) + || confirmations < 1) { + fail( + 'RECONCILIATION_EVIDENCE_MISMATCH', + 'only exact post-expiry unused-authorization proof can release a signed hold', + ); + } + return frozenCopy(proof); +} + +function writeBuyerOutcome( + db, + intentId, + status, + reasonCode, + recordedAt, + expectedPredecessorStatus, +) { + const current = db.prepare('SELECT * FROM buyer_outcomes WHERE intent_id = ?').get(intentId); + if (!current) { + fail('BUYER_OUTCOME_CORRUPTION', 'BuyerOutcome predecessor is missing'); + } + if (current.status !== expectedPredecessorStatus + || typeof current.reason_code !== 'string' + || !/^[A-Z][A-Z0-9_]{0,99}$/.test(current.reason_code)) { + fail('BUYER_OUTCOME_CORRUPTION', 'BuyerOutcome is not the exact legal predecessor'); + } + canonicalPersistedTimestamp( + current.recorded_at, + 'BuyerOutcome recordedAt', + 'BUYER_OUTCOME_CORRUPTION', + ); + if (Date.parse(current.recorded_at) > Date.parse(recordedAt)) { + fail('BUYER_OUTCOME_CORRUPTION', 'BuyerOutcome time regressed'); + } + const revision = safeInteger( + current.revision, + 'buyer outcome revision', + 'BUYER_OUTCOME_CORRUPTION', + ) + 1; + if (!Number.isSafeInteger(revision)) fail('BUDGET_CORRUPTION', 'buyer outcome revision overflowed'); + const changed = db.prepare(`UPDATE buyer_outcomes + SET status = ?, reason_code = ?, revision = ?, recorded_at = ? + WHERE intent_id = ? AND revision = ?`).run( + status, + reasonCode, + revision, + recordedAt, + intentId, + current.revision, + ); + if (changed.changes !== 1n) fail('BUDGET_CONFLICT', 'buyer outcome update lost its race'); + return revision; +} + +export function createBudgetLedger({ store, now }) { + if (!store || typeof store.transaction !== 'function' || typeof store.within !== 'function') { + throw new TypeError('budget ledger requires a Wallet Kernel store'); + } + if (typeof now !== 'function' || utilTypes.isProxy(now)) { + throw new TypeError('budget ledger requires an ordinary clock'); + } + + const prepareSnapshot = (input) => { + const record = closedInput( + input, + ['sessionId', 'sellerOrigin'], + ['at'], + 'BUDGET_SNAPSHOT_SCHEMA', + 'budget snapshot request', + ); + return Object.freeze({ + sessionId: canonicalToken(record.sessionId, 'session ID'), + sellerOrigin: canonicalOrigin(record.sellerOrigin, 'seller origin'), + at: canonicalTimestamp( + Object.hasOwn(record, 'at') ? record.at : now(), + 'budget snapshot time', + ), + }); + }; + + const snapshotInTransaction = (token, input) => store.within( + token, + ({ db }) => snapshotImpl(db, prepareSnapshot(input)).public, + ); + + const snapshot = (input) => { + const prepared = prepareSnapshot(input); + return store.transaction((token) => store.within( + token, + ({ db }) => snapshotImpl(db, prepared).public, + )); + }; + + const prepareReserve = (input) => { + const record = closedInput( + input, + ['intentId', 'amountAtomic'], + [], + 'BUDGET_RESERVE_SCHEMA', + 'budget reservation', + ); + const amount = canonicalAtomicText(record.amountAtomic, 'reservation amount', 'BUDGET_RESERVE'); + if (amount.value <= 0n) fail('BUDGET_RESERVE', 'reservation amount must be positive'); + return Object.freeze({ + intentId: canonicalToken(record.intentId, 'intent ID'), + amount, + }); + }; + + const reserveImpl = (db, appendEvent, prepared) => { + if (readBudgetRow(db, prepared.intentId)) { + fail('BUDGET_ALREADY_RESERVED', 'Spend Intent already owns a BudgetReservation'); + } + const updatedAt = canonicalTimestamp(now(), 'budget reservedAt'); + const authority = loadReservationAuthority(db, prepared.intentId, { + requireReservable: true, + at: updatedAt, + }); + if (authority.amount.text !== prepared.amount.text) { + fail('BUDGET_AMOUNT_MISMATCH', 'reservation amount differs from PolicyDecision ceiling'); + } + const current = snapshotImpl(db, { + sessionId: authority.session.id, + sellerOrigin: authority.intent.seller_origin, + at: updatedAt, + }); + if (current.blockers.unresolvedBudget) { + fail('WALLET_UNRESOLVED', 'wallet has an unresolved payment hold'); + } + if (current.blockers.resolutionRequired) { + fail('WALLET_RESOLUTION_REQUIRED', 'wallet has unresolved execution or refund state'); + } + const amount = prepared.amount.value; + const { seller } = authority.projection; + const policy = authority.policyVersion.policy; + if (BigInt(current.public.sellerSessionExposureAtomic) + amount + > BigInt(seller.sellerSessionMaxAtomic) + || BigInt(current.public.sessionExposureAtomic) + amount + > BigInt(policy.sessionMaxAtomic) + || BigInt(current.public.rolling24hExposureAtomic) + amount + > BigInt(policy.rolling24hMaxAtomic)) { + fail('LIMIT_EXCEEDED', 'BudgetReservation exceeds an authoritative spend ceiling'); + } + const inserted = db.prepare(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, committed_at, updated_at) + VALUES (?, ?, ?, ?, '0', '0', '0', 'reserved', NULL, ?)`).run( + prepared.intentId, + authority.session.id, + authority.intent.seller_origin, + prepared.amount.text, + updatedAt, + ); + if (inserted.changes !== 1n) fail('BUDGET_CONFLICT', 'reservation insert lost its race'); + appendEvent({ + entityType: 'budget_reservation', + entityId: prepared.intentId, + eventType: 'budget.reserved', + data: { + sessionId: authority.session.id, + sellerOrigin: authority.intent.seller_origin, + amountAtomic: prepared.amount.text, + previousState: null, + nextState: 'reserved', + updatedAt, + }, + }); + const persisted = readBudgetRow(db, prepared.intentId); + if (!persisted) fail('BUDGET_CORRUPTION', 'new BudgetReservation disappeared'); + snapshotImpl(db, { + sessionId: authority.session.id, + sellerOrigin: authority.intent.seller_origin, + at: updatedAt, + }); + return publicReservation(persisted); + }; + + const reserveInTransaction = (token, input) => store.within( + token, + ({ db, appendEvent }) => reserveImpl(db, appendEvent, prepareReserve(input)), + ); + + const reserve = (input) => { + const prepared = prepareReserve(input); + return store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => reserveImpl(db, appendEvent, prepared), + )); + }; + + const prepareCommit = (input) => { + const record = closedInput( + input, + ['intentId', 'settlementEvidence'], + [], + 'BUDGET_COMMIT_SCHEMA', + 'budget commit', + ); + const settlementEvidence = validateSettlementEvidence( + Object.getOwnPropertyDescriptor(input, 'settlementEvidence').value, + ); + return Object.freeze({ + intentId: canonicalToken(record.intentId, 'intent ID'), + settlementEvidence, + }); + }; + + const commitImpl = (db, appendEvent, prepared) => { + const authority = loadReservationAuthority(db, prepared.intentId); + const reservation = readBudgetRow(db, prepared.intentId); + if (!reservation) fail('BUDGET_RESERVATION_MISSING', 'BudgetReservation does not exist'); + const evidence = prepared.settlementEvidence; + const evidenceJson = canonicalJson(evidence); + const transactionId = evidence.transaction; + if (evidence.network !== authority.policyVersion.policy.network + || evidence.payer !== authority.session.wallet_address + || evidence.paymentHash === null + || (Object.hasOwn(evidence, 'amountAtomic') + && evidence.amountAtomic !== authority.amount.text)) { + fail('SETTLEMENT_BINDING_MISMATCH', 'settlement evidence differs from persisted authority'); + } + assertGloballyUniqueTransaction(db, transactionId, { + allowedPaymentIntentId: prepared.intentId, + }); + + if (reservation.state === 'committed') { + const attempt = validatePaymentAttempt(db, authority, new Set(['settled'])).row; + const event = loadBudgetEvent(db, prepared.intentId, 'budget.committed'); + if (attempt.transaction_id !== transactionId + || attempt.payment_hash !== evidence.paymentHash + || attempt.settlement_json !== evidenceJson + || !event + || event.transactionId !== transactionId + || event.paymentHash !== evidence.paymentHash + || event.headerHash !== evidence.headerHash + || event.amountAtomic !== authority.amount.text + || event.previousState !== 'reserved' + || event.nextState !== 'committed') { + fail('BUDGET_IDEMPOTENCY_CONFLICT', 'committed payment replay differs from authority'); + } + return publicReservation(reservation); + } + if (reservation.state !== 'reserved') { + fail('BUDGET_STATE', 'only a reserved BudgetReservation can commit'); + } + if (authority.intent.state !== 'retrying') { + fail('PAYMENT_ATTEMPT_STATE', 'budget commit requires retrying Spend Intent'); + } + const attempt = validatePaymentAttempt(db, authority, new Set(['retrying'])).row; + if (attempt.payment_hash !== evidence.paymentHash) { + fail('SETTLEMENT_BINDING_MISMATCH', 'settlement payment hash differs from paid retry'); + } + const reused = db.prepare(`SELECT intent_id FROM payment_attempts + WHERE transaction_id = ? AND intent_id != ?`).get(transactionId, prepared.intentId); + if (reused) fail('TRANSACTION_REUSED', 'settlement transaction belongs to another intent'); + const committedAt = canonicalTimestamp(now(), 'budget committedAt'); + if (Date.parse(committedAt) < Date.parse(attempt.retry_started_at)) { + fail('BUDGET_TIME', 'budget commit predates paid retry'); + } + const attemptUpdate = db.prepare(`UPDATE payment_attempts + SET state = 'settled', settlement_json = ?, transaction_id = ?, reason_code = NULL, + settled_at = ?, updated_at = ? + WHERE intent_id = ? AND state = 'retrying' + AND settlement_json IS NULL AND transaction_id IS NULL AND settled_at IS NULL`).run( + evidenceJson, + transactionId, + committedAt, + committedAt, + prepared.intentId, + ); + if (attemptUpdate.changes !== 1n) { + fail('PAYMENT_ATTEMPT_STATE', 'PaymentAttempt commit lost its race'); + } + const budgetUpdate = db.prepare(`UPDATE budget_reservations + SET reserved_atomic = '0', committed_atomic = ?, state = 'committed', + committed_at = ?, updated_at = ? + WHERE intent_id = ? AND state = 'reserved' + AND reserved_atomic = ? AND committed_atomic = '0' + AND released_atomic = '0' AND unresolved_atomic = '0'`).run( + authority.amount.text, + committedAt, + committedAt, + prepared.intentId, + authority.amount.text, + ); + if (budgetUpdate.changes !== 1n) fail('BUDGET_CONFLICT', 'budget commit lost its race'); + appendEvent({ + entityType: 'budget_reservation', + entityId: prepared.intentId, + eventType: 'budget.committed', + data: { + amountAtomic: authority.amount.text, + transactionId, + paymentHash: evidence.paymentHash, + headerHash: evidence.headerHash, + previousState: 'reserved', + nextState: 'committed', + committedAt, + }, + }); + const persisted = readBudgetRow(db, prepared.intentId); + if (!persisted || persisted.state !== 'committed') { + fail('BUDGET_CORRUPTION', 'committed BudgetReservation disappeared'); + } + const persistedAttempt = validatePaymentAttempt( + db, + authority, + new Set(['settled']), + ).row; + if (persistedAttempt.transaction_id !== transactionId + || persistedAttempt.payment_hash !== evidence.paymentHash + || persistedAttempt.settlement_json !== evidenceJson) { + fail('PAYMENT_ATTEMPT_CORRUPTION', 'committed transaction did not persist exactly'); + } + snapshotImpl(db, { + sessionId: persisted.sessionId, + sellerOrigin: persisted.sellerOrigin, + at: committedAt, + }); + return publicReservation(persisted); + }; + + const commitInTransaction = (token, input) => store.within( + token, + ({ db, appendEvent }) => commitImpl(db, appendEvent, prepareCommit(input)), + ); + + const commit = (input) => { + const prepared = prepareCommit(input); + return store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => commitImpl(db, appendEvent, prepared), + )); + }; + + const prepareReasonTransition = (input, { preSignField = false } = {}) => { + let intentId; + let reason; + let preSignRejection; + let hasPreSignRejection = false; + if (preSignField) { + if (!input || typeof input !== 'object' || utilTypes.isProxy(input) + || Array.isArray(input) || Object.getPrototypeOf(input) !== Object.prototype) { + fail('BUDGET_TRANSITION_SCHEMA', 'budget transition must be one plain object'); + } + const keys = Reflect.ownKeys(input); + const allowed = new Set(['intentId', 'reasonCode', 'preSignRejection']); + const descriptors = new Map(); + if (!Object.hasOwn(input, 'intentId') || !Object.hasOwn(input, 'reasonCode') + || keys.some((key) => typeof key !== 'string' || !allowed.has(key))) { + fail('BUDGET_TRANSITION_SCHEMA', 'budget transition fields do not match the closed schema'); + } + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail('BUDGET_TRANSITION_SCHEMA', 'budget transition fields do not match the closed schema'); + } + descriptors.set(key, descriptor.value); + } + intentId = descriptors.get('intentId'); + reason = descriptors.get('reasonCode'); + hasPreSignRejection = descriptors.has('preSignRejection'); + preSignRejection = descriptors.get('preSignRejection'); + } else { + const record = closedInput( + input, + ['intentId', 'reasonCode'], + [], + 'BUDGET_TRANSITION_SCHEMA', + 'budget transition', + ); + intentId = record.intentId; + reason = record.reasonCode; + } + if (typeof reason !== 'string' + || !/^[A-Z][A-Z0-9_]{0,99}$/.test(reason)) { + fail('BUDGET_REASON', 'budget reason code must be one stable uppercase token'); + } + return Object.freeze({ + intentId: canonicalToken(intentId, 'intent ID'), + reasonCode: reason, + hasPreSignRejection, + preSignRejection, + }); + }; + + const releaseImpl = (db, appendEvent, prepared) => { + const typedPreSignRelease = prepared.hasPreSignRejection + && isExactPreSignRejection(prepared.preSignRejection) + && prepared.reasonCode === 'WALLET_PRE_SIGN_REJECTED'; + if (prepared.hasPreSignRejection && !typedPreSignRelease) { + fail( + 'BUDGET_RELEASE_UNSAFE', + 'only the exact typed pre-sign rejection can release after a signing claim', + ); + } + if (prepared.reasonCode === 'WALLET_PRE_SIGN_REJECTED' && !typedPreSignRelease) { + fail('BUDGET_RELEASE_UNSAFE', 'typed pre-sign release proof is required'); + } + const authority = loadReservationAuthority(db, prepared.intentId); + const reservation = readBudgetRow(db, prepared.intentId); + if (!reservation) fail('BUDGET_RESERVATION_MISSING', 'BudgetReservation does not exist'); + if (reservation.state === 'released') { + const event = loadBudgetEvent(db, prepared.intentId, 'budget.released'); + const rawAttempt = db.prepare('SELECT * FROM payment_attempts WHERE intent_id = ?') + .get(prepared.intentId); + if (rawAttempt) { + const attempt = validatePaymentAttempt(db, authority, new Set(['rejected'])).row; + const claimOnly = attempt.signing_claimed_at !== null + && attempt.payment_payload_json === null; + if (attempt.payment_payload_json !== null + || attempt.reason_code !== prepared.reasonCode + || claimOnly !== typedPreSignRelease) { + fail('BUDGET_IDEMPOTENCY_CONFLICT', 'released payment attempt replay changed'); + } + } + if (!event + || event.reasonCode !== prepared.reasonCode + || event.amountAtomic !== authority.amount.text + || event.previousState !== 'reserved' + || event.nextState !== 'released') { + fail('BUDGET_IDEMPOTENCY_CONFLICT', 'budget release replay differs from authority'); + } + return publicReservation(reservation); + } + if (reservation.state !== 'reserved') { + fail('BUDGET_RELEASE_UNSAFE', 'only a definitely unsigned reservation can release'); + } + const rawAttempt = db.prepare('SELECT * FROM payment_attempts WHERE intent_id = ?') + .get(prepared.intentId); + const releasedAt = canonicalTimestamp(now(), 'budget releasedAt'); + if (typedPreSignRelease) { + if (authority.intent.state !== 'signing' || !rawAttempt) { + fail('BUDGET_RELEASE_UNSAFE', 'typed release requires the live signing boundary'); + } + const attempt = validatePaymentAttempt(db, authority, new Set(['signing'])).row; + assertTransitionChronology(releasedAt, attemptChronology(attempt)); + const attemptUpdate = db.prepare(`UPDATE payment_attempts + SET state = 'rejected', reason_code = ?, updated_at = ? + WHERE intent_id = ? AND state = 'signing' + AND nonce IS NOT NULL AND valid_after IS NOT NULL AND valid_before IS NOT NULL + AND signing_claimed_at IS NOT NULL AND payment_payload_json IS NULL + AND payment_header IS NULL AND payment_hash IS NULL AND signed_at IS NULL + AND retry_started_at IS NULL AND settlement_json IS NULL + AND transaction_id IS NULL AND settled_at IS NULL`).run( + prepared.reasonCode, + releasedAt, + prepared.intentId, + ); + if (attemptUpdate.changes !== 1n) { + fail('BUDGET_RELEASE_UNSAFE', 'PaymentAttempt is not one claim-only signing attempt'); + } + } else { + if (authority.intent.state !== 'authorized' && authority.intent.state !== 'reserved') { + fail('BUDGET_RELEASE_UNSAFE', 'Spend Intent may have entered the signing boundary'); + } + if (rawAttempt) validatePaymentAttempt(db, authority, new Set(['reserved'])); + if (rawAttempt) { + const attemptUpdate = db.prepare(`UPDATE payment_attempts + SET state = 'rejected', reason_code = ?, updated_at = ? + WHERE intent_id = ? AND state = 'reserved' + AND nonce IS NULL AND valid_after IS NULL AND valid_before IS NULL + AND signing_claimed_at IS NULL AND payment_payload_json IS NULL + AND payment_header IS NULL AND payment_hash IS NULL AND signed_at IS NULL`).run( + prepared.reasonCode, + releasedAt, + prepared.intentId, + ); + if (attemptUpdate.changes !== 1n) { + fail('BUDGET_RELEASE_UNSAFE', 'PaymentAttempt is not definitely unsigned'); + } + } + } + const changed = db.prepare(`UPDATE budget_reservations + SET reserved_atomic = '0', released_atomic = ?, state = 'released', updated_at = ? + WHERE intent_id = ? AND state = 'reserved' + AND reserved_atomic = ? AND committed_atomic = '0' + AND released_atomic = '0' AND unresolved_atomic = '0'`).run( + authority.amount.text, + releasedAt, + prepared.intentId, + authority.amount.text, + ); + if (changed.changes !== 1n) fail('BUDGET_CONFLICT', 'budget release lost its race'); + appendEvent({ + entityType: 'budget_reservation', + entityId: prepared.intentId, + eventType: 'budget.released', + data: { + amountAtomic: authority.amount.text, + reasonCode: prepared.reasonCode, + previousState: 'reserved', + nextState: 'released', + releasedAt, + }, + }); + const persisted = readBudgetRow(db, prepared.intentId); + if (!persisted || persisted.state !== 'released') { + fail('BUDGET_CORRUPTION', 'released BudgetReservation disappeared'); + } + snapshotImpl(db, { + sessionId: persisted.sessionId, + sellerOrigin: persisted.sellerOrigin, + at: releasedAt, + }); + return publicReservation(persisted); + }; + + const releaseInTransaction = (token, input) => store.within( + token, + ({ db, appendEvent }) => releaseImpl( + db, + appendEvent, + prepareReasonTransition(input, { preSignField: true }), + ), + ); + + const release = (input) => { + const prepared = prepareReasonTransition(input); + return store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => releaseImpl(db, appendEvent, prepared), + )); + }; + + const holdUnresolvedImpl = (db, appendEvent, prepared) => { + const authority = loadReservationAuthority(db, prepared.intentId); + const reservation = readBudgetRow(db, prepared.intentId); + if (!reservation) fail('BUDGET_RESERVATION_MISSING', 'BudgetReservation does not exist'); + if (reservation.state === 'unresolved') { + const event = loadBudgetEvent(db, prepared.intentId, 'budget.held_unresolved'); + if (!event + || event.reasonCode !== prepared.reasonCode + || event.amountAtomic !== authority.amount.text + || event.previousState !== 'reserved' + || event.nextState !== 'unresolved') { + fail('BUDGET_IDEMPOTENCY_CONFLICT', 'unresolved hold replay differs from authority'); + } + return publicReservation(reservation); + } + if (reservation.state !== 'reserved') { + fail('BUDGET_STATE', 'only a reserved BudgetReservation can become unresolved'); + } + const heldAt = canonicalTimestamp(now(), 'budget unresolvedAt'); + const changed = db.prepare(`UPDATE budget_reservations + SET reserved_atomic = '0', unresolved_atomic = ?, state = 'unresolved', updated_at = ? + WHERE intent_id = ? AND state = 'reserved' + AND reserved_atomic = ? AND committed_atomic = '0' + AND released_atomic = '0' AND unresolved_atomic = '0'`).run( + authority.amount.text, + heldAt, + prepared.intentId, + authority.amount.text, + ); + if (changed.changes !== 1n) fail('BUDGET_CONFLICT', 'unresolved hold lost its race'); + appendEvent({ + entityType: 'budget_reservation', + entityId: prepared.intentId, + eventType: 'budget.held_unresolved', + data: { + amountAtomic: authority.amount.text, + reasonCode: prepared.reasonCode, + previousState: 'reserved', + nextState: 'unresolved', + heldAt, + }, + }); + const persisted = readBudgetRow(db, prepared.intentId); + if (!persisted || persisted.state !== 'unresolved') { + fail('BUDGET_CORRUPTION', 'unresolved BudgetReservation disappeared'); + } + const after = snapshotImpl(db, { + sessionId: persisted.sessionId, + sellerOrigin: persisted.sellerOrigin, + at: heldAt, + }); + if (!after.blockers.unresolvedBudget) { + fail('BUDGET_CORRUPTION', 'unresolved hold did not block its wallet'); + } + return publicReservation(persisted); + }; + + const holdUnresolvedInTransaction = (token, input) => store.within( + token, + ({ db, appendEvent }) => holdUnresolvedImpl( + db, + appendEvent, + prepareReasonTransition(input), + ), + ); + + const holdUnresolved = (input) => { + const prepared = prepareReasonTransition(input); + return store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => holdUnresolvedImpl(db, appendEvent, prepared), + )); + }; + + const preparePaymentResolution = (input) => { + const record = closedInput( + input, + ['intentId', 'outcome', 'evidenceId'], + [], + 'PAYMENT_RESOLUTION_SCHEMA', + 'payment resolution', + ); + if (record.outcome !== 'settled' && record.outcome !== 'rejected') { + fail('PAYMENT_RESOLUTION_OUTCOME', 'payment resolution must be settled or rejected'); + } + return Object.freeze({ + intentId: canonicalToken(record.intentId, 'intent ID'), + outcome: record.outcome, + evidenceId: canonicalToken(record.evidenceId, 'reconciliation evidence ID'), + }); + }; + + const loadPaymentCandidates = (db, intentId) => db.prepare(`SELECT * + FROM payment_reconciliation_candidates + WHERE intent_id = ? ORDER BY rowid`).all(intentId); + + const validateCandidate = (candidate, intentId) => { + canonicalToken(candidate.id, 'payment reconciliation candidate ID'); + const transactionId = canonicalEvmHash( + candidate.transaction_id, + 'payment reconciliation transaction', + ); + if (candidate.transaction_id !== transactionId + || candidate.intent_id !== intentId + || !new Set(['pending', 'abandoned', 'rejected', 'confirmed']).has(candidate.state)) { + fail('RECONCILIATION_EVIDENCE_MISMATCH', 'payment candidate binding is invalid'); + } + canonicalPersistedTimestamp( + candidate.created_at, + 'payment candidate createdAt', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + canonicalPersistedTimestamp( + candidate.updated_at, + 'payment candidate updatedAt', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + if (candidate.evidence_json !== null) { + parseCanonicalJson( + candidate.evidence_json, + 'payment candidate evidence', + 'RECONCILIATION_EVIDENCE_MISMATCH', + ); + } + return Object.freeze({ row: candidate, transactionId }); + }; + + const assertResolvedBuyerOutcome = (db, intentId, status, reasonCode) => { + const outcome = db.prepare('SELECT * FROM buyer_outcomes WHERE intent_id = ?').get(intentId); + if (!outcome || outcome.status !== status || outcome.reason_code !== reasonCode) { + fail('BUDGET_IDEMPOTENCY_CONFLICT', 'payment resolution BuyerOutcome changed'); + } + safeInteger(outcome.revision, 'buyer outcome revision'); + canonicalPersistedTimestamp(outcome.recorded_at, 'buyer outcome recordedAt'); + return outcome; + }; + + const resolveSettledPayment = ( + db, + appendEvent, + prepared, + authority, + reservation, + reconciliation, + ) => { + const evidenceJson = reconciliation.row.evidence_json; + const candidates = loadPaymentCandidates(db, prepared.intentId).map( + (candidate) => validateCandidate(candidate, prepared.intentId), + ); + const liveCandidates = candidates.filter( + (candidate) => candidate.row.state === 'pending' || candidate.row.state === 'confirmed', + ); + if (liveCandidates.length !== 1) { + fail( + 'RECONCILIATION_EVIDENCE_MISMATCH', + 'settled payment resolution requires one persisted transaction candidate', + ); + } + const candidate = liveCandidates[0]; + + if (reservation.state === 'committed') { + const attempt = validatePaymentAttempt(db, authority, new Set(['settled'])).row; + validateSettledTransferEvidence( + reconciliation.evidence, + authority, + attempt, + candidate.transactionId, + ); + assertGloballyUniqueTransaction(db, candidate.transactionId, { + allowedPaymentIntentId: prepared.intentId, + allowedPaymentCandidateId: candidate.row.id, + }); + const execution = db.prepare('SELECT * FROM execution_outcomes WHERE intent_id = ?') + .get(prepared.intentId); + const resolution = db.prepare('SELECT * FROM execution_resolutions WHERE intent_id = ?') + .get(prepared.intentId); + const event = loadBudgetEvent(db, prepared.intentId, 'budget.payment_resolved'); + const buyerOutcome = assertResolvedBuyerOutcome( + db, + prepared.intentId, + 'execution_unknown', + 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + ); + const replayNow = canonicalTimestamp(now(), 'payment reconciliation replay time'); + if (!event || Date.parse(event.resolvedAt) > Date.parse(replayNow)) { + fail('BUDGET_TIME', 'payment reconciliation replay clock regressed'); + } + assertTransitionChronology(event.resolvedAt, [ + ['BudgetReservation updatedAt', reservation.updatedAt], + ['BudgetReservation committedAt', reservation.committedAt], + ...attemptChronology(attempt), + ['payment candidate createdAt', candidate.row.created_at], + ['payment candidate updatedAt', candidate.row.updated_at], + ['payment reconciliation recordedAt', reconciliation.row.recorded_at], + ['execution outcome recordedAt', execution?.recorded_at], + ['execution resolution openedAt', resolution?.opened_at], + ['BuyerOutcome recordedAt', buyerOutcome.recorded_at], + ]); + if (attempt.transaction_id !== candidate.transactionId + || attempt.settlement_json !== evidenceJson + || candidate.row.state !== 'confirmed' + || candidate.row.evidence_json !== evidenceJson + || execution?.state !== 'unknown' + || resolution?.state !== 'reconciliation_required' + || BigInt(resolution?.blocks_wallet ?? 0) !== 1n + || !event + || event.evidenceId !== prepared.evidenceId + || event.outcome !== 'settled' + || event.transactionId !== candidate.transactionId + || event.previousState !== 'unresolved' + || event.nextState !== 'committed' + || event.buyerOutcomeRevision !== Number(buyerOutcome.revision)) { + fail('BUDGET_IDEMPOTENCY_CONFLICT', 'settled payment resolution replay changed'); + } + return publicReservation(reservation); + } + if (reservation.state !== 'unresolved') { + fail('BUDGET_STATE', 'settled reconciliation requires one unresolved hold'); + } + const attempt = validatePaymentAttempt(db, authority, new Set(['unresolved'])).row; + if (candidate.row.state !== 'pending') { + fail( + 'RECONCILIATION_EVIDENCE_MISMATCH', + 'unresolved payment requires one still-pending transaction candidate', + ); + } + if (attempt.payment_payload_json === null) { + fail('RECONCILIATION_EVIDENCE_MISMATCH', 'settlement cannot bind an unpersisted signature'); + } + if (candidate.row.evidence_json !== null) { + fail( + 'RECONCILIATION_EVIDENCE_MISMATCH', + 'pending payment candidate already contains evidence', + ); + } + validateSettledTransferEvidence( + reconciliation.evidence, + authority, + attempt, + candidate.transactionId, + ); + assertGloballyUniqueTransaction(db, candidate.transactionId, { + allowedPaymentIntentId: prepared.intentId, + allowedPaymentCandidateId: candidate.row.id, + }); + const resolvedAt = canonicalTimestamp(now(), 'payment reconciliation time'); + const predecessorOutcome = db.prepare( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ?', + ).get(prepared.intentId); + assertTransitionChronology(resolvedAt, [ + ['BudgetReservation unresolvedAt', reservation.updatedAt], + ...attemptChronology(attempt), + ['payment candidate createdAt', candidate.row.created_at], + ['payment candidate updatedAt', candidate.row.updated_at], + ['payment reconciliation recordedAt', reconciliation.row.recorded_at], + ['BuyerOutcome predecessor recordedAt', predecessorOutcome?.recorded_at], + ]); + if (candidate.row.state === 'pending') { + const candidateUpdate = db.prepare(`UPDATE payment_reconciliation_candidates + SET state = 'confirmed', evidence_json = ?, updated_at = ? + WHERE id = ? AND intent_id = ? AND state = 'pending' + AND transaction_id = ?`).run( + evidenceJson, + resolvedAt, + candidate.row.id, + prepared.intentId, + candidate.transactionId, + ); + if (candidateUpdate.changes !== 1n) { + fail('RECONCILIATION_CONFLICT', 'payment candidate confirmation lost its race'); + } + } else if (candidate.row.evidence_json !== evidenceJson) { + fail('RECONCILIATION_EVIDENCE_MISMATCH', 'confirmed candidate evidence changed'); + } + const attemptUpdate = db.prepare(`UPDATE payment_attempts + SET state = 'settled', settlement_json = ?, transaction_id = ?, + reason_code = 'TRUSTED_RECONCILIATION', settled_at = ?, updated_at = ? + WHERE intent_id = ? AND state = 'unresolved' + AND settlement_json IS NULL AND transaction_id IS NULL`).run( + evidenceJson, + candidate.transactionId, + resolvedAt, + resolvedAt, + prepared.intentId, + ); + if (attemptUpdate.changes !== 1n) { + fail('PAYMENT_ATTEMPT_STATE', 'payment reconciliation lost its attempt race'); + } + const budgetUpdate = db.prepare(`UPDATE budget_reservations + SET unresolved_atomic = '0', committed_atomic = ?, state = 'committed', + committed_at = ?, updated_at = ? + WHERE intent_id = ? AND state = 'unresolved' + AND unresolved_atomic = ? AND reserved_atomic = '0' + AND committed_atomic = '0' AND released_atomic = '0'`).run( + authority.amount.text, + resolvedAt, + resolvedAt, + prepared.intentId, + authority.amount.text, + ); + if (budgetUpdate.changes !== 1n) { + fail('BUDGET_CONFLICT', 'payment reconciliation lost its budget race'); + } + if (db.prepare('SELECT intent_id FROM execution_outcomes WHERE intent_id = ?') + .get(prepared.intentId) + || db.prepare('SELECT intent_id FROM execution_resolutions WHERE intent_id = ?') + .get(prepared.intentId)) { + fail('BUDGET_CORRUPTION', 'unresolved payment already owns execution state'); + } + const executionMetadata = canonicalJson({ + reasonCode: 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + reconciliationEvidenceId: prepared.evidenceId, + }); + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'unknown', NULL, NULL, ?, ?)`).run( + prepared.intentId, + executionMetadata, + resolvedAt, + ); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at, resolved_at) + VALUES (?, 'reconciliation_required', ?, 1, ?, NULL)`).run( + prepared.intentId, + 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + resolvedAt, + ); + const buyerOutcomeRevision = writeBuyerOutcome( + db, + prepared.intentId, + 'execution_unknown', + 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + resolvedAt, + 'payment_unresolved', + ); + appendEvent({ + entityType: 'budget_reservation', + entityId: prepared.intentId, + eventType: 'budget.payment_resolved', + data: { + evidenceId: prepared.evidenceId, + outcome: 'settled', + transactionId: candidate.transactionId, + amountAtomic: authority.amount.text, + previousState: 'unresolved', + nextState: 'committed', + buyerOutcomeRevision, + resolvedAt, + }, + }); + const persisted = readBudgetRow(db, prepared.intentId); + if (!persisted || persisted.state !== 'committed') { + fail('BUDGET_CORRUPTION', 'reconciled committed budget disappeared'); + } + const after = snapshotImpl(db, { + sessionId: persisted.sessionId, + sellerOrigin: persisted.sellerOrigin, + at: resolvedAt, + }); + if (!after.blockers.resolutionRequired) { + fail('BUDGET_CORRUPTION', 'unknown reconciled execution did not block the wallet'); + } + return publicReservation(persisted); + }; + + const resolveRejectedPayment = ( + db, + appendEvent, + prepared, + authority, + reservation, + reconciliation, + ) => { + const candidates = loadPaymentCandidates(db, prepared.intentId).map( + (candidate) => validateCandidate(candidate, prepared.intentId), + ); + if (candidates.some((candidate) => candidate.row.state === 'confirmed')) { + fail('RECONCILIATION_EVIDENCE_MISMATCH', 'confirmed payment cannot be rejected'); + } + if (reservation.state === 'released') { + const attempt = validatePaymentAttempt(db, authority, new Set(['rejected'])).row; + if (attempt.payment_payload_json === null) { + fail('BUDGET_IDEMPOTENCY_CONFLICT', 'rejected payment proof lost its signed attempt'); + } + const event = loadBudgetEvent(db, prepared.intentId, 'budget.payment_resolved'); + const replayNow = canonicalTimestamp(now(), 'payment rejection replay time'); + if (!event || Date.parse(event.resolvedAt) > Date.parse(replayNow)) { + fail('BUDGET_TIME', 'payment rejection replay clock regressed'); + } + validateUnusedAuthorizationEvidence( + reconciliation.evidence, + authority, + attempt, + reconciliation.row.recorded_at, + event.resolvedAt, + ); + const buyerOutcome = assertResolvedBuyerOutcome( + db, + prepared.intentId, + 'payment_rejected', + 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + ); + assertTransitionChronology(event.resolvedAt, [ + ['BudgetReservation updatedAt', reservation.updatedAt], + ...attemptChronology(attempt), + ...candidates.flatMap((candidate) => ([ + ['payment candidate createdAt', candidate.row.created_at], + ['payment candidate updatedAt', candidate.row.updated_at], + ])), + ['payment reconciliation recordedAt', reconciliation.row.recorded_at], + ['BuyerOutcome recordedAt', buyerOutcome.recorded_at], + ]); + if (attempt?.state !== 'rejected' + || attempt.reason_code !== 'AUTHORIZATION_UNUSED_AFTER_EXPIRY' + || candidates.some((candidate) => candidate.row.state === 'pending') + || !event + || event.evidenceId !== prepared.evidenceId + || event.outcome !== 'rejected' + || event.previousState !== 'unresolved' + || event.nextState !== 'released' + || event.buyerOutcomeRevision !== Number(buyerOutcome.revision)) { + fail('BUDGET_IDEMPOTENCY_CONFLICT', 'rejected payment resolution replay changed'); + } + return publicReservation(reservation); + } + if (reservation.state !== 'unresolved') { + fail('BUDGET_STATE', 'payment rejection requires one unresolved hold'); + } + const attempt = validatePaymentAttempt(db, authority, new Set(['unresolved'])).row; + if (attempt.payment_payload_json === null) { + fail('RECONCILIATION_EVIDENCE_MISMATCH', 'unused proof needs persisted authorization'); + } + const resolvedAt = canonicalTimestamp(now(), 'payment rejection time'); + const predecessorOutcome = db.prepare( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ?', + ).get(prepared.intentId); + assertTransitionChronology(resolvedAt, [ + ['BudgetReservation unresolvedAt', reservation.updatedAt], + ...attemptChronology(attempt), + ...candidates.flatMap((candidate) => ([ + ['payment candidate createdAt', candidate.row.created_at], + ['payment candidate updatedAt', candidate.row.updated_at], + ])), + ['payment reconciliation recordedAt', reconciliation.row.recorded_at], + ['BuyerOutcome predecessor recordedAt', predecessorOutcome?.recorded_at], + ]); + validateUnusedAuthorizationEvidence( + reconciliation.evidence, + authority, + attempt, + reconciliation.row.recorded_at, + resolvedAt, + ); + if (candidates.some((candidate) => ( + candidate.row.state === 'pending' && candidate.row.evidence_json !== null + ))) { + fail( + 'RECONCILIATION_EVIDENCE_MISMATCH', + 'pending payment candidate already contains evidence', + ); + } + for (const candidate of candidates.filter((item) => item.row.state === 'pending')) { + const changed = db.prepare(`UPDATE payment_reconciliation_candidates + SET state = 'rejected', evidence_json = ?, updated_at = ? + WHERE id = ? AND intent_id = ? AND state = 'pending'`).run( + reconciliation.row.evidence_json, + resolvedAt, + candidate.row.id, + prepared.intentId, + ); + if (changed.changes !== 1n) { + fail('RECONCILIATION_CONFLICT', 'candidate rejection lost its race'); + } + } + const attemptUpdate = db.prepare(`UPDATE payment_attempts + SET state = 'rejected', reason_code = ?, updated_at = ? + WHERE intent_id = ? AND state = 'unresolved' + AND transaction_id IS NULL AND settlement_json IS NULL`).run( + 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + resolvedAt, + prepared.intentId, + ); + if (attemptUpdate.changes !== 1n) { + fail('PAYMENT_ATTEMPT_STATE', 'payment rejection lost its attempt race'); + } + const budgetUpdate = db.prepare(`UPDATE budget_reservations + SET unresolved_atomic = '0', released_atomic = ?, state = 'released', updated_at = ? + WHERE intent_id = ? AND state = 'unresolved' + AND unresolved_atomic = ? AND reserved_atomic = '0' + AND committed_atomic = '0' AND released_atomic = '0'`).run( + authority.amount.text, + resolvedAt, + prepared.intentId, + authority.amount.text, + ); + if (budgetUpdate.changes !== 1n) { + fail('BUDGET_CONFLICT', 'payment rejection lost its budget race'); + } + if (db.prepare('SELECT intent_id FROM execution_outcomes WHERE intent_id = ?') + .get(prepared.intentId)) { + fail('BUDGET_CORRUPTION', 'rejected unresolved payment already has execution state'); + } + const buyerOutcomeRevision = writeBuyerOutcome( + db, + prepared.intentId, + 'payment_rejected', + 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + resolvedAt, + 'payment_unresolved', + ); + appendEvent({ + entityType: 'budget_reservation', + entityId: prepared.intentId, + eventType: 'budget.payment_resolved', + data: { + evidenceId: prepared.evidenceId, + outcome: 'rejected', + amountAtomic: authority.amount.text, + previousState: 'unresolved', + nextState: 'released', + buyerOutcomeRevision, + resolvedAt, + }, + }); + const persisted = readBudgetRow(db, prepared.intentId); + if (!persisted || persisted.state !== 'released') { + fail('BUDGET_CORRUPTION', 'rejected payment budget disappeared'); + } + snapshotImpl(db, { + sessionId: persisted.sessionId, + sellerOrigin: persisted.sellerOrigin, + at: resolvedAt, + }); + return publicReservation(persisted); + }; + + const resolvePaymentImpl = (db, appendEvent, prepared) => { + // This Task-5 ledger owns monetary, attempt, execution-case, and BuyerOutcome rows only. + // Task 11 must compose the InTransaction form with the SpendIntent terminal transition + // and retry_matchable = 0 under this same authority token before committing the aggregate. + const authority = loadReservationAuthority(db, prepared.intentId); + const reservation = readBudgetRow(db, prepared.intentId); + if (!reservation) fail('BUDGET_RESERVATION_MISSING', 'BudgetReservation does not exist'); + const reconciliation = loadReconciliation( + db, + prepared.intentId, + prepared.evidenceId, + 'payment', + prepared.outcome, + ); + return prepared.outcome === 'settled' + ? resolveSettledPayment( + db, + appendEvent, + prepared, + authority, + reservation, + reconciliation, + ) + : resolveRejectedPayment( + db, + appendEvent, + prepared, + authority, + reservation, + reconciliation, + ); + }; + + const resolvePaymentInTransaction = (token, input) => store.within( + token, + ({ db, appendEvent }) => resolvePaymentImpl( + db, + appendEvent, + preparePaymentResolution(input), + ), + ); + + const resolvePayment = (input) => { + const prepared = preparePaymentResolution(input); + return store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => resolvePaymentImpl(db, appendEvent, prepared), + )); + }; + + const prepareRefund = (input) => { + const record = closedInput( + input, + ['intentId', 'evidenceId', 'refundTransactionId'], + [], + 'REFUND_CONFIRMATION_SCHEMA', + 'confirmed refund', + ); + return Object.freeze({ + intentId: canonicalToken(record.intentId, 'intent ID'), + evidenceId: canonicalToken(record.evidenceId, 'reconciliation evidence ID'), + refundTransactionId: canonicalEvmHash( + record.refundTransactionId, + 'refund transaction ID', + ), + }); + }; + + const recordConfirmedRefundImpl = (db, appendEvent, prepared) => { + const authority = loadReservationAuthority(db, prepared.intentId); + const reservation = readBudgetRow(db, prepared.intentId); + if (!reservation) fail('BUDGET_RESERVATION_MISSING', 'BudgetReservation does not exist'); + const reconciliation = loadReconciliation( + db, + prepared.intentId, + prepared.evidenceId, + 'refund', + 'refund_confirmed', + ); + const attempt = validatePaymentAttempt(db, authority, new Set(['settled'])).row; + const refundRows = db.prepare('SELECT * FROM refunds WHERE intent_id = ? ORDER BY rowid') + .all(prepared.intentId); + for (const row of refundRows.filter((candidate) => ( + candidate.refund_transaction_id !== null + ))) { + const transactionId = canonicalEvmHashFor( + row.refund_transaction_id, + 'persisted refund transaction', + 'TRANSACTION_BINDING_CORRUPTION', + ); + if (row.refund_transaction_id !== transactionId) { + fail( + 'TRANSACTION_BINDING_CORRUPTION', + 'persisted refund transaction is not canonical lowercase', + ); + } + } + const matching = refundRows.filter( + (row) => row.refund_transaction_id === prepared.refundTransactionId, + ); + if (matching.length !== 1) { + fail('REFUND_EVIDENCE_MISMATCH', 'confirmed refund has no exact persisted candidate'); + } + const refund = matching[0]; + canonicalToken(refund.id, 'refund ID'); + const refundAmount = canonicalAtomicText( + refund.amount_atomic, + 'refund amount', + 'REFUND_EVIDENCE_MISMATCH', + ); + const originalTransactionId = canonicalEvmHash( + refund.original_transaction_id, + 'persisted original payment transaction', + ); + if (refund.original_transaction_id !== originalTransactionId + || originalTransactionId !== attempt.transaction_id + || refundAmount.text !== authority.amount.text + || prepared.refundTransactionId === attempt.transaction_id) { + fail('REFUND_EVIDENCE_MISMATCH', 'refund does not bind the exact committed payment'); + } + validateConfirmedRefundEvidence( + reconciliation.evidence, + authority, + attempt, + prepared.refundTransactionId, + ); + assertGloballyUniqueTransaction(db, prepared.refundTransactionId, { + allowedRefundId: refund.id, + }); + const resolution = db.prepare('SELECT * FROM execution_resolutions WHERE intent_id = ?') + .get(prepared.intentId); + const execution = db.prepare('SELECT * FROM execution_outcomes WHERE intent_id = ?') + .get(prepared.intentId); + + if (reservation.state === 'released') { + const event = loadBudgetEvent(db, prepared.intentId, 'budget.refund_confirmed'); + const buyerOutcome = assertResolvedBuyerOutcome( + db, + prepared.intentId, + 'refunded', + 'REFUND_CONFIRMED', + ); + const replayNow = canonicalTimestamp(now(), 'refund confirmation replay time'); + if (!event || Date.parse(event.confirmedAt) > Date.parse(replayNow)) { + fail('BUDGET_TIME', 'refund confirmation replay clock regressed'); + } + assertTransitionChronology(event.confirmedAt, [ + ['BudgetReservation updatedAt', reservation.updatedAt], + ['BudgetReservation committedAt', reservation.committedAt], + ...attemptChronology(attempt), + ['refund createdAt', refund.created_at], + ['refund updatedAt', refund.updated_at], + ['refund reconciliation recordedAt', reconciliation.row.recorded_at], + ['execution outcome recordedAt', execution?.recorded_at], + ['execution resolution openedAt', resolution?.opened_at], + ['execution resolution resolvedAt', resolution?.resolved_at], + ['BuyerOutcome recordedAt', buyerOutcome.recorded_at], + ]); + if (refund.state !== 'confirmed' + || refund.evidence_json !== reconciliation.row.evidence_json + || resolution?.state !== 'resolved' + || BigInt(resolution?.blocks_wallet ?? 1) !== 0n + || !event + || event.evidenceId !== prepared.evidenceId + || event.refundTransactionId !== prepared.refundTransactionId + || event.originalTransactionId !== attempt.transaction_id + || event.previousState !== 'committed' + || event.nextState !== 'released' + || event.buyerOutcomeRevision !== Number(buyerOutcome.revision)) { + fail('BUDGET_IDEMPOTENCY_CONFLICT', 'confirmed refund replay changed'); + } + return publicReservation(reservation); + } + if (reservation.state !== 'committed' + || execution?.state !== 'failed' + || resolution?.state !== 'refund_pending' + || BigInt(resolution.blocks_wallet) !== 1n + || resolution.resolved_at !== null + || (refund.state !== 'pending' && refund.state !== 'unresolved') + || refund.evidence_json !== null) { + fail('REFUND_STATE', 'refund confirmation requires one blocking full-refund case'); + } + const confirmedAt = canonicalTimestamp(now(), 'refund confirmedAt'); + const predecessorOutcome = db.prepare( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ?', + ).get(prepared.intentId); + assertTransitionChronology(confirmedAt, [ + ['BudgetReservation committedAt', reservation.committedAt], + ['BudgetReservation updatedAt', reservation.updatedAt], + ...attemptChronology(attempt), + ['refund createdAt', refund.created_at], + ['refund updatedAt', refund.updated_at], + ['refund reconciliation recordedAt', reconciliation.row.recorded_at], + ['execution outcome recordedAt', execution?.recorded_at], + ['execution resolution openedAt', resolution?.opened_at], + ['BuyerOutcome predecessor recordedAt', predecessorOutcome?.recorded_at], + ]); + const refundUpdate = db.prepare(`UPDATE refunds + SET state = 'confirmed', evidence_json = ?, updated_at = ? + WHERE id = ? AND intent_id = ? AND state = ? + AND refund_transaction_id = ? AND original_transaction_id = ? + AND amount_atomic = ? AND evidence_json IS NULL`).run( + reconciliation.row.evidence_json, + confirmedAt, + refund.id, + prepared.intentId, + refund.state, + prepared.refundTransactionId, + attempt.transaction_id, + authority.amount.text, + ); + if (refundUpdate.changes !== 1n) { + fail('REFUND_CONFLICT', 'refund confirmation lost its race'); + } + const resolutionUpdate = db.prepare(`UPDATE execution_resolutions + SET state = 'resolved', blocks_wallet = 0, resolved_at = ? + WHERE intent_id = ? AND state = 'refund_pending' + AND blocks_wallet = 1 AND resolved_at IS NULL`).run( + confirmedAt, + prepared.intentId, + ); + if (resolutionUpdate.changes !== 1n) { + fail('REFUND_CONFLICT', 'execution refund resolution lost its race'); + } + const budgetUpdate = db.prepare(`UPDATE budget_reservations + SET committed_atomic = '0', released_atomic = ?, state = 'released', updated_at = ? + WHERE intent_id = ? AND state = 'committed' + AND committed_atomic = ? AND reserved_atomic = '0' + AND released_atomic = '0' AND unresolved_atomic = '0'`).run( + authority.amount.text, + confirmedAt, + prepared.intentId, + authority.amount.text, + ); + if (budgetUpdate.changes !== 1n) { + fail('BUDGET_CONFLICT', 'confirmed refund lost its budget race'); + } + const buyerOutcomeRevision = writeBuyerOutcome( + db, + prepared.intentId, + 'refunded', + 'REFUND_CONFIRMED', + confirmedAt, + 'execution_failed', + ); + appendEvent({ + entityType: 'budget_reservation', + entityId: prepared.intentId, + eventType: 'budget.refund_confirmed', + data: { + evidenceId: prepared.evidenceId, + originalTransactionId: attempt.transaction_id, + refundTransactionId: prepared.refundTransactionId, + amountAtomic: authority.amount.text, + previousState: 'committed', + nextState: 'released', + buyerOutcomeRevision, + confirmedAt, + }, + }); + const persisted = readBudgetRow(db, prepared.intentId); + if (!persisted || persisted.state !== 'released') { + fail('BUDGET_CORRUPTION', 'refunded BudgetReservation disappeared'); + } + snapshotImpl(db, { + sessionId: persisted.sessionId, + sellerOrigin: persisted.sellerOrigin, + at: confirmedAt, + }); + return publicReservation(persisted); + }; + + const recordConfirmedRefundInTransaction = (token, input) => store.within( + token, + ({ db, appendEvent }) => recordConfirmedRefundImpl( + db, + appendEvent, + prepareRefund(input), + ), + ); + + const recordConfirmedRefund = (input) => { + const prepared = prepareRefund(input); + return store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => recordConfirmedRefundImpl(db, appendEvent, prepared), + )); + }; + + return Object.freeze({ + snapshot, + snapshotInTransaction, + reserve, + reserveInTransaction, + commit, + commitInTransaction, + release, + releaseInTransaction, + holdUnresolved, + holdUnresolvedInTransaction, + resolvePayment, + resolvePaymentInTransaction, + recordConfirmedRefund, + recordConfirmedRefundInTransaction, + }); +} diff --git a/spikes/pi-wielder/src/kernel/intent-builder.mjs b/spikes/pi-wielder/src/kernel/intent-builder.mjs new file mode 100644 index 0000000..fd3ecf3 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/intent-builder.mjs @@ -0,0 +1,2277 @@ +import { types as utilTypes } from 'node:util'; + +import { + canonicalJson, + canonicalTimestamp, + canonicalToken, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; +import { + projectPaymentRequired, + validateChallengeProjection, + validatePolicyDocument, +} from './policy-engine.mjs'; + +export const FORBIDDEN_AGENT_HEADERS = Object.freeze([ + 'payment-required', + 'payment-signature', + 'payment-response', + 'x-payment', + 'x-payment-required', + 'x-payment-response', + 'idempotency-key', + 'x-approval-id', + 'x-spend-session', +]); + +const FORBIDDEN_HEADER_SET = new Set(FORBIDDEN_AGENT_HEADERS); +const ALLOWED_HEADER_SET = new Set(['accept', 'content-type', 'user-agent']); +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const INSTANCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/; +const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i; +const MAX_BODY_BYTES = 1_048_576; +const MAX_HEADER_BYTES = 8_192; +const MAX_ID_ATTEMPTS = 32; + +const LEGAL_TRANSITIONS = Object.freeze(new Map([ + ['captured', new Set(['challenged', 'terminal'])], + ['challenged', new Set(['approval_pending', 'authorized', 'terminal'])], + ['approval_pending', new Set(['authorized', 'terminal'])], + ['authorized', new Set(['reserved', 'terminal'])], + ['reserved', new Set(['signing', 'terminal'])], + ['signing', new Set(['signed', 'unresolved'])], + ['signed', new Set(['retrying', 'unresolved'])], + ['retrying', new Set(['terminal', 'unresolved'])], + ['unresolved', new Set(['terminal'])], + ['terminal', new Set()], +])); + +function fail(code, message) { + throw new KernelError(code, message); +} + +function closedRecord(value, required, optional, code, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail(code, `${label} must be one plain object`); + } + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(value); + if (required.some((key) => !Object.hasOwn(value, key)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key))) { + fail(code, `${label} fields do not match the closed schema`); + } + const copy = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail(code, `${label} fields must be enumerable data properties`); + } + copy[key] = descriptor.value; + } + return copy; +} + +function canonicalHash(value, label, code = 'INTENT_CORRUPTION') { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + fail(code, `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalAddress(value, label, code = 'SESSION_SCHEMA') { + if (typeof value !== 'string' || !ADDRESS_PATTERN.test(value)) { + fail(code, `${label} must be one canonical lowercase EVM address`); + } + return value; +} + +function canonicalInstanceId(value) { + if (typeof value !== 'string' || !INSTANCE_ID_PATTERN.test(value)) { + fail('AGENT_INSTANCE_ID', 'agent instance ID must be one canonical 16-byte identifier'); + } + let decoded; + try { + decoded = Buffer.from(value, 'base64url'); + } catch { + fail('AGENT_INSTANCE_ID', 'agent instance ID must be one canonical 16-byte identifier'); + } + if (decoded.length !== 16 || decoded.toString('base64url') !== value) { + fail('AGENT_INSTANCE_ID', 'agent instance ID must be one canonical 16-byte identifier'); + } + return value; +} + +function boundedToken(value, label, code, maximum = 200) { + try { + return canonicalToken(value, label, maximum); + } catch (error) { + if (error instanceof KernelError) fail(code, `${label} must be one bounded canonical token`); + throw error; + } +} + +function isCanonicalLiteralLoopbackHttp(value, parsed) { + if (parsed.protocol !== 'http:' || !value.startsWith('http://')) return false; + const authority = value.slice('http://'.length).split(/[/?#]/u, 1)[0]; + if (!/^(?:127\.0\.0\.1|\[::1\])(?::[1-9][0-9]{0,4})?$/.test(authority)) return false; + return parsed.origin === `http://${authority}`; +} + +function canonicalRequestUrl(value, allowLoopbackHttp) { + if (typeof value !== 'string' || value.length === 0 + || Buffer.byteLength(value, 'utf8') > 4_096) { + fail('REQUEST_URL', 'request URL must be one bounded string'); + } + let parsed; + try { + parsed = new URL(value); + } catch { + fail('REQUEST_URL', 'request URL must be absolute and canonical'); + } + const allowedProtocol = parsed.protocol === 'https:' + || (allowLoopbackHttp && isCanonicalLiteralLoopbackHttp(value, parsed)); + if (!allowedProtocol + || parsed.username !== '' + || parsed.password !== '' + || value.includes('?') + || value.includes('#') + || parsed.search !== '' + || parsed.hash !== '' + || parsed.href !== value + || parsed.pathname.startsWith('//') + || parsed.pathname.includes('\\') + || ENCODED_PATH_SEPARATOR.test(parsed.pathname)) { + fail('REQUEST_URL', 'request URL must be one exact queryless HTTPS or allowed loopback URL'); + } + return Object.freeze({ + href: value, + origin: parsed.origin, + pathname: parsed.pathname, + }); +} + +function canonicalMethod(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > 32) { + fail('REQUEST_METHOD', 'request method must be one bounded HTTP token'); + } + const method = value.toUpperCase(); + if (!/^[A-Z][A-Z0-9-]{0,31}$/.test(method)) { + fail('REQUEST_METHOD', 'request method must be one canonical HTTP token'); + } + return method; +} + +function canonicalHeaders(value) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail('AGENT_HEADER_SCHEMA', 'agent headers must be one plain object'); + } + const normalized = new Map(); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !HEADER_NAME_PATTERN.test(key)) { + fail('AGENT_HEADER_SCHEMA', 'agent header name is invalid'); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail('AGENT_HEADER_SCHEMA', 'agent headers must contain only data properties'); + } + const lower = key.toLowerCase(); + if (normalized.has(lower)) { + fail('AGENT_HEADER_SCHEMA', 'case-colliding agent headers are forbidden'); + } + if (FORBIDDEN_HEADER_SET.has(lower)) { + fail('AGENT_HEADER_FORBIDDEN', `agent may not supply ${lower}`); + } + if (!ALLOWED_HEADER_SET.has(lower)) { + fail('AGENT_HEADER_UNSUPPORTED', `agent header ${lower} is not allowlisted`); + } + const raw = descriptor.value; + if (typeof raw !== 'string' || /[\x00-\x08\x0a-\x1f\x7f]/.test(raw) + || Buffer.byteLength(raw, 'utf8') > MAX_HEADER_BYTES) { + fail('AGENT_HEADER_SCHEMA', 'agent header value is invalid'); + } + const trimmed = raw.replace(/^[ \t]+|[ \t]+$/g, ''); + if (trimmed.length === 0) fail('AGENT_HEADER_SCHEMA', 'agent header value is empty'); + normalized.set(lower, trimmed); + } + return Object.freeze(Object.fromEntries([...normalized.entries()].sort(([a], [b]) => ( + a < b ? -1 : a > b ? 1 : 0 + )))); +} + +function canonicalRouteMetadata(value) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError('routeMetadata must be one plain operator-owned map'); + } + const normalized = new Map(); + for (const routeKey of Reflect.ownKeys(value)) { + if (typeof routeKey !== 'string') { + throw new TypeError('routeMetadata keys must be canonical route IDs'); + } + const descriptor = Object.getOwnPropertyDescriptor(value, routeKey); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError('routeMetadata must contain only enumerable data properties'); + } + const routeId = boundedToken(routeKey, 'route metadata ID', 'ROUTE_METADATA'); + const metadata = closedRecord( + descriptor.value, + ['description', 'mimeType'], + [], + 'ROUTE_METADATA', + 'route metadata', + ); + if (typeof metadata.description !== 'string' + || metadata.description.length === 0 + || Buffer.byteLength(metadata.description, 'utf8') > 1_024 + || /[\x00-\x1f\x7f]/.test(metadata.description) + || typeof metadata.mimeType !== 'string' + || Buffer.byteLength(metadata.mimeType, 'utf8') > 200 + || !/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/.test( + metadata.mimeType, + )) { + fail('ROUTE_METADATA', 'route metadata must be bounded canonical public text'); + } + normalized.set(routeId, Object.freeze({ + description: metadata.description, + mimeType: metadata.mimeType, + })); + } + return normalized; +} + +function copyBodyBytes(value) { + if (utilTypes.isProxy(value)) fail('REQUEST_BODY', 'request body must be inert bytes'); + const isBuffer = Buffer.isBuffer(value) && Object.getPrototypeOf(value) === Buffer.prototype; + const isUint8 = value instanceof Uint8Array + && Object.getPrototypeOf(value) === Uint8Array.prototype; + if ((!isBuffer && !isUint8) + || value.buffer instanceof SharedArrayBuffer + || value.byteLength > MAX_BODY_BYTES) { + fail('REQUEST_BODY', 'request body must be bounded inert bytes'); + } + return Buffer.from(value); +} + +function prepareFingerprintInput(value, { allowLoopbackHttp, allowCorrelation = false }) { + const request = closedRecord(value, [ + 'routeId', + 'method', + 'requestUrl', + 'headers', + 'bodyBytes', + 'purposeLabel', + ], allowCorrelation ? ['correlationId'] : [], 'INTENT_SCHEMA', 'intent request'); + const routeId = boundedToken(request.routeId, 'route ID', 'ROUTE_ID'); + const method = canonicalMethod(request.method); + const url = canonicalRequestUrl(request.requestUrl, allowLoopbackHttp); + const headers = canonicalHeaders(request.headers); + const bodyBytes = copyBodyBytes(request.bodyBytes); + const purposeLabel = boundedToken(request.purposeLabel, 'purpose label', 'PURPOSE_LABEL'); + const requestUrlHash = sha256(url.href); + const bodyHash = sha256(bodyBytes); + const headerAllowlistHash = sha256(canonicalJson(headers)); + const ordinary = Object.freeze({ + routeId, + method, + requestUrlHash, + bodyHash, + headerAllowlistHash, + purposeLabel, + }); + return Object.freeze({ + routeId, + method, + requestUrl: url.href, + requestUrlHash, + sellerOrigin: url.origin, + resourcePath: url.pathname, + bodyHash, + headerAllowlistHash, + purposeLabel, + ordinaryFingerprint: sha256(canonicalJson(ordinary)), + ...(allowCorrelation && Object.hasOwn(request, 'correlationId') + ? { correlationId: boundedToken( + request.correlationId, + 'correlation ID', + 'CORRELATION_ID', + ) } + : {}), + }); +} + +export function canonicalIntentFingerprint(value) { + return frozenCopy(prepareFingerprintInput(value, { + allowLoopbackHttp: false, + allowCorrelation: false, + })); +} + +function validatePolicyRow(row) { + if (!row) fail('POLICY_VERSION_MISSING', 'PolicyVersion does not exist'); + let parsed; + try { + parsed = JSON.parse(row.canonical_json); + } catch { + fail('POLICY_CORRUPTION', 'persisted policy JSON is invalid'); + } + let policy; + try { + policy = validatePolicyDocument(parsed); + } catch (error) { + if (error instanceof KernelError) fail('POLICY_CORRUPTION', 'persisted policy is invalid'); + throw error; + } + const canonical = canonicalJson(policy); + const hash = sha256(canonical); + const id = boundedToken(row.id, 'policy version ID', 'POLICY_CORRUPTION'); + const appliedAt = persistedTimestamp(row.applied_at, 'policy appliedAt', 'POLICY_CORRUPTION'); + const predecessorHash = row.predecessor_hash === null + ? null + : canonicalHash(row.predecessor_hash, 'policy predecessor hash', 'POLICY_CORRUPTION'); + if (canonical !== row.canonical_json || hash !== row.policy_hash + || !HASH_PATTERN.test(row.policy_hash) + || Number(row.schema_version) !== policy.schemaVersion) { + fail('POLICY_CORRUPTION', 'persisted PolicyVersion binding changed'); + } + return Object.freeze({ id, policy, hash, predecessorHash, appliedAt }); +} + +function persistedTimestamp(value, label, code) { + try { + return canonicalTimestamp(value, label); + } catch (error) { + if (error instanceof KernelError) fail(code, `${label} is not canonical`); + throw error; + } +} + +function timestampIsBefore(left, right) { + return Date.parse(left) < Date.parse(right); +} + +function validateEnrollmentRow(row, { requireActive = true } = {}) { + if (!row) fail('AGENT_ENROLLMENT_REQUIRED', 'agent enrollment does not exist'); + if (requireActive && row.state !== 'active') fail('AGENT_REVOKED', 'agent enrollment is revoked'); + if (row.state !== 'active' && row.state !== 'revoked') { + fail('AGENT_ENROLLMENT_CORRUPTION', 'agent enrollment state is invalid'); + } + let agentInstanceId; + try { + agentInstanceId = canonicalInstanceId(row.agent_instance_id); + } catch (error) { + if (error instanceof KernelError) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted agent instance ID is invalid'); + } + throw error; + } + const descriptor = { + schemaVersion: 1, + agentInstanceId, + credentialDigest: canonicalHash( + row.credential_digest, + 'credential digest', + 'AGENT_ENROLLMENT_CORRUPTION', + ), + agentUid: row.agent_uid, + agentGid: row.agent_gid, + }; + if (!/^[1-9][0-9]*$/.test(descriptor.agentUid) + || !/^[1-9][0-9]*$/.test(descriptor.agentGid) + || !Number.isSafeInteger(Number(descriptor.agentUid)) + || !Number.isSafeInteger(Number(descriptor.agentGid)) + || String(Number(descriptor.agentUid)) !== descriptor.agentUid + || String(Number(descriptor.agentGid)) !== descriptor.agentGid) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted agent identity is invalid'); + } + const enrollmentHash = canonicalHash( + row.enrollment_hash, + 'enrollment hash', + 'AGENT_ENROLLMENT_CORRUPTION', + ); + if (sha256(canonicalJson(descriptor)) !== enrollmentHash) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'persisted enrollment hash changed'); + } + const enrolledByOperatorHash = canonicalHash( + row.enrolled_by_operator_hash, + 'enrollment operator hash', + 'AGENT_ENROLLMENT_CORRUPTION', + ); + const enrolledAt = persistedTimestamp( + row.enrolled_at, + 'enrollment enrolledAt', + 'AGENT_ENROLLMENT_CORRUPTION', + ); + let revokedByOperatorHash = null; + let revokedAt = null; + if (row.state === 'active') { + if (row.revoked_by_operator_hash !== null || row.revoked_at !== null) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'active enrollment has revocation fields'); + } + } else { + revokedByOperatorHash = canonicalHash( + row.revoked_by_operator_hash, + 'revocation operator hash', + 'AGENT_ENROLLMENT_CORRUPTION', + ); + revokedAt = persistedTimestamp( + row.revoked_at, + 'enrollment revokedAt', + 'AGENT_ENROLLMENT_CORRUPTION', + ); + if (timestampIsBefore(revokedAt, enrolledAt)) { + fail('AGENT_ENROLLMENT_CORRUPTION', 'enrollment revocation predates enrollment'); + } + } + return Object.freeze({ + ...descriptor, + enrollmentHash, + state: row.state, + enrolledByOperatorHash, + enrolledAt, + revokedByOperatorHash, + revokedAt, + }); +} + +function bindingIdFor(sessionId) { + const digest = sha256(canonicalJson({ + domain: 'wallet-kernel.session-binding.v1', + sessionId, + })); + return `binding-${digest.slice('sha256:'.length)}`; +} + +function correlationAliasIdFor(sessionId, correlationId) { + const digest = sha256(canonicalJson({ + domain: 'wallet-kernel.intent-correlation-alias.v1', + sessionId, + correlationId, + })); + return `correlation-${digest.slice('sha256:'.length)}`; +} + +function sessionProjection(authority) { + return { + session: { + id: authority.id, + adapterId: authority.adapterId, + walletAddress: authority.walletAddress, + policyVersionId: authority.policyVersionId, + state: authority.state, + createdAt: authority.createdAt, + closedAt: authority.closedAt, + }, + binding: { + id: authority.bindingId, + agentInstanceId: authority.agentInstanceId, + credentialDigest: authority.credentialDigest, + enrollmentHash: authority.enrollmentHash, + sessionId: authority.id, + state: authority.bindingState, + createdAt: authority.bindingCreatedAt, + lastSeenAt: authority.lastSeenAt, + closedAt: authority.bindingClosedAt, + }, + }; +} + +function publicSession(authority) { + return frozenCopy({ + id: authority.id, + adapterId: authority.adapterId, + agentInstanceId: authority.agentInstanceId, + enrollmentHash: authority.enrollmentHash, + walletAddress: authority.walletAddress, + policyVersionId: authority.policyVersionId, + state: authority.state, + createdAt: authority.createdAt, + closedAt: authority.closedAt, + sessionHash: sha256(canonicalJson(sessionProjection(authority))), + }); +} + +function exactEventData(row, required, code, label) { + let parsed; + try { + parsed = JSON.parse(row.data_json); + } catch { + fail(code, `${label} event JSON is invalid`); + } + const data = closedRecord(parsed, required, [], code, `${label} event data`); + if (canonicalJson(data) !== row.data_json) { + fail(code, `${label} event JSON is not canonical`); + } + persistedTimestamp(row.created_at, `${label} event createdAt`, code); + return data; +} + +function validateSessionGenesisEvents(db, authority) { + const initialAuthority = { + ...authority, + state: 'open', + closedAt: null, + bindingState: 'open', + lastSeenAt: authority.createdAt, + bindingClosedAt: null, + }; + const initialSessionHash = publicSession(initialAuthority).sessionHash; + const sessionEvents = db.prepare(`SELECT data_json, created_at FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`).all('spend_session', authority.id, 'session.started'); + const bindingEvents = db.prepare(`SELECT data_json, created_at FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`).all('session_binding', authority.bindingId, 'session.binding_opened'); + if (sessionEvents.length !== 1 || bindingEvents.length !== 1) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'session genesis events are missing or ambiguous'); + } + const sessionData = exactEventData(sessionEvents[0], [ + 'adapterId', + 'enrollmentHash', + 'policyVersionId', + 'sessionHash', + 'walletAddress', + 'createdAt', + ], 'SESSION_AUTHORITY_AMBIGUOUS', 'session.started'); + const bindingData = exactEventData(bindingEvents[0], [ + 'agentInstanceId', + 'enrollmentHash', + 'sessionId', + 'createdAt', + ], 'SESSION_AUTHORITY_AMBIGUOUS', 'session.binding_opened'); + const expectedSessionData = { + adapterId: authority.adapterId, + enrollmentHash: authority.enrollmentHash, + policyVersionId: authority.policyVersionId, + sessionHash: initialSessionHash, + walletAddress: authority.walletAddress, + createdAt: authority.createdAt, + }; + const expectedBindingData = { + agentInstanceId: authority.agentInstanceId, + enrollmentHash: authority.enrollmentHash, + sessionId: authority.id, + createdAt: authority.createdAt, + }; + if (canonicalJson(sessionData) !== canonicalJson(expectedSessionData) + || canonicalJson(bindingData) !== canonicalJson(expectedBindingData)) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'session genesis event binding changed'); + } +} + +function loadSessionCommandEvent(db, sessionId, eventType) { + const rows = db.prepare(`SELECT data_json, created_at FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`).all('spend_session', sessionId, eventType); + if (rows.length === 0) return null; + if (rows.length !== 1) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'session command replay is ambiguous'); + } + const fields = eventType === 'session.closed' + ? ['expectedSessionHash', 'closedSessionHash', 'closedAt'] + : eventType === 'session.policy_transitioned' + ? [ + 'expectedSessionHash', + 'targetPolicyVersionId', + 'closedSessionHash', + 'replacementSessionId', + 'replacementSessionHash', + 'transitionedAt', + ] + : null; + if (!fields) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'session command event type is unsupported'); + } + const data = exactEventData( + rows[0], + fields, + 'SESSION_AUTHORITY_AMBIGUOUS', + eventType, + ); + canonicalHash(data.expectedSessionHash, 'expected session hash', 'SESSION_AUTHORITY_AMBIGUOUS'); + canonicalHash(data.closedSessionHash, 'closed session hash', 'SESSION_AUTHORITY_AMBIGUOUS'); + if (eventType === 'session.closed') { + persistedTimestamp(data.closedAt, 'session close event time', 'SESSION_AUTHORITY_AMBIGUOUS'); + } else { + boundedToken( + data.targetPolicyVersionId, + 'target policy version ID', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + boundedToken( + data.replacementSessionId, + 'replacement session ID', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + canonicalHash( + data.replacementSessionHash, + 'replacement session hash', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + persistedTimestamp( + data.transitionedAt, + 'session transition event time', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + } + return data; +} + +function validateSessionLifecycleEvents(db, authority) { + const bindingEvents = db.prepare(`SELECT data_json, created_at FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`).all( + 'session_binding', + authority.bindingId, + 'session.binding_closed', + ); + const closeEvent = loadSessionCommandEvent(db, authority.id, 'session.closed'); + const transitionEvent = loadSessionCommandEvent( + db, + authority.id, + 'session.policy_transitioned', + ); + if (authority.state !== 'closed') { + if (bindingEvents.length !== 0 || closeEvent || transitionEvent) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'live session has a close event'); + } + return; + } + if (bindingEvents.length !== 1 || Number(Boolean(closeEvent)) + Number(Boolean(transitionEvent)) !== 1) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'closed session lifecycle events are incomplete'); + } + const bindingData = exactEventData(bindingEvents[0], [ + 'sessionId', + 'closedAt', + 'reasonCode', + ], 'SESSION_AUTHORITY_AMBIGUOUS', 'session.binding_closed'); + boundedToken(bindingData.reasonCode, 'binding close reason', 'SESSION_AUTHORITY_AMBIGUOUS'); + persistedTimestamp( + bindingData.closedAt, + 'binding close event time', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + const closedSessionHash = publicSession(authority).sessionHash; + if (bindingData.sessionId !== authority.id || bindingData.closedAt !== authority.closedAt) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'binding close event disagrees with session'); + } + if (closeEvent) { + if (bindingData.reasonCode !== 'SESSION_CLOSED' + || closeEvent.closedAt !== authority.closedAt + || closeEvent.closedSessionHash !== closedSessionHash) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'session close event disagrees with authority'); + } + return; + } + if (bindingData.reasonCode !== 'POLICY_SUPERSEDED' + || transitionEvent.transitionedAt !== authority.closedAt + || transitionEvent.closedSessionHash !== closedSessionHash + || transitionEvent.replacementSessionId === authority.id) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'policy transition event disagrees with authority'); + } + loadHistoricalReplacementSession(db, transitionEvent, authority); +} + +function loadHistoricalReplacementSession(db, transitionEvent, previousAuthority) { + const replacement = loadSessionAuthority(db, transitionEvent.replacementSessionId, { + requireActive: false, + validateEvents: false, + }); + validateSessionGenesisEvents(db, replacement); + const initialReplacement = { + ...replacement, + state: 'open', + closedAt: null, + bindingState: 'open', + lastSeenAt: replacement.createdAt, + bindingClosedAt: null, + }; + const replacementSession = publicSession(initialReplacement); + if (replacement.createdAt !== previousAuthority.closedAt + || replacement.adapterId !== previousAuthority.adapterId + || replacement.agentInstanceId !== previousAuthority.agentInstanceId + || replacement.enrollmentHash !== previousAuthority.enrollmentHash + || replacement.walletAddress !== previousAuthority.walletAddress + || replacement.policyVersionId !== transitionEvent.targetPolicyVersionId + || replacementSession.sessionHash !== transitionEvent.replacementSessionHash) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'policy replacement session binding changed'); + } + return replacementSession; +} + +function loadSessionAuthority(db, sessionId, { + requireActive = true, + validateEvents = true, +} = {}) { + const session = db.prepare('SELECT * FROM spend_sessions WHERE id = ?').get(sessionId); + if (!session) fail('SESSION_UNKNOWN', 'Spend Session does not exist'); + const bindings = db.prepare( + 'SELECT * FROM agent_session_bindings WHERE session_id = ? ORDER BY rowid', + ).all(sessionId); + if (bindings.length !== 1) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'Spend Session must have exactly one binding'); + } + const binding = bindings[0]; + const enrollmentRow = db.prepare( + 'SELECT * FROM agent_enrollments WHERE enrollment_hash = ?', + ).get(binding.enrollment_hash); + const enrollment = validateEnrollmentRow(enrollmentRow, { requireActive }); + const policy = validatePolicyRow(db.prepare( + 'SELECT * FROM policy_versions WHERE id = ?', + ).get(session.policy_version_id)); + const activePolicyId = db.prepare( + 'SELECT value FROM metadata WHERE key = ?' + ).get('active_policy_id')?.value ?? null; + const id = boundedToken(session.id, 'session ID', 'SESSION_AUTHORITY_AMBIGUOUS'); + const policyVersionId = boundedToken( + session.policy_version_id, + 'session policy version ID', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + const walletAddress = canonicalAddress( + session.wallet_address, + 'session wallet', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + const createdAt = persistedTimestamp( + session.created_at, + 'session createdAt', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + const lastSeenAt = persistedTimestamp( + binding.last_seen_at, + 'binding lastSeenAt', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + const bindingCreatedAt = persistedTimestamp( + binding.created_at, + 'binding createdAt', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + const closedAt = session.closed_at === null ? null : persistedTimestamp( + session.closed_at, + 'session closedAt', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + const bindingClosedAt = binding.closed_at === null ? null : persistedTimestamp( + binding.closed_at, + 'binding closedAt', + 'SESSION_AUTHORITY_AMBIGUOUS', + ); + if (session.state !== 'open' && session.state !== 'policy_blocked' && session.state !== 'closed') { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'Spend Session state is invalid'); + } + const sessionOpen = session.state === 'open' || session.state === 'policy_blocked'; + const pairIsValid = id === sessionId + && binding.id === bindingIdFor(id) + && binding.agent_instance_id === enrollment.agentInstanceId + && binding.credential_digest === enrollment.credentialDigest + && binding.enrollment_hash === enrollment.enrollmentHash + && binding.state === (sessionOpen ? 'open' : 'closed') + && session.adapter_id === `pi:${enrollment.agentInstanceId}` + && walletAddress === policy.policy.wallet + && policy.id === policyVersionId + && bindingCreatedAt === createdAt + && !timestampIsBefore(lastSeenAt, createdAt) + && ((sessionOpen && closedAt === null && bindingClosedAt === null) + || (session.state === 'closed' && closedAt !== null + && bindingClosedAt === closedAt && lastSeenAt === closedAt)); + if (!pairIsValid) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'Spend Session authority rows disagree'); + } + if (activePolicyId !== null) { + boundedToken(activePolicyId, 'active policy version ID', 'SESSION_AUTHORITY_AMBIGUOUS'); + } + if (session.state === 'open' && activePolicyId !== policyVersionId) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'open Spend Session is not bound to active policy'); + } + if (session.state === 'policy_blocked' && activePolicyId === policyVersionId) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'policy-blocked session still names active policy'); + } + const authority = Object.freeze({ + id, + adapterId: session.adapter_id, + walletAddress, + policyVersionId, + state: session.state, + createdAt, + closedAt, + bindingId: binding.id, + agentInstanceId: binding.agent_instance_id, + credentialDigest: binding.credential_digest, + enrollmentHash: binding.enrollment_hash, + bindingState: binding.state, + bindingCreatedAt, + lastSeenAt, + bindingClosedAt, + policy, + enrollment, + }); + if (validateEvents) { + validateSessionGenesisEvents(db, authority); + validateSessionLifecycleEvents(db, authority); + } + return authority; +} + +function rowToIntent(row, authority, allowLoopbackHttp) { + if (!row) return null; + try { + const id = boundedToken(row.id, 'intent ID', 'INTENT_CORRUPTION'); + const requestId = boundedToken(row.request_id, 'request ID', 'INTENT_CORRUPTION'); + const sessionId = boundedToken(row.session_id, 'session ID', 'INTENT_CORRUPTION'); + const enrollmentHash = canonicalHash( + row.enrollment_hash, + 'intent enrollment hash', + 'INTENT_CORRUPTION', + ); + const routeId = boundedToken(row.route_id, 'route ID', 'INTENT_CORRUPTION'); + const method = canonicalMethod(row.method); + if (method !== row.method) fail('INTENT_CORRUPTION', 'persisted method is not canonical'); + const requestUrlHash = canonicalHash( + row.request_url_hash, + 'request URL hash', + 'INTENT_CORRUPTION', + ); + const bodyHash = canonicalHash(row.body_hash, 'body hash', 'INTENT_CORRUPTION'); + const headerAllowlistHash = canonicalHash( + row.header_allowlist_hash, + 'header allowlist hash', + 'INTENT_CORRUPTION', + ); + const ordinaryFingerprint = canonicalHash( + row.ordinary_fingerprint, + 'ordinary fingerprint', + 'INTENT_CORRUPTION', + ); + const purposeLabel = boundedToken( + row.purpose_label, + 'purpose label', + 'INTENT_CORRUPTION', + ); + const correlationId = boundedToken( + row.correlation_id, + 'correlation ID', + 'INTENT_CORRUPTION', + ); + const walletAddress = canonicalAddress( + row.wallet_address, + 'intent wallet', + 'INTENT_CORRUPTION', + ); + const intentHash = canonicalHash(row.intent_hash, 'intent hash', 'INTENT_CORRUPTION'); + const createdAt = persistedTimestamp( + row.created_at, + 'intent createdAt', + 'INTENT_CORRUPTION', + ); + const updatedAt = persistedTimestamp( + row.updated_at, + 'intent updatedAt', + 'INTENT_CORRUPTION', + ); + if (timestampIsBefore(updatedAt, createdAt)) { + fail('INTENT_CORRUPTION', 'Spend Intent update predates capture'); + } + if (!LEGAL_TRANSITIONS.has(row.state)) { + fail('INTENT_CORRUPTION', 'Spend Intent state is invalid'); + } + const retryValue = typeof row.retry_matchable === 'bigint' + ? row.retry_matchable + : BigInt(row.retry_matchable); + if ((retryValue !== 0n && retryValue !== 1n) + || (row.state === 'terminal') !== (retryValue === 0n)) { + fail('INTENT_CORRUPTION', 'Spend Intent retry authority disagrees with state'); + } + let reconstructedUrl; + try { + reconstructedUrl = canonicalRequestUrl( + `${row.seller_origin}${row.resource_path}`, + allowLoopbackHttp, + ); + } catch (error) { + if (error instanceof KernelError) { + fail('INTENT_CORRUPTION', 'persisted request URL projection is invalid'); + } + throw error; + } + if (reconstructedUrl.origin !== row.seller_origin + || reconstructedUrl.pathname !== row.resource_path + || sha256(reconstructedUrl.href) !== requestUrlHash) { + fail('INTENT_CORRUPTION', 'persisted request URL projection changed'); + } + const expectedOrdinaryFingerprint = sha256(canonicalJson({ + routeId, + method, + requestUrlHash, + bodyHash, + headerAllowlistHash, + purposeLabel, + })); + if (ordinaryFingerprint !== expectedOrdinaryFingerprint) { + fail('INTENT_CORRUPTION', 'persisted ordinary fingerprint changed'); + } + if (!authority || sessionId !== authority.id + || enrollmentHash !== authority.enrollmentHash + || walletAddress !== authority.walletAddress + || timestampIsBefore(createdAt, authority.createdAt)) { + fail('INTENT_CORRUPTION', 'Spend Intent authority binding changed'); + } + const expectedIdempotencyKey = idempotencyKeyFor({ + intentId: id, + requestId, + sessionId, + enrollmentHash, + ordinaryFingerprint, + correlationId, + }); + if (row.idempotency_key !== expectedIdempotencyKey) { + fail('INTENT_CORRUPTION', 'Spend Intent idempotency binding changed'); + } + const expectedIntentHash = sha256(canonicalJson({ + requestId, + sessionId, + enrollmentHash, + routeId, + method, + requestUrlHash, + sellerOrigin: reconstructedUrl.origin, + resourcePath: reconstructedUrl.pathname, + bodyHash, + headerAllowlistHash, + purposeLabel, + correlationId, + walletAddress, + policyVersionId: authority.policyVersionId, + })); + if (intentHash !== expectedIntentHash) { + fail('INTENT_CORRUPTION', 'Spend Intent hash binding changed'); + } + const challengeValues = [ + row.challenge_projection_json, + row.challenge_hash, + row.challenge_received_at, + ]; + const nullCount = challengeValues.filter((value) => value === null).length; + if (nullCount !== 0 && nullCount !== challengeValues.length) { + fail('INTENT_CORRUPTION', 'Spend Intent challenge columns are partial'); + } + let challengeProjectionJson = null; + let challengeHash = null; + let challengeReceivedAt = null; + if (nullCount === 0) { + let parsed; + try { + parsed = JSON.parse(row.challenge_projection_json); + const projection = validateChallengeProjection(parsed); + challengeProjectionJson = canonicalJson(projection); + } catch (error) { + if (error instanceof KernelError || error instanceof SyntaxError) { + fail('INTENT_CORRUPTION', 'persisted challenge projection is invalid'); + } + throw error; + } + challengeHash = canonicalHash( + row.challenge_hash, + 'challenge hash', + 'INTENT_CORRUPTION', + ); + challengeReceivedAt = persistedTimestamp( + row.challenge_received_at, + 'challenge receivedAt', + 'INTENT_CORRUPTION', + ); + if (challengeProjectionJson !== row.challenge_projection_json + || sha256(challengeProjectionJson) !== challengeHash + || parsed.resource.urlHash !== requestUrlHash + || row.state === 'captured' + || timestampIsBefore(challengeReceivedAt, createdAt) + || timestampIsBefore(updatedAt, challengeReceivedAt)) { + fail('INTENT_CORRUPTION', 'persisted challenge binding changed'); + } + } else if (row.state !== 'captured' && row.state !== 'terminal') { + fail('INTENT_CORRUPTION', 'Spend Intent state requires an attached challenge'); + } + return frozenCopy({ + id, + requestId, + sessionId, + enrollmentHash, + routeId, + method, + requestUrlHash, + sellerOrigin: reconstructedUrl.origin, + resourcePath: reconstructedUrl.pathname, + bodyHash, + headerAllowlistHash, + ordinaryFingerprint, + retryMatchable: retryValue === 1n, + purposeLabel, + correlationId, + idempotencyKey: row.idempotency_key, + walletAddress, + intentHash, + challengeProjectionJson, + challengeHash, + challengeReceivedAt, + state: row.state, + createdAt, + updatedAt, + }); + } catch (error) { + if (error instanceof KernelError && error.code !== 'INTENT_CORRUPTION') { + fail('INTENT_CORRUPTION', 'persisted Spend Intent is invalid'); + } + throw error; + } +} + +function validateIntentEvents(db, intent, authority) { + const rows = db.prepare(`SELECT event_type, data_json, created_at FROM events + WHERE entity_type = ? AND entity_id = ? + AND event_type IN (?, ?, ?) + ORDER BY sequence`).all( + 'spend_intent', + intent.id, + 'intent.captured', + 'intent.challenge_attached', + 'intent.transitioned', + ); + const captured = rows.filter((row) => row.event_type === 'intent.captured'); + if (captured.length !== 1 || rows[0]?.event_type !== 'intent.captured') { + fail('INTENT_CORRUPTION', 'Spend Intent capture event is missing or ambiguous'); + } + const captureData = exactEventData(captured[0], [ + 'requestId', + 'sessionId', + 'enrollmentHash', + 'routeId', + 'method', + 'requestUrlHash', + 'sellerOrigin', + 'resourcePath', + 'bodyHash', + 'headerAllowlistHash', + 'ordinaryFingerprint', + 'purposeLabel', + 'correlationId', + 'idempotencyKey', + 'walletAddress', + 'policyVersionId', + 'intentHash', + 'createdAt', + ], 'INTENT_CORRUPTION', 'intent.captured'); + const expectedCapture = { + requestId: intent.requestId, + sessionId: intent.sessionId, + enrollmentHash: intent.enrollmentHash, + routeId: intent.routeId, + method: intent.method, + requestUrlHash: intent.requestUrlHash, + sellerOrigin: intent.sellerOrigin, + resourcePath: intent.resourcePath, + bodyHash: intent.bodyHash, + headerAllowlistHash: intent.headerAllowlistHash, + ordinaryFingerprint: intent.ordinaryFingerprint, + purposeLabel: intent.purposeLabel, + correlationId: intent.correlationId, + idempotencyKey: intent.idempotencyKey, + walletAddress: intent.walletAddress, + policyVersionId: authority.policyVersionId, + intentHash: intent.intentHash, + createdAt: intent.createdAt, + }; + if (canonicalJson(captureData) !== canonicalJson(expectedCapture)) { + fail('INTENT_CORRUPTION', 'Spend Intent capture event binding changed'); + } + + let state = 'captured'; + let updatedAt = intent.createdAt; + let challengeCount = 0; + for (const event of rows.slice(1)) { + if (event.event_type === 'intent.captured') { + fail('INTENT_CORRUPTION', 'Spend Intent has duplicate capture events'); + } + if (event.event_type === 'intent.challenge_attached') { + challengeCount += 1; + if (challengeCount !== 1 || state !== 'captured' || intent.challengeHash === null) { + fail('INTENT_CORRUPTION', 'Spend Intent challenge history is invalid'); + } + const data = exactEventData(event, [ + 'challengeHash', + 'challengeReceivedAt', + 'projectionHash', + 'updatedAt', + ], 'INTENT_CORRUPTION', 'intent.challenge_attached'); + const eventUpdatedAt = persistedTimestamp( + data.updatedAt, + 'challenge event updatedAt', + 'INTENT_CORRUPTION', + ); + if (data.challengeHash !== intent.challengeHash + || data.projectionHash !== intent.challengeHash + || data.challengeReceivedAt !== intent.challengeReceivedAt + || timestampIsBefore(eventUpdatedAt, intent.challengeReceivedAt) + || timestampIsBefore(eventUpdatedAt, updatedAt)) { + fail('INTENT_CORRUPTION', 'Spend Intent challenge event binding changed'); + } + state = 'challenged'; + updatedAt = eventUpdatedAt; + continue; + } + const data = exactEventData(event, [ + 'previousState', + 'nextState', + 'reasonCode', + 'retryMatchable', + 'updatedAt', + ], 'INTENT_CORRUPTION', 'intent.transitioned'); + if (!LEGAL_TRANSITIONS.has(data.previousState) + || !LEGAL_TRANSITIONS.has(data.nextState) + || data.previousState !== state + || data.nextState === 'challenged' + || !LEGAL_TRANSITIONS.get(state).has(data.nextState) + || typeof data.retryMatchable !== 'boolean' + || data.retryMatchable !== (data.nextState !== 'terminal')) { + fail('INTENT_CORRUPTION', 'Spend Intent transition event is invalid'); + } + boundedToken(data.reasonCode, 'transition reason code', 'INTENT_CORRUPTION'); + const eventUpdatedAt = persistedTimestamp( + data.updatedAt, + 'transition event updatedAt', + 'INTENT_CORRUPTION', + ); + if (timestampIsBefore(eventUpdatedAt, updatedAt)) { + fail('INTENT_CORRUPTION', 'Spend Intent transition time regressed'); + } + state = data.nextState; + updatedAt = eventUpdatedAt; + } + if ((intent.challengeHash === null ? 0 : 1) !== challengeCount + || state !== intent.state + || updatedAt !== intent.updatedAt) { + fail('INTENT_CORRUPTION', 'Spend Intent row disagrees with event history'); + } +} + +function decodeIntent(db, row, authority, allowLoopbackHttp) { + const intent = rowToIntent(row, authority, allowLoopbackHttp); + if (intent) validateIntentEvents(db, intent, authority); + return intent; +} + +function exactIntentMatch(intent, prepared) { + return intent.routeId === prepared.routeId + && intent.method === prepared.method + && intent.requestUrlHash === prepared.requestUrlHash + && intent.sellerOrigin === prepared.sellerOrigin + && intent.resourcePath === prepared.resourcePath + && intent.bodyHash === prepared.bodyHash + && intent.headerAllowlistHash === prepared.headerAllowlistHash + && intent.ordinaryFingerprint === prepared.ordinaryFingerprint + && intent.purposeLabel === prepared.purposeLabel; +} + +function generatedId(idFactory, kind) { + return boundedToken(idFactory(kind), `${kind} ID`, 'ID_FACTORY'); +} + +function idempotencyKeyFor(fields) { + const digest = sha256(canonicalJson({ + domain: 'wallet-kernel.intent-idempotency.v1', + ...fields, + })); + return `wk_${digest.slice('sha256:'.length)}`; +} + +export function createIntentRepository({ + store, + idFactory, + now, + allowLoopbackHttp = false, + routeMetadata = {}, +}) { + if (!store || typeof store.transaction !== 'function' || typeof store.within !== 'function') { + throw new TypeError('intent repository requires a Wallet Kernel store'); + } + if (typeof idFactory !== 'function' || utilTypes.isProxy(idFactory)) { + throw new TypeError('intent repository requires an ID factory'); + } + if (typeof now !== 'function' || utilTypes.isProxy(now)) { + throw new TypeError('intent repository requires a clock'); + } + if (typeof allowLoopbackHttp !== 'boolean') { + throw new TypeError('allowLoopbackHttp must be boolean'); + } + const routeMetadataById = canonicalRouteMetadata(routeMetadata); + + const nextSessionIdentity = (db) => { + for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) { + const id = generatedId(idFactory, 'session'); + const bindingId = bindingIdFor(id); + const sessionExists = db.prepare('SELECT id FROM spend_sessions WHERE id = ?').get(id); + const bindingExists = db.prepare( + 'SELECT id FROM agent_session_bindings WHERE id = ?', + ).get(bindingId); + if (!sessionExists && !bindingExists) return Object.freeze({ id, bindingId }); + } + fail('ID_FACTORY_COLLISION', 'session ID factory exhausted collision retries'); + }; + + const nextIntentId = (db) => { + for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) { + const id = generatedId(idFactory, 'intent'); + if (!db.prepare('SELECT id FROM spend_intents WHERE id = ?').get(id)) return id; + } + fail('ID_FACTORY_COLLISION', 'intent ID factory exhausted collision retries'); + }; + + const nextRequestId = (db) => { + for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) { + const id = generatedId(idFactory, 'request'); + if (!db.prepare('SELECT id FROM spend_intents WHERE request_id = ?').get(id)) return id; + } + fail('ID_FACTORY_COLLISION', 'request ID factory exhausted collision retries'); + }; + + const nextCorrelationId = (db, sessionId) => { + for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) { + const id = generatedId(idFactory, 'correlation'); + const intent = db.prepare(`SELECT id FROM spend_intents + WHERE session_id = ? AND correlation_id = ?`).get(sessionId, id); + const alias = db.prepare(`SELECT sequence FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + LIMIT 1`).get( + 'intent_correlation', + correlationAliasIdFor(sessionId, id), + 'intent.correlation_bound', + ); + if (!intent && !alias) return id; + } + fail('ID_FACTORY_COLLISION', 'correlation ID factory exhausted collision retries'); + }; + + const loadCorrelatedIntent = (db, sessionId, correlationId, authority) => { + const primaryRow = db.prepare(`SELECT * FROM spend_intents + WHERE session_id = ? AND correlation_id = ?`).get(sessionId, correlationId); + const aliasRows = db.prepare(`SELECT data_json, created_at FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + ORDER BY sequence`).all( + 'intent_correlation', + correlationAliasIdFor(sessionId, correlationId), + 'intent.correlation_bound', + ); + if (aliasRows.length > 1 || (primaryRow && aliasRows.length !== 0)) { + fail('INTENT_CORRUPTION', 'correlation authority is ambiguous'); + } + if (primaryRow) { + return decodeIntent(db, primaryRow, authority, allowLoopbackHttp); + } + if (aliasRows.length === 0) return null; + const data = exactEventData(aliasRows[0], [ + 'sessionId', + 'intentId', + 'correlationId', + 'ordinaryFingerprint', + ], 'INTENT_CORRUPTION', 'intent.correlation_bound'); + const intentId = boundedToken(data.intentId, 'alias intent ID', 'INTENT_CORRUPTION'); + const fingerprint = canonicalHash( + data.ordinaryFingerprint, + 'alias ordinary fingerprint', + 'INTENT_CORRUPTION', + ); + if (data.sessionId !== sessionId || data.correlationId !== correlationId) { + fail('INTENT_CORRUPTION', 'correlation alias key changed'); + } + const intent = decodeIntent( + db, + db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(intentId), + authority, + allowLoopbackHttp, + ); + if (!intent || intent.sessionId !== sessionId + || intent.ordinaryFingerprint !== fingerprint) { + fail('INTENT_CORRUPTION', 'correlation alias target changed'); + } + return intent; + }; + + const appendCorrelationAlias = (db, appendEvent, intent, correlationId) => { + if (correlationId === intent.correlationId) return; + const primary = db.prepare(`SELECT id FROM spend_intents + WHERE session_id = ? AND correlation_id = ?`).get(intent.sessionId, correlationId); + const alias = db.prepare(`SELECT sequence FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ? + LIMIT 1`).get( + 'intent_correlation', + correlationAliasIdFor(intent.sessionId, correlationId), + 'intent.correlation_bound', + ); + if (primary || alias) { + fail('INTENT_CORRUPTION', 'correlation alias already exists'); + } + appendEvent({ + entityType: 'intent_correlation', + entityId: correlationAliasIdFor(intent.sessionId, correlationId), + eventType: 'intent.correlation_bound', + data: { + sessionId: intent.sessionId, + intentId: intent.id, + correlationId, + ordinaryFingerprint: intent.ordinaryFingerprint, + }, + }); + }; + + const getSession = (sessionId) => { + const id = boundedToken(sessionId, 'session ID', 'SESSION_SCHEMA'); + return store.transaction((token) => store.within(token, ({ db }) => { + if (!db.prepare('SELECT id FROM spend_sessions WHERE id = ?').get(id)) return null; + return publicSession(loadSessionAuthority(db, id, { requireActive: false })); + })); + }; + + const openOrResumeSession = (input) => { + const request = closedRecord(input, [ + 'agentInstanceId', + 'walletAddress', + 'policyVersionId', + ], [], 'SESSION_SCHEMA', 'open session request'); + const agentInstanceId = canonicalInstanceId(request.agentInstanceId); + const walletAddress = canonicalAddress(request.walletAddress, 'session wallet'); + const policyVersionId = boundedToken( + request.policyVersionId, + 'policy version ID', + 'SESSION_SCHEMA', + ); + return store.transaction((token) => store.within(token, ({ db, appendEvent }) => { + const activeRows = db.prepare( + "SELECT * FROM agent_enrollments WHERE state = 'active' ORDER BY rowid", + ).all(); + if (activeRows.length > 1) { + fail('AGENT_ENROLLMENT_AMBIGUOUS', 'multiple active agent enrollments exist'); + } + const enrollmentRow = activeRows.find((row) => row.agent_instance_id === agentInstanceId); + if (!enrollmentRow) { + const historical = db.prepare( + 'SELECT state FROM agent_enrollments WHERE agent_instance_id = ?', + ).get(agentInstanceId); + fail(historical?.state === 'revoked' ? 'AGENT_REVOKED' : 'AGENT_ENROLLMENT_REQUIRED', + 'exact active agent enrollment is required'); + } + const enrollment = validateEnrollmentRow(enrollmentRow); + const policy = validatePolicyRow(db.prepare( + 'SELECT * FROM policy_versions WHERE id = ?', + ).get(policyVersionId)); + const activePolicyId = db.prepare( + 'SELECT value FROM metadata WHERE key = ?' + ).get('active_policy_id')?.value ?? null; + if (activePolicyId !== policyVersionId) { + fail('POLICY_NOT_ACTIVE', 'Spend Session requires the active PolicyVersion'); + } + if (policy.policy.wallet !== walletAddress) { + fail('POLICY_WALLET_MISMATCH', 'session wallet differs from PolicyVersion wallet'); + } + const adapterId = `pi:${agentInstanceId}`; + const authorityRows = db.prepare(`SELECT DISTINCT spend_sessions.id + FROM spend_sessions + LEFT JOIN agent_session_bindings + ON agent_session_bindings.session_id = spend_sessions.id + WHERE (spend_sessions.state IN ('open','policy_blocked') + AND (spend_sessions.adapter_id = ? + OR agent_session_bindings.agent_instance_id = ? + OR agent_session_bindings.credential_digest = ? + OR agent_session_bindings.enrollment_hash = ?)) + OR (agent_session_bindings.state = 'open' + AND (agent_session_bindings.agent_instance_id = ? + OR agent_session_bindings.credential_digest = ? + OR agent_session_bindings.enrollment_hash = ?)) + ORDER BY spend_sessions.id`).all( + adapterId, + agentInstanceId, + enrollment.credentialDigest, + enrollment.enrollmentHash, + agentInstanceId, + enrollment.credentialDigest, + enrollment.enrollmentHash, + ); + if (authorityRows.length > 1) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'agent has ambiguous open session authority'); + } + if (authorityRows.length === 1) { + const authority = loadSessionAuthority(db, authorityRows[0].id, { + requireActive: false, + }); + if (authority.state !== 'open' + || authority.enrollment.state !== 'active' + || authority.agentInstanceId !== agentInstanceId + || authority.enrollmentHash !== enrollment.enrollmentHash + || authority.walletAddress !== walletAddress + || authority.policyVersionId !== policyVersionId) { + fail('AGENT_SESSION_UNAVAILABLE', 'existing agent session has a different authority'); + } + return publicSession(authority); + } + + const { id, bindingId } = nextSessionIdentity(db); + const createdAt = canonicalTimestamp(now(), 'session createdAt'); + db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at) + VALUES (?, ?, ?, ?, 'open', ?)`).run( + id, + adapterId, + walletAddress, + policyVersionId, + createdAt, + ); + db.prepare(`INSERT INTO agent_session_bindings + (id, agent_instance_id, credential_digest, enrollment_hash, session_id, + state, created_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, 'open', ?, ?)`).run( + bindingId, + agentInstanceId, + enrollment.credentialDigest, + enrollment.enrollmentHash, + id, + createdAt, + createdAt, + ); + const session = publicSession(loadSessionAuthority(db, id, { validateEvents: false })); + appendEvent({ + entityType: 'spend_session', + entityId: id, + eventType: 'session.started', + data: { + adapterId, + enrollmentHash: enrollment.enrollmentHash, + policyVersionId, + sessionHash: session.sessionHash, + walletAddress, + createdAt, + }, + }); + appendEvent({ + entityType: 'session_binding', + entityId: bindingId, + eventType: 'session.binding_opened', + data: { + agentInstanceId, + enrollmentHash: enrollment.enrollmentHash, + sessionId: id, + createdAt, + }, + }); + const persisted = publicSession(loadSessionAuthority(db, id)); + if (persisted.sessionHash !== session.sessionHash) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'new session authority changed during creation'); + } + return persisted; + })); + }; + + const prepareCapture = (input) => { + const request = closedRecord(input, [ + 'sessionId', + 'routeId', + 'method', + 'requestUrl', + 'headers', + 'bodyBytes', + 'purposeLabel', + ], ['correlationId'], 'INTENT_SCHEMA', 'intent capture'); + const sessionId = boundedToken(request.sessionId, 'session ID', 'INTENT_SCHEMA'); + const fingerprint = prepareFingerprintInput({ + routeId: request.routeId, + method: request.method, + requestUrl: request.requestUrl, + headers: request.headers, + bodyBytes: request.bodyBytes, + purposeLabel: request.purposeLabel, + ...(Object.hasOwn(request, 'correlationId') + ? { correlationId: request.correlationId } + : {}), + }, { allowLoopbackHttp, allowCorrelation: true }); + return Object.freeze({ sessionId, ...fingerprint }); + }; + + const capturePrepared = (db, appendEvent, prepared) => { + const authority = loadSessionAuthority(db, prepared.sessionId); + if (authority.state !== 'open') { + fail(authority.state === 'policy_blocked' ? 'SESSION_POLICY_BLOCKED' : 'SESSION_CLOSED', + 'Spend Session cannot capture an intent'); + } + if (prepared.correlationId) { + const byCorrelation = loadCorrelatedIntent( + db, + prepared.sessionId, + prepared.correlationId, + authority, + ); + if (byCorrelation) { + if (!exactIntentMatch(byCorrelation, prepared) + || byCorrelation.enrollmentHash !== authority.enrollmentHash + || byCorrelation.walletAddress !== authority.walletAddress) { + fail('CORRELATION_CONFLICT', 'correlation ID is bound to a different request'); + } + return byCorrelation; + } + } + const retryRows = db.prepare(`SELECT * FROM spend_intents + WHERE session_id = ? AND ordinary_fingerprint = ? AND retry_matchable = 1 + ORDER BY rowid`).all(prepared.sessionId, prepared.ordinaryFingerprint); + if (retryRows.length > 1) fail('INTENT_RETRY_AMBIGUOUS', 'retry authority is ambiguous'); + if (retryRows.length === 1) { + const existing = decodeIntent(db, retryRows[0], authority, allowLoopbackHttp); + if (!exactIntentMatch(existing, prepared) + || existing.enrollmentHash !== authority.enrollmentHash + || existing.walletAddress !== authority.walletAddress) { + fail('INTENT_CORRUPTION', 'retry fingerprint row differs from canonical request'); + } + if (prepared.correlationId) { + appendCorrelationAlias(db, appendEvent, existing, prepared.correlationId); + } + return existing; + } + + const correlationId = prepared.correlationId + ?? nextCorrelationId(db, prepared.sessionId); + const id = nextIntentId(db); + const requestId = nextRequestId(db); + const createdAt = canonicalTimestamp(now(), 'intent createdAt'); + const intentHash = sha256(canonicalJson({ + requestId, + sessionId: prepared.sessionId, + enrollmentHash: authority.enrollmentHash, + routeId: prepared.routeId, + method: prepared.method, + requestUrlHash: prepared.requestUrlHash, + sellerOrigin: prepared.sellerOrigin, + resourcePath: prepared.resourcePath, + bodyHash: prepared.bodyHash, + headerAllowlistHash: prepared.headerAllowlistHash, + purposeLabel: prepared.purposeLabel, + correlationId, + walletAddress: authority.walletAddress, + policyVersionId: authority.policyVersionId, + })); + const idempotencyKey = idempotencyKeyFor({ + intentId: id, + requestId, + sessionId: prepared.sessionId, + enrollmentHash: authority.enrollmentHash, + ordinaryFingerprint: prepared.ordinaryFingerprint, + correlationId, + }); + db.prepare(`INSERT INTO spend_intents + (id, request_id, session_id, enrollment_hash, route_id, method, + request_url_hash, seller_origin, resource_path, body_hash, + header_allowlist_hash, ordinary_fingerprint, purpose_label, + correlation_id, idempotency_key, wallet_address, intent_hash, + state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + 'captured', ?, ?)`).run( + id, + requestId, + prepared.sessionId, + authority.enrollmentHash, + prepared.routeId, + prepared.method, + prepared.requestUrlHash, + prepared.sellerOrigin, + prepared.resourcePath, + prepared.bodyHash, + prepared.headerAllowlistHash, + prepared.ordinaryFingerprint, + prepared.purposeLabel, + correlationId, + idempotencyKey, + authority.walletAddress, + intentHash, + createdAt, + createdAt, + ); + appendEvent({ + entityType: 'spend_intent', + entityId: id, + eventType: 'intent.captured', + data: { + requestId, + sessionId: prepared.sessionId, + enrollmentHash: authority.enrollmentHash, + routeId: prepared.routeId, + method: prepared.method, + requestUrlHash: prepared.requestUrlHash, + sellerOrigin: prepared.sellerOrigin, + resourcePath: prepared.resourcePath, + bodyHash: prepared.bodyHash, + headerAllowlistHash: prepared.headerAllowlistHash, + ordinaryFingerprint: prepared.ordinaryFingerprint, + purposeLabel: prepared.purposeLabel, + correlationId, + idempotencyKey, + walletAddress: authority.walletAddress, + policyVersionId: authority.policyVersionId, + intentHash, + createdAt, + }, + }); + return decodeIntent( + db, + db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(id), + authority, + allowLoopbackHttp, + ); + }; + + const captureIntentInTransaction = (token, input) => { + const prepared = prepareCapture(input); + return store.within(token, ({ db, appendEvent }) => capturePrepared( + db, + appendEvent, + prepared, + )); + }; + + const captureIntent = (input) => { + const prepared = prepareCapture(input); + return store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => capturePrepared(db, appendEvent, prepared), + )); + }; + + const getIntent = (intentId) => { + const id = boundedToken(intentId, 'intent ID', 'INTENT_SCHEMA'); + return store.transaction((token) => store.within(token, ({ db }) => { + const row = db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(id); + if (!row) return null; + const sessionId = boundedToken( + row.session_id, + 'persisted session ID', + 'INTENT_CORRUPTION', + ); + const authority = loadSessionAuthority(db, sessionId, { requireActive: false }); + return decodeIntent(db, row, authority, allowLoopbackHttp); + })); + }; + + const matchRetry = (input) => { + const request = closedRecord( + input, + ['sessionId', 'request'], + [], + 'INTENT_SCHEMA', + 'retry match', + ); + const sessionId = boundedToken(request.sessionId, 'session ID', 'INTENT_SCHEMA'); + const prepared = prepareFingerprintInput(request.request, { + allowLoopbackHttp, + allowCorrelation: true, + }); + return store.transaction((token) => store.within(token, ({ db }) => { + if (!db.prepare('SELECT id FROM spend_sessions WHERE id = ?').get(sessionId)) return null; + const authority = loadSessionAuthority(db, sessionId); + if (authority.state !== 'open') { + fail(authority.state === 'policy_blocked' ? 'SESSION_POLICY_BLOCKED' : 'SESSION_CLOSED', + 'Spend Session cannot match an agent retry'); + } + if (prepared.correlationId) { + const correlation = loadCorrelatedIntent( + db, + sessionId, + prepared.correlationId, + authority, + ); + if (correlation) { + if (!exactIntentMatch(correlation, prepared)) { + fail('CORRELATION_CONFLICT', 'correlation ID is bound to a different request'); + } + return correlation.id; + } + } + const rows = db.prepare(`SELECT * FROM spend_intents + WHERE session_id = ? AND ordinary_fingerprint = ? AND retry_matchable = 1 + ORDER BY rowid`).all(sessionId, prepared.ordinaryFingerprint); + if (rows.length > 1) fail('INTENT_RETRY_AMBIGUOUS', 'retry authority is ambiguous'); + if (rows.length === 0) return null; + const existing = decodeIntent(db, rows[0], authority, allowLoopbackHttp); + if (!exactIntentMatch(existing, prepared)) { + fail('INTENT_CORRUPTION', 'retry fingerprint row differs from canonical request'); + } + return existing.id; + })); + }; + + const prepareChallenge = (input) => { + const request = closedRecord(input, [ + 'intentId', + 'paymentRequired', + 'challengeReceivedAt', + ], [], 'CHALLENGE_ATTACH_SCHEMA', 'challenge attachment'); + const intentId = boundedToken(request.intentId, 'intent ID', 'CHALLENGE_ATTACH_SCHEMA'); + const challengeReceivedAt = canonicalTimestamp( + request.challengeReceivedAt, + 'challenge receivedAt', + ); + const projection = projectPaymentRequired(request.paymentRequired); + const projectionJson = canonicalJson(projection); + return Object.freeze({ + intentId, + projection, + projectionJson, + challengeHash: sha256(projectionJson), + challengeReceivedAt, + }); + }; + + const attachPrepared = (db, appendEvent, prepared) => { + const row = db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(prepared.intentId); + if (!row) fail('INTENT_UNKNOWN', 'Spend Intent does not exist'); + const sessionId = boundedToken(row.session_id, 'session ID', 'INTENT_CORRUPTION'); + const authority = loadSessionAuthority(db, sessionId); + if (authority.state !== 'open') { + fail(authority.state === 'policy_blocked' ? 'SESSION_POLICY_BLOCKED' : 'SESSION_CLOSED', + 'Spend Session cannot attach a challenge'); + } + const intent = decodeIntent(db, row, authority, allowLoopbackHttp); + const fixedMetadata = routeMetadataById.get(intent.routeId); + if (!fixedMetadata) { + fail('ROUTE_METADATA_REQUIRED', 'Spend Intent route has no operator-owned metadata'); + } + if (prepared.projection.resource.description !== fixedMetadata.description + || prepared.projection.resource.mimeType !== fixedMetadata.mimeType) { + fail( + 'CHALLENGE_RESOURCE_METADATA_MISMATCH', + 'challenge resource metadata differs from the operator route map', + ); + } + if (prepared.projection.resource.urlHash !== intent.requestUrlHash) { + fail('CHALLENGE_RESOURCE_MISMATCH', 'challenge resource differs from Spend Intent'); + } + if (intent.challengeHash !== null) { + if (intent.challengeProjectionJson !== prepared.projectionJson + || intent.challengeHash !== prepared.challengeHash + || intent.challengeReceivedAt !== prepared.challengeReceivedAt) { + fail('CHALLENGE_CHANGED', 'Spend Intent challenge is immutable'); + } + return intent; + } + if (intent.state !== 'captured') { + fail('INTENT_CORRUPTION', 'unchallenged Spend Intent is not captured'); + } + const updatedAt = canonicalTimestamp(now(), 'intent updatedAt'); + if (timestampIsBefore(prepared.challengeReceivedAt, intent.createdAt) + || timestampIsBefore(updatedAt, prepared.challengeReceivedAt) + || timestampIsBefore(updatedAt, intent.updatedAt)) { + fail('CHALLENGE_TIME', 'challenge or Kernel clock regressed'); + } + const updated = db.prepare(`UPDATE spend_intents + SET challenge_projection_json = ?, challenge_hash = ?, challenge_received_at = ?, + state = 'challenged', updated_at = ? + WHERE id = ? AND state = 'captured' + AND challenge_projection_json IS NULL + AND challenge_hash IS NULL + AND challenge_received_at IS NULL`).run( + prepared.projectionJson, + prepared.challengeHash, + prepared.challengeReceivedAt, + updatedAt, + prepared.intentId, + ); + if (updated.changes !== 1n) fail('CHALLENGE_CHANGED', 'challenge attachment lost its race'); + appendEvent({ + entityType: 'spend_intent', + entityId: prepared.intentId, + eventType: 'intent.challenge_attached', + data: { + challengeHash: prepared.challengeHash, + challengeReceivedAt: prepared.challengeReceivedAt, + projectionHash: prepared.challengeHash, + updatedAt, + }, + }); + return decodeIntent( + db, + db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(prepared.intentId), + authority, + allowLoopbackHttp, + ); + }; + + const attachChallengeInTransaction = (token, input) => { + const prepared = prepareChallenge(input); + return store.within(token, ({ db, appendEvent }) => attachPrepared( + db, + appendEvent, + prepared, + )); + }; + + const attachChallenge = (input) => { + const prepared = prepareChallenge(input); + return store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => attachPrepared(db, appendEvent, prepared), + )); + }; + + const prepareTransition = (input) => { + const request = closedRecord(input, [ + 'intentId', + 'expectedState', + 'nextState', + 'reasonCode', + ], [], 'INTENT_TRANSITION_SCHEMA', 'intent transition'); + const intentId = boundedToken(request.intentId, 'intent ID', 'INTENT_TRANSITION_SCHEMA'); + if (!LEGAL_TRANSITIONS.has(request.expectedState) + || !LEGAL_TRANSITIONS.has(request.nextState)) { + fail('INTENT_TRANSITION', 'intent transition names an unknown state'); + } + return Object.freeze({ + intentId, + expectedState: request.expectedState, + nextState: request.nextState, + reasonCode: boundedToken(request.reasonCode, 'reason code', 'INTENT_TRANSITION_SCHEMA'), + }); + }; + + const transitionPrepared = (db, appendEvent, prepared) => { + const row = db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(prepared.intentId); + if (!row) fail('INTENT_UNKNOWN', 'Spend Intent does not exist'); + const advancesAuthority = new Set([ + 'challenged', + 'approval_pending', + 'authorized', + 'reserved', + 'signing', + ]).has(prepared.nextState); + const sessionId = boundedToken(row.session_id, 'session ID', 'INTENT_CORRUPTION'); + const authority = loadSessionAuthority(db, sessionId, { + requireActive: advancesAuthority, + }); + if (advancesAuthority && authority.state !== 'open') { + fail(authority.state === 'policy_blocked' ? 'SESSION_POLICY_BLOCKED' : 'SESSION_CLOSED', + 'Spend Session cannot advance spend authority'); + } + const intent = decodeIntent(db, row, authority, allowLoopbackHttp); + if (intent.state !== prepared.expectedState) { + fail('INTENT_STATE_CONFLICT', 'Spend Intent state differs from expected state'); + } + if (!LEGAL_TRANSITIONS.get(prepared.expectedState).has(prepared.nextState) + || prepared.nextState === 'challenged') { + fail('INTENT_TRANSITION', 'intent state edge is not legal'); + } + const updatedAt = canonicalTimestamp(now(), 'intent updatedAt'); + if (timestampIsBefore(updatedAt, intent.updatedAt)) { + fail('INTENT_TIME', 'Kernel clock regressed during intent transition'); + } + const retryMatchable = prepared.nextState === 'terminal' ? 0 : 1; + const changed = db.prepare(`UPDATE spend_intents + SET state = ?, retry_matchable = ?, updated_at = ? + WHERE id = ? AND state = ?`).run( + prepared.nextState, + retryMatchable, + updatedAt, + prepared.intentId, + prepared.expectedState, + ); + if (changed.changes !== 1n) { + fail('INTENT_STATE_CONFLICT', 'Spend Intent transition lost its race'); + } + appendEvent({ + entityType: 'spend_intent', + entityId: prepared.intentId, + eventType: 'intent.transitioned', + data: { + previousState: prepared.expectedState, + nextState: prepared.nextState, + reasonCode: prepared.reasonCode, + retryMatchable: retryMatchable === 1, + updatedAt, + }, + }); + return decodeIntent( + db, + db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(prepared.intentId), + authority, + allowLoopbackHttp, + ); + }; + + const transitionInTransaction = (token, input) => { + const prepared = prepareTransition(input); + return store.within(token, ({ db, appendEvent }) => transitionPrepared( + db, + appendEvent, + prepared, + )); + }; + + const transition = (input) => { + const prepared = prepareTransition(input); + return store.transaction((token) => store.within( + token, + ({ db, appendEvent }) => transitionPrepared(db, appendEvent, prepared), + )); + }; + + const assertSessionMonetarySafety = (db, sessionId, authority) => { + const intentRows = db.prepare(`SELECT * FROM spend_intents + WHERE session_id = ? ORDER BY id`).all(sessionId); + for (const row of intentRows) { + const intent = decodeIntent(db, row, authority, allowLoopbackHttp); + const outcome = db.prepare( + 'SELECT intent_id FROM buyer_outcomes WHERE intent_id = ?', + ).get(intent.id); + if (intent.state !== 'terminal' || intent.retryMatchable || !outcome) { + fail('SESSION_MONETARY_AMBIGUITY', 'Spend Session has nonterminal intent authority'); + } + } + const openApproval = db.prepare(`SELECT approvals.id + FROM approvals JOIN spend_intents ON spend_intents.id = approvals.intent_id + WHERE spend_intents.session_id = ? + AND (approvals.decision = 'pending' + OR (approvals.decision = 'approved' + AND NOT (spend_intents.state = 'terminal' + AND spend_intents.retry_matchable = 0))) + LIMIT 1`).get(sessionId); + const heldBudget = db.prepare(`SELECT budget_reservations.intent_id + FROM budget_reservations + WHERE session_id = ? AND state IN ('reserved','unresolved') LIMIT 1`).get(sessionId); + const activePayment = db.prepare(`SELECT payment_attempts.intent_id + FROM payment_attempts JOIN spend_intents ON spend_intents.id = payment_attempts.intent_id + WHERE spend_intents.session_id = ? + AND payment_attempts.state IN ('reserved','signing','signed','retrying','unresolved') + LIMIT 1`).get(sessionId); + const paymentCandidate = db.prepare(`SELECT payment_reconciliation_candidates.id + FROM payment_reconciliation_candidates + JOIN spend_intents ON spend_intents.id = payment_reconciliation_candidates.intent_id + WHERE spend_intents.session_id = ? + AND payment_reconciliation_candidates.state = 'pending' LIMIT 1`).get(sessionId); + const executionCase = db.prepare(`SELECT execution_resolutions.intent_id + FROM execution_resolutions + JOIN spend_intents ON spend_intents.id = execution_resolutions.intent_id + WHERE spend_intents.session_id = ? + AND execution_resolutions.state != 'resolved' LIMIT 1`).get(sessionId); + const executionWithoutResolution = db.prepare(`SELECT execution_outcomes.intent_id + FROM execution_outcomes + JOIN spend_intents ON spend_intents.id = execution_outcomes.intent_id + LEFT JOIN payment_attempts + ON payment_attempts.intent_id = execution_outcomes.intent_id + LEFT JOIN budget_reservations + ON budget_reservations.intent_id = execution_outcomes.intent_id + LEFT JOIN execution_resolutions + ON execution_resolutions.intent_id = execution_outcomes.intent_id + WHERE spend_intents.session_id = ? + AND execution_outcomes.state IN ('failed','unknown') + AND (payment_attempts.state = 'settled' + OR budget_reservations.state = 'committed') + AND (execution_resolutions.intent_id IS NULL + OR execution_resolutions.state != 'resolved') + LIMIT 1`).get(sessionId); + const refundCase = db.prepare(`SELECT refunds.id FROM refunds + JOIN spend_intents ON spend_intents.id = refunds.intent_id + WHERE spend_intents.session_id = ? + AND refunds.state IN ('pending','unresolved') LIMIT 1`).get(sessionId); + const unresolvedReconciliation = db.prepare(`SELECT reconciliations.id + FROM reconciliations + JOIN spend_intents ON spend_intents.id = reconciliations.intent_id + WHERE spend_intents.session_id = ? + AND reconciliations.outcome = 'unresolved' + AND NOT EXISTS ( + SELECT 1 FROM reconciliations AS later + WHERE later.intent_id = reconciliations.intent_id + AND later.kind = reconciliations.kind + AND later.rowid > reconciliations.rowid + ) + LIMIT 1`).get(sessionId); + if (openApproval || heldBudget || activePayment || paymentCandidate || executionCase + || executionWithoutResolution || refundCase || unresolvedReconciliation) { + fail('SESSION_MONETARY_AMBIGUITY', 'Spend Session retains monetary ambiguity'); + } + }; + + const commandEvent = (db, sessionId, eventType) => ( + loadSessionCommandEvent(db, sessionId, eventType) + ); + + const closeBoundSessionInTransaction = (token, input) => { + const request = closedRecord(input, [ + 'sessionId', + 'expectedSessionHash', + ], [], 'SESSION_CLOSE_SCHEMA', 'session close'); + const sessionId = boundedToken(request.sessionId, 'session ID', 'SESSION_CLOSE_SCHEMA'); + const expectedSessionHash = canonicalHash( + request.expectedSessionHash, + 'expected session hash', + 'SESSION_CLOSE_SCHEMA', + ); + return store.within(token, ({ db, appendEvent }) => { + const authority = loadSessionAuthority(db, sessionId, { requireActive: false }); + if (authority.state === 'closed') { + const replay = commandEvent(db, sessionId, 'session.closed'); + if (!replay || replay.expectedSessionHash !== expectedSessionHash) { + fail('SESSION_CONFIRMATION_STALE', 'session close confirmation is stale'); + } + const closedSession = publicSession(authority); + if (replay.closedSessionHash !== closedSession.sessionHash + || replay.closedAt !== authority.closedAt) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'closed session replay binding changed'); + } + return frozenCopy({ closedSession }); + } + if (authority.state !== 'open' && authority.state !== 'policy_blocked') { + fail('SESSION_STATE', 'Spend Session cannot be closed'); + } + const current = publicSession(authority); + if (current.sessionHash !== expectedSessionHash) { + fail('SESSION_CONFIRMATION_STALE', 'session close confirmation is stale'); + } + assertSessionMonetarySafety(db, sessionId, authority); + const closedAt = canonicalTimestamp(now(), 'session closedAt'); + if (timestampIsBefore(closedAt, authority.lastSeenAt)) { + fail('SESSION_TIME', 'Kernel clock regressed during session close'); + } + const sessionUpdate = db.prepare(`UPDATE spend_sessions + SET state = 'closed', closed_at = ? + WHERE id = ? AND state = ? AND closed_at IS NULL`).run( + closedAt, + sessionId, + authority.state, + ); + if (sessionUpdate.changes !== 1n) { + fail('SESSION_CONFIRMATION_STALE', 'Spend Session close lost its race'); + } + const bindingUpdate = db.prepare(`UPDATE agent_session_bindings + SET state = 'closed', last_seen_at = ?, closed_at = ? + WHERE id = ? AND session_id = ? AND state = 'open' AND closed_at IS NULL`).run( + closedAt, + closedAt, + authority.bindingId, + sessionId, + ); + if (bindingUpdate.changes !== 1n) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'session binding close lost its race'); + } + const closedSession = publicSession(loadSessionAuthority(db, sessionId, { + requireActive: false, + validateEvents: false, + })); + appendEvent({ + entityType: 'session_binding', + entityId: authority.bindingId, + eventType: 'session.binding_closed', + data: { sessionId, closedAt, reasonCode: 'SESSION_CLOSED' }, + }); + appendEvent({ + entityType: 'spend_session', + entityId: sessionId, + eventType: 'session.closed', + data: { + expectedSessionHash, + closedSessionHash: closedSession.sessionHash, + closedAt, + }, + }); + const persistedClosedSession = publicSession(loadSessionAuthority(db, sessionId, { + requireActive: false, + })); + if (persistedClosedSession.sessionHash !== closedSession.sessionHash) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'closed session authority changed'); + } + return frozenCopy({ closedSession: persistedClosedSession }); + }); + }; + + const transitionBlockedSessionInTransaction = (token, input) => { + const request = closedRecord(input, [ + 'sessionId', + 'targetPolicyVersionId', + 'expectedSessionHash', + ], [], 'SESSION_TRANSITION_SCHEMA', 'session policy transition'); + const sessionId = boundedToken(request.sessionId, 'session ID', 'SESSION_TRANSITION_SCHEMA'); + const targetPolicyVersionId = boundedToken( + request.targetPolicyVersionId, + 'target policy version ID', + 'SESSION_TRANSITION_SCHEMA', + ); + const expectedSessionHash = canonicalHash( + request.expectedSessionHash, + 'expected session hash', + 'SESSION_TRANSITION_SCHEMA', + ); + return store.within(token, ({ db, appendEvent }) => { + const authority = loadSessionAuthority(db, sessionId, { requireActive: false }); + if (authority.state === 'closed') { + const replay = commandEvent(db, sessionId, 'session.policy_transitioned'); + if (!replay + || replay.expectedSessionHash !== expectedSessionHash + || replay.targetPolicyVersionId !== targetPolicyVersionId) { + fail('SESSION_CONFIRMATION_STALE', 'session transition confirmation is stale'); + } + const previousSession = publicSession(authority); + const replacementSession = loadHistoricalReplacementSession(db, replay, authority); + if (previousSession.sessionHash !== replay.closedSessionHash + || replay.transitionedAt !== authority.closedAt + || replacementSession.sessionHash !== replay.replacementSessionHash) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'session transition replay binding changed'); + } + return frozenCopy({ previousSession, replacementSession }); + } + if (authority.enrollment.state !== 'active') { + fail('AGENT_REVOKED', 'policy transition requires active enrollment'); + } + if (authority.state !== 'policy_blocked') { + fail('SESSION_STATE', 'policy transition requires a policy-blocked session'); + } + const previous = publicSession(authority); + if (previous.sessionHash !== expectedSessionHash) { + fail('SESSION_CONFIRMATION_STALE', 'session transition confirmation is stale'); + } + const activePolicyId = db.prepare( + 'SELECT value FROM metadata WHERE key = ?' + ).get('active_policy_id')?.value ?? null; + if (activePolicyId !== targetPolicyVersionId) { + fail('POLICY_NOT_ACTIVE', 'target PolicyVersion is not active'); + } + const targetPolicy = validatePolicyRow(db.prepare( + 'SELECT * FROM policy_versions WHERE id = ?', + ).get(targetPolicyVersionId)); + if (targetPolicy.policy.wallet !== authority.walletAddress) { + fail('POLICY_WALLET_MISMATCH', 'target PolicyVersion wallet changed'); + } + assertSessionMonetarySafety(db, sessionId, authority); + const transitionedAt = canonicalTimestamp(now(), 'session transitionedAt'); + if (timestampIsBefore(transitionedAt, authority.lastSeenAt)) { + fail('SESSION_TIME', 'Kernel clock regressed during policy transition'); + } + const closedSessionUpdate = db.prepare(`UPDATE spend_sessions + SET state = 'closed', closed_at = ? + WHERE id = ? AND state = 'policy_blocked' AND closed_at IS NULL`).run( + transitionedAt, + sessionId, + ); + if (closedSessionUpdate.changes !== 1n) { + fail('SESSION_CONFIRMATION_STALE', 'policy transition lost its race'); + } + const closedBindingUpdate = db.prepare(`UPDATE agent_session_bindings + SET state = 'closed', last_seen_at = ?, closed_at = ? + WHERE id = ? AND session_id = ? AND state = 'open' AND closed_at IS NULL`).run( + transitionedAt, + transitionedAt, + authority.bindingId, + sessionId, + ); + if (closedBindingUpdate.changes !== 1n) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'policy transition binding changed'); + } + const { + id: replacementId, + bindingId: replacementBindingId, + } = nextSessionIdentity(db); + db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at) + VALUES (?, ?, ?, ?, 'open', ?)`).run( + replacementId, + authority.adapterId, + authority.walletAddress, + targetPolicyVersionId, + transitionedAt, + ); + db.prepare(`INSERT INTO agent_session_bindings + (id, agent_instance_id, credential_digest, enrollment_hash, session_id, + state, created_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, 'open', ?, ?)`).run( + replacementBindingId, + authority.agentInstanceId, + authority.credentialDigest, + authority.enrollmentHash, + replacementId, + transitionedAt, + transitionedAt, + ); + const previousSession = publicSession(loadSessionAuthority(db, sessionId, { + requireActive: false, + validateEvents: false, + })); + const replacementSession = publicSession(loadSessionAuthority(db, replacementId, { + validateEvents: false, + })); + appendEvent({ + entityType: 'session_binding', + entityId: authority.bindingId, + eventType: 'session.binding_closed', + data: { sessionId, closedAt: transitionedAt, reasonCode: 'POLICY_SUPERSEDED' }, + }); + appendEvent({ + entityType: 'spend_session', + entityId: replacementId, + eventType: 'session.started', + data: { + adapterId: authority.adapterId, + enrollmentHash: authority.enrollmentHash, + policyVersionId: targetPolicyVersionId, + sessionHash: replacementSession.sessionHash, + walletAddress: authority.walletAddress, + createdAt: transitionedAt, + }, + }); + appendEvent({ + entityType: 'session_binding', + entityId: replacementBindingId, + eventType: 'session.binding_opened', + data: { + agentInstanceId: authority.agentInstanceId, + enrollmentHash: authority.enrollmentHash, + sessionId: replacementId, + createdAt: transitionedAt, + }, + }); + appendEvent({ + entityType: 'spend_session', + entityId: sessionId, + eventType: 'session.policy_transitioned', + data: { + expectedSessionHash, + targetPolicyVersionId, + closedSessionHash: previousSession.sessionHash, + replacementSessionId: replacementId, + replacementSessionHash: replacementSession.sessionHash, + transitionedAt, + }, + }); + const persistedPrevious = publicSession(loadSessionAuthority(db, sessionId, { + requireActive: false, + })); + const persistedReplacement = publicSession(loadSessionAuthority(db, replacementId)); + if (persistedPrevious.sessionHash !== previousSession.sessionHash + || persistedReplacement.sessionHash !== replacementSession.sessionHash) { + fail('SESSION_AUTHORITY_AMBIGUOUS', 'policy transition authority changed'); + } + return frozenCopy({ + previousSession: persistedPrevious, + replacementSession: persistedReplacement, + }); + }); + }; + + return Object.freeze({ + openOrResumeSession, + transitionBlockedSessionInTransaction, + closeBoundSessionInTransaction, + getSession, + captureIntent, + captureIntentInTransaction, + attachChallenge, + attachChallengeInTransaction, + transition, + transitionInTransaction, + getIntent, + matchRetry, + }); +} diff --git a/spikes/pi-wielder/src/kernel/projection-exporter.mjs b/spikes/pi-wielder/src/kernel/projection-exporter.mjs new file mode 100644 index 0000000..210fbc3 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/projection-exporter.mjs @@ -0,0 +1,1888 @@ +import crypto from 'node:crypto'; + +import { + decodePaymentSignatureHeader, + encodePaymentSignatureHeader, +} from '@x402/core/http'; + +import { + canonicalAtomic, + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; +import { receiptKeyId } from './receipt-signing.mjs'; +import { createBudgetLedger } from './budget-ledger.mjs'; +import { + validateChallengeProjection, + validatePolicyDocument, +} from './policy-engine.mjs'; + +const HASH = /^sha256:[0-9a-f]{64}$/; +const ADDRESS = /^0x[0-9a-f]{40}$/; +const REASON = /^[A-Z][A-Z0-9_]{0,127}$/; +const ECDSA_SIGNATURE = /^0x[0-9a-f]{130}$/; +const SECP256K1_N = BigInt('0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'); +const SECP256K1_HALF_N = SECP256K1_N / 2n; +const FORBIDDEN_EXPORT_TERMS = /prompt|body|authorization|payment.signature|private|secret|token|stack|file.path/i; +const FILESYSTEM_PATH = /(?:file:\/\/|\/(?:Users|home|private|tmp|var|etc|opt|root|proc|sys|dev)\/|[A-Za-z]:\\)/i; +const APPROVAL_STATES = Object.freeze([ + 'approved', + 'cancelled', + 'consumed', + 'denied', + 'expired', + 'pending', +]); +const ENFORCED_PROBES = Object.freeze({ + authorityDirectory: 'EACCES', + database: 'EACCES', + operatorToken: 'EACCES', + receiptKey: 'EACCES', + kernelEnvironment: 'EACCES', + agentCredential: 'READABLE', + releaseTreeWrite: 'EACCES', + dependencyTreeWrite: 'EACCES', + serviceArtifactsWrite: 'EACCES', + kernelEnvironmentParentWrite: 'EACCES', +}); + +function fail(code, message, options) { + throw new KernelError(code, message, options); +} + +function canonicalHash(value, label) { + if (typeof value !== 'string' || !HASH.test(value)) { + fail('PROJECTION_CORRUPTION', `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalAddress(value, label) { + if (typeof value !== 'string' || !ADDRESS.test(value)) { + fail('PROJECTION_CORRUPTION', `${label} must be one lowercase EVM address`); + } + return value; +} + +function canonicalReason(value, label) { + if (typeof value !== 'string' || !REASON.test(value)) { + fail('PROJECTION_CORRUPTION', `${label} must be one bounded reason code`); + } + return value; +} + +function safeInteger(value, label) { + const number = typeof value === 'bigint' ? Number(value) : value; + if (!Number.isSafeInteger(number) || number < 0) { + fail('PROJECTION_CORRUPTION', `${label} must be one nonnegative safe integer`); + } + return number; +} + +function persistedToken(value, label, maximum) { + try { + return canonicalToken(value, label, maximum); + } catch (error) { + fail('PROJECTION_CORRUPTION', `${label} is not canonical`, { cause: error }); + } +} + +function persistedTimestamp(value, label) { + try { + return canonicalTimestamp(value, label); + } catch (error) { + fail('PROJECTION_CORRUPTION', `${label} is not canonical`, { cause: error }); + } +} + +function persistedAtomic(value, label) { + try { + return canonicalAtomic(value, label); + } catch (error) { + fail('PROJECTION_CORRUPTION', `${label} is not canonical atomic text`, { cause: error }); + } +} + +function canonicalOrigin(value, label) { + let parsed; + try { parsed = new URL(value); } catch (error) { + fail('PROJECTION_CORRUPTION', `${label} is not a URL origin`, { cause: error }); + } + if (!['http:', 'https:'].includes(parsed.protocol) + || parsed.username || parsed.password || parsed.pathname !== '/' + || parsed.search || parsed.hash || parsed.origin !== value) { + fail('PROJECTION_CORRUPTION', `${label} must be one credential-free HTTP origin`); + } + return value; +} + +function parseCanonicalJson(text, label) { + if (typeof text !== 'string') { + fail('PROJECTION_CORRUPTION', `${label} must be canonical JSON text`); + } + let value; + try { value = JSON.parse(text); } catch (error) { + fail('PROJECTION_CORRUPTION', `${label} is invalid JSON`, { cause: error }); + } + if (canonicalJson(value) !== text) { + fail('PROJECTION_CORRUPTION', `${label} is not canonical JSON`); + } + return value; +} + +function hashPrivateLabel(domain, key, value) { + if (typeof value !== 'string') { + fail('PROJECTION_CORRUPTION', `${key} must be text before sanitization`); + } + return sha256(canonicalJson({ domain, [key]: value })); +} + +function assertSanitized(value, path = '$', seen = new WeakSet()) { + if (FORBIDDEN_EXPORT_TERMS.test(path)) { + fail('PROJECTION_SANITIZATION', 'projection field name is forbidden'); + } + if (typeof value === 'string') { + if (FORBIDDEN_EXPORT_TERMS.test(value) || FILESYSTEM_PATH.test(value)) { + fail('PROJECTION_SANITIZATION', 'projection contains forbidden source material'); + } + return; + } + if (!value || typeof value !== 'object') return; + if (seen.has(value)) fail('PROJECTION_SANITIZATION', 'projection contains a cycle'); + seen.add(value); + try { + for (const [key, child] of Object.entries(value)) { + if (FORBIDDEN_EXPORT_TERMS.test(key)) { + fail('PROJECTION_SANITIZATION', 'projection field name is forbidden'); + } + assertSanitized(child, `${path}.${key}`, seen); + } + } finally { + seen.delete(value); + } +} + +function validateStore(store) { + const methods = ['transaction', 'within']; + if (!store || typeof store !== 'object' + || methods.some((method) => typeof store[method] !== 'function')) { + throw new TypeError('projection exporter requires a readable Wallet Kernel store'); + } +} + +function validateReceipts(receipts) { + if (!receipts || typeof receipts !== 'object' + || typeof receipts.assertParityInTransaction !== 'function' + || typeof receipts.verify !== 'function') { + throw new TypeError('projection exporter requires a signed receipt repository'); + } +} + +function transactionAccess(db) { + return Object.freeze({ + one: (sql, parameters = []) => db.prepare(sql).get(...parameters), + all: (sql, parameters = []) => db.prepare(sql).all(...parameters), + }); +} + +function normalizeSigner(signer) { + if (!signer || typeof signer !== 'object' + || signer.algorithm !== 'Ed25519' + || typeof signer.signHash !== 'function' + || typeof signer.publicKeyPem !== 'string' + || typeof signer.keyId !== 'string') { + throw new TypeError('projection exporter requires an Ed25519 signer'); + } + let publicKey; + try { publicKey = crypto.createPublicKey(signer.publicKeyPem); } catch (error) { + throw new TypeError('projection exporter signer public key is invalid', { cause: error }); + } + if (publicKey.asymmetricKeyType !== 'ed25519' + || receiptKeyId(publicKey) !== signer.keyId) { + throw new TypeError('projection exporter signer key ID must match its Ed25519 SPKI'); + } + return Object.freeze({ + algorithm: 'Ed25519', + keyId: signer.keyId, + publicKey, + publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(), + signHash: (hashHex) => signer.signHash.call(signer, hashHex), + }); +} + +function validateSessionRow(session) { + persistedToken(session.id, 'Spend Session ID'); + persistedToken(session.adapter_id, 'wallet adapter ID'); + canonicalAddress(session.wallet_address, 'Spend Session wallet'); + persistedToken(session.policy_version_id, 'Spend Session policy version ID'); + const createdAt = persistedTimestamp(session.created_at, 'Spend Session createdAt'); + if (!['open', 'policy_blocked', 'closed'].includes(session.state)) { + fail('PROJECTION_CORRUPTION', 'Spend Session state is invalid'); + } + if ((session.state === 'closed') !== (session.closed_at !== null)) { + fail('PROJECTION_CORRUPTION', 'Spend Session close fields disagree'); + } + if (session.closed_at !== null) { + const closedAt = persistedTimestamp(session.closed_at, 'Spend Session closedAt'); + if (Date.parse(closedAt) < Date.parse(createdAt)) { + fail('PROJECTION_CORRUPTION', 'Spend Session closedAt predates creation'); + } + } + return session; +} + +function projectPolicies(access) { + const rows = access.all('SELECT * FROM policy_versions ORDER BY rowid'); + const byId = new Map(); + let previousHash = null; + for (const row of rows) { + persistedToken(row.id, 'PolicyVersion ID'); + const schemaVersion = safeInteger(row.schema_version, 'PolicyVersion schema version'); + if (schemaVersion !== 1) fail('PROJECTION_CORRUPTION', 'PolicyVersion schema is unsupported'); + const parsed = parseCanonicalJson(row.canonical_json, 'PolicyVersion document'); + let policy; + try { policy = validatePolicyDocument(parsed); } catch (error) { + if (error instanceof KernelError) { + fail('PROJECTION_CORRUPTION', 'PolicyVersion document is invalid'); + } + throw error; + } + const policyHash = canonicalHash(row.policy_hash, 'PolicyVersion hash'); + if (canonicalJson(policy) !== row.canonical_json + || sha256(row.canonical_json) !== policyHash + || row.predecessor_hash !== previousHash) { + fail('PROJECTION_CORRUPTION', 'PolicyVersion hash chain changed'); + } + const appliedAt = persistedTimestamp(row.applied_at, 'PolicyVersion appliedAt'); + const predecessor = rows[byId.size - 1]; + if (predecessor && Date.parse(appliedAt) < Date.parse(predecessor.applied_at)) { + fail('PROJECTION_CORRUPTION', 'PolicyVersion time regressed'); + } + byId.set(row.id, Object.freeze({ hash: policyHash, policy, row })); + previousHash = policyHash; + } + const activeId = access.one("SELECT value FROM metadata WHERE key = 'active_policy_id'")?.value; + const active = byId.get(activeId); + if (!active) fail('PROJECTION_CORRUPTION', 'active PolicyVersion is missing'); + return Object.freeze({ + active, + byId, + historyHashes: Object.freeze(rows.map((row) => row.policy_hash)), + }); +} + +function validateSessionBinding(access, session) { + const bindings = access.all( + 'SELECT * FROM agent_session_bindings WHERE session_id = ? ORDER BY rowid', + [session.id], + ); + if (bindings.length !== 1) { + fail('PROJECTION_CORRUPTION', 'Spend Session must have one exact agent binding'); + } + const binding = bindings[0]; + persistedToken(binding.id, 'agent binding ID'); + if ((binding.state === 'open') !== (session.state !== 'closed') + || (binding.state === 'closed') !== (session.state === 'closed')) { + fail('PROJECTION_CORRUPTION', 'agent binding state disagrees with Spend Session'); + } + const enrollments = access.all( + 'SELECT * FROM agent_enrollments WHERE enrollment_hash = ? ORDER BY rowid', + [binding.enrollment_hash], + ); + if (enrollments.length !== 1) { + fail('PROJECTION_CORRUPTION', 'agent binding enrollment is missing or ambiguous'); + } + const enrollment = enrollments[0]; + const descriptor = { + schemaVersion: 1, + agentInstanceId: enrollment.agent_instance_id, + credentialDigest: canonicalHash(enrollment.credential_digest, 'agent credential digest'), + agentUid: enrollment.agent_uid, + agentGid: enrollment.agent_gid, + }; + if (!/^[1-9][0-9]*$/.test(descriptor.agentUid) + || !/^[1-9][0-9]*$/.test(descriptor.agentGid) + || sha256(canonicalJson(descriptor)) !== enrollment.enrollment_hash + || binding.agent_instance_id !== enrollment.agent_instance_id + || binding.credential_digest !== enrollment.credential_digest + || binding.enrollment_hash !== enrollment.enrollment_hash + || session.adapter_id !== `pi:${enrollment.agent_instance_id}`) { + fail('PROJECTION_CORRUPTION', 'agent enrollment binding changed'); + } + const enrollmentHash = canonicalHash(enrollment.enrollment_hash, 'agent enrollment hash'); + if (!['active', 'revoked'].includes(enrollment.state)) { + fail('PROJECTION_CORRUPTION', 'agent enrollment state is invalid'); + } + const sessionCreatedAt = persistedTimestamp(session.created_at, 'Spend Session createdAt'); + const bindingCreatedAt = persistedTimestamp(binding.created_at, 'agent binding createdAt'); + const lastSeenAt = persistedTimestamp(binding.last_seen_at, 'agent binding lastSeenAt'); + const bindingClosedAt = binding.closed_at === null + ? null + : persistedTimestamp(binding.closed_at, 'agent binding closedAt'); + if (bindingCreatedAt !== sessionCreatedAt + || Date.parse(lastSeenAt) < Date.parse(bindingCreatedAt) + || (session.state === 'closed' + && (bindingClosedAt !== session.closed_at || lastSeenAt !== session.closed_at)) + || (session.state !== 'closed' && bindingClosedAt !== null)) { + fail('PROJECTION_CORRUPTION', 'agent binding chronology changed'); + } + canonicalHash(enrollment.enrolled_by_operator_hash, 'enrollment operator hash'); + const enrolledAt = persistedTimestamp(enrollment.enrolled_at, 'agent enrolledAt'); + if (Date.parse(bindingCreatedAt) < Date.parse(enrolledAt)) { + fail('PROJECTION_CORRUPTION', 'agent binding predates enrollment'); + } + if (enrollment.state === 'active') { + if (enrollment.revoked_by_operator_hash !== null || enrollment.revoked_at !== null) { + fail('PROJECTION_CORRUPTION', 'active enrollment contains revocation fields'); + } + } else { + canonicalHash(enrollment.revoked_by_operator_hash, 'revocation operator hash'); + const revokedAt = persistedTimestamp(enrollment.revoked_at, 'agent revokedAt'); + if (Date.parse(revokedAt) < Date.parse(enrolledAt)) { + fail('PROJECTION_CORRUPTION', 'agent revocation predates enrollment'); + } + } + const eventRows = access.all(`SELECT data_json FROM events + WHERE entity_type = 'agent_enrollment' AND entity_id = ? + AND event_type = 'agent.enrolled' ORDER BY sequence`, [enrollment.agent_instance_id]); + if (eventRows.length !== 1) { + fail('PROJECTION_CORRUPTION', 'agent enrollment creation event is missing or ambiguous'); + } + const event = exactRecord( + parseCanonicalJson(eventRows[0].data_json, 'agent enrollment event'), + [ + 'enrollmentHash', 'credentialDigest', 'agentUid', 'agentGid', + 'operatorIdHash', 'isolation', 'enrolledAt', + ], + [], + 'PROJECTION_CORRUPTION', + 'agent enrollment event', + ); + if (event.enrollmentHash !== enrollmentHash + || event.credentialDigest !== enrollment.credential_digest + || event.agentUid !== enrollment.agent_uid + || event.agentGid !== enrollment.agent_gid + || canonicalHash(event.operatorIdHash, 'enrollment event operator hash') + !== enrollment.enrolled_by_operator_hash + || persistedTimestamp(event.enrolledAt, 'enrollment event enrolledAt') !== enrolledAt + || !['simulated', 'pending_verification'].includes(event.isolation)) { + fail('PROJECTION_CORRUPTION', 'agent enrollment event binding changed'); + } + return Object.freeze({ binding, enrollment, enrollmentHash, isolationLabel: event.isolation }); +} + +function projectEnrollment(access, session, issuedAt, sessionAuthority = null) { + const authority = sessionAuthority ?? validateSessionBinding(access, session); + const { enrollment, enrollmentHash } = authority; + + const current = access.all( + "SELECT * FROM isolation_attestations WHERE state = 'current' ORDER BY rowid", + ); + if (current.length > 1 + || current.some((row) => row.enrollment_hash !== enrollmentHash)) { + fail('PROJECTION_CORRUPTION', 'current isolation attestation is ambiguous or misbound'); + } + let isolation = { status: authority.isolationLabel, preflightDigest: null }; + if (current.length === 1) { + if (enrollment.state !== 'active' || authority.isolationLabel !== 'pending_verification') { + fail( + 'PROJECTION_CORRUPTION', + 'only an active live enrollment can claim a current isolation report', + ); + } + const attestation = current[0]; + persistedToken(attestation.id, 'isolation attestation ID'); + const reportHash = canonicalHash(attestation.report_hash, 'isolation preflight digest'); + canonicalHash( + attestation.imported_by_operator_hash, + 'isolation attestation operator hash', + ); + if (attestation.superseded_at !== null) { + fail('PROJECTION_CORRUPTION', 'current isolation attestation is superseded'); + } + const { fresh, report } = validateEnforcedIsolationReport( + attestation, + enrollment, + issuedAt, + ); + if (sha256(canonicalJson(report)) !== reportHash) { + fail('PROJECTION_CORRUPTION', 'isolation preflight digest changed'); + } + isolation = { + status: fresh ? 'enforced' : authority.isolationLabel, + preflightDigest: reportHash, + }; + } + return Object.freeze({ + agentEnrollment: frozenCopy({ + enrollmentHash, + identityHash: sha256(canonicalJson({ + domain: 'wallet-kernel.agent-identity.v1', + agentUid: enrollment.agent_uid, + agentGid: enrollment.agent_gid, + })), + state: enrollment.state, + }), + isolation: frozenCopy(isolation), + }); +} + +function validateEnforcedIsolationReport(attestation, enrollment, issuedAt) { + const report = exactRecord( + parseCanonicalJson(attestation.report_json, 'current isolation report'), + [ + 'schemaVersion', 'enrollmentHash', 'kernelUid', 'kernelGid', 'agentUid', 'agentGid', + 'authorityMetadataHash', 'credentialMetadataHash', 'releaseManifestHash', + 'releaseTreeHash', 'nodeExecutableHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', 'environmentMetadataHash', 'probeResults', + 'probedAt', 'expiresAt', + ], + [], + 'PROJECTION_CORRUPTION', + 'current isolation report', + ); + if (report.schemaVersion !== 1 + || report.enrollmentHash !== enrollment.enrollment_hash + || report.agentUid !== enrollment.agent_uid + || report.agentGid !== enrollment.agent_gid + || !/^[1-9][0-9]*$/.test(report.kernelUid) + || !/^[1-9][0-9]*$/.test(report.kernelGid) + || report.kernelUid === report.agentUid) { + fail('PROJECTION_CORRUPTION', 'current isolation report identity binding is invalid'); + } + for (const name of [ + 'authorityMetadataHash', 'credentialMetadataHash', 'releaseManifestHash', + 'releaseTreeHash', 'nodeExecutableHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', 'environmentMetadataHash', + ]) canonicalHash(report[name], `isolation report ${name}`); + const probes = exactRecord( + report.probeResults, + Object.keys(ENFORCED_PROBES), + [], + 'PROJECTION_CORRUPTION', + 'isolation probe results', + ); + if (Object.entries(ENFORCED_PROBES).some(([name, result]) => probes[name] !== result)) { + fail('PROJECTION_CORRUPTION', 'current isolation report does not prove enforced isolation'); + } + const probedAt = persistedTimestamp(report.probedAt, 'isolation report probedAt'); + const expiresAt = persistedTimestamp(report.expiresAt, 'isolation report expiresAt'); + const importedAt = persistedTimestamp( + attestation.imported_at, + 'isolation attestation importedAt', + ); + if (attestation.probed_at !== probedAt + || attestation.expires_at !== expiresAt + || Date.parse(expiresAt) <= Date.parse(probedAt) + || Date.parse(expiresAt) - Date.parse(probedAt) > 15 * 60 * 1_000 + || Date.parse(importedAt) < Date.parse(probedAt) + || Date.parse(importedAt) >= Date.parse(expiresAt) + || Date.parse(importedAt) > Date.parse(issuedAt) + ) { + fail('PROJECTION_CORRUPTION', 'current isolation report has invalid timestamps'); + } + return Object.freeze({ fresh: Date.parse(issuedAt) < Date.parse(expiresAt), report }); +} + +function canonicalTransactionId(value, label) { + if (typeof value !== 'string' || !/^0x[0-9a-f]{64}$/.test(value)) { + fail('PROJECTION_CORRUPTION', `${label} must be one canonical EVM transaction hash`); + } + return value; +} + +function validateSessions(access, policies) { + const rows = access.all('SELECT * FROM spend_sessions ORDER BY rowid'); + const byId = new Map(); + for (const raw of rows) { + const session = validateSessionRow(raw); + const policyVersion = policies.byId.get(session.policy_version_id); + if (!policyVersion + || session.wallet_address !== policyVersion.policy.wallet + || Date.parse(session.created_at) < Date.parse(policyVersion.row.applied_at)) { + fail('PROJECTION_CORRUPTION', 'Spend Session is detached from its PolicyVersion wallet'); + } + const pinsActivePolicy = session.policy_version_id === policies.active.row.id; + if ((session.state === 'open' && !pinsActivePolicy) + || (session.state === 'policy_blocked' && pinsActivePolicy)) { + fail('PROJECTION_CORRUPTION', 'Spend Session state disagrees with active policy lifecycle'); + } + const bindingAuthority = validateSessionBinding(access, session); + byId.set(session.id, Object.freeze({ session, policyVersion, ...bindingAuthority })); + } + return byId; +} + +function validateDecision(access, intent, sessionAuthority) { + const rows = access.all( + 'SELECT * FROM policy_decisions WHERE intent_id = ? ORDER BY rowid', + [intent.id], + ); + if (rows.length > 1) { + fail('PROJECTION_CORRUPTION', 'Spend Intent PolicyDecision is ambiguous'); + } + if (rows.length === 0) return Object.freeze({ decision: null, projection: null }); + const decision = rows[0]; + if (decision.intent_id !== intent.id + || decision.policy_version_id !== sessionAuthority.policyVersion.row.id + || !['allow', 'approval_required', 'deny'].includes(decision.decision)) { + fail('PROJECTION_CORRUPTION', 'PolicyDecision authority was rebound'); + } + canonicalReason(decision.reason_code, 'PolicyDecision reason'); + const challengeHash = canonicalHash(decision.challenge_hash, 'PolicyDecision challenge hash'); + const amount = persistedAtomic(decision.amount_ceiling_atomic, 'PolicyDecision amount ceiling'); + const decidedAt = persistedTimestamp(decision.decided_at, 'PolicyDecision decidedAt'); + if (intent.challenge_projection_json === null + || intent.challenge_hash === null + || intent.challenge_received_at === null) { + fail('PROJECTION_CORRUPTION', 'PolicyDecision has no complete challenge authority'); + } + const parsed = parseCanonicalJson( + intent.challenge_projection_json, + 'Spend Intent challenge projection', + ); + let projection; + try { + projection = validateChallengeProjection(parsed); + } catch (error) { + fail('PROJECTION_CORRUPTION', 'Spend Intent challenge projection is invalid', { cause: error }); + } + const projectionJson = canonicalJson(projection); + const receivedAt = persistedTimestamp( + intent.challenge_received_at, + 'Spend Intent challenge receivedAt', + ); + if (projectionJson !== intent.challenge_projection_json + || sha256(projectionJson) !== intent.challenge_hash + || intent.challenge_hash !== challengeHash + || Date.parse(receivedAt) < Date.parse(intent.created_at) + || Date.parse(decidedAt) < Date.parse(receivedAt) + || Date.parse(decidedAt) > Date.parse(intent.updated_at)) { + fail('PROJECTION_CORRUPTION', 'PolicyDecision challenge or chronology changed'); + } + const bothQuoteFieldsNull = decision.accepted_index === null && decision.quote_id === null; + const bothQuoteFieldsPresent = decision.accepted_index !== null && decision.quote_id !== null; + if (!bothQuoteFieldsNull && !bothQuoteFieldsPresent) { + fail('PROJECTION_CORRUPTION', 'PolicyDecision quote fields are partial'); + } + if (bothQuoteFieldsNull) { + if (decision.decision !== 'deny' || amount.value !== 0n) { + fail('PROJECTION_CORRUPTION', 'spend-authorizing PolicyDecision has no selected quote'); + } + } else { + const acceptedIndex = safeInteger( + decision.accepted_index, + 'PolicyDecision accepted index', + ); + canonicalHash(decision.quote_id, 'PolicyDecision quote ID'); + if (decision.quote_id !== sha256(canonicalJson({ + challengeHash, + acceptedIndex, + }))) { + fail('PROJECTION_CORRUPTION', 'PolicyDecision quote ID lost its canonical binding'); + } + const selected = projection.accepts[acceptedIndex]; + const policy = sessionAuthority.policyVersion.policy; + const seller = policy.sellers.find((candidate) => candidate.origin === intent.seller_origin); + const amountValue = amount.value; + const automatic = seller ? BigInt(seller.autoApproveAtomic) : 0n; + const human = seller ? BigInt(seller.humanApproveAtomic) : 0n; + const perRequest = seller ? BigInt(seller.perRequestMaxAtomic) : 0n; + if (!selected + || !seller + || selected.amount !== amount.text + || selected.network !== policy.network + || selected.asset !== policy.asset + || selected.payTo !== seller.payTo + || projection.resource.urlHash !== intent.request_url_hash + || !policy.methods.includes(intent.method) + || !seller.pathPrefixes.some((prefix) => intent.resource_path.startsWith(prefix)) + || (decision.decision === 'allow' && (decision.reason_code !== 'WITHIN_AUTO_LIMIT' + || amountValue > automatic)) + || (decision.decision === 'approval_required' + && (decision.reason_code !== 'HUMAN_APPROVAL_REQUIRED' + || amountValue <= automatic || amountValue > human || amountValue > perRequest))) { + fail('PROJECTION_CORRUPTION', 'PolicyDecision selected quote lost policy authority'); + } + if (decision.decision !== 'deny' && amount.value === 0n) { + fail('PROJECTION_CORRUPTION', 'spend-authorizing PolicyDecision has a zero ceiling'); + } + } + return Object.freeze({ decision, projection }); +} + +function validateOutcome(access, intent) { + const rows = access.all( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ? ORDER BY rowid', + [intent.id], + ); + if (rows.length > 1) fail('PROJECTION_CORRUPTION', 'BuyerOutcome is ambiguous'); + if (rows.length === 0) return null; + const outcome = rows[0]; + const allowed = new Set([ + 'completed', 'upstream_failed', 'payment_denied', 'payment_failed', + 'payment_unresolved', 'payment_rejected', 'execution_failed', + 'execution_unknown', 'refunded', + ]); + const revision = safeInteger(outcome.revision, 'BuyerOutcome revision'); + const recordedAt = persistedTimestamp(outcome.recorded_at, 'BuyerOutcome recordedAt'); + if (outcome.intent_id !== intent.id + || !allowed.has(outcome.status) + || revision < 1 + || Date.parse(recordedAt) < Date.parse(intent.created_at)) { + fail('PROJECTION_CORRUPTION', 'BuyerOutcome is invalid or detached'); + } + canonicalReason(outcome.reason_code, 'BuyerOutcome reason'); + return outcome; +} + +function validateIntents(access, sessions) { + const rows = access.all('SELECT * FROM spend_intents ORDER BY rowid'); + const byId = new Map(); + const states = new Set([ + 'captured', 'challenged', 'approval_pending', 'authorized', 'reserved', + 'signing', 'signed', 'retrying', 'unresolved', 'terminal', + ]); + for (const intent of rows) { + persistedToken(intent.id, 'Spend Intent ID'); + persistedToken(intent.request_id, 'Spend Intent request ID'); + persistedToken(intent.session_id, 'Spend Intent session ID'); + persistedToken(intent.route_id, 'Spend Intent route ID'); + persistedToken(intent.method, 'Spend Intent method'); + persistedToken(intent.purpose_label, 'Spend Intent purpose label'); + persistedToken(intent.correlation_id, 'Spend Intent correlation ID'); + persistedToken(intent.idempotency_key, 'Spend Intent idempotency key'); + const sessionAuthority = sessions.get(intent.session_id); + if (!sessionAuthority + || intent.wallet_address !== sessionAuthority.session.wallet_address + || intent.enrollment_hash !== sessionAuthority.enrollmentHash) { + fail('PROJECTION_CORRUPTION', 'Spend Intent wallet or session authority was rebound'); + } + canonicalAddress(intent.wallet_address, 'Spend Intent wallet'); + canonicalHash(intent.enrollment_hash, 'Spend Intent enrollment hash'); + canonicalHash(intent.request_url_hash, 'Spend Intent request URL hash'); + canonicalHash(intent.body_hash, 'Spend Intent body hash'); + canonicalHash(intent.header_allowlist_hash, 'Spend Intent header allowlist hash'); + canonicalHash(intent.ordinary_fingerprint, 'Spend Intent ordinary fingerprint'); + canonicalHash(intent.intent_hash, 'Spend Intent hash'); + canonicalOrigin(intent.seller_origin, 'Spend Intent seller origin'); + if (typeof intent.resource_path !== 'string' + || !intent.resource_path.startsWith('/') + || /[\u0000-\u001f\u007f]/.test(intent.resource_path) + || !states.has(intent.state) + || safeInteger(intent.retry_matchable, 'Spend Intent retry flag') > 1) { + fail('PROJECTION_CORRUPTION', 'Spend Intent persisted shape is invalid'); + } + const createdAt = persistedTimestamp(intent.created_at, 'Spend Intent createdAt'); + const updatedAt = persistedTimestamp(intent.updated_at, 'Spend Intent updatedAt'); + if (Date.parse(createdAt) < Date.parse(sessionAuthority.session.created_at) + || Date.parse(updatedAt) < Date.parse(createdAt)) { + fail('PROJECTION_CORRUPTION', 'Spend Intent lifecycle time regressed'); + } + const challengeFields = [ + intent.challenge_projection_json, + intent.challenge_hash, + intent.challenge_received_at, + ]; + if (!challengeFields.every((value) => value === null) + && !challengeFields.every((value) => value !== null)) { + fail('PROJECTION_CORRUPTION', 'Spend Intent challenge fields are partial'); + } + if (intent.challenge_hash !== null) canonicalHash(intent.challenge_hash, 'Spend Intent challenge hash'); + const { decision, projection } = validateDecision(access, intent, sessionAuthority); + const outcome = validateOutcome(access, intent); + byId.set(intent.id, Object.freeze({ + intent, + sessionAuthority, + decision, + projection, + outcome, + })); + } + return byId; +} + +function validateCanonicalPaymentPayload(row, authority) { + const payment = exactRecord( + parseCanonicalJson(row.payment_payload_json, 'PaymentAttempt payload'), + ['x402Version', 'resource', 'accepted', 'payload'], + [], + 'PROJECTION_CORRUPTION', + 'PaymentAttempt payload', + ); + const resource = exactRecord( + payment.resource, + ['url', 'description', 'mimeType'], + [], + 'PROJECTION_CORRUPTION', + 'PaymentAttempt resource', + ); + const accepted = exactRecord( + payment.accepted, + ['scheme', 'network', 'asset', 'amount', 'payTo', 'maxTimeoutSeconds', 'extra'], + [], + 'PROJECTION_CORRUPTION', + 'PaymentAttempt accepted offer', + ); + exactRecord( + accepted.extra, + ['name', 'version'], + ['assetTransferMethod'], + 'PROJECTION_CORRUPTION', + 'PaymentAttempt accepted extra', + ); + const body = exactRecord( + payment.payload, + ['signature', 'authorization'], + [], + 'PROJECTION_CORRUPTION', + 'PaymentAttempt signed payload', + ); + const authorization = exactRecord( + body.authorization, + ['from', 'to', 'value', 'validAfter', 'validBefore', 'nonce'], + [], + 'PROJECTION_CORRUPTION', + 'PaymentAttempt EIP-3009 authorization', + ); + let resourceUrl; + try { resourceUrl = new URL(resource.url); } catch (error) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt resource URL is invalid', { cause: error }); + } + const selected = authority.projection.accepts[row.accepted_index]; + if (payment.x402Version !== 2 + || resourceUrl.href !== resource.url + || resourceUrl.origin !== authority.intent.seller_origin + || resourceUrl.pathname !== authority.intent.resource_path + || resourceUrl.username || resourceUrl.password || resourceUrl.hash + || sha256(resource.url) !== authority.intent.request_url_hash + || resource.description !== authority.projection.resource.description + || resource.mimeType !== authority.projection.resource.mimeType + || canonicalJson(accepted) !== canonicalJson(selected) + || authorization.from !== authority.intent.wallet_address + || authorization.to !== selected.payTo + || authorization.value !== authority.decision.amount_ceiling_atomic + || authorization.nonce !== row.nonce + || authorization.validAfter !== row.valid_after + || authorization.validBefore !== row.valid_before) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt payload binding changed'); + } + + const signature = body.signature; + if (typeof signature !== 'string' || !ECDSA_SIGNATURE.test(signature)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt signature is not canonical EOA bytes'); + } + const r = BigInt(`0x${signature.slice(2, 66)}`); + const s = BigInt(`0x${signature.slice(66, 130)}`); + const v = Number.parseInt(signature.slice(130), 16); + if (r <= 0n || r >= SECP256K1_N || s <= 0n || s > SECP256K1_HALF_N + || ![27, 28].includes(v)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt signature is not canonical low-s EOA form'); + } + + let decoded; + try { decoded = decodePaymentSignatureHeader(row.payment_header); } catch (error) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt header is not canonical x402', { cause: error }); + } + if (canonicalJson(decoded) !== row.payment_payload_json + || encodePaymentSignatureHeader(decoded) !== row.payment_header + || Buffer.from(row.payment_header, 'ascii').toString('ascii') !== row.payment_header + || sha256(Buffer.from(row.payment_header, 'ascii')) !== row.payment_hash) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt header lost its canonical payload binding'); + } + return payment; +} + +function validateAttemptEvents(access, row, authority) { + const events = access.all(`SELECT entity_id, event_type, data_json FROM events + WHERE entity_type = 'payment_attempt' AND (entity_id = ? OR entity_id = ?) + ORDER BY sequence`, [row.id, row.intent_id]); + const expected = [{ + entityId: row.id, + eventType: 'payment.reserved', + data: { + intentId: row.intent_id, + policyVersionId: authority.decision.policy_version_id, + quoteId: row.quote_id, + createdAt: row.created_at, + }, + }]; + if (row.signing_claimed_at !== null) { + expected.push({ + entityId: row.intent_id, + eventType: 'payment.signing_claimed', + data: { + nonce: row.nonce, + validAfter: row.valid_after, + validBefore: row.valid_before, + signingClaimedAt: row.signing_claimed_at, + }, + }); + } + if (row.signed_at !== null) { + expected.push({ + entityId: row.intent_id, + eventType: 'payment.signed', + data: { paymentHash: row.payment_hash, signedAt: row.signed_at }, + }); + } + if (row.retry_started_at !== null) { + expected.push({ + entityId: row.intent_id, + eventType: 'payment.retrying', + data: { retryStartedAt: row.retry_started_at }, + }); + } + const holdRows = access.all(`SELECT data_json FROM events + WHERE entity_type = 'budget_reservation' AND entity_id = ? + AND event_type = 'budget.held_unresolved' ORDER BY sequence`, [row.intent_id]); + if (holdRows.length > 1) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt unresolved provenance is ambiguous'); + } + const hold = holdRows.length === 1 + ? parseCanonicalJson(holdRows[0].data_json, 'budget unresolved event') + : null; + if (row.state === 'unresolved' || hold !== null) { + expected.push({ + entityId: row.intent_id, + eventType: 'payment.unresolved', + data: { + reasonCode: row.state === 'unresolved' ? row.reason_code : hold?.reasonCode, + recordedAt: row.state === 'unresolved' ? row.updated_at : hold?.heldAt, + }, + }); + } + const actual = events.map((event) => ({ + entityId: event.entity_id, + eventType: event.event_type, + data: parseCanonicalJson(event.data_json, 'PaymentAttempt transition event'), + })); + if (canonicalJson(actual) !== canonicalJson(expected)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt transition provenance changed'); + } +} + +function validateSettlement(row, authority) { + if (row.reason_code === 'TRUSTED_RECONCILIATION') { + const proof = exactRecord( + parseCanonicalJson(row.settlement_json, 'trusted payment settlement'), + ['kind', 'transactionId', 'rpcProofHash', 'localAttemptHash'], + [], + 'PROJECTION_CORRUPTION', + 'trusted payment settlement', + ); + canonicalHash(proof.rpcProofHash, 'trusted settlement RPC proof hash'); + canonicalHash(proof.localAttemptHash, 'trusted settlement local attempt hash'); + if (proof.kind !== 'settled_transfer' || proof.transactionId !== row.transaction_id) { + fail('PROJECTION_CORRUPTION', 'trusted payment settlement binding changed'); + } + return proof; + } + const settlement = exactRecord( + parseCanonicalJson(row.settlement_json, 'PaymentAttempt settlement'), + ['source', 'headerHash', 'success', 'transaction', 'network', 'payer', 'paymentHash'], + ['amountAtomic'], + 'PROJECTION_CORRUPTION', + 'PaymentAttempt settlement', + ); + canonicalHash(settlement.headerHash, 'PaymentAttempt settlement header hash'); + const amountMatches = !Object.hasOwn(settlement, 'amountAtomic') + || settlement.amountAtomic === authority.decision.amount_ceiling_atomic; + if (settlement.source !== 'x402-payment-response' + || settlement.success !== true + || settlement.transaction !== row.transaction_id + || settlement.network !== authority.projection.accepts[row.accepted_index].network + || settlement.payer !== authority.intent.wallet_address + || settlement.paymentHash !== row.payment_hash + || !amountMatches) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt settlement binding changed'); + } + return settlement; +} + +function validateAttemptRow(access, row, authority) { + persistedToken(row.id, 'PaymentAttempt ID'); + if (row.intent_id !== authority.intent.id + || !['reserved', 'signing', 'signed', 'retrying', 'unresolved', 'settled', 'rejected'] + .includes(row.state) + || row.payment_required_projection_json !== authority.intent.challenge_projection_json + || safeInteger(row.accepted_index, 'PaymentAttempt accepted index') + !== safeInteger(authority.decision.accepted_index, 'PolicyDecision accepted index') + || row.quote_id !== authority.decision.quote_id) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt challenge authority was rebound'); + } + persistedToken(row.quote_id, 'PaymentAttempt quote ID'); + parseCanonicalJson(row.payment_required_projection_json, 'PaymentAttempt challenge projection'); + const createdAt = persistedTimestamp(row.created_at, 'PaymentAttempt createdAt'); + const updatedAt = persistedTimestamp(row.updated_at, 'PaymentAttempt updatedAt'); + if (Date.parse(createdAt) < Date.parse(authority.decision.decided_at) + || Date.parse(updatedAt) < Date.parse(createdAt)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt chronology changed'); + } + if (row.reason_code !== null) canonicalReason(row.reason_code, 'PaymentAttempt reason'); + + const claimFields = [row.nonce, row.valid_after, row.valid_before, row.signing_claimed_at]; + const signedFields = [row.payment_payload_json, row.payment_header, row.payment_hash, row.signed_at]; + const settlementFields = [row.settlement_json, row.transaction_id, row.settled_at]; + const allOrNone = (fields) => fields.every((value) => value !== null) + || fields.every((value) => value === null); + if (!allOrNone(claimFields) || !allOrNone(signedFields) || !allOrNone(settlementFields)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt durable authority fields are partial'); + } + const hasClaim = claimFields[0] !== null; + const hasSigned = signedFields[0] !== null; + const hasSettlement = settlementFields[0] !== null; + let signingClaimedAt = null; + let signedAt = null; + let retryStartedAt = null; + let settledAt = null; + if (hasClaim) { + if (!/^0x[0-9a-f]{64}$/.test(row.nonce)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt nonce is not canonical'); + } + const validAfter = persistedAtomic(row.valid_after, 'PaymentAttempt validAfter'); + const validBefore = persistedAtomic(row.valid_before, 'PaymentAttempt validBefore'); + signingClaimedAt = persistedTimestamp( + row.signing_claimed_at, + 'PaymentAttempt signing claimedAt', + ); + if (validBefore.value <= validAfter.value + || validAfter.text !== '0' + || Date.parse(signingClaimedAt) < Date.parse(createdAt) + || Date.parse(signingClaimedAt) > Date.parse(updatedAt)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt signing claim is invalid'); + } + const selected = authority.projection.accepts[row.accepted_index]; + const approval = access.one('SELECT expires_at FROM approvals WHERE intent_id = ?', [row.intent_id]); + const expectedValidBefore = Math.min( + Math.floor(Date.parse(signingClaimedAt) / 1_000) + selected.maxTimeoutSeconds, + Math.floor((Date.parse(authority.intent.challenge_received_at) + + authority.sessionAuthority.policyVersion.policy.challengeMaxAgeMs) / 1_000), + approval === undefined + ? Number.POSITIVE_INFINITY + : Math.floor(Date.parse(approval.expires_at) / 1_000), + ); + if (!Number.isSafeInteger(expectedValidBefore) + || row.valid_before !== String(expectedValidBefore)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt authorization window changed'); + } + } + if (hasSigned) { + if (!hasClaim || typeof row.payment_header !== 'string' || row.payment_header.length === 0 + || Buffer.byteLength(row.payment_header, 'utf8') > 16_384) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt signed bytes are invalid'); + } + canonicalHash(row.payment_hash, 'PaymentAttempt hash'); + signedAt = persistedTimestamp(row.signed_at, 'PaymentAttempt signedAt'); + if (sha256(row.payment_header) !== row.payment_hash + || Date.parse(signedAt) < Date.parse(signingClaimedAt) + || Date.parse(signedAt) > Date.parse(updatedAt)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt signed bytes lost their binding'); + } + validateCanonicalPaymentPayload(row, authority); + } + if (row.retry_started_at !== null) { + retryStartedAt = persistedTimestamp(row.retry_started_at, 'PaymentAttempt retry startedAt'); + if (!hasSigned + || Date.parse(retryStartedAt) < Date.parse(signedAt) + || Date.parse(retryStartedAt) > Date.parse(updatedAt)) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt retry chronology is invalid'); + } + } + if (hasSettlement) { + if (!hasSigned || retryStartedAt === null) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt settlement has no paid retry'); + } + canonicalTransactionId(row.transaction_id, 'PaymentAttempt transaction'); + settledAt = persistedTimestamp(row.settled_at, 'PaymentAttempt settledAt'); + if (Date.parse(settledAt) < Date.parse(retryStartedAt) + || settledAt !== updatedAt) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt settlement chronology is invalid'); + } + validateSettlement(row, authority); + } + const shapeIsLegal = { + reserved: !hasClaim && !hasSigned && row.retry_started_at === null && !hasSettlement, + signing: hasClaim && !hasSigned && row.retry_started_at === null && !hasSettlement, + signed: hasClaim && hasSigned && row.retry_started_at === null && !hasSettlement, + retrying: hasClaim && hasSigned && retryStartedAt !== null && !hasSettlement, + unresolved: hasClaim && !hasSettlement, + settled: hasClaim && hasSigned && retryStartedAt !== null && hasSettlement, + rejected: !hasSettlement && row.reason_code !== null, + }[row.state]; + const reasonIsLegal = (['reserved', 'signing', 'signed', 'retrying'].includes(row.state) + && row.reason_code === null) + || (['unresolved', 'rejected'].includes(row.state) && row.reason_code !== null) + || (row.state === 'settled' + && [null, 'TRUSTED_RECONCILIATION'].includes(row.reason_code)); + if (!shapeIsLegal || !reasonIsLegal) { + fail('PROJECTION_CORRUPTION', 'PaymentAttempt state owns illegal fields'); + } + validateAttemptEvents(access, row, authority); + return row; +} + +function validateExecutionRows(access, budget, authority, attempt) { + const executionRows = access.all( + 'SELECT * FROM execution_outcomes WHERE intent_id = ? ORDER BY rowid', + [budget.intent_id], + ); + const resolutionRows = access.all( + 'SELECT * FROM execution_resolutions WHERE intent_id = ? ORDER BY rowid', + [budget.intent_id], + ); + if (executionRows.length > 1 || resolutionRows.length > 1) { + fail('PROJECTION_CORRUPTION', 'execution authority is ambiguous'); + } + const execution = executionRows[0] ?? null; + const resolution = resolutionRows[0] ?? null; + if (execution) { + if (execution.intent_id !== budget.intent_id + || !['succeeded', 'failed', 'unknown'].includes(execution.state)) { + fail('PROJECTION_CORRUPTION', 'execution outcome is invalid'); + } + const status = execution.http_status === null + ? null + : safeInteger(execution.http_status, 'execution HTTP status'); + if (status !== null && (status < 100 || status > 599)) { + fail('PROJECTION_CORRUPTION', 'execution HTTP status is invalid'); + } + if (execution.response_hash !== null) canonicalHash(execution.response_hash, 'execution response hash'); + parseCanonicalJson(execution.metadata_json, 'execution metadata'); + const recordedAt = persistedTimestamp(execution.recorded_at, 'execution recordedAt'); + if (attempt?.settled_at !== null + && Date.parse(recordedAt) < Date.parse(attempt.settled_at)) { + fail('PROJECTION_CORRUPTION', 'execution predates payment settlement'); + } + if ((execution.state === 'succeeded' + && (status === null || status < 200 || status > 299 || execution.response_hash === null)) + || (execution.state === 'failed' && (status === null || status < 300)) + || (execution.state === 'unknown' + && status !== null && (status < 200 || status > 299))) { + fail('PROJECTION_CORRUPTION', 'execution state, status, and response hash disagree'); + } + } + if (resolution) { + if (!execution + || resolution.intent_id !== budget.intent_id + || !['refund_pending', 'reconciliation_required', 'resolved'].includes(resolution.state)) { + fail('PROJECTION_CORRUPTION', 'execution resolution is detached'); + } + canonicalReason(resolution.reason_code, 'execution resolution reason'); + const openedAt = persistedTimestamp(resolution.opened_at, 'execution resolution openedAt'); + const blocksWallet = safeInteger(resolution.blocks_wallet, 'execution resolution blocker'); + const executionRecordedAt = Date.parse(execution.recorded_at); + if (blocksWallet > 1 + || (resolution.state === 'resolved') !== (resolution.resolved_at !== null) + || (resolution.state === 'resolved' && blocksWallet !== 0) + || (resolution.state !== 'resolved' && blocksWallet !== 1) + || (resolution.state !== 'resolved' && Date.parse(openedAt) < executionRecordedAt)) { + fail('PROJECTION_CORRUPTION', 'execution resolution state is inconsistent'); + } + if (resolution.resolved_at !== null) { + const resolvedAt = persistedTimestamp( + resolution.resolved_at, + 'execution resolution resolvedAt', + ); + if (Date.parse(resolvedAt) < Date.parse(openedAt) + || Date.parse(resolvedAt) < executionRecordedAt) { + fail('PROJECTION_CORRUPTION', 'execution resolution time regressed'); + } + } + } + + const refunds = access.all('SELECT * FROM refunds WHERE intent_id = ? ORDER BY rowid', [budget.intent_id]); + const activeRefunds = []; + const confirmedRefunds = []; + for (const refund of refunds) { + persistedToken(refund.id, 'refund ID'); + if (!attempt + || refund.intent_id !== budget.intent_id + || refund.original_transaction_id !== attempt.transaction_id + || refund.amount_atomic !== authority.decision.amount_ceiling_atomic + || !['pending', 'unresolved', 'abandoned', 'confirmed', 'rejected'].includes(refund.state)) { + fail('PROJECTION_CORRUPTION', 'refund authority was rebound'); + } + canonicalTransactionId(refund.original_transaction_id, 'refund original transaction'); + persistedAtomic(refund.amount_atomic, 'refund amount'); + const createdAt = persistedTimestamp(refund.created_at, 'refund createdAt'); + const updatedAt = persistedTimestamp(refund.updated_at, 'refund updatedAt'); + if (Date.parse(createdAt) < Date.parse(attempt.settled_at) + || Date.parse(updatedAt) < Date.parse(createdAt)) { + fail('PROJECTION_CORRUPTION', 'refund chronology changed'); + } + if (refund.evidence_json !== null) parseCanonicalJson(refund.evidence_json, 'refund evidence'); + if (refund.refund_transaction_id !== null) { + canonicalTransactionId(refund.refund_transaction_id, 'refund transaction'); + if (refund.refund_transaction_id === refund.original_transaction_id) { + fail('PROJECTION_CORRUPTION', 'refund reused the payment transaction'); + } + } + if (refund.state === 'confirmed' + && (refund.refund_transaction_id === null || refund.evidence_json === null)) { + fail('PROJECTION_CORRUPTION', 'confirmed refund has no exact evidence'); + } + if (['pending', 'unresolved'].includes(refund.state)) activeRefunds.push(refund); + if (refund.state === 'confirmed') confirmedRefunds.push(refund); + } + if (activeRefunds.length > 1 || confirmedRefunds.length > 1) { + fail('PROJECTION_CORRUPTION', 'refund authority is ambiguous'); + } + return Object.freeze({ execution, resolution, refunds, activeRefunds, confirmedRefunds }); +} + +function validatePaymentCandidates(access, budget, attempt) { + const rows = access.all( + 'SELECT * FROM payment_reconciliation_candidates WHERE intent_id = ? ORDER BY rowid', + [budget.intent_id], + ); + let pendingCount = 0; + for (const row of rows) { + persistedToken(row.id, 'payment reconciliation candidate ID'); + if (row.intent_id !== budget.intent_id + || !['pending', 'abandoned', 'rejected', 'confirmed'].includes(row.state)) { + fail('PROJECTION_CORRUPTION', 'payment reconciliation candidate is detached'); + } + canonicalTransactionId(row.transaction_id, 'payment reconciliation transaction'); + const createdAt = persistedTimestamp(row.created_at, 'payment candidate createdAt'); + const updatedAt = persistedTimestamp(row.updated_at, 'payment candidate updatedAt'); + if (Date.parse(createdAt) < Date.parse(attempt.created_at) + || Date.parse(updatedAt) < Date.parse(createdAt)) { + fail('PROJECTION_CORRUPTION', 'payment candidate chronology changed'); + } + if (row.evidence_json !== null) parseCanonicalJson(row.evidence_json, 'payment candidate evidence'); + if (row.state === 'pending') pendingCount += 1; + } + if (pendingCount > 1 || (pendingCount === 1 && budget.state !== 'unresolved')) { + fail('PROJECTION_CORRUPTION', 'pending payment reconciliation is not an unresolved hold'); + } + return rows; +} + +function validateDirectCommitEvent(access, budget, attempt, authority) { + const rows = access.all(`SELECT data_json FROM events + WHERE entity_type = 'budget_reservation' AND entity_id = ? + AND event_type = 'budget.committed' ORDER BY sequence`, [budget.intent_id]); + if (attempt.reason_code === 'TRUSTED_RECONCILIATION') { + if (rows.length !== 0) { + fail('PROJECTION_CORRUPTION', 'trusted reconciliation gained a direct commit event'); + } + return; + } + if (rows.length !== 1) { + fail('PROJECTION_CORRUPTION', 'direct payment commit event is missing or ambiguous'); + } + const settlement = parseCanonicalJson(attempt.settlement_json, 'direct payment settlement'); + const event = exactRecord( + parseCanonicalJson(rows[0].data_json, 'budget.committed event'), + [ + 'amountAtomic', 'transactionId', 'paymentHash', 'headerHash', + 'previousState', 'nextState', 'committedAt', + ], + [], + 'PROJECTION_CORRUPTION', + 'budget.committed event', + ); + if (event.amountAtomic !== authority.decision.amount_ceiling_atomic + || event.transactionId !== attempt.transaction_id + || event.paymentHash !== attempt.payment_hash + || event.headerHash !== settlement.headerHash + || event.previousState !== 'reserved' + || event.nextState !== 'committed' + || event.committedAt !== attempt.settled_at) { + fail('PROJECTION_CORRUPTION', 'direct payment commit proof binding changed'); + } +} + +function validateBudgetRows(access, intents) { + const rawRows = access.all('SELECT * FROM budget_reservations ORDER BY intent_id'); + const validated = []; + for (const budget of rawRows) { + persistedToken(budget.intent_id, 'BudgetReservation intent ID'); + persistedToken(budget.session_id, 'BudgetReservation session ID'); + const authority = intents.get(budget.intent_id); + if (!authority + || !authority.decision + || !['allow', 'approval_required'].includes(authority.decision.decision) + || budget.session_id !== authority.intent.session_id + || budget.seller_origin !== authority.intent.seller_origin) { + fail('PROJECTION_CORRUPTION', 'BudgetReservation historical authority was rebound'); + } + canonicalOrigin(budget.seller_origin, 'BudgetReservation seller origin'); + const ceiling = persistedAtomic( + authority.decision.amount_ceiling_atomic, + 'BudgetReservation PolicyDecision ceiling', + ); + const amounts = { + reserved: persistedAtomic(budget.reserved_atomic, 'BudgetReservation reserved amount'), + committed: persistedAtomic(budget.committed_atomic, 'BudgetReservation committed amount'), + released: persistedAtomic(budget.released_atomic, 'BudgetReservation released amount'), + unresolved: persistedAtomic(budget.unresolved_atomic, 'BudgetReservation unresolved amount'), + }; + if (ceiling.value <= 0n + || Object.values(amounts).reduce((sum, amount) => sum + amount.value, 0n) + !== ceiling.value + || !Object.hasOwn(amounts, budget.state) + || Object.entries(amounts).some(([state, amount]) => ( + amount.value !== (state === budget.state ? ceiling.value : 0n) + ))) { + fail('PROJECTION_CORRUPTION', 'BudgetReservation conservation or disposition changed'); + } + const updatedAt = persistedTimestamp(budget.updated_at, 'BudgetReservation updatedAt'); + const committedAt = budget.committed_at === null + ? null + : persistedTimestamp(budget.committed_at, 'BudgetReservation committedAt'); + if (Date.parse(updatedAt) < Date.parse(authority.decision.decided_at) + || (['reserved', 'unresolved'].includes(budget.state) && committedAt !== null) + || (budget.state === 'committed' && committedAt === null) + || (committedAt !== null && Date.parse(committedAt) > Date.parse(updatedAt))) { + fail('PROJECTION_CORRUPTION', 'BudgetReservation chronology or state is invalid'); + } + + const attemptRows = access.all( + 'SELECT * FROM payment_attempts WHERE intent_id = ? ORDER BY rowid', + [budget.intent_id], + ); + if (attemptRows.length > 1 + || (budget.state !== 'released' && attemptRows.length !== 1)) { + fail('PROJECTION_CORRUPTION', 'BudgetReservation has no exact PaymentAttempt'); + } + const attempt = attemptRows.length === 0 + ? null + : validateAttemptRow(access, attemptRows[0], authority); + const candidates = attempt ? validatePaymentCandidates(access, budget, attempt) : []; + const aftermath = validateExecutionRows(access, budget, authority, attempt); + const { execution, resolution, activeRefunds, confirmedRefunds, refunds } = aftermath; + + if (budget.state === 'reserved') { + if (!['reserved', 'signing', 'signed', 'retrying'].includes(attempt.state) + || authority.intent.state !== attempt.state + || execution || resolution || refunds.length > 0) { + fail('PROJECTION_CORRUPTION', 'reserved budget owns illegal payment aftermath'); + } + } else if (budget.state === 'unresolved') { + if (authority.intent.state !== 'unresolved' + || attempt.state !== 'unresolved' + || attempt.updated_at !== budget.updated_at + || authority.outcome?.status !== 'payment_unresolved' + || authority.outcome.reason_code !== attempt.reason_code + || execution || resolution || refunds.length > 0) { + fail('PROJECTION_CORRUPTION', 'unresolved budget owns illegal payment aftermath'); + } + } else if (budget.state === 'committed') { + if (authority.intent.state !== 'terminal' + || attempt.state !== 'settled' + || attempt.settled_at !== committedAt + || budget.updated_at !== committedAt + || !execution + || !authority.outcome) { + fail('PROJECTION_CORRUPTION', 'committed budget has no settled PaymentAttempt'); + } + validateDirectCommitEvent(access, budget, attempt, authority); + if (execution.state === 'succeeded') { + const reconciled = authority.outcome.reason_code === 'EXECUTION_RECONCILED_SUCCEEDED'; + const direct = ['PAYMENT_SETTLED', 'EXECUTION_SUCCEEDED'] + .includes(authority.outcome.reason_code); + const exactReconciledResolution = reconciled + && resolution?.state === 'resolved' + && resolution.reason_code === 'EXECUTION_RECONCILED_SUCCEEDED' + && resolution.resolved_at === execution.recorded_at; + if (authority.outcome.status !== 'completed' + || (!direct && !reconciled) + || (direct && resolution !== null) + || (reconciled && !exactReconciledResolution) + || refunds.length > 0) { + fail('PROJECTION_CORRUPTION', 'successful execution owns invalid resolution aftermath'); + } + } else if (execution.state === 'failed' + && (authority.outcome.status !== 'execution_failed' + || !resolution + || resolution.state !== 'refund_pending' + || refunds.length !== 1 + || confirmedRefunds.length !== 0 + || (refunds[0].state === 'pending' + && authority.outcome.reason_code !== resolution.reason_code) + || (refunds[0].state !== 'pending' + && (!['unresolved', 'abandoned', 'rejected'].includes(refunds[0].state) + || authority.outcome.reason_code !== 'REFUND_UNRESOLVED')))) { + fail('PROJECTION_CORRUPTION', 'failed execution has no exact pending refund'); + } else if (execution.state === 'unknown' + && (authority.outcome.status !== 'execution_unknown' + || !resolution + || authority.outcome.reason_code !== resolution.reason_code + || resolution.state !== 'reconciliation_required' || refunds.length !== 0)) { + fail('PROJECTION_CORRUPTION', 'unknown execution has no exact reconciliation blocker'); + } + } else { + const refunded = committedAt !== null; + if (!refunded) { + if (authority.intent.state !== 'terminal' + || !authority.outcome + || !['payment_denied', 'payment_failed', 'payment_rejected'] + .includes(authority.outcome.status) + || (attempt && attempt.state !== 'rejected') + || (attempt && authority.outcome.reason_code !== attempt.reason_code) + || (attempt && attempt.updated_at !== budget.updated_at) + || execution || resolution || refunds.length > 0) { + fail('PROJECTION_CORRUPTION', 'released unsigned budget owns paid aftermath'); + } + } else if (authority.intent.state !== 'terminal' + || authority.outcome?.status !== 'refunded' + || authority.outcome.reason_code !== 'REFUND_CONFIRMED' + || !attempt + || attempt.state !== 'settled' + || attempt.settled_at !== committedAt + || execution?.state !== 'failed' + || resolution?.state !== 'resolved' + || resolution.resolved_at !== budget.updated_at + || activeRefunds.length !== 0 + || confirmedRefunds.length !== 1 + || confirmedRefunds[0].updated_at !== budget.updated_at) { + fail('PROJECTION_CORRUPTION', 'refunded budget has incomplete terminal authority'); + } + } + validated.push(Object.freeze({ + budget, + authority, + attempt, + candidates, + ...aftermath, + })); + } + const reservationIds = new Set(validated.map(({ budget }) => budget.intent_id)); + for (const [table, label] of [ + ['payment_attempts', 'PaymentAttempt'], + ['payment_reconciliation_candidates', 'payment reconciliation candidate'], + ['execution_resolutions', 'execution resolution'], + ['refunds', 'refund'], + ]) { + const orphans = access.all(`SELECT intent_id FROM ${table} ORDER BY rowid`) + .filter((row) => !reservationIds.has(row.intent_id)); + if (orphans.length > 0) { + fail('PROJECTION_CORRUPTION', `${label} has no BudgetReservation authority`); + } + } + return validated; +} + +function aggregateBudgetRows(rows, label) { + const total = { + reservedAtomic: 0n, + committedAtomic: 0n, + releasedAtomic: 0n, + unresolvedAtomic: 0n, + }; + for (const { budget } of rows) { + for (const [output, column] of [ + ['reservedAtomic', 'reserved_atomic'], + ['committedAtomic', 'committed_atomic'], + ['releasedAtomic', 'released_atomic'], + ['unresolvedAtomic', 'unresolved_atomic'], + ]) total[output] += persistedAtomic(budget[column], `${label} ${output}`).value; + } + return frozenCopy({ + reservedAtomic: total.reservedAtomic.toString(), + committedAtomic: total.committedAtomic.toString(), + releasedAtomic: total.releasedAtomic.toString(), + unresolvedAtomic: total.unresolvedAtomic.toString(), + exposureAtomic: ( + total.reservedAtomic + total.committedAtomic + total.unresolvedAtomic + ).toString(), + }); +} + +function projectBudgets(rows, session) { + const walletRows = rows.filter(({ authority }) => ( + authority.sessionAuthority.session.wallet_address === session.wallet_address + )); + return frozenCopy({ + session: aggregateBudgetRows( + walletRows.filter(({ budget }) => budget.session_id === session.id), + 'session budget', + ), + wallet: aggregateBudgetRows(walletRows, 'wallet budget'), + }); +} + +function exactApprovalEvent(row, eventType, expectedData) { + if (!row || row.event_type !== eventType + || canonicalJson(parseCanonicalJson(row.data_json, `${eventType} event`)) + !== canonicalJson(expectedData)) { + fail('PROJECTION_CORRUPTION', `Approval ${eventType} provenance changed`); + } + const transitionAt = Object.values(expectedData).at(-1); + const createdAt = persistedTimestamp(row.created_at, `${eventType} event createdAt`); + if (typeof transitionAt !== 'string' || Date.parse(transitionAt) > Date.parse(createdAt)) { + fail('PROJECTION_CORRUPTION', `Approval ${eventType} chronology changed`); + } + return transitionAt; +} + +function validateApprovalEvents(access, row, authority) { + const events = access.all(`SELECT event_type, data_json, created_at FROM events + WHERE entity_type = 'approval' AND entity_id = ? ORDER BY sequence`, [row.id]); + const persistedRequest = events[0] + ? parseCanonicalJson(events[0].data_json, 'approval.requested event') + : null; + const requestedAt = persistedRequest?.requestedAt; + const requestData = { + intentId: row.intent_id, + intentHash: row.intent_hash, + challengeHash: row.challenge_hash, + quoteId: row.quote_id, + amountCeilingAtomic: row.amount_ceiling_atomic, + walletAddress: row.wallet_address, + policyVersionId: row.policy_version_id, + acceptedIndex: Number(row.accepted_index), + expiresAt: row.expires_at, + requestedAt, + }; + let lastAt = exactApprovalEvent(events[0], 'approval.requested', requestData); + if (Date.parse(lastAt) < Date.parse(authority.decision.decided_at) + || Date.parse(lastAt) >= Date.parse(row.expires_at)) { + fail('PROJECTION_CORRUPTION', 'Approval request chronology changed'); + } + let index = 1; + let previousDecision = 'pending'; + const approve = (bindRowDecision) => { + const persistedApproval = events[index] + ? parseCanonicalJson(events[index].data_json, 'approval.approved event') + : null; + const expectedApprovedAt = bindRowDecision ? row.decided_at : persistedApproval?.approvedAt; + const approvedAt = exactApprovalEvent(events[index], 'approval.approved', { + intentId: row.intent_id, + intentHash: row.intent_hash, + operatorIdHash: row.operator_id_hash, + approvedAt: expectedApprovedAt, + }); + if (Date.parse(approvedAt) < Date.parse(lastAt) + || Date.parse(approvedAt) >= Date.parse(row.expires_at)) { + fail('PROJECTION_CORRUPTION', 'Approval approval chronology changed'); + } + lastAt = approvedAt; + previousDecision = 'approved'; + index += 1; + }; + + if (['approved', 'consumed'].includes(row.decision)) approve(true); + if (row.decision === 'denied') { + const deniedAt = exactApprovalEvent(events[index], 'approval.denied', { + intentId: row.intent_id, + intentHash: row.intent_hash, + operatorIdHash: row.operator_id_hash, + reasonCode: row.reason_code, + deniedAt: row.decided_at, + }); + if (Date.parse(deniedAt) < Date.parse(lastAt) + || Date.parse(deniedAt) >= Date.parse(row.expires_at)) { + fail('PROJECTION_CORRUPTION', 'Approval denial chronology changed'); + } + lastAt = deniedAt; + index += 1; + } else if (row.decision === 'consumed') { + const consumedAt = exactApprovalEvent(events[index], 'approval.consumed', { + intentId: row.intent_id, + intentHash: row.intent_hash, + consumedAt: row.consumed_at, + }); + if (Date.parse(consumedAt) < Date.parse(lastAt) + || Date.parse(consumedAt) >= Date.parse(row.expires_at)) { + fail('PROJECTION_CORRUPTION', 'Approval consumption chronology changed'); + } + index += 1; + } else if (['expired', 'cancelled'].includes(row.decision)) { + const terminal = events.at(-1); + const terminalData = terminal + ? parseCanonicalJson(terminal.data_json, `approval.${row.decision} event`) + : null; + previousDecision = terminalData?.previousDecision; + if (!['pending', 'approved'].includes(previousDecision)) { + fail('PROJECTION_CORRUPTION', 'Approval terminal event has no legal predecessor'); + } + if (previousDecision === 'approved') approve(false); + const timeField = row.decision === 'expired' ? 'expiredAt' : 'cancelledAt'; + const terminalAt = exactApprovalEvent(events[index], `approval.${row.decision}`, { + intentId: row.intent_id, + intentHash: row.intent_hash, + previousDecision, + reasonCode: row.reason_code, + [timeField]: row.decided_at, + }); + if (Date.parse(terminalAt) < Date.parse(lastAt) + || (row.decision === 'expired' + && Date.parse(terminalAt) < Date.parse(row.expires_at))) { + fail('PROJECTION_CORRUPTION', 'Approval terminal chronology changed'); + } + index += 1; + } + if (events.length !== index) { + fail('PROJECTION_CORRUPTION', 'Approval event lifecycle is missing, duplicated, or reordered'); + } + if ((['expired', 'cancelled'].includes(row.decision) + && previousDecision === 'pending' && row.operator_id_hash !== null) + || (['expired', 'cancelled'].includes(row.decision) + && previousDecision === 'approved' && row.operator_id_hash === null)) { + fail('PROJECTION_CORRUPTION', 'Approval terminal event lost predecessor authority'); + } +} + +function projectApprovals(access, intents, session) { + const counts = Object.fromEntries(APPROVAL_STATES.map((state) => [state, 0])); + const rows = access.all('SELECT * FROM approvals ORDER BY id'); + for (const row of rows) { + persistedToken(row.id, 'Approval ID'); + const authority = intents.get(row.intent_id); + if (!authority + || !authority.decision + || authority.decision.decision !== 'approval_required' + || !Object.hasOwn(counts, row.decision) + || row.intent_hash !== authority.intent.intent_hash + || row.challenge_hash !== authority.decision.challenge_hash + || row.quote_id !== authority.decision.quote_id + || safeInteger(row.accepted_index, 'Approval accepted index') + !== safeInteger(authority.decision.accepted_index, 'PolicyDecision accepted index') + || row.amount_ceiling_atomic !== authority.decision.amount_ceiling_atomic + || row.wallet_address !== authority.sessionAuthority.session.wallet_address + || row.policy_version_id !== authority.sessionAuthority.policyVersion.row.id) { + fail('PROJECTION_CORRUPTION', 'Approval authority was rebound'); + } + canonicalHash(row.intent_hash, 'Approval intent hash'); + canonicalHash(row.challenge_hash, 'Approval challenge hash'); + canonicalHash(row.quote_id, 'Approval quote hash'); + canonicalAddress(row.wallet_address, 'Approval wallet'); + persistedAtomic(row.amount_ceiling_atomic, 'Approval amount ceiling'); + const expiresAt = persistedTimestamp(row.expires_at, 'Approval expiresAt'); + const decidedAt = row.decided_at === null + ? null + : persistedTimestamp(row.decided_at, 'Approval decidedAt'); + const consumedAt = row.consumed_at === null + ? null + : persistedTimestamp(row.consumed_at, 'Approval consumedAt'); + if (row.operator_id_hash !== null) canonicalHash(row.operator_id_hash, 'Approval operator hash'); + if (row.reason_code !== null) canonicalReason(row.reason_code, 'Approval reason'); + const policy = authority.sessionAuthority.policyVersion.policy; + const expectedExpiresAt = new Date(Math.min( + Date.parse(authority.intent.challenge_received_at) + policy.challengeMaxAgeMs, + Date.parse(authority.decision.decided_at) + policy.approvalTtlMs, + )).toISOString(); + const lifecycleValid = (row.decision === 'pending' + && row.operator_id_hash === null && row.reason_code === null + && decidedAt === null && consumedAt === null) + || (row.decision === 'approved' + && row.operator_id_hash !== null && row.reason_code === null + && decidedAt !== null && consumedAt === null) + || (row.decision === 'denied' + && row.operator_id_hash !== null && row.reason_code !== null + && decidedAt !== null && consumedAt === null) + || (row.decision === 'expired' + && row.reason_code === 'APPROVAL_EXPIRED' + && decidedAt !== null && consumedAt === null) + || (row.decision === 'cancelled' + && ['POLICY_SUPERSEDED', 'SESSION_CLOSED', 'APPROVAL_CHALLENGE_CHANGED'] + .includes(row.reason_code) + && decidedAt !== null && consumedAt === null) + || (row.decision === 'consumed' + && row.operator_id_hash !== null && row.reason_code === null + && decidedAt !== null && consumedAt !== null); + if (expiresAt !== expectedExpiresAt + || !lifecycleValid + || (decidedAt !== null && Date.parse(decidedAt) < Date.parse(authority.decision.decided_at)) + || (['approved', 'denied'].includes(row.decision) + && Date.parse(decidedAt) >= Date.parse(expiresAt)) + || (consumedAt !== null && (Date.parse(consumedAt) < Date.parse(decidedAt) + || Date.parse(consumedAt) >= Date.parse(expiresAt)))) { + fail('PROJECTION_CORRUPTION', 'Approval lifecycle is inconsistent'); + } + validateApprovalEvents(access, row, authority); + if (authority.intent.session_id === session.id) counts[row.decision] += 1; + } + return frozenCopy(counts); +} + +function projectBlockers(rows, walletAddress) { + const payment = new Map(); + const execution = new Map(); + const refund = new Map(); + for (const item of rows) { + if (item.authority.sessionAuthority.session.wallet_address !== walletAddress) continue; + const intentId = item.budget.intent_id; + const hasPendingCandidate = item.candidates.some((candidate) => candidate.state === 'pending'); + if (item.budget.state === 'unresolved' || hasPendingCandidate) { + const reason = item.attempt?.reason_code + ?? item.authority.outcome?.reason_code + ?? 'PAYMENT_UNRESOLVED'; + payment.set(intentId, canonicalReason(reason, 'payment blocker reason')); + } + if (item.resolution?.state !== 'resolved' && item.resolution?.blocks_wallet === 1n) { + execution.set( + intentId, + canonicalReason(item.resolution.reason_code, 'execution blocker reason'), + ); + } + for (const active of item.activeRefunds) { + refund.set( + intentId, + active.state === 'pending' ? 'REFUND_PENDING' : 'REFUND_UNRESOLVED', + ); + } + } + const reasonCodes = (entries) => [...new Set(entries.values())].sort(); + const blockedIntents = new Set([...payment.keys(), ...execution.keys(), ...refund.keys()]); + return frozenCopy({ + blockedIntentCount: blockedIntents.size, + execution: { openCount: execution.size, reasonCodes: reasonCodes(execution) }, + payment: { openCount: payment.size, reasonCodes: reasonCodes(payment) }, + refund: { openCount: refund.size, reasonCodes: reasonCodes(refund) }, + walletBlocked: blockedIntents.size > 0, + }); +} + +function projectIntents(intents, session) { + const rows = [...intents.values()] + .filter(({ intent }) => intent.session_id === session.id) + .sort((left, right) => left.intent.created_at.localeCompare(right.intent.created_at) + || left.intent.id.localeCompare(right.intent.id)); + return frozenCopy(rows.map(({ intent: row, outcome: persistedOutcome }) => { + const outcome = persistedOutcome === null + ? null + : { + status: persistedToken(persistedOutcome.status, 'BuyerOutcome status'), + reasonCode: canonicalReason(persistedOutcome.reason_code, 'BuyerOutcome reason'), + revision: safeInteger(persistedOutcome.revision, 'BuyerOutcome revision'), + }; + return { + intentHash: canonicalHash(row.intent_hash, 'Spend Intent hash'), + requestIdHash: hashPrivateLabel( + 'wallet-kernel.request-identity.v1', + 'requestId', + row.request_id, + ), + routeHash: hashPrivateLabel('wallet-kernel.route-identity.v1', 'routeId', row.route_id), + method: persistedToken(row.method, 'Spend Intent method'), + sellerOrigin: canonicalOrigin(row.seller_origin, 'Spend Intent seller origin'), + requestUrlHash: canonicalHash(row.request_url_hash, 'Spend Intent request URL hash'), + resourceHash: hashPrivateLabel( + 'wallet-kernel.resource-identity.v1', + 'resourcePath', + row.resource_path, + ), + purposeHash: hashPrivateLabel( + 'wallet-kernel.purpose-identity.v1', + 'purposeLabel', + row.purpose_label, + ), + correlationHash: hashPrivateLabel( + 'wallet-kernel.correlation-identity.v1', + 'correlationId', + row.correlation_id, + ), + state: row.state, + outcome, + createdAt: persistedTimestamp(row.created_at, 'Spend Intent createdAt'), + updatedAt: persistedTimestamp(row.updated_at, 'Spend Intent updatedAt'), + }; + })); +} + +function projectReceipts(access, receipts, session) { + const rows = access.all(`SELECT signed_receipts.* + FROM signed_receipts + JOIN spend_intents ON spend_intents.id = signed_receipts.intent_id + WHERE spend_intents.session_id = ? + ORDER BY signed_receipts.intent_id, signed_receipts.revision`, [session.id]); + const projected = rows.map((row) => { + const revision = safeInteger(row.revision, 'signed receipt revision'); + if (revision < 1) fail('PROJECTION_CORRUPTION', 'signed receipt revision must be positive'); + const record = { + id: persistedToken(row.id, 'signed receipt ID'), + intentId: persistedToken(row.intent_id, 'signed receipt intent ID'), + revision, + receipt: parseCanonicalJson(row.receipt_json, 'signed receipt projection'), + receiptHash: row.receipt_hash, + signature: row.signature, + algorithm: row.algorithm, + keyId: row.key_id, + supersedesReceiptHash: row.supersedes_receipt_hash, + createdAt: persistedTimestamp(row.created_at, 'signed receipt createdAt'), + }; + if (!receipts.verify(record)) { + fail('RECEIPT_PARITY_REQUIRED', 'projection contains an invalid signed receipt'); + } + return record; + }); + return frozenCopy(projected); +} + +function verifyEventChain(access) { + const rows = access.all('SELECT * FROM events ORDER BY sequence'); + let previousHash = null; + let expectedSequence = 1; + let head = null; + for (const row of rows) { + try { + const sequence = safeInteger(row.sequence, 'event sequence'); + const entityType = persistedToken(row.entity_type, 'event entity type'); + const entityId = persistedToken(row.entity_id, 'event entity ID'); + const eventType = persistedToken(row.event_type, 'event type'); + const data = parseCanonicalJson(row.data_json, 'event data'); + const createdAt = persistedTimestamp(row.created_at, 'event createdAt'); + const eventHash = canonicalHash(row.event_hash, 'event hash'); + if (!data || typeof data !== 'object' || Array.isArray(data) + || sequence !== expectedSequence + || row.previous_hash !== previousHash + || eventHash !== sha256(canonicalJson({ + entityType, + entityId, + eventType, + data, + previousHash, + createdAt, + }))) { + fail('PROJECTION_EVENT_CHAIN', 'authority event hash chain is invalid'); + } + previousHash = eventHash; + expectedSequence += 1; + head = Object.freeze({ eventHash, createdAt }); + } catch (error) { + if (error instanceof KernelError && error.code === 'PROJECTION_EVENT_CHAIN') throw error; + fail('PROJECTION_EVENT_CHAIN', 'authority event hash chain is invalid', { cause: error }); + } + } + return head; +} + +export function createProjectionExporter({ store, receipts, signer, now }) { + validateStore(store); + validateReceipts(receipts); + const exportSigner = normalizeSigner(signer); + if (typeof now !== 'function') throw new TypeError('projection exporter requires a clock'); + const budgetLedger = createBudgetLedger({ store, now }); + + const snapshot = (input) => { + const request = exactRecord( + input, + ['sessionId'], + [], + 'PROJECTION_INPUT', + 'projection snapshot request', + ); + const sessionId = canonicalToken(request.sessionId, 'projection Spend Session ID'); + return store.transaction((token) => { + receipts.assertParityInTransaction(token); + return store.within(token, ({ db }) => { + const access = transactionAccess(db); + const schemaVersion = safeInteger( + access.one('SELECT user_version FROM pragma_user_version')?.user_version, + 'Wallet Kernel schema version', + ); + if (schemaVersion !== 1) { + fail('PROJECTION_CORRUPTION', 'Wallet Kernel schema version is unsupported'); + } + const eventHead = verifyEventChain(access); + const issuedAt = canonicalTimestamp(now(), 'projection issuedAt'); + if (eventHead && Date.parse(issuedAt) < Date.parse(eventHead.createdAt)) { + fail('PROJECTION_TIME', 'projection issuedAt predates its authority event head'); + } + + const policyState = projectPolicies(access); + const sessions = validateSessions(access, policyState); + const target = sessions.get(sessionId); + if (!target) fail('SESSION_UNKNOWN', 'Spend Session does not exist'); + const session = target.session; + try { + budgetLedger.snapshotInTransaction(token, { + sessionId: session.id, + sellerOrigin: target.policyVersion.policy.sellers[0].origin, + at: issuedAt, + }); + } catch (error) { + if (error instanceof KernelError) { + fail('PROJECTION_CORRUPTION', 'budget authority cannot be projected', { cause: error }); + } + throw error; + } + const intents = validateIntents(access, sessions); + const budgetRows = validateBudgetRows(access, intents); + const enrollment = projectEnrollment(access, session, issuedAt, target); + const projection = { + schemaVersion: 1, + domain: 'wallet-kernel.sanitized-projection.v1', + authoritySchemaVersion: schemaVersion, + sessionHash: hashPrivateLabel( + 'wallet-kernel.session-identity.v1', + 'sessionId', + session.id, + ), + sessionState: session.state, + wallet: { + address: session.wallet_address, + adapterHash: sha256(canonicalJson({ + domain: 'wallet-kernel.adapter-identity.v1', + adapterId: session.adapter_id, + })), + }, + agentEnrollment: enrollment.agentEnrollment, + isolation: enrollment.isolation, + policies: { + activePolicyHash: policyState.active.hash, + sessionPolicyHash: target.policyVersion.hash, + historyHashes: policyState.historyHashes, + }, + budgets: projectBudgets(budgetRows, session), + approvals: projectApprovals(access, intents, session), + blockers: projectBlockers(budgetRows, session.wallet_address), + intents: projectIntents(intents, session), + signedReceipts: projectReceipts(access, receipts, session), + eventHeadHash: eventHead?.eventHash ?? null, + issuedAt, + }; + assertSanitized(projection); + return frozenCopy(projection); + }); + }); + }; + + const exportSigned = (input) => { + const projection = snapshot(input); + const unsigned = { + schemaVersion: 1, + domain: 'wallet-kernel.projection-export.v1', + projection, + algorithm: exportSigner.algorithm, + keyId: exportSigner.keyId, + publicKeyPem: exportSigner.publicKeyPem, + }; + const projectionHash = sha256(canonicalJson(unsigned)); + const hashHex = projectionHash.slice('sha256:'.length); + const signature = exportSigner.signHash(hashHex); + let signatureBytes; + try { signatureBytes = Buffer.from(signature, 'base64'); } catch { + fail('PROJECTION_SIGNATURE', 'projection signer returned an invalid signature'); + } + if (typeof signature !== 'string' + || signatureBytes.length !== 64 + || signatureBytes.toString('base64') !== signature + || !crypto.verify( + null, + Buffer.from(hashHex, 'hex'), + exportSigner.publicKey, + signatureBytes, + )) { + fail('PROJECTION_SIGNATURE', 'projection signer returned an invalid signature'); + } + const bundle = { + ...unsigned, + projectionHash, + signature, + }; + assertSanitized(bundle); + return frozenCopy(bundle); + }; + + return Object.freeze({ snapshot, exportSigned }); +} diff --git a/spikes/pi-wielder/src/kernel/receipt-signing.mjs b/spikes/pi-wielder/src/kernel/receipt-signing.mjs new file mode 100644 index 0000000..8757346 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/receipt-signing.mjs @@ -0,0 +1,158 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { loadOrInitializePrivateFile } from './secure-storage.mjs'; + +const PKCS8_PRIVATE_KEY_LABEL = ['PRIVATE', 'KEY'].join(' '); +const PKCS8_PRIVATE_KEY_PEM = new RegExp( + `^-----BEGIN ${PKCS8_PRIVATE_KEY_LABEL}-----\\r?\\n` + + `(?:[A-Za-z0-9+/]{1,64}={0,2}\\r?\\n)+` + + `-----END ${PKCS8_PRIVATE_KEY_LABEL}-----(?:[ \\t\\r\\n]*)$`, +); + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])); + } + return value; +} + +export const canonicalJson = (value) => JSON.stringify(canonicalize(value)); + +export function receiptKeyId(publicKey) { + const keyObject = publicKey?.type === 'public' ? publicKey : crypto.createPublicKey(publicKey); + return `sha256:${crypto.createHash('sha256') + .update(keyObject.export({ type: 'spki', format: 'der' })) + .digest('hex')}`; +} + +function normalizeReceiptSigner(signer, { requirePersistent = false } = {}) { + if (!signer || typeof signer !== 'object' || typeof signer.signHash !== 'function') { + throw new Error('receipt signer must provide signHash'); + } + if (signer.algorithm !== 'Ed25519') { + throw new Error("receipt signer algorithm must be 'Ed25519'"); + } + let publicKey; + try { + publicKey = crypto.createPublicKey(signer.publicKeyPem); + } catch (error) { + throw new Error('receipt signer must provide a valid public key', { cause: error }); + } + if (publicKey.asymmetricKeyType !== 'ed25519') { + throw new Error('receipt signer public key must be Ed25519'); + } + const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const keyId = receiptKeyId(publicKey); + if (signer.keyId !== keyId) { + throw new Error('receipt signer key ID must be the SPKI-derived SHA-256 identifier'); + } + if (requirePersistent && signer.persistent !== true) { + throw new Error('persistent journal refuses an ephemeral receipt signer'); + } + return Object.freeze({ + algorithm: 'Ed25519', + publicKeyPem, + keyId, + persistent: signer.persistent === true, + signHash: (hashHex) => signer.signHash.call(signer, hashHex), + }); +} + +function verifyHashSignature(hashHex, signature, publicKey) { + if (!/^[0-9a-f]{64}$/.test(String(hashHex ?? '')) || typeof signature !== 'string') return false; + const signatureBytes = Buffer.from(signature, 'base64'); + if (signatureBytes.length !== 64 || signatureBytes.toString('base64') !== signature) return false; + try { + return crypto.verify(null, Buffer.from(hashHex, 'hex'), publicKey, signatureBytes); + } catch { + return false; + } +} + +export function createReceiptSigner(keys = {}, { persistent = false } = {}) { + const pair = keys.privateKey && keys.publicKey + ? { privateKey: keys.privateKey, publicKey: keys.publicKey } + : crypto.generateKeyPairSync('ed25519'); + const publicKeyPem = pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const keyId = receiptKeyId(pair.publicKey); + return normalizeReceiptSigner({ + algorithm: 'Ed25519', + publicKeyPem, + keyId, + persistent, + signHash(hashHex) { + return crypto.sign(null, Buffer.from(hashHex, 'hex'), pair.privateKey).toString('base64'); + }, + }); +} + +function parseOneEd25519PrivateKey(bytes) { + const pem = Buffer.from(bytes).toString('utf8'); + if (!PKCS8_PRIVATE_KEY_PEM.test(pem)) { + throw new Error('receipt private key must contain exactly one PKCS#8 PEM with no trailing data'); + } + let privateKey; + try { + privateKey = crypto.createPrivateKey(pem); + } catch (error) { + throw new Error('receipt private key is invalid', { cause: error }); + } + if (privateKey.type !== 'private' || privateKey.asymmetricKeyType !== 'ed25519') { + throw new Error('receipt private key must be Ed25519'); + } + return privateKey; +} + +function inferredCompatibilityPathTrust(keyPath) { + if (typeof keyPath !== 'string' || !path.isAbsolute(keyPath)) { + throw new Error('persistent receipt key path must be absolute'); + } + const lexicalParent = path.resolve(path.dirname(keyPath)); + const trustedAncestor = fs.realpathSync(lexicalParent); + if (trustedAncestor !== lexicalParent) { + throw new Error('persistent receipt key must not traverse a symlinked directory'); + } + const uid = process.getuid(); + return Object.freeze({ + mode: 'deterministic', + trustedAncestor, + kernelUid: uid, + agentUid: uid, + }); +} + +export function loadOrCreateReceiptSigner(keyPath, options = {}) { + const pathTrust = options.pathTrust ?? inferredCompatibilityPathTrust(keyPath); + const privateKey = loadOrInitializePrivateFile({ + filePath: keyPath, + label: 'Receipt private key', + createBytes: () => { + const { privateKey: generated } = crypto.generateKeyPairSync('ed25519'); + return generated.export({ type: 'pkcs8', format: 'pem' }); + }, + validateBytes: parseOneEd25519PrivateKey, + ...(options.randomBytes ? { randomBytes: options.randomBytes } : {}), + ...(options.faultInjector ? { faultInjector: options.faultInjector } : {}), + pathTrust, + }); + return createReceiptSigner( + { privateKey, publicKey: crypto.createPublicKey(privateKey) }, + { persistent: true }, + ); +} + +export function verifySignedReceipt(bundle, { publicKeyPem, keyId }) { + try { + if (bundle?.algorithm !== 'Ed25519' || bundle.keyId !== keyId) return false; + const expectedHash = crypto.createHash('sha256').update(canonicalJson(bundle.receipt)).digest('hex'); + if (bundle.receiptHash !== expectedHash) return false; + const publicKey = crypto.createPublicKey(publicKeyPem); + return publicKey.asymmetricKeyType === 'ed25519' + && verifyHashSignature(expectedHash, bundle.signature, publicKey); + } catch { + return false; + } +} diff --git a/spikes/pi-wielder/src/kernel/recovery.mjs b/spikes/pi-wielder/src/kernel/recovery.mjs new file mode 100644 index 0000000..073e030 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/recovery.mjs @@ -0,0 +1,5406 @@ +import { types as utilTypes } from 'node:util'; + +import { + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; +import { validatePolicyDocument } from './policy-engine.mjs'; +import { KERNEL_SCHEMA_VERSION } from './sqlite-schema.mjs'; + +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const EVM_HASH_PATTERN = /^0x[0-9a-f]{64}$/; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const INSTANCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/; +const STABLE_REASON_PATTERN = /^[A-Z][A-Z0-9_]{0,99}$/; +const OPEN_APPROVALS = new Set(['pending', 'approved']); +const AMBIGUOUS_PAYMENT_STATES = new Set(['signing', 'signed', 'retrying']); +const LEGAL_INTENT_TRANSITIONS = Object.freeze(new Map([ + ['captured', new Set(['challenged', 'terminal'])], + ['challenged', new Set(['approval_pending', 'authorized', 'terminal'])], + ['approval_pending', new Set(['authorized', 'terminal'])], + ['authorized', new Set(['reserved', 'terminal'])], + ['reserved', new Set(['signing', 'terminal'])], + ['signing', new Set(['signed', 'unresolved'])], + ['signed', new Set(['retrying', 'unresolved'])], + ['retrying', new Set(['terminal', 'unresolved'])], + ['unresolved', new Set(['terminal'])], + ['terminal', new Set()], +])); +const APPROVAL_CANCEL_REASONS = new Set([ + 'POLICY_SUPERSEDED', + 'SESSION_CLOSED', + 'APPROVAL_CHALLENGE_CHANGED', +]); +const ENFORCED_ISOLATION_PROBES = Object.freeze({ + authorityDirectory: 'EACCES', + database: 'EACCES', + operatorToken: 'EACCES', + receiptKey: 'EACCES', + kernelEnvironment: 'EACCES', + agentCredential: 'READABLE', + releaseTreeWrite: 'EACCES', + dependencyTreeWrite: 'EACCES', + serviceArtifactsWrite: 'EACCES', + kernelEnvironmentParentWrite: 'EACCES', +}); +const SELLER_EVIDENCE_UNKNOWN_REASONS = new Set([ + 'SELLER_EVIDENCE_BINDING_INVALID', + 'SELLER_EVIDENCE_ENDPOINT_INVALID', + 'SELLER_EVIDENCE_FETCH_FAILED', + 'SELLER_EVIDENCE_TIMEOUT', + 'SELLER_EVIDENCE_REDIRECT', + 'SELLER_EVIDENCE_HTTP_STATUS', + 'SELLER_EVIDENCE_CONTENT_TYPE', + 'SELLER_EVIDENCE_TOO_LARGE', + 'SELLER_EVIDENCE_RESPONSE_INVALID', + 'SELLER_EVIDENCE_JSON_INVALID', + 'SELLER_EVIDENCE_ATTESTATION_INVALID', + 'SELLER_EVIDENCE_ATTESTATION_MISMATCH', + 'SELLER_EVIDENCE_TIME_INVALID', + 'SELLER_EVIDENCE_SIGNATURE_INVALID', +]); +const PAYMENT_RPC_UNKNOWN_REASONS = new Set([ + 'RPC_RECEIPT_MISSING', + 'RPC_CONFIRMATIONS_INSUFFICIENT', + 'RPC_PROVIDER_UNAVAILABLE', + 'RPC_REORG_DETECTED', + 'RPC_EVIDENCE_INVALID', + 'AUTHORIZATION_ALREADY_USED', + 'AUTHORIZATION_NOT_EXPIRED', +]); +const REFUND_RPC_UNKNOWN_REASONS = new Set([ + 'RPC_RECEIPT_MISSING', + 'RPC_CONFIRMATIONS_INSUFFICIENT', + 'RPC_PROVIDER_UNAVAILABLE', + 'RPC_REORG_DETECTED', + 'RPC_EVIDENCE_INVALID', +]); + +function semantic(message, cause) { + if (cause instanceof KernelError && cause.code === 'AUTHORITY_SEMANTIC_CORRUPTION') { + return cause; + } + return new KernelError('AUTHORITY_SEMANTIC_CORRUPTION', message, { cause }); +} + +function assertPlainDependencies(value, names, label) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError(`${label} must be one plain object`); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== names.length + || keys.some((key) => typeof key !== 'string' || !names.includes(key))) { + throw new TypeError(`${label} has an invalid shape`); + } + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError(`${label} must contain enumerable data properties`); + } + } + return value; +} + +function requireMethods(value, names, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) { + throw new TypeError(`${label} is required`); + } + for (const name of names) { + if (typeof value[name] !== 'function') throw new TypeError(`${label}.${name} is required`); + } +} + +function canonicalHash(value, label) { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + throw semantic(`${label} is not a canonical SHA-256 hash`); + } + return value; +} + +function canonicalAddress(value, label) { + if (typeof value !== 'string' || !ADDRESS_PATTERN.test(value)) { + throw semantic(`${label} is not a canonical lowercase EVM address`); + } + return value; +} + +function canonicalTransactionId(value, label) { + if (typeof value !== 'string' || !EVM_HASH_PATTERN.test(value)) { + throw semantic(`${label} is not a canonical lowercase transaction hash`); + } + return value; +} + +function canonicalReason(value, label) { + if (typeof value !== 'string' || !STABLE_REASON_PATTERN.test(value)) { + throw semantic(`${label} is not a stable reason code`); + } + return value; +} + +function parseCanonical(value, label) { + let parsed; + try { + parsed = JSON.parse(value); + if (canonicalJson(parsed) !== value) throw new Error('non-canonical JSON'); + } catch (cause) { + throw semantic(`${label} is not canonical JSON`, cause); + } + return parsed; +} + +function safeTimestamp(value, label) { + try { + return canonicalTimestamp(value, label); + } catch (cause) { + throw semantic(`${label} is invalid`, cause); + } +} + +function safeToken(value, label) { + try { + return canonicalToken(value, label); + } catch (cause) { + throw semantic(`${label} is invalid`, cause); + } +} + +function number(value, label) { + const converted = typeof value === 'bigint' ? Number(value) : value; + if (!Number.isSafeInteger(converted)) throw semantic(`${label} is not a safe integer`); + return converted; +} + +function mapBy(rows, key) { + const mapped = new Map(); + for (const row of rows) { + const value = row[key]; + const existing = mapped.get(value) ?? []; + existing.push(row); + mapped.set(value, existing); + } + return mapped; +} + +function one(mapped, key, label) { + const rows = mapped.get(key) ?? []; + if (rows.length > 1) throw semantic(`${label} is ambiguous`); + return rows[0] ?? null; +} + +function exactSemantic(value, required, optional, label) { + try { + return exactRecord( + value, + required, + optional, + 'AUTHORITY_SEMANTIC_CORRUPTION', + label, + ); + } catch (cause) { + throw semantic(`${label} has an invalid closed shape`, cause); + } +} + +function canonicalAtomic(value, label) { + if (typeof value !== 'string' || !/^(?:0|[1-9][0-9]*)$/.test(value)) { + throw semantic(`${label} is not canonical atomic text`); + } + return value; +} + +function entityEvents(snapshot, entityType, entityId) { + return snapshot.events.filter((event) => ( + event.entity_type === entityType && event.entity_id === entityId + )); +} + +function bindingIdFor(sessionId) { + const digest = sha256(canonicalJson({ + domain: 'wallet-kernel.session-binding.v1', + sessionId, + })); + return `binding-${digest.slice('sha256:'.length)}`; +} + +function sessionAuthorityHash(session, binding, { + sessionState = session.state, + sessionClosedAt = session.closed_at, + bindingState = binding.state, + bindingLastSeenAt = binding.last_seen_at, + bindingClosedAt = binding.closed_at, +} = {}) { + return sha256(canonicalJson({ + session: { + id: session.id, + adapterId: session.adapter_id, + walletAddress: session.wallet_address, + policyVersionId: session.policy_version_id, + state: sessionState, + createdAt: session.created_at, + closedAt: sessionClosedAt, + }, + binding: { + id: binding.id, + agentInstanceId: binding.agent_instance_id, + credentialDigest: binding.credential_digest, + enrollmentHash: binding.enrollment_hash, + sessionId: session.id, + state: bindingState, + createdAt: binding.created_at, + lastSeenAt: bindingLastSeenAt, + closedAt: bindingClosedAt, + }, + })); +} + +function verifyEventChain(events) { + let previousHash = null; + for (const row of events) { + const data = parseCanonical(row.data_json, 'event data'); + const createdAt = safeTimestamp(row.created_at, 'event timestamp'); + safeToken(row.entity_type, 'event entity type'); + safeToken(row.entity_id, 'event entity ID'); + safeToken(row.event_type, 'event type'); + const expected = sha256(canonicalJson({ + entityType: row.entity_type, + entityId: row.entity_id, + eventType: row.event_type, + data, + previousHash, + createdAt, + })); + if (row.previous_hash !== previousHash || row.event_hash !== expected) { + throw semantic('authority event hash chain is invalid'); + } + previousHash = row.event_hash; + } +} + +function verifyPolicies(snapshot) { + const policies = new Map(); + let predecessor = null; + let predecessorAppliedAt = null; + for (const row of snapshot.policy_versions) { + safeToken(row.id, 'PolicyVersion ID'); + const document = parseCanonical(row.canonical_json, 'PolicyVersion document'); + let policy; + try { + policy = validatePolicyDocument(document); + } catch (cause) { + throw semantic('PolicyVersion document is invalid', cause); + } + if (canonicalJson(policy) !== row.canonical_json + || canonicalHash(row.policy_hash, 'PolicyVersion hash') !== sha256(row.canonical_json) + || number(row.schema_version, 'PolicyVersion schema version') !== policy.schemaVersion) { + throw semantic('PolicyVersion immutable binding changed'); + } + if (row.predecessor_hash !== null) canonicalHash(row.predecessor_hash, 'policy predecessor'); + const appliedAt = safeTimestamp(row.applied_at, 'PolicyVersion appliedAt'); + if (row.predecessor_hash !== predecessor + || (predecessorAppliedAt !== null + && Date.parse(appliedAt) < Date.parse(predecessorAppliedAt))) { + throw semantic('PolicyVersion predecessor chain or chronology changed'); + } + const events = entityEvents(snapshot, 'policy', row.id); + if (events.length !== 1 || events[0].event_type !== 'policy.applied') { + throw semantic('PolicyVersion application event is incomplete or ambiguous'); + } + const eventData = exactSemantic(parseCanonical( + events[0].data_json, + 'PolicyVersion applied event data', + ), ['policyHash', 'predecessorHash', 'blockedSessionIds'], [], 'policy.applied event'); + if (eventData.policyHash !== row.policy_hash + || eventData.predecessorHash !== row.predecessor_hash + || !Array.isArray(eventData.blockedSessionIds) + || eventData.blockedSessionIds.some((id) => typeof id !== 'string') + || Date.parse(events[0].created_at) < Date.parse(appliedAt)) { + throw semantic('PolicyVersion application event binding changed'); + } + policies.set(row.id, Object.freeze({ row, policy })); + predecessor = row.policy_hash; + predecessorAppliedAt = appliedAt; + } + const activeRows = snapshot.metadata.filter((row) => row.key === 'active_policy_id'); + if (activeRows.length > 1) throw semantic('active PolicyVersion metadata is ambiguous'); + const activePolicyId = activeRows[0]?.value ?? null; + if (activePolicyId !== null && !policies.has(activePolicyId)) { + throw semantic('active PolicyVersion metadata is dangling'); + } + const latestPolicyId = snapshot.policy_versions.at(-1)?.id ?? null; + if (activePolicyId !== latestPolicyId) { + throw semantic('active PolicyVersion is not the exact chain head'); + } + return Object.freeze({ activePolicyId, policies }); +} + +function verifyEnrollments(snapshot) { + const byHash = new Map(); + let activeCount = 0; + for (const row of snapshot.agent_enrollments) { + if (typeof row.agent_instance_id !== 'string' + || !INSTANCE_ID_PATTERN.test(row.agent_instance_id) + || Buffer.from(row.agent_instance_id, 'base64url').length !== 16 + || Buffer.from(row.agent_instance_id, 'base64url').toString('base64url') + !== row.agent_instance_id) { + throw semantic('agent enrollment instance ID is invalid'); + } + canonicalHash(row.credential_digest, 'agent credential digest'); + canonicalHash(row.enrollment_hash, 'agent enrollment hash'); + canonicalHash(row.enrolled_by_operator_hash, 'agent enrollment operator hash'); + if (!/^[1-9][0-9]*$/.test(row.agent_uid) || !/^[1-9][0-9]*$/.test(row.agent_gid)) { + throw semantic('agent enrollment identity is invalid'); + } + const descriptor = { + schemaVersion: 1, + agentInstanceId: row.agent_instance_id, + credentialDigest: row.credential_digest, + agentUid: row.agent_uid, + agentGid: row.agent_gid, + }; + if (sha256(canonicalJson(descriptor)) !== row.enrollment_hash) { + throw semantic('agent enrollment hash changed'); + } + if (byHash.has(row.enrollment_hash)) { + throw semantic('agent enrollment hash is ambiguous'); + } + const enrolledAt = safeTimestamp(row.enrolled_at, 'agent enrolledAt'); + const events = entityEvents(snapshot, 'agent_enrollment', row.agent_instance_id); + const expectedTypes = row.state === 'revoked' + ? ['agent.enrolled', 'agent.revoked'] + : ['agent.enrolled']; + if (canonicalJson(events.map((event) => event.event_type)) !== canonicalJson(expectedTypes)) { + throw semantic('agent enrollment event lifecycle is missing, duplicated, or reordered'); + } + const enrolledEvent = exactSemantic( + parseCanonical(events[0].data_json, 'agent enrollment event data'), + [ + 'enrollmentHash', 'credentialDigest', 'agentUid', 'agentGid', + 'operatorIdHash', 'isolation', 'enrolledAt', + ], + [], + 'agent.enrolled event', + ); + if (enrolledEvent.enrollmentHash !== row.enrollment_hash + || enrolledEvent.credentialDigest !== row.credential_digest + || enrolledEvent.agentUid !== row.agent_uid + || enrolledEvent.agentGid !== row.agent_gid + || enrolledEvent.operatorIdHash !== row.enrolled_by_operator_hash + || enrolledEvent.enrolledAt !== enrolledAt + || !new Set(['simulated', 'pending_verification']).has(enrolledEvent.isolation) + || Date.parse(safeTimestamp(events[0].created_at, 'agent enrolled event createdAt')) + < Date.parse(enrolledAt)) { + throw semantic('agent enrollment creation event binding changed'); + } + if (row.state === 'active') { + activeCount += 1; + if (row.revoked_by_operator_hash !== null || row.revoked_at !== null) { + throw semantic('active enrollment contains revocation fields'); + } + } else if (row.state === 'revoked') { + canonicalHash(row.revoked_by_operator_hash, 'agent revocation operator hash'); + const revokedAt = safeTimestamp(row.revoked_at, 'agent revokedAt'); + if (Date.parse(revokedAt) < Date.parse(enrolledAt)) { + throw semantic('agent revocation predates enrollment'); + } + const revokedEvent = exactSemantic( + parseCanonical(events[1].data_json, 'agent revocation event data'), + ['enrollmentHash', 'operatorIdHash', 'boundSessionIds', 'revokedAt'], + [], + 'agent.revoked event', + ); + if (!Array.isArray(revokedEvent.boundSessionIds) + || revokedEvent.boundSessionIds.some((id) => { + try { safeToken(id, 'revoked bound session ID'); return false; } catch { return true; } + })) { + throw semantic('agent revocation event session binding list is invalid'); + } + const expectedBoundSessionIds = snapshot.agent_session_bindings + .filter((binding) => { + if (binding.enrollment_hash !== row.enrollment_hash) return false; + const createdAt = safeTimestamp(binding.created_at, 'revoked binding createdAt'); + const closedAt = binding.closed_at === null + ? null + : safeTimestamp(binding.closed_at, 'revoked binding closedAt'); + return Date.parse(createdAt) <= Date.parse(revokedAt) + && (closedAt === null || Date.parse(closedAt) > Date.parse(revokedAt)); + }) + .map((binding) => binding.session_id) + .sort(); + if (revokedEvent.enrollmentHash !== row.enrollment_hash + || revokedEvent.operatorIdHash !== row.revoked_by_operator_hash + || revokedEvent.revokedAt !== revokedAt + || canonicalJson(revokedEvent.boundSessionIds) !== canonicalJson(expectedBoundSessionIds) + || Date.parse(safeTimestamp(events[1].created_at, 'agent revoked event createdAt')) + < Date.parse(revokedAt)) { + throw semantic('agent revocation event binding changed'); + } + } else { + throw semantic('agent enrollment state is invalid'); + } + byHash.set(row.enrollment_hash, Object.freeze({ + ...row, + isolation: enrolledEvent.isolation, + })); + } + if (activeCount > 1) throw semantic('multiple active enrollments exist'); + const identities = new Set(snapshot.agent_enrollments.map((row) => row.agent_instance_id)); + if (snapshot.events.some((event) => ( + event.entity_type === 'agent_enrollment' && !identities.has(event.entity_id) + ))) { + throw semantic('orphan agent enrollment lifecycle event exists'); + } + return byHash; +} + +function verifySessions(snapshot, policyAuthority, enrollments) { + const bindingsBySession = mapBy(snapshot.agent_session_bindings, 'session_id'); + const bindingsById = new Map(snapshot.agent_session_bindings.map((row) => [row.id, row])); + const sessionRowsById = new Map(snapshot.spend_sessions.map((row) => [row.id, row])); + const sessions = new Map(); + const seenOpenEnrollment = new Set(); + for (const session of snapshot.spend_sessions) { + safeToken(session.id, 'Spend Session ID'); + canonicalAddress(session.wallet_address, 'Spend Session wallet'); + safeTimestamp(session.created_at, 'Spend Session createdAt'); + const policy = policyAuthority.policies.get(session.policy_version_id); + if (!policy || policy.policy.wallet !== session.wallet_address) { + throw semantic('Spend Session policy binding changed'); + } + const bindings = bindingsBySession.get(session.id) ?? []; + if (bindings.length !== 1) { + throw semantic('Spend Session must have exactly one agent binding'); + } + const binding = bindings[0]; + const enrollment = enrollments.get(binding.enrollment_hash); + if (!enrollment + || binding.id !== bindingIdFor(session.id) + || binding.agent_instance_id !== enrollment.agent_instance_id + || binding.credential_digest !== enrollment.credential_digest + || session.adapter_id !== `pi:${enrollment.agent_instance_id}` + || binding.created_at !== session.created_at) { + throw semantic('Spend Session and enrollment binding disagree'); + } + const initialSessionHash = sessionAuthorityHash(session, binding, { + sessionState: 'open', + sessionClosedAt: null, + bindingState: 'open', + bindingLastSeenAt: session.created_at, + bindingClosedAt: null, + }); + const sessionEvents = entityEvents(snapshot, 'spend_session', session.id); + const bindingEvents = entityEvents(snapshot, 'session_binding', binding.id); + const startedEvents = sessionEvents.filter((event) => event.event_type === 'session.started'); + const openedEvents = bindingEvents.filter( + (event) => event.event_type === 'session.binding_opened', + ); + if (startedEvents.length !== 1 || openedEvents.length !== 1 + || sessionEvents.some((event) => !new Set([ + 'session.started', 'session.policy_blocked', 'session.closed', + 'session.policy_transitioned', + ]).has(event.event_type)) + || bindingEvents.some((event) => !new Set([ + 'session.binding_opened', 'session.binding_closed', + ]).has(event.event_type))) { + throw semantic('Spend Session genesis or lifecycle events are incomplete'); + } + const started = exactSemantic( + parseCanonical(startedEvents[0].data_json, 'session.started event data'), + [ + 'adapterId', 'enrollmentHash', 'policyVersionId', 'sessionHash', + 'walletAddress', 'createdAt', + ], + [], + 'session.started event', + ); + const opened = exactSemantic( + parseCanonical(openedEvents[0].data_json, 'session.binding_opened event data'), + ['agentInstanceId', 'enrollmentHash', 'sessionId', 'createdAt'], + [], + 'session.binding_opened event', + ); + if (started.adapterId !== session.adapter_id + || started.enrollmentHash !== binding.enrollment_hash + || started.policyVersionId !== session.policy_version_id + || started.sessionHash !== initialSessionHash + || started.walletAddress !== session.wallet_address + || started.createdAt !== session.created_at + || opened.agentInstanceId !== binding.agent_instance_id + || opened.enrollmentHash !== binding.enrollment_hash + || opened.sessionId !== session.id + || opened.createdAt !== session.created_at + || Date.parse(safeTimestamp( + startedEvents[0].created_at, + 'session.started event createdAt', + )) < Date.parse(session.created_at) + || Date.parse(safeTimestamp( + openedEvents[0].created_at, + 'session.binding_opened event createdAt', + )) < Date.parse(session.created_at)) { + throw semantic('Spend Session genesis event binding changed'); + } + const bindingLastSeenAt = safeTimestamp( + binding.last_seen_at, + 'session binding lastSeenAt', + ); + if (Date.parse(bindingLastSeenAt) < Date.parse(session.created_at)) { + throw semantic('Spend Session binding chronology regressed'); + } + const open = session.state === 'open' || session.state === 'policy_blocked'; + if (open) { + if (binding.state !== 'open' || session.closed_at !== null || binding.closed_at !== null) { + throw semantic('open Spend Session has a closed or partial binding'); + } + const identity = `${binding.agent_instance_id}\u0000${binding.credential_digest}`; + if (seenOpenEnrollment.has(identity)) { + throw semantic('agent enrollment owns more than one open session candidate'); + } + seenOpenEnrollment.add(identity); + if (session.state === 'open' + && policyAuthority.activePolicyId !== null + && session.policy_version_id !== policyAuthority.activePolicyId) { + throw semantic('open Spend Session does not use the active policy'); + } + if (session.state === 'policy_blocked' + && session.policy_version_id === policyAuthority.activePolicyId) { + throw semantic('policy-blocked Spend Session still uses the active policy'); + } + const blockedEvents = sessionEvents.filter( + (event) => event.event_type === 'session.policy_blocked', + ); + const terminalEvents = sessionEvents.filter((event) => ( + event.event_type === 'session.closed' + || event.event_type === 'session.policy_transitioned' + )); + const closedBindingEvents = bindingEvents.filter( + (event) => event.event_type === 'session.binding_closed', + ); + if (terminalEvents.length !== 0 || closedBindingEvents.length !== 0 + || (session.state === 'open' && blockedEvents.length !== 0) + || (session.state === 'policy_blocked' && blockedEvents.length !== 1)) { + throw semantic('open Spend Session event lifecycle disagrees with its state'); + } + if (blockedEvents.length === 1) { + const blocked = exactSemantic( + parseCanonical(blockedEvents[0].data_json, 'session.policy_blocked event data'), + ['previousPolicyVersionId', 'targetPolicyVersionId'], + [], + 'session.policy_blocked event', + ); + const targetPolicy = policyAuthority.policies.get(blocked.targetPolicyVersionId); + const targetEvent = targetPolicy === undefined + ? null + : entityEvents(snapshot, 'policy', targetPolicy.row.id)[0]; + const targetData = targetEvent === null + ? null + : parseCanonical(targetEvent.data_json, 'blocking policy event data'); + if (blocked.previousPolicyVersionId !== session.policy_version_id + || targetPolicy === undefined + || !Array.isArray(targetData?.blockedSessionIds) + || !targetData.blockedSessionIds.includes(session.id)) { + throw semantic('policy-blocked Spend Session event binding changed'); + } + } + } else if (session.state === 'closed') { + const closedAt = safeTimestamp(session.closed_at, 'Spend Session closedAt'); + if (binding.state !== 'closed' || binding.closed_at !== closedAt + || binding.last_seen_at !== closedAt + || Date.parse(closedAt) < Date.parse(session.created_at)) { + throw semantic('closed Spend Session and binding disagree'); + } + const closedBindingEvents = bindingEvents.filter( + (event) => event.event_type === 'session.binding_closed', + ); + const terminalEvents = sessionEvents.filter((event) => ( + event.event_type === 'session.closed' + || event.event_type === 'session.policy_transitioned' + )); + if (closedBindingEvents.length !== 1 || terminalEvents.length !== 1) { + throw semantic('closed Spend Session lifecycle event is missing or ambiguous'); + } + const terminalType = terminalEvents[0].event_type; + const closedBinding = exactSemantic( + parseCanonical(closedBindingEvents[0].data_json, 'session binding close event data'), + ['sessionId', 'closedAt', 'reasonCode'], + [], + 'session.binding_closed event', + ); + if (closedBinding.sessionId !== session.id || closedBinding.closedAt !== closedAt + || closedBinding.reasonCode !== (terminalType === 'session.closed' + ? 'SESSION_CLOSED' + : 'POLICY_SUPERSEDED')) { + throw semantic('closed Spend Session binding event changed'); + } + const closedSessionHash = sessionAuthorityHash(session, binding); + if (terminalType === 'session.closed') { + const closed = exactSemantic( + parseCanonical(terminalEvents[0].data_json, 'session.closed event data'), + ['expectedSessionHash', 'closedSessionHash', 'closedAt'], + [], + 'session.closed event', + ); + if (closed.closedAt !== closedAt + || canonicalHash(closed.expectedSessionHash, 'expected closed session hash') + !== closed.expectedSessionHash + || closed.closedSessionHash !== closedSessionHash) { + throw semantic('session.closed event binding changed'); + } + } else { + const transitioned = exactSemantic( + parseCanonical(terminalEvents[0].data_json, 'session transition event data'), + [ + 'expectedSessionHash', 'targetPolicyVersionId', 'closedSessionHash', + 'replacementSessionId', 'replacementSessionHash', 'transitionedAt', + ], + [], + 'session.policy_transitioned event', + ); + const replacement = sessionRowsById.get(transitioned.replacementSessionId); + const replacementBindings = replacement === undefined + ? [] + : bindingsBySession.get(replacement.id) ?? []; + const replacementBinding = replacementBindings.length === 1 + ? replacementBindings[0] + : null; + const replacementInitialHash = replacementBinding === null + ? null + : sessionAuthorityHash(replacement, replacementBinding, { + sessionState: 'open', + sessionClosedAt: null, + bindingState: 'open', + bindingLastSeenAt: replacement.created_at, + bindingClosedAt: null, + }); + if (transitioned.transitionedAt !== closedAt + || canonicalHash(transitioned.expectedSessionHash, 'transition expected session hash') + !== transitioned.expectedSessionHash + || transitioned.closedSessionHash !== closedSessionHash + || transitioned.targetPolicyVersionId !== replacement?.policy_version_id + || replacement?.created_at !== closedAt + || transitioned.replacementSessionHash !== replacementInitialHash) { + throw semantic('session.policy_transitioned event binding changed'); + } + } + if (Date.parse(safeTimestamp( + closedBindingEvents[0].created_at, + 'session binding closed event createdAt', + )) < Date.parse(closedAt) + || Date.parse(safeTimestamp( + terminalEvents[0].created_at, + 'session terminal event createdAt', + )) < Date.parse(closedAt)) { + throw semantic('closed Spend Session event chronology regressed'); + } + } else { + throw semantic('Spend Session state is invalid'); + } + sessions.set(session.id, Object.freeze({ session, binding, enrollment, policy })); + } + if (snapshot.agent_session_bindings.length !== snapshot.spend_sessions.length) { + throw semantic('dangling agent session binding exists'); + } + if (snapshot.events.some((event) => ( + (event.entity_type === 'spend_session' && !sessionRowsById.has(event.entity_id)) + || (event.entity_type === 'session_binding' && !bindingsById.has(event.entity_id)) + ))) { + throw semantic('orphan Spend Session lifecycle event exists'); + } + const activeEnrollment = [...enrollments.values()].find((row) => row.state === 'active') ?? null; + if (activeEnrollment) { + const activeOpenBinding = snapshot.agent_session_bindings.some((binding) => ( + binding.enrollment_hash === activeEnrollment.enrollment_hash && binding.state === 'open' + )); + const revokedOpenBinding = snapshot.agent_session_bindings.some((binding) => ( + binding.state === 'open' + && enrollments.get(binding.enrollment_hash)?.state === 'revoked' + )); + if (!activeOpenBinding && revokedOpenBinding) { + throw semantic('active unbound enrollment conflicts with retained revoked open authority'); + } + } + return sessions; +} + +function verifyIsolation(snapshot, enrollments, startupAt) { + let current = null; + const attestationIds = new Set(snapshot.isolation_attestations.map((row) => row.id)); + for (const row of snapshot.isolation_attestations) { + safeToken(row.id, 'isolation attestation ID'); + const enrollment = enrollments.get(row.enrollment_hash); + if (!enrollment) throw semantic('isolation attestation enrollment is missing'); + const report = parseCanonical(row.report_json, 'isolation attestation report'); + if (canonicalHash(row.report_hash, 'isolation report hash') !== sha256(canonicalJson(report))) { + throw semantic('isolation attestation report hash changed'); + } + canonicalHash(row.imported_by_operator_hash, 'isolation attestation operator hash'); + const probedAt = safeTimestamp(row.probed_at, 'isolation probedAt'); + const expiresAt = safeTimestamp(row.expires_at, 'isolation expiresAt'); + const importedAt = safeTimestamp(row.imported_at, 'isolation importedAt'); + if (Date.parse(expiresAt) <= Date.parse(probedAt) + || Date.parse(expiresAt) - Date.parse(probedAt) > 15 * 60 * 1_000 + || Date.parse(importedAt) < Date.parse(probedAt) + || Date.parse(importedAt) >= Date.parse(expiresAt)) { + throw semantic('isolation attestation chronology is invalid'); + } + if (row.state === 'current') { + if (current !== null || enrollment.state !== 'active' + || enrollment.isolation !== 'pending_verification' + || row.superseded_at !== null) { + throw semantic('current isolation attestation is ambiguous or misbound'); + } + let normalized; + try { + normalized = exactRecord(report, [ + 'schemaVersion', 'enrollmentHash', 'kernelUid', 'kernelGid', + 'agentUid', 'agentGid', 'authorityMetadataHash', 'credentialMetadataHash', + 'releaseManifestHash', 'releaseTreeHash', 'nodeExecutableHash', + 'serviceArtifactsHash', 'systemdEffectiveConfigHash', + 'environmentMetadataHash', 'probeResults', 'probedAt', 'expiresAt', + ], [], 'AUTHORITY_SEMANTIC_CORRUPTION', 'current isolation report'); + } catch (cause) { + throw semantic('current isolation report shape is invalid', cause); + } + if (normalized.schemaVersion !== 1 + || normalized.enrollmentHash !== enrollment.enrollment_hash + || normalized.agentUid !== enrollment.agent_uid + || normalized.agentGid !== enrollment.agent_gid + || !/^[1-9][0-9]*$/.test(normalized.kernelUid) + || !/^[1-9][0-9]*$/.test(normalized.kernelGid) + || normalized.kernelUid === normalized.agentUid + || normalized.probedAt !== probedAt + || normalized.expiresAt !== expiresAt + || Date.parse(importedAt) > Date.parse(startupAt) + || Date.parse(expiresAt) <= Date.parse(startupAt)) { + throw semantic('current isolation report identity or time binding changed'); + } + for (const name of [ + 'authorityMetadataHash', 'credentialMetadataHash', 'releaseManifestHash', + 'releaseTreeHash', 'nodeExecutableHash', 'serviceArtifactsHash', + 'systemdEffectiveConfigHash', 'environmentMetadataHash', + ]) canonicalHash(normalized[name], `isolation report ${name}`); + let probes; + try { + probes = exactRecord( + normalized.probeResults, + Object.keys(ENFORCED_ISOLATION_PROBES), + [], + 'AUTHORITY_SEMANTIC_CORRUPTION', + 'isolation probe results', + ); + } catch (cause) { + throw semantic('isolation probe result shape is invalid', cause); + } + if (Object.entries(ENFORCED_ISOLATION_PROBES) + .some(([name, result]) => probes[name] !== result)) { + throw semantic('current isolation report does not prove enforced isolation'); + } + current = row; + } else if (row.state === 'superseded') { + const supersededAt = safeTimestamp(row.superseded_at, 'isolation supersededAt'); + if (Date.parse(supersededAt) < Date.parse(importedAt)) { + throw semantic('isolation supersession predates import'); + } + const events = entityEvents(snapshot, 'isolation_attestation', row.id) + .filter((event) => event.event_type === 'isolation.attestation_superseded'); + if (events.length !== 1) { + throw semantic('isolation attestation supersession event is missing or ambiguous'); + } + const event = exactSemantic( + parseCanonical(events[0].data_json, 'isolation supersession event data'), + ['enrollmentHash', 'reportHash', 'supersededAt', 'reasonCode'], + [], + 'isolation.attestation_superseded event', + ); + const replacementRows = snapshot.isolation_attestations.filter((candidate) => ( + candidate.id !== row.id + && candidate.enrollment_hash === row.enrollment_hash + && candidate.imported_at === supersededAt + && candidate.report_hash !== row.report_hash + )); + const reasonIsBound = event.reasonCode === 'AGENT_REVOKED' + ? enrollment.state === 'revoked' && enrollment.revoked_at === supersededAt + : event.reasonCode === 'ATTESTATION_REPLACED' && replacementRows.length === 1; + if (event.enrollmentHash !== row.enrollment_hash + || event.reportHash !== row.report_hash + || event.supersededAt !== supersededAt + || !reasonIsBound + || Date.parse(safeTimestamp( + events[0].created_at, + 'isolation supersession event createdAt', + )) < Date.parse(supersededAt)) { + throw semantic('isolation attestation supersession binding changed'); + } + } else { + throw semantic('isolation attestation state is invalid'); + } + } + if (snapshot.events.some((event) => ( + event.entity_type === 'isolation_attestation' && !attestationIds.has(event.entity_id) + ))) { + throw semantic('orphan isolation attestation lifecycle event exists'); + } +} + +function verifyApprovalHistory(snapshot) { + const approvalsById = new Map(); + for (const row of snapshot.approvals) { + safeToken(row.id, 'Approval ID'); + if (approvalsById.has(row.id)) throw semantic('Approval identity is ambiguous'); + approvalsById.set(row.id, row); + canonicalHash(row.intent_hash, 'Approval intent hash'); + canonicalHash(row.challenge_hash, 'Approval challenge hash'); + canonicalHash(row.quote_id, 'Approval quote ID'); + canonicalAddress(row.wallet_address, 'Approval wallet'); + safeToken(row.policy_version_id, 'Approval PolicyVersion ID'); + canonicalAtomic(row.amount_ceiling_atomic, 'Approval amount ceiling'); + const acceptedIndex = number(row.accepted_index, 'Approval accepted index'); + if (acceptedIndex < 0) throw semantic('Approval accepted index is negative'); + const expiresAt = safeTimestamp(row.expires_at, 'Approval expiresAt'); + const decidedAt = row.decided_at === null + ? null + : safeTimestamp(row.decided_at, 'Approval decidedAt'); + const consumedAt = row.consumed_at === null + ? null + : safeTimestamp(row.consumed_at, 'Approval consumedAt'); + if (row.operator_id_hash !== null) { + canonicalHash(row.operator_id_hash, 'Approval operator hash'); + } + if (row.reason_code !== null) canonicalReason(row.reason_code, 'Approval reason'); + const lifecycleValid = (row.decision === 'pending' + && row.operator_id_hash === null && row.reason_code === null + && decidedAt === null && consumedAt === null) + || (row.decision === 'approved' + && row.operator_id_hash !== null && row.reason_code === null + && decidedAt !== null && consumedAt === null) + || (row.decision === 'denied' + && row.operator_id_hash !== null && row.reason_code !== null + && decidedAt !== null && consumedAt === null) + || (row.decision === 'expired' + && row.reason_code === 'APPROVAL_EXPIRED' + && decidedAt !== null && consumedAt === null) + || (row.decision === 'cancelled' + && APPROVAL_CANCEL_REASONS.has(row.reason_code) + && decidedAt !== null && consumedAt === null) + || (row.decision === 'consumed' + && row.operator_id_hash !== null && row.reason_code === null + && decidedAt !== null && consumedAt !== null + && Date.parse(consumedAt) >= Date.parse(decidedAt)); + if (!lifecycleValid) throw semantic('Approval row lifecycle is invalid'); + + const events = entityEvents(snapshot, 'approval', row.id); + const types = events.map((event) => event.event_type); + let expectedTypes; + if (row.decision === 'pending') expectedTypes = ['approval.requested']; + else if (row.decision === 'approved') { + expectedTypes = ['approval.requested', 'approval.approved']; + } else if (row.decision === 'denied') { + expectedTypes = ['approval.requested', 'approval.denied']; + } else if (row.decision === 'consumed') { + expectedTypes = ['approval.requested', 'approval.approved', 'approval.consumed']; + } else { + const priorApproved = events.some((event) => event.event_type === 'approval.approved'); + expectedTypes = [ + 'approval.requested', + ...(priorApproved ? ['approval.approved'] : []), + `approval.${row.decision}`, + ]; + } + if (canonicalJson(types) !== canonicalJson(expectedTypes)) { + throw semantic('Approval event lifecycle is missing, duplicated, or reordered'); + } + let predecessorAt = null; + for (const [index, event] of events.entries()) { + const data = parseCanonical(event.data_json, 'Approval event data'); + let transitionAt; + if (event.event_type === 'approval.requested') { + const bound = exactSemantic(data, [ + 'intentId', 'intentHash', 'challengeHash', 'quoteId', 'amountCeilingAtomic', + 'walletAddress', 'policyVersionId', 'acceptedIndex', 'expiresAt', 'requestedAt', + ], [], 'approval.requested event'); + if (bound.intentId !== row.intent_id + || bound.intentHash !== row.intent_hash + || bound.challengeHash !== row.challenge_hash + || bound.quoteId !== row.quote_id + || bound.amountCeilingAtomic !== row.amount_ceiling_atomic + || bound.walletAddress !== row.wallet_address + || bound.policyVersionId !== row.policy_version_id + || number(bound.acceptedIndex, 'approval event accepted index') !== acceptedIndex + || bound.expiresAt !== expiresAt) { + throw semantic('approval.requested event immutable binding changed'); + } + transitionAt = safeTimestamp(bound.requestedAt, 'approval requestedAt'); + if (Date.parse(transitionAt) >= Date.parse(expiresAt)) { + throw semantic('Approval was requested after immutable expiry'); + } + } else if (event.event_type === 'approval.approved') { + const bound = exactSemantic(data, [ + 'intentId', 'intentHash', 'operatorIdHash', 'approvedAt', + ], [], 'approval.approved event'); + if (bound.intentId !== row.intent_id || bound.intentHash !== row.intent_hash + || bound.operatorIdHash !== row.operator_id_hash) { + throw semantic('approval.approved event binding changed'); + } + transitionAt = safeTimestamp(bound.approvedAt, 'approval approvedAt'); + if (row.decision === 'approved' || row.decision === 'consumed') { + if (transitionAt !== decidedAt) throw semantic('Approval approvedAt changed'); + } + } else if (event.event_type === 'approval.denied') { + const bound = exactSemantic(data, [ + 'intentId', 'intentHash', 'operatorIdHash', 'reasonCode', 'deniedAt', + ], [], 'approval.denied event'); + if (bound.intentId !== row.intent_id || bound.intentHash !== row.intent_hash + || bound.operatorIdHash !== row.operator_id_hash + || bound.reasonCode !== row.reason_code) { + throw semantic('approval.denied event binding changed'); + } + transitionAt = safeTimestamp(bound.deniedAt, 'approval deniedAt'); + } else if (event.event_type === 'approval.consumed') { + const bound = exactSemantic(data, [ + 'intentId', 'intentHash', 'consumedAt', + ], [], 'approval.consumed event'); + if (bound.intentId !== row.intent_id || bound.intentHash !== row.intent_hash) { + throw semantic('approval.consumed event binding changed'); + } + transitionAt = safeTimestamp(bound.consumedAt, 'approval consumedAt'); + if (transitionAt !== consumedAt) throw semantic('Approval consumedAt changed'); + } else { + const timestampField = row.decision === 'expired' ? 'expiredAt' : 'cancelledAt'; + const bound = exactSemantic(data, [ + 'intentId', 'intentHash', 'previousDecision', 'reasonCode', timestampField, + ], [], `approval.${row.decision} event`); + if (bound.intentId !== row.intent_id || bound.intentHash !== row.intent_hash + || !OPEN_APPROVALS.has(bound.previousDecision) + || bound.reasonCode !== row.reason_code) { + throw semantic(`approval.${row.decision} event binding changed`); + } + transitionAt = safeTimestamp(bound[timestampField], `approval ${timestampField}`); + if (transitionAt !== decidedAt) throw semantic('Approval terminal time changed'); + } + const createdAt = safeTimestamp(event.created_at, 'Approval event createdAt'); + if (Date.parse(createdAt) < Date.parse(transitionAt) + || (predecessorAt !== null + && Date.parse(transitionAt) < Date.parse(predecessorAt))) { + throw semantic('Approval event chronology regressed'); + } + if (index === events.length - 1 && row.decision === 'expired' + && Date.parse(transitionAt) < Date.parse(expiresAt)) { + throw semantic('Approval expired before immutable expiry'); + } + predecessorAt = transitionAt; + } + } + const orphanEvents = snapshot.events.filter((event) => ( + event.entity_type === 'approval' && !approvalsById.has(event.entity_id) + )); + if (orphanEvents.length > 0) throw semantic('orphan Approval lifecycle event exists'); +} + +function verifyReceiptRows(snapshot, receipts) { + for (const row of snapshot.signed_receipts) { + let receipt; + try { receipt = JSON.parse(row.receipt_json); } catch (cause) { + throw semantic('signed receipt JSON is invalid', cause); + } + const record = { + id: row.id, + intentId: row.intent_id, + revision: number(row.revision, 'signed receipt revision'), + receipt, + receiptHash: row.receipt_hash, + signature: row.signature, + algorithm: row.algorithm, + keyId: row.key_id, + supersedesReceiptHash: row.supersedes_receipt_hash, + createdAt: row.created_at, + }; + if (!receipts.verify(record)) throw semantic('signed receipt signature or schema is invalid'); + } +} + +function reconciliationAuditAuthority(snapshot, policyAuthority, sessions, intentId) { + const intent = snapshot.spend_intents.find((row) => row.id === intentId); + if (!intent) throw semantic('reconciliation references a missing Spend Intent'); + const sessionAuthority = sessions.get(intent.session_id); + const decision = snapshot.policy_decisions.find((row) => row.intent_id === intentId); + const attempt = snapshot.payment_attempts.find((row) => row.intent_id === intentId); + if (!sessionAuthority || !decision || !attempt) { + throw semantic('reconciliation authority is incomplete'); + } + const policyVersion = policyAuthority.policies.get(decision.policy_version_id); + if (!policyVersion) throw semantic('reconciliation PolicyVersion is missing'); + const challenge = parseCanonical(intent.challenge_projection_json, 'reconciliation challenge'); + const acceptedIndex = number(decision.accepted_index, 'reconciliation accepted index'); + const selected = challenge.accepts?.[acceptedIndex]; + const seller = policyVersion.policy.sellers.find((candidate) => ( + candidate.origin === intent.seller_origin + && candidate.pathPrefixes.some((prefix) => intent.resource_path.startsWith(prefix)) + )); + if (!selected || !seller || selected.payTo !== seller.payTo + || selected.amount !== decision.amount_ceiling_atomic + || selected.network !== policyVersion.policy.network + || selected.asset !== policyVersion.policy.asset) { + throw semantic('reconciliation selected payment authority changed'); + } + return Object.freeze({ + intent, + session: sessionAuthority.session, + decision, + attempt, + policyVersion, + selected, + seller, + }); +} + +function auditedExecutionCaseHash(snapshot, authority, reconciliationEvent) { + const intentId = authority.intent.id; + const preceding = (event) => event.sequence < reconciliationEvent.sequence; + const recordedEvents = entityEvents(snapshot, 'execution_outcome', intentId) + .filter((event) => event.event_type === 'execution.recorded' && preceding(event)); + const openedEvents = entityEvents(snapshot, 'execution_resolution', intentId) + .filter((event) => event.event_type === 'execution_resolution.opened' && preceding(event)); + const outcomeEvents = entityEvents(snapshot, 'buyer_outcome', intentId) + .filter((event) => new Set([ + 'buyer_outcome.recorded', 'buyer_outcome.revised', + ]).has(event.event_type) && preceding(event)); + if (recordedEvents.length !== 1 || openedEvents.length !== 1 || outcomeEvents.length < 1) { + throw semantic('execution reconciliation predecessor history is incomplete or ambiguous'); + } + const recorded = exactSemantic(parseCanonical( + recordedEvents[0].data_json, + 'historical execution recorded event data', + ), [ + 'state', 'httpStatus', 'responseHash', 'metadataHash', 'reasonCode', 'recordedAt', + ], [], 'historical execution.recorded event'); + const resolution = exactSemantic(parseCanonical( + openedEvents[0].data_json, + 'historical execution resolution event data', + ), [ + 'intentId', 'state', 'reasonCode', 'blocksWallet', 'openedAt', + ], [], 'historical execution_resolution.opened event'); + const buyerOutcome = exactSemantic(parseCanonical( + outcomeEvents.at(-1).data_json, + 'historical BuyerOutcome event data', + ), [ + 'status', 'reasonCode', 'revision', 'recordedAt', + ], [], 'historical BuyerOutcome event'); + const httpStatus = recorded.httpStatus === null + ? null + : number(recorded.httpStatus, 'historical execution HTTP status'); + if (recorded.state !== 'unknown' + || (httpStatus !== null && (httpStatus < 100 || httpStatus > 599)) + || (recorded.responseHash !== null + && canonicalHash(recorded.responseHash, 'historical execution response hash') + !== recorded.responseHash) + || canonicalHash(recorded.metadataHash, 'historical execution metadata hash') + !== recorded.metadataHash + || canonicalReason(recorded.reasonCode, 'historical execution reason') + !== recorded.reasonCode + || resolution.intentId !== intentId + || resolution.state !== 'reconciliation_required' + || resolution.reasonCode !== recorded.reasonCode + || resolution.blocksWallet !== true + || buyerOutcome.status !== 'execution_unknown') { + throw semantic('execution reconciliation predecessor authority changed'); + } + const recordedAt = safeTimestamp(recorded.recordedAt, 'historical execution recordedAt'); + const openedAt = safeTimestamp(resolution.openedAt, 'historical execution resolution openedAt'); + safeTimestamp(buyerOutcome.recordedAt, 'historical BuyerOutcome recordedAt'); + if (Date.parse(reconciliationEvent.created_at) < Date.parse(recordedAt) + || Date.parse(reconciliationEvent.created_at) < Date.parse(openedAt)) { + throw semantic('execution reconciliation predecessor chronology regressed'); + } + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.execution-reconciliation-case.v1', + intentId, + intentHash: authority.intent.intent_hash, + transactionId: canonicalTransactionId( + authority.attempt.transaction_id, + 'historical execution payment transaction', + ), + execution: { + state: recorded.state, + httpStatus, + responseHash: recorded.responseHash, + metadataHash: recorded.metadataHash, + recordedAt, + }, + resolution: { + state: resolution.state, + reasonCode: resolution.reasonCode, + openedAt, + }, + buyerOutcomeRevision: number( + buyerOutcome.revision, + 'historical BuyerOutcome revision', + ), + })); +} + +function verifyReconciliationHistory(snapshot, policyAuthority, sessions) { + const histories = mapBy(snapshot.reconciliations, 'intent_id'); + const seenIds = new Set(); + for (const [intentId, rows] of histories) { + const authority = reconciliationAuditAuthority( + snapshot, + policyAuthority, + sessions, + intentId, + ); + let predecessorAt = null; + for (const row of rows) { + safeToken(row.id, 'reconciliation ID'); + if (seenIds.has(row.id)) throw semantic('reconciliation identity is ambiguous'); + seenIds.add(row.id); + canonicalHash(row.operator_id_hash, 'reconciliation operator hash'); + const recordedAt = safeTimestamp(row.recorded_at, 'reconciliation recordedAt'); + if (predecessorAt !== null && Date.parse(recordedAt) < Date.parse(predecessorAt)) { + throw semantic('reconciliation history chronology regressed'); + } + predecessorAt = recordedAt; + const evidence = parseCanonical(row.evidence_json, 'reconciliation evidence'); + const events = entityEvents(snapshot, 'reconciliation', row.id); + if (events.length !== 1 || events[0].event_type !== 'reconciliation.recorded') { + throw semantic('reconciliation event history is incomplete or ambiguous'); + } + const event = events[0]; + const eventData = exactSemantic(parseCanonical( + event.data_json, + 'reconciliation event data', + ), [ + 'intentId', 'kind', 'outcome', 'evidenceHash', 'operatorIdHash', + 'recordedAt', 'requestCaseHash', 'observedCaseHash', + ], [], 'reconciliation.recorded event'); + if (eventData.intentId !== intentId + || eventData.kind !== row.kind + || eventData.outcome !== row.outcome + || eventData.evidenceHash !== sha256(row.evidence_json) + || eventData.operatorIdHash !== row.operator_id_hash + || eventData.recordedAt !== recordedAt + || Date.parse(event.created_at) < Date.parse(recordedAt)) { + throw semantic('reconciliation event binding changed'); + } + canonicalHash(eventData.requestCaseHash, 'reconciliation requested case hash'); + canonicalHash(eventData.observedCaseHash, 'reconciliation observed case hash'); + + if (row.kind === 'payment' && row.outcome === 'settled') { + const proof = exactSemantic(evidence, [ + 'kind', 'transactionId', 'rpcProofHash', 'localAttemptHash', + ], [], 'settled payment reconciliation evidence'); + canonicalTransactionId(proof.transactionId, 'settled payment evidence transaction'); + canonicalHash(proof.rpcProofHash, 'settled payment RPC proof hash'); + canonicalHash(proof.localAttemptHash, 'settled payment local binding hash'); + if (proof.kind !== 'settled_transfer' + || authority.attempt.transaction_id !== proof.transactionId + || proof.localAttemptHash !== localAttemptBindingHash(authority)) { + throw semantic('settled payment reconciliation authority changed'); + } + const candidate = snapshot.payment_reconciliation_candidates.find( + (entry) => entry.intent_id === intentId + && entry.transaction_id === proof.transactionId, + ); + const persistedEvent = candidate + ? entityEvents(snapshot, 'payment_reconciliation_candidate', candidate.id) + .find((entry) => entry.event_type === 'payment.candidate_persisted') + : null; + if (!persistedEvent) throw semantic('settled payment candidate history is missing'); + } else if (row.kind === 'payment' && row.outcome === 'unresolved') { + const proof = exactSemantic(evidence, [ + 'kind', 'transactionId', 'reasonCode', 'rpcProofHash', + ], [], 'rejected payment candidate evidence'); + canonicalTransactionId(proof.transactionId, 'rejected payment candidate transaction'); + canonicalHash(proof.rpcProofHash, 'rejected payment candidate RPC proof hash'); + if (proof.kind !== 'payment_candidate_rejected' + || !new Set(['TRANSACTION_REVERTED', 'EXACT_TRANSFER_ABSENT']) + .has(proof.reasonCode)) { + throw semantic('rejected payment candidate evidence changed'); + } + const candidate = snapshot.payment_reconciliation_candidates.find( + (entry) => entry.intent_id === intentId + && entry.transaction_id === proof.transactionId, + ); + const persistedEvent = candidate + ? entityEvents(snapshot, 'payment_reconciliation_candidate', candidate.id) + .find((entry) => entry.event_type === 'payment.candidate_persisted') + : null; + if (!persistedEvent) throw semantic('rejected payment candidate history is missing'); + } else if (row.kind === 'payment' && row.outcome === 'rejected') { + const proof = exactSemantic(evidence, [ + 'kind', 'network', 'asset', 'payer', 'nonce', 'validBefore', + 'authorizationState', 'observedBlockNumber', 'observedBlockHash', + 'observedBlockTimestamp', 'confirmations', + ], [], 'unused authorization reconciliation evidence'); + canonicalTransactionId(proof.nonce, 'unused authorization nonce'); + canonicalTransactionId(proof.observedBlockHash, 'unused authorization block hash'); + canonicalAtomic(proof.validBefore, 'unused authorization validBefore'); + canonicalAtomic(proof.observedBlockNumber, 'unused authorization block number'); + canonicalAtomic(proof.observedBlockTimestamp, 'unused authorization block timestamp'); + if (proof.kind !== 'authorization_unused_after_expiry' + || proof.network !== authority.policyVersion.policy.network + || proof.asset !== authority.policyVersion.policy.asset + || proof.payer !== authority.session.wallet_address + || proof.nonce !== authority.attempt.nonce + || proof.validBefore !== authority.attempt.valid_before + || proof.authorizationState !== false + || !Number.isSafeInteger(proof.confirmations) + || proof.confirmations < 1) { + throw semantic('unused authorization reconciliation authority changed'); + } + if (eventData.requestCaseHash !== eventData.observedCaseHash) { + throw semantic('unused authorization reconciliation case binding changed'); + } + } else if (row.kind === 'execution' + && new Set(['execution_succeeded', 'execution_failed']).has(row.outcome)) { + const proof = exactSemantic(evidence, [ + 'kind', 'attestationHash', 'attestation', + ], [], 'execution reconciliation evidence'); + const attestation = exactSemantic(proof.attestation, [ + 'schemaVersion', 'domain', 'network', 'sellerOrigin', 'intentHash', + 'transactionId', 'outcome', 'httpStatus', 'responseHash', 'issuedAt', + 'expiresAt', 'signer', + ], [], 'execution reconciliation attestation'); + const issuedAt = safeTimestamp(attestation.issuedAt, 'execution attestation issuedAt'); + const expiresAt = safeTimestamp(attestation.expiresAt, 'execution attestation expiresAt'); + const expectedState = row.outcome === 'execution_succeeded' ? 'succeeded' : 'failed'; + const execution = snapshot.execution_outcomes.find( + (candidate) => candidate.intent_id === intentId, + ); + const metadata = execution + ? parseCanonical(execution.metadata_json, 'execution reconciliation metadata') + : null; + if (proof.kind !== 'execution_attested' + || canonicalHash(proof.attestationHash, 'execution attestation hash') + !== sha256(canonicalJson(attestation)) + || attestation.schemaVersion !== 1 + || attestation.domain !== 'wallet-kernel.execution.v1' + || attestation.network !== authority.policyVersion.policy.network + || attestation.sellerOrigin !== authority.intent.seller_origin + || attestation.intentHash !== authority.intent.intent_hash + || attestation.transactionId !== authority.attempt.transaction_id + || attestation.outcome !== expectedState + || attestation.signer !== authority.seller.executionSigner + || Date.parse(recordedAt) < Date.parse(issuedAt) + || Date.parse(recordedAt) >= Date.parse(expiresAt) + || Date.parse(expiresAt) - Date.parse(issuedAt) > 15 * 60 * 1_000 + || execution?.state !== expectedState + || number(execution.http_status, 'execution reconciliation HTTP status') + !== attestation.httpStatus + || execution.response_hash !== attestation.responseHash + || execution.recorded_at !== recordedAt + || metadata?.attestationHash !== proof.attestationHash) { + throw semantic('execution reconciliation attestation authority changed'); + } + const expectedCaseHash = auditedExecutionCaseHash(snapshot, authority, event); + if (eventData.requestCaseHash !== expectedCaseHash + || eventData.observedCaseHash !== expectedCaseHash) { + throw semantic('execution reconciliation case binding changed'); + } + } else if (row.kind === 'refund' && row.outcome === 'refund_rejected') { + const proof = exactSemantic(evidence, [ + 'kind', 'refundTransactionId', 'reasonCode', 'rpcProofHash', + ], [], 'rejected refund candidate evidence'); + canonicalTransactionId(proof.refundTransactionId, 'rejected refund transaction'); + canonicalHash(proof.rpcProofHash, 'rejected refund RPC proof hash'); + if (proof.kind !== 'refund_candidate_rejected' + || !new Set(['TRANSACTION_REVERTED', 'EXACT_TRANSFER_ABSENT']) + .has(proof.reasonCode)) { + throw semantic('rejected refund candidate evidence changed'); + } + const refund = snapshot.refunds.find((entry) => ( + entry.intent_id === intentId + && entry.refund_transaction_id === proof.refundTransactionId + )); + const persistedEvent = refund + ? entityEvents(snapshot, 'refund', refund.id) + .find((entry) => entry.event_type === 'refund.candidate_persisted') + : null; + if (!persistedEvent) throw semantic('rejected refund candidate history is missing'); + } else if (row.kind === 'refund' && row.outcome === 'refund_confirmed') { + const proof = exactSemantic(evidence, [ + 'kind', 'originalTransactionId', 'refundTransactionId', 'attestationHash', + 'attestation', 'rpcProofHash', 'localRefundBindingHash', + ], [], 'confirmed refund reconciliation evidence'); + const attestation = exactSemantic(proof.attestation, [ + 'schemaVersion', 'domain', 'network', 'sellerOrigin', 'intentHash', + 'originalTransactionId', 'refundTransactionId', 'asset', 'originalPayer', + 'originalPayee', 'refundSource', 'amountAtomic', 'issuedAt', 'expiresAt', 'signer', + ], [], 'confirmed refund attestation'); + const issuedAt = safeTimestamp(attestation.issuedAt, 'refund attestation issuedAt'); + const expiresAt = safeTimestamp(attestation.expiresAt, 'refund attestation expiresAt'); + canonicalHash(proof.rpcProofHash, 'confirmed refund RPC proof hash'); + canonicalTransactionId(proof.refundTransactionId, 'confirmed refund transaction'); + if (proof.kind !== 'refund_attested_and_confirmed' + || canonicalHash(proof.attestationHash, 'refund attestation hash') + !== sha256(canonicalJson(attestation)) + || attestation.schemaVersion !== 1 + || attestation.domain !== 'wallet-kernel.refund.v1' + || attestation.network !== authority.policyVersion.policy.network + || attestation.sellerOrigin !== authority.intent.seller_origin + || attestation.intentHash !== authority.intent.intent_hash + || attestation.originalTransactionId !== authority.attempt.transaction_id + || attestation.refundTransactionId !== proof.refundTransactionId + || attestation.asset !== authority.policyVersion.policy.asset + || attestation.originalPayer !== authority.session.wallet_address + || attestation.originalPayee !== authority.selected.payTo + || attestation.refundSource !== authority.seller.refundSource + || attestation.amountAtomic !== authority.decision.amount_ceiling_atomic + || attestation.signer !== authority.seller.refundSigner + || Date.parse(recordedAt) < Date.parse(issuedAt) + || Date.parse(recordedAt) >= Date.parse(expiresAt) + || Date.parse(expiresAt) - Date.parse(issuedAt) > 15 * 60 * 1_000 + || proof.localRefundBindingHash + !== localRefundBindingHash(authority, proof.refundTransactionId)) { + throw semantic('confirmed refund reconciliation authority changed'); + } + const refund = snapshot.refunds.find((entry) => ( + entry.intent_id === intentId + && entry.refund_transaction_id === proof.refundTransactionId + )); + const persistedEvent = refund + ? entityEvents(snapshot, 'refund', refund.id) + .find((entry) => entry.event_type === 'refund.candidate_persisted') + : null; + if (!persistedEvent) throw semantic('confirmed refund candidate history is missing'); + } else { + throw semantic('reconciliation kind and outcome pair is unsupported'); + } + } + } + const orphanEvents = snapshot.events.filter((event) => ( + event.entity_type === 'reconciliation' && !seenIds.has(event.entity_id) + )); + if (orphanEvents.length > 0) throw semantic('orphan reconciliation event exists'); + return histories; +} + +function auditedPaymentCaseHash(intent, buyerOutcomeRevision, rows) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-reconciliation-case.v1', + intentId: intent.id, + intentHash: intent.intent_hash, + attemptState: 'unresolved', + budgetState: 'unresolved', + buyerOutcomeRevision, + history: rows.map((row) => ({ + id: row.id, + transactionId: row.transaction_id, + state: row.state, + evidenceHash: row.evidence_json === null ? null : sha256(row.evidence_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + })); +} + +function auditedRefundCaseHash(intent, attempt, buyerOutcomeRevision, rows) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-observation-case.v1', + intentId: intent.id, + intentHash: intent.intent_hash, + originalTransactionId: attempt.transaction_id, + executionState: 'failed', + resolutionState: 'refund_pending', + buyerOutcomeRevision, + history: rows.map((row) => ({ + id: row.id, + originalTransactionId: row.original_transaction_id, + amountAtomic: row.amount_atomic, + state: row.state, + refundTransactionId: row.refund_transaction_id, + evidenceHash: row.evidence_json === null ? null : sha256(row.evidence_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + })); +} + +function verifyCandidateHistory(snapshot, reconciliationHistories) { + const paymentByIntent = mapBy(snapshot.payment_reconciliation_candidates, 'intent_id'); + const refundByIntent = mapBy(snapshot.refunds, 'intent_id'); + const attemptsByIntent = mapBy(snapshot.payment_attempts, 'intent_id'); + const attemptOwnerByTransaction = new Map(); + const globallyUsed = new Map(); + const rememberTransaction = (transactionId, owner) => { + const previous = globallyUsed.get(transactionId); + if (previous && previous !== owner) throw semantic('transaction ID is reused across authority rows'); + globallyUsed.set(transactionId, owner); + }; + for (const attempt of snapshot.payment_attempts) { + if (attempt.transaction_id !== null) { + const transactionId = canonicalTransactionId( + attempt.transaction_id, + 'payment transaction ID', + ); + rememberTransaction( + transactionId, + `payment:${attempt.intent_id}`, + ); + attemptOwnerByTransaction.set(transactionId, attempt.intent_id); + } + } + for (const [intentId, rows] of paymentByIntent) { + const intent = snapshot.spend_intents.find((entry) => entry.id === intentId); + if (!intent) throw semantic('payment candidate references a missing Spend Intent'); + let open = 0; + let predecessorUpdatedAt = null; + let historicalRevision = 1; + const historicalRows = []; + for (const row of rows) { + safeToken(row.id, 'payment candidate ID'); + const transactionId = canonicalTransactionId(row.transaction_id, 'payment candidate transaction'); + const attempt = one(attemptsByIntent, intentId, 'PaymentAttempt'); + const exactConfirmedSettlement = row.state === 'confirmed' + && attempt?.state === 'settled' + && attempt.transaction_id === transactionId + && attemptOwnerByTransaction.get(transactionId) === intentId; + if (!exactConfirmedSettlement) { + rememberTransaction(transactionId, `payment-candidate:${row.id}`); + } + if (row.state === 'pending') open += 1; + if (!new Set(['pending', 'abandoned', 'rejected', 'confirmed']).has(row.state)) { + throw semantic('payment candidate state is invalid'); + } + if (row.state === 'confirmed' && !exactConfirmedSettlement) { + throw semantic('confirmed payment candidate does not own its exact settlement'); + } + const evidence = row.evidence_json === null + ? null + : parseCanonical(row.evidence_json, 'payment candidate evidence'); + if ((row.state === 'pending' || row.state === 'abandoned') && evidence !== null) { + throw semantic('open or abandoned payment candidate contains resolution evidence'); + } + if ((row.state === 'rejected' || row.state === 'confirmed') && evidence === null) { + throw semantic('resolved payment candidate lacks exact evidence'); + } + const matchingReconciliations = (reconciliationHistories.get(intentId) ?? []) + .filter((reconciliation) => reconciliation.evidence_json === row.evidence_json); + if (row.state === 'confirmed' + && (matchingReconciliations.length !== 1 + || matchingReconciliations[0].kind !== 'payment' + || matchingReconciliations[0].outcome !== 'settled')) { + throw semantic('confirmed payment candidate lost its exact reconciliation'); + } + if (row.state === 'rejected' + && (matchingReconciliations.length !== 1 + || matchingReconciliations[0].kind !== 'payment' + || !new Set(['unresolved', 'rejected']).has(matchingReconciliations[0].outcome))) { + throw semantic('rejected payment candidate lost its exact reconciliation'); + } + const createdAt = safeTimestamp(row.created_at, 'payment candidate createdAt'); + const updatedAt = safeTimestamp(row.updated_at, 'payment candidate updatedAt'); + if (Date.parse(updatedAt) < Date.parse(createdAt) + || (predecessorUpdatedAt !== null + && Date.parse(createdAt) < Date.parse(predecessorUpdatedAt))) { + throw semantic('payment candidate chronology regressed'); + } + predecessorUpdatedAt = updatedAt; + const previousCaseHash = auditedPaymentCaseHash( + intent, + historicalRevision, + historicalRows, + ); + const pendingProjection = Object.freeze({ + ...row, + state: 'pending', + evidence_json: null, + updated_at: createdAt, + }); + const observedCaseHash = auditedPaymentCaseHash( + intent, + historicalRevision, + [...historicalRows, pendingProjection], + ); + const events = entityEvents(snapshot, 'payment_reconciliation_candidate', row.id); + const expectedTypes = [ + 'payment.candidate_persisted', + ...(row.state === 'abandoned' ? ['payment.candidate_abandoned'] : []), + ...(row.state === 'rejected' ? ['payment.candidate_rejected'] : []), + ...(row.state === 'confirmed' ? ['payment.candidate_confirmed'] : []), + ]; + if (canonicalJson(events.map((event) => event.event_type)) !== canonicalJson(expectedTypes)) { + throw semantic('payment candidate event lifecycle is incomplete or ambiguous'); + } + const persisted = exactSemantic(parseCanonical( + events[0].data_json, + 'payment candidate persisted event data', + ), [ + 'intentId', 'transactionId', 'operatorIdHash', 'previousCaseHash', 'createdAt', + ], [], 'payment candidate persisted event'); + if (persisted.intentId !== intentId + || persisted.transactionId !== transactionId + || persisted.createdAt !== createdAt) { + throw semantic('payment candidate persisted event binding changed'); + } + canonicalHash(persisted.operatorIdHash, 'payment candidate operator hash'); + if (canonicalHash(persisted.previousCaseHash, 'payment candidate previous case hash') + !== previousCaseHash) { + throw semantic('payment candidate predecessor case hash changed'); + } + if (row.state === 'abandoned') { + const abandoned = exactSemantic(parseCanonical( + events[1].data_json, + 'payment candidate abandonment event data', + ), [ + 'intentId', 'transactionId', 'operatorIdHash', 'previousCaseHash', 'abandonedAt', + ], [], 'payment candidate abandonment event'); + if (abandoned.intentId !== intentId || abandoned.transactionId !== transactionId + || abandoned.previousCaseHash !== observedCaseHash + || abandoned.abandonedAt !== updatedAt) { + throw semantic('payment candidate abandonment event binding changed'); + } + } else if (row.state === 'rejected') { + const rejected = exactSemantic(parseCanonical( + events[1].data_json, + 'payment candidate rejection event data', + ), [ + 'intentId', 'transactionId', 'evidenceHash', 'operatorIdHash', 'rejectedAt', + ], [], 'payment candidate rejection event'); + if (rejected.intentId !== intentId || rejected.transactionId !== transactionId + || rejected.evidenceHash !== sha256(row.evidence_json) + || rejected.rejectedAt !== updatedAt) { + throw semantic('payment candidate rejection event binding changed'); + } + } else if (row.state === 'confirmed') { + const confirmed = exactSemantic(parseCanonical( + events[1].data_json, + 'payment candidate confirmation event data', + ), [ + 'intentId', 'transactionId', 'evidenceHash', 'operatorIdHash', 'confirmedAt', + ], [], 'payment candidate confirmation event'); + if (confirmed.intentId !== intentId || confirmed.transactionId !== transactionId + || confirmed.evidenceHash !== sha256(row.evidence_json) + || confirmed.confirmedAt !== updatedAt) { + throw semantic('payment candidate confirmation event binding changed'); + } + } + if (matchingReconciliations.length === 1) { + const reconciliation = matchingReconciliations[0]; + const reconciliationEvent = entityEvents( + snapshot, + 'reconciliation', + reconciliation.id, + )[0]; + const eventData = parseCanonical( + reconciliationEvent.data_json, + 'payment candidate reconciliation event data', + ); + if (eventData.observedCaseHash !== observedCaseHash + || !new Set([previousCaseHash, observedCaseHash]).has(eventData.requestCaseHash)) { + throw semantic('payment candidate reconciliation case hash changed'); + } + historicalRevision += 1; + } + historicalRows.push(row); + } + if (open > 1) throw semantic(`payment candidate history for ${intentId} is ambiguous`); + } + for (const [intentId, rows] of refundByIntent) { + const intent = snapshot.spend_intents.find((entry) => entry.id === intentId); + const attempt = one(attemptsByIntent, intentId, 'PaymentAttempt'); + const outcome = snapshot.buyer_outcomes.find((entry) => entry.intent_id === intentId); + if (!intent || !attempt || !outcome) { + throw semantic('refund history references incomplete authority'); + } + const resolvedRefundCount = rows.filter( + (row) => row.state === 'confirmed' || row.state === 'rejected', + ).length; + let historicalRevision = number(outcome.revision, 'refund BuyerOutcome revision') + - resolvedRefundCount; + if (historicalRevision < 1) throw semantic('refund history revision underflowed'); + const historicalRows = []; + let open = 0; + let predecessorUpdatedAt = null; + for (const row of rows) { + safeToken(row.id, 'refund candidate ID'); + canonicalTransactionId(row.original_transaction_id, 'refund original transaction'); + if (row.refund_transaction_id !== null) { + rememberTransaction( + canonicalTransactionId(row.refund_transaction_id, 'refund transaction'), + `refund:${row.id}`, + ); + } + if (row.state === 'pending' || row.state === 'unresolved') open += 1; + const evidence = row.evidence_json === null + ? null + : parseCanonical(row.evidence_json, 'refund evidence'); + if (new Set(['pending', 'unresolved', 'abandoned']).has(row.state) + && evidence !== null) { + throw semantic('open or abandoned refund contains resolution evidence'); + } + if (new Set(['confirmed', 'rejected']).has(row.state) && evidence === null) { + throw semantic('resolved refund lacks exact evidence'); + } + if ((row.state === 'confirmed' || row.state === 'rejected') + && row.refund_transaction_id === null) { + throw semantic('resolved refund lacks a named transaction'); + } + const matchingReconciliations = (reconciliationHistories.get(intentId) ?? []) + .filter((reconciliation) => reconciliation.evidence_json === row.evidence_json); + const expectedOutcome = row.state === 'confirmed' + ? 'refund_confirmed' + : (row.state === 'rejected' ? 'refund_rejected' : null); + if (expectedOutcome !== null + && (matchingReconciliations.length !== 1 + || matchingReconciliations[0].kind !== 'refund' + || matchingReconciliations[0].outcome !== expectedOutcome)) { + throw semantic('resolved refund lost its exact reconciliation'); + } + const createdAt = safeTimestamp(row.created_at, 'refund createdAt'); + const updatedAt = safeTimestamp(row.updated_at, 'refund updatedAt'); + if (Date.parse(updatedAt) < Date.parse(createdAt) + || (predecessorUpdatedAt !== null + && Date.parse(createdAt) < Date.parse(predecessorUpdatedAt))) { + throw semantic('refund chronology regressed'); + } + predecessorUpdatedAt = updatedAt; + const events = entityEvents(snapshot, 'refund', row.id); + const opened = events.filter((event) => event.event_type === 'refund.opened'); + if (opened.length !== 1) throw semantic('refund opening event is incomplete or ambiguous'); + const openedData = exactSemantic(parseCanonical( + opened[0].data_json, + 'refund opened event data', + ), [ + 'refundId', 'intentId', 'originalTransactionId', 'amountAtomic', 'state', 'createdAt', + ], [], 'refund.opened event'); + if (openedData.refundId !== row.id || openedData.intentId !== intentId + || openedData.originalTransactionId !== row.original_transaction_id + || openedData.amountAtomic !== row.amount_atomic + || openedData.state !== 'pending' || openedData.createdAt !== createdAt) { + throw semantic('refund opening event binding changed'); + } + let observedCaseHash = null; + if (row.refund_transaction_id !== null) { + const persisted = events.filter( + (event) => event.event_type === 'refund.candidate_persisted', + ); + if (persisted.length !== 1) { + throw semantic('named refund candidate lacks one persistence event'); + } + const persistedData = exactSemantic(parseCanonical( + persisted[0].data_json, + 'refund candidate persistence event data', + ), [ + 'intentId', 'originalTransactionId', 'refundTransactionId', 'operatorIdHash', + 'previousCaseHash', 'recordedAt', + ], [], 'refund.candidate_persisted event'); + if (persistedData.intentId !== intentId + || persistedData.originalTransactionId !== row.original_transaction_id + || persistedData.refundTransactionId !== row.refund_transaction_id + || Date.parse(safeTimestamp( + persistedData.recordedAt, + 'refund candidate persistedAt', + )) < Date.parse(createdAt) + || Date.parse(persistedData.recordedAt) > Date.parse(updatedAt) + || (row.state === 'pending' && persistedData.recordedAt !== updatedAt)) { + throw semantic('refund candidate persistence event binding changed'); + } + canonicalHash(persistedData.operatorIdHash, 'refund candidate operator hash'); + const caseWithoutRow = auditedRefundCaseHash( + intent, + attempt, + historicalRevision, + historicalRows, + ); + const unnamedProjection = Object.freeze({ + ...row, + state: 'pending', + evidence_json: null, + refund_transaction_id: null, + updated_at: createdAt, + }); + const caseWithUnnamedRow = auditedRefundCaseHash( + intent, + attempt, + historicalRevision, + [...historicalRows, unnamedProjection], + ); + if (!new Set([caseWithoutRow, caseWithUnnamedRow]).has(canonicalHash( + persistedData.previousCaseHash, + 'refund candidate previous case hash', + ))) { + throw semantic('refund candidate predecessor case hash changed'); + } + const namedProjection = Object.freeze({ + ...row, + state: 'pending', + evidence_json: null, + updated_at: persistedData.recordedAt, + }); + observedCaseHash = auditedRefundCaseHash( + intent, + attempt, + historicalRevision, + [...historicalRows, namedProjection], + ); + } + const terminalType = row.state === 'abandoned' + ? 'refund.candidate_abandoned' + : (row.state === 'rejected' + ? 'refund.candidate_rejected' + : (row.state === 'confirmed' ? 'refund.confirmed' : null)); + if (terminalType !== null + && events.filter((event) => event.event_type === terminalType).length !== 1) { + throw semantic('refund candidate terminal event is incomplete or ambiguous'); + } + if (terminalType === 'refund.candidate_abandoned') { + const terminal = exactSemantic(parseCanonical( + events.find((event) => event.event_type === terminalType).data_json, + 'refund abandonment event data', + ), [ + 'intentId', 'refundTransactionId', 'operatorIdHash', 'previousCaseHash', 'abandonedAt', + ], [], 'refund.candidate_abandoned event'); + if (terminal.intentId !== intentId + || terminal.refundTransactionId !== row.refund_transaction_id + || terminal.previousCaseHash !== observedCaseHash + || terminal.abandonedAt !== updatedAt) { + throw semantic('refund abandonment event binding changed'); + } + } else if (terminalType === 'refund.candidate_rejected') { + const terminal = exactSemantic(parseCanonical( + events.find((event) => event.event_type === terminalType).data_json, + 'refund rejection event data', + ), [ + 'intentId', 'refundTransactionId', 'evidenceHash', 'operatorIdHash', 'rejectedAt', + ], [], 'refund.candidate_rejected event'); + if (terminal.intentId !== intentId + || terminal.refundTransactionId !== row.refund_transaction_id + || terminal.evidenceHash !== sha256(row.evidence_json) + || terminal.rejectedAt !== updatedAt) { + throw semantic('refund rejection event binding changed'); + } + } else if (terminalType === 'refund.confirmed') { + const terminal = exactSemantic(parseCanonical( + events.find((event) => event.event_type === terminalType).data_json, + 'refund confirmation event data', + ), [ + 'refundId', 'intentId', 'originalTransactionId', 'refundTransactionId', + 'amountAtomic', 'evidenceId', 'confirmedAt', + ], [], 'refund.confirmed event'); + if (terminal.refundId !== row.id || terminal.intentId !== intentId + || terminal.originalTransactionId !== row.original_transaction_id + || terminal.refundTransactionId !== row.refund_transaction_id + || terminal.amountAtomic !== row.amount_atomic + || terminal.confirmedAt !== updatedAt) { + throw semantic('refund confirmation event binding changed'); + } + safeToken(terminal.evidenceId, 'refund confirmation evidence ID'); + } + if (matchingReconciliations.length === 1) { + const reconciliation = matchingReconciliations[0]; + const reconciliationEvent = entityEvents( + snapshot, + 'reconciliation', + reconciliation.id, + )[0]; + const eventData = parseCanonical( + reconciliationEvent.data_json, + 'refund candidate reconciliation event data', + ); + if (observedCaseHash === null + || eventData.observedCaseHash !== observedCaseHash + || !new Set([ + observedCaseHash, + parseCanonical( + events.find((event) => event.event_type === 'refund.candidate_persisted') + .data_json, + 'refund candidate case event data', + ).previousCaseHash, + ]).has(eventData.requestCaseHash)) { + throw semantic('refund candidate reconciliation case hash changed'); + } + historicalRevision += 1; + } + historicalRows.push(row); + } + if (open > 1) throw semantic(`refund candidate history for ${intentId} is ambiguous`); + } +} + +function verifyOutcomeAndExecutionEvents(snapshot) { + for (const outcome of snapshot.buyer_outcomes) { + const revision = number(outcome.revision, 'BuyerOutcome revision'); + const events = entityEvents(snapshot, 'buyer_outcome', outcome.intent_id) + .filter((event) => new Set([ + 'buyer_outcome.recorded', 'buyer_outcome.revised', + ]).has(event.event_type)); + if (events.length !== revision) { + throw semantic('BuyerOutcome event revision history is incomplete or ambiguous'); + } + for (const [index, event] of events.entries()) { + const data = exactSemantic(parseCanonical( + event.data_json, + 'BuyerOutcome event data', + ), ['status', 'reasonCode', 'revision', 'recordedAt'], [], 'buyer_outcome.recorded event'); + const expectedEventType = index === 0 + ? 'buyer_outcome.recorded' + : 'buyer_outcome.revised'; + if (event.event_type !== expectedEventType + || number(data.revision, 'BuyerOutcome event revision') !== index + 1) { + throw semantic('BuyerOutcome event revisions are not contiguous'); + } + safeTimestamp(data.recordedAt, 'BuyerOutcome event recordedAt'); + if (Date.parse(event.created_at) < Date.parse(data.recordedAt)) { + throw semantic('BuyerOutcome event predates its recorded fact'); + } + if (index === events.length - 1 + && (data.status !== outcome.status + || data.reasonCode !== outcome.reason_code + || data.recordedAt !== outcome.recorded_at)) { + throw semantic('current BuyerOutcome event projection changed'); + } + } + } + + for (const execution of snapshot.execution_outcomes) { + const executionEvents = entityEvents(snapshot, 'execution_outcome', execution.intent_id); + const recordedEvents = executionEvents + .filter((event) => event.event_type === 'execution.recorded'); + const reconciledEvents = executionEvents + .filter((event) => event.event_type === 'execution.reconciled'); + if (recordedEvents.length !== 1 || reconciledEvents.length > 1 + || executionEvents.length !== recordedEvents.length + reconciledEvents.length) { + throw semantic('execution event lifecycle is incomplete or ambiguous'); + } + const recordedData = exactSemantic(parseCanonical( + recordedEvents[0].data_json, + 'execution recorded event data', + ), [ + 'state', 'httpStatus', 'responseHash', 'metadataHash', 'reasonCode', 'recordedAt', + ], [], 'execution.recorded event'); + canonicalHash(recordedData.metadataHash, 'execution recorded metadata hash'); + canonicalReason(recordedData.reasonCode, 'execution recorded reason'); + const historicalRecordedAt = safeTimestamp( + recordedData.recordedAt, + 'execution recorded event recordedAt', + ); + if (Date.parse(recordedEvents[0].created_at) < Date.parse(historicalRecordedAt)) { + throw semantic('execution recorded event predates its recorded fact'); + } + const currentEvent = reconciledEvents[0] ?? recordedEvents[0]; + const data = exactSemantic(parseCanonical( + currentEvent.data_json, + 'execution event data', + ), reconciledEvents.length === 1 + ? [ + 'state', 'httpStatus', 'responseHash', 'metadataHash', 'attestationHash', + 'reasonCode', 'recordedAt', + ] + : [ + 'state', 'httpStatus', 'responseHash', 'metadataHash', 'reasonCode', 'recordedAt', + ], [], + reconciledEvents.length === 1 ? 'execution.reconciled event' : 'execution.recorded event'); + const metadata = parseCanonical(execution.metadata_json, 'current execution metadata'); + const recordedAt = safeTimestamp(data.recordedAt, 'current execution event recordedAt'); + if (data.state !== execution.state + || data.httpStatus !== (execution.http_status === null + ? null + : number(execution.http_status, 'execution event HTTP status')) + || data.responseHash !== execution.response_hash + || data.metadataHash !== sha256(execution.metadata_json) + || data.reasonCode !== metadata.reasonCode + || recordedAt !== execution.recorded_at + || Date.parse(currentEvent.created_at) < Date.parse(recordedAt) + || (reconciledEvents.length === 1 + && reconciledEvents[0].sequence <= recordedEvents[0].sequence)) { + throw semantic('current execution event projection changed'); + } + canonicalHash(data.metadataHash, 'current execution metadata hash'); + canonicalReason(data.reasonCode, 'current execution reason'); + if (Object.hasOwn(data, 'attestationHash')) { + canonicalHash(data.attestationHash, 'execution event attestation hash'); + } + } + + for (const resolution of snapshot.execution_resolutions) { + const events = entityEvents(snapshot, 'execution_resolution', resolution.intent_id); + const opened = events.filter((event) => event.event_type === 'execution_resolution.opened'); + const resolved = events.filter((event) => event.event_type === 'execution_resolution.resolved'); + if (opened.length < 1 || opened.length > 2 || resolved.length > 1) { + throw semantic('execution resolution event lifecycle is incomplete or ambiguous'); + } + const currentOpen = opened.at(-1); + const openData = exactSemantic(parseCanonical( + currentOpen.data_json, + 'execution resolution opened event data', + ), [ + 'intentId', 'state', 'reasonCode', 'blocksWallet', 'openedAt', + ], [], 'execution_resolution.opened event'); + if (openData.intentId !== resolution.intent_id + || openData.openedAt !== resolution.opened_at + || openData.blocksWallet !== true) { + throw semantic('execution resolution opening event binding changed'); + } + if (resolution.state === 'resolved') { + if (resolved.length !== 1) { + throw semantic('resolved execution case lacks one resolution event'); + } + const resolvedData = exactSemantic(parseCanonical( + resolved[0].data_json, + 'execution resolution resolved event data', + ), [ + 'intentId', 'state', 'reasonCode', 'blocksWallet', 'resolvedAt', + ], [], 'execution_resolution.resolved event'); + if (resolvedData.intentId !== resolution.intent_id + || resolvedData.state !== 'resolved' + || resolvedData.reasonCode !== resolution.reason_code + || resolvedData.blocksWallet !== false + || resolvedData.resolvedAt !== resolution.resolved_at) { + throw semantic('execution resolution terminal event projection changed'); + } + } else if (resolved.length !== 0 + || openData.state !== resolution.state + || openData.reasonCode !== resolution.reason_code) { + throw semantic('open execution resolution event projection changed'); + } + } +} + +function verifyIntentAndDecisionHistory(snapshot, sessions, policyAuthority) { + const decisionsByIntent = mapBy(snapshot.policy_decisions, 'intent_id'); + const intentsById = new Map(); + for (const intent of snapshot.spend_intents) { + safeToken(intent.id, 'Spend Intent ID'); + safeToken(intent.request_id, 'Spend Intent request ID'); + safeToken(intent.route_id, 'Spend Intent route ID'); + safeToken(intent.purpose_label, 'Spend Intent purpose label'); + safeToken(intent.correlation_id, 'Spend Intent correlation ID'); + canonicalHash(intent.enrollment_hash, 'Spend Intent enrollment hash'); + canonicalHash(intent.request_url_hash, 'Spend Intent request URL hash'); + canonicalHash(intent.body_hash, 'Spend Intent body hash'); + canonicalHash(intent.header_allowlist_hash, 'Spend Intent header allowlist hash'); + canonicalHash(intent.ordinary_fingerprint, 'Spend Intent ordinary fingerprint'); + canonicalHash(intent.intent_hash, 'Spend Intent hash'); + canonicalAddress(intent.wallet_address, 'Spend Intent wallet'); + if (!/^[A-Z]+$/.test(intent.method) + || typeof intent.seller_origin !== 'string' + || new URL(intent.seller_origin).origin !== intent.seller_origin + || typeof intent.resource_path !== 'string' + || !intent.resource_path.startsWith('/') + || !/^wk_[0-9a-f]{64}$/.test(intent.idempotency_key)) { + throw semantic('Spend Intent immutable request fields are invalid'); + } + const authority = sessions.get(intent.session_id); + if (!authority) throw semantic('Spend Intent session is missing'); + const createdAt = safeTimestamp(intent.created_at, 'Spend Intent createdAt'); + const updatedAt = safeTimestamp(intent.updated_at, 'Spend Intent updatedAt'); + if (Date.parse(updatedAt) < Date.parse(createdAt)) { + throw semantic('Spend Intent chronology regressed'); + } + const ordinaryFingerprint = sha256(canonicalJson({ + routeId: intent.route_id, + method: intent.method, + requestUrlHash: intent.request_url_hash, + bodyHash: intent.body_hash, + headerAllowlistHash: intent.header_allowlist_hash, + purposeLabel: intent.purpose_label, + })); + const intentHash = sha256(canonicalJson({ + requestId: intent.request_id, + sessionId: intent.session_id, + enrollmentHash: intent.enrollment_hash, + routeId: intent.route_id, + method: intent.method, + requestUrlHash: intent.request_url_hash, + sellerOrigin: intent.seller_origin, + resourcePath: intent.resource_path, + bodyHash: intent.body_hash, + headerAllowlistHash: intent.header_allowlist_hash, + purposeLabel: intent.purpose_label, + correlationId: intent.correlation_id, + walletAddress: intent.wallet_address, + policyVersionId: authority.session.policy_version_id, + })); + const idempotencyDigest = sha256(canonicalJson({ + domain: 'wallet-kernel.intent-idempotency.v1', + intentId: intent.id, + requestId: intent.request_id, + sessionId: intent.session_id, + enrollmentHash: intent.enrollment_hash, + ordinaryFingerprint: intent.ordinary_fingerprint, + correlationId: intent.correlation_id, + })); + if (ordinaryFingerprint !== intent.ordinary_fingerprint + || intentHash !== intent.intent_hash + || intent.idempotency_key !== `wk_${idempotencyDigest.slice('sha256:'.length)}` + || number(intent.retry_matchable, 'Spend Intent retry flag') + !== (intent.state === 'terminal' ? 0 : 1)) { + throw semantic('Spend Intent immutable hash projection changed'); + } + + const events = entityEvents(snapshot, 'spend_intent', intent.id); + if (events.length === 0 || events[0].event_type !== 'intent.captured' + || events.some((event) => !new Set([ + 'intent.captured', 'intent.challenge_attached', + 'policy.decision_recorded', 'intent.transitioned', + ]).has(event.event_type))) { + throw semantic('Spend Intent event lifecycle is missing or contains an unknown event'); + } + const captured = exactSemantic( + parseCanonical(events[0].data_json, 'intent.captured event data'), + [ + 'requestId', 'sessionId', 'enrollmentHash', 'routeId', 'method', + 'requestUrlHash', 'sellerOrigin', 'resourcePath', 'bodyHash', + 'headerAllowlistHash', 'ordinaryFingerprint', 'purposeLabel', + 'correlationId', 'idempotencyKey', 'walletAddress', 'policyVersionId', + 'intentHash', 'createdAt', + ], + [], + 'intent.captured event', + ); + const expectedCaptured = { + requestId: intent.request_id, + sessionId: intent.session_id, + enrollmentHash: intent.enrollment_hash, + routeId: intent.route_id, + method: intent.method, + requestUrlHash: intent.request_url_hash, + sellerOrigin: intent.seller_origin, + resourcePath: intent.resource_path, + bodyHash: intent.body_hash, + headerAllowlistHash: intent.header_allowlist_hash, + ordinaryFingerprint: intent.ordinary_fingerprint, + purposeLabel: intent.purpose_label, + correlationId: intent.correlation_id, + idempotencyKey: intent.idempotency_key, + walletAddress: intent.wallet_address, + policyVersionId: authority.session.policy_version_id, + intentHash: intent.intent_hash, + createdAt, + }; + if (canonicalJson(captured) !== canonicalJson(expectedCaptured) + || Date.parse(safeTimestamp( + events[0].created_at, + 'intent captured event createdAt', + )) < Date.parse(createdAt)) { + throw semantic('Spend Intent capture event binding changed'); + } + + const decision = one(decisionsByIntent, intent.id, 'PolicyDecision'); + let state = 'captured'; + let projectionUpdatedAt = createdAt; + let predecessorAt = createdAt; + let challengeEventCount = 0; + let decisionEventCount = 0; + for (const event of events.slice(1)) { + let transitionAt; + if (event.event_type === 'intent.challenge_attached') { + challengeEventCount += 1; + const challenge = exactSemantic( + parseCanonical(event.data_json, 'intent challenge event data'), + ['challengeHash', 'challengeReceivedAt', 'projectionHash', 'updatedAt'], + [], + 'intent.challenge_attached event', + ); + if (challengeEventCount !== 1 || state !== 'captured' + || intent.challenge_hash === null + || challenge.challengeHash !== intent.challenge_hash + || challenge.projectionHash !== intent.challenge_hash + || challenge.challengeReceivedAt !== intent.challenge_received_at) { + throw semantic('Spend Intent challenge event binding changed'); + } + transitionAt = safeTimestamp(challenge.updatedAt, 'intent challenge updatedAt'); + if (Date.parse(intent.challenge_received_at) < Date.parse(createdAt) + || Date.parse(transitionAt) < Date.parse(intent.challenge_received_at)) { + throw semantic('Spend Intent challenge chronology is invalid'); + } + state = 'challenged'; + projectionUpdatedAt = transitionAt; + } else if (event.event_type === 'policy.decision_recorded') { + decisionEventCount += 1; + const data = exactSemantic( + parseCanonical(event.data_json, 'PolicyDecision event data'), + [ + 'policyVersionId', 'decision', 'reasonCode', 'challengeHash', + 'acceptedIndex', 'quoteId', 'amountCeilingAtomic', 'decidedAt', + ], + [], + 'policy.decision_recorded event', + ); + if (decisionEventCount !== 1 || state !== 'challenged' || !decision + || data.policyVersionId !== decision.policy_version_id + || data.decision !== decision.decision + || data.reasonCode !== decision.reason_code + || data.challengeHash !== decision.challenge_hash + || data.acceptedIndex !== (decision.accepted_index === null + ? null + : number(decision.accepted_index, 'PolicyDecision event accepted index')) + || data.quoteId !== decision.quote_id + || data.amountCeilingAtomic !== decision.amount_ceiling_atomic + || data.decidedAt !== decision.decided_at) { + throw semantic('PolicyDecision event projection changed'); + } + transitionAt = safeTimestamp(data.decidedAt, 'PolicyDecision event decidedAt'); + } else if (event.event_type === 'intent.transitioned') { + const transition = exactSemantic( + parseCanonical(event.data_json, 'Spend Intent transition event data'), + ['previousState', 'nextState', 'reasonCode', 'retryMatchable', 'updatedAt'], + [], + 'intent.transitioned event', + ); + if (transition.previousState !== state + || !LEGAL_INTENT_TRANSITIONS.get(state)?.has(transition.nextState) + || transition.retryMatchable !== (transition.nextState !== 'terminal')) { + throw semantic('Spend Intent transition event is not a legal state edge'); + } + canonicalReason(transition.reasonCode, 'Spend Intent transition reason'); + transitionAt = safeTimestamp(transition.updatedAt, 'Spend Intent transition updatedAt'); + state = transition.nextState; + projectionUpdatedAt = transitionAt; + } else { + throw semantic('Spend Intent capture event is duplicated or reordered'); + } + if (Date.parse(transitionAt) < Date.parse(predecessorAt) + || Date.parse(safeTimestamp(event.created_at, 'Spend Intent event createdAt')) + < Date.parse(transitionAt)) { + throw semantic('Spend Intent event chronology regressed'); + } + predecessorAt = transitionAt; + } + if (state !== intent.state || projectionUpdatedAt !== updatedAt + || challengeEventCount !== (intent.challenge_hash === null ? 0 : 1) + || decisionEventCount !== (decision === null ? 0 : 1)) { + throw semantic('Spend Intent event history does not project its current row'); + } + if (decision !== null) { + const policy = policyAuthority.policies.get(decision.policy_version_id); + const acceptedIndex = decision.accepted_index === null + ? null + : number(decision.accepted_index, 'PolicyDecision accepted index'); + const decidedAt = safeTimestamp(decision.decided_at, 'PolicyDecision decidedAt'); + canonicalReason(decision.reason_code, 'PolicyDecision reason'); + canonicalAtomic(decision.amount_ceiling_atomic, 'PolicyDecision amount ceiling'); + if (!new Set(['allow', 'approval_required', 'deny']).has(decision.decision) + || policy === undefined + || decision.challenge_hash !== intent.challenge_hash + || Date.parse(decidedAt) < Date.parse(intent.challenge_received_at) + || Date.parse(decidedAt) < Date.parse(policy.row.applied_at) + || (acceptedIndex === null + ? (decision.quote_id !== null + || decision.amount_ceiling_atomic !== '0' + || decision.decision !== 'deny') + : (acceptedIndex < 0 + || decision.quote_id !== sha256(canonicalJson({ + challengeHash: decision.challenge_hash, + acceptedIndex, + })) + || decision.amount_ceiling_atomic === '0'))) { + throw semantic('PolicyDecision immutable projection is invalid'); + } + } + intentsById.set(intent.id, intent); + } + + const aliasIds = new Set(); + for (const event of snapshot.events.filter( + (row) => row.entity_type === 'intent_correlation', + )) { + if (event.event_type !== 'intent.correlation_bound' || aliasIds.has(event.entity_id)) { + throw semantic('intent correlation alias event is duplicated or invalid'); + } + aliasIds.add(event.entity_id); + const data = exactSemantic( + parseCanonical(event.data_json, 'intent correlation event data'), + ['sessionId', 'intentId', 'correlationId', 'ordinaryFingerprint'], + [], + 'intent.correlation_bound event', + ); + const intent = intentsById.get(data.intentId); + safeToken(data.correlationId, 'intent correlation alias'); + const digest = sha256(canonicalJson({ + domain: 'wallet-kernel.intent-correlation-alias.v1', + sessionId: data.sessionId, + correlationId: data.correlationId, + })); + if (!intent || data.sessionId !== intent.session_id + || data.correlationId === intent.correlation_id + || data.ordinaryFingerprint !== intent.ordinary_fingerprint + || event.entity_id !== `correlation-${digest.slice('sha256:'.length)}`) { + throw semantic('intent correlation alias binding changed'); + } + } + if (snapshot.events.some((event) => ( + event.entity_type === 'spend_intent' && !intentsById.has(event.entity_id) + ))) { + throw semantic('orphan Spend Intent lifecycle event exists'); + } +} + +function dependencyMaps(snapshot) { + return Object.freeze({ + decisions: mapBy(snapshot.policy_decisions, 'intent_id'), + approvals: mapBy(snapshot.approvals, 'intent_id'), + budgets: mapBy(snapshot.budget_reservations, 'intent_id'), + attempts: mapBy(snapshot.payment_attempts, 'intent_id'), + candidates: mapBy(snapshot.payment_reconciliation_candidates, 'intent_id'), + executions: mapBy(snapshot.execution_outcomes, 'intent_id'), + resolutions: mapBy(snapshot.execution_resolutions, 'intent_id'), + refunds: mapBy(snapshot.refunds, 'intent_id'), + reconciliations: mapBy(snapshot.reconciliations, 'intent_id'), + outcomes: mapBy(snapshot.buyer_outcomes, 'intent_id'), + receiptRows: mapBy(snapshot.signed_receipts, 'intent_id'), + }); +} + +function assertIntentBindings(intent, deps, sessions) { + const authority = sessions.get(intent.session_id); + if (!authority || intent.enrollment_hash !== authority.binding.enrollment_hash + || intent.wallet_address !== authority.session.wallet_address) { + throw semantic('Spend Intent session or enrollment binding changed'); + } + const challengeFields = [ + intent.challenge_projection_json, + intent.challenge_hash, + intent.challenge_received_at, + ]; + const hasChallenge = challengeFields.every((value) => value !== null); + if (!hasChallenge && challengeFields.some((value) => value !== null)) { + throw semantic('Spend Intent challenge fields are partial'); + } + if (hasChallenge) { + const projection = parseCanonical(intent.challenge_projection_json, 'challenge projection'); + if (sha256(canonicalJson(projection)) !== intent.challenge_hash) { + throw semantic('Spend Intent challenge hash changed'); + } + safeTimestamp(intent.challenge_received_at, 'challenge receivedAt'); + } + const decision = one(deps.decisions, intent.id, 'PolicyDecision'); + if (decision) { + if (!hasChallenge || decision.challenge_hash !== intent.challenge_hash + || decision.policy_version_id !== authority.session.policy_version_id) { + throw semantic('PolicyDecision immutable binding changed'); + } + canonicalReason(decision.reason_code, 'PolicyDecision reason'); + safeTimestamp(decision.decided_at, 'PolicyDecision decidedAt'); + } + const approval = one(deps.approvals, intent.id, 'Approval'); + if (approval) { + if (!decision || approval.intent_hash !== intent.intent_hash + || approval.challenge_hash !== intent.challenge_hash + || approval.policy_version_id !== decision.policy_version_id + || approval.quote_id !== decision.quote_id + || number(approval.accepted_index, 'approval accepted index') + !== number(decision.accepted_index, 'decision accepted index') + || approval.amount_ceiling_atomic !== decision.amount_ceiling_atomic + || approval.wallet_address !== intent.wallet_address) { + throw semantic('Approval immutable binding changed'); + } + safeTimestamp(approval.expires_at, 'approval expiresAt'); + } + const budget = one(deps.budgets, intent.id, 'BudgetReservation'); + if (budget && (budget.session_id !== intent.session_id + || budget.seller_origin !== intent.seller_origin)) { + throw semantic('BudgetReservation immutable binding changed'); + } + const attempt = one(deps.attempts, intent.id, 'PaymentAttempt'); + if (attempt && (!decision + || attempt.payment_required_projection_json !== intent.challenge_projection_json + || attempt.quote_id !== decision.quote_id + || number(attempt.accepted_index, 'attempt accepted index') + !== number(decision.accepted_index, 'decision accepted index'))) { + throw semantic('PaymentAttempt immutable binding changed'); + } + const execution = one(deps.executions, intent.id, 'execution outcome'); + const resolution = one(deps.resolutions, intent.id, 'execution resolution'); + if (resolution && !execution) throw semantic('execution resolution has no execution outcome'); + if (execution?.state === 'unknown' && resolution?.state !== 'reconciliation_required') { + throw semantic('unknown execution has no reconciliation blocker'); + } + if (execution?.state === 'failed' && resolution?.state !== 'refund_pending' + && resolution?.state !== 'resolved') { + throw semantic('failed execution has no refund resolution'); + } + if (resolution?.state === 'refund_pending') { + const refunds = deps.refunds.get(intent.id) ?? []; + const openRefunds = refunds.filter( + (row) => row.state === 'pending' || row.state === 'unresolved', + ); + const terminalHistoryOnly = refunds.length > 0 + && openRefunds.length === 0 + && refunds.every((row) => row.state === 'abandoned' || row.state === 'rejected'); + if (execution?.state !== 'failed' + || (openRefunds.length !== 1 && !terminalHistoryOnly)) { + throw semantic('refund-pending execution has no exact open refund'); + } + } + return Object.freeze({ approval, attempt, budget, decision, execution, resolution }); +} + +function classifyIntents(snapshot, sessions, startupAt, { final = false } = {}) { + const deps = dependencyMaps(snapshot); + const repairs = []; + let retainedIntentCount = 0; + let unresolvedIntentCount = 0; + let pendingApprovalCount = 0; + for (const intent of snapshot.spend_intents) { + safeToken(intent.id, 'Spend Intent ID'); + canonicalHash(intent.enrollment_hash, 'Spend Intent enrollment hash'); + canonicalHash(intent.intent_hash, 'Spend Intent hash'); + safeTimestamp(intent.created_at, 'Spend Intent createdAt'); + safeTimestamp(intent.updated_at, 'Spend Intent updatedAt'); + const authority = assertIntentBindings(intent, deps, sessions); + const outcome = one(deps.outcomes, intent.id, 'BuyerOutcome'); + if (outcome) { + canonicalReason(outcome.reason_code, 'BuyerOutcome reason'); + const revision = number(outcome.revision, 'BuyerOutcome revision'); + if (revision < 1) throw semantic('BuyerOutcome revision is invalid'); + safeTimestamp(outcome.recorded_at, 'BuyerOutcome recordedAt'); + const receiptRows = deps.receiptRows.get(intent.id) ?? []; + if (receiptRows.length > revision) throw semantic('receipt history exceeds BuyerOutcome revision'); + } + + const hasNoMoney = !authority.budget && !authority.attempt + && (deps.candidates.get(intent.id) ?? []).length === 0 + && !authority.execution && !authority.resolution + && (deps.refunds.get(intent.id) ?? []).length === 0 + && (deps.reconciliations.get(intent.id) ?? []).length === 0; + + if (intent.state === 'captured') { + if (authority.decision || authority.approval || !hasNoMoney || outcome + || intent.challenge_hash !== null) { + throw semantic('captured recovery gap is not exactly unsigned'); + } + repairs.push({ kind: 'abandon_unsigned', intentId: intent.id, expectedState: 'captured', + status: 'upstream_failed', reasonCode: 'RECOVERY_ABANDONED_UNSIGNED' }); + continue; + } + if (intent.state === 'challenged') { + if (!authority.decision || authority.approval || !hasNoMoney || outcome) { + throw semantic('challenged recovery gap has unexpected dependent authority'); + } + if (authority.decision.decision === 'deny') { + repairs.push({ kind: 'abandon_unsigned', intentId: intent.id, expectedState: 'challenged', + status: 'payment_denied', reasonCode: authority.decision.reason_code }); + } else if (authority.decision.decision === 'allow' + || authority.decision.decision === 'approval_required') { + repairs.push({ kind: 'abandon_unsigned', intentId: intent.id, expectedState: 'challenged', + status: 'payment_failed', reasonCode: 'RECOVERY_ABANDONED_UNSIGNED' }); + } else { + throw semantic('challenged recovery PolicyDecision is invalid'); + } + continue; + } + if (intent.state === 'approval_pending') { + if (!authority.approval || authority.decision?.decision !== 'approval_required' + || authority.budget || authority.attempt || authority.execution || outcome) { + throw semantic('approval-pending recovery authority is incomplete'); + } + if (!OPEN_APPROVALS.has(authority.approval.decision)) { + throw semantic('approval-pending intent has a closed approval'); + } + if (Date.parse(authority.approval.expires_at) <= Date.parse(startupAt)) { + repairs.push({ kind: 'expire_approval', intentId: intent.id, + approvalId: authority.approval.id, intentHash: intent.intent_hash }); + } else { + retainedIntentCount += 1; + pendingApprovalCount += 1; + } + continue; + } + if (intent.state === 'reserved') { + if (!authority.budget || authority.budget.state !== 'reserved' + || !authority.attempt || authority.attempt.state !== 'reserved' + || authority.execution || outcome) { + throw semantic('reserved recovery gap is not definitely unsigned'); + } + repairs.push({ kind: 'release_reserved', intentId: intent.id }); + continue; + } + if (authority.attempt?.state === 'settled' && authority.budget?.state === 'committed' + && !authority.execution && !authority.resolution + && (deps.refunds.get(intent.id) ?? []).length === 0) { + if (intent.state !== 'retrying' || outcome) { + throw semantic('settled-without-execution gap has an illegal predecessor'); + } + repairs.push({ kind: 'repair_missing_execution', intentId: intent.id, + expectedState: 'retrying' }); + continue; + } + if (AMBIGUOUS_PAYMENT_STATES.has(intent.state)) { + if (!authority.budget || authority.budget.state !== 'reserved' + || !authority.attempt || authority.attempt.state !== intent.state + || authority.execution || outcome) { + throw semantic('in-flight payment recovery gap is not exact'); + } + repairs.push({ kind: 'hold_ambiguous', intentId: intent.id, + expectedState: intent.state }); + continue; + } + if (intent.state === 'unresolved') { + if (authority.budget?.state !== 'unresolved' + || authority.attempt?.state !== 'unresolved' + || outcome?.status !== 'payment_unresolved' + || authority.execution || authority.resolution) { + throw semantic('unresolved payment authority is incomplete'); + } + unresolvedIntentCount += 1; + retainedIntentCount += 1; + continue; + } + if (intent.state === 'terminal') { + if (!outcome) throw semantic('terminal Spend Intent has no BuyerOutcome'); + if (authority.budget?.state === 'reserved' || authority.budget?.state === 'unresolved' + || authority.attempt?.state === 'reserved' + || AMBIGUOUS_PAYMENT_STATES.has(authority.attempt?.state)) { + throw semantic('terminal Spend Intent retains in-flight payment authority'); + } + retainedIntentCount += 1; + continue; + } + if (intent.state === 'authorized') { + throw semantic('authorized intent without its aggregate reservation is not recoverable'); + } + throw semantic('Spend Intent state is not covered by the recovery matrix'); + } + if (final && repairs.length > 0) throw semantic('final recovery audit still finds a crash gap'); + return Object.freeze({ + repairs: Object.freeze(repairs.map((repair) => Object.freeze(repair))), + retainedIntentCount, + unresolvedIntentCount, + pendingApprovalCount, + }); +} + +const TABLES = Object.freeze([ + 'metadata', 'policy_versions', 'spend_sessions', 'agent_enrollments', + 'isolation_attestations', 'agent_session_bindings', 'spend_intents', + 'policy_decisions', 'budget_reservations', 'approvals', 'payment_attempts', + 'payment_reconciliation_candidates', 'execution_outcomes', 'execution_resolutions', + 'refunds', 'reconciliations', 'buyer_outcomes', 'signed_receipts', 'events', +]); + +function auditAndClassify({ store, budgets, receipts, startupAt, final = false }) { + return store.transaction((token) => { + if (final) receipts.assertParityInTransaction(token); + else receipts.assertRecoverableParityInTransaction(token); + const snapshot = store.within(token, ({ db }) => { + const integrity = db.prepare('SELECT * FROM pragma_integrity_check').all(); + const foreignKeyViolations = db.prepare('SELECT * FROM pragma_foreign_key_check').all(); + const userVersion = db.prepare('SELECT * FROM pragma_user_version').get(); + const foreignKeys = db.prepare('SELECT * FROM pragma_foreign_keys').get(); + if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok' + || foreignKeyViolations.length !== 0 + || number(userVersion.user_version, 'schema version') !== KERNEL_SCHEMA_VERSION + || number(foreignKeys.foreign_keys, 'foreign-key enforcement') !== 1) { + throw semantic('SQLite physical, schema, or foreign-key audit failed'); + } + const value = {}; + for (const table of TABLES) { + value[table] = db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(); + } + return value; + }); + verifyEventChain(snapshot.events); + const policyAuthority = verifyPolicies(snapshot); + const enrollments = verifyEnrollments(snapshot); + const sessions = verifySessions(snapshot, policyAuthority, enrollments); + verifyIsolation(snapshot, enrollments, startupAt); + verifyIntentAndDecisionHistory(snapshot, sessions, policyAuthority); + verifyApprovalHistory(snapshot); + const reconciliationHistories = verifyReconciliationHistory( + snapshot, + policyAuthority, + sessions, + ); + verifyCandidateHistory(snapshot, reconciliationHistories); + verifyOutcomeAndExecutionEvents(snapshot); + verifyReceiptRows(snapshot, receipts); + for (const reservation of snapshot.budget_reservations) { + budgets.snapshotInTransaction(token, { + sessionId: reservation.session_id, + sellerOrigin: reservation.seller_origin, + at: startupAt, + }); + } + return classifyIntents(snapshot, sessions, startupAt, { final }); + }); +} + +function writeInitialOutcome(store, token, { + intentId, + status, + reasonCode, + recordedAt, +}) { + store.within(token, ({ db, appendEvent }) => { + const inserted = db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, ?, ?, 1, ?)`).run(intentId, status, reasonCode, recordedAt); + if (inserted.changes !== 1n) throw semantic('recovery BuyerOutcome insert lost its race'); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intentId, + eventType: 'buyer_outcome.recorded', + data: { status, reasonCode, revision: 1, recordedAt }, + }); + }); +} + +function applyRepair({ store, intents, budgets, approvals, now }, repair, startupAt) { + const recordedAt = canonicalTimestamp(now(), 'recovery repair timestamp'); + if (Date.parse(recordedAt) < Date.parse(startupAt)) { + throw semantic('recovery clock regressed after startup classification'); + } + store.transaction((token) => { + if (repair.kind === 'abandon_unsigned') { + intents.transitionInTransaction(token, { + intentId: repair.intentId, + expectedState: repair.expectedState, + nextState: 'terminal', + reasonCode: repair.reasonCode, + }); + writeInitialOutcome(store, token, { ...repair, recordedAt }); + return; + } + if (repair.kind === 'expire_approval') { + const expired = approvals.expireForIntentInTransaction(token, { + approvalId: repair.approvalId, + intentId: repair.intentId, + expectedIntentHash: repair.intentHash, + at: recordedAt, + }); + if (expired === null) throw semantic('due approval changed before recovery expiry'); + intents.transitionInTransaction(token, { + intentId: repair.intentId, + expectedState: 'approval_pending', + nextState: 'terminal', + reasonCode: 'APPROVAL_EXPIRED', + }); + writeInitialOutcome(store, token, { + intentId: repair.intentId, + status: 'payment_denied', + reasonCode: 'APPROVAL_EXPIRED', + recordedAt, + }); + return; + } + if (repair.kind === 'release_reserved') { + budgets.releaseInTransaction(token, { + intentId: repair.intentId, + reasonCode: 'RECOVERY_ABANDONED_UNSIGNED', + }); + intents.transitionInTransaction(token, { + intentId: repair.intentId, + expectedState: 'reserved', + nextState: 'terminal', + reasonCode: 'RECOVERY_ABANDONED_UNSIGNED', + }); + writeInitialOutcome(store, token, { + intentId: repair.intentId, + status: 'payment_failed', + reasonCode: 'RECOVERY_ABANDONED_UNSIGNED', + recordedAt, + }); + return; + } + if (repair.kind === 'hold_ambiguous') { + budgets.holdUnresolvedInTransaction(token, { + intentId: repair.intentId, + reasonCode: 'RECOVERY_PAYMENT_AMBIGUOUS', + }); + store.within(token, ({ db, appendEvent }) => { + const changed = db.prepare(`UPDATE payment_attempts + SET state = 'unresolved', reason_code = 'RECOVERY_PAYMENT_AMBIGUOUS', updated_at = ? + WHERE intent_id = ? AND state = ?`).run( + recordedAt, + repair.intentId, + repair.expectedState, + ); + if (changed.changes !== 1n) throw semantic('recovery PaymentAttempt update lost its race'); + appendEvent({ + entityType: 'payment_attempt', + entityId: repair.intentId, + eventType: 'payment.unresolved', + data: { reasonCode: 'RECOVERY_PAYMENT_AMBIGUOUS', recordedAt }, + }); + }); + intents.transitionInTransaction(token, { + intentId: repair.intentId, + expectedState: repair.expectedState, + nextState: 'unresolved', + reasonCode: 'RECOVERY_PAYMENT_AMBIGUOUS', + }); + writeInitialOutcome(store, token, { + intentId: repair.intentId, + status: 'payment_unresolved', + reasonCode: 'RECOVERY_PAYMENT_AMBIGUOUS', + recordedAt, + }); + return; + } + if (repair.kind === 'repair_missing_execution') { + store.within(token, ({ db, appendEvent }) => { + const metadataJson = canonicalJson({ reasonCode: 'RECOVERY_EXECUTION_MISSING' }); + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'unknown', NULL, NULL, ?, ?)`).run( + repair.intentId, + metadataJson, + recordedAt, + ); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at, resolved_at) + VALUES (?, 'reconciliation_required', 'RECOVERY_EXECUTION_MISSING', 1, ?, NULL)`).run( + repair.intentId, + recordedAt, + ); + appendEvent({ + entityType: 'execution_outcome', + entityId: repair.intentId, + eventType: 'execution.recorded', + data: { + state: 'unknown', + httpStatus: null, + responseHash: null, + metadataHash: sha256(metadataJson), + reasonCode: 'RECOVERY_EXECUTION_MISSING', + recordedAt, + }, + }); + appendEvent({ + entityType: 'execution_resolution', + entityId: repair.intentId, + eventType: 'execution_resolution.opened', + data: { + intentId: repair.intentId, + state: 'reconciliation_required', + reasonCode: 'RECOVERY_EXECUTION_MISSING', + blocksWallet: true, + openedAt: recordedAt, + }, + }); + }); + intents.transitionInTransaction(token, { + intentId: repair.intentId, + expectedState: repair.expectedState, + nextState: 'terminal', + reasonCode: 'RECOVERY_EXECUTION_MISSING', + }); + writeInitialOutcome(store, token, { + intentId: repair.intentId, + status: 'execution_unknown', + reasonCode: 'RECOVERY_EXECUTION_MISSING', + recordedAt, + }); + return; + } + throw semantic('unknown deterministic recovery repair'); + }); +} + +export function recoverKernelAuthority(value) { + if (arguments.length !== 1) { + throw new TypeError('recoverKernelAuthority requires exactly one dependency object'); + } + const dependencies = assertPlainDependencies( + value, + ['store', 'intents', 'budgets', 'approvals', 'receipts', 'now'], + 'recovery dependencies', + ); + const { store, intents, budgets, approvals, receipts, now } = dependencies; + requireMethods(store, ['transaction', 'within'], 'recovery store'); + requireMethods(intents, ['transitionInTransaction'], 'recovery intent repository'); + requireMethods(budgets, [ + 'snapshotInTransaction', 'releaseInTransaction', 'holdUnresolvedInTransaction', + ], 'recovery budget ledger'); + requireMethods(approvals, ['expireForIntentInTransaction'], 'recovery approval queue'); + requireMethods(receipts, [ + 'issueMissingTerminalReceipts', 'assertParity', 'assertParityInTransaction', + 'assertRecoverableParityInTransaction', 'verify', + ], 'recovery receipt repository'); + if (typeof now !== 'function' || utilTypes.isProxy(now)) { + throw new TypeError('recovery requires an ordinary clock'); + } + + let startupAt; + try { startupAt = canonicalTimestamp(now(), 'recovery startup time'); } catch (cause) { + throw semantic('recovery clock is invalid', cause); + } + let classification; + try { + classification = auditAndClassify({ store, budgets, receipts, startupAt }); + } catch (cause) { + throw semantic('pre-classification authority audit failed', cause); + } + for (const repair of classification.repairs) { + try { + applyRepair({ store, intents, budgets, approvals, now }, repair, startupAt); + } catch (cause) { + throw semantic('deterministic recovery repair failed', cause); + } + } + + let repairedReceipts; + let final; + try { + repairedReceipts = receipts.issueMissingTerminalReceipts(); + receipts.assertParity(); + final = auditAndClassify({ store, budgets, receipts, startupAt, final: true }); + } catch (cause) { + throw semantic('post-recovery authority audit failed', cause); + } + return frozenCopy({ + ready: true, + repairedIntentCount: classification.repairs.length, + repairedReceiptCount: Array.isArray(repairedReceipts) ? repairedReceipts.length : 0, + retainedIntentCount: final.retainedIntentCount, + unresolvedIntentCount: final.unresolvedIntentCount, + pendingApprovalCount: final.pendingApprovalCount, + }); +} + +function reconciliationInput(value, required, optional, label) { + let record; + try { + record = exactRecord(value, required, optional, 'RECONCILIATION_INPUT', label); + } catch (cause) { + if (cause instanceof KernelError && cause.code === 'RECONCILIATION_INPUT') throw cause; + throw new KernelError('RECONCILIATION_INPUT', `${label} is invalid`, { cause }); + } + return record; +} + +function canonicalReconciliationHash(value, label) { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + throw new KernelError('RECONCILIATION_INPUT', `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalReconciliationToken(value, label) { + try { return canonicalToken(value, label); } catch (cause) { + throw new KernelError('RECONCILIATION_INPUT', `${label} is invalid`, { cause }); + } +} + +function canonicalReconciliationTransaction(value, label) { + if (typeof value !== 'string' || !EVM_HASH_PATTERN.test(value)) { + throw new KernelError( + 'RECONCILIATION_INPUT', + `${label} must be one canonical lowercase transaction hash`, + ); + } + return value; +} + +function reconciliationConflict(message) { + throw new KernelError('RECONCILIATION_CONFLICT', message); +} + +function reconciliationMismatch(message) { + throw new KernelError('RECONCILIATION_MISMATCH', message); +} + +function assertExpectedReconciliationIntentHash(authority, expectedIntentHash) { + if (authority.intent.intent_hash !== expectedIntentHash) { + reconciliationConflict('displayed intent hash is stale'); + } +} + +function reconciliationClockTimestamp(now, label, notBefore = []) { + let value; + try { + value = canonicalTimestamp(now(), label); + } catch (cause) { + throw new KernelError('RECONCILIATION_TIME', `${label} is invalid`, { cause }); + } + const instant = Date.parse(value); + for (const predecessor of notBefore) { + if (predecessor === null || predecessor === undefined) continue; + let canonicalPredecessor; + try { + canonicalPredecessor = canonicalTimestamp(predecessor, `${label} predecessor`); + } catch (cause) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + `${label} predecessor timestamp is invalid`, + { cause }, + ); + } + if (instant < Date.parse(canonicalPredecessor)) { + throw new KernelError( + 'RECONCILIATION_TIME', + `${label} regressed behind persisted authority`, + ); + } + } + return value; +} + +function assertEvidenceTimestampNotFuture(value, resolvedAt, label) { + const observedAt = safeTimestamp(value, label); + if (Date.parse(observedAt) > Date.parse(resolvedAt)) { + reconciliationMismatch(`${label} is later than resolver completion`); + } + return observedAt; +} + +function exactResolverResult(value, required, optional, label) { + try { + return exactRecord(value, required, optional, 'RECONCILIATION_EVIDENCE', label); + } catch (cause) { + if (cause instanceof KernelError && cause.code === 'RECONCILIATION_EVIDENCE') throw cause; + throw new KernelError('RECONCILIATION_EVIDENCE', `${label} is malformed`, { cause }); + } +} + +function loadReconciliationPolicy(db, policyVersionId) { + const row = db.prepare('SELECT * FROM policy_versions WHERE id = ?').get(policyVersionId); + if (!row) throw new KernelError('RECONCILIATION_CORRUPTION', 'PolicyVersion is missing'); + let policy; + try { + const parsed = JSON.parse(row.canonical_json); + policy = validatePolicyDocument(parsed); + if (canonicalJson(policy) !== row.canonical_json + || sha256(row.canonical_json) !== row.policy_hash) { + throw new Error('policy binding changed'); + } + } catch (cause) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'persisted PolicyVersion is invalid', + { cause }, + ); + } + return Object.freeze({ id: row.id, hash: row.policy_hash, policy }); +} + +function selectedReconciliationAuthority(db, intentId) { + const intent = db.prepare('SELECT * FROM spend_intents WHERE id = ?').get(intentId); + if (!intent) throw new KernelError('INTENT_UNKNOWN', 'Spend Intent does not exist'); + const session = db.prepare('SELECT * FROM spend_sessions WHERE id = ?').get(intent.session_id); + const decision = db.prepare('SELECT * FROM policy_decisions WHERE intent_id = ?').get(intentId); + const budget = db.prepare('SELECT * FROM budget_reservations WHERE intent_id = ?').get(intentId); + const attempt = db.prepare('SELECT * FROM payment_attempts WHERE intent_id = ?').get(intentId); + const outcome = db.prepare('SELECT * FROM buyer_outcomes WHERE intent_id = ?').get(intentId); + if (!session || !decision || !budget || !attempt || !outcome) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'reconciliation authority is incomplete', + ); + } + const policyVersion = loadReconciliationPolicy(db, decision.policy_version_id); + let challenge; + try { + challenge = JSON.parse(intent.challenge_projection_json); + if (canonicalJson(challenge) !== intent.challenge_projection_json + || sha256(intent.challenge_projection_json) !== intent.challenge_hash) { + throw new Error('challenge binding changed'); + } + } catch (cause) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'persisted challenge projection is invalid', + { cause }, + ); + } + const acceptedIndex = number(decision.accepted_index, 'PolicyDecision accepted index'); + const selected = challenge.accepts?.[acceptedIndex]; + const seller = policyVersion.policy.sellers.find((candidate) => ( + candidate.origin === intent.seller_origin + && candidate.pathPrefixes.some((prefix) => intent.resource_path.startsWith(prefix)) + )); + if (!selected || !seller + || session.policy_version_id !== policyVersion.id + || intent.wallet_address !== session.wallet_address + || policyVersion.policy.wallet !== session.wallet_address + || decision.challenge_hash !== intent.challenge_hash + || decision.quote_id !== attempt.quote_id + || acceptedIndex !== number(attempt.accepted_index, 'PaymentAttempt accepted index') + || attempt.payment_required_projection_json !== intent.challenge_projection_json + || selected.network !== policyVersion.policy.network + || selected.asset !== policyVersion.policy.asset + || selected.payTo !== seller.payTo + || selected.amount !== decision.amount_ceiling_atomic) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'persisted payment authority bindings disagree', + ); + } + canonicalReconciliationHash(intent.intent_hash, 'persisted intent hash'); + canonicalReconciliationHash(intent.challenge_hash, 'persisted challenge hash'); + canonicalReconciliationHash(attempt.payment_hash, 'persisted payment header hash'); + canonicalReconciliationTransaction(attempt.nonce, 'persisted authorization nonce'); + if (attempt.payment_payload_json === null || attempt.payment_header === null + || attempt.valid_after === null || attempt.valid_before === null) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'reconciliation requires one fully persisted signed authorization', + ); + } + const receipt = db.prepare(`SELECT * FROM signed_receipts + WHERE intent_id = ? ORDER BY revision DESC LIMIT 1`).get(intentId); + if (!receipt || number(receipt.revision, 'signed receipt revision') + !== number(outcome.revision, 'BuyerOutcome revision')) { + throw new KernelError('RECEIPT_PARITY_REQUIRED', 'current BuyerOutcome receipt is missing'); + } + return Object.freeze({ + intent, + session, + decision, + budget, + attempt, + outcome, + policyVersion, + selected, + seller, + predecessorReceiptHash: receipt.receipt_hash, + }); +} + +function localAttemptBindingHash(authority) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-attempt-binding.v1', + intentHash: authority.intent.intent_hash, + challengeHash: authority.intent.challenge_hash, + quoteId: authority.attempt.quote_id, + paymentPayloadHash: sha256(authority.attempt.payment_payload_json), + paymentHeaderHash: authority.attempt.payment_hash, + network: authority.policyVersion.policy.network, + payer: authority.session.wallet_address, + payee: authority.selected.payTo, + asset: authority.policyVersion.policy.asset, + amountAtomic: authority.decision.amount_ceiling_atomic, + nonce: authority.attempt.nonce, + validAfter: authority.attempt.valid_after, + validBefore: authority.attempt.valid_before, + })); +} + +function localRefundBindingHash(authority, refundTransactionId) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-binding.v1', + intentHash: authority.intent.intent_hash, + originalTransactionId: authority.attempt.transaction_id, + refundTransactionId, + network: authority.policyVersion.policy.network, + sellerOrigin: authority.intent.seller_origin, + asset: authority.policyVersion.policy.asset, + originalPayer: authority.session.wallet_address, + originalPayee: authority.selected.payTo, + refundSource: authority.seller.refundSource, + refundSigner: authority.seller.refundSigner, + amountAtomic: authority.decision.amount_ceiling_atomic, + })); +} + +function paymentHistory(db, intentId) { + return db.prepare(`SELECT * FROM payment_reconciliation_candidates + WHERE intent_id = ? ORDER BY rowid`).all(intentId); +} + +function refundHistory(db, intentId) { + return db.prepare('SELECT * FROM refunds WHERE intent_id = ? ORDER BY rowid').all(intentId); +} + +function evidenceDigest(value) { + return value === null ? null : sha256(value); +} + +function paymentCaseHash(authority, candidates) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-reconciliation-case.v1', + intentId: authority.intent.id, + intentHash: authority.intent.intent_hash, + attemptState: authority.attempt.state, + budgetState: authority.budget.state, + buyerOutcomeRevision: number(authority.outcome.revision, 'BuyerOutcome revision'), + history: candidates.map((row) => ({ + id: row.id, + transactionId: row.transaction_id, + state: row.state, + evidenceHash: evidenceDigest(row.evidence_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + })); +} + +function executionCaseHash(authority, execution, resolution) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.execution-reconciliation-case.v1', + intentId: authority.intent.id, + intentHash: authority.intent.intent_hash, + transactionId: authority.attempt.transaction_id, + execution: { + state: execution.state, + httpStatus: execution.http_status === null ? null : number(execution.http_status, 'HTTP status'), + responseHash: execution.response_hash, + metadataHash: sha256(execution.metadata_json), + recordedAt: execution.recorded_at, + }, + resolution: { + state: resolution.state, + reasonCode: resolution.reason_code, + openedAt: resolution.opened_at, + }, + buyerOutcomeRevision: number(authority.outcome.revision, 'BuyerOutcome revision'), + })); +} + +function refundCaseHash(authority, execution, resolution, refunds) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-observation-case.v1', + intentId: authority.intent.id, + intentHash: authority.intent.intent_hash, + originalTransactionId: authority.attempt.transaction_id, + executionState: execution.state, + resolutionState: resolution.state, + buyerOutcomeRevision: number(authority.outcome.revision, 'BuyerOutcome revision'), + history: refunds.map((row) => ({ + id: row.id, + originalTransactionId: row.original_transaction_id, + amountAtomic: row.amount_atomic, + state: row.state, + refundTransactionId: row.refund_transaction_id, + evidenceHash: evidenceDigest(row.evidence_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + })); +} + +function loadPaymentCase(db, intentId) { + const authority = selectedReconciliationAuthority(db, intentId); + if (authority.intent.state !== 'unresolved' + || authority.attempt.state !== 'unresolved' + || authority.budget.state !== 'unresolved' + || authority.outcome.status !== 'payment_unresolved') { + throw new KernelError( + 'RECONCILIATION_STATE', + 'payment reconciliation requires one unresolved signed payment', + ); + } + const candidates = paymentHistory(db, intentId); + if (candidates.filter((row) => row.state === 'pending').length > 1) { + throw new KernelError('RECONCILIATION_CORRUPTION', 'payment candidates are ambiguous'); + } + for (const row of candidates) { + canonicalReconciliationTransaction(row.transaction_id, 'persisted payment candidate'); + if (row.state === 'pending' && row.evidence_json !== null) { + throw new KernelError('RECONCILIATION_CORRUPTION', 'pending candidate contains evidence'); + } + } + return Object.freeze({ + authority, + candidates: Object.freeze(candidates), + caseHash: paymentCaseHash(authority, candidates), + }); +} + +function loadExecutionCase(db, intentId) { + const authority = selectedReconciliationAuthority(db, intentId); + const execution = db.prepare('SELECT * FROM execution_outcomes WHERE intent_id = ?').get(intentId); + const resolution = db.prepare('SELECT * FROM execution_resolutions WHERE intent_id = ?').get(intentId); + if (authority.intent.state !== 'terminal' + || authority.attempt.state !== 'settled' + || authority.budget.state !== 'committed' + || authority.outcome.status !== 'execution_unknown' + || execution?.state !== 'unknown' + || resolution?.state !== 'reconciliation_required' + || number(resolution.blocks_wallet, 'execution wallet blocker') !== 1) { + throw new KernelError( + 'RECONCILIATION_STATE', + 'execution reconciliation requires one blocking unknown execution', + ); + } + return Object.freeze({ + authority, + execution, + resolution, + caseHash: executionCaseHash(authority, execution, resolution), + }); +} + +function loadRefundCase(db, intentId) { + const authority = selectedReconciliationAuthority(db, intentId); + const execution = db.prepare('SELECT * FROM execution_outcomes WHERE intent_id = ?').get(intentId); + const resolution = db.prepare('SELECT * FROM execution_resolutions WHERE intent_id = ?').get(intentId); + const refunds = refundHistory(db, intentId); + const open = refunds.filter((row) => row.state === 'pending' || row.state === 'unresolved'); + if (authority.intent.state !== 'terminal' + || authority.attempt.state !== 'settled' + || authority.budget.state !== 'committed' + || authority.outcome.status !== 'execution_failed' + || execution?.state !== 'failed' + || resolution?.state !== 'refund_pending' + || number(resolution.blocks_wallet, 'refund wallet blocker') !== 1 + || open.length > 1) { + throw new KernelError( + 'RECONCILIATION_STATE', + 'refund observation requires one blocking failed execution', + ); + } + for (const refund of refunds) { + if (refund.original_transaction_id !== authority.attempt.transaction_id + || refund.amount_atomic !== authority.decision.amount_ceiling_atomic) { + throw new KernelError('RECONCILIATION_CORRUPTION', 'refund history binding changed'); + } + if (refund.refund_transaction_id !== null) { + canonicalReconciliationTransaction(refund.refund_transaction_id, 'persisted refund transaction'); + } + } + return Object.freeze({ + authority, + execution, + resolution, + refunds: Object.freeze(refunds), + openRefund: open[0] ?? null, + caseHash: refundCaseHash(authority, execution, resolution, refunds), + }); +} + +function assertUnusedTransaction(db, transactionId, allowances = {}) { + const rows = [ + ...db.prepare(`SELECT 'payment' AS kind, intent_id AS owner, transaction_id AS value + FROM payment_attempts WHERE transaction_id IS NOT NULL`).all(), + ...db.prepare(`SELECT 'payment_candidate' AS kind, id AS owner, transaction_id AS value + FROM payment_reconciliation_candidates`).all(), + ...db.prepare(`SELECT 'refund' AS kind, id AS owner, refund_transaction_id AS value + FROM refunds WHERE refund_transaction_id IS NOT NULL`).all(), + ]; + for (const row of rows) { + if (row.value === transactionId && allowances[`${row.kind}:${row.owner}`] !== true) { + throw new KernelError('TRANSACTION_REUSED', 'transaction is already bound to authority'); + } + } +} + +function nextReconciliationId(db, idFactory, kind, table = 'reconciliations') { + for (let attempt = 0; attempt < 32; attempt += 1) { + const id = canonicalReconciliationToken(idFactory(kind), `${kind} ID`); + if (!db.prepare(`SELECT rowid FROM ${table} WHERE id = ?`).get(id)) return id; + } + throw new KernelError('ID_FACTORY_COLLISION', `${kind} ID factory exhausted collisions`); +} + +function persistedPaymentBinding(paymentCase, candidate) { + const { authority } = paymentCase; + return frozenCopy({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-observation.v1', + intentId: authority.intent.id, + intentHash: authority.intent.intent_hash, + challengeHash: authority.intent.challenge_hash, + quoteId: authority.attempt.quote_id, + network: authority.policyVersion.policy.network, + asset: authority.policyVersion.policy.asset, + payer: authority.session.wallet_address, + payee: authority.selected.payTo, + amountAtomic: authority.decision.amount_ceiling_atomic, + nonce: authority.attempt.nonce, + validAfter: authority.attempt.valid_after, + validBefore: authority.attempt.valid_before, + paymentPayloadHash: sha256(authority.attempt.payment_payload_json), + paymentHeaderHash: authority.attempt.payment_hash, + localAttemptHash: localAttemptBindingHash(authority), + caseHash: paymentCase.caseHash, + candidate: candidate === null ? null : { + id: candidate.id, + transactionId: candidate.transaction_id, + state: candidate.state, + createdAt: candidate.created_at, + }, + }); +} + +function persistedExecutionBinding(executionCase) { + const { authority, execution, resolution } = executionCase; + return frozenCopy({ + schemaVersion: 1, + domain: 'wallet-kernel.execution-observation.v1', + intentId: authority.intent.id, + intentHash: authority.intent.intent_hash, + policyVersion: { + id: authority.policyVersion.id, + hash: authority.policyVersion.hash, + policy: authority.policyVersion.policy, + }, + seller: authority.seller, + resourcePath: authority.intent.resource_path, + network: authority.policyVersion.policy.network, + sellerOrigin: authority.intent.seller_origin, + transactionId: authority.attempt.transaction_id, + executionSigner: authority.seller.executionSigner, + persistedHttpStatus: execution.http_status === null + ? null : number(execution.http_status, 'persisted HTTP status'), + persistedResponseHash: execution.response_hash, + resolutionReasonCode: resolution.reason_code, + caseHash: executionCase.caseHash, + }); +} + +function persistedRefundBinding(refundCase, refund) { + const { authority } = refundCase; + return frozenCopy({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-observation.v1', + intentId: authority.intent.id, + intentHash: authority.intent.intent_hash, + policyVersion: { + id: authority.policyVersion.id, + hash: authority.policyVersion.hash, + policy: authority.policyVersion.policy, + }, + seller: authority.seller, + resourcePath: authority.intent.resource_path, + network: authority.policyVersion.policy.network, + sellerOrigin: authority.intent.seller_origin, + originalTransactionId: authority.attempt.transaction_id, + refundTransactionId: refund.refund_transaction_id, + asset: authority.policyVersion.policy.asset, + originalPayer: authority.session.wallet_address, + originalPayee: authority.selected.payTo, + refundSource: authority.seller.refundSource, + refundSigner: authority.seller.refundSigner, + amountAtomic: authority.decision.amount_ceiling_atomic, + localRefundBindingHash: localRefundBindingHash(authority, refund.refund_transaction_id), + refundId: refund.id, + caseHash: refundCase.caseHash, + }); +} + +function canonicalDecimal(value, label, { positive = false } = {}) { + if (typeof value !== 'string' || !/^(?:0|[1-9][0-9]*)$/.test(value)) { + reconciliationMismatch(`${label} must be canonical decimal text`); + } + const parsed = BigInt(value); + if (positive && parsed <= 0n) reconciliationMismatch(`${label} must be positive`); + return parsed; +} + +function nonnegativeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) reconciliationMismatch(`${label} is invalid`); + return value; +} + +function confirmedInteger(value, minimumConfirmations, label) { + if (!Number.isSafeInteger(value) || value < minimumConfirmations) { + reconciliationMismatch(`${label} does not meet the minimum confirmation depth`); + } + return value; +} + +const TRANSFER_PROOF_FIELDS = Object.freeze([ + 'source', + 'network', + 'transactionId', + 'blockHash', + 'blockNumber', + 'transactionStatus', + 'confirmations', + 'transferLogIndex', + 'authorizationLogIndex', + 'tokenContract', + 'from', + 'to', + 'valueAtomic', + 'authorizationNonce', + 'observedAt', +]); + +function validateTransferProof(value, binding, minimumConfirmations, resolvedAt) { + const proof = exactResolverResult( + value, + TRANSFER_PROOF_FIELDS, + [], + 'settled transfer RPC proof', + ); + if (proof.source !== 'base-sepolia-rpc' + || proof.network !== binding.network + || proof.transactionId !== binding.candidate?.transactionId + || proof.transactionStatus !== 'success' + || proof.tokenContract !== binding.asset + || proof.from !== binding.payer + || proof.to !== binding.payee + || proof.valueAtomic !== binding.amountAtomic + || proof.authorizationNonce !== binding.nonce) { + reconciliationMismatch('settled transfer proof differs from persisted payment authority'); + } + canonicalReconciliationTransaction(proof.transactionId, 'settled transaction ID'); + canonicalReconciliationTransaction(proof.blockHash, 'settled block hash'); + canonicalReconciliationTransaction(proof.authorizationNonce, 'settled authorization nonce'); + canonicalDecimal(proof.blockNumber, 'settled block number', { positive: true }); + confirmedInteger( + proof.confirmations, + minimumConfirmations, + 'settled transfer confirmations', + ); + nonnegativeInteger(proof.transferLogIndex, 'settled transfer log index'); + nonnegativeInteger(proof.authorizationLogIndex, 'settled authorization log index'); + assertEvidenceTimestampNotFuture( + proof.observedAt, + resolvedAt, + 'settled transfer observedAt', + ); + return frozenCopy(proof); +} + +function validateRejectedCandidateProof( + value, + binding, + kind, + minimumConfirmations, + resolvedAt, +) { + const proof = exactResolverResult(value, [ + 'source', 'network', 'transactionId', 'blockHash', 'blockNumber', + 'transactionStatus', 'confirmations', 'reasonCode', 'observedAt', + ], [], `${kind} rejected-candidate proof`); + const expectedTransaction = kind === 'payment' + ? binding.candidate?.transactionId + : binding.refundTransactionId; + if (proof.source !== 'base-sepolia-rpc' + || proof.network !== binding.network + || proof.transactionId !== expectedTransaction + || !new Set(['reverted', 'success']).has(proof.transactionStatus) + || !new Set(['TRANSACTION_REVERTED', 'EXACT_TRANSFER_ABSENT']).has(proof.reasonCode) + || (proof.transactionStatus === 'reverted' && proof.reasonCode !== 'TRANSACTION_REVERTED') + || (proof.transactionStatus === 'success' && proof.reasonCode !== 'EXACT_TRANSFER_ABSENT')) { + reconciliationMismatch(`${kind} rejected-candidate proof is not conclusive`); + } + canonicalReconciliationTransaction(proof.transactionId, `${kind} rejected transaction ID`); + canonicalReconciliationTransaction(proof.blockHash, `${kind} rejected block hash`); + canonicalDecimal(proof.blockNumber, `${kind} rejected block number`, { positive: true }); + confirmedInteger(proof.confirmations, minimumConfirmations, `${kind} rejected confirmations`); + assertEvidenceTimestampNotFuture( + proof.observedAt, + resolvedAt, + `${kind} rejected observedAt`, + ); + return frozenCopy(proof); +} + +function validateUnusedAuthorization(value, binding, minimumConfirmations, resolvedAt) { + const proof = exactResolverResult(value, [ + 'kind', + 'network', + 'asset', + 'payer', + 'nonce', + 'validBefore', + 'authorizationState', + 'observedBlockNumber', + 'observedBlockHash', + 'observedBlockTimestamp', + 'confirmations', + ], [], 'unused-authorization proof'); + const validBefore = canonicalDecimal(proof.validBefore, 'authorization validBefore'); + const observedAt = canonicalDecimal( + proof.observedBlockTimestamp, + 'authorization observed block timestamp', + ); + if (proof.kind !== 'authorization_unused_after_expiry' + || proof.network !== binding.network + || proof.asset !== binding.asset + || proof.payer !== binding.payer + || proof.nonce !== binding.nonce + || proof.validBefore !== binding.validBefore + || proof.authorizationState !== false + || observedAt < validBefore + || observedAt * 1_000n > BigInt(Date.parse(resolvedAt))) { + reconciliationMismatch('unused-authorization proof differs from persisted authority'); + } + canonicalReconciliationTransaction(proof.nonce, 'unused authorization nonce'); + canonicalReconciliationTransaction(proof.observedBlockHash, 'unused authorization block hash'); + canonicalDecimal(proof.observedBlockNumber, 'unused authorization block number', { positive: true }); + confirmedInteger( + proof.confirmations, + minimumConfirmations, + 'unused authorization confirmations', + ); + return frozenCopy(proof); +} + +function normalizePaymentObservation(value, binding, minimumConfirmations, resolvedAt) { + const discriminator = exactResolverResult( + value, + ['kind'], + ['rpcTransferProof', 'rejectionProof', 'reasonCode', 'network', 'asset', 'payer', 'nonce', + 'validBefore', 'authorizationState', 'observedBlockNumber', 'observedBlockHash', + 'observedBlockTimestamp', 'confirmations'], + 'payment observation result', + ); + if (discriminator.kind === 'unknown') { + const unknown = exactResolverResult( + value, + ['kind', 'reasonCode'], + [], + 'unknown payment observation', + ); + if (!PAYMENT_RPC_UNKNOWN_REASONS.has(unknown.reasonCode)) { + throw new KernelError( + 'RECONCILIATION_EVIDENCE', + 'unknown payment observation reason is not allowlisted', + ); + } + return Object.freeze({ kind: 'unknown', reasonCode: unknown.reasonCode }); + } + if (discriminator.kind === 'settled_transfer') { + const result = exactResolverResult( + value, + ['kind', 'rpcTransferProof'], + [], + 'settled payment observation', + ); + if (binding.candidate === null) { + reconciliationMismatch('settled payment observation has no persisted candidate'); + } + const rpcTransferProof = validateTransferProof( + result.rpcTransferProof, + binding, + minimumConfirmations, + resolvedAt, + ); + return frozenCopy({ + kind: 'settled_transfer', + evidence: { + kind: 'settled_transfer', + transactionId: binding.candidate.transactionId, + rpcProofHash: sha256(canonicalJson(rpcTransferProof)), + localAttemptHash: binding.localAttemptHash, + }, + }); + } + if (discriminator.kind === 'payment_candidate_rejected') { + const result = exactResolverResult( + value, + ['kind', 'rejectionProof'], + [], + 'rejected payment candidate observation', + ); + if (binding.candidate === null) { + reconciliationMismatch('rejected payment observation has no persisted candidate'); + } + const proof = validateRejectedCandidateProof( + result.rejectionProof, + binding, + 'payment', + minimumConfirmations, + resolvedAt, + ); + return frozenCopy({ + kind: 'payment_candidate_rejected', + evidence: { + kind: 'payment_candidate_rejected', + transactionId: binding.candidate.transactionId, + reasonCode: proof.reasonCode, + rpcProofHash: sha256(canonicalJson(proof)), + }, + }); + } + if (discriminator.kind === 'authorization_unused_after_expiry') { + return frozenCopy({ + kind: 'authorization_unused_after_expiry', + evidence: validateUnusedAuthorization( + value, + binding, + minimumConfirmations, + resolvedAt, + ), + }); + } + throw new KernelError('RECONCILIATION_EVIDENCE', 'payment resolver returned an unknown kind'); +} + +function validateAttestationWindow(attestation, observedAt, label) { + const issuedAt = safeTimestamp(attestation.issuedAt, `${label} issuedAt`); + const expiresAt = safeTimestamp(attestation.expiresAt, `${label} expiresAt`); + const current = safeTimestamp(observedAt, `${label} observation time`); + if (Date.parse(expiresAt) <= Date.parse(issuedAt) + || Date.parse(expiresAt) - Date.parse(issuedAt) > 15 * 60 * 1_000 + || Date.parse(current) < Date.parse(issuedAt) + || Date.parse(current) >= Date.parse(expiresAt)) { + reconciliationMismatch(`${label} validity window is not current and bounded`); + } +} + +function normalizeExecutionObservation(value, binding, observedAt) { + const discriminator = exactResolverResult( + value, + ['kind'], + ['attestation', 'attestationHash', 'reasonCode'], + 'execution observation result', + ); + if (discriminator.kind === 'unknown') { + const unknown = exactResolverResult( + value, + ['kind', 'reasonCode'], + [], + 'unknown execution observation', + ); + if (!SELLER_EVIDENCE_UNKNOWN_REASONS.has(unknown.reasonCode)) { + throw new KernelError( + 'RECONCILIATION_EVIDENCE', + 'unknown execution observation reason is not allowlisted', + ); + } + return Object.freeze({ kind: 'unknown', reasonCode: unknown.reasonCode }); + } + const result = exactResolverResult( + value, + ['kind', 'attestation', 'attestationHash'], + [], + 'verified execution observation', + ); + if (result.kind !== 'execution_attested') { + throw new KernelError('RECONCILIATION_EVIDENCE', 'execution resolver returned an unknown kind'); + } + const attestation = exactResolverResult(result.attestation, [ + 'schemaVersion', 'domain', 'network', 'sellerOrigin', 'intentHash', + 'transactionId', 'outcome', 'httpStatus', 'responseHash', 'issuedAt', + 'expiresAt', 'signer', + ], [], 'verified execution attestation'); + const attestationHash = canonicalReconciliationHash( + result.attestationHash, + 'execution attestation hash', + ); + if (attestation.schemaVersion !== 1 + || attestation.domain !== 'wallet-kernel.execution.v1' + || attestation.network !== binding.network + || attestation.sellerOrigin !== binding.sellerOrigin + || attestation.intentHash !== binding.intentHash + || attestation.transactionId !== binding.transactionId + || !new Set(['succeeded', 'failed']).has(attestation.outcome) + || attestation.signer !== binding.executionSigner + || sha256(canonicalJson(attestation)) !== attestationHash + || (binding.persistedHttpStatus !== null + && attestation.httpStatus !== binding.persistedHttpStatus) + || (binding.persistedResponseHash !== null + && attestation.responseHash !== binding.persistedResponseHash)) { + reconciliationMismatch('execution attestation differs from persisted authority'); + } + if (!Number.isSafeInteger(attestation.httpStatus) + || attestation.httpStatus < 100 || attestation.httpStatus > 599 + || (attestation.outcome === 'succeeded' + && (attestation.httpStatus < 200 || attestation.httpStatus > 299)) + || (attestation.outcome === 'failed' && attestation.httpStatus < 400)) { + reconciliationMismatch('execution attestation status disagrees with its outcome'); + } + if (attestation.responseHash !== null) { + canonicalReconciliationHash(attestation.responseHash, 'execution response hash'); + } + validateAttestationWindow(attestation, observedAt, 'execution attestation'); + return frozenCopy({ kind: 'execution_attested', attestation, attestationHash }); +} + +const REFUND_RPC_FIELDS = Object.freeze([ + 'source', 'network', 'transactionId', 'blockHash', 'blockNumber', + 'transactionStatus', 'confirmations', 'transferLogIndex', 'tokenContract', + 'from', 'to', 'valueAtomic', 'observedAt', +]); + +function validateRefundTransferProof(value, binding, minimumConfirmations, resolvedAt) { + const proof = exactResolverResult(value, REFUND_RPC_FIELDS, [], 'refund transfer RPC proof'); + if (proof.source !== 'base-sepolia-rpc' + || proof.network !== binding.network + || proof.transactionId !== binding.refundTransactionId + || proof.transactionStatus !== 'success' + || proof.tokenContract !== binding.asset + || proof.from !== binding.refundSource + || proof.to !== binding.originalPayer + || proof.valueAtomic !== binding.amountAtomic) { + reconciliationMismatch('refund transfer proof differs from persisted authority'); + } + canonicalReconciliationTransaction(proof.transactionId, 'refund transaction ID'); + canonicalReconciliationTransaction(proof.blockHash, 'refund block hash'); + canonicalDecimal(proof.blockNumber, 'refund block number', { positive: true }); + confirmedInteger(proof.confirmations, minimumConfirmations, 'refund confirmations'); + nonnegativeInteger(proof.transferLogIndex, 'refund transfer log index'); + assertEvidenceTimestampNotFuture(proof.observedAt, resolvedAt, 'refund observedAt'); + return frozenCopy(proof); +} + +function normalizeRefundObservation(value, binding, observedAt, minimumConfirmations) { + const discriminator = exactResolverResult( + value, + ['kind'], + ['attestation', 'attestationHash', 'rpcTransferProof', 'rejectionProof', 'reasonCode'], + 'refund observation result', + ); + if (discriminator.kind === 'unknown') { + const unknown = exactResolverResult( + value, + ['kind', 'reasonCode'], + [], + 'unknown refund observation', + ); + if (!SELLER_EVIDENCE_UNKNOWN_REASONS.has(unknown.reasonCode) + && !REFUND_RPC_UNKNOWN_REASONS.has(unknown.reasonCode)) { + throw new KernelError( + 'RECONCILIATION_EVIDENCE', + 'unknown refund observation reason is not allowlisted', + ); + } + return Object.freeze({ kind: 'unknown', reasonCode: unknown.reasonCode }); + } + if (discriminator.kind === 'refund_candidate_rejected') { + const result = exactResolverResult( + value, + ['kind', 'rejectionProof'], + [], + 'rejected refund candidate observation', + ); + const proof = validateRejectedCandidateProof( + result.rejectionProof, + binding, + 'refund', + minimumConfirmations, + observedAt, + ); + return frozenCopy({ + kind: 'refund_candidate_rejected', + evidence: { + kind: 'refund_candidate_rejected', + refundTransactionId: binding.refundTransactionId, + reasonCode: proof.reasonCode, + rpcProofHash: sha256(canonicalJson(proof)), + }, + }); + } + const result = exactResolverResult(value, [ + 'kind', 'attestation', 'attestationHash', 'rpcTransferProof', + ], [], 'confirmed refund observation'); + if (result.kind !== 'refund_attested_and_confirmed') { + throw new KernelError('RECONCILIATION_EVIDENCE', 'refund resolver returned an unknown kind'); + } + const attestation = exactResolverResult(result.attestation, [ + 'schemaVersion', 'domain', 'network', 'sellerOrigin', 'intentHash', + 'originalTransactionId', 'refundTransactionId', 'asset', 'originalPayer', + 'originalPayee', 'refundSource', 'amountAtomic', 'issuedAt', 'expiresAt', 'signer', + ], [], 'verified refund attestation'); + const attestationHash = canonicalReconciliationHash( + result.attestationHash, + 'refund attestation hash', + ); + if (attestation.schemaVersion !== 1 + || attestation.domain !== 'wallet-kernel.refund.v1' + || attestation.network !== binding.network + || attestation.sellerOrigin !== binding.sellerOrigin + || attestation.intentHash !== binding.intentHash + || attestation.originalTransactionId !== binding.originalTransactionId + || attestation.refundTransactionId !== binding.refundTransactionId + || attestation.asset !== binding.asset + || attestation.originalPayer !== binding.originalPayer + || attestation.originalPayee !== binding.originalPayee + || attestation.refundSource !== binding.refundSource + || attestation.amountAtomic !== binding.amountAtomic + || attestation.signer !== binding.refundSigner + || sha256(canonicalJson(attestation)) !== attestationHash) { + reconciliationMismatch('refund attestation differs from persisted authority'); + } + validateAttestationWindow(attestation, observedAt, 'refund attestation'); + const rpcProof = validateRefundTransferProof( + result.rpcTransferProof, + binding, + minimumConfirmations, + observedAt, + ); + return frozenCopy({ + kind: 'refund_attested_and_confirmed', + evidence: { + kind: 'refund_attested_and_confirmed', + originalTransactionId: binding.originalTransactionId, + refundTransactionId: binding.refundTransactionId, + attestationHash, + attestation, + rpcProofHash: sha256(canonicalJson(rpcProof)), + localRefundBindingHash: binding.localRefundBindingHash, + }, + }); +} + +function insertReconciliationRow({ db, appendEvent }, idFactory, { + intentId, + kind, + outcome, + evidence, + operatorIdHash, + recordedAt, + requestCaseHash, + observedCaseHash, +}) { + const id = nextReconciliationId(db, idFactory, 'reconciliation'); + const evidenceJson = canonicalJson(evidence); + db.prepare(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + id, + intentId, + kind, + outcome, + evidenceJson, + operatorIdHash, + recordedAt, + ); + appendEvent({ + entityType: 'reconciliation', + entityId: id, + eventType: 'reconciliation.recorded', + data: { + intentId, + kind, + outcome, + evidenceHash: sha256(evidenceJson), + operatorIdHash, + recordedAt, + requestCaseHash, + observedCaseHash, + }, + }); + return id; +} + +function reconciliationReplayEvent(db, reconciliation) { + const events = db.prepare(`SELECT * FROM events + WHERE entity_type = 'reconciliation' AND entity_id = ? + AND event_type = 'reconciliation.recorded' ORDER BY sequence`).all(reconciliation.id); + if (events.length !== 1) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'reconciliation replay has no exact recorded event', + ); + } + let data; + try { + data = JSON.parse(events[0].data_json); + if (canonicalJson(data) !== events[0].data_json) throw new Error('non-canonical'); + data = exactRecord(data, [ + 'intentId', 'kind', 'outcome', 'evidenceHash', 'operatorIdHash', 'recordedAt', + 'requestCaseHash', 'observedCaseHash', + ], [], 'RECONCILIATION_CORRUPTION', 'reconciliation replay event'); + } catch (cause) { + if (cause instanceof KernelError) throw cause; + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'reconciliation replay event is malformed', + { cause }, + ); + } + if (data.intentId !== reconciliation.intent_id + || data.kind !== reconciliation.kind + || data.outcome !== reconciliation.outcome + || data.evidenceHash !== sha256(reconciliation.evidence_json) + || data.operatorIdHash !== reconciliation.operator_id_hash + || data.recordedAt !== reconciliation.recorded_at + || !HASH_PATTERN.test(data.requestCaseHash) + || !HASH_PATTERN.test(data.observedCaseHash)) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'reconciliation replay event changed its authority binding', + ); + } + return Object.freeze(data); +} + +function parsedReconciliationEvidence(row) { + try { + const evidence = JSON.parse(row.evidence_json); + if (canonicalJson(evidence) !== row.evidence_json) throw new Error('non-canonical'); + return evidence; + } catch (cause) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'reconciliation replay evidence is malformed', + { cause }, + ); + } +} + +function loadPaymentReplay(db, { + intentId, + operatorIdHash, + expectedIntentHash, + transactionId, + expectedCaseHash, +}) { + const authority = selectedReconciliationAuthority(db, intentId); + assertExpectedReconciliationIntentHash(authority, expectedIntentHash); + const candidates = paymentHistory(db, intentId); + const reconciliations = db.prepare( + `SELECT * FROM reconciliations WHERE intent_id = ? AND kind = 'payment' ORDER BY rowid DESC`, + ).all(intentId); + let candidate = null; + let reconciliation = null; + let expectedObservationKind = null; + let domain = null; + + if (transactionId !== null) { + candidate = candidates.find((row) => row.transaction_id === transactionId) ?? null; + if (!candidate || !new Set(['confirmed', 'rejected']).has(candidate.state) + || candidate.evidence_json === null) { + return null; + } + const expectedOutcome = candidate.state === 'confirmed' ? 'settled' : 'unresolved'; + reconciliation = reconciliations.find((row) => ( + row.outcome === expectedOutcome && row.evidence_json === candidate.evidence_json + )) ?? null; + if (!reconciliation) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'resolved payment candidate lost its reconciliation evidence', + ); + } + if (candidate.state === 'confirmed') { + const execution = db.prepare( + 'SELECT * FROM execution_outcomes WHERE intent_id = ?', + ).get(intentId); + const resolution = db.prepare( + 'SELECT * FROM execution_resolutions WHERE intent_id = ?', + ).get(intentId); + if (authority.attempt.state !== 'settled' + || authority.attempt.transaction_id !== transactionId + || authority.budget.state !== 'committed' + || authority.outcome.status !== 'execution_unknown' + || execution?.state !== 'unknown' + || resolution?.state !== 'reconciliation_required') { + return null; + } + expectedObservationKind = 'settled_transfer'; + domain = frozenCopy({ + status: 'execution_unknown', + reasonCode: authority.outcome.reason_code, + executionCaseHash: executionCaseHash(authority, execution, resolution), + }); + } else { + if (authority.attempt.state !== 'unresolved' + || authority.budget.state !== 'unresolved' + || authority.outcome.status !== 'payment_unresolved' + || authority.outcome.reason_code !== 'PAYMENT_CANDIDATE_REJECTED') { + return null; + } + expectedObservationKind = 'payment_candidate_rejected'; + domain = frozenCopy({ + status: 'payment_unresolved', + reasonCode: authority.outcome.reason_code, + paymentCaseHash: paymentCaseHash(authority, candidates), + }); + } + } else { + reconciliation = reconciliations.find((row) => { + if (row.outcome !== 'rejected') return false; + return parsedReconciliationEvidence(row).kind === 'authorization_unused_after_expiry'; + }) ?? null; + if (!reconciliation + || authority.attempt.state !== 'rejected' + || authority.attempt.reason_code !== 'AUTHORIZATION_UNUSED_AFTER_EXPIRY' + || authority.budget.state !== 'released' + || authority.outcome.status !== 'payment_rejected' + || authority.outcome.reason_code !== 'AUTHORIZATION_UNUSED_AFTER_EXPIRY') { + return null; + } + expectedObservationKind = 'authorization_unused_after_expiry'; + domain = frozenCopy({ + status: 'payment_rejected', + reasonCode: 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + }); + } + + const event = reconciliationReplayEvent(db, reconciliation); + if (event.operatorIdHash !== operatorIdHash) { + reconciliationConflict('payment replay operator differs from committed authority'); + } + const currentCaseHash = domain.paymentCaseHash ?? null; + if (expectedCaseHash !== event.requestCaseHash + && expectedCaseHash !== currentCaseHash) { + reconciliationConflict('payment replay case hash differs from committed authority'); + } + const binding = persistedPaymentBinding({ + authority, + candidates: Object.freeze(candidates), + caseHash: event.observedCaseHash, + }, candidate); + return Object.freeze({ + binding, + candidateId: candidate?.id ?? null, + domain, + event, + expectedObservationKind, + reconciliationId: reconciliation.id, + reconciliationEvidenceJson: reconciliation.evidence_json, + }); +} + +function loadExecutionReplay(db, { + intentId, + operatorIdHash, + expectedIntentHash, + expectedCaseHash, +}) { + const authority = selectedReconciliationAuthority(db, intentId); + assertExpectedReconciliationIntentHash(authority, expectedIntentHash); + const execution = db.prepare( + 'SELECT * FROM execution_outcomes WHERE intent_id = ?', + ).get(intentId); + const resolution = db.prepare( + 'SELECT * FROM execution_resolutions WHERE intent_id = ?', + ).get(intentId); + if (!execution || !resolution || !new Set(['succeeded', 'failed']).has(execution.state)) { + return null; + } + const reconciliations = db.prepare( + 'SELECT * FROM reconciliations WHERE intent_id = ? ORDER BY rowid', + ).all(intentId); + const reconciliation = reconciliations.at(-1); + const expectedOutcome = execution.state === 'succeeded' + ? 'execution_succeeded' + : 'execution_failed'; + if (reconciliation?.kind !== 'execution' + || reconciliation.outcome !== expectedOutcome) { + return null; + } + const evidence = parsedReconciliationEvidence(reconciliation); + if (evidence.kind !== 'execution_attested' + || evidence.attestation?.outcome !== execution.state + || evidence.attestationHash !== sha256(canonicalJson(evidence.attestation))) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'resolved execution lost its exact attestation projection', + ); + } + const event = reconciliationReplayEvent(db, reconciliation); + if (event.operatorIdHash !== operatorIdHash) { + reconciliationConflict('execution replay operator differs from committed authority'); + } + if (expectedCaseHash !== event.requestCaseHash) { + reconciliationConflict('execution replay case hash differs from committed authority'); + } + let domain; + if (execution.state === 'succeeded') { + if (authority.outcome.status !== 'completed' + || authority.outcome.reason_code !== 'EXECUTION_RECONCILED_SUCCEEDED' + || authority.budget.state !== 'committed' + || resolution.state !== 'resolved' + || number(resolution.blocks_wallet, 'execution replay blocker') !== 0) { + return null; + } + domain = frozenCopy({ + status: 'completed', + reasonCode: 'EXECUTION_RECONCILED_SUCCEEDED', + }); + } else { + const refunds = refundHistory(db, intentId); + if (authority.outcome.status !== 'execution_failed' + || authority.outcome.reason_code !== 'REFUND_UNRESOLVED' + || authority.budget.state !== 'committed' + || resolution.state !== 'refund_pending' + || number(resolution.blocks_wallet, 'execution replay blocker') !== 1) { + return null; + } + domain = frozenCopy({ + status: 'execution_failed', + reasonCode: 'REFUND_UNRESOLVED', + refundCaseHash: refundCaseHash(authority, execution, resolution, refunds), + }); + } + const binding = persistedExecutionBinding({ + authority, + execution, + resolution, + caseHash: event.observedCaseHash, + }); + return Object.freeze({ + binding, + domain, + event, + reconciliationEvidenceJson: reconciliation.evidence_json, + reconciliationId: reconciliation.id, + }); +} + +function loadRefundReplay(db, { + intentId, + operatorIdHash, + expectedIntentHash, + refundTransactionId, + expectedCaseHash, +}) { + const authority = selectedReconciliationAuthority(db, intentId); + assertExpectedReconciliationIntentHash(authority, expectedIntentHash); + const execution = db.prepare( + 'SELECT * FROM execution_outcomes WHERE intent_id = ?', + ).get(intentId); + const resolution = db.prepare( + 'SELECT * FROM execution_resolutions WHERE intent_id = ?', + ).get(intentId); + const refunds = refundHistory(db, intentId); + const refund = refunds.find((row) => row.refund_transaction_id === refundTransactionId) ?? null; + if (!refund || !new Set(['confirmed', 'rejected']).has(refund.state) + || refund.evidence_json === null) { + return null; + } + const expectedOutcome = refund.state === 'confirmed' + ? 'refund_confirmed' + : 'refund_rejected'; + const reconciliations = db.prepare( + 'SELECT * FROM reconciliations WHERE intent_id = ? ORDER BY rowid', + ).all(intentId); + const reconciliation = reconciliations.findLast((row) => ( + row.kind === 'refund' + && row.outcome === expectedOutcome + && row.evidence_json === refund.evidence_json + )) ?? null; + if (!reconciliation || reconciliations.at(-1)?.id !== reconciliation.id) { + return null; + } + const evidence = parsedReconciliationEvidence(reconciliation); + const expectedObservationKind = refund.state === 'confirmed' + ? 'refund_attested_and_confirmed' + : 'refund_candidate_rejected'; + if (evidence.kind !== expectedObservationKind + || evidence.refundTransactionId !== refundTransactionId) { + throw new KernelError( + 'RECONCILIATION_CORRUPTION', + 'resolved refund lost its exact evidence projection', + ); + } + const event = reconciliationReplayEvent(db, reconciliation); + if (event.operatorIdHash !== operatorIdHash) { + reconciliationConflict('refund replay operator differs from committed authority'); + } + let domain; + let currentCaseHash = null; + if (refund.state === 'confirmed') { + if (authority.outcome.status !== 'refunded' + || authority.outcome.reason_code !== 'REFUND_CONFIRMED' + || authority.budget.state !== 'released' + || execution?.state !== 'failed' + || resolution?.state !== 'resolved' + || number(resolution.blocks_wallet, 'refund replay blocker') !== 0) { + return null; + } + domain = frozenCopy({ status: 'refunded', reasonCode: 'REFUND_CONFIRMED' }); + } else { + if (authority.outcome.status !== 'execution_failed' + || authority.outcome.reason_code !== 'REFUND_UNRESOLVED' + || authority.budget.state !== 'committed' + || execution?.state !== 'failed' + || resolution?.state !== 'refund_pending' + || number(resolution.blocks_wallet, 'refund replay blocker') !== 1) { + return null; + } + currentCaseHash = refundCaseHash(authority, execution, resolution, refunds); + domain = frozenCopy({ + status: 'execution_failed', + reasonCode: 'REFUND_UNRESOLVED', + refundCaseHash: currentCaseHash, + }); + } + if (expectedCaseHash !== event.requestCaseHash + && expectedCaseHash !== currentCaseHash) { + reconciliationConflict('refund replay case hash differs from committed authority'); + } + const binding = persistedRefundBinding({ + authority, + execution, + resolution, + refunds: Object.freeze(refunds), + caseHash: event.observedCaseHash, + }, refund); + return Object.freeze({ + binding, + domain, + event, + expectedObservationKind, + reconciliationEvidenceJson: reconciliation.evidence_json, + reconciliationId: reconciliation.id, + }); +} + +function terminalizeIntent({ db, appendEvent }, intentId, expectedState, reasonCode, updatedAt) { + const changed = db.prepare(`UPDATE spend_intents + SET state = 'terminal', retry_matchable = 0, updated_at = ? + WHERE id = ? AND state = ? AND retry_matchable = 1`).run( + updatedAt, + intentId, + expectedState, + ); + if (changed.changes !== 1n) reconciliationConflict('Spend Intent resolution lost its race'); + appendEvent({ + entityType: 'spend_intent', + entityId: intentId, + eventType: 'intent.transitioned', + data: { + previousState: expectedState, + nextState: 'terminal', + reasonCode, + retryMatchable: false, + updatedAt, + }, + }); +} + +function updateBuyerOutcome({ db, appendEvent }, { + intentId, + expectedStatus, + status, + reasonCode, + recordedAt, +}) { + const current = db.prepare('SELECT * FROM buyer_outcomes WHERE intent_id = ?').get(intentId); + if (!current || current.status !== expectedStatus) { + throw new KernelError('RECONCILIATION_CORRUPTION', 'BuyerOutcome predecessor changed'); + } + const revision = number(current.revision, 'BuyerOutcome revision') + 1; + const changed = db.prepare(`UPDATE buyer_outcomes + SET status = ?, reason_code = ?, revision = ?, recorded_at = ? + WHERE intent_id = ? AND revision = ?`).run( + status, + reasonCode, + revision, + recordedAt, + intentId, + current.revision, + ); + if (changed.changes !== 1n) reconciliationConflict('BuyerOutcome update lost its race'); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intentId, + eventType: 'buyer_outcome.revised', + data: { status, reasonCode, revision, recordedAt }, + }); + return revision; +} + +function issueReconciliationReceipt(receipts, markAuthorityUnhealthy, intentId, predecessorHash) { + try { + return receipts.issueRevisionForTerminal({ + intentId, + supersedesReceiptHash: predecessorHash, + }); + } catch (cause) { + try { + markAuthorityUnhealthy('RECEIPT_PARITY_REQUIRED'); + } catch (markCause) { + throw new KernelError( + 'RECEIPT_PARITY_REQUIRED', + 'receipt parity failed and the authority fail-stop hook also failed', + { cause: markCause }, + ); + } + throw cause; + } +} + +export function createReconciler(value) { + if (arguments.length !== 1) { + throw new TypeError('createReconciler requires exactly one dependency object'); + } + const requiredDependencies = [ + 'store', + 'budgets', + 'receipts', + 'resolver', + 'now', + 'idFactory', + 'authorityMutationCoordinator', + 'markAuthorityUnhealthy', + ]; + const hasMinimumConfirmations = Boolean( + value && typeof value === 'object' && Object.hasOwn(value, 'minimumConfirmations'), + ); + const dependencies = assertPlainDependencies( + value, + hasMinimumConfirmations + ? [...requiredDependencies, 'minimumConfirmations'] + : requiredDependencies, + 'reconciler dependencies', + ); + const { + store, + budgets, + receipts, + resolver, + now, + idFactory, + authorityMutationCoordinator, + markAuthorityUnhealthy, + } = dependencies; + const minimumConfirmations = Object.hasOwn(dependencies, 'minimumConfirmations') + ? dependencies.minimumConfirmations + : 2; + if (!Number.isSafeInteger(minimumConfirmations) + || minimumConfirmations < 1 + || minimumConfirmations > 1_000) { + throw new TypeError('reconciler minimumConfirmations must be an integer from 1 through 1000'); + } + requireMethods(store, ['transaction', 'within'], 'reconciler store'); + requireMethods(budgets, [ + 'resolvePaymentInTransaction', 'recordConfirmedRefundInTransaction', + ], 'reconciler budget ledger'); + requireMethods(receipts, [ + 'assertParityInTransaction', 'issueRevisionForTerminal', 'latest', + ], 'reconciler receipt repository'); + assertPlainDependencies( + resolver, + ['observePayment', 'observeExecution', 'observeRefund'], + 'trusted resolver', + ); + requireMethods(resolver, ['observePayment', 'observeExecution', 'observeRefund'], 'trusted resolver'); + requireMethods(authorityMutationCoordinator, ['runExclusive'], 'authority coordinator'); + for (const [label, dependency] of [ + ['clock', now], + ['ID factory', idFactory], + ['authority fail-stop hook', markAuthorityUnhealthy], + ]) { + if (typeof dependency !== 'function' || utilTypes.isProxy(dependency)) { + throw new TypeError(`reconciler ${label} must be an ordinary function`); + } + } + + const reconcilePayment = async (input) => { + const request = reconciliationInput(input, [ + 'intentId', 'operatorIdHash', 'expectedIntentHash', 'expectedPaymentCaseHash', + ], ['paymentTransactionId'], 'payment reconciliation request'); + canonicalReconciliationToken(request.intentId, 'payment intent ID'); + canonicalReconciliationHash(request.operatorIdHash, 'payment operator hash'); + canonicalReconciliationHash(request.expectedIntentHash, 'expected intent hash'); + canonicalReconciliationHash(request.expectedPaymentCaseHash, 'expected payment case hash'); + const transactionId = Object.hasOwn(request, 'paymentTransactionId') + ? request.paymentTransactionId + : null; + if (transactionId !== null) { + canonicalReconciliationTransaction(transactionId, 'payment transaction ID'); + } + const intentId = request.intentId; + const operatorIdHash = request.operatorIdHash; + const expectedIntentHash = request.expectedIntentHash; + const expectedCaseHash = request.expectedPaymentCaseHash; + + const prepared = await authorityMutationCoordinator.runExclusive(() => ( + store.transaction((token) => { + receipts.assertParityInTransaction(token); + return store.within(token, ({ db, appendEvent }) => { + const replay = loadPaymentReplay(db, { + intentId, + operatorIdHash, + expectedIntentHash, + transactionId, + expectedCaseHash, + }); + if (replay) return Object.freeze({ binding: replay.binding, replay }); + let paymentCase = loadPaymentCase(db, intentId); + if (paymentCase.caseHash !== expectedCaseHash) { + reconciliationConflict('displayed payment case hash is stale'); + } + let candidate = null; + if (transactionId !== null) { + candidate = paymentCase.candidates.find((row) => row.state === 'pending') ?? null; + if (candidate !== null && candidate.transaction_id !== transactionId) { + reconciliationConflict('a different payment candidate is already pending'); + } + if (candidate === null) { + assertUnusedTransaction(db, transactionId); + const candidateId = nextReconciliationId( + db, + idFactory, + 'payment_candidate', + 'payment_reconciliation_candidates', + ); + const createdAt = reconciliationClockTimestamp( + now, + 'payment candidate createdAt', + [ + paymentCase.authority.intent.updated_at, + paymentCase.authority.attempt.updated_at, + paymentCase.authority.budget.updated_at, + paymentCase.authority.outcome.recorded_at, + ], + ); + db.prepare(`INSERT INTO payment_reconciliation_candidates + (id, intent_id, transaction_id, state, evidence_json, created_at, updated_at) + VALUES (?, ?, ?, 'pending', NULL, ?, ?)`).run( + candidateId, + intentId, + transactionId, + createdAt, + createdAt, + ); + appendEvent({ + entityType: 'payment_reconciliation_candidate', + entityId: candidateId, + eventType: 'payment.candidate_persisted', + data: { + intentId, + transactionId, + operatorIdHash, + previousCaseHash: paymentCase.caseHash, + createdAt, + }, + }); + paymentCase = loadPaymentCase(db, intentId); + candidate = paymentCase.candidates.find((row) => row.id === candidateId); + } + } + return Object.freeze({ + binding: persistedPaymentBinding(paymentCase, candidate), + replay: null, + }); + }); + }) + )); + const persistedBinding = prepared.binding; + + const resolverStartedAt = reconciliationClockTimestamp(now, 'payment resolver startedAt'); + const resolverValue = await resolver.observePayment(persistedBinding); + const resolverResolvedAt = reconciliationClockTimestamp( + now, + 'payment resolver resolvedAt', + [resolverStartedAt], + ); + const evidenceValidationAt = prepared.replay?.event.recordedAt ?? resolverResolvedAt; + const observation = normalizePaymentObservation( + resolverValue, + persistedBinding, + minimumConfirmations, + evidenceValidationAt, + ); + + return await authorityMutationCoordinator.runExclusive(() => { + if (prepared.replay !== null) { + const domain = store.transaction((token) => { + receipts.assertParityInTransaction(token); + return store.within(token, ({ db }) => { + const replay = loadPaymentReplay(db, { + intentId, + operatorIdHash, + expectedIntentHash, + transactionId, + expectedCaseHash, + }); + if (!replay + || replay.reconciliationId !== prepared.replay.reconciliationId + || replay.event.observedCaseHash !== prepared.replay.event.observedCaseHash) { + reconciliationConflict('payment replay authority changed while evidence was observed'); + } + if (observation.kind !== replay.expectedObservationKind + || canonicalJson(observation.evidence) + !== replay.reconciliationEvidenceJson) { + reconciliationConflict('payment replay evidence differs from committed authority'); + } + return replay.domain; + }); + }); + return frozenCopy({ ...domain, receipt: receipts.latest(intentId) }); + } + let domain; + let predecessorHash = null; + store.transaction((token) => { + receipts.assertParityInTransaction(token); + store.within(token, ({ db, appendEvent }) => { + const current = loadPaymentCase(db, intentId); + if (current.caseHash !== persistedBinding.caseHash + || localAttemptBindingHash(current.authority) !== persistedBinding.localAttemptHash) { + reconciliationConflict('payment authority changed while evidence was observed'); + } + const candidate = persistedBinding.candidate === null + ? null + : current.candidates.find((row) => row.id === persistedBinding.candidate.id); + if (persistedBinding.candidate !== null + && (!candidate || candidate.state !== 'pending' + || candidate.transaction_id !== persistedBinding.candidate.transactionId)) { + reconciliationConflict('payment candidate changed while evidence was observed'); + } + predecessorHash = current.authority.predecessorReceiptHash; + if (observation.kind === 'unknown') { + domain = frozenCopy({ + status: 'payment_unresolved', + reasonCode: current.authority.outcome.reason_code, + paymentCaseHash: current.caseHash, + receipt: null, + }); + return; + } + const recordedAt = reconciliationClockTimestamp( + now, + 'payment reconciliation recordedAt', + [ + resolverResolvedAt, + current.authority.intent.updated_at, + current.authority.attempt.updated_at, + current.authority.budget.updated_at, + current.authority.outcome.recorded_at, + candidate?.updated_at, + ], + ); + if (observation.kind === 'payment_candidate_rejected') { + const evidenceJson = canonicalJson(observation.evidence); + const changed = db.prepare(`UPDATE payment_reconciliation_candidates + SET state = 'rejected', evidence_json = ?, updated_at = ? + WHERE id = ? AND intent_id = ? AND state = 'pending' + AND transaction_id = ? AND evidence_json IS NULL`).run( + evidenceJson, + recordedAt, + candidate.id, + intentId, + candidate.transaction_id, + ); + if (changed.changes !== 1n) reconciliationConflict('payment candidate rejection lost its race'); + const attemptReason = db.prepare(`UPDATE payment_attempts + SET reason_code = 'PAYMENT_CANDIDATE_REJECTED' + WHERE intent_id = ? AND state = 'unresolved'`).run(intentId); + if (attemptReason.changes !== 1n) { + reconciliationConflict('payment candidate rejection lost its attempt binding'); + } + insertReconciliationRow({ db, appendEvent }, idFactory, { + intentId, + kind: 'payment', + outcome: 'unresolved', + evidence: observation.evidence, + operatorIdHash, + recordedAt, + requestCaseHash: expectedCaseHash, + observedCaseHash: current.caseHash, + }); + updateBuyerOutcome({ db, appendEvent }, { + intentId, + expectedStatus: 'payment_unresolved', + status: 'payment_unresolved', + reasonCode: 'PAYMENT_CANDIDATE_REJECTED', + recordedAt, + }); + appendEvent({ + entityType: 'payment_reconciliation_candidate', + entityId: candidate.id, + eventType: 'payment.candidate_rejected', + data: { + intentId, + transactionId: candidate.transaction_id, + evidenceHash: sha256(evidenceJson), + operatorIdHash, + rejectedAt: recordedAt, + }, + }); + const updatedAuthority = Object.freeze({ + ...current.authority, + attempt: db.prepare( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', + ).get(intentId), + outcome: db.prepare( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ?', + ).get(intentId), + }); + const updatedCandidates = paymentHistory(db, intentId); + domain = frozenCopy({ + status: 'payment_unresolved', + reasonCode: 'PAYMENT_CANDIDATE_REJECTED', + paymentCaseHash: paymentCaseHash(updatedAuthority, updatedCandidates), + }); + return; + } + const outcome = observation.kind === 'settled_transfer' ? 'settled' : 'rejected'; + const evidenceId = insertReconciliationRow({ db, appendEvent }, idFactory, { + intentId, + kind: 'payment', + outcome, + evidence: observation.evidence, + operatorIdHash, + recordedAt, + requestCaseHash: expectedCaseHash, + observedCaseHash: current.caseHash, + }); + budgets.resolvePaymentInTransaction(token, { intentId, outcome, evidenceId }); + if (outcome === 'settled') { + const confirmedCandidate = db.prepare( + 'SELECT * FROM payment_reconciliation_candidates WHERE id = ?', + ).get(candidate.id); + appendEvent({ + entityType: 'payment_reconciliation_candidate', + entityId: candidate.id, + eventType: 'payment.candidate_confirmed', + data: { + intentId, + transactionId: confirmedCandidate.transaction_id, + evidenceHash: sha256(confirmedCandidate.evidence_json), + operatorIdHash, + confirmedAt: confirmedCandidate.updated_at, + }, + }); + } else { + for (const pendingCandidate of current.candidates.filter( + (row) => row.state === 'pending', + )) { + const rejectedCandidate = db.prepare( + 'SELECT * FROM payment_reconciliation_candidates WHERE id = ?', + ).get(pendingCandidate.id); + if (rejectedCandidate?.state !== 'rejected' + || rejectedCandidate.evidence_json !== canonicalJson(observation.evidence)) { + reconciliationConflict('unused authorization candidate projection changed'); + } + appendEvent({ + entityType: 'payment_reconciliation_candidate', + entityId: rejectedCandidate.id, + eventType: 'payment.candidate_rejected', + data: { + intentId, + transactionId: rejectedCandidate.transaction_id, + evidenceHash: sha256(rejectedCandidate.evidence_json), + operatorIdHash, + rejectedAt: rejectedCandidate.updated_at, + }, + }); + } + } + const resolvedOutcome = db.prepare( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ?', + ).get(intentId); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intentId, + eventType: 'buyer_outcome.revised', + data: { + status: resolvedOutcome.status, + reasonCode: resolvedOutcome.reason_code, + revision: number(resolvedOutcome.revision, 'resolved BuyerOutcome revision'), + recordedAt: resolvedOutcome.recorded_at, + }, + }); + const reasonCode = outcome === 'settled' + ? 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN' + : 'AUTHORIZATION_UNUSED_AFTER_EXPIRY'; + terminalizeIntent( + { db, appendEvent }, + intentId, + 'unresolved', + reasonCode, + recordedAt, + ); + if (outcome === 'settled') { + const execution = db.prepare( + 'SELECT * FROM execution_outcomes WHERE intent_id = ?', + ).get(intentId); + const resolution = db.prepare( + 'SELECT * FROM execution_resolutions WHERE intent_id = ?', + ).get(intentId); + appendEvent({ + entityType: 'execution_outcome', + entityId: intentId, + eventType: 'execution.recorded', + data: { + state: execution.state, + httpStatus: execution.http_status, + responseHash: execution.response_hash, + metadataHash: sha256(execution.metadata_json), + reasonCode: resolution.reason_code, + recordedAt: execution.recorded_at, + }, + }); + appendEvent({ + entityType: 'execution_resolution', + entityId: intentId, + eventType: 'execution_resolution.opened', + data: { + intentId, + state: resolution.state, + reasonCode: resolution.reason_code, + blocksWallet: number( + resolution.blocks_wallet, + 'payment execution resolution blocker', + ) === 1, + openedAt: resolution.opened_at, + }, + }); + const updatedAuthority = Object.freeze({ + ...current.authority, + intent: db.prepare( + 'SELECT * FROM spend_intents WHERE id = ?', + ).get(intentId), + attempt: db.prepare( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', + ).get(intentId), + budget: db.prepare( + 'SELECT * FROM budget_reservations WHERE intent_id = ?', + ).get(intentId), + outcome: resolvedOutcome, + }); + domain = frozenCopy({ + status: 'execution_unknown', + reasonCode, + executionCaseHash: executionCaseHash(updatedAuthority, execution, resolution), + }); + } else { + domain = frozenCopy({ status: 'payment_rejected', reasonCode }); + } + }); + }); + if (observation.kind === 'unknown') return domain; + const receipt = issueReconciliationReceipt( + receipts, + markAuthorityUnhealthy, + intentId, + predecessorHash, + ); + return frozenCopy({ ...domain, receipt }); + }); + }; + + const reconcileExecution = async (input) => { + const request = reconciliationInput(input, [ + 'intentId', 'operatorIdHash', 'expectedIntentHash', 'expectedExecutionCaseHash', + ], [], 'execution reconciliation request'); + canonicalReconciliationToken(request.intentId, 'execution intent ID'); + canonicalReconciliationHash(request.operatorIdHash, 'execution operator hash'); + canonicalReconciliationHash(request.expectedIntentHash, 'expected intent hash'); + canonicalReconciliationHash(request.expectedExecutionCaseHash, 'expected execution case hash'); + const intentId = request.intentId; + const operatorIdHash = request.operatorIdHash; + const expectedIntentHash = request.expectedIntentHash; + const prepared = await authorityMutationCoordinator.runExclusive(() => ( + store.transaction((token) => { + receipts.assertParityInTransaction(token); + return store.within(token, ({ db }) => { + const replay = loadExecutionReplay(db, { + intentId, + operatorIdHash, + expectedIntentHash, + expectedCaseHash: request.expectedExecutionCaseHash, + }); + if (replay) return Object.freeze({ binding: replay.binding, replay }); + const executionCase = loadExecutionCase(db, intentId); + if (executionCase.caseHash !== request.expectedExecutionCaseHash) { + reconciliationConflict('displayed execution case hash is stale'); + } + return Object.freeze({ + binding: persistedExecutionBinding(executionCase), + replay: null, + }); + }); + }) + )); + const persistedBinding = prepared.binding; + const resolverStartedAt = reconciliationClockTimestamp(now, 'execution resolver startedAt'); + const resolverValue = await resolver.observeExecution(persistedBinding); + const resolverResolvedAt = reconciliationClockTimestamp( + now, + 'execution resolver resolvedAt', + [resolverStartedAt], + ); + const evidenceValidationAt = prepared.replay?.event.recordedAt ?? resolverResolvedAt; + const observation = normalizeExecutionObservation( + resolverValue, + persistedBinding, + evidenceValidationAt, + ); + + return await authorityMutationCoordinator.runExclusive(() => { + if (prepared.replay !== null) { + const domain = store.transaction((token) => { + receipts.assertParityInTransaction(token); + return store.within(token, ({ db }) => { + const replay = loadExecutionReplay(db, { + intentId, + operatorIdHash, + expectedIntentHash, + expectedCaseHash: request.expectedExecutionCaseHash, + }); + if (!replay + || replay.reconciliationId !== prepared.replay.reconciliationId + || replay.event.observedCaseHash !== prepared.replay.event.observedCaseHash) { + reconciliationConflict('execution replay authority changed while evidence was observed'); + } + const evidenceJson = canonicalJson({ + kind: 'execution_attested', + attestationHash: observation.attestationHash, + attestation: observation.attestation, + }); + if (observation.kind !== 'execution_attested' + || evidenceJson !== replay.reconciliationEvidenceJson) { + reconciliationConflict('execution replay evidence differs from committed authority'); + } + return replay.domain; + }); + }); + return frozenCopy({ ...domain, receipt: receipts.latest(intentId) }); + } + let domain; + let predecessorHash = null; + store.transaction((token) => { + receipts.assertParityInTransaction(token); + store.within(token, ({ db, appendEvent }) => { + const current = loadExecutionCase(db, intentId); + if (current.caseHash !== persistedBinding.caseHash) { + reconciliationConflict('execution authority changed while evidence was observed'); + } + predecessorHash = current.authority.predecessorReceiptHash; + if (observation.kind === 'unknown') { + domain = frozenCopy({ + status: 'execution_unknown', + reasonCode: current.authority.outcome.reason_code, + executionCaseHash: current.caseHash, + receipt: null, + }); + return; + } + const recordedAt = reconciliationClockTimestamp( + now, + 'execution reconciliation recordedAt', + [ + resolverResolvedAt, + current.authority.intent.updated_at, + current.authority.attempt.updated_at, + current.authority.budget.updated_at, + current.authority.outcome.recorded_at, + current.execution.recorded_at, + current.resolution.opened_at, + ], + ); + const attestation = observation.attestation; + const succeeded = attestation.outcome === 'succeeded'; + const reasonCode = succeeded + ? 'EXECUTION_RECONCILED_SUCCEEDED' + : 'REFUND_UNRESOLVED'; + insertReconciliationRow({ db, appendEvent }, idFactory, { + intentId, + kind: 'execution', + outcome: succeeded ? 'execution_succeeded' : 'execution_failed', + evidence: { + kind: 'execution_attested', + attestationHash: observation.attestationHash, + attestation, + }, + operatorIdHash, + recordedAt, + requestCaseHash: request.expectedExecutionCaseHash, + observedCaseHash: current.caseHash, + }); + const executionMetadataJson = canonicalJson({ + attestationHash: observation.attestationHash, + reasonCode, + }); + const executionUpdate = db.prepare(`UPDATE execution_outcomes + SET state = ?, http_status = ?, response_hash = ?, metadata_json = ?, recorded_at = ? + WHERE intent_id = ? AND state = 'unknown'`).run( + attestation.outcome, + attestation.httpStatus, + attestation.responseHash, + executionMetadataJson, + recordedAt, + intentId, + ); + if (executionUpdate.changes !== 1n) reconciliationConflict('execution resolution lost its race'); + if (succeeded) { + const resolutionUpdate = db.prepare(`UPDATE execution_resolutions + SET state = 'resolved', reason_code = ?, blocks_wallet = 0, resolved_at = ? + WHERE intent_id = ? AND state = 'reconciliation_required' + AND blocks_wallet = 1 AND resolved_at IS NULL`).run( + reasonCode, + recordedAt, + intentId, + ); + if (resolutionUpdate.changes !== 1n) reconciliationConflict('execution case resolution lost its race'); + appendEvent({ + entityType: 'execution_resolution', + entityId: intentId, + eventType: 'execution_resolution.resolved', + data: { + intentId, + state: 'resolved', + reasonCode, + blocksWallet: false, + resolvedAt: recordedAt, + }, + }); + } else { + const resolutionUpdate = db.prepare(`UPDATE execution_resolutions + SET state = 'refund_pending', reason_code = ?, opened_at = ? + WHERE intent_id = ? AND state = 'reconciliation_required' + AND blocks_wallet = 1 AND resolved_at IS NULL`).run( + reasonCode, + recordedAt, + intentId, + ); + if (resolutionUpdate.changes !== 1n) reconciliationConflict('refund case opening lost its race'); + if (db.prepare('SELECT id FROM refunds WHERE intent_id = ?').get(intentId)) { + throw new KernelError('RECONCILIATION_CORRUPTION', 'execution case already owns refund history'); + } + const refundId = nextReconciliationId(db, idFactory, 'refund', 'refunds'); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + evidence_json, refund_transaction_id, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', NULL, NULL, ?, ?)`).run( + refundId, + intentId, + current.authority.attempt.transaction_id, + current.authority.decision.amount_ceiling_atomic, + recordedAt, + recordedAt, + ); + appendEvent({ + entityType: 'execution_resolution', + entityId: intentId, + eventType: 'execution_resolution.opened', + data: { + intentId, + state: 'refund_pending', + reasonCode, + blocksWallet: true, + openedAt: recordedAt, + }, + }); + appendEvent({ + entityType: 'refund', + entityId: refundId, + eventType: 'refund.opened', + data: { + refundId, + intentId, + originalTransactionId: current.authority.attempt.transaction_id, + amountAtomic: current.authority.decision.amount_ceiling_atomic, + state: 'pending', + createdAt: recordedAt, + }, + }); + } + updateBuyerOutcome({ db, appendEvent }, { + intentId, + expectedStatus: 'execution_unknown', + status: succeeded ? 'completed' : 'execution_failed', + reasonCode, + recordedAt, + }); + appendEvent({ + entityType: 'execution_outcome', + entityId: intentId, + eventType: 'execution.reconciled', + data: { + state: attestation.outcome, + httpStatus: attestation.httpStatus, + responseHash: attestation.responseHash, + metadataHash: sha256(executionMetadataJson), + attestationHash: observation.attestationHash, + reasonCode, + recordedAt, + }, + }); + if (succeeded) { + domain = frozenCopy({ status: 'completed', reasonCode }); + } else { + const updatedExecution = db.prepare( + 'SELECT * FROM execution_outcomes WHERE intent_id = ?', + ).get(intentId); + const updatedResolution = db.prepare( + 'SELECT * FROM execution_resolutions WHERE intent_id = ?', + ).get(intentId); + const updatedRefunds = refundHistory(db, intentId); + const updatedAuthority = Object.freeze({ + ...current.authority, + intent: db.prepare( + 'SELECT * FROM spend_intents WHERE id = ?', + ).get(intentId), + attempt: db.prepare( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', + ).get(intentId), + budget: db.prepare( + 'SELECT * FROM budget_reservations WHERE intent_id = ?', + ).get(intentId), + outcome: db.prepare( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ?', + ).get(intentId), + }); + domain = frozenCopy({ + status: 'execution_failed', + reasonCode, + refundCaseHash: refundCaseHash( + updatedAuthority, + updatedExecution, + updatedResolution, + updatedRefunds, + ), + }); + } + }); + }); + if (observation.kind === 'unknown') return domain; + const receipt = issueReconciliationReceipt( + receipts, + markAuthorityUnhealthy, + intentId, + predecessorHash, + ); + return frozenCopy({ ...domain, receipt }); + }); + }; + + const observeRefund = async (input) => { + const request = reconciliationInput(input, [ + 'intentId', 'operatorIdHash', 'expectedIntentHash', 'refundTransactionId', + 'expectedRefundCaseHash', + ], [], 'refund observation request'); + canonicalReconciliationToken(request.intentId, 'refund intent ID'); + canonicalReconciliationHash(request.operatorIdHash, 'refund operator hash'); + canonicalReconciliationHash(request.expectedIntentHash, 'expected intent hash'); + canonicalReconciliationTransaction(request.refundTransactionId, 'refund transaction ID'); + canonicalReconciliationHash(request.expectedRefundCaseHash, 'expected refund case hash'); + const intentId = request.intentId; + const operatorIdHash = request.operatorIdHash; + const expectedIntentHash = request.expectedIntentHash; + const refundTransactionId = request.refundTransactionId; + const prepared = await authorityMutationCoordinator.runExclusive(() => ( + store.transaction((token) => { + receipts.assertParityInTransaction(token); + return store.within(token, ({ db, appendEvent }) => { + const replay = loadRefundReplay(db, { + intentId, + operatorIdHash, + expectedIntentHash, + refundTransactionId, + expectedCaseHash: request.expectedRefundCaseHash, + }); + if (replay) return Object.freeze({ binding: replay.binding, replay }); + let refundCase = loadRefundCase(db, intentId); + if (refundCase.caseHash !== request.expectedRefundCaseHash) { + reconciliationConflict('displayed refund case hash is stale'); + } + let refund = refundCase.openRefund; + if (refund && refund.refund_transaction_id !== null + && refund.refund_transaction_id !== refundTransactionId) { + reconciliationConflict('a different refund candidate is already pending'); + } + if (!refund || refund.refund_transaction_id === null) { + assertUnusedTransaction(db, refundTransactionId); + const recordedAt = reconciliationClockTimestamp( + now, + 'refund candidate recordedAt', + [ + refundCase.authority.intent.updated_at, + refundCase.authority.attempt.updated_at, + refundCase.authority.budget.updated_at, + refundCase.authority.outcome.recorded_at, + refundCase.execution.recorded_at, + refundCase.resolution.opened_at, + refund?.updated_at, + ], + ); + if (!refund) { + const refundId = nextReconciliationId(db, idFactory, 'refund', 'refunds'); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + evidence_json, refund_transaction_id, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', NULL, ?, ?, ?)`).run( + refundId, + intentId, + refundCase.authority.attempt.transaction_id, + refundCase.authority.decision.amount_ceiling_atomic, + refundTransactionId, + recordedAt, + recordedAt, + ); + refund = db.prepare('SELECT * FROM refunds WHERE id = ?').get(refundId); + appendEvent({ + entityType: 'refund', + entityId: refundId, + eventType: 'refund.opened', + data: { + refundId, + intentId, + originalTransactionId: refundCase.authority.attempt.transaction_id, + amountAtomic: refundCase.authority.decision.amount_ceiling_atomic, + state: 'pending', + createdAt: recordedAt, + }, + }); + } else { + const changed = db.prepare(`UPDATE refunds + SET refund_transaction_id = ?, updated_at = ? + WHERE id = ? AND intent_id = ? AND state IN ('pending','unresolved') + AND refund_transaction_id IS NULL AND evidence_json IS NULL`).run( + refundTransactionId, + recordedAt, + refund.id, + intentId, + ); + if (changed.changes !== 1n) reconciliationConflict('refund candidate binding lost its race'); + refund = db.prepare('SELECT * FROM refunds WHERE id = ?').get(refund.id); + } + appendEvent({ + entityType: 'refund', + entityId: refund.id, + eventType: 'refund.candidate_persisted', + data: { + intentId, + originalTransactionId: refundCase.authority.attempt.transaction_id, + refundTransactionId, + operatorIdHash, + previousCaseHash: refundCase.caseHash, + recordedAt, + }, + }); + refundCase = loadRefundCase(db, intentId); + refund = refundCase.refunds.find((row) => row.id === refund.id); + } + return Object.freeze({ + binding: persistedRefundBinding(refundCase, refund), + replay: null, + }); + }); + }) + )); + const persistedBinding = prepared.binding; + const resolverStartedAt = reconciliationClockTimestamp(now, 'refund resolver startedAt'); + const resolverValue = await resolver.observeRefund(persistedBinding); + const resolverResolvedAt = reconciliationClockTimestamp( + now, + 'refund resolver resolvedAt', + [resolverStartedAt], + ); + const evidenceValidationAt = prepared.replay?.event.recordedAt ?? resolverResolvedAt; + const observation = normalizeRefundObservation( + resolverValue, + persistedBinding, + evidenceValidationAt, + minimumConfirmations, + ); + + return await authorityMutationCoordinator.runExclusive(() => { + if (prepared.replay !== null) { + const domain = store.transaction((token) => { + receipts.assertParityInTransaction(token); + return store.within(token, ({ db }) => { + const replay = loadRefundReplay(db, { + intentId, + operatorIdHash, + expectedIntentHash, + refundTransactionId, + expectedCaseHash: request.expectedRefundCaseHash, + }); + if (!replay + || replay.reconciliationId !== prepared.replay.reconciliationId + || replay.event.observedCaseHash !== prepared.replay.event.observedCaseHash) { + reconciliationConflict('refund replay authority changed while evidence was observed'); + } + if (observation.kind !== replay.expectedObservationKind + || canonicalJson(observation.evidence) + !== replay.reconciliationEvidenceJson) { + reconciliationConflict('refund replay evidence differs from committed authority'); + } + return replay.domain; + }); + }); + return frozenCopy({ ...domain, receipt: receipts.latest(intentId) }); + } + let domain; + let predecessorHash = null; + store.transaction((token) => { + receipts.assertParityInTransaction(token); + store.within(token, ({ db, appendEvent }) => { + const current = loadRefundCase(db, intentId); + const refund = current.refunds.find((row) => row.id === persistedBinding.refundId); + if (current.caseHash !== persistedBinding.caseHash + || !refund + || !new Set(['pending', 'unresolved']).has(refund.state) + || refund.refund_transaction_id !== refundTransactionId) { + reconciliationConflict('refund authority changed while evidence was observed'); + } + predecessorHash = current.authority.predecessorReceiptHash; + if (observation.kind === 'unknown') { + domain = frozenCopy({ + status: 'execution_failed', + reasonCode: current.authority.outcome.reason_code, + refundCaseHash: current.caseHash, + receipt: null, + }); + return; + } + const recordedAt = reconciliationClockTimestamp( + now, + 'refund reconciliation recordedAt', + [ + resolverResolvedAt, + current.authority.intent.updated_at, + current.authority.attempt.updated_at, + current.authority.budget.updated_at, + current.authority.outcome.recorded_at, + current.execution.recorded_at, + current.resolution.opened_at, + refund.updated_at, + ], + ); + if (observation.kind === 'refund_candidate_rejected') { + const evidenceJson = canonicalJson(observation.evidence); + const changed = db.prepare(`UPDATE refunds + SET state = 'rejected', evidence_json = ?, updated_at = ? + WHERE id = ? AND intent_id = ? AND state IN ('pending','unresolved') + AND refund_transaction_id = ? AND evidence_json IS NULL`).run( + evidenceJson, + recordedAt, + refund.id, + intentId, + refundTransactionId, + ); + if (changed.changes !== 1n) reconciliationConflict('refund candidate rejection lost its race'); + insertReconciliationRow({ db, appendEvent }, idFactory, { + intentId, + kind: 'refund', + outcome: 'refund_rejected', + evidence: observation.evidence, + operatorIdHash, + recordedAt, + requestCaseHash: request.expectedRefundCaseHash, + observedCaseHash: current.caseHash, + }); + updateBuyerOutcome({ db, appendEvent }, { + intentId, + expectedStatus: 'execution_failed', + status: 'execution_failed', + reasonCode: 'REFUND_UNRESOLVED', + recordedAt, + }); + appendEvent({ + entityType: 'refund', + entityId: refund.id, + eventType: 'refund.candidate_rejected', + data: { + intentId, + refundTransactionId, + evidenceHash: sha256(evidenceJson), + operatorIdHash, + rejectedAt: recordedAt, + }, + }); + const updatedExecution = db.prepare( + 'SELECT * FROM execution_outcomes WHERE intent_id = ?', + ).get(intentId); + const updatedResolution = db.prepare( + 'SELECT * FROM execution_resolutions WHERE intent_id = ?', + ).get(intentId); + const updatedRefunds = refundHistory(db, intentId); + const updatedAuthority = Object.freeze({ + ...current.authority, + intent: db.prepare( + 'SELECT * FROM spend_intents WHERE id = ?', + ).get(intentId), + attempt: db.prepare( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', + ).get(intentId), + budget: db.prepare( + 'SELECT * FROM budget_reservations WHERE intent_id = ?', + ).get(intentId), + outcome: db.prepare( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ?', + ).get(intentId), + }); + domain = frozenCopy({ + status: 'execution_failed', + reasonCode: 'REFUND_UNRESOLVED', + refundCaseHash: refundCaseHash( + updatedAuthority, + updatedExecution, + updatedResolution, + updatedRefunds, + ), + }); + return; + } + const evidenceId = insertReconciliationRow({ db, appendEvent }, idFactory, { + intentId, + kind: 'refund', + outcome: 'refund_confirmed', + evidence: observation.evidence, + operatorIdHash, + recordedAt, + requestCaseHash: request.expectedRefundCaseHash, + observedCaseHash: current.caseHash, + }); + budgets.recordConfirmedRefundInTransaction(token, { + intentId, + evidenceId, + refundTransactionId, + }); + const confirmedRefund = db.prepare( + 'SELECT * FROM refunds WHERE id = ?', + ).get(refund.id); + const resolvedCase = db.prepare( + 'SELECT * FROM execution_resolutions WHERE intent_id = ?', + ).get(intentId); + const resolvedOutcome = db.prepare( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ?', + ).get(intentId); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intentId, + eventType: 'buyer_outcome.revised', + data: { + status: resolvedOutcome.status, + reasonCode: resolvedOutcome.reason_code, + revision: number(resolvedOutcome.revision, 'refunded BuyerOutcome revision'), + recordedAt: resolvedOutcome.recorded_at, + }, + }); + appendEvent({ + entityType: 'refund', + entityId: refund.id, + eventType: 'refund.confirmed', + data: { + refundId: refund.id, + intentId, + originalTransactionId: confirmedRefund.original_transaction_id, + refundTransactionId: confirmedRefund.refund_transaction_id, + amountAtomic: confirmedRefund.amount_atomic, + evidenceId, + confirmedAt: confirmedRefund.updated_at, + }, + }); + appendEvent({ + entityType: 'execution_resolution', + entityId: intentId, + eventType: 'execution_resolution.resolved', + data: { + intentId, + state: resolvedCase.state, + reasonCode: resolvedCase.reason_code, + blocksWallet: number( + resolvedCase.blocks_wallet, + 'refund execution resolution blocker', + ) === 1, + resolvedAt: resolvedCase.resolved_at, + }, + }); + domain = frozenCopy({ status: 'refunded', reasonCode: 'REFUND_CONFIRMED' }); + }); + }); + if (observation.kind === 'unknown') return domain; + const receipt = issueReconciliationReceipt( + receipts, + markAuthorityUnhealthy, + intentId, + predecessorHash, + ); + return frozenCopy({ ...domain, receipt }); + }); + }; + + const abandonCandidate = async (input) => { + const request = reconciliationInput(input, [ + 'intentId', 'kind', 'operatorIdHash', 'expectedCaseHash', + ], [], 'candidate abandonment request'); + canonicalReconciliationToken(request.intentId, 'candidate intent ID'); + if (request.kind !== 'payment' && request.kind !== 'refund-observation') { + throw new KernelError( + 'RECONCILIATION_INPUT', + 'candidate kind must be payment or refund-observation', + ); + } + canonicalReconciliationHash(request.operatorIdHash, 'candidate operator hash'); + canonicalReconciliationHash(request.expectedCaseHash, 'expected candidate case hash'); + return await authorityMutationCoordinator.runExclusive(() => ( + store.transaction((token) => { + receipts.assertParityInTransaction(token); + return store.within(token, ({ db, appendEvent }) => { + if (request.kind === 'payment') { + const paymentCase = loadPaymentCase(db, request.intentId); + if (paymentCase.caseHash !== request.expectedCaseHash) { + reconciliationConflict('displayed payment case hash is stale'); + } + const candidate = paymentCase.candidates.find((row) => row.state === 'pending'); + if (!candidate) reconciliationConflict('no pending payment candidate can be abandoned'); + const abandonedAt = reconciliationClockTimestamp( + now, + 'payment candidate abandonedAt', + [ + candidate.created_at, + candidate.updated_at, + paymentCase.authority.attempt.updated_at, + paymentCase.authority.outcome.recorded_at, + ], + ); + const changed = db.prepare(`UPDATE payment_reconciliation_candidates + SET state = 'abandoned', updated_at = ? + WHERE id = ? AND intent_id = ? AND state = 'pending' + AND transaction_id = ? AND evidence_json IS NULL`).run( + abandonedAt, + candidate.id, + request.intentId, + candidate.transaction_id, + ); + if (changed.changes !== 1n) reconciliationConflict('payment abandonment lost its race'); + appendEvent({ + entityType: 'payment_reconciliation_candidate', + entityId: candidate.id, + eventType: 'payment.candidate_abandoned', + data: { + intentId: request.intentId, + transactionId: candidate.transaction_id, + operatorIdHash: request.operatorIdHash, + previousCaseHash: paymentCase.caseHash, + abandonedAt, + }, + }); + const nextCase = loadPaymentCase(db, request.intentId); + return frozenCopy({ + intentId: request.intentId, + kind: request.kind, + caseHash: nextCase.caseHash, + }); + } + const refundCase = loadRefundCase(db, request.intentId); + if (refundCase.caseHash !== request.expectedCaseHash) { + reconciliationConflict('displayed refund case hash is stale'); + } + const candidate = refundCase.openRefund; + if (!candidate || candidate.refund_transaction_id === null) { + reconciliationConflict('no named refund candidate can be abandoned'); + } + const abandonedAt = reconciliationClockTimestamp( + now, + 'refund candidate abandonedAt', + [ + candidate.created_at, + candidate.updated_at, + refundCase.execution.recorded_at, + refundCase.resolution.opened_at, + refundCase.authority.outcome.recorded_at, + ], + ); + const changed = db.prepare(`UPDATE refunds + SET state = 'abandoned', updated_at = ? + WHERE id = ? AND intent_id = ? AND state IN ('pending','unresolved') + AND refund_transaction_id = ? AND evidence_json IS NULL`).run( + abandonedAt, + candidate.id, + request.intentId, + candidate.refund_transaction_id, + ); + if (changed.changes !== 1n) reconciliationConflict('refund abandonment lost its race'); + appendEvent({ + entityType: 'refund', + entityId: candidate.id, + eventType: 'refund.candidate_abandoned', + data: { + intentId: request.intentId, + refundTransactionId: candidate.refund_transaction_id, + operatorIdHash: request.operatorIdHash, + previousCaseHash: refundCase.caseHash, + abandonedAt, + }, + }); + const nextCase = loadRefundCase(db, request.intentId); + return frozenCopy({ + intentId: request.intentId, + kind: request.kind, + caseHash: nextCase.caseHash, + }); + }); + }) + )); + }; + + return Object.freeze({ + reconcilePayment, + reconcileExecution, + observeRefund, + abandonCandidate, + }); +} diff --git a/spikes/pi-wielder/src/kernel/release-integrity.mjs b/spikes/pi-wielder/src/kernel/release-integrity.mjs new file mode 100644 index 0000000..42b6333 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/release-integrity.mjs @@ -0,0 +1,524 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + canonicalJson, + canonicalTimestamp, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './canonical.mjs'; + +const HASH = /^sha256:[0-9a-f]{64}$/; +const COMMIT = /^[0-9a-f]{40}$/; +const POSITIVE_DECIMAL = /^[1-9][0-9]*$/; +const NONNEGATIVE_DECIMAL = /^(0|[1-9][0-9]*)$/; +const OCTAL = /^(0|[1-7][0-7]{0,3})$/; +const RELEASE_ENTRY_KINDS = new Set(['directory', 'file', 'symlink']); +const SERVICE_ROLES = Object.freeze(['console-socket', 'kernel-service']); +const MANIFEST_FIELDS = Object.freeze([ + 'schemaVersion', 'commit', 'createdAt', 'entrypoint', 'packageLockHash', + 'releaseTreeHash', 'kernelIdentity', 'node', 'environment', 'serviceArtifacts', + 'systemd', 'entries', +]); +const LOADER_EXACT = new Set([ + 'NODE_OPTIONS', 'NODE_PATH', 'GCONV_PATH', 'GLIBC_TUNABLES', +]); +const BASE_ENVIRONMENT = new Set(['PATH', 'LANG', 'LC_ALL', 'TZ']); +const TREE_DOMAIN = 'wallet-kernel/release-tree/v1\0'; +const SERVICE_DOMAIN = 'wallet-kernel/service-artifacts/v1\0'; +const ENVIRONMENT_DOMAIN = 'wallet-kernel/environment-metadata/v1\0'; +const PATH_DOMAIN = 'wallet-kernel/absolute-path/v1\0'; + +function fail(code, message, cause) { + throw new KernelError(code, message, cause ? { cause } : undefined); +} + +function canonicalHash(value, label) { + if (typeof value !== 'string' || !HASH.test(value)) { + fail('RELEASE_MANIFEST_SCHEMA', `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalIdentity(value, label, { positive = true } = {}) { + const pattern = positive ? POSITIVE_DECIMAL : NONNEGATIVE_DECIMAL; + if (typeof value !== 'string' || !pattern.test(value)) { + fail('RELEASE_MANIFEST_SCHEMA', `${label} must be canonical ${positive ? 'positive' : 'nonnegative'} decimal text`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || String(parsed) !== value) { + fail('RELEASE_MANIFEST_SCHEMA', `${label} must round-trip through a safe integer`); + } + return value; +} + +function canonicalMode(value, label) { + if (typeof value !== 'string' || !OCTAL.test(value) + || Number.parseInt(value, 8) > 0o7777 + || Number.parseInt(value, 8).toString(8) !== value) { + fail('RELEASE_MANIFEST_SCHEMA', `${label} must be canonical octal text`); + } + return value; +} + +function assertAbsolute(value, label) { + if (typeof value !== 'string' || !path.isAbsolute(value) || path.resolve(value) !== value + || value.includes('\0')) { + fail('RELEASE_PATH', `${label} must be one canonical absolute path`); + } + return value; +} + +function assertNoMutableBits(stat, label, expectedOwnerUid) { + const mode = Number(stat.mode & 0o7777n); + if (Number(stat.uid) !== expectedOwnerUid || (mode & 0o022) !== 0) { + fail('RELEASE_OWNERSHIP', `${label} must have the expected owner and no group/other write bit`); + } + return mode; +} + +function metadata(stat, expectedOwnerUid, label) { + const mode = assertNoMutableBits(stat, label, expectedOwnerUid); + return Object.freeze({ + uid: String(Number(stat.uid)), + gid: String(Number(stat.gid)), + mode: mode.toString(8), + }); +} + +function readRegularFile(filePath, expectedOwnerUid, label) { + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + const before = fs.fstatSync(descriptor, { bigint: true }); + if (!before.isFile() || before.nlink !== 1n) { + fail('RELEASE_FILE', `${label} must be one regular file with link count one`); + } + const meta = metadata(before, expectedOwnerUid, label); + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor, { bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeNs !== after.mtimeNs || before.mode !== after.mode + || before.uid !== after.uid || before.gid !== after.gid) { + fail('RELEASE_RACE', `${label} changed while it was being hashed`); + } + return Object.freeze({ bytes, ...meta }); + } finally { + fs.closeSync(descriptor); + } +} + +function canonicalRelative(relativePath) { + if (typeof relativePath !== 'string' || relativePath === '' || path.isAbsolute(relativePath) + || relativePath.includes('\\') || relativePath.includes('\0')) { + fail('RELEASE_ENTRY', 'release entry path must be a canonical relative POSIX path'); + } + const parts = relativePath.split('/'); + if (parts.some((part) => part === '' || part === '.' || part === '..') + || parts.join('/') !== relativePath) { + fail('RELEASE_ENTRY', 'release entry path must be a canonical relative POSIX path'); + } + return relativePath; +} + +function inside(root, candidate) { + const relative = path.relative(root, candidate); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative)); +} + +function collectEntries({ releaseRoot, manifestPath, expectedOwnerUid }) { + const root = assertAbsolute(releaseRoot, 'release root'); + const rootStat = fs.lstatSync(root, { bigint: true }); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + fail('RELEASE_ROOT', 'release root must be one direct directory'); + } + assertNoMutableBits(rootStat, 'release root', expectedOwnerUid); + const excluded = manifestPath === undefined ? null : path.relative(root, manifestPath).split(path.sep).join('/'); + const entries = []; + + const walk = (directory, prefix = '') => { + const names = fs.readdirSync(directory); + names.sort((left, right) => Buffer.from(left).compare(Buffer.from(right))); + for (const name of names) { + if (name === '.' || name === '..' || name.includes('/') || name.includes('\0')) { + fail('RELEASE_ENTRY', 'release contains an invalid entry name'); + } + const relative = canonicalRelative(prefix ? `${prefix}/${name}` : name); + if (relative === excluded) continue; + const absolute = path.join(directory, name); + const stat = fs.lstatSync(absolute, { bigint: true }); + const meta = metadata(stat, expectedOwnerUid, `release entry ${relative}`); + if (stat.isDirectory()) { + entries.push({ path: relative, kind: 'directory', ...meta, bytes: null, sha256: null, target: null }); + walk(absolute, relative); + } else if (stat.isFile()) { + if (stat.nlink !== 1n) fail('RELEASE_HARDLINK', 'release regular files must have link count one'); + const file = readRegularFile(absolute, expectedOwnerUid, `release entry ${relative}`); + entries.push({ + path: relative, kind: 'file', uid: file.uid, gid: file.gid, mode: file.mode, + bytes: String(file.bytes.length), sha256: sha256(file.bytes), target: null, + }); + } else if (stat.isSymbolicLink()) { + const rawTarget = fs.readlinkSync(absolute); + const resolved = path.resolve(path.dirname(absolute), rawTarget); + if (!inside(root, resolved) || !fs.existsSync(resolved)) { + fail('RELEASE_SYMLINK', 'release symlink must resolve to an existing in-root target'); + } + const target = path.relative(root, resolved).split(path.sep).join('/'); + canonicalRelative(target); + entries.push({ path: relative, kind: 'symlink', ...meta, bytes: null, sha256: null, target }); + } else { + fail('RELEASE_ENTRY', 'release may contain only directories, files, and in-root symlinks'); + } + } + }; + walk(root); + entries.sort((left, right) => Buffer.from(left.path).compare(Buffer.from(right.path))); + return entries; +} + +function validateEntry(value) { + const entry = exactRecord(value, [ + 'path', 'kind', 'uid', 'gid', 'mode', 'bytes', 'sha256', 'target', + ], [], 'RELEASE_MANIFEST_SCHEMA', 'release entry'); + canonicalRelative(entry.path); + if (!RELEASE_ENTRY_KINDS.has(entry.kind)) fail('RELEASE_MANIFEST_SCHEMA', 'release entry kind is invalid'); + canonicalIdentity(entry.uid, 'release entry UID', { positive: false }); + canonicalIdentity(entry.gid, 'release entry GID', { positive: false }); + canonicalMode(entry.mode, 'release entry mode'); + if (entry.kind === 'file') { + canonicalIdentity(entry.bytes, 'release entry byte count', { positive: false }); + canonicalHash(entry.sha256, 'release entry content hash'); + if (entry.target !== null) fail('RELEASE_MANIFEST_SCHEMA', 'file target must be null'); + } else if (entry.kind === 'directory') { + if (entry.bytes !== null || entry.sha256 !== null || entry.target !== null) { + fail('RELEASE_MANIFEST_SCHEMA', 'directory content fields must be null'); + } + } else { + if (entry.bytes !== null || entry.sha256 !== null) { + fail('RELEASE_MANIFEST_SCHEMA', 'symlink content fields must be null'); + } + canonicalRelative(entry.target); + } + return Object.freeze(entry); +} + +function validateServiceArtifact(value) { + const artifact = exactRecord(value, [ + 'role', 'pathHash', 'sha256', 'uid', 'gid', 'mode', + ], [], 'RELEASE_MANIFEST_SCHEMA', 'service artifact'); + if (!SERVICE_ROLES.includes(artifact.role)) fail('RELEASE_MANIFEST_SCHEMA', 'service artifact role is invalid'); + canonicalHash(artifact.pathHash, 'service artifact path hash'); + canonicalHash(artifact.sha256, 'service artifact hash'); + canonicalIdentity(artifact.uid, 'service artifact UID', { positive: false }); + canonicalIdentity(artifact.gid, 'service artifact GID', { positive: false }); + canonicalMode(artifact.mode, 'service artifact mode'); + return Object.freeze(artifact); +} + +function validateSystemd(value) { + const systemd = exactRecord(value, [ + 'managerVersion', 'systemctlVersion', 'systemctlExecutablePathHash', + 'systemctlExecutableSha256', 'effectiveConfigHash', + ], [], 'RELEASE_MANIFEST_SCHEMA', 'systemd manifest'); + for (const field of ['managerVersion', 'systemctlVersion']) { + if (typeof systemd[field] !== 'string' || systemd[field].length < 1 + || systemd[field].length > 256 || /[\r\n\0]/.test(systemd[field])) { + fail('RELEASE_MANIFEST_SCHEMA', `${field} must be bounded canonical text`); + } + } + for (const field of [ + 'systemctlExecutablePathHash', 'systemctlExecutableSha256', 'effectiveConfigHash', + ]) canonicalHash(systemd[field], field); + return Object.freeze(systemd); +} + +export function computeServiceArtifactsHash(value) { + if (!Array.isArray(value) || value.length !== 2) { + fail('RELEASE_MANIFEST_SCHEMA', 'service artifacts must contain exactly two roles'); + } + const artifacts = value.map(validateServiceArtifact) + .sort((left, right) => left.role.localeCompare(right.role)); + if (artifacts.map(({ role }) => role).join(',') !== SERVICE_ROLES.join(',')) { + fail('RELEASE_MANIFEST_SCHEMA', 'service artifacts must contain the exact roles'); + } + return sha256(`${SERVICE_DOMAIN}${canonicalJson(artifacts)}`); +} + +export function validateReleaseManifest(value) { + const manifest = exactRecord(value, MANIFEST_FIELDS, [], + 'RELEASE_MANIFEST_SCHEMA', 'release manifest'); + if (manifest.schemaVersion !== 1) fail('RELEASE_MANIFEST_SCHEMA', 'release manifest schemaVersion must equal 1'); + if (typeof manifest.commit !== 'string' || !COMMIT.test(manifest.commit)) { + fail('RELEASE_MANIFEST_SCHEMA', 'release commit must be one full lowercase hash'); + } + canonicalTimestamp(manifest.createdAt, 'release createdAt'); + if (manifest.entrypoint !== 'src/control-plane.mjs') { + fail('RELEASE_MANIFEST_SCHEMA', 'release entrypoint is not the pinned control plane'); + } + canonicalHash(manifest.packageLockHash, 'package-lock hash'); + canonicalHash(manifest.releaseTreeHash, 'release tree hash'); + const kernelIdentity = exactRecord(manifest.kernelIdentity, ['uid', 'gid'], [], + 'RELEASE_MANIFEST_SCHEMA', 'Kernel identity'); + canonicalIdentity(kernelIdentity.uid, 'Kernel UID'); + canonicalIdentity(kernelIdentity.gid, 'Kernel GID'); + const node = exactRecord(manifest.node, [ + 'version', 'executablePathHash', 'executableSha256', 'uid', 'gid', 'mode', + ], [], 'RELEASE_MANIFEST_SCHEMA', 'Node runtime'); + if (node.version !== 'v24.18.1') fail('RELEASE_NODE_VERSION', 'Node runtime must equal v24.18.1'); + canonicalHash(node.executablePathHash, 'Node path hash'); + canonicalHash(node.executableSha256, 'Node executable hash'); + if (node.uid !== '0') fail('RELEASE_MANIFEST_SCHEMA', 'Node executable must be root owned'); + canonicalIdentity(node.gid, 'Node executable GID', { positive: false }); + canonicalMode(node.mode, 'Node executable mode'); + const environment = exactRecord(manifest.environment, ['environmentMetadataHash'], [], + 'RELEASE_MANIFEST_SCHEMA', 'environment metadata'); + canonicalHash(environment.environmentMetadataHash, 'environment metadata hash'); + const serviceArtifacts = manifest.serviceArtifacts.map(validateServiceArtifact) + .sort((left, right) => left.role.localeCompare(right.role)); + computeServiceArtifactsHash(serviceArtifacts); + if (!Array.isArray(manifest.entries) || manifest.entries.length === 0) { + fail('RELEASE_MANIFEST_SCHEMA', 'release manifest entries must be nonempty'); + } + const entries = manifest.entries.map(validateEntry); + const paths = entries.map((entry) => entry.path); + if (new Set(paths).size !== paths.length + || paths.some((entryPath, index) => index > 0 && Buffer.from(paths[index - 1]).compare(Buffer.from(entryPath)) >= 0)) { + fail('RELEASE_MANIFEST_SCHEMA', 'release entries must be unique and canonically sorted'); + } + const calculatedTreeHash = sha256(`${TREE_DOMAIN}${canonicalJson(entries)}`); + if (calculatedTreeHash !== manifest.releaseTreeHash) { + fail('RELEASE_MANIFEST_HASH', 'release tree aggregate hash does not match its entries'); + } + const packageEntry = entries.find((entry) => entry.path === 'package-lock.json'); + if (!packageEntry || packageEntry.sha256 !== manifest.packageLockHash) { + fail('RELEASE_MANIFEST_HASH', 'package-lock hash does not match its tree entry'); + } + if (!entries.some((entry) => entry.path === manifest.entrypoint && entry.kind === 'file')) { + fail('RELEASE_MANIFEST_SCHEMA', 'release entrypoint is missing from the tree'); + } + validateSystemd(manifest.systemd); + return frozenCopy({ ...manifest, kernelIdentity, node, environment, serviceArtifacts, entries }); +} + +function captureBuildInput(input) { + return exactRecord(input, [ + 'mode', 'releaseRoot', 'manifestPath', 'commit', 'createdAt', 'kernelUid', 'kernelGid', + 'node', 'environmentPath', 'serviceArtifacts', 'systemd', 'expectedOwnerUid', + ], [], 'RELEASE_BUILD_INPUT', 'release manifest build input'); +} + +export function buildReleaseManifest(input) { + const options = captureBuildInput(input); + if (options.mode !== 'deterministic' && options.mode !== 'cdp-testnet') { + fail('RELEASE_BUILD_INPUT', 'release manifest mode is invalid'); + } + if (!Number.isSafeInteger(options.expectedOwnerUid) || options.expectedOwnerUid < 0 + || (options.mode === 'cdp-testnet' && options.expectedOwnerUid !== 0)) { + fail('RELEASE_BUILD_INPUT', 'release expected owner is invalid'); + } + const releaseRoot = assertAbsolute(options.releaseRoot, 'release root'); + const manifestPath = assertAbsolute(options.manifestPath, 'release manifest path'); + if (!inside(releaseRoot, manifestPath) || path.dirname(manifestPath) !== releaseRoot) { + fail('RELEASE_BUILD_INPUT', 'release manifest must be a direct child of the release root'); + } + if (typeof options.commit !== 'string' || !COMMIT.test(options.commit)) { + fail('RELEASE_BUILD_INPUT', 'release commit must be one full lowercase hash'); + } + canonicalTimestamp(options.createdAt, 'release createdAt'); + canonicalIdentity(options.kernelUid, 'Kernel UID'); + canonicalIdentity(options.kernelGid, 'Kernel GID'); + const nodeInput = exactRecord(options.node, ['path', 'version'], [], + 'RELEASE_BUILD_INPUT', 'Node build input'); + if (nodeInput.version !== 'v24.18.1') fail('RELEASE_NODE_VERSION', 'Node runtime must equal v24.18.1'); + const nodePath = assertAbsolute(nodeInput.path, 'Node executable'); + const nodeFile = readRegularFile(nodePath, options.expectedOwnerUid, 'Node executable'); + const environmentPath = assertAbsolute(options.environmentPath, 'environment file'); + const environmentFile = readRegularFile(environmentPath, options.expectedOwnerUid, 'environment file'); + if (Number.parseInt(environmentFile.mode, 8) !== 0o600) { + fail('RELEASE_ENVIRONMENT', 'environment file must have mode 0600'); + } + if (!Array.isArray(options.serviceArtifacts) || options.serviceArtifacts.length !== 2) { + fail('RELEASE_BUILD_INPUT', 'service artifact inputs must contain exactly two roles'); + } + const artifactPaths = new Set(); + const serviceArtifacts = options.serviceArtifacts.map((candidate) => { + const item = exactRecord(candidate, ['role', 'path'], [], + 'RELEASE_BUILD_INPUT', 'service artifact input'); + if (!SERVICE_ROLES.includes(item.role)) fail('RELEASE_BUILD_INPUT', 'service artifact roles are invalid'); + const artifactPath = assertAbsolute(item.path, 'service artifact path'); + if (artifactPaths.has(artifactPath)) fail('RELEASE_BUILD_INPUT', 'service artifact path reuse is forbidden'); + artifactPaths.add(artifactPath); + const file = readRegularFile(artifactPath, options.expectedOwnerUid, `service artifact ${item.role}`); + return { + role: item.role, + pathHash: sha256(`${PATH_DOMAIN}${artifactPath}`), + sha256: sha256(file.bytes), uid: file.uid, gid: file.gid, mode: file.mode, + }; + }).sort((left, right) => left.role.localeCompare(right.role)); + computeServiceArtifactsHash(serviceArtifacts); + const entries = collectEntries({ releaseRoot, manifestPath, expectedOwnerUid: options.expectedOwnerUid }); + const packageLock = entries.find((entry) => entry.path === 'package-lock.json'); + if (!packageLock || packageLock.kind !== 'file') fail('RELEASE_BUILD_INPUT', 'package-lock.json is required'); + const nodeMode = Number.parseInt(nodeFile.mode, 8); + const manifest = { + schemaVersion: 1, + commit: options.commit, + createdAt: options.createdAt, + entrypoint: 'src/control-plane.mjs', + packageLockHash: packageLock.sha256, + releaseTreeHash: sha256(`${TREE_DOMAIN}${canonicalJson(entries)}`), + kernelIdentity: { uid: options.kernelUid, gid: options.kernelGid }, + node: { + version: nodeInput.version, + executablePathHash: sha256(`${PATH_DOMAIN}${nodePath}`), + executableSha256: sha256(nodeFile.bytes), + uid: options.mode === 'cdp-testnet' ? nodeFile.uid : '0', + gid: nodeFile.gid, + mode: nodeMode.toString(8), + }, + environment: { + environmentMetadataHash: sha256(`${ENVIRONMENT_DOMAIN}${canonicalJson({ + device: fs.statSync(environmentPath, { bigint: true }).dev.toString(10), + inode: fs.statSync(environmentPath, { bigint: true }).ino.toString(10), + uid: environmentFile.uid, gid: environmentFile.gid, mode: environmentFile.mode, + })}`), + }, + serviceArtifacts, + systemd: validateSystemd(options.systemd), + entries, + }; + return validateReleaseManifest(manifest); +} + +export function verifyReleaseIntegrity(input) { + const options = exactRecord(input, [ + 'mode', 'releaseRoot', 'manifest', 'expectedOwnerUid', 'expectedKernelUid', + 'expectedKernelGid', 'nodePath', 'nodeVersion', 'environmentPath', 'serviceArtifactPaths', + ], [], 'RELEASE_VERIFY_INPUT', 'release verification input'); + const manifest = validateReleaseManifest(options.manifest); + if (options.mode !== 'deterministic' && options.mode !== 'cdp-testnet') fail('RELEASE_VERIFY_INPUT', 'release verification mode is invalid'); + if (!Number.isSafeInteger(options.expectedOwnerUid) || options.expectedOwnerUid < 0 + || (options.mode === 'cdp-testnet' && options.expectedOwnerUid !== 0)) { + fail('RELEASE_VERIFY_INPUT', 'release verification owner is invalid'); + } + if (manifest.kernelIdentity.uid !== options.expectedKernelUid + || manifest.kernelIdentity.gid !== options.expectedKernelGid) { + fail('RELEASE_IDENTITY_MISMATCH', 'runtime Kernel identity differs from the manifest'); + } + const entries = collectEntries({ + releaseRoot: assertAbsolute(options.releaseRoot, 'release root'), + manifestPath: path.join(options.releaseRoot, 'manifest.json'), + expectedOwnerUid: options.expectedOwnerUid, + }); + if (canonicalJson(entries) !== canonicalJson(manifest.entries) + || sha256(`${TREE_DOMAIN}${canonicalJson(entries)}`) !== manifest.releaseTreeHash) { + fail('RELEASE_TREE_CHANGED', 'installed release tree differs from its manifest'); + } + if (options.nodeVersion !== manifest.node.version) fail('RELEASE_NODE_VERSION', 'running Node version differs from the manifest'); + const nodePath = assertAbsolute(options.nodePath, 'Node executable'); + const nodeFile = readRegularFile(nodePath, options.expectedOwnerUid, 'Node executable'); + if (sha256(`${PATH_DOMAIN}${nodePath}`) !== manifest.node.executablePathHash + || sha256(nodeFile.bytes) !== manifest.node.executableSha256) { + fail('RELEASE_NODE_CHANGED', 'Node executable differs from the manifest'); + } + const environmentPath = assertAbsolute(options.environmentPath, 'environment file'); + const envStat = fs.statSync(environmentPath, { bigint: true }); + const envFile = readRegularFile(environmentPath, options.expectedOwnerUid, 'environment file'); + const environmentMetadataHash = sha256(`${ENVIRONMENT_DOMAIN}${canonicalJson({ + device: envStat.dev.toString(10), inode: envStat.ino.toString(10), + uid: envFile.uid, gid: envFile.gid, mode: envFile.mode, + })}`); + if (environmentMetadataHash !== manifest.environment.environmentMetadataHash) { + fail('RELEASE_ENVIRONMENT_CHANGED', 'environment metadata differs from the manifest'); + } + const paths = exactRecord(options.serviceArtifactPaths, SERVICE_ROLES, [], + 'RELEASE_VERIFY_INPUT', 'service artifact paths'); + for (const artifact of manifest.serviceArtifacts) { + const artifactPath = assertAbsolute(paths[artifact.role], `${artifact.role} path`); + const file = readRegularFile(artifactPath, options.expectedOwnerUid, artifact.role); + if (sha256(`${PATH_DOMAIN}${artifactPath}`) !== artifact.pathHash + || sha256(file.bytes) !== artifact.sha256 + || file.uid !== artifact.uid || file.gid !== artifact.gid || file.mode !== artifact.mode) { + fail('RELEASE_SERVICE_CHANGED', 'service artifact differs from the manifest'); + } + } + return Object.freeze({ + releaseManifestHash: sha256(canonicalJson(manifest)), + releaseTreeHash: manifest.releaseTreeHash, + nodeExecutableHash: manifest.node.executableSha256, + serviceArtifactsHash: computeServiceArtifactsHash(manifest.serviceArtifacts), + systemdEffectiveConfigHash: manifest.systemd.effectiveConfigHash, + environmentMetadataHash, + }); +} + +export function assertClosedLoaderEnvironment(environment, { allowedWalletKernelFields = [] } = {}) { + const captured = exactRecord({ environment, allowedWalletKernelFields }, + ['environment', 'allowedWalletKernelFields'], [], 'RELEASE_ENVIRONMENT', 'loader environment input'); + if (!Array.isArray(captured.allowedWalletKernelFields) + || captured.allowedWalletKernelFields.some((name) => typeof name !== 'string' + || !/^WALLET_KERNEL_[A-Z0-9_]+$/.test(name))) { + fail('RELEASE_ENVIRONMENT', 'allowed Wallet Kernel environment fields are invalid'); + } + const allowed = new Set([...BASE_ENVIRONMENT, ...captured.allowedWalletKernelFields]); + const result = {}; + for (const name of Reflect.ownKeys(captured.environment)) { + if (typeof name !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(name) + || LOADER_EXACT.has(name) || name.startsWith('LD_') || name.startsWith('DYLD_') + || (name.startsWith('WALLET_KERNEL_') && !allowed.has(name)) || !allowed.has(name)) { + fail('RELEASE_ENVIRONMENT', 'process environment contains an unrecognized or loader-control field'); + } + const value = captured.environment[name]; + if (typeof value !== 'string' || value.includes('\0') || value.length > 4096) { + fail('RELEASE_ENVIRONMENT', 'process environment contains an invalid value'); + } + result[name] = value; + } + return Object.freeze(result); +} + +export function captureInheritedConsoleSocket(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) + || Object.getPrototypeOf(input) !== Object.prototype + || Reflect.ownKeys(input).length !== 3 + || !['env', 'processId', 'inspectDescriptor'].every((field) => Object.hasOwn(input, field))) { + fail('SOCKET_ACTIVATION', 'socket activation input fields do not match the closed schema'); + } + const descriptors = Object.getOwnPropertyDescriptors(input); + if (Object.values(descriptors).some((descriptor) => !descriptor.enumerable + || !Object.hasOwn(descriptor, 'value'))) { + fail('SOCKET_ACTIVATION', 'socket activation input must use enumerable data fields'); + } + const options = Object.freeze({ + env: descriptors.env.value, + processId: descriptors.processId.value, + inspectDescriptor: descriptors.inspectDescriptor.value, + }); + if (!Number.isSafeInteger(options.processId) || options.processId <= 0 + || typeof options.inspectDescriptor !== 'function') { + fail('SOCKET_ACTIVATION', 'socket activation dependencies are invalid'); + } + const env = options.env; + if (!env || typeof env !== 'object' + || env.LISTEN_PID !== String(options.processId) + || env.LISTEN_FDS !== '1' + || env.LISTEN_FDNAMES !== 'wallet-kernel-console') { + fail('SOCKET_ACTIVATION', 'socket activation metadata is missing or inconsistent'); + } + const details = options.inspectDescriptor(3); + const socket = exactRecord(details, [ + 'family', 'type', 'listening', 'address', 'port', + ], ['fd'], 'SOCKET_ACTIVATION', 'inherited socket descriptor'); + if (socket.family !== 'AF_INET' || socket.type !== 'SOCK_STREAM' || socket.listening !== true + || socket.address !== '127.0.0.1' || socket.port !== 8405 + || (Object.hasOwn(socket, 'fd') && socket.fd !== 3)) { + fail('SOCKET_ACTIVATION', 'inherited socket does not match the reserved console socket'); + } + delete env.LISTEN_PID; + delete env.LISTEN_FDS; + delete env.LISTEN_FDNAMES; + return Object.freeze({ fd: 3, name: 'wallet-kernel-console', address: '127.0.0.1', port: 8405 }); +} diff --git a/spikes/pi-wielder/src/kernel/secure-storage.mjs b/spikes/pi-wielder/src/kernel/secure-storage.mjs index 10ba2cc..ecc0fde 100644 --- a/spikes/pi-wielder/src/kernel/secure-storage.mjs +++ b/spikes/pi-wielder/src/kernel/secure-storage.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { openTrustedParent } from './trusted-path.mjs'; +import { openAgentTrustedParent, openTrustedParent } from './trusted-path.mjs'; const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../../', import.meta.url))); const MAXIMUM_PRIVATE_BYTES = 1_048_576; @@ -24,6 +24,11 @@ const PATH_TRUST_FIELDS = Object.freeze([ 'kernelUid', 'agentUid', ]); +const AGENT_PATH_TRUST_FIELDS = Object.freeze([ + 'mode', + 'trustedAncestor', + 'agentUid', +]); const ABSENT = Symbol('absent-private-file'); const SQLITE_PREFLIGHTS = new WeakMap(); @@ -66,6 +71,31 @@ function capturePathTrust(pathTrust) { }); } +function captureAgentPathTrust(pathTrust) { + if (pathTrust === null + || typeof pathTrust !== 'object' + || !Object.isFrozen(pathTrust) + || Object.getPrototypeOf(pathTrust) !== Object.prototype + || Object.getOwnPropertySymbols(pathTrust).length !== 0) { + throw new Error('Agent pathTrust must be one frozen plain object'); + } + const descriptors = Object.getOwnPropertyDescriptors(pathTrust); + const keys = Object.keys(descriptors); + if (keys.length !== AGENT_PATH_TRUST_FIELDS.length + || AGENT_PATH_TRUST_FIELDS.some((field) => !Object.hasOwn(descriptors, field))) { + throw new Error('Agent pathTrust must contain the exact fields'); + } + const captured = {}; + for (const field of AGENT_PATH_TRUST_FIELDS) { + const descriptor = descriptors[field]; + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new Error('Agent pathTrust fields must be own enumerable data fields'); + } + captured[field] = descriptor.value; + } + return Object.freeze(captured); +} + function inside(parent, child) { const relative = path.relative(parent, child); return relative === '' @@ -161,6 +191,25 @@ function privateParent(filePath, label, checkoutRoot, pathTrust) { }); } +function agentPrivateParent(filePath, label, checkoutRoot, pathTrust) { + assertSecurePlatform(); + const trust = captureAgentPathTrust(pathTrust); + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) { + throw new Error(`${label} path must be absolute`); + } + const lexicalParent = path.resolve(path.dirname(filePath)); + if (inside(checkoutRoot, lexicalParent)) { + throw new Error(`${label} must be outside the checkout`); + } + return openAgentTrustedParent({ + ...trust, + targetFile: filePath, + terminalOwnerUid: process.getuid(), + terminalMode: 0o700, + role: 'agent-private', + }); +} + function readBoundedDescriptor(descriptor, maximumBytes, label) { const scratch = Buffer.allocUnsafe(maximumBytes + 1); let total = 0; @@ -522,7 +571,7 @@ function unlinkCandidate(guard, candidate) { guard.fsyncParent(); } -export function loadOrInitializePrivateFile({ +function loadOrInitializePrivateFileInternal({ filePath, label, createBytes, @@ -530,7 +579,7 @@ export function loadOrInitializePrivateFile({ randomBytes = crypto.randomBytes, faultInjector = () => {}, pathTrust, -}) { +}, role) { if (typeof createBytes !== 'function' || typeof validateBytes !== 'function') { throw new Error(`${label} initializer and validator must be functions`); } @@ -538,7 +587,9 @@ export function loadOrInitializePrivateFile({ throw new Error(`${label} randomness and fault injector must be functions`); } - const guard = privateParent(filePath, label, CHECKOUT_ROOT, pathTrust); + const guard = role === 'agent-private' + ? agentPrivateParent(filePath, label, CHECKOUT_ROOT, pathTrust) + : privateParent(filePath, label, CHECKOUT_ROOT, pathTrust); const readExisting = () => { const descriptor = guard.openLeaf(fs.constants.O_RDONLY | NOFOLLOW); try { @@ -664,3 +715,11 @@ export function loadOrInitializePrivateFile({ } } } + +export function loadOrInitializePrivateFile(options) { + return loadOrInitializePrivateFileInternal(options, 'kernel-private'); +} + +export function loadOrInitializeAgentPrivateFile(options) { + return loadOrInitializePrivateFileInternal(options, 'agent-private'); +} diff --git a/spikes/pi-wielder/src/kernel/signed-receipts.mjs b/spikes/pi-wielder/src/kernel/signed-receipts.mjs new file mode 100644 index 0000000..8b0f45e --- /dev/null +++ b/spikes/pi-wielder/src/kernel/signed-receipts.mjs @@ -0,0 +1,1514 @@ +import crypto from 'node:crypto'; + +import { + canonicalJson as signingCanonicalJson, + receiptKeyId, + verifySignedReceipt, +} from './receipt-signing.mjs'; +import { + canonicalTimestamp, + canonicalToken, + exactRecord, + frozenCopy, + KernelError, +} from './canonical.mjs'; + +const OUTCOME_STATUSES = new Set([ + 'completed', + 'upstream_failed', + 'payment_denied', + 'payment_failed', + 'payment_unresolved', + 'payment_rejected', + 'execution_failed', + 'execution_unknown', + 'refunded', +]); +const POLICY_DECISIONS = new Set(['allow', 'approval_required', 'deny']); +const APPROVAL_STATES = new Set([ + 'not_required', 'pending', 'approved', 'denied', 'expired', 'cancelled', 'consumed', +]); +const EXECUTION_STATES = new Set(['none', 'succeeded', 'failed', 'unknown']); +const PAYMENT_STATES = new Set(['none', 'not_signed', 'unresolved', 'rejected', 'settled']); +const BUDGET_DISPOSITIONS = new Set(['reserved', 'committed', 'released', 'unresolved']); +const RECONCILIATION_KINDS = new Set(['payment', 'execution', 'refund']); +const RECONCILIATION_OUTCOMES = new Set([ + 'settled', 'rejected', 'execution_succeeded', 'execution_failed', + 'execution_unknown', 'refund_confirmed', 'refund_rejected', 'unresolved', +]); +const REASON_CODE = /^[A-Z][A-Z0-9_]{0,127}$/; +const SHA256 = /^sha256:[0-9a-f]{64}$/; +const EVM_ADDRESS = /^0x[0-9a-f]{40}$/; +const EVM_TRANSACTION = /^0x[0-9a-f]{64}$/; +const ATOMIC = /^(0|[1-9][0-9]*)$/; +const PRE_POLICY_DENIAL_REASONS = new Set([ + 'PAYMENT_CHALLENGE_MALFORMED', + 'PAYMENT_CHALLENGE_OVERSIZED', + 'PAYMENT_CHALLENGE_EXPIRED', +]); +const POLICY_DENIAL_REASONS = new Set([ + 'POLICY_DENIED', + 'X402_VERSION', + 'SCHEME_UNSUPPORTED', + 'NETWORK_MISMATCH', + 'ASSET_MISMATCH', + 'WALLET_MISMATCH', + 'METHOD_UNSUPPORTED', + 'SELLER_UNTRUSTED', + 'RESOURCE_PATH', + 'PAYEE_MISMATCH', + 'PAYMENT_OPTIONS_AMBIGUOUS', + 'CHALLENGE_EXPIRED', + 'PER_REQUEST_LIMIT', + 'SELLER_SESSION_LIMIT', + 'SESSION_LIMIT', + 'ROLLING_24H_LIMIT', + 'APPROVAL_CAPACITY', +]); +const APPROVAL_CANCELLATION_REASONS = new Set([ + 'APPROVAL_CHALLENGE_CHANGED', +]); +const SESSION_CANCELLATION_REASONS = new Set([ + 'POLICY_SUPERSEDED', + 'SESSION_CLOSED', +]); +const UNSIGNED_RELEASE_REASONS = new Set([ + 'SIGNER_REJECTED', + 'NONCE_COLLISION', + 'WALLET_PRE_SIGN_REJECTED', +]); +const PRE_CLAIM_RELEASE_REASONS = new Set([ + 'CHALLENGE_EXPIRED', + 'NONCE_COLLISION', + 'AGENT_REVOKED', + 'WALLET_RECOVERY_REQUIRED', + 'POLICY_SUPERSEDED', + 'SESSION_CLOSED', +]); +const PAYMENT_UNRESOLVED_REASONS = new Set([ + 'PAID_RESPONSE_AMBIGUOUS', + 'PAYMENT_CANDIDATE_REJECTED', + 'RECOVERY_PAYMENT_AMBIGUOUS', + 'WALLET_SIGNATURE_AMBIGUOUS', + 'SIGNATURE_PERSISTENCE_UNCERTAIN', + 'SECOND_PAYMENT_REQUIRED', + 'SETTLEMENT_EVIDENCE_INVALID', +]); + +function fail(code, message, options) { + throw new KernelError(code, message, options); +} + +function positiveInteger(value, label) { + const numeric = typeof value === 'bigint' ? Number(value) : value; + if (!Number.isSafeInteger(numeric) || numeric < 1) { + fail('RECEIPT_CORRUPTION', `${label} must be a positive safe integer`); + } + return numeric; +} + +function optionalInteger(value, label) { + if (value === null) return null; + const numeric = typeof value === 'bigint' ? Number(value) : value; + if (!Number.isSafeInteger(numeric)) { + fail('RECEIPT_CORRUPTION', `${label} must be a safe integer or null`); + } + return numeric; +} + +function reasonCode(value, label) { + if (typeof value !== 'string' || !REASON_CODE.test(value)) { + fail('RECEIPT_CORRUPTION', `${label} must be one bounded stable reason code`); + } + return value; +} + +function hash(value, label) { + if (typeof value !== 'string' || !SHA256.test(value)) { + fail('RECEIPT_CORRUPTION', `${label} must be one canonical SHA-256 identifier`); + } + return value; +} + +function atomic(value, label) { + if (typeof value !== 'string' || !ATOMIC.test(value)) { + fail('RECEIPT_CORRUPTION', `${label} must be canonical atomic text`); + } + return value; +} + +function canonicalOrigin(value) { + let parsed; + try { parsed = new URL(value); } catch (error) { + fail('RECEIPT_CORRUPTION', 'receipt seller origin is invalid', { cause: error }); + } + if (!['http:', 'https:'].includes(parsed.protocol) + || parsed.username || parsed.password || parsed.pathname !== '/' + || parsed.search || parsed.hash || parsed.origin !== value) { + fail('RECEIPT_CORRUPTION', 'receipt seller origin must be one canonical HTTP origin'); + } + return value; +} + +function resourcePath(value) { + if (typeof value !== 'string' || !value.startsWith('/') + || value.includes('?') || value.includes('#') || value.includes('\\')) { + fail('RECEIPT_CORRUPTION', 'receipt resource path must be canonical'); + } + return value; +} + +function parseCanonicalJson(value, label) { + if (typeof value !== 'string') fail('RECEIPT_CORRUPTION', `${label} must be JSON text`); + let parsed; + try { parsed = JSON.parse(value); } catch (error) { + fail('RECEIPT_CORRUPTION', `${label} is invalid JSON`, { cause: error }); + } + if (signingCanonicalJson(parsed) !== value) { + fail('RECEIPT_CORRUPTION', `${label} is not canonical JSON`); + } + return parsed; +} + +function dataAccess(store) { + return Object.freeze({ + one: (sql, parameters = []) => store.readOne(sql, parameters), + all: (sql, parameters = []) => store.readAll(sql, parameters), + }); +} + +function transactionAccess(db) { + return Object.freeze({ + one: (sql, parameters = []) => db.prepare(sql).get(...parameters), + all: (sql, parameters = []) => db.prepare(sql).all(...parameters), + }); +} + +function readUnique(access, sql, parameters, label, { optional = false } = {}) { + const rows = access.all(sql, parameters); + if (rows.length === 0 && optional) return null; + if (rows.length !== 1) fail('RECEIPT_CORRUPTION', `${label} must have exactly one row`); + return rows[0]; +} + +function readSelectedPayment(access, intent, decision) { + const attempt = readUnique( + access, + 'SELECT * FROM payment_attempts WHERE intent_id = ? ORDER BY rowid', + [intent.id], + 'PaymentAttempt', + { optional: true }, + ); + if (!attempt) return { attempt: null, selected: null }; + if (!decision) fail('RECEIPT_CORRUPTION', 'PaymentAttempt exists without PolicyDecision'); + const projection = parseCanonicalJson( + attempt.payment_required_projection_json, + 'PaymentAttempt challenge projection', + ); + if (!projection || typeof projection !== 'object' || Array.isArray(projection) + || !Array.isArray(projection.accepts)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt challenge projection is invalid'); + } + const index = optionalInteger(attempt.accepted_index, 'accepted payment index'); + const selected = projection.accepts[index]; + if (!selected || typeof selected !== 'object' || Array.isArray(selected) + || attempt.payment_required_projection_json !== intent.challenge_projection_json + || attempt.accepted_index !== decision.accepted_index + || attempt.quote_id !== decision.quote_id) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt no longer binds its PolicyDecision'); + } + atomic(selected.amount, 'payment amount'); + if (selected.amount !== decision.amount_ceiling_atomic + || typeof selected.network !== 'string' || selected.network.length < 1 + || !EVM_ADDRESS.test(selected.asset) || !EVM_ADDRESS.test(selected.payTo)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt selected payment is invalid'); + } + return { attempt, selected }; +} + +function paymentAttemptMaterial(attempt) { + const createdAt = canonicalTimestamp(attempt.created_at, 'PaymentAttempt createdAt'); + const updatedAt = canonicalTimestamp(attempt.updated_at, 'PaymentAttempt updatedAt'); + if (Date.parse(updatedAt) < Date.parse(createdAt)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt updatedAt predates creation'); + } + const claimFields = [ + attempt.nonce, + attempt.valid_after, + attempt.valid_before, + attempt.signing_claimed_at, + ]; + const signedFields = [ + attempt.payment_payload_json, + attempt.payment_header, + attempt.payment_hash, + attempt.signed_at, + ]; + const hasClaim = claimFields.every((value) => value !== null); + const hasNoClaim = claimFields.every((value) => value === null); + const hasSignedBytes = signedFields.every((value) => value !== null); + const hasNoSignedBytes = signedFields.every((value) => value === null); + const settlementFields = [ + attempt.settlement_json, + attempt.transaction_id, + attempt.settled_at, + ]; + const hasSettlement = settlementFields.every((value) => value !== null); + const hasNoSettlement = settlementFields.every((value) => value === null); + if ((!hasClaim && !hasNoClaim) || (!hasSignedBytes && !hasNoSignedBytes)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt claim or signed-byte projection is partial'); + } + if (!hasSettlement && !hasNoSettlement) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt settlement projection is partial'); + } + let signingClaimedAt = null; + if (hasClaim) { + if (typeof attempt.nonce !== 'string' || !/^0x[0-9a-f]{64}$/.test(attempt.nonce)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt signing claim nonce is invalid'); + } + const validAfter = atomic(attempt.valid_after, 'PaymentAttempt validAfter'); + const validBefore = atomic(attempt.valid_before, 'PaymentAttempt validBefore'); + if (BigInt(validBefore) <= BigInt(validAfter)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt signing claim window is invalid'); + } + signingClaimedAt = canonicalTimestamp( + attempt.signing_claimed_at, + 'PaymentAttempt signing claimedAt', + ); + if (Date.parse(signingClaimedAt) < Date.parse(createdAt) + || Date.parse(signingClaimedAt) > Date.parse(updatedAt)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt signing-claim chronology is invalid'); + } + } + let signedAt = null; + if (hasSignedBytes) { + if (!hasClaim || typeof attempt.payment_payload_json !== 'string' + || typeof attempt.payment_header !== 'string' || attempt.payment_header.length === 0) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt signed bytes have no complete claim'); + } + parseCanonicalJson(attempt.payment_payload_json, 'PaymentAttempt payment payload'); + hash(attempt.payment_hash, 'PaymentAttempt payment hash'); + signedAt = canonicalTimestamp(attempt.signed_at, 'PaymentAttempt signedAt'); + if (Date.parse(signedAt) < Date.parse(signingClaimedAt) + || Date.parse(signedAt) > Date.parse(updatedAt)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt signedAt chronology is invalid'); + } + } + const hasRetry = attempt.retry_started_at !== null; + let retryStartedAt = null; + if (hasRetry) { + if (!hasSignedBytes) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt retry has no persisted signed bytes'); + } + retryStartedAt = canonicalTimestamp( + attempt.retry_started_at, + 'PaymentAttempt retry startedAt', + ); + if (Date.parse(retryStartedAt) < Date.parse(signedAt) + || Date.parse(retryStartedAt) > Date.parse(updatedAt)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt retry chronology is invalid'); + } + } + if (hasSettlement) { + if (!hasRetry) { + fail('RECEIPT_CORRUPTION', 'settled PaymentAttempt has no paid retry'); + } + parseCanonicalJson(attempt.settlement_json, 'PaymentAttempt settlement'); + if (!EVM_TRANSACTION.test(attempt.transaction_id)) { + fail('RECEIPT_CORRUPTION', 'settled PaymentAttempt transaction is invalid'); + } + const settledAt = canonicalTimestamp(attempt.settled_at, 'PaymentAttempt settledAt'); + if (Date.parse(settledAt) < Date.parse(retryStartedAt) + || Date.parse(settledAt) > Date.parse(updatedAt) + || settledAt !== updatedAt) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt settlement chronology is invalid'); + } + } + const validState = { + reserved: hasNoClaim && hasNoSignedBytes && !hasRetry && hasNoSettlement, + signing: hasClaim && hasNoSignedBytes && !hasRetry && hasNoSettlement, + signed: hasClaim && hasSignedBytes && !hasRetry && hasNoSettlement, + retrying: hasClaim && hasSignedBytes && hasRetry && hasNoSettlement, + unresolved: hasClaim && (hasNoSignedBytes || hasSignedBytes) + && (!hasRetry || hasSignedBytes) && hasNoSettlement, + settled: hasClaim && hasSignedBytes && hasRetry && hasSettlement, + rejected: (hasNoClaim && hasNoSignedBytes) + ? !hasRetry && hasNoSettlement + : hasClaim && (hasNoSignedBytes || hasSignedBytes) + && (!hasRetry || hasSignedBytes) && hasNoSettlement, + }[attempt.state]; + if (!validState) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt state contradicts its durable signing material'); + } + if (['unresolved', 'rejected'].includes(attempt.state)) { + reasonCode(attempt.reason_code, 'PaymentAttempt terminal reason'); + } + if (attempt.state === 'settled' + && attempt.reason_code !== null + && attempt.reason_code !== 'TRUSTED_RECONCILIATION') { + fail('RECEIPT_CORRUPTION', 'settled PaymentAttempt reason is invalid'); + } + return Object.freeze({ hasClaim, hasSignedBytes }); +} + +function projectPayment(attempt, selected, outcomeReason) { + if (!attempt) return { state: 'none' }; + const material = paymentAttemptMaterial(attempt); + let state; + if (attempt.state === 'settled') state = 'settled'; + else if (attempt.state === 'rejected') { + reasonCode(attempt.reason_code, 'PaymentAttempt rejection reason'); + if (attempt.reason_code !== outcomeReason) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt rejection reason changed'); + } + if ((outcomeReason === 'WALLET_PRE_SIGN_REJECTED' && !material.hasClaim) + || (PRE_CLAIM_RELEASE_REASONS.has(outcomeReason) && material.hasClaim)) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt rejection contradicts its signing claim'); + } + state = material.hasSignedBytes ? 'rejected' : 'not_signed'; + } else if (attempt.state === 'unresolved') { + if (attempt.reason_code !== outcomeReason) { + fail('RECEIPT_CORRUPTION', 'PaymentAttempt unresolved reason changed'); + } + state = 'unresolved'; + } + else if (['reserved', 'signing', 'signed', 'retrying'].includes(attempt.state)) { + fail('RECEIPT_CORRUPTION', 'terminal receipt cannot retain a nonterminal PaymentAttempt'); + } + else fail('RECEIPT_CORRUPTION', 'PaymentAttempt state is unsupported'); + if (!PAYMENT_STATES.has(state)) fail('RECEIPT_CORRUPTION', 'receipt payment state is invalid'); + const transactionId = attempt.transaction_id; + if (state === 'settled') { + if (!EVM_TRANSACTION.test(transactionId)) { + fail('RECEIPT_CORRUPTION', 'settled payment requires a canonical transaction ID'); + } + } else if (transactionId !== null) { + fail('RECEIPT_CORRUPTION', 'unsettled payment must not expose a transaction ID'); + } + return { + state, + amountAtomic: selected.amount, + network: selected.network, + asset: selected.asset, + payTo: selected.payTo, + transactionId, + }; +} + +function projectExecution(access, intentId) { + const row = readUnique( + access, + 'SELECT * FROM execution_outcomes WHERE intent_id = ? ORDER BY rowid', + [intentId], + 'ExecutionOutcome', + { optional: true }, + ); + if (!row) return { state: 'none', httpStatus: null, responseHash: null }; + if (!EXECUTION_STATES.has(row.state) || row.state === 'none') { + fail('RECEIPT_CORRUPTION', 'ExecutionOutcome state is invalid'); + } + const httpStatus = optionalInteger(row.http_status, 'execution HTTP status'); + if (httpStatus !== null && (httpStatus < 100 || httpStatus > 599)) { + fail('RECEIPT_CORRUPTION', 'execution HTTP status is invalid'); + } + const responseHash = row.response_hash === null ? null : hash(row.response_hash, 'response hash'); + if (row.state === 'succeeded' && (httpStatus === null || httpStatus < 200 || httpStatus > 299)) { + fail('RECEIPT_CORRUPTION', 'successful execution requires a 2xx HTTP status'); + } + if (row.state === 'failed' && (httpStatus === null || httpStatus < 300)) { + fail('RECEIPT_CORRUPTION', 'failed execution requires a known 3xx-5xx HTTP status'); + } + if (row.state === 'unknown' && httpStatus !== null + && (httpStatus < 200 || httpStatus > 299)) { + fail('RECEIPT_CORRUPTION', 'unknown execution permits only a known 2xx HTTP status'); + } + return { state: row.state, httpStatus, responseHash }; +} + +function projectBudget(access, intentId) { + const row = readUnique( + access, + 'SELECT * FROM budget_reservations WHERE intent_id = ? ORDER BY rowid', + [intentId], + 'BudgetReservation', + { optional: true }, + ); + if (!row) return null; + if (!BUDGET_DISPOSITIONS.has(row.state)) { + fail('RECEIPT_CORRUPTION', 'BudgetReservation state is invalid'); + } + const parts = [ + atomic(row.reserved_atomic, 'reserved amount'), + atomic(row.committed_atomic, 'committed amount'), + atomic(row.released_atomic, 'released amount'), + atomic(row.unresolved_atomic, 'unresolved amount'), + ]; + const amountAtomic = parts.reduce((sum, part) => sum + BigInt(part), 0n).toString(10); + const activeColumn = { + reserved: row.reserved_atomic, + committed: row.committed_atomic, + released: row.released_atomic, + unresolved: row.unresolved_atomic, + }[row.state]; + if (activeColumn !== amountAtomic + || parts.filter((part) => part !== '0').length !== 1) { + fail('RECEIPT_CORRUPTION', 'BudgetReservation does not have one conserved disposition'); + } + return { disposition: row.state, amountAtomic }; +} + +function projectReconciliation(access, intentId) { + const rows = access.all( + 'SELECT * FROM reconciliations WHERE intent_id = ? ORDER BY rowid', + [intentId], + ); + if (rows.length === 0) return null; + const row = rows.at(-1); + canonicalToken(row.id, 'reconciliation ID'); + canonicalToken(row.kind, 'reconciliation kind'); + canonicalToken(row.outcome, 'reconciliation outcome'); + hash(row.operator_id_hash, 'reconciliation operator hash'); + canonicalTimestamp(row.recorded_at, 'reconciliation recordedAt'); + return { + kind: row.kind, + outcome: row.outcome, + operatorIdHash: row.operator_id_hash, + recordedAt: row.recorded_at, + }; +} + +function projectRefund(access, intentId) { + const rows = access.all('SELECT * FROM refunds WHERE intent_id = ? ORDER BY rowid', [intentId]); + if (rows.length === 0) return null; + const row = rows.at(-1); + canonicalToken(row.id, 'refund ID'); + atomic(row.amount_atomic, 'refund amount'); + if (!['pending', 'unresolved', 'abandoned', 'confirmed', 'rejected'].includes(row.state)) { + fail('RECEIPT_CORRUPTION', 'refund state is invalid'); + } + const transactionId = row.refund_transaction_id; + if (transactionId !== null && !EVM_TRANSACTION.test(transactionId)) { + fail('RECEIPT_CORRUPTION', 'refund candidate transaction ID is invalid'); + } + if (row.state === 'confirmed') { + if (transactionId === null) { + fail('RECEIPT_CORRUPTION', 'confirmed refund requires a canonical transaction ID'); + } + return { state: 'confirmed', amountAtomic: row.amount_atomic, transactionId }; + } + + const outcome = readUnique( + access, + 'SELECT * FROM buyer_outcomes WHERE intent_id = ? ORDER BY rowid', + [intentId], + 'BuyerOutcome', + ); + const reconciliations = access.all( + 'SELECT * FROM reconciliations WHERE intent_id = ? ORDER BY rowid', + [intentId], + ); + const reconciliation = reconciliations.at(-1) ?? null; + if (row.state === 'rejected' + && (reconciliation?.kind !== 'refund' + || reconciliation.outcome !== 'refund_rejected')) { + fail('RECEIPT_CORRUPTION', 'rejected refund lacks its durable reconciliation'); + } + + let state = row.state; + if (reconciliation?.kind === 'refund') { + if (reconciliation.outcome === 'refund_confirmed') { + fail('RECEIPT_CORRUPTION', 'confirmed refund reconciliation lacks a confirmed refund'); + } + if (reconciliation.outcome === 'refund_rejected') state = 'rejected'; + if (reconciliation.outcome === 'unresolved') state = 'unresolved'; + } else if (reconciliation?.kind === 'execution' + && reconciliation.outcome === 'execution_failed') { + state = 'pending'; + } else if (['UPSTREAM_HTTP_FAILURE', 'UPSTREAM_FAILED'].includes(outcome.reason_code)) { + state = 'pending'; + } else if (outcome.reason_code === 'REFUND_UNRESOLVED') { + state = 'unresolved'; + } + + // A named refund candidate is operational reconciliation state. It is not + // authoritative receipt state until trusted evidence revises BuyerOutcome. + return { state, amountAtomic: row.amount_atomic, transactionId: null }; +} + +function validateReceiptSchema(value) { + const receipt = exactRecord(value, [ + 'schemaVersion', 'receiptId', 'revision', 'issuedAt', 'intent', 'outcome', + 'policy', 'approval', 'payment', 'execution', 'budget', 'reconciliation', + 'refund', 'supersedesReceiptHash', + ], [], 'RECEIPT_SCHEMA', 'signed receipt'); + if (receipt.schemaVersion !== 1) fail('RECEIPT_SCHEMA', 'receipt schemaVersion must equal 1'); + canonicalToken(receipt.receiptId, 'receipt ID'); + positiveInteger(receipt.revision, 'receipt revision'); + canonicalTimestamp(receipt.issuedAt, 'receipt issuedAt'); + const intent = exactRecord(receipt.intent, [ + 'id', 'requestId', 'intentHash', 'sessionId', 'sellerOrigin', 'resourcePath', 'purposeLabel', + ], [], 'RECEIPT_SCHEMA', 'receipt intent'); + for (const [label, token] of [ + ['intent ID', intent.id], ['request ID', intent.requestId], ['session ID', intent.sessionId], + ['purpose label', intent.purposeLabel], + ]) canonicalToken(token, label); + hash(intent.intentHash, 'receipt intent hash'); + canonicalOrigin(intent.sellerOrigin); + resourcePath(intent.resourcePath); + const outcome = exactRecord(receipt.outcome, ['status', 'reasonCode'], [], + 'RECEIPT_SCHEMA', 'receipt outcome'); + if (!OUTCOME_STATUSES.has(outcome.status)) fail('RECEIPT_SCHEMA', 'receipt outcome status is invalid'); + reasonCode(outcome.reasonCode, 'receipt outcome reason'); + if (receipt.policy !== null) { + const policy = exactRecord(receipt.policy, ['versionId', 'decision', 'reasonCode'], [], + 'RECEIPT_SCHEMA', 'receipt policy'); + canonicalToken(policy.versionId, 'policy version ID'); + if (!POLICY_DECISIONS.has(policy.decision)) fail('RECEIPT_SCHEMA', 'receipt policy decision is invalid'); + reasonCode(policy.reasonCode, 'receipt policy reason'); + } + const approval = exactRecord(receipt.approval, ['state', 'operatorIdHash'], [], + 'RECEIPT_SCHEMA', 'receipt approval'); + if (!APPROVAL_STATES.has(approval.state)) fail('RECEIPT_SCHEMA', 'receipt approval state is invalid'); + if (approval.operatorIdHash !== null) hash(approval.operatorIdHash, 'approval operator hash'); + const payment = exactRecord(receipt.payment, receipt.payment?.state === 'none' + ? ['state'] + : ['state', 'amountAtomic', 'network', 'asset', 'payTo', 'transactionId'], [], + 'RECEIPT_SCHEMA', 'receipt payment'); + if (!PAYMENT_STATES.has(payment.state)) fail('RECEIPT_SCHEMA', 'receipt payment state is invalid'); + if (payment.state !== 'none') { + atomic(payment.amountAtomic, 'receipt payment amount'); + if (typeof payment.network !== 'string' || payment.network.length < 1 + || !EVM_ADDRESS.test(payment.asset) || !EVM_ADDRESS.test(payment.payTo) + || (payment.transactionId !== null && !EVM_TRANSACTION.test(payment.transactionId)) + || (payment.state === 'settled') !== (payment.transactionId !== null)) { + fail('RECEIPT_SCHEMA', 'receipt payment binding is invalid'); + } + } + const execution = exactRecord(receipt.execution, ['state', 'httpStatus', 'responseHash'], [], + 'RECEIPT_SCHEMA', 'receipt execution'); + if (!EXECUTION_STATES.has(execution.state)) fail('RECEIPT_SCHEMA', 'receipt execution state is invalid'); + if (execution.httpStatus !== null && (!Number.isSafeInteger(execution.httpStatus) + || execution.httpStatus < 100 || execution.httpStatus > 599)) { + fail('RECEIPT_SCHEMA', 'receipt execution HTTP status is invalid'); + } + if (execution.responseHash !== null) hash(execution.responseHash, 'receipt response hash'); + if ((execution.state === 'none' + && (execution.httpStatus !== null || execution.responseHash !== null)) + || (execution.state === 'succeeded' + && (execution.httpStatus < 200 || execution.httpStatus > 299 + || execution.responseHash === null)) + || (execution.state === 'failed' + && (execution.httpStatus === null || execution.httpStatus < 300)) + || (execution.state === 'unknown' && execution.httpStatus !== null + && (execution.httpStatus < 200 || execution.httpStatus > 299))) { + fail('RECEIPT_SCHEMA', 'receipt execution fields disagree with its state'); + } + if (receipt.budget !== null) { + const budget = exactRecord(receipt.budget, ['disposition', 'amountAtomic'], [], + 'RECEIPT_SCHEMA', 'receipt budget'); + if (!BUDGET_DISPOSITIONS.has(budget.disposition)) fail('RECEIPT_SCHEMA', 'receipt budget disposition is invalid'); + atomic(budget.amountAtomic, 'receipt budget amount'); + } + if (receipt.reconciliation !== null) { + const reconciliation = exactRecord(receipt.reconciliation, + ['kind', 'outcome', 'operatorIdHash', 'recordedAt'], [], + 'RECEIPT_SCHEMA', 'receipt reconciliation'); + if (!RECONCILIATION_KINDS.has(reconciliation.kind) + || !RECONCILIATION_OUTCOMES.has(reconciliation.outcome)) { + fail('RECEIPT_SCHEMA', 'receipt reconciliation projection is invalid'); + } + hash(reconciliation.operatorIdHash, 'receipt reconciliation operator hash'); + canonicalTimestamp(reconciliation.recordedAt, 'receipt reconciliation recordedAt'); + } + if (receipt.refund !== null) { + const refund = exactRecord(receipt.refund, ['state', 'amountAtomic', 'transactionId'], [], + 'RECEIPT_SCHEMA', 'receipt refund'); + if (!['pending', 'unresolved', 'abandoned', 'confirmed', 'rejected'].includes(refund.state)) { + fail('RECEIPT_SCHEMA', 'receipt refund state is invalid'); + } + atomic(refund.amountAtomic, 'receipt refund amount'); + if ((refund.state === 'confirmed') !== (refund.transactionId !== null) + || (refund.transactionId !== null && !EVM_TRANSACTION.test(refund.transactionId))) { + fail('RECEIPT_SCHEMA', 'receipt refund transaction binding is invalid'); + } + } + const hasPredecessor = receipt.supersedesReceiptHash !== null; + if ((receipt.revision === 1) === hasPredecessor + || (hasPredecessor && !/^[0-9a-f]{64}$/.test(receipt.supersedesReceiptHash))) { + fail('RECEIPT_SCHEMA', 'receipt predecessor hash is invalid'); + } + validateReceiptConsistency(receipt); + return frozenCopy(receipt); +} + +function isNoExecution(execution) { + return execution.state === 'none' + && execution.httpStatus === null + && execution.responseHash === null; +} + +function isUnknownExecution(execution) { + return execution.state === 'unknown'; +} + +function isNoPayment(receipt) { + return receipt.payment.state === 'none' && receipt.budget === null; +} + +function isReleasedUnsigned(receipt) { + return receipt.payment.state === 'not_signed' + && receipt.budget?.disposition === 'released' + && receipt.payment.amountAtomic === receipt.budget.amountAtomic; +} + +function isAutomaticAuthority(receipt) { + return receipt.policy?.decision === 'allow' + && receipt.policy.reasonCode === 'WITHIN_AUTO_LIMIT' + && receipt.approval.state === 'not_required' + && receipt.approval.operatorIdHash === null; +} + +function isConsumedApprovalAuthority(receipt) { + return receipt.policy?.decision === 'approval_required' + && receipt.policy.reasonCode === 'HUMAN_APPROVAL_REQUIRED' + && receipt.approval.state === 'consumed' + && receipt.approval.operatorIdHash !== null; +} + +function hasSpendAuthority(receipt) { + return isAutomaticAuthority(receipt) || isConsumedApprovalAuthority(receipt); +} + +function hasNoSensitiveAftermath(receipt) { + return receipt.reconciliation === null && receipt.refund === null; +} + +function isSafeSessionCancellation(receipt) { + const { approval, policy } = receipt; + if (!isNoExecution(receipt.execution) || !hasNoSensitiveAftermath(receipt)) return false; + if (isNoPayment(receipt)) { + return (policy === null + && approval.state === 'not_required' + && approval.operatorIdHash === null) + || isAutomaticAuthority(receipt) + || (policy?.decision === 'approval_required' + && policy.reasonCode === 'HUMAN_APPROVAL_REQUIRED' + && ['not_required', 'cancelled'].includes(approval.state)); + } + if (!isReleasedUnsigned(receipt)) return false; + return isAutomaticAuthority(receipt) || isConsumedApprovalAuthority(receipt); +} + +function requireProjection(condition, message) { + if (!condition) fail('RECEIPT_SCHEMA', message); +} + +function validateReconciliationConsistency(receipt) { + const reconciliation = receipt.reconciliation; + if (reconciliation === null) return; + requireProjection( + receipt.revision > 1, + 'trusted reconciliation requires a superseding receipt revision', + ); + const allowedOutcomes = { + payment: new Set(['settled', 'rejected', 'unresolved']), + execution: new Set(['execution_succeeded', 'execution_failed', 'execution_unknown']), + refund: new Set(['refund_confirmed', 'refund_rejected', 'unresolved']), + }[reconciliation.kind]; + requireProjection( + allowedOutcomes?.has(reconciliation.outcome), + 'receipt reconciliation kind and outcome contradict one another', + ); + const matchesProjection = { + settled: receipt.payment.state === 'settled' + && receipt.budget?.disposition === 'committed', + rejected: receipt.payment.state === 'rejected' + && receipt.budget?.disposition === 'released' + && isNoExecution(receipt.execution), + unresolved: reconciliation.kind === 'payment' + ? (receipt.payment.state === 'unresolved' + && receipt.budget?.disposition === 'unresolved' + && isNoExecution(receipt.execution)) + : (receipt.refund !== null + && ['unresolved', 'abandoned'].includes(receipt.refund.state) + && receipt.budget?.disposition === 'committed'), + execution_succeeded: receipt.payment.state === 'settled' + && receipt.execution.state === 'succeeded' + && receipt.budget?.disposition === 'committed', + execution_failed: receipt.payment.state === 'settled' + && receipt.execution.state === 'failed' + && receipt.budget?.disposition === 'committed', + execution_unknown: receipt.payment.state === 'settled' + && isUnknownExecution(receipt.execution) + && receipt.budget?.disposition === 'committed', + refund_confirmed: receipt.refund?.state === 'confirmed' + && receipt.budget?.disposition === 'released', + refund_rejected: receipt.refund?.state === 'rejected' + && receipt.budget?.disposition === 'committed', + }[reconciliation.outcome]; + requireProjection( + matchesProjection, + 'receipt reconciliation outcome contradicts its durable projection', + ); +} + +function validateRefundConsistency(receipt) { + const refund = receipt.refund; + if (refund === null) { + requireProjection( + receipt.reconciliation?.kind !== 'refund', + 'receipt refund reconciliation has no refund projection', + ); + return; + } + requireProjection( + receipt.payment.state === 'settled' + && receipt.execution.state === 'failed' + && refund.amountAtomic === receipt.payment.amountAtomic + && refund.amountAtomic === receipt.budget?.amountAtomic, + 'receipt refund does not bind the exact settled payment and failed execution', + ); + if (refund.state === 'confirmed') { + requireProjection( + receipt.revision > 1 + && receipt.outcome.status === 'refunded' + && receipt.outcome.reasonCode === 'REFUND_CONFIRMED' + && refund.transactionId !== receipt.payment.transactionId + && receipt.budget?.disposition === 'released' + && receipt.reconciliation?.kind === 'refund' + && receipt.reconciliation.outcome === 'refund_confirmed', + 'confirmed refund projection is incomplete or contradictory', + ); + return; + } + requireProjection( + receipt.outcome.status === 'execution_failed' + && receipt.budget?.disposition === 'committed', + 'unconfirmed refund must retain committed failed-execution authority', + ); + if (refund.state === 'pending') { + const attestedExecutionFailure = receipt.revision > 1 + && receipt.outcome.reasonCode === 'REFUND_UNRESOLVED' + && receipt.reconciliation?.kind === 'execution' + && receipt.reconciliation.outcome === 'execution_failed'; + requireProjection( + (['UPSTREAM_HTTP_FAILURE', 'UPSTREAM_FAILED'].includes(receipt.outcome.reasonCode) + && receipt.reconciliation === null) + || attestedExecutionFailure, + 'pending refund projection is contradictory', + ); + } else if (['unresolved', 'abandoned'].includes(refund.state)) { + requireProjection( + receipt.outcome.reasonCode === 'REFUND_UNRESOLVED' + && (receipt.reconciliation === null + || (receipt.reconciliation.kind === 'refund' + && receipt.reconciliation.outcome === 'unresolved')), + 'unresolved refund projection is contradictory', + ); + } else { + requireProjection( + refund.state === 'rejected' + && receipt.outcome.reasonCode === 'REFUND_UNRESOLVED' + && receipt.reconciliation?.kind === 'refund' + && receipt.reconciliation.outcome === 'refund_rejected', + 'rejected refund projection is contradictory', + ); + } +} + +function validateReceiptConsistency(receipt) { + const { approval, budget, execution, outcome, payment, policy, reconciliation, refund } = receipt; + if ((policy === null && (approval.state !== 'not_required' + || approval.operatorIdHash !== null || payment.state !== 'none' || budget !== null)) + || (policy?.decision === 'deny' && (approval.state !== 'not_required' + || approval.operatorIdHash !== null || payment.state !== 'none' || budget !== null)) + || (approval.state !== 'not_required' && policy?.decision !== 'approval_required') + || (['approved', 'denied', 'consumed'].includes(approval.state) + && approval.operatorIdHash === null) + || (['not_required', 'pending'].includes(approval.state) + && approval.operatorIdHash !== null) + || (payment.state !== 'none' && policy === null) + || (payment.state !== 'none' && budget !== null + && payment.amountAtomic !== budget.amountAtomic)) { + fail('RECEIPT_SCHEMA', 'receipt authority projections contradict one another'); + } + + validateReconciliationConsistency(receipt); + validateRefundConsistency(receipt); + + const noExecution = isNoExecution(execution); + const noPayment = isNoPayment(receipt); + const noAftermath = hasNoSensitiveAftermath(receipt); + const paidCommitted = hasSpendAuthority(receipt) + && payment.state === 'settled' + && budget?.disposition === 'committed' + && payment.amountAtomic === budget.amountAtomic; + const reason = outcome.reasonCode; + let matches = false; + + if (outcome.status === 'completed' && reason === 'ORDINARY_SUCCESS') { + matches = policy === null && noPayment && noAftermath + && approval.state === 'not_required' && execution.state === 'succeeded'; + } else if (outcome.status === 'upstream_failed' && reason === 'ORDINARY_HTTP_FAILURE') { + matches = policy === null && noPayment && noAftermath + && approval.state === 'not_required' && execution.state === 'failed' + && execution.httpStatus >= 400 && execution.httpStatus <= 599; + } else if (outcome.status === 'upstream_failed' && reason === 'UPSTREAM_TRANSPORT_FAILURE') { + const ordinaryTransportFailure = policy === null + && approval.state === 'not_required' && approval.operatorIdHash === null; + const approvedRetryTransportFailure = policy?.decision === 'approval_required' + && policy.reasonCode === 'HUMAN_APPROVAL_REQUIRED' + && approval.state === 'approved' && approval.operatorIdHash !== null; + matches = noPayment && noAftermath && isUnknownExecution(execution) + && (ordinaryTransportFailure || approvedRetryTransportFailure); + } else if (outcome.status === 'upstream_failed' && reason === 'RECOVERY_ABANDONED_UNSIGNED') { + matches = policy === null && noPayment && noAftermath + && approval.state === 'not_required' && noExecution; + } else if (outcome.status === 'payment_denied' && PRE_POLICY_DENIAL_REASONS.has(reason)) { + matches = policy === null && noPayment && noAftermath + && approval.state === 'not_required' && noExecution; + } else if (outcome.status === 'payment_denied' && POLICY_DENIAL_REASONS.has(reason)) { + const deniedBeforeSpend = policy?.decision === 'deny' && policy.reasonCode === reason + && approval.state === 'not_required' && approval.operatorIdHash === null + && noPayment && noAftermath && noExecution; + const expiredAfterReservation = reason === 'CHALLENGE_EXPIRED' + && hasSpendAuthority(receipt) && isReleasedUnsigned(receipt) + && noExecution && noAftermath; + matches = deniedBeforeSpend || expiredAfterReservation; + } else if (outcome.status === 'payment_denied' && reason === 'OPERATOR_DENIED') { + matches = policy?.decision === 'approval_required' + && policy.reasonCode === 'HUMAN_APPROVAL_REQUIRED' + && approval.state === 'denied' && approval.operatorIdHash !== null + && noPayment && noAftermath && noExecution; + } else if (outcome.status === 'payment_denied' && reason === 'APPROVAL_EXPIRED') { + matches = policy?.decision === 'approval_required' + && policy.reasonCode === 'HUMAN_APPROVAL_REQUIRED' + && approval.state === 'expired' + && noPayment && noAftermath && noExecution; + } else if (outcome.status === 'payment_denied' + && APPROVAL_CANCELLATION_REASONS.has(reason)) { + matches = policy?.decision === 'approval_required' + && policy.reasonCode === 'HUMAN_APPROVAL_REQUIRED' + && approval.state === 'cancelled' + && noPayment && noAftermath && noExecution; + } else if (outcome.status === 'payment_denied' + && SESSION_CANCELLATION_REASONS.has(reason)) { + matches = isSafeSessionCancellation(receipt); + } else if (outcome.status === 'payment_denied' + && ['AGENT_REVOKED', 'WALLET_RECOVERY_REQUIRED'].includes(reason)) { + matches = hasSpendAuthority(receipt) && isReleasedUnsigned(receipt) + && noExecution && noAftermath; + } else if (outcome.status === 'payment_failed' && UNSIGNED_RELEASE_REASONS.has(reason)) { + matches = hasSpendAuthority(receipt) && isReleasedUnsigned(receipt) + && noExecution && noAftermath; + } else if (outcome.status === 'payment_failed' && reason === 'RECOVERY_ABANDONED_UNSIGNED') { + const recordedPolicyBeforeSpend = isAutomaticAuthority(receipt) + || (policy?.decision === 'approval_required' + && policy.reasonCode === 'HUMAN_APPROVAL_REQUIRED' + && approval.state === 'not_required' + && approval.operatorIdHash === null); + matches = noExecution && noAftermath + && ((recordedPolicyBeforeSpend && noPayment) + || (hasSpendAuthority(receipt) && isReleasedUnsigned(receipt))); + } else if (outcome.status === 'payment_unresolved' + && PAYMENT_UNRESOLVED_REASONS.has(reason)) { + matches = hasSpendAuthority(receipt) && payment.state === 'unresolved' + && budget?.disposition === 'unresolved' && noExecution && refund === null + && (reconciliation === null + || (reconciliation.kind === 'payment' && reconciliation.outcome === 'unresolved')); + } else if (outcome.status === 'payment_rejected' + && reason === 'AUTHORIZATION_UNUSED_AFTER_EXPIRY') { + matches = hasSpendAuthority(receipt) && payment.state === 'rejected' + && budget?.disposition === 'released' && noExecution && refund === null + && reconciliation?.kind === 'payment' && reconciliation.outcome === 'rejected'; + } else if (outcome.status === 'completed' + && ['PAYMENT_SETTLED', 'EXECUTION_SUCCEEDED'].includes(reason)) { + matches = paidCommitted && execution.state === 'succeeded' && noAftermath; + } else if (outcome.status === 'completed' + && reason === 'EXECUTION_RECONCILED_SUCCEEDED') { + matches = receipt.revision > 1 && paidCommitted && execution.state === 'succeeded' + && refund === null && reconciliation?.kind === 'execution' + && reconciliation.outcome === 'execution_succeeded'; + } else if (outcome.status === 'execution_failed' + && ['UPSTREAM_HTTP_FAILURE', 'UPSTREAM_FAILED'].includes(reason)) { + matches = paidCommitted && execution.state === 'failed' + && refund?.state === 'pending' && reconciliation === null; + } else if (outcome.status === 'execution_failed' && reason === 'REFUND_UNRESOLVED') { + const attestedFailure = receipt.revision > 1 + && refund?.state === 'pending' + && reconciliation?.kind === 'execution' + && reconciliation.outcome === 'execution_failed'; + matches = paidCommitted && execution.state === 'failed' + && (['unresolved', 'abandoned', 'rejected'].includes(refund?.state) + || attestedFailure); + } else if (outcome.status === 'execution_unknown' && reason === 'PAID_RESPONSE_AMBIGUOUS') { + matches = paidCommitted && isUnknownExecution(execution) && noAftermath; + } else if (outcome.status === 'execution_unknown' + && reason === 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN') { + matches = receipt.revision > 1 && paidCommitted && isUnknownExecution(execution) + && refund === null && reconciliation?.kind === 'payment' + && reconciliation.outcome === 'settled'; + } else if (outcome.status === 'execution_unknown' + && reason === 'RECOVERY_EXECUTION_MISSING') { + matches = paidCommitted && isUnknownExecution(execution) && noAftermath; + } else if (outcome.status === 'refunded' && reason === 'REFUND_CONFIRMED') { + matches = hasSpendAuthority(receipt) && payment.state === 'settled' + && budget?.disposition === 'released' && execution.state === 'failed' + && refund?.state === 'confirmed' && reconciliation?.kind === 'refund' + && reconciliation.outcome === 'refund_confirmed'; + } + + requireProjection(matches, 'receipt outcome reason contradicts its terminal projection'); +} + +function receiptRecordFromRow(row) { + if (!row) return null; + const receipt = validateReceiptSchema(parseCanonicalJson(row.receipt_json, 'signed receipt JSON')); + const revision = positiveInteger(row.revision, 'signed receipt revision'); + const record = { + id: row.id, + intentId: row.intent_id, + revision, + receipt, + receiptHash: row.receipt_hash, + signature: row.signature, + algorithm: row.algorithm, + keyId: row.key_id, + supersedesReceiptHash: row.supersedes_receipt_hash, + createdAt: row.created_at, + }; + canonicalToken(record.id, 'signed receipt ID'); + canonicalToken(record.intentId, 'signed receipt intent ID'); + canonicalTimestamp(record.createdAt, 'signed receipt createdAt'); + if (receipt.receiptId !== record.id || receipt.intent.id !== record.intentId + || receipt.revision !== revision || receipt.issuedAt !== record.createdAt + || receipt.supersedesReceiptHash !== record.supersedesReceiptHash) { + fail('RECEIPT_CORRUPTION', 'signed receipt columns disagree with its projection'); + } + return frozenCopy(record); +} + +function buildProjection(access, { intentId, receiptId, issuedAt, supersedesReceiptHash }) { + const intent = readUnique( + access, + 'SELECT * FROM spend_intents WHERE id = ? ORDER BY rowid', + [intentId], + 'Spend Intent', + ); + const outcome = readUnique( + access, + 'SELECT * FROM buyer_outcomes WHERE intent_id = ? ORDER BY rowid', + [intentId], + 'BuyerOutcome', + ); + if (!['terminal', 'unresolved'].includes(intent.state)) { + fail('RECEIPT_NOT_TERMINAL', 'BuyerOutcome belongs to a nonterminal Spend Intent'); + } + if (!OUTCOME_STATUSES.has(outcome.status)) fail('RECEIPT_CORRUPTION', 'BuyerOutcome status is invalid'); + reasonCode(outcome.reason_code, 'BuyerOutcome reason'); + const outcomeRecordedAt = canonicalTimestamp(outcome.recorded_at, 'BuyerOutcome recordedAt'); + if (Date.parse(issuedAt) < Date.parse(outcomeRecordedAt)) { + fail('RECEIPT_TIME', 'receipt issuedAt predates its authoritative BuyerOutcome'); + } + const decision = readUnique( + access, + 'SELECT * FROM policy_decisions WHERE intent_id = ? ORDER BY rowid', + [intentId], + 'PolicyDecision', + { optional: true }, + ); + let policy = null; + if (decision) { + if (!POLICY_DECISIONS.has(decision.decision)) fail('RECEIPT_CORRUPTION', 'PolicyDecision is invalid'); + reasonCode(decision.reason_code, 'PolicyDecision reason'); + atomic(decision.amount_ceiling_atomic, 'PolicyDecision amount ceiling'); + policy = { + versionId: canonicalToken(decision.policy_version_id, 'policy version ID'), + decision: decision.decision, + reasonCode: decision.reason_code, + }; + } + const approvalRow = readUnique( + access, + 'SELECT * FROM approvals WHERE intent_id = ? ORDER BY rowid', + [intentId], + 'Approval', + { optional: true }, + ); + const approval = approvalRow + ? { + state: approvalRow.decision, + operatorIdHash: approvalRow.operator_id_hash, + } + : { state: 'not_required', operatorIdHash: null }; + if (!APPROVAL_STATES.has(approval.state) || approval.state === 'not_required' && approvalRow) { + fail('RECEIPT_CORRUPTION', 'Approval projection state is invalid'); + } + if (approval.operatorIdHash !== null) hash(approval.operatorIdHash, 'approval operator hash'); + if (['approved', 'denied', 'consumed'].includes(approval.state) + && approval.operatorIdHash === null) { + fail('RECEIPT_CORRUPTION', 'authenticated approval decision lost its operator hash'); + } + const { attempt, selected } = readSelectedPayment(access, intent, decision); + const receipt = { + schemaVersion: 1, + receiptId: canonicalToken(receiptId, 'receipt ID'), + revision: positiveInteger(outcome.revision, 'BuyerOutcome revision'), + issuedAt: canonicalTimestamp(issuedAt, 'receipt issuedAt'), + intent: { + id: canonicalToken(intent.id, 'intent ID'), + requestId: canonicalToken(intent.request_id, 'request ID'), + intentHash: hash(intent.intent_hash, 'intent hash'), + sessionId: canonicalToken(intent.session_id, 'session ID'), + sellerOrigin: canonicalOrigin(intent.seller_origin), + resourcePath: resourcePath(intent.resource_path), + purposeLabel: canonicalToken(intent.purpose_label, 'purpose label'), + }, + outcome: { status: outcome.status, reasonCode: outcome.reason_code }, + policy, + approval, + payment: projectPayment(attempt, selected, outcome.reason_code), + execution: projectExecution(access, intentId), + budget: projectBudget(access, intentId), + reconciliation: projectReconciliation(access, intentId), + refund: projectRefund(access, intentId), + supersedesReceiptHash, + }; + const validated = validateReceiptSchema(receipt); + if (validated.outcome.reasonCode === 'PAYMENT_CANDIDATE_REJECTED') { + const candidates = access.all( + `SELECT * FROM payment_reconciliation_candidates + WHERE intent_id = ? ORDER BY rowid`, + [intentId], + ); + const reconciliations = access.all( + `SELECT * FROM reconciliations WHERE intent_id = ? ORDER BY rowid`, + [intentId], + ); + const reconciliation = reconciliations.at(-1); + let rejectionEvidence = null; + try { + rejectionEvidence = JSON.parse(reconciliation?.evidence_json ?? 'null'); + if (signingCanonicalJson(rejectionEvidence) !== reconciliation?.evidence_json) { + throw new Error('non-canonical'); + } + } catch (cause) { + fail( + 'RECEIPT_CORRUPTION', + 'payment candidate rejection reconciliation is malformed', + { cause }, + ); + } + const rejectedIndex = candidates.findIndex((candidate) => ( + candidate.state === 'rejected' + && candidate.transaction_id === rejectionEvidence?.transactionId + && candidate.evidence_json === reconciliation?.evidence_json + )); + const rejected = rejectedIndex < 0 ? null : candidates[rejectedIndex]; + const laterCandidates = rejectedIndex < 0 ? [] : candidates.slice(rejectedIndex + 1); + if (validated.revision <= 1 + || validated.outcome.status !== 'payment_unresolved' + || validated.payment.state !== 'unresolved' + || validated.budget?.disposition !== 'unresolved' + || validated.reconciliation?.kind !== 'payment' + || validated.reconciliation.outcome !== 'unresolved' + || reconciliation?.kind !== 'payment' + || reconciliation.outcome !== 'unresolved' + || !rejected + || !EVM_TRANSACTION.test(rejected.transaction_id) + || candidates.slice(0, rejectedIndex).some((candidate) => ( + candidate.state === 'pending' || candidate.state === 'confirmed' + )) + || laterCandidates.some((candidate) => ( + candidate.state !== 'pending' && candidate.state !== 'abandoned' + ))) { + fail( + 'RECEIPT_CORRUPTION', + 'payment candidate rejection lacks exact immutable rejected history', + ); + } + try { + canonicalTimestamp(rejected.created_at, 'payment candidate createdAt'); + canonicalTimestamp(rejected.updated_at, 'payment candidate updatedAt'); + } catch (cause) { + fail( + 'RECEIPT_CORRUPTION', + 'payment candidate rejection history is malformed', + { cause }, + ); + } + } + return validated; +} + +export function createSignedReceiptRepository({ store, signer, idFactory, now }) { + if (!store || typeof store.transaction !== 'function' || typeof store.within !== 'function') { + throw new TypeError('signed receipt repository requires a Wallet Kernel store'); + } + if (!signer || signer.algorithm !== 'Ed25519' || typeof signer.signHash !== 'function' + || typeof signer.publicKeyPem !== 'string' || typeof signer.keyId !== 'string') { + throw new TypeError('signed receipt repository requires an Ed25519 signer'); + } + let signingPublicKey; + try { signingPublicKey = crypto.createPublicKey(signer.publicKeyPem); } catch (error) { + throw new TypeError('signed receipt repository signer public key is invalid', { cause: error }); + } + if (signingPublicKey.asymmetricKeyType !== 'ed25519' + || receiptKeyId(signingPublicKey) !== signer.keyId) { + throw new TypeError('signed receipt repository signer key ID must match its Ed25519 SPKI'); + } + if (typeof idFactory !== 'function' || typeof now !== 'function') { + throw new TypeError('signed receipt repository requires ID and clock functions'); + } + const trust = Object.freeze({ publicKeyPem: signer.publicKeyPem, keyId: signer.keyId }); + + const verify = (input) => { + try { + const record = exactRecord(input, [ + 'id', 'intentId', 'revision', 'receipt', 'receiptHash', 'signature', 'algorithm', + 'keyId', 'supersedesReceiptHash', 'createdAt', + ], [], 'RECEIPT_SCHEMA', 'signed receipt record'); + const normalized = receiptRecordFromRow({ + id: record.id, + intent_id: record.intentId, + revision: record.revision, + receipt_json: signingCanonicalJson(record.receipt), + receipt_hash: record.receiptHash, + signature: record.signature, + algorithm: record.algorithm, + key_id: record.keyId, + supersedes_receipt_hash: record.supersedesReceiptHash, + created_at: record.createdAt, + }); + return verifySignedReceipt(normalized, trust); + } catch { + return false; + } + }; + + const assertHistory = (access, intentId) => { + const rows = access.all( + 'SELECT * FROM signed_receipts WHERE intent_id = ? ORDER BY revision', + [intentId], + ); + let previousHash = null; + let previousEventSequence = 0; + for (let index = 0; index < rows.length; index += 1) { + const record = receiptRecordFromRow(rows[index]); + if (record.revision !== index + 1 + || record.supersedesReceiptHash !== previousHash + || !verify(record)) { + fail('RECEIPT_PARITY_REQUIRED', 'signed receipt history is incomplete or invalid'); + } + const event = readUnique( + access, + `SELECT * FROM events WHERE entity_type = 'signed_receipt' + AND entity_id = ? AND event_type = 'receipt.issued' ORDER BY sequence`, + [record.id], + 'signed receipt event', + ); + const eventData = exactRecord( + parseCanonicalJson(event.data_json, 'signed receipt event JSON'), + ['intentId', 'revision', 'receiptHash', 'keyId', 'supersedesReceiptHash'], + [], + 'RECEIPT_PARITY_REQUIRED', + 'signed receipt event', + ); + const eventSequence = positiveInteger(event.sequence, 'receipt event sequence'); + canonicalTimestamp(event.created_at, 'receipt event createdAt'); + if (eventData.intentId !== record.intentId + || eventData.revision !== record.revision + || eventData.receiptHash !== record.receiptHash + || eventData.keyId !== record.keyId + || eventData.supersedesReceiptHash !== record.supersedesReceiptHash + || Date.parse(event.created_at) < Date.parse(record.createdAt) + || eventSequence <= previousEventSequence) { + fail('RECEIPT_PARITY_REQUIRED', 'signed receipt event disagrees with receipt history'); + } + previousEventSequence = eventSequence; + previousHash = record.receiptHash; + } + return rows.length === 0 ? null : receiptRecordFromRow(rows.at(-1)); + }; + + const assertCurrentProjection = (access, record) => { + if (record === null) return null; + const projected = buildProjection(access, { + intentId: record.intentId, + receiptId: record.id, + issuedAt: record.createdAt, + supersedesReceiptHash: record.supersedesReceiptHash, + }); + if (signingCanonicalJson(projected) !== signingCanonicalJson(record.receipt)) { + fail('RECEIPT_PARITY_REQUIRED', 'current signed receipt disagrees with durable authority'); + } + return record; + }; + + const assertParityWithAccess = (access) => { + const outcomes = access.all('SELECT * FROM buyer_outcomes ORDER BY intent_id'); + const outcomeIds = new Set(outcomes.map((row) => row.intent_id)); + const orphans = access.all('SELECT intent_id FROM signed_receipts ORDER BY intent_id') + .filter((row) => !outcomeIds.has(row.intent_id)); + if (orphans.length > 0) fail('RECEIPT_PARITY_REQUIRED', 'signed receipt has no BuyerOutcome'); + const receiptCount = access.one('SELECT COUNT(*) AS count FROM signed_receipts').count; + const receiptEventCount = access.one(`SELECT COUNT(*) AS count FROM events + WHERE entity_type = 'signed_receipt' AND event_type = 'receipt.issued'`).count; + if (receiptCount !== receiptEventCount) { + fail('RECEIPT_PARITY_REQUIRED', 'signed receipt event history is incomplete or ambiguous'); + } + for (const outcome of outcomes) { + const latest = assertHistory(access, outcome.intent_id); + const revision = positiveInteger(outcome.revision, 'BuyerOutcome revision'); + if (!latest || latest.revision !== revision) { + fail('RECEIPT_PARITY_REQUIRED', 'BuyerOutcome is missing its current signed receipt'); + } + assertCurrentProjection(access, latest); + } + return true; + }; + + const assertRecoverableParityWithAccess = (access) => { + const outcomes = access.all('SELECT * FROM buyer_outcomes ORDER BY intent_id'); + const outcomeIds = new Set(outcomes.map((row) => row.intent_id)); + const orphans = access.all('SELECT intent_id FROM signed_receipts ORDER BY intent_id') + .filter((row) => !outcomeIds.has(row.intent_id)); + if (orphans.length > 0) fail('RECEIPT_PARITY_REQUIRED', 'signed receipt has no BuyerOutcome'); + const receiptCount = access.one('SELECT COUNT(*) AS count FROM signed_receipts').count; + const receiptEventCount = access.one(`SELECT COUNT(*) AS count FROM events + WHERE entity_type = 'signed_receipt' AND event_type = 'receipt.issued'`).count; + if (receiptCount !== receiptEventCount) { + fail('RECEIPT_PARITY_REQUIRED', 'signed receipt event history is incomplete or ambiguous'); + } + for (const outcome of outcomes) { + const latest = assertHistory(access, outcome.intent_id); + const revision = positiveInteger(outcome.revision, 'BuyerOutcome revision'); + if (latest?.revision === revision) { + assertCurrentProjection(access, latest); + continue; + } + const exactRepairableTailGap = (latest === null && revision === 1) + || (latest !== null && latest.revision === revision - 1); + if (!exactRepairableTailGap) { + fail('RECEIPT_PARITY_REQUIRED', 'missing receipt history cannot be reconstructed'); + } + } + return true; + }; + + const issueInTransaction = (token, { + intentId, + suppliedPredecessor, + initialOnly, + deferGlobalParity = false, + }) => store.within( + token, + ({ db, appendEvent }) => { + const access = transactionAccess(db); + const outcome = readUnique( + access, + 'SELECT * FROM buyer_outcomes WHERE intent_id = ? ORDER BY rowid', + [intentId], + 'BuyerOutcome', + ); + const revision = positiveInteger(outcome.revision, 'BuyerOutcome revision'); + const history = assertHistory(access, intentId); + if (initialOnly && revision !== 1) { + fail('RECEIPT_REVISION', 'initial receipt issuance requires BuyerOutcome revision 1'); + } + const existing = access.one( + 'SELECT * FROM signed_receipts WHERE intent_id = ? AND revision = ?', + [intentId, revision], + ); + if (existing) { + const record = receiptRecordFromRow(existing); + if (history?.receiptHash !== record.receiptHash + || !verify(record) || (suppliedPredecessor !== undefined + && record.supersedesReceiptHash !== suppliedPredecessor)) { + fail('RECEIPT_CONFLICT', 'existing signed receipt differs from requested revision'); + } + const projected = buildProjection(access, { + intentId, + receiptId: record.id, + issuedAt: record.createdAt, + supersedesReceiptHash: record.supersedesReceiptHash, + }); + if (signingCanonicalJson(projected) !== signingCanonicalJson(record.receipt)) { + fail('RECEIPT_CONFLICT', 'existing signed receipt no longer projects current authority'); + } + if (!deferGlobalParity) assertParityWithAccess(access); + return record; + } + + const previous = history; + const expectedPredecessor = previous?.receiptHash ?? null; + if ((previous === null && revision !== 1) + || (previous !== null && previous.revision !== revision - 1) + || (suppliedPredecessor !== undefined && suppliedPredecessor !== expectedPredecessor)) { + fail('RECEIPT_REVISION', 'receipt revision requires its exact predecessor hash'); + } + const receiptId = canonicalToken(idFactory('receipt'), 'receipt ID'); + const issuedAt = canonicalTimestamp(now(), 'receipt issuedAt'); + const receipt = buildProjection(access, { + intentId, + receiptId, + issuedAt, + supersedesReceiptHash: expectedPredecessor, + }); + const receiptJson = signingCanonicalJson(receipt); + const receiptHash = crypto.createHash('sha256').update(receiptJson).digest('hex'); + const signature = signer.signHash(receiptHash); + const candidate = frozenCopy({ + id: receiptId, + intentId, + revision, + receipt, + receiptHash, + signature, + algorithm: signer.algorithm, + keyId: signer.keyId, + supersedesReceiptHash: expectedPredecessor, + createdAt: issuedAt, + }); + if (!verify(candidate)) fail('RECEIPT_SIGNATURE', 'receipt signer returned an invalid signature'); + const inserted = db.prepare(`INSERT INTO signed_receipts + (id, intent_id, revision, receipt_json, receipt_hash, signature, algorithm, + key_id, supersedes_receipt_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + receiptId, + intentId, + revision, + receiptJson, + receiptHash, + signature, + signer.algorithm, + signer.keyId, + expectedPredecessor, + issuedAt, + ); + if (inserted.changes !== 1n) fail('RECEIPT_CONFLICT', 'receipt insert lost its race'); + appendEvent({ + entityType: 'signed_receipt', + entityId: receiptId, + eventType: 'receipt.issued', + data: { + intentId, + revision, + receiptHash, + keyId: signer.keyId, + supersedesReceiptHash: expectedPredecessor, + }, + }); + if (!deferGlobalParity) assertParityWithAccess(access); + return candidate; + }, + ); + + const issueForTerminal = (input) => { + const { intentId } = exactRecord(input, ['intentId'], [], + 'RECEIPT_INPUT', 'terminal receipt request'); + const normalizedIntentId = canonicalToken(intentId, 'intent ID'); + return store.transaction((token) => issueInTransaction(token, { + intentId: normalizedIntentId, + suppliedPredecessor: undefined, + initialOnly: true, + })); + }; + + const issueRevisionForTerminal = (input) => { + const request = exactRecord(input, ['intentId', 'supersedesReceiptHash'], [], + 'RECEIPT_INPUT', 'receipt revision request'); + const intentId = canonicalToken(request.intentId, 'intent ID'); + if (!/^[0-9a-f]{64}$/.test(request.supersedesReceiptHash)) { + fail('RECEIPT_REVISION', 'receipt revision predecessor hash is invalid'); + } + return store.transaction((token) => issueInTransaction(token, { + intentId, + suppliedPredecessor: request.supersedesReceiptHash, + initialOnly: false, + })); + }; + + const issueMissingTerminalReceipts = () => { + return store.transaction((token) => store.within(token, ({ db }) => { + const access = transactionAccess(db); + const outcomes = access.all( + 'SELECT intent_id, revision FROM buyer_outcomes ORDER BY intent_id', + ); + const issued = []; + for (const outcome of outcomes) { + const latest = assertHistory(access, outcome.intent_id); + const revision = positiveInteger(outcome.revision, 'BuyerOutcome revision'); + if (latest?.revision === revision) continue; + if (latest === null && revision === 1) { + issued.push(issueInTransaction(token, { + intentId: outcome.intent_id, + suppliedPredecessor: undefined, + initialOnly: true, + deferGlobalParity: true, + })); + } else if (latest && latest.revision === revision - 1) { + issued.push(issueInTransaction(token, { + intentId: outcome.intent_id, + suppliedPredecessor: latest.receiptHash, + initialOnly: false, + deferGlobalParity: true, + })); + } else { + fail('RECEIPT_REVISION', 'missing receipt history cannot be reconstructed'); + } + } + assertParityWithAccess(access); + return frozenCopy(issued); + })); + }; + + const assertParityInTransaction = (token) => store.within( + token, + ({ db }) => assertParityWithAccess(transactionAccess(db)), + ); + const assertRecoverableParityInTransaction = (token) => store.within( + token, + ({ db }) => assertRecoverableParityWithAccess(transactionAccess(db)), + ); + const assertParity = () => store.transaction((token) => assertParityInTransaction(token)); + + const latest = (intentId) => { + const access = dataAccess(store); + return assertCurrentProjection( + access, + assertHistory(access, canonicalToken(intentId, 'intent ID')), + ); + }; + + const list = (input) => { + const request = exactRecord(input, ['sessionId', 'limit'], [], + 'RECEIPT_INPUT', 'receipt list request'); + const sessionId = canonicalToken(request.sessionId, 'session ID'); + if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 1_000) { + fail('RECEIPT_INPUT', 'receipt list limit must be between 1 and 1000'); + } + const access = dataAccess(store); + const rows = access.all(`SELECT r.* FROM signed_receipts r + JOIN spend_intents i ON i.id = r.intent_id + WHERE i.session_id = ? + ORDER BY r.created_at DESC, r.revision DESC, r.id DESC + LIMIT ?`, [sessionId, request.limit]); + for (const intentId of new Set(rows.map((row) => row.intent_id))) { + assertCurrentProjection(access, assertHistory(access, intentId)); + } + return frozenCopy(rows.map(receiptRecordFromRow)); + }; + + return Object.freeze({ + issueForTerminal, + issueRevisionForTerminal, + issueMissingTerminalReceipts, + assertParity, + assertParityInTransaction, + assertRecoverableParityInTransaction, + latest, + list, + verify, + }); +} diff --git a/spikes/pi-wielder/src/kernel/trusted-path.mjs b/spikes/pi-wielder/src/kernel/trusted-path.mjs index f5e0080..bc481f5 100644 --- a/spikes/pi-wielder/src/kernel/trusted-path.mjs +++ b/spikes/pi-wielder/src/kernel/trusted-path.mjs @@ -4,7 +4,9 @@ import path from 'node:path'; import { canonicalJson, sha256 } from './canonical.mjs'; const MODES = new Set(['deterministic', 'cdp-testnet']); -const ROLES = new Set(['kernel-private', 'root-only']); +const KERNEL_ROLES = new Set(['kernel-private', 'root-only']); +const AGENT_ROLES = new Set(['agent-private', 'agent-handoff']); +const ROLES = new Set([...KERNEL_ROLES, ...AGENT_ROLES]); const SQLITE_SUFFIXES = new Set(['', '-wal', '-shm']); const METADATA_DOMAIN = 'wallet-kernel/trusted-parent-metadata/v1\0'; const FILE_IDENTITY_FIELDS = Object.freeze([ @@ -63,6 +65,7 @@ function pathComponents(value, label) { fail(`${label} must be a direct canonical path without dot or empty components`); } const root = path.parse(value).root; + if (value === root) return []; const components = value.slice(root.length).split(path.sep); if (components.some((component) => component === '' || component === '.' || component === '..')) { fail(`${label} must be a direct canonical path without dot or empty components`); @@ -189,13 +192,17 @@ function validatePolicy(projection, { mode, role, kernelUid, + agentUid, terminalOwnerUid, terminalMode, }) { const terminal = projection.at(-1); if (mode === 'deterministic') { const currentUid = process.getuid(); - for (const component of projection) { + const deterministicChain = KERNEL_ROLES.has(role) + ? projection + : projection.slice(0, -1); + for (const component of deterministicChain) { if (component.uid !== currentUid || component.mode !== 0o700) { fail('deterministic trusted path components must be current-UID owner-only directories'); } @@ -206,7 +213,7 @@ function validatePolicy(projection, { fail('root-only trusted path components must be root-owned and not group/other writable'); } } - } else { + } else if (role === 'kernel-private') { const ancestor = projection[0]; if (ancestor.uid !== 0 || (ancestor.mode & 0o022) !== 0) { fail('live trusted ancestor must be root-owned and not group/other writable'); @@ -217,6 +224,16 @@ function validatePolicy(projection, { fail('Kernel-private intermediate must be root/Kernel-owned and not group/other writable'); } } + } else { + const ancestor = projection[0]; + if (ancestor.uid !== 0 || (ancestor.mode & 0o022) !== 0) { + fail('live Agent trusted ancestor must be root-owned and not group/other writable'); + } + for (const component of projection.slice(1, -1)) { + if (component.uid !== 0 || (component.mode & 0o022) !== 0) { + fail('Agent path intermediate must be root-owned and not group/other writable'); + } + } } if (terminal.uid !== terminalOwnerUid) { @@ -260,7 +277,7 @@ function openDirectory(location, label) { } } -export function openTrustedParent({ +function openTrustedParentInternal({ mode, trustedAncestor, targetFile, @@ -272,8 +289,8 @@ export function openTrustedParent({ }) { assertPlatformBoundary(); if (!MODES.has(mode)) fail('trusted path mode must be deterministic or cdp-testnet'); - if (!ROLES.has(role)) fail('trusted path role must be kernel-private or root-only'); - assertUid(kernelUid, 'Kernel'); + if (!ROLES.has(role)) fail('trusted path role is outside the closed role set'); + if (KERNEL_ROLES.has(role)) assertUid(kernelUid, 'Kernel'); assertUid(agentUid, 'Agent'); assertUid(terminalOwnerUid, 'terminal owner'); assertMode(terminalMode, 'terminal'); @@ -281,19 +298,29 @@ export function openTrustedParent({ pathComponents(targetFile, 'target file'); if (mode === 'deterministic') { - if (kernelUid !== process.getuid() || agentUid !== process.getuid()) { - fail('deterministic Kernel and Agent UIDs must both equal the current UID'); + if (agentUid !== process.getuid() + || (KERNEL_ROLES.has(role) && kernelUid !== process.getuid())) { + fail('deterministic path identities must equal the current UID'); } } else { - if (kernelUid === 0 || agentUid === 0) { - fail('live Kernel and Pi UIDs must be nonzero'); - } - if (kernelUid === agentUid) fail('live Kernel and Pi UIDs must be distinct'); - if (role === 'kernel-private' && terminalOwnerUid !== kernelUid) { - fail('Kernel-private terminal owner must be the Kernel UID'); - } - if (role === 'root-only' && terminalOwnerUid !== 0) { - fail('root-only terminal owner must be root'); + if (KERNEL_ROLES.has(role)) { + if (kernelUid === 0 || agentUid === 0) { + fail('live Kernel and Pi UIDs must be nonzero'); + } + if (kernelUid === agentUid) fail('live Kernel and Pi UIDs must be distinct'); + if (role === 'kernel-private' && terminalOwnerUid !== kernelUid) { + fail('Kernel-private terminal owner must be the Kernel UID'); + } + if (role === 'root-only' && terminalOwnerUid !== 0) { + fail('root-only terminal owner must be root'); + } + } else { + if (agentUid === 0 || process.getuid() !== agentUid) { + fail('live Agent path must run as the configured non-root Agent UID'); + } + if (terminalOwnerUid !== agentUid) { + fail('Agent path terminal owner must be the Agent UID'); + } } if (process.platform !== 'linux') { fail('cdp-testnet trusted paths require Linux'); @@ -334,6 +361,7 @@ export function openTrustedParent({ mode, role, kernelUid, + agentUid, terminalOwnerUid, terminalMode, }); @@ -643,3 +671,23 @@ export function openTrustedParent({ throw error; } } + +export function openTrustedParent(options) { + if (!options || typeof options !== 'object' || !KERNEL_ROLES.has(options.role)) { + fail('Kernel trusted parent requires a kernel-private or root-only role'); + } + return openTrustedParentInternal(options); +} + +export function openAgentTrustedParent(options) { + if (!options || typeof options !== 'object' || !AGENT_ROLES.has(options.role) + || Object.hasOwn(options, 'kernelUid')) { + fail('Agent trusted parent requires a scoped Agent role without Kernel identity'); + } + const requiredMode = options.role === 'agent-private' ? 0o700 : 0o755; + if (options.terminalMode !== requiredMode + || options.terminalOwnerUid !== options.agentUid) { + fail('Agent trusted parent role requires its exact terminal owner and mode'); + } + return openTrustedParentInternal(options); +} diff --git a/spikes/pi-wielder/src/kernel/wallet-kernel.mjs b/spikes/pi-wielder/src/kernel/wallet-kernel.mjs new file mode 100644 index 0000000..9ddfa45 --- /dev/null +++ b/spikes/pi-wielder/src/kernel/wallet-kernel.mjs @@ -0,0 +1,2253 @@ +import { types as utilTypes } from 'node:util'; + +import { + canonicalJson, + canonicalToken, + canonicalTimestamp, + exactRecord, + KernelError, + sha256, +} from './canonical.mjs'; +import { deriveAuthorizationWindow } from './authorized-permit.mjs'; +import { WalletSigningError } from '../adapters/wallet-adapter-contract.mjs'; +import { + evaluateSpendPolicy, + projectPaymentRequired, +} from './policy-engine.mjs'; + +const DEPENDENCY_NAMES = Object.freeze([ + 'store', + 'policies', + 'enrollments', + 'intents', + 'budgets', + 'approvals', + 'receipts', + 'permitAuthority', + 'walletAdapter', + 'transport', + 'authorityMutationCoordinator', + 'markAuthorityUnhealthy', + 'now', + 'idFactory', + 'randomBytes', + 'faultInjector', +]); + +const FUNCTION_DEPENDENCIES = new Set([ + 'markAuthorityUnhealthy', + 'now', + 'idFactory', + 'randomBytes', + 'faultInjector', +]); + +export const KERNEL_FAULT_POINTS = Object.freeze([ + 'after_intent_commit', + 'after_challenge_commit', + 'after_reservation_commit', + 'after_signing_claim_commit', + 'after_signer_return', + 'after_signed_payment_commit', + 'after_retry_claim_commit', + 'after_paid_response', + 'after_settlement_commit', + 'before_terminal_receipt_commit', +]); + +function readDependencies(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError('Wallet Kernel dependencies must be one plain object'); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== DEPENDENCY_NAMES.length + || keys.some((key) => typeof key !== 'string' || !DEPENDENCY_NAMES.includes(key))) { + throw new TypeError('Wallet Kernel dependencies have an invalid shape'); + } + const dependencies = {}; + for (const name of DEPENDENCY_NAMES) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError(`Wallet Kernel dependency ${name} must be an enumerable data property`); + } + const dependency = descriptor.value; + if (FUNCTION_DEPENDENCIES.has(name)) { + if (typeof dependency !== 'function' || utilTypes.isProxy(dependency)) { + throw new TypeError(`Wallet Kernel dependency ${name} must be a non-proxy function`); + } + } else if (!dependency || typeof dependency !== 'object' || utilTypes.isProxy(dependency)) { + throw new TypeError(`Wallet Kernel dependency ${name} must be a non-proxy object`); + } + dependencies[name] = dependency; + } + return Object.freeze(dependencies); +} + +function canonicalHash(value, code, label) { + if (typeof value !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value)) { + throw new KernelError(code, `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function shallowRecord(value, required, optional, code, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new KernelError(code, `${label} must be one plain object`); + } + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(value); + if (required.some((key) => !Object.hasOwn(value, key)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key))) { + throw new KernelError(code, `${label} fields do not match the closed schema`); + } + const result = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new KernelError(code, `${label} fields must be enumerable data properties`); + } + result[key] = descriptor.value; + } + return Object.freeze(result); +} + +function isNonceCollision(error) { + return error?.code === 'ERR_SQLITE_ERROR' + && error?.errcode === 2067 + && error?.message === 'UNIQUE constraint failed: payment_attempts.nonce'; +} + +function challengeDenialReason(error) { + if (error?.code === 'PAYMENT_REQUIRED_TOO_LARGE') { + return 'PAYMENT_CHALLENGE_OVERSIZED'; + } + if (error?.code === 'CHALLENGE_SCHEMA' + || (typeof error?.code === 'string' && error.code.startsWith('PAYMENT_REQUIRED_'))) { + return 'PAYMENT_CHALLENGE_MALFORMED'; + } + return null; +} + +export function createWalletKernel(value) { + const dependencies = readDependencies(value); + const { + policies, + enrollments, + intents, + budgets, + approvals, + receipts, + store, + transport, + walletAdapter, + permitAuthority, + authorityMutationCoordinator, + markAuthorityUnhealthy, + now, + idFactory, + randomBytes, + faultInjector, + } = dependencies; + const inFlightByIntent = new Map(); + let agentSessionUnavailable = false; + + const runMutation = (operation) => authorityMutationCoordinator.runExclusive(operation); + + const assertAgentSessionAvailable = () => { + if (agentSessionUnavailable) { + throw new KernelError( + 'AGENT_SESSION_UNAVAILABLE', + 'this Kernel instance closed its agent session and cannot hot-rotate or reopen it', + ); + } + }; + + const openOrResumeSession = async (input) => { + const request = exactRecord(input, [ + 'agentInstanceId', + 'walletAddress', + 'policyVersionId', + ], [], 'SESSION_SCHEMA', 'open session request'); + return await runMutation(() => { + assertAgentSessionAvailable(); + return intents.openOrResumeSession(request); + }); + }; + + const applyPolicy = async (input) => { + const request = exactRecord(input, [ + 'document', + 'expectedPolicyHash', + ], [], 'POLICY_APPLY_SCHEMA', 'policy apply request'); + const expectedPolicyHash = canonicalHash( + request.expectedPolicyHash, + 'POLICY_APPLY_SCHEMA', + 'expected policy hash', + ); + let actualPolicyHash; + try { + actualPolicyHash = sha256(canonicalJson(request.document)); + } catch (cause) { + throw new KernelError( + 'POLICY_APPLY_SCHEMA', + 'policy document must be inert canonical data', + { cause }, + ); + } + if (actualPolicyHash !== expectedPolicyHash) { + throw new KernelError( + 'POLICY_CONFIRMATION_STALE', + 'displayed policy hash differs from the submitted document', + ); + } + assertAgentSessionAvailable(); + const configuredWallet = await walletAdapter.walletIdentity(); + return await runMutation(() => { + assertAgentSessionAvailable(); + if (configuredWallet?.address !== request.document.wallet) { + throw new KernelError( + 'WALLET_ROTATION_REQUIRES_OFFLINE_RESTART', + 'policy wallet differs from the wallet configured for this Kernel instance', + ); + } + return policies.apply(request.document, now()); + }); + }; + + const revokeAgent = async (input) => { + const request = exactRecord(input, [ + 'agentInstanceId', + 'expectedEnrollmentHash', + 'operatorIdHash', + ], [], 'AGENT_REVOCATION_SCHEMA', 'agent revocation'); + canonicalHash( + request.expectedEnrollmentHash, + 'AGENT_REVOCATION_SCHEMA', + 'expected enrollment hash', + ); + canonicalHash(request.operatorIdHash, 'AGENT_REVOCATION_SCHEMA', 'operator ID hash'); + return await runMutation(() => enrollments.revoke(request)); + }; + + const approvePending = async (input) => { + const request = exactRecord(input, [ + 'approvalId', + 'expectedIntentHash', + 'operatorIdHash', + ], [], 'APPROVAL_DECISION_SCHEMA', 'approval decision'); + canonicalHash( + request.expectedIntentHash, + 'APPROVAL_DECISION_SCHEMA', + 'expected intent hash', + ); + canonicalHash(request.operatorIdHash, 'APPROVAL_DECISION_SCHEMA', 'operator ID hash'); + const expired = await expireDueApprovals({ limit: 1_000 }); + if (expired.some((entry) => entry.approvalId === request.approvalId)) { + throw new KernelError( + 'APPROVAL_STATE_CONFLICT', + 'approval expired before the operator decision', + ); + } + return await runMutation(() => approvals.approve(request)); + }; + + const denyPending = async (input) => { + const request = exactRecord(input, [ + 'approvalId', + 'expectedIntentHash', + 'operatorIdHash', + 'reasonCode', + ], [], 'APPROVAL_DECISION_SCHEMA', 'approval denial'); + canonicalHash( + request.expectedIntentHash, + 'APPROVAL_DECISION_SCHEMA', + 'expected intent hash', + ); + canonicalHash(request.operatorIdHash, 'APPROVAL_DECISION_SCHEMA', 'operator ID hash'); + if (request.reasonCode !== 'OPERATOR_DENIED') { + throw new KernelError( + 'APPROVAL_DENIAL_REASON', + 'operator denial reason must be OPERATOR_DENIED', + ); + } + const expired = await expireDueApprovals({ limit: 1_000 }); + if (expired.some((entry) => entry.approvalId === request.approvalId)) { + throw new KernelError( + 'APPROVAL_STATE_CONFLICT', + 'approval expired before the operator decision', + ); + } + const approval = approvals.get(request.approvalId); + if (!approval || approval.intentHash !== request.expectedIntentHash) { + throw new KernelError('APPROVAL_BINDING_MISMATCH', 'displayed approval binding is stale'); + } + const intent = intents.getIntent(approval.intentId); + if (intent === null) { + throw new KernelError('INTENT_UNKNOWN', 'approval Spend Intent does not exist'); + } + return await runMutation(() => { + const recordedAt = canonicalTimestamp(now(), 'approval denial outcome recordedAt'); + store.transaction((token) => { + receipts.assertParityInTransaction(token); + approvals.denyForIntentInTransaction(token, { + approvalId: approval.approvalId, + intentId: approval.intentId, + expectedIntentHash: request.expectedIntentHash, + operatorIdHash: request.operatorIdHash, + reasonCode: request.reasonCode, + }); + intents.transitionInTransaction(token, { + intentId: approval.intentId, + expectedState: 'approval_pending', + nextState: 'terminal', + reasonCode: request.reasonCode, + }); + store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_denied', ?, 1, ?)`).run( + approval.intentId, + request.reasonCode, + recordedAt, + ); + appendEvent({ + entityType: 'buyer_outcome', + entityId: approval.intentId, + eventType: 'buyer_outcome.recorded', + data: { + status: 'payment_denied', + reasonCode: request.reasonCode, + revision: 1, + recordedAt, + }, + }); + }); + }); + const receipt = issueTerminalReceipt(approval.intentId); + return Object.freeze({ + requestId: intent.requestId, + approvalId: approval.approvalId, + intentId: approval.intentId, + status: 'payment_denied', + reasonCode: request.reasonCode, + receipt, + }); + }); + }; + + const issueTerminalReceipt = (intentId) => { + faultInjector('before_terminal_receipt_commit', Object.freeze({ intentId })); + try { + return receipts.issueForTerminal({ intentId }); + } catch (error) { + try { + markAuthorityUnhealthy('RECEIPT_PARITY_REQUIRED'); + } catch (markError) { + throw new KernelError( + 'RECEIPT_PARITY_REQUIRED', + 'terminal receipt failed and the authority fail-stop hook also failed', + { cause: markError }, + ); + } + throw error; + } + }; + + const failStopAfterSignerReturn = (error) => { + try { + markAuthorityUnhealthy('AUTHORITY_UNHEALTHY'); + } catch (cause) { + throw new KernelError( + 'AUTHORITY_UNHEALTHY', + 'post-signer authority failed and the authority fail-stop hook also failed', + { cause }, + ); + } + throw error; + }; + + const expireApprovalCandidate = async (candidate, sweepAt) => await runMutation(() => { + const recordedAt = canonicalTimestamp(now(), 'approval expiry outcome recordedAt'); + let expired; + store.transaction((token) => { + receipts.assertParityInTransaction(token); + expired = approvals.expireForIntentInTransaction(token, { + approvalId: candidate.approvalId, + intentId: candidate.intentId, + expectedIntentHash: candidate.intentHash, + at: sweepAt, + }); + if (expired === null) return; + intents.transitionInTransaction(token, { + intentId: candidate.intentId, + expectedState: 'approval_pending', + nextState: 'terminal', + reasonCode: 'APPROVAL_EXPIRED', + }); + store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_denied', 'APPROVAL_EXPIRED', 1, ?)`).run( + candidate.intentId, + recordedAt, + ); + appendEvent({ + entityType: 'buyer_outcome', + entityId: candidate.intentId, + eventType: 'buyer_outcome.recorded', + data: { + status: 'payment_denied', + reasonCode: 'APPROVAL_EXPIRED', + revision: 1, + recordedAt, + }, + }); + }); + }); + if (expired === null) return null; + const intent = intents.getIntent(candidate.intentId); + const receipt = issueTerminalReceipt(candidate.intentId); + return Object.freeze({ + requestId: intent.requestId, + approvalId: candidate.approvalId, + intentId: candidate.intentId, + status: 'payment_denied', + reasonCode: 'APPROVAL_EXPIRED', + receipt, + }); + }); + + const expireDueApprovals = async (input) => { + const request = exactRecord( + input, + ['limit'], + [], + 'APPROVAL_EXPIRY_SCHEMA', + 'approval expiry sweep', + ); + if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 1_000) { + throw new KernelError( + 'APPROVAL_EXPIRY_SCHEMA', + 'approval expiry sweep limit must be between 1 and 1000', + ); + } + const sweepAt = canonicalTimestamp(now(), 'approval expiry sweep time'); + const due = approvals.listDue({ at: sweepAt, limit: request.limit }); + const results = []; + for (const candidate of due) { + const result = await expireApprovalCandidate(candidate, sweepAt); + if (result !== null) results.push(result); + } + return Object.freeze(results); + }; + + const terminalizeOrdinary = async (intent, result) => await runMutation(() => { + const recordedAt = canonicalTimestamp(now(), 'ordinary outcome recordedAt'); + store.transaction((token) => { + receipts.assertParityInTransaction(token); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: result.expectedState ?? 'captured', + nextState: 'terminal', + reasonCode: result.reasonCode, + }); + return store.within(token, ({ db, appendEvent }) => { + const responseHash = result.body === null ? null : sha256(result.body); + const metadataJson = canonicalJson({ reasonCode: result.reasonCode }); + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, ?, ?, ?, ?, ?)`).run( + intent.id, + result.executionState, + result.upstreamStatus, + responseHash, + metadataJson, + recordedAt, + ); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, ?, ?, 1, ?)`).run( + intent.id, + result.status, + result.reasonCode, + recordedAt, + ); + appendEvent({ + entityType: 'execution_outcome', + entityId: intent.id, + eventType: 'execution.recorded', + data: { + state: result.executionState, + httpStatus: result.upstreamStatus, + responseHash, + metadataHash: sha256(metadataJson), + reasonCode: result.reasonCode, + recordedAt, + }, + }); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.recorded', + data: { + status: result.status, + reasonCode: result.reasonCode, + revision: 1, + recordedAt, + }, + }); + }); + }); + const receipt = issueTerminalReceipt(intent.id); + return Object.freeze({ + requestId: intent.requestId, + status: result.status, + reasonCode: result.reasonCode, + upstreamStatus: result.upstreamStatus, + body: result.body === null ? null : Buffer.from(result.body), + receipt, + }); + }); + + const terminalizeWithoutExecution = async (intent, { + expectedState, + status, + reasonCode, + }) => await runMutation(() => { + const recordedAt = canonicalTimestamp(now(), 'terminal outcome recordedAt'); + store.transaction((token) => { + receipts.assertParityInTransaction(token); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState, + nextState: 'terminal', + reasonCode, + }); + return store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, ?, ?, 1, ?)`).run(intent.id, status, reasonCode, recordedAt); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.recorded', + data: { status, reasonCode, revision: 1, recordedAt }, + }); + }); + }); + const receipt = issueTerminalReceipt(intent.id); + return Object.freeze({ + requestId: intent.requestId, + status, + reasonCode, + receipt, + }); + }); + + const evaluateChallenge = async ({ intent, request, paymentRequired, persist = true }) => { + const challengeReceivedAt = canonicalTimestamp(now(), 'challenge receivedAt'); + const session = intents.getSession(intent.sessionId); + const policyVersion = policies.get(session.policyVersionId); + if (!policyVersion) { + throw new KernelError('POLICY_VERSION_MISSING', 'Spend Session PolicyVersion is missing'); + } + const wallet = await walletAdapter.walletIdentity(); + const budget = budgets.snapshot({ + sessionId: intent.sessionId, + sellerOrigin: intent.sellerOrigin, + at: challengeReceivedAt, + }); + const pendingApprovalCount = approvals.list({ limit: 1_000, state: 'pending' }).length; + const evaluation = evaluateSpendPolicy({ + policy: policyVersion.policy, + policyVersion: { id: policyVersion.id, hash: policyVersion.hash }, + intent: { + id: intent.id, + method: intent.method, + requestUrl: request.requestUrl, + sellerOrigin: intent.sellerOrigin, + resourcePath: intent.resourcePath, + walletAddress: intent.walletAddress, + }, + wallet, + paymentRequired, + challengeReceivedAtMs: Date.parse(challengeReceivedAt), + nowMs: Date.parse(challengeReceivedAt), + budgetSnapshot: { + sellerSessionExposureAtomic: budget.sellerSessionExposureAtomic, + sessionExposureAtomic: budget.sessionExposureAtomic, + rolling24hExposureAtomic: budget.rolling24hExposureAtomic, + pendingApprovalCount, + }, + }); + if (persist) { + await runMutation(() => store.transaction((token) => { + receipts.assertParityInTransaction(token); + const challenged = intents.attachChallengeInTransaction(token, { + intentId: intent.id, + paymentRequired, + challengeReceivedAt, + }); + policies.recordDecisionInTransaction(token, { + intentId: intent.id, + policyVersionId: policyVersion.id, + evaluation, + decidedAt: challenged.updatedAt, + }); + })); + faultInjector('after_challenge_commit', Object.freeze({ intentId: intent.id })); + } + return Object.freeze({ challengeReceivedAt, evaluation, policyVersion, wallet }); + }; + + const approvalRequiredResult = (intent, approval) => Object.freeze({ + requestId: intent.requestId, + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, + }); + + const requestApproval = async (intent, evaluation, policyVersionId) => { + await expireDueApprovals({ limit: 1_000 }); + const approval = await runMutation(() => store.transaction((token) => { + receipts.assertParityInTransaction(token); + const created = approvals.requestInTransaction(token, { + intentId: intent.id, + intentHash: intent.intentHash, + challengeHash: evaluation.challengeHash, + quoteId: evaluation.quoteId, + amountCeilingAtomic: evaluation.amountCeilingAtomic, + walletAddress: intent.walletAddress, + policyVersionId, + acceptedIndex: evaluation.acceptedIndex, + }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'challenged', + nextState: 'approval_pending', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + }); + return created; + })); + return approvalRequiredResult(intent, approval); + }; + + const reserveAutomatic = async ({ intent, evaluation, policyVersion }) => { + const challenged = intents.getIntent(intent.id); + await runMutation(() => store.transaction((token) => { + receipts.assertParityInTransaction(token); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'challenged', + nextState: 'authorized', + reasonCode: 'POLICY_ALLOWED', + }); + budgets.reserveInTransaction(token, { + intentId: intent.id, + amountAtomic: evaluation.amountCeilingAtomic, + }); + store.within(token, ({ db, appendEvent }) => { + const createdAt = canonicalTimestamp(now(), 'PaymentAttempt createdAt'); + const attemptId = idFactory('payment'); + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, created_at, updated_at) + VALUES (?, ?, 'reserved', ?, ?, ?, ?, ?)`).run( + attemptId, + intent.id, + challenged.challengeProjectionJson, + evaluation.acceptedIndex, + evaluation.quoteId, + createdAt, + createdAt, + ); + appendEvent({ + entityType: 'payment_attempt', + entityId: attemptId, + eventType: 'payment.reserved', + data: { + intentId: intent.id, + policyVersionId: policyVersion.id, + quoteId: evaluation.quoteId, + createdAt, + }, + }); + }); + // Reload active session and wallet authority in the same transaction. Any + // failure here rolls back the payment-attempt claim above before signing. + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'authorized', + nextState: 'reserved', + reasonCode: 'BUDGET_RESERVED', + }); + })); + faultInjector('after_reservation_commit', Object.freeze({ intentId: intent.id })); + }; + + const reserveApproved = async ({ intent, evaluation, policyVersion, approval }) => { + await runMutation(() => store.transaction((token) => { + receipts.assertParityInTransaction(token); + const consumed = approvals.consumeForInTransaction(token, { + intentId: intent.id, + intentHash: intent.intentHash, + challengeHash: evaluation.challengeHash, + quoteId: evaluation.quoteId, + amountCeilingAtomic: evaluation.amountCeilingAtomic, + walletAddress: intent.walletAddress, + policyVersionId: policyVersion.id, + acceptedIndex: evaluation.acceptedIndex, + expiresAt: approval.expiresAt, + }); + if (!consumed) { + throw new KernelError('APPROVAL_EXPIRED', 'approved spend authority is no longer usable'); + } + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'approval_pending', + nextState: 'authorized', + reasonCode: 'APPROVAL_CONSUMED', + }); + budgets.reserveInTransaction(token, { + intentId: intent.id, + amountAtomic: evaluation.amountCeilingAtomic, + }); + store.within(token, ({ db, appendEvent }) => { + const createdAt = canonicalTimestamp(now(), 'PaymentAttempt createdAt'); + const attemptId = idFactory('payment'); + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, created_at, updated_at) + VALUES (?, ?, 'reserved', ?, ?, ?, ?, ?)`).run( + attemptId, + intent.id, + intent.challengeProjectionJson, + evaluation.acceptedIndex, + evaluation.quoteId, + createdAt, + createdAt, + ); + appendEvent({ + entityType: 'payment_attempt', + entityId: attemptId, + eventType: 'payment.reserved', + data: { + intentId: intent.id, + policyVersionId: policyVersion.id, + quoteId: evaluation.quoteId, + createdAt, + }, + }); + }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'authorized', + nextState: 'reserved', + reasonCode: 'BUDGET_RESERVED', + }); + })); + faultInjector('after_reservation_commit', Object.freeze({ intentId: intent.id })); + }; + + const signingClaim = async ({ + intent, + request, + paymentRequired, + evaluation, + policyVersion, + approvalExpiresAt = null, + }) => { + const claimed = await runMutation(() => store.transaction((token) => { + receipts.assertParityInTransaction(token); + const at = canonicalTimestamp(now(), 'signing claimedAt'); + const snapshot = budgets.snapshotInTransaction(token, { + sessionId: intent.sessionId, + sellerOrigin: intent.sellerOrigin, + at, + }); + if (snapshot.walletBlocked) { + throw new KernelError( + 'WALLET_RECOVERY_REQUIRED', + 'wallet recovery must complete before a signing claim', + ); + } + const selected = paymentRequired.accepts[evaluation.acceptedIndex]; + const atMs = Date.parse(at); + const challengeDeadlineMs = Date.parse(intent.challengeReceivedAt) + + policyVersion.policy.challengeMaxAgeMs; + const approvalDeadlineMs = approvalExpiresAt === null + ? Number.POSITIVE_INFINITY + : Date.parse(approvalExpiresAt); + const authorizationDeadlineMs = Math.min(challengeDeadlineMs, approvalDeadlineMs); + if (!Number.isSafeInteger(challengeDeadlineMs) + || !Number.isFinite(atMs) + || !Number.isFinite(authorizationDeadlineMs) + || Math.floor(authorizationDeadlineMs / 1_000) <= Math.floor(atMs / 1_000)) { + throw new KernelError( + 'CHALLENGE_EXPIRED', + 'challenge authorization window expired before the signing claim', + ); + } + const window = deriveAuthorizationWindow({ + nowMs: atMs, + challengeReceivedAtMs: Date.parse(intent.challengeReceivedAt), + challengeMaxAgeMs: policyVersion.policy.challengeMaxAgeMs, + maxTimeoutSeconds: selected.maxTimeoutSeconds, + approvalExpiresAt, + randomBytes, + }); + store.within(token, ({ db, appendEvent }) => { + const changed = db.prepare(`UPDATE payment_attempts + SET state = 'signing', nonce = ?, valid_after = ?, valid_before = ?, + signing_claimed_at = ?, updated_at = ? + WHERE intent_id = ? AND state = 'reserved' + AND nonce IS NULL AND valid_after IS NULL AND valid_before IS NULL + AND signing_claimed_at IS NULL`).run( + window.nonce, + window.validAfter, + window.validBefore, + at, + at, + intent.id, + ); + if (changed.changes !== 1n) { + throw new KernelError('PAYMENT_ATTEMPT_STATE', 'signing claim lost its race'); + } + appendEvent({ + entityType: 'payment_attempt', + entityId: intent.id, + eventType: 'payment.signing_claimed', + data: { ...window, signingClaimedAt: at }, + }); + }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'reserved', + nextState: 'signing', + reasonCode: 'SIGNING_CLAIMED', + }); + return Object.freeze({ + intentId: intent.id, + intentHash: intent.intentHash, + challengeHash: intent.challengeHash, + quoteId: evaluation.quoteId, + acceptedIndex: evaluation.acceptedIndex, + requestUrl: request.requestUrl, + resourceDescription: paymentRequired.resource.description, + resourceMimeType: paymentRequired.resource.mimeType, + scheme: selected.scheme, + network: selected.network, + asset: selected.asset, + walletAddress: intent.walletAddress, + payTo: selected.payTo, + amountAtomic: evaluation.amountCeilingAtomic, + validAfter: window.validAfter, + validBefore: window.validBefore, + nonce: window.nonce, + policyVersionId: policyVersion.id, + }); + })); + faultInjector('after_signing_claim_commit', Object.freeze({ intentId: intent.id })); + return claimed; + }; + + const assertSignedPayload = (paymentPayload, binding, paymentRequired) => { + const payment = shallowRecord(paymentPayload, [ + 'x402Version', + 'resource', + 'accepted', + 'payload', + ], [], 'WALLET_PAYMENT_PAYLOAD', 'signed payment'); + const payload = shallowRecord(payment.payload, [ + 'signature', + 'authorization', + ], [], 'WALLET_PAYMENT_PAYLOAD', 'signed payment payload'); + const authorization = exactRecord(payload.authorization, [ + 'from', + 'to', + 'value', + 'validAfter', + 'validBefore', + 'nonce', + ], [], 'WALLET_PAYMENT_PAYLOAD', 'signed authorization'); + if (payment.x402Version !== 2 + || canonicalJson(payment.resource) !== canonicalJson(paymentRequired.resource) + || canonicalJson(payment.accepted) + !== canonicalJson(paymentRequired.accepts[binding.acceptedIndex]) + || typeof payload.signature !== 'string' + || !/^0x[0-9a-fA-F]{130}$/.test(payload.signature) + || authorization.from !== binding.walletAddress + || authorization.to !== binding.payTo + || authorization.value !== binding.amountAtomic + || authorization.validAfter !== binding.validAfter + || authorization.validBefore !== binding.validBefore + || authorization.nonce !== binding.nonce) { + throw new KernelError( + 'WALLET_PAYMENT_PAYLOAD', + 'signed payment differs from its durable authorization claim', + ); + } + canonicalJson(paymentPayload); + return paymentPayload; + }; + + const persistSignedPayment = async ({ intentId, paymentPayload, paymentHeader }) => { + if (typeof paymentHeader !== 'string' || paymentHeader.length === 0 + || Buffer.byteLength(paymentHeader, 'ascii') > 16_384 + || /[^\x20-\x7e]/.test(paymentHeader)) { + throw new KernelError('PAYMENT_HEADER_SCHEMA', 'encoded payment header is invalid'); + } + const paymentPayloadJson = canonicalJson(paymentPayload); + const paymentHash = sha256(Buffer.from(paymentHeader, 'ascii')); + await runMutation(() => store.transaction((token) => { + receipts.assertParityInTransaction(token); + const signedAt = canonicalTimestamp(now(), 'payment signedAt'); + store.within(token, ({ db, appendEvent }) => { + const changed = db.prepare(`UPDATE payment_attempts + SET state = 'signed', payment_payload_json = ?, payment_header = ?, + payment_hash = ?, signed_at = ?, updated_at = ? + WHERE intent_id = ? AND state = 'signing' + AND nonce IS NOT NULL AND valid_after IS NOT NULL AND valid_before IS NOT NULL + AND signing_claimed_at IS NOT NULL AND payment_payload_json IS NULL + AND payment_header IS NULL AND payment_hash IS NULL AND signed_at IS NULL`).run( + paymentPayloadJson, + paymentHeader, + paymentHash, + signedAt, + signedAt, + intentId, + ); + if (changed.changes !== 1n) { + throw new KernelError('PAYMENT_ATTEMPT_STATE', 'signed payment persistence lost its race'); + } + appendEvent({ + entityType: 'payment_attempt', + entityId: intentId, + eventType: 'payment.signed', + data: { paymentHash, signedAt }, + }); + }); + intents.transitionInTransaction(token, { + intentId, + expectedState: 'signing', + nextState: 'signed', + reasonCode: 'PAYMENT_SIGNED', + }); + })); + faultInjector('after_signed_payment_commit', Object.freeze({ intentId })); + return paymentHash; + }; + + const claimPaidRetry = async (intentId) => { + await runMutation(() => store.transaction((token) => { + receipts.assertParityInTransaction(token); + const retryStartedAt = canonicalTimestamp(now(), 'paid retry startedAt'); + store.within(token, ({ db, appendEvent }) => { + const changed = db.prepare(`UPDATE payment_attempts + SET state = 'retrying', retry_started_at = ?, updated_at = ? + WHERE intent_id = ? AND state = 'signed' + AND payment_payload_json IS NOT NULL AND payment_header IS NOT NULL + AND payment_hash IS NOT NULL AND signed_at IS NOT NULL + AND retry_started_at IS NULL`).run(retryStartedAt, retryStartedAt, intentId); + if (changed.changes !== 1n) { + throw new KernelError('PAYMENT_ATTEMPT_STATE', 'paid retry claim lost its race'); + } + appendEvent({ + entityType: 'payment_attempt', + entityId: intentId, + eventType: 'payment.retrying', + data: { retryStartedAt }, + }); + }); + intents.transitionInTransaction(token, { + intentId, + expectedState: 'signed', + nextState: 'retrying', + reasonCode: 'PAID_RETRY_STARTED', + }); + })); + faultInjector('after_retry_claim_commit', Object.freeze({ intentId })); + }; + + const settleExecution = async ({ intent, paid }) => await runMutation(() => { + let status; + let reasonCode; + if (paid.executionState === 'succeeded') { + status = 'completed'; + reasonCode = 'PAYMENT_SETTLED'; + } else if (paid.executionState === 'failed') { + status = 'execution_failed'; + reasonCode = 'UPSTREAM_HTTP_FAILURE'; + } else { + status = 'execution_unknown'; + reasonCode = 'PAID_RESPONSE_AMBIGUOUS'; + } + store.transaction((token) => { + receipts.assertParityInTransaction(token); + const committed = budgets.commitInTransaction(token, { + intentId: intent.id, + settlementEvidence: paid.settlement, + }); + const recordedAt = canonicalTimestamp( + committed.committedAt, + 'settled execution recordedAt', + ); + store.within(token, ({ db, appendEvent }) => { + const responseHash = paid.body === null ? null : sha256(paid.body); + const metadataJson = canonicalJson({ + ...(Object.hasOwn(paid, 'deliveryReason') + ? { deliveryReason: paid.deliveryReason } + : {}), + reasonCode, + }); + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, ?, ?, ?, ?, ?)`).run( + intent.id, + paid.executionState, + paid.status, + responseHash, + metadataJson, + recordedAt, + ); + if (paid.executionState === 'failed') { + const amountAtomic = db.prepare(`SELECT amount_ceiling_atomic + FROM policy_decisions WHERE intent_id = ?`).get(intent.id)?.amount_ceiling_atomic; + if (typeof amountAtomic !== 'string') { + throw new KernelError( + 'POLICY_DECISION_MISSING', + 'failed execution lost its committed amount authority', + ); + } + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at, resolved_at) + VALUES (?, 'refund_pending', ?, 1, ?, NULL)`).run( + intent.id, + reasonCode, + recordedAt, + ); + appendEvent({ + entityType: 'execution_resolution', + entityId: intent.id, + eventType: 'execution_resolution.opened', + data: { + intentId: intent.id, + state: 'refund_pending', + reasonCode, + blocksWallet: true, + openedAt: recordedAt, + }, + }); + const refundId = idFactory('refund'); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + evidence_json, refund_transaction_id, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', NULL, NULL, ?, ?)`).run( + refundId, + intent.id, + paid.settlement.transaction, + amountAtomic, + recordedAt, + recordedAt, + ); + appendEvent({ + entityType: 'refund', + entityId: refundId, + eventType: 'refund.opened', + data: { + refundId, + intentId: intent.id, + originalTransactionId: paid.settlement.transaction, + amountAtomic, + state: 'pending', + createdAt: recordedAt, + }, + }); + } else if (paid.executionState === 'unknown') { + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at, resolved_at) + VALUES (?, 'reconciliation_required', ?, 1, ?, NULL)`).run( + intent.id, + reasonCode, + recordedAt, + ); + appendEvent({ + entityType: 'execution_resolution', + entityId: intent.id, + eventType: 'execution_resolution.opened', + data: { + intentId: intent.id, + state: 'reconciliation_required', + reasonCode, + blocksWallet: true, + openedAt: recordedAt, + }, + }); + } + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, ?, ?, 1, ?)`).run(intent.id, status, reasonCode, recordedAt); + appendEvent({ + entityType: 'execution_outcome', + entityId: intent.id, + eventType: 'execution.recorded', + data: { + state: paid.executionState, + httpStatus: paid.status, + responseHash, + metadataHash: sha256(metadataJson), + reasonCode, + recordedAt, + }, + }); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.recorded', + data: { status, reasonCode, revision: 1, recordedAt }, + }); + }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'retrying', + nextState: 'terminal', + reasonCode, + }); + }); + faultInjector('after_settlement_commit', Object.freeze({ intentId: intent.id })); + const receipt = issueTerminalReceipt(intent.id); + return Object.freeze({ + requestId: intent.requestId, + status, + reasonCode, + upstreamStatus: paid.status, + body: paid.body === null ? null : Buffer.from(paid.body), + receipt, + }); + }); + + const holdPaidAmbiguity = async ({ intent, paid }) => await runMutation(() => { + const reasonCode = paid.reasonCode === 'SECOND_PAYMENT_REQUIRED' + ? 'SECOND_PAYMENT_REQUIRED' + : (typeof paid.reasonCode === 'string' && paid.reasonCode.startsWith('SETTLEMENT_') + ? 'SETTLEMENT_EVIDENCE_INVALID' + : 'PAID_RESPONSE_AMBIGUOUS'); + const recordedAt = canonicalTimestamp(now(), 'payment unresolved recordedAt'); + store.transaction((token) => { + receipts.assertParityInTransaction(token); + budgets.holdUnresolvedInTransaction(token, { intentId: intent.id, reasonCode }); + const heldAt = store.within(token, ({ db }) => db.prepare( + 'SELECT updated_at FROM budget_reservations WHERE intent_id = ?', + ).get(intent.id)?.updated_at); + if (typeof heldAt !== 'string') { + throw new KernelError('BUDGET_CORRUPTION', 'unresolved budget hold lost its timestamp'); + } + store.within(token, ({ db, appendEvent }) => { + const changed = db.prepare(`UPDATE payment_attempts + SET state = 'unresolved', reason_code = ?, updated_at = ? + WHERE intent_id = ? AND state = 'retrying' + AND retry_started_at IS NOT NULL + AND settlement_json IS NULL AND transaction_id IS NULL AND settled_at IS NULL`).run( + reasonCode, + heldAt, + intent.id, + ); + if (changed.changes !== 1n) { + throw new KernelError('PAYMENT_ATTEMPT_STATE', 'payment hold lost its retrying attempt'); + } + appendEvent({ + entityType: 'payment_attempt', + entityId: intent.id, + eventType: 'payment.unresolved', + data: { reasonCode, recordedAt: heldAt }, + }); + }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'retrying', + nextState: 'unresolved', + reasonCode, + }); + store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_unresolved', ?, 1, ?)`).run( + intent.id, + reasonCode, + recordedAt, + ); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.recorded', + data: { + status: 'payment_unresolved', + reasonCode, + revision: 1, + recordedAt, + }, + }); + }); + }); + const receipt = issueTerminalReceipt(intent.id); + return Object.freeze({ + requestId: intent.requestId, + status: 'payment_unresolved', + reasonCode, + receipt, + }); + }); + + const persistedResult = (intent) => { + const outcome = store.readOne( + 'SELECT status, reason_code FROM buyer_outcomes WHERE intent_id = ?', + [intent.id], + ); + if (!outcome) return null; + const receipt = receipts.latest(intent.id); + if (!receipt) { + throw new KernelError( + 'RECEIPT_PARITY_REQUIRED', + 'persisted BuyerOutcome is missing its signed receipt', + ); + } + return Object.freeze({ + requestId: intent.requestId, + status: outcome.status, + reasonCode: outcome.reason_code, + receipt, + }); + }; + + const persistedTerminalWinnerAfterConflict = ({ error, originalIntent, correlationId }) => { + if (!new Set([ + 'INTENT_STATE_CONFLICT', + 'APPROVAL_STATE_CONFLICT', + 'APPROVAL_EXPIRED', + ]).has(error?.code)) return null; + if (correlationId !== originalIntent.correlationId) return null; + const winner = intents.getIntent(originalIntent.id); + if (winner.state !== 'terminal' || winner.retryMatchable) return null; + const exactBindingFields = [ + 'id', + 'requestId', + 'sessionId', + 'enrollmentHash', + 'routeId', + 'method', + 'requestUrlHash', + 'sellerOrigin', + 'resourcePath', + 'bodyHash', + 'headerAllowlistHash', + 'ordinaryFingerprint', + 'purposeLabel', + 'correlationId', + 'idempotencyKey', + 'walletAddress', + 'intentHash', + 'challengeProjectionJson', + 'challengeHash', + 'challengeReceivedAt', + 'createdAt', + ]; + if (exactBindingFields.some((field) => winner[field] !== originalIntent[field])) return null; + const result = persistedResult(winner); + if (result === null) return null; + const receiptIntent = result.receipt?.receipt?.intent; + if (result.requestId !== winner.requestId + || result.receipt?.intentId !== winner.id + || receiptIntent?.id !== winner.id + || receiptIntent.requestId !== winner.requestId + || receiptIntent.sessionId !== winner.sessionId + || receiptIntent.intentHash !== winner.intentHash) { + throw new KernelError( + 'RECEIPT_PARITY_REQUIRED', + 'terminal race winner receipt disagrees with its Spend Intent binding', + ); + } + return result; + }; + + const releaseUnsignedReservation = async ({ intent, status, reasonCode }) => await runMutation(() => { + const recordedAt = canonicalTimestamp(now(), 'unsigned reservation outcome recordedAt'); + store.transaction((token) => { + receipts.assertParityInTransaction(token); + budgets.releaseInTransaction(token, { intentId: intent.id, reasonCode }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'reserved', + nextState: 'terminal', + reasonCode, + }); + store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, ?, ?, 1, ?)`).run(intent.id, status, reasonCode, recordedAt); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.recorded', + data: { status, reasonCode, revision: 1, recordedAt }, + }); + }); + }); + const receipt = issueTerminalReceipt(intent.id); + return Object.freeze({ + requestId: intent.requestId, + status, + reasonCode, + receipt, + }); + }); + + const releasePreSignerRejection = async ({ intent, error }) => await runMutation(() => { + const reasonCode = 'WALLET_PRE_SIGN_REJECTED'; + const recordedAt = canonicalTimestamp(now(), 'pre-signer rejection recordedAt'); + store.transaction((token) => { + receipts.assertParityInTransaction(token); + budgets.releaseInTransaction(token, { + intentId: intent.id, + reasonCode, + preSignRejection: error, + }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'signing', + nextState: 'unresolved', + reasonCode, + }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode, + }); + store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_failed', ?, 1, ?)`).run(intent.id, reasonCode, recordedAt); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.recorded', + data: { + status: 'payment_failed', + reasonCode, + revision: 1, + recordedAt, + }, + }); + }); + }); + const receipt = issueTerminalReceipt(intent.id); + return Object.freeze({ + requestId: intent.requestId, + status: 'payment_failed', + reasonCode, + receipt, + }); + }); + + const holdSigningAmbiguity = async (intent) => await runMutation(() => { + const reasonCode = 'WALLET_SIGNATURE_AMBIGUOUS'; + const recordedAt = canonicalTimestamp(now(), 'wallet signing ambiguity recordedAt'); + store.transaction((token) => { + receipts.assertParityInTransaction(token); + budgets.holdUnresolvedInTransaction(token, { intentId: intent.id, reasonCode }); + const heldAt = store.within(token, ({ db }) => db.prepare( + 'SELECT updated_at FROM budget_reservations WHERE intent_id = ?', + ).get(intent.id)?.updated_at); + if (typeof heldAt !== 'string') { + throw new KernelError('BUDGET_CORRUPTION', 'unresolved budget hold lost its timestamp'); + } + store.within(token, ({ db, appendEvent }) => { + const changed = db.prepare(`UPDATE payment_attempts + SET state = 'unresolved', reason_code = ?, updated_at = ? + WHERE intent_id = ? AND state = 'signing' + AND signing_claimed_at IS NOT NULL + AND settlement_json IS NULL AND transaction_id IS NULL AND settled_at IS NULL`).run( + reasonCode, + heldAt, + intent.id, + ); + if (changed.changes !== 1n) { + throw new KernelError('PAYMENT_ATTEMPT_STATE', 'signing ambiguity lost its attempt'); + } + appendEvent({ + entityType: 'payment_attempt', + entityId: intent.id, + eventType: 'payment.unresolved', + data: { reasonCode, recordedAt: heldAt }, + }); + }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'signing', + nextState: 'unresolved', + reasonCode, + }); + store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_unresolved', ?, 1, ?)`).run( + intent.id, + reasonCode, + recordedAt, + ); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.recorded', + data: { + status: 'payment_unresolved', + reasonCode, + revision: 1, + recordedAt, + }, + }); + }); + }); + const receipt = issueTerminalReceipt(intent.id); + return Object.freeze({ + requestId: intent.requestId, + status: 'payment_unresolved', + reasonCode, + receipt, + }); + }); + + const executeReserved = async ({ + intent, + request, + transportRequest, + paymentRequired, + evaluation, + policyVersion, + approvalExpiresAt = null, + }) => { + let binding; + try { + binding = await signingClaim({ + intent, + request, + paymentRequired, + evaluation, + policyVersion, + approvalExpiresAt, + }); + } catch (error) { + if (isNonceCollision(error)) { + return await releaseUnsignedReservation({ + intent, + status: 'payment_failed', + reasonCode: 'NONCE_COLLISION', + }); + } + if (error?.code === 'AGENT_REVOKED') { + return await releaseUnsignedReservation({ + intent, + status: 'payment_denied', + reasonCode: 'AGENT_REVOKED', + }); + } + if (error?.code === 'WALLET_RECOVERY_REQUIRED') { + return await releaseUnsignedReservation({ + intent, + status: 'payment_denied', + reasonCode: 'WALLET_RECOVERY_REQUIRED', + }); + } + if (error?.code === 'CHALLENGE_EXPIRED') { + return await releaseUnsignedReservation({ + intent, + status: 'payment_denied', + reasonCode: 'CHALLENGE_EXPIRED', + }); + } + throw error; + } + const permit = permitAuthority.issue(binding); + let signed; + try { + signed = await walletAdapter.signX402Exact(permit, paymentRequired); + } catch (error) { + if (WalletSigningError.isExact(error, 'WALLET_PRE_SIGN_REJECTED', false)) { + return await releasePreSignerRejection({ intent, error }); + } + return await holdSigningAmbiguity(intent); + } + try { + faultInjector('after_signer_return', Object.freeze({ intentId: intent.id })); + let paymentPayload; + let paymentHeader; + try { + const signedResult = shallowRecord( + signed, + ['paymentPayload'], + [], + 'WALLET_PAYMENT_PAYLOAD', + 'wallet signing result', + ); + paymentPayload = assertSignedPayload( + signedResult.paymentPayload, + binding, + paymentRequired, + ); + paymentHeader = transport.encodePayment(paymentPayload); + } catch { + return await holdSigningAmbiguity(intent); + } + const paymentHash = await persistSignedPayment({ + intentId: intent.id, + paymentPayload, + paymentHeader, + }); + await claimPaidRetry(intent.id); + let paid; + try { + paid = await transport.retryPaid({ + request: transportRequest, + paymentHeader, + binding: { + network: binding.network, + walletAddress: binding.walletAddress, + amountAtomic: binding.amountAtomic, + paymentHash, + }, + }); + } catch { + return await holdPaidAmbiguity({ + intent, + paid: Object.freeze({ reasonCode: 'PAID_RESPONSE_AMBIGUOUS' }), + }); + } + faultInjector('after_paid_response', Object.freeze({ intentId: intent.id })); + if (paid.kind === 'settled_response') { + return await settleExecution({ intent, paid }); + } + return await holdPaidAmbiguity({ intent, paid }); + } catch (error) { + return failStopAfterSignerReturn(error); + } + }; + + const replaceChangedApproval = async ({ + intent, + intentRequest, + paymentRequired, + challengeReceivedAt, + evaluation, + policyVersion, + }) => await runMutation(() => { + const recordedAt = canonicalTimestamp(now(), 'changed approval outcome recordedAt'); + let replacement = null; + store.transaction((token) => { + receipts.assertParityInTransaction(token); + const cancelled = approvals.cancelForIntentInTransaction(token, { + intentId: intent.id, + reasonCode: 'APPROVAL_CHALLENGE_CHANGED', + }); + if (cancelled === null) { + throw new KernelError( + 'APPROVAL_STATE_CONFLICT', + 'changed challenge lost its open approval authority', + ); + } + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'approval_pending', + nextState: 'terminal', + reasonCode: 'APPROVAL_CHALLENGE_CHANGED', + }); + store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_denied', 'APPROVAL_CHALLENGE_CHANGED', 1, ?)`).run( + intent.id, + recordedAt, + ); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.recorded', + data: { + status: 'payment_denied', + reasonCode: 'APPROVAL_CHALLENGE_CHANGED', + revision: 1, + recordedAt, + }, + }); + }); + if (evaluation?.decision !== 'approval_required') return; + const { correlationId: ignoredCorrelationId, ...replacementRequest } = intentRequest; + void ignoredCorrelationId; + const replacementIntent = intents.captureIntentInTransaction(token, { + sessionId: intent.sessionId, + ...replacementRequest, + }); + const replacementChallenged = intents.attachChallengeInTransaction(token, { + intentId: replacementIntent.id, + paymentRequired, + challengeReceivedAt: replacementIntent.updatedAt, + }); + policies.recordDecisionInTransaction(token, { + intentId: replacementIntent.id, + policyVersionId: policyVersion.id, + evaluation, + decidedAt: replacementChallenged.updatedAt, + }); + const replacementApproval = approvals.requestInTransaction(token, { + intentId: replacementIntent.id, + intentHash: replacementIntent.intentHash, + challengeHash: evaluation.challengeHash, + quoteId: evaluation.quoteId, + amountCeilingAtomic: evaluation.amountCeilingAtomic, + walletAddress: replacementIntent.walletAddress, + policyVersionId: policyVersion.id, + acceptedIndex: evaluation.acceptedIndex, + }); + intents.transitionInTransaction(token, { + intentId: replacementIntent.id, + expectedState: 'challenged', + nextState: 'approval_pending', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + }); + replacement = Object.freeze({ + intent: replacementIntent, + approval: replacementApproval, + }); + }); + const receipt = issueTerminalReceipt(intent.id); + return Object.freeze({ + requestId: intent.requestId, + status: 'payment_denied', + reasonCode: 'APPROVAL_CHALLENGE_CHANGED', + receipt, + ...(replacement === null ? {} : { + replacementRequestId: replacement.intent.requestId, + replacementExpiresAt: replacement.approval.expiresAt, + }), + }); + }); + + const terminateSessionIntentsInTransaction = (token, { + sessionId, + reasonCode, + blockedCode, + recordedAt, + }) => { + const rows = store.within(token, ({ db }) => db.prepare(`SELECT + spend_intents.id, + spend_intents.state, + approvals.decision AS approval_decision, + policy_decisions.decision AS policy_decision, + policy_decisions.reason_code AS policy_reason_code, + budget_reservations.state AS budget_state, + payment_attempts.state AS payment_state + FROM spend_intents + LEFT JOIN approvals ON approvals.intent_id = spend_intents.id + LEFT JOIN policy_decisions ON policy_decisions.intent_id = spend_intents.id + LEFT JOIN budget_reservations ON budget_reservations.intent_id = spend_intents.id + LEFT JOIN payment_attempts ON payment_attempts.intent_id = spend_intents.id + WHERE spend_intents.session_id = ? + ORDER BY spend_intents.id`).all(sessionId)); + const unsafeIntentStates = new Set(['signing', 'signed', 'retrying', 'unresolved']); + const unsafePaymentStates = new Set(['signing', 'signed', 'retrying', 'unresolved', 'settled']); + for (const row of rows) { + if (row.state === 'terminal') continue; + if (unsafeIntentStates.has(row.state) + || unsafePaymentStates.has(row.payment_state) + || (row.budget_state !== null && row.budget_state !== 'reserved') + || (row.payment_state !== null && row.payment_state !== 'reserved') + || (row.state === 'reserved' + && (row.budget_state !== 'reserved' || row.payment_state !== 'reserved')) + || (row.state === 'approval_pending' + && !new Set(['pending', 'approved']).has(row.approval_decision)) + || (row.state === 'challenged' + && !new Set(['allow', 'approval_required', 'deny']).has(row.policy_decision))) { + throw new KernelError(blockedCode, 'Spend Session retains money-sensitive authority'); + } + } + const terminalIntentIds = []; + for (const row of rows) { + if (row.state === 'terminal') continue; + if (row.approval_decision === 'pending' || row.approval_decision === 'approved') { + const cancelled = approvals.cancelForIntentInTransaction(token, { + intentId: row.id, + reasonCode, + }); + if (cancelled === null) { + throw new KernelError(blockedCode, 'session approval changed during cancellation'); + } + } + if (row.budget_state === 'reserved') { + budgets.releaseInTransaction(token, { intentId: row.id, reasonCode }); + } + const terminalReasonCode = row.state === 'challenged' && row.policy_decision === 'deny' + ? row.policy_reason_code + : reasonCode; + intents.transitionInTransaction(token, { + intentId: row.id, + expectedState: row.state, + nextState: 'terminal', + reasonCode: terminalReasonCode, + }); + store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_denied', ?, 1, ?)`).run( + row.id, + terminalReasonCode, + recordedAt, + ); + appendEvent({ + entityType: 'buyer_outcome', + entityId: row.id, + eventType: 'buyer_outcome.recorded', + data: { + status: 'payment_denied', + reasonCode: terminalReasonCode, + revision: 1, + recordedAt, + }, + }); + }); + terminalIntentIds.push(row.id); + } + return Object.freeze(terminalIntentIds); + }; + + const runSessionAggregate = async ({ + request, + reasonCode, + blockedCode, + command, + latchAgentSessionUnavailable = false, + }) => { + try { + return await runMutation(() => { + const recordedAt = canonicalTimestamp(now(), 'session aggregate outcome recordedAt'); + let terminalIntentIds; + let sessionResult; + store.transaction((token) => { + receipts.assertParityInTransaction(token); + terminalIntentIds = terminateSessionIntentsInTransaction(token, { + sessionId: request.sessionId, + reasonCode, + blockedCode, + recordedAt, + }); + sessionResult = command(token); + }); + const terminalReceipts = Object.freeze(terminalIntentIds.map((intentId) => Object.freeze({ + intentId, + receipt: issueTerminalReceipt(intentId), + }))); + if (latchAgentSessionUnavailable) agentSessionUnavailable = true; + return Object.freeze({ ...sessionResult, terminalReceipts }); + }); + } catch (error) { + if (error?.code === 'SESSION_MONETARY_AMBIGUITY') { + throw new KernelError(blockedCode, 'Spend Session retains monetary ambiguity', { + cause: error, + }); + } + throw error; + } + }; + + const transitionSessionPolicy = async (input) => { + const request = exactRecord(input, [ + 'sessionId', + 'targetPolicyVersionId', + 'expectedSessionHash', + ], [], 'SESSION_TRANSITION_SCHEMA', 'session policy transition'); + canonicalToken(request.sessionId, 'session ID'); + canonicalToken(request.targetPolicyVersionId, 'target policy version ID'); + canonicalHash( + request.expectedSessionHash, + 'SESSION_TRANSITION_SCHEMA', + 'expected session hash', + ); + return await runSessionAggregate({ + request, + reasonCode: 'POLICY_SUPERSEDED', + blockedCode: 'SESSION_TRANSITION_BLOCKED', + command: (token) => intents.transitionBlockedSessionInTransaction(token, request), + }); + }; + + const closeSession = async (input) => { + const request = exactRecord(input, [ + 'sessionId', + 'expectedSessionHash', + ], [], 'SESSION_CLOSE_SCHEMA', 'session close'); + canonicalToken(request.sessionId, 'session ID'); + canonicalHash( + request.expectedSessionHash, + 'SESSION_CLOSE_SCHEMA', + 'expected session hash', + ); + return await runSessionAggregate({ + request, + reasonCode: 'SESSION_CLOSED', + blockedCode: 'SESSION_CLOSE_BLOCKED', + command: (token) => intents.closeBoundSessionInTransaction(token, request), + latchAgentSessionUnavailable: true, + }); + }; + + const status = (input) => { + const request = exactRecord(input, [ + 'sessionId', + 'intentId', + ], [], 'STATUS_SCHEMA', 'Kernel status request'); + const sessionId = canonicalToken(request.sessionId, 'session ID'); + const intentId = canonicalToken(request.intentId, 'intent ID'); + const session = intents.getSession(sessionId); + if (session === null) { + throw new KernelError('SESSION_UNKNOWN', 'Spend Session does not exist'); + } + const intent = intents.getIntent(intentId); + if (intent === null) { + throw new KernelError('INTENT_UNKNOWN', 'Spend Intent does not exist'); + } + if (intent.sessionId !== session.id) { + throw new KernelError( + 'INTENT_SESSION_MISMATCH', + 'Spend Intent does not belong to the requested Spend Session', + ); + } + const approval = store.readOne( + 'SELECT decision FROM approvals WHERE intent_id = ?', + [intent.id], + ); + const budget = store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', + [intent.id], + ); + const payment = store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', + [intent.id], + ); + const persistedOutcome = store.readOne( + 'SELECT status, reason_code, revision FROM buyer_outcomes WHERE intent_id = ?', + [intent.id], + ); + const receipt = receipts.latest(intent.id); + if ((persistedOutcome == null) !== (receipt === null)) { + throw new KernelError( + 'RECEIPT_PARITY_REQUIRED', + 'BuyerOutcome and signed receipt must exist together', + ); + } + const outcome = persistedOutcome == null ? null : Object.freeze({ + status: persistedOutcome.status, + reasonCode: persistedOutcome.reason_code, + revision: Number(persistedOutcome.revision), + }); + return Object.freeze({ + sessionId: session.id, + intentId: intent.id, + sessionState: session.state, + intentState: intent.state, + approvalState: approval?.decision ?? null, + budgetState: budget?.state ?? null, + paymentState: payment?.state ?? null, + outcome, + receipt, + }); + }; + + const agentStatusView = (sessionId, intentId) => { + const internal = status({ sessionId, intentId }); + const intent = intents.getIntent(intentId); + if (intent === null || intent.requestId === undefined + || typeof intent.sellerOrigin !== 'string' + || typeof intent.purposeLabel !== 'string') { + throw new KernelError('INTENT_CORRUPTION', 'Spend Intent public identity is invalid'); + } + const approvalRow = store.readOne( + `SELECT decision, expires_at, amount_ceiling_atomic + FROM approvals WHERE intent_id = ?`, + [intent.id], + ); + let approval = null; + if (approvalRow && (approvalRow.decision === 'pending' || approvalRow.decision === 'approved')) { + if (typeof approvalRow.expires_at !== 'string' + || typeof approvalRow.amount_ceiling_atomic !== 'string' + || !/^(0|[1-9][0-9]*)$/.test(approvalRow.amount_ceiling_atomic)) { + throw new KernelError('APPROVAL_CORRUPTION', 'approval public projection is invalid'); + } + approval = Object.freeze({ + state: approvalRow.decision, + expiresAt: approvalRow.expires_at, + amountAtomic: approvalRow.amount_ceiling_atomic, + }); + } + const snapshot = budgets.snapshot({ + sessionId, + sellerOrigin: intent.sellerOrigin, + at: now(), + }); + const session = intents.getSession(sessionId); + const policyVersion = session === null ? null : policies.get(session.policyVersionId); + if (!policyVersion?.policy + || typeof policyVersion.policy.sessionMaxAtomic !== 'string' + || !/^(0|[1-9][0-9]*)$/.test(policyVersion.policy.sessionMaxAtomic) + || typeof snapshot?.sessionExposureAtomic !== 'string' + || !/^(0|[1-9][0-9]*)$/.test(snapshot.sessionExposureAtomic)) { + throw new KernelError('BUDGET_CORRUPTION', 'session remaining projection is invalid'); + } + const maximum = BigInt(policyVersion.policy.sessionMaxAtomic); + const exposure = BigInt(snapshot.sessionExposureAtomic); + if (exposure > maximum) { + throw new KernelError('BUDGET_CORRUPTION', 'session exposure exceeds its policy maximum'); + } + return Object.freeze({ + requestId: intent.requestId, + sellerOrigin: intent.sellerOrigin, + purposeLabel: intent.purposeLabel, + intentState: internal.intentState, + approval, + outcome: internal.outcome, + receipt: internal.receipt, + remainingSessionAtomic: (maximum - exposure).toString(), + }); + }; + + const statusByRequestId = (input) => { + const request = exactRecord(input, [ + 'sessionId', + 'requestId', + ], [], 'STATUS_SCHEMA', 'Kernel public request status'); + const sessionId = canonicalToken(request.sessionId, 'session ID'); + const requestId = canonicalToken(request.requestId, 'request ID'); + const row = store.readOne( + 'SELECT id FROM spend_intents WHERE session_id = ? AND request_id = ?', + [sessionId, requestId], + ); + if (row == null) return null; + if (typeof row.id !== 'string') { + throw new KernelError('INTENT_CORRUPTION', 'Spend Intent lookup is invalid'); + } + return agentStatusView(sessionId, row.id); + }; + + const receiptById = (input) => { + const request = exactRecord(input, [ + 'sessionId', + 'receiptId', + ], [], 'STATUS_SCHEMA', 'Kernel public receipt status'); + const sessionId = canonicalToken(request.sessionId, 'session ID'); + const receiptId = canonicalToken(request.receiptId, 'receipt ID'); + const row = store.readOne( + `SELECT signed_receipts.intent_id + FROM signed_receipts + JOIN spend_intents ON spend_intents.id = signed_receipts.intent_id + WHERE signed_receipts.id = ? AND spend_intents.session_id = ?`, + [receiptId, sessionId], + ); + if (row == null) return null; + if (typeof row.intent_id !== 'string') { + throw new KernelError('RECEIPT_CORRUPTION', 'signed receipt lookup is invalid'); + } + return agentStatusView(sessionId, row.intent_id); + }; + + const execute = async (input) => { + const invocation = shallowRecord(input, [ + 'sessionId', + 'routeId', + 'request', + 'purposeLabel', + 'correlationId', + ], [], 'EXECUTE_SCHEMA', 'Kernel execution'); + const request = shallowRecord(invocation.request, [ + 'requestUrl', + 'method', + 'headers', + 'bodyBytes', + ], [], 'EXECUTE_SCHEMA', 'Kernel ordinary request'); + const intentRequest = { + routeId: invocation.routeId, + method: request.method, + requestUrl: request.requestUrl, + headers: request.headers, + bodyBytes: request.bodyBytes, + purposeLabel: invocation.purposeLabel, + correlationId: invocation.correlationId, + }; + const matchedIntentId = intents.matchRetry({ + sessionId: invocation.sessionId, + request: intentRequest, + }); + const intent = matchedIntentId === null + ? await runMutation(() => intents.captureIntent({ + sessionId: invocation.sessionId, + ...intentRequest, + })) + : intents.getIntent(matchedIntentId); + if (intent.state === 'terminal') { + const existing = persistedResult(intent); + if (existing === null) { + throw new KernelError( + 'BUYER_OUTCOME_CORRUPTION', + 'terminal Spend Intent is missing its persisted BuyerOutcome', + ); + } + return existing; + } + if (intent.state === 'unresolved') { + const existing = persistedResult(intent); + if (existing) return existing; + } + if (new Set(['signing', 'signed', 'retrying']).has(intent.state)) { + return Object.freeze({ + requestId: intent.requestId, + status: 'request_in_flight', + reasonCode: 'REQUEST_IN_FLIGHT', + receipt: null, + }); + } + let approvedRetry = null; + if (intent.state === 'approval_pending') { + const approval = approvals.findRetryable({ + sessionId: intent.sessionId, + intentHash: intent.intentHash, + }); + const decisionAt = canonicalTimestamp(now(), 'approval retry decision time'); + if (approval !== null && Date.parse(decisionAt) >= Date.parse(approval.expiresAt)) { + const expired = await expireApprovalCandidate({ + approvalId: approval.approvalId, + intentId: approval.intentId, + intentHash: approval.intentHash, + }, decisionAt); + if (expired !== null) return expired; + const existing = persistedResult(intents.getIntent(intent.id)); + if (existing !== null) return existing; + throw new KernelError( + 'APPROVAL_STATE_CONFLICT', + 'expired approval changed before its terminal outcome was observed', + ); + } + if (approval?.decision === 'pending') return approvalRequiredResult(intent, approval); + if (approval?.decision === 'approved') approvedRetry = approval; + } + const active = inFlightByIntent.get(intent.id); + if (active) { + return Object.freeze({ + requestId: intent.requestId, + status: 'request_in_flight', + reasonCode: 'REQUEST_IN_FLIGHT', + receipt: null, + }); + } + faultInjector('after_intent_commit', Object.freeze({ intentId: intent.id })); + + let complete; + const operation = (async () => { + const transportRequest = Object.freeze({ + requestUrl: request.requestUrl, + method: request.method, + headers: Object.freeze({ + ...request.headers, + 'idempotency-key': intent.idempotencyKey, + }), + bodyBytes: Buffer.from(request.bodyBytes), + }); + let probed; + try { + probed = await transport.probe(transportRequest); + } catch (error) { + const challengeReason = challengeDenialReason(error); + if (challengeReason !== null) { + if (approvedRetry !== null) { + return await replaceChangedApproval({ + intent, + intentRequest, + paymentRequired: null, + challengeReceivedAt: null, + evaluation: null, + policyVersion: null, + }); + } + return await terminalizeWithoutExecution(intent, { + expectedState: 'captured', + status: 'payment_denied', + reasonCode: challengeReason, + }); + } + if (approvedRetry !== null) { + return await terminalizeOrdinary(intent, { + status: 'upstream_failed', + reasonCode: 'UPSTREAM_TRANSPORT_FAILURE', + executionState: 'unknown', + upstreamStatus: null, + body: null, + expectedState: 'approval_pending', + }); + } + return await terminalizeOrdinary(intent, { + status: 'upstream_failed', + reasonCode: 'UPSTREAM_TRANSPORT_FAILURE', + executionState: 'unknown', + upstreamStatus: null, + body: null, + }); + } + if (probed.kind === 'response') { + if (approvedRetry !== null) { + return await replaceChangedApproval({ + intent, + intentRequest, + paymentRequired: null, + challengeReceivedAt: null, + evaluation: null, + policyVersion: null, + }); + } + const successful = probed.status >= 200 && probed.status <= 299; + return await terminalizeOrdinary(intent, { + status: successful ? 'completed' : 'upstream_failed', + reasonCode: successful ? 'ORDINARY_SUCCESS' : 'ORDINARY_HTTP_FAILURE', + executionState: successful ? 'succeeded' : 'failed', + upstreamStatus: probed.status, + body: Buffer.from(probed.body), + }); + } + if (probed.kind === 'payment_required') { + let challengeResult; + try { + challengeResult = await evaluateChallenge({ + intent, + request, + paymentRequired: probed.paymentRequired, + persist: approvedRetry === null, + }); + } catch (error) { + const challengeReason = challengeDenialReason(error); + if (challengeReason === null) throw error; + if (approvedRetry !== null) { + return await replaceChangedApproval({ + intent, + intentRequest, + paymentRequired: null, + challengeReceivedAt: null, + evaluation: null, + policyVersion: null, + }); + } + return await terminalizeWithoutExecution(intent, { + expectedState: 'captured', + status: 'payment_denied', + reasonCode: challengeReason, + }); + } + const { challengeReceivedAt, evaluation, policyVersion } = challengeResult; + if (approvedRetry !== null) { + const freshChallengeHash = sha256(canonicalJson( + projectPaymentRequired(probed.paymentRequired), + )); + if (freshChallengeHash !== intent.challengeHash + || evaluation.challengeHash !== intent.challengeHash) { + return await replaceChangedApproval({ + intent, + intentRequest, + paymentRequired: probed.paymentRequired, + challengeReceivedAt, + evaluation, + policyVersion, + }); + } + if (evaluation.decision !== 'approval_required') { + return await replaceChangedApproval({ + intent, + intentRequest, + paymentRequired: probed.paymentRequired, + challengeReceivedAt, + evaluation, + policyVersion, + }); + } + await reserveApproved({ + intent, + evaluation, + policyVersion, + approval: approvedRetry, + }); + return await executeReserved({ + intent: intents.getIntent(intent.id), + request, + transportRequest, + paymentRequired: probed.paymentRequired, + evaluation, + policyVersion, + approvalExpiresAt: approvedRetry.expiresAt, + }); + } + if (evaluation.decision === 'deny') { + return await terminalizeWithoutExecution(intents.getIntent(intent.id), { + expectedState: 'challenged', + status: 'payment_denied', + reasonCode: evaluation.reasonCode, + }); + } + if (evaluation.decision === 'approval_required') { + return await requestApproval( + intents.getIntent(intent.id), + evaluation, + policyVersion.id, + ); + } + await reserveAutomatic({ intent, evaluation, policyVersion }); + return await executeReserved({ + intent: intents.getIntent(intent.id), + request, + transportRequest, + paymentRequired: probed.paymentRequired, + evaluation, + policyVersion, + }); + } + throw new KernelError( + 'TRANSPORT_RESULT_SCHEMA', + 'unpaid transport returned an unsupported result kind', + ); + })(); + complete = operation; + inFlightByIntent.set(intent.id, operation); + try { + return await operation; + } catch (error) { + const winner = persistedTerminalWinnerAfterConflict({ + error, + originalIntent: intent, + correlationId: invocation.correlationId, + }); + if (winner !== null) return winner; + throw error; + } finally { + if (inFlightByIntent.get(intent.id) === complete) inFlightByIntent.delete(intent.id); + } + }; + + return Object.freeze({ + openOrResumeSession, + applyPolicy, + revokeAgent, + transitionSessionPolicy, + closeSession, + approvePending, + denyPending, + expireDueApprovals, + execute, + status, + statusByRequestId, + receiptById, + }); +} diff --git a/spikes/pi-wielder/src/offline-bootstrap.mjs b/spikes/pi-wielder/src/offline-bootstrap.mjs new file mode 100644 index 0000000..d4c3765 --- /dev/null +++ b/spikes/pi-wielder/src/offline-bootstrap.mjs @@ -0,0 +1,731 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { types as utilTypes } from 'node:util'; + +import { + createIsolationAttestationRepository, + validateIsolationReportBytes, +} from './agent/isolation-preflight.mjs'; +import { createAgentEnrollmentRepository } from './kernel/agent-enrollment.mjs'; +import { createApprovalQueue } from './kernel/approval-queue.mjs'; +import { acquireAuthorityLock } from './kernel/authority-lock.mjs'; +import { createBudgetLedger } from './kernel/budget-ledger.mjs'; +import { + canonicalJson, + exactRecord, + frozenCopy, + KernelError, + sha256, +} from './kernel/canonical.mjs'; +import { createIntentRepository } from './kernel/intent-builder.mjs'; +import { validatePolicyDocument } from './kernel/policy-engine.mjs'; +import { createPolicyRepository } from './kernel/policy-repository.mjs'; +import { loadOrCreateReceiptSigner } from './kernel/receipt-signing.mjs'; +import { recoverKernelAuthority } from './kernel/recovery.mjs'; +import { readPrivateInputFile } from './kernel/secure-storage.mjs'; +import { createSignedReceiptRepository } from './kernel/signed-receipts.mjs'; +import { openKernelStore } from './kernel/sqlite-store.mjs'; + +const ORIGIN = 'http://127.0.0.1:8405'; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const MAXIMUM_POLICY_BYTES = 65_536; +const MAXIMUM_DESCRIPTOR_BYTES = 1_024; +const MAXIMUM_REPORT_BYTES = 16_384; +const CONFIG_FIELDS = Object.freeze([ + 'mode', + 'databasePath', + 'receiptKeyPath', + 'operatorTokenPath', + 'operatorSocketPath', + 'origin', + 'trustedAncestor', + 'enrollmentInboxPath', + 'expectedAgentUid', + 'expectedAgentGid', + 'kernelUid', + 'kernelGid', +]); +const COMMAND_FIELDS = Object.freeze({ + preflight: Object.freeze([]), + 'agent-enroll': Object.freeze(['descriptorPath', 'expectedDescriptorHash']), + 'policy-validate': Object.freeze(['policyPath']), + 'policy-apply': Object.freeze(['policyPath', 'expectedPolicyHash']), + 'isolation-attest': Object.freeze(['reportPath', 'expectedReportHash']), +}); + +function fail(code, message, cause) { + throw new KernelError(code, message, cause === undefined ? undefined : { cause }); +} + +function canonicalPath(value, label, { nullable = false } = {}) { + if (nullable && value === null) return null; + if (typeof value !== 'string' || value.length === 0 || value.includes('\0') + || !path.isAbsolute(value) || path.resolve(value) !== value + || (value !== path.parse(value).root && value.endsWith(path.sep))) { + fail('BOOTSTRAP_CONFIG_INVALID', `${label} must be one canonical absolute path`); + } + return value; +} + +function positiveIdentity(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + fail('BOOTSTRAP_CONFIG_INVALID', `${label} must be one positive safe integer`); + } + return value; +} + +function captureConfig(value) { + const config = exactRecord( + value, + CONFIG_FIELDS, + [], + 'BOOTSTRAP_CONFIG_SCHEMA', + 'offline bootstrap configuration', + ); + if (config.mode !== 'deterministic' && config.mode !== 'cdp-testnet') { + fail('BOOTSTRAP_CONFIG_INVALID', 'offline bootstrap mode is invalid'); + } + if (config.origin !== ORIGIN) { + fail('BOOTSTRAP_CONFIG_INVALID', 'offline bootstrap origin must be exact loopback'); + } + const databasePath = canonicalPath(config.databasePath, 'database path'); + const receiptKeyPath = canonicalPath( + config.receiptKeyPath, + 'receipt key path', + { nullable: true }, + ); + const operatorTokenPath = canonicalPath(config.operatorTokenPath, 'operator token path'); + const operatorSocketPath = canonicalPath( + config.operatorSocketPath, + 'operator socket path', + { nullable: true }, + ); + const enrollmentInboxPath = canonicalPath( + config.enrollmentInboxPath, + 'enrollment inbox path', + { nullable: true }, + ); + const kernelUid = positiveIdentity(config.kernelUid, 'Kernel UID'); + const kernelGid = positiveIdentity(config.kernelGid, 'Kernel GID'); + const expectedAgentUid = positiveIdentity(config.expectedAgentUid, 'Agent UID'); + const expectedAgentGid = positiveIdentity(config.expectedAgentGid, 'Agent GID'); + if (typeof process.getuid !== 'function' || typeof process.getgid !== 'function' + || process.getuid() !== kernelUid || process.getgid() !== kernelGid) { + fail('BOOTSTRAP_IDENTITY_INVALID', 'offline bootstrap must run as the configured Kernel identity'); + } + if (config.mode === 'deterministic') { + if (operatorSocketPath !== null + || expectedAgentUid !== kernelUid || expectedAgentGid !== kernelGid) { + fail( + 'BOOTSTRAP_CONFIG_INVALID', + 'deterministic bootstrap requires one explicit same-identity fixture and no Unix socket', + ); + } + } else if (process.platform !== 'linux' || kernelUid === 0 + || expectedAgentUid === kernelUid || operatorSocketPath === null) { + fail( + 'BOOTSTRAP_CONFIG_INVALID', + 'cdp-testnet bootstrap requires Linux, non-root distinct identities, and a Unix socket path', + ); + } + const trustedAncestor = canonicalPath( + config.trustedAncestor ?? (config.mode === 'deterministic' + ? path.dirname(databasePath) + : null), + 'trusted ancestor', + ); + if (new Set([ + databasePath, + receiptKeyPath, + operatorTokenPath, + operatorSocketPath, + enrollmentInboxPath, + ].filter((item) => item !== null)).size + !== [ + databasePath, + receiptKeyPath, + operatorTokenPath, + operatorSocketPath, + enrollmentInboxPath, + ].filter((item) => item !== null).length) { + fail('BOOTSTRAP_CONFIG_INVALID', 'offline bootstrap filesystem roles must be distinct'); + } + const sqliteAuthorityPaths = new Set([ + databasePath, + `${databasePath}-wal`, + `${databasePath}-shm`, + `${databasePath}.authority-lock.sqlite`, + ]); + if ([receiptKeyPath, operatorTokenPath, operatorSocketPath, enrollmentInboxPath] + .some((item) => item !== null && sqliteAuthorityPaths.has(item))) { + fail('BOOTSTRAP_CONFIG_INVALID', 'offline bootstrap path collides with SQLite authority'); + } + return Object.freeze({ + mode: config.mode, + databasePath, + receiptKeyPath, + operatorTokenPath, + operatorSocketPath, + origin: ORIGIN, + trustedAncestor, + enrollmentInboxPath, + expectedAgentUid, + expectedAgentGid, + kernelUid, + kernelGid, + }); +} + +function captureCommand(value) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail('BOOTSTRAP_COMMAND_SCHEMA', 'offline bootstrap command must be one plain object'); + } + const descriptor = Object.getOwnPropertyDescriptor(value, 'name'); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') + || typeof descriptor.value !== 'string' + || !Object.hasOwn(COMMAND_FIELDS, descriptor.value)) { + fail('BOOTSTRAP_COMMAND_SCHEMA', 'offline bootstrap command name is invalid'); + } + const name = descriptor.value; + return Object.freeze(exactRecord( + value, + ['name', ...COMMAND_FIELDS[name]], + [], + 'BOOTSTRAP_COMMAND_SCHEMA', + `${name} command`, + )); +} + +function validateOperatorToken(value) { + if (typeof value !== 'string' || !TOKEN_PATTERN.test(value)) { + fail('OPERATOR_TOKEN_INVALID', 'operator token is invalid'); + } + const bytes = Buffer.from(value, 'base64url'); + if (bytes.length !== 32 || bytes.toString('base64url') !== value) { + bytes.fill(0); + fail('OPERATOR_TOKEN_INVALID', 'operator token is invalid'); + } + return bytes; +} + +function operatorIdentityHash(token) { + const bytes = validateOperatorToken(token); + try { + return sha256(Buffer.concat([ + Buffer.from('wallet-kernel.operator-id.v1\0', 'utf8'), + bytes, + ])); + } finally { + bytes.fill(0); + } +} + +function validateOwnerAuthority(token, config) { + const suppliedDecoded = validateOperatorToken(token); + suppliedDecoded.fill(0); + const supplied = Buffer.from(token, 'ascii'); + const pathTrust = Object.freeze({ + mode: config.mode, + trustedAncestor: config.trustedAncestor, + kernelUid: config.kernelUid, + agentUid: config.expectedAgentUid, + }); + let persisted; + try { + persisted = readPrivateInputFile( + config.operatorTokenPath, + 'Operator token', + { maximumBytes: 43, pathTrust }, + ); + } catch (cause) { + supplied.fill(0); + fail('OPERATOR_TOKEN_INVALID', 'operator token is invalid', cause); + } + let persistedText = ''; + let suppliedDigest; + let persistedDigest; + try { + if (persisted.some((byte) => byte > 0x7f)) { + fail('OPERATOR_TOKEN_INVALID', 'operator token is invalid'); + } + persistedText = persisted.toString('ascii'); + const persistedBytes = validateOperatorToken(persistedText); + persistedBytes.fill(0); + suppliedDigest = crypto.createHash('sha256') + .update('wallet-kernel.operator-bearer.v1\0', 'utf8') + .update(supplied) + .digest(); + persistedDigest = crypto.createHash('sha256') + .update('wallet-kernel.operator-bearer.v1\0', 'utf8') + .update(persisted) + .digest(); + if (!crypto.timingSafeEqual(suppliedDigest, persistedDigest)) { + fail('OPERATOR_TOKEN_INVALID', 'operator token is invalid'); + } + } finally { + supplied.fill(0); + persisted.fill(0); + suppliedDigest?.fill(0); + persistedDigest?.fill(0); + persistedText = ''; + } +} + +function identity(stat) { + return Object.freeze({ + dev: stat.dev, + ino: stat.ino, + uid: stat.uid, + gid: stat.gid, + mode: stat.mode & 0o7777n, + nlink: stat.nlink, + size: stat.size, + mtimeNs: stat.mtimeNs, + }); +} + +function sameIdentity(left, right) { + return left.dev === right.dev + && left.ino === right.ino + && left.uid === right.uid + && left.gid === right.gid + && left.mode === right.mode + && left.nlink === right.nlink + && left.size === right.size + && left.mtimeNs === right.mtimeNs; +} + +function readBoundedFile(filePath, { + code, + maximumBytes, + expectedUid, + expectedGid, + expectedMode, + expectedParentUid, + expectedParentGid, + expectedParentMode, + live, +}) { + const canonical = canonicalPath(filePath, 'bootstrap input path'); + const parentPath = path.dirname(canonical); + const leafName = path.basename(canonical); + let parentDescriptor; + let descriptor; + let bytes; + const reject = (message, cause) => fail(code, message, cause); + try { + parentDescriptor = fs.openSync( + parentPath, + fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW, + ); + const parentBeforeStat = fs.fstatSync(parentDescriptor, { bigint: true }); + const parentPathStat = fs.lstatSync(parentPath, { bigint: true }); + const parentBefore = identity(parentBeforeStat); + if (!parentBeforeStat.isDirectory() || parentPathStat.isSymbolicLink() + || !parentPathStat.isDirectory() + || parentBefore.dev !== parentPathStat.dev || parentBefore.ino !== parentPathStat.ino + || (expectedParentUid !== undefined && Number(parentBefore.uid) !== expectedParentUid) + || (expectedParentGid !== undefined && Number(parentBefore.gid) !== expectedParentGid) + || (expectedParentMode !== undefined && Number(parentBefore.mode) !== expectedParentMode)) { + reject('bootstrap input parent authority is invalid'); + } + const childLocation = live ? `/proc/self/fd/${parentDescriptor}/${leafName}` : canonical; + descriptor = fs.openSync(childLocation, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + const beforeStat = fs.fstatSync(descriptor, { bigint: true }); + const pathStat = fs.lstatSync(canonical, { bigint: true }); + const before = identity(beforeStat); + if (!beforeStat.isFile() || pathStat.isSymbolicLink() || !pathStat.isFile() + || before.dev !== pathStat.dev || before.ino !== pathStat.ino + || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maximumBytes) + || (expectedUid !== undefined && Number(before.uid) !== expectedUid) + || (expectedGid !== undefined && Number(before.gid) !== expectedGid) + || (expectedMode !== undefined && Number(before.mode) !== expectedMode)) { + reject('bootstrap input file authority is invalid'); + } + bytes = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < bytes.length) { + const count = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count <= 0) reject('bootstrap input was truncated during its single read'); + offset += count; + } + const overflow = Buffer.alloc(1); + if (fs.readSync(descriptor, overflow, 0, 1, offset) !== 0) { + reject('bootstrap input exceeded its captured size'); + } + const after = identity(fs.fstatSync(descriptor, { bigint: true })); + const afterPath = fs.lstatSync(canonical, { bigint: true }); + const parentAfter = identity(fs.fstatSync(parentDescriptor, { bigint: true })); + const parentAfterPath = fs.lstatSync(parentPath, { bigint: true }); + if (!sameIdentity(before, after) + || afterPath.isSymbolicLink() || afterPath.dev !== after.dev || afterPath.ino !== after.ino + || !sameIdentity(parentBefore, parentAfter) + || parentAfterPath.isSymbolicLink() + || parentAfterPath.dev !== parentAfter.dev || parentAfterPath.ino !== parentAfter.ino) { + reject('bootstrap input authority changed during its single read'); + } + return bytes; + } catch (error) { + if (error instanceof KernelError && error.code === code) throw error; + reject('bootstrap input could not be read safely', error); + } finally { + if (descriptor !== undefined) { + try { fs.closeSync(descriptor); } catch {} + } + if (parentDescriptor !== undefined) { + try { fs.closeSync(parentDescriptor); } catch {} + } + } +} + +function parseJsonBytes(bytes, code, label, { canonicalLine = false } = {}) { + let text; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (cause) { + fail(code, `${label} is not valid UTF-8`, cause); + } + if (text.includes('\0')) fail(code, `${label} contains a NUL byte`); + let source = text; + if (canonicalLine) { + if (!text.endsWith('\n') || text.slice(0, -1).includes('\n')) { + fail(code, `${label} must be canonical JSON plus one newline`); + } + source = text.slice(0, -1); + } + let parsed; + try { + parsed = JSON.parse(source); + } catch (cause) { + fail(code, `${label} is not valid JSON`, cause); + } + if (canonicalLine && `${canonicalJson(parsed)}\n` !== text) { + fail(code, `${label} bytes are not canonical JSON plus one newline`); + } + return parsed; +} + +function readPolicy(command, config) { + const bytes = readBoundedFile(command.policyPath, { + code: 'POLICY_FILE_UNSAFE', + maximumBytes: MAXIMUM_POLICY_BYTES, + live: config.mode === 'cdp-testnet', + }); + try { + const policy = validatePolicyDocument(parseJsonBytes(bytes, 'POLICY_FILE_INVALID', 'policy')); + const policyHash = sha256(canonicalJson(policy)); + if (command.name === 'policy-apply') { + if (typeof command.expectedPolicyHash !== 'string' + || !HASH_PATTERN.test(command.expectedPolicyHash) + || command.expectedPolicyHash !== policyHash) { + fail('POLICY_HASH_MISMATCH', 'policy hash does not match the confirmed canonical policy'); + } + } + return Object.freeze({ policy, policyHash }); + } finally { + bytes.fill(0); + } +} + +function validateEnrollmentInbox(config) { + const relative = path.relative(config.trustedAncestor, config.enrollmentInboxPath); + if (relative === '' || path.isAbsolute(relative) || relative === '..' + || relative.startsWith(`..${path.sep}`)) { + fail('AGENT_DESCRIPTOR_PATH', 'enrollment inbox must be beneath the trusted ancestor'); + } + const paths = [config.trustedAncestor]; + for (const component of relative.split(path.sep)) { + if (component.length === 0 || component === '.' || component === '..') { + fail('AGENT_DESCRIPTOR_PATH', 'enrollment inbox path is not canonical'); + } + paths.push(path.join(paths.at(-1), component)); + } + for (let index = 0; index < paths.length; index += 1) { + let stat; + try { + stat = fs.lstatSync(paths[index], { bigint: true }); + } catch (cause) { + fail('AGENT_DESCRIPTOR_PATH', 'enrollment inbox path cannot be inspected', cause); + } + const terminal = index === paths.length - 1; + const uid = Number(stat.uid); + const gid = Number(stat.gid); + const mode = Number(stat.mode & 0o7777n); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + fail('AGENT_DESCRIPTOR_PATH', 'enrollment inbox chain must contain only directories'); + } + if (terminal) { + if (uid !== config.expectedAgentUid || gid !== config.expectedAgentGid || mode !== 0o755) { + fail('AGENT_DESCRIPTOR_PATH', 'enrollment inbox must have exact Pi ownership and mode'); + } + } else if (config.mode === 'deterministic') { + if (uid !== config.kernelUid || mode !== 0o700) { + fail('AGENT_DESCRIPTOR_PATH', 'deterministic inbox ancestors must be owner-only'); + } + } else if ((index === 0 && uid !== 0) + || (index > 0 && uid !== 0 && uid !== config.kernelUid) + || (mode & 0o022) !== 0) { + fail('AGENT_DESCRIPTOR_PATH', 'live inbox ancestors must not be Pi-writable'); + } + } +} + +function readEnrollmentDescriptor(command, config) { + if (config.enrollmentInboxPath === null + || path.dirname(command.descriptorPath) !== config.enrollmentInboxPath) { + fail('AGENT_DESCRIPTOR_PATH', 'enrollment descriptor must be in the configured Pi inbox'); + } + validateEnrollmentInbox(config); + const bytes = readBoundedFile(command.descriptorPath, { + code: 'AGENT_DESCRIPTOR_PATH', + maximumBytes: MAXIMUM_DESCRIPTOR_BYTES, + expectedUid: config.expectedAgentUid, + expectedGid: config.expectedAgentGid, + expectedMode: 0o644, + expectedParentUid: config.expectedAgentUid, + expectedParentGid: config.expectedAgentGid, + expectedParentMode: 0o755, + live: config.mode === 'cdp-testnet', + }); + try { + const descriptor = exactRecord( + parseJsonBytes(bytes, 'AGENT_DESCRIPTOR_BYTES', 'agent descriptor', { + canonicalLine: true, + }), + ['schemaVersion', 'agentInstanceId', 'credentialDigest', 'agentUid', 'agentGid'], + [], + 'AGENT_DESCRIPTOR_BYTES', + 'agent descriptor', + ); + const descriptorHash = sha256(canonicalJson(descriptor)); + if (typeof command.expectedDescriptorHash !== 'string' + || !HASH_PATTERN.test(command.expectedDescriptorHash) + || descriptorHash !== command.expectedDescriptorHash) { + fail('AGENT_DESCRIPTOR_HASH', 'agent descriptor hash differs from the confirmation'); + } + return Object.freeze({ descriptor: Object.freeze(descriptor), descriptorHash }); + } finally { + bytes.fill(0); + } +} + +function readIsolationReport(command, config, now) { + if (config.mode !== 'cdp-testnet') { + fail('ISOLATION_MODE_INVALID', 'privileged isolation attestation exists only in cdp-testnet mode'); + } + const bytes = readBoundedFile(command.reportPath, { + code: 'ISOLATION_REPORT_PATH', + maximumBytes: MAXIMUM_REPORT_BYTES, + expectedUid: config.kernelUid, + expectedGid: config.kernelGid, + expectedMode: 0o600, + expectedParentUid: config.kernelUid, + expectedParentGid: config.kernelGid, + expectedParentMode: 0o700, + live: true, + }); + try { + const validated = validateIsolationReportBytes(bytes, { + expectedReportHash: command.expectedReportHash, + expectedKernelUid: String(config.kernelUid), + expectedKernelGid: String(config.kernelGid), + now, + }); + return Object.freeze({ + bytes: Buffer.from(bytes), + report: validated.report, + reportHash: validated.reportHash, + }); + } finally { + bytes.fill(0); + } +} + +function sequenceFactory(randomBytes = crypto.randomBytes) { + return (kind) => `${kind}-${randomBytes(16).toString('base64url')}`; +} + +function openRecoveryContext(config, now) { + if (config.receiptKeyPath === null) { + fail('BOOTSTRAP_CONFIG_INVALID', 'receipt key path is required for authority recovery'); + } + const pathTrust = Object.freeze({ + mode: config.mode, + trustedAncestor: config.trustedAncestor, + kernelUid: config.kernelUid, + agentUid: config.expectedAgentUid, + }); + const signer = loadOrCreateReceiptSigner(config.receiptKeyPath, { pathTrust }); + const store = openKernelStore({ + filePath: config.databasePath, + pathTrust, + now, + }); + try { + const idFactory = sequenceFactory(); + const intents = createIntentRepository({ + store, + idFactory, + now, + allowLoopbackHttp: config.mode === 'deterministic', + routeMetadata: {}, + }); + const budgets = createBudgetLedger({ store, now }); + const approvals = createApprovalQueue({ store, idFactory, now }); + const receipts = createSignedReceiptRepository({ store, signer, idFactory, now }); + return Object.freeze({ + store, + intents, + budgets, + approvals, + receipts, + policies: createPolicyRepository(store), + enrollments: createAgentEnrollmentRepository({ store, now }), + attestations: createIsolationAttestationRepository({ + store, + now, + idFactory: () => idFactory('isolation'), + }), + recoveryDependencies: Object.freeze({ store, intents, budgets, approvals, receipts, now }), + close: () => store.close(), + }); + } catch (error) { + try { store.close(); } catch {} + throw error; + } +} + +function projectEnrollment(value) { + return frozenCopy({ + agentInstanceId: value.agentInstanceId, + credentialDigest: value.credentialDigest, + enrollmentHash: value.enrollmentHash, + agentUid: value.agentUid, + agentGid: value.agentGid, + state: value.state, + isolation: value.isolation, + enrolledAt: value.enrolledAt, + }); +} + +function recoveryRequired(cause) { + if (cause instanceof KernelError && cause.code === 'AUTHORITY_BUSY') throw cause; + fail( + 'AUTHORITY_RECOVERY_REQUIRED', + 'offline bootstrap could not prove healthy Wallet authority', + cause, + ); +} + +export async function runOfflineBootstrap(value) { + const input = exactRecord( + value, + ['command', 'config', 'operatorToken'], + [], + 'BOOTSTRAP_SCHEMA', + 'offline bootstrap request', + ); + const command = captureCommand(input.command); + const config = captureConfig(input.config); + validateOwnerAuthority(input.operatorToken, config); + const operatorIdHash = operatorIdentityHash(input.operatorToken); + const now = () => new Date().toISOString(); + const pathTrust = Object.freeze({ + mode: config.mode, + trustedAncestor: config.trustedAncestor, + kernelUid: config.kernelUid, + agentUid: config.expectedAgentUid, + }); + + let lock; + let context; + let prepared = null; + let operationSucceeded = false; + let cleanupFailure = null; + let result; + try { + try { + lock = acquireAuthorityLock({ + databasePath: config.databasePath, + role: 'bootstrap', + pathTrust, + }); + } catch (cause) { + recoveryRequired(cause); + } + + if (command.name === 'policy-validate' || command.name === 'policy-apply') { + prepared = readPolicy(command, config); + } else if (command.name === 'agent-enroll') { + prepared = readEnrollmentDescriptor(command, config); + } else if (command.name === 'isolation-attest') { + prepared = readIsolationReport(command, config, now); + } + + if (command.name === 'policy-validate') { + result = frozenCopy({ policy: prepared.policy, policyHash: prepared.policyHash }); + operationSucceeded = true; + } else { + try { + context = openRecoveryContext(config, now); + } catch (cause) { + recoveryRequired(cause); + } + let recovery; + try { + recovery = recoverKernelAuthority(context.recoveryDependencies); + } catch (cause) { + recoveryRequired(cause); + } + + if (command.name === 'preflight') { + result = frozenCopy({ state: 'healthy', recovery }); + } else if (command.name === 'policy-apply') { + result = context.policies.apply(prepared.policy, now()); + } else if (command.name === 'agent-enroll') { + result = projectEnrollment(context.enrollments.enroll({ + descriptor: prepared.descriptor, + expectedDescriptorHash: prepared.descriptorHash, + operatorIdHash, + mode: config.mode, + kernelUid: config.kernelUid, + kernelGid: config.kernelGid, + expectedAgentUid: config.expectedAgentUid, + expectedAgentGid: config.expectedAgentGid, + })); + } else if (command.name === 'isolation-attest') { + const active = context.enrollments.active(); + if (active === null || active.enrollmentHash !== prepared.report.enrollmentHash) { + fail('ISOLATION_ENROLLMENT', 'isolation report does not bind the active enrollment'); + } + result = context.attestations.importCurrent({ + reportBytes: prepared.bytes, + expectedReportHash: prepared.reportHash, + operatorIdHash, + }); + } + operationSucceeded = true; + } + } finally { + if (context) { + try { + context.close(); + } catch (cause) { + cleanupFailure ??= cause; + } + } + if (lock) { + try { + lock.close(); + } catch (cause) { + cleanupFailure ??= cause; + } + } + if (prepared?.bytes && Buffer.isBuffer(prepared.bytes)) prepared.bytes.fill(0); + if (operationSucceeded && cleanupFailure !== null) recoveryRequired(cleanupFailure); + } + return result; +} diff --git a/spikes/pi-wielder/src/operator/api.mjs b/spikes/pi-wielder/src/operator/api.mjs new file mode 100644 index 0000000..b9edc93 --- /dev/null +++ b/spikes/pi-wielder/src/operator/api.mjs @@ -0,0 +1,855 @@ +import { types as utilTypes } from 'node:util'; + +import { Hono } from 'hono'; + +import { + canonicalJson, + exactRecord, + KernelError, + sha256, +} from '../kernel/canonical.mjs'; +import { validatePolicyDocument } from '../kernel/policy-engine.mjs'; + +const AUTH_METHODS = Object.freeze([ + 'authenticateBearer', + 'authenticateBrowser', + 'exchangeBrowserSession', + 'issueBrowserLaunch', + 'revokeBrowserSession', +]); + +const SERVICE_METHODS = Object.freeze([ + 'overview', + 'listPolicies', + 'walletIdentity', + 'applyPolicy', + 'revokeAgent', + 'transitionSessionPolicy', + 'closeSession', + 'listApprovals', + 'approvePending', + 'denyPending', + 'listReceipts', + 'getReceipt', + 'reconcilePayment', + 'reconcileExecution', + 'reconcileRefundObservation', + 'abandonCandidate', + 'exportSession', + 'receiptPublicKey', +]); + +const APPROVAL_STATES = new Set([ + 'pending', + 'approved', + 'denied', + 'expired', + 'cancelled', +]); +const RECONCILIATION_KINDS = new Set(['payment', 'execution', 'refund-observation']); +const ABANDON_KINDS = new Set(['payment', 'refund-observation']); +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const TRANSACTION_PATTERN = /^0x[0-9a-f]{64}$/; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const PUBLIC_RESULT_FIELDS = new Set([ + 'accepted', 'acceptedIndex', 'active', 'adapterId', 'address', 'admission', + 'agent', 'agentEnrollment', 'agentGid', 'agentInstanceId', 'agentUid', 'algorithm', + 'amountAtomic', + 'approval', 'approvalId', 'approvalTtlMs', 'approvals', 'asset', 'authorizationNonce', + 'approved', 'authoritySchemaVersion', 'autoApproveAtomic', 'availableAtomic', 'binding', + 'blockedIntentCount', 'blockedSessionIds', 'blockedSessions', 'blockers', 'budget', + 'budgetReservations', 'budgets', 'buyerOutcomes', 'cancelled', 'candidate', 'caseHash', + 'commandState', 'committedAtomic', 'consumed', + 'challengeHash', 'challengeMaxAgeMs', 'chargedAtomic', 'closedAt', 'closedSessionHash', + 'consumedAt', 'correlationHash', 'createdAt', 'credentialHash', 'decision', 'defaultAction', + 'denied', 'deployment', + 'disposition', 'domain', 'enrolledAt', 'enrollment', 'enrollmentHash', 'eventHash', + 'eventHead', 'eventHeadHash', 'events', 'evidencePath', 'execution', 'executionCaseHash', + 'executionOutcomes', 'executionResolutions', 'executionSigner', 'expiresAt', + 'expired', 'exposureAtomic', 'hash', 'health', 'historyHashes', 'httpStatus', + 'humanApproveAtomic', 'id', 'idempotent', 'identityHash', 'intent', 'intentHash', + 'intentId', 'intents', 'isolation', 'issuedAt', 'items', 'keyId', 'kind', 'lastSeenAt', + 'localBinding', 'maxPendingApprovals', 'metadataHash', 'method', 'methods', 'network', + 'openCount', 'operation', 'operatorIdHash', 'origin', 'originalTransactionId', 'outcome', + 'pathPrefixes', 'payTo', 'pending', + 'payment', 'paymentAttempts', 'paymentCaseHash', 'paymentTransactionId', + 'perRequestMaxAtomic', 'policy', 'policyHash', 'policyVersion', 'policyVersionId', + 'policies', 'policyVersions', 'predecessorHash', 'preflightDigest', 'previousEventHash', + 'previousSession', 'projection', + 'projectionHash', 'publicKeyPem', 'purposeHash', 'purposeLabel', 'quoteId', + 'reasonCode', 'receipt', 'receiptHash', 'receiptId', 'receipts', 'reconciliation', + 'reconciliations', 'recordedAt', 'refund', 'refundCaseHash', 'refundSigner', + 'refundSource', 'refundTransactionId', 'refunds', 'remainingSessionAtomic', + 'releasedAtomic', 'replacementSession', 'replacementSessionHash', 'requestHash', 'requestId', + 'requestIdHash', + 'requestUrlHash', 'reservedAtomic', 'resourceHash', 'resourcePath', 'revision', + 'revokedAt', 'rolling24hMaxAtomic', 'routeHash', 'schemaVersion', 'sellerOrigin', 'session', + 'sellerSessionMaxAtomic', 'sellers', 'sessionHash', 'sessionId', 'sessionMaxAtomic', + 'sessionPolicyHash', 'sessionState', 'sessions', 'signature', 'signedReceipts', 'state', + 'status', 'supersedesReceiptHash', 'terminalReceipts', + 'terminalState', 'tokenContract', 'transactionId', 'transactionPrefix', 'unresolvedAtomic', 'updatedAt', + 'url', 'versionId', 'wallet', 'walletAddress', 'walletBlocked', 'authorizationState', + 'activePolicyHash', 'adapterHash', 'reasonCodes', 'responseHash', +]); +const PUBLIC_OPERATIONS = new Set(SERVICE_METHODS); +const RAW_HASH_PATTERN = /^[0-9a-f]{64}$/; +const SIGNATURE_PATTERN = /^(?:[A-Za-z0-9+/]{4}){21}[A-Za-z0-9+/][AQgw]==$/; +const PUBLIC_KERNEL_CODES = new Set([ + 'AGENT_DESCRIPTOR_HASH', 'AGENT_ENROLLMENT_AMBIGUOUS', 'AGENT_ENROLLMENT_CONFLICT', + 'AGENT_ENROLLMENT_CORRUPTION', 'AGENT_ENROLLMENT_REQUIRED', 'AGENT_ENROLLMENT_STALE', + 'AGENT_IDENTITY_MISMATCH', 'AGENT_REVOKED', 'AGENT_SESSION_UNAVAILABLE', + 'APPROVAL_AUTHORITY_INACTIVE', 'APPROVAL_BINDING_MISMATCH', 'APPROVAL_CAPACITY', + 'APPROVAL_CORRUPTION', 'APPROVAL_DENIAL_REASON', 'APPROVAL_EXPIRED', + 'APPROVAL_STATE_CONFLICT', 'APPROVAL_UNKNOWN', 'AUTHORITY_BUSY', + 'AUTHORITY_RECOVERY_REQUIRED', 'AUTHORITY_SEMANTIC_CORRUPTION', 'AUTHORITY_UNHEALTHY', + 'BUDGET_CORRUPTION', 'INTENT_UNKNOWN', 'OPERATOR_BODY_FORBIDDEN', + 'OPERATOR_BODY_SCHEMA', 'OPERATOR_BODY_TOO_LARGE', 'OPERATOR_CONTENT_TYPE', + 'OPERATOR_CAPACITY', 'OPERATOR_IDENTIFIER', 'OPERATOR_QUERY_SCHEMA', 'OPERATOR_SERVICE_RESULT', + 'OPERATOR_UNAUTHORIZED', 'POLICY_CONFIRMATION_STALE', 'POLICY_CORRUPTION', + 'POLICY_ADDRESS', 'POLICY_APPROVAL_CAPACITY', 'POLICY_ASSET', 'POLICY_ATOMIC', + 'POLICY_DEFAULT', 'POLICY_EVIDENCE_PATH', 'POLICY_HASH_MISMATCH', 'POLICY_LIMIT_ORDER', + 'POLICY_METHODS', 'POLICY_NETWORK', 'POLICY_NOT_ACTIVE', 'POLICY_PATH_DUPLICATE', + 'POLICY_RESOURCE_PATH', 'POLICY_SCHEMA', 'POLICY_SCHEMA_VERSION', 'POLICY_SELLERS', + 'POLICY_SELLER_DUPLICATE', 'POLICY_SELLER_ORIGIN', 'POLICY_TIME', + 'POLICY_TRANSITION_REQUIRED', 'POLICY_VERSION_MISSING', 'POLICY_WALLET_MISMATCH', + 'PROJECTION_CORRUPTION', + 'PROJECTION_EVENT_CHAIN', 'PROJECTION_SANITIZATION', 'PROJECTION_SIGNATURE', + 'RECEIPT_CONFLICT', 'RECEIPT_CORRUPTION', 'RECEIPT_PARITY_REQUIRED', + 'RECEIPT_REVISION', 'RECEIPT_SIGNATURE', 'RECONCILIATION_CONFLICT', + 'RECONCILIATION_CORRUPTION', 'RECONCILIATION_EVIDENCE', 'RECONCILIATION_INPUT', + 'RECONCILIATION_KIND', 'RECONCILIATION_MISMATCH', 'RECONCILIATION_STATE', + 'RECONCILIATION_TIME', 'RECOVERY_ONLY_OPERATION_FORBIDDEN', + 'SESSION_AUTHORITY_AMBIGUOUS', 'SESSION_CONFIRMATION_STALE', + 'SESSION_CLOSE_BLOCKED', 'SESSION_MONETARY_AMBIGUITY', 'SESSION_STATE', + 'SESSION_STATE_CONFLICT', 'SESSION_TRANSITION_BLOCKED', + 'SESSION_UNKNOWN', 'TRANSACTION_REUSED', 'WALLET_ROTATION_REQUIRES_OFFLINE_RESTART', +]); + +function fail(code, message) { + throw new KernelError(code, message); +} + +export function projectOperatorPublicResult( + value, + ancestors = new Set(), + path = [], + state = { nodes: 0 }, +) { + state.nodes += 1; + if (state.nodes > 20_000 || path.length > 64) { + throw new TypeError('operator service result exceeds the public projection boundary'); + } + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || Object.is(value, -0)) { + throw new TypeError('operator service result number is not canonical'); + } + return value; + } + if (typeof value === 'string') { + const field = path.at(-1); + if (Buffer.byteLength(value, 'utf8') > 65_536 || value.includes('\0')) { + throw new TypeError('operator service result string is outside the public boundary'); + } + if (field === 'operation' && !PUBLIC_OPERATIONS.has(value)) { + throw new TypeError('operator service operation is not public'); + } + if (field === 'reasonCode' && !/^[A-Z][A-Z0-9_]{0,127}$/.test(value)) { + throw new TypeError('operator service reason code is not public'); + } + if (field?.endsWith('Hash') && field !== 'receiptHash' + && field !== 'supersedesReceiptHash' + && !HASH_PATTERN.test(value)) { + throw new TypeError('operator service hash is not canonical'); + } + if ((field === 'receiptHash' || field === 'supersedesReceiptHash') + && value !== null && !HASH_PATTERN.test(value) && !RAW_HASH_PATTERN.test(value)) { + throw new TypeError('operator signed receipt hash is not canonical'); + } + return value; + } + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || ancestors.has(value)) { + throw new TypeError('operator service result must be inert acyclic data'); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype || value.length > 10_000) { + throw new TypeError('operator service result array is outside the public boundary'); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Reflect.ownKeys(value).length !== value.length + 1) { + throw new TypeError('operator service result array must be dense data'); + } + const copy = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError('operator service result array must contain data fields'); + } + copy.push(projectOperatorPublicResult(descriptor.value, ancestors, path, state)); + } + return Object.freeze(copy); + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError('operator service result object must be plain data'); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string' + || !descriptors[key]?.enumerable + || !Object.hasOwn(descriptors[key], 'value') + || !PUBLIC_RESULT_FIELDS.has(key))) { + throw new TypeError('operator service result contains a non-public field'); + } + if (Object.hasOwn(descriptors, 'operatorIdHash')) { + if (!path.includes('receipt') + || (descriptors.operatorIdHash.value !== null + && (typeof descriptors.operatorIdHash.value !== 'string' + || !HASH_PATTERN.test(descriptors.operatorIdHash.value)))) { + throw new TypeError('operator identity is outside this public projection'); + } + } + if (Object.hasOwn(descriptors, 'authorizationState') + && typeof descriptors.authorizationState.value !== 'boolean') { + throw new TypeError('operator authorization state is not public'); + } + if (Object.hasOwn(descriptors, 'tokenContract') + && (typeof descriptors.tokenContract.value !== 'string' + || !ADDRESS_PATTERN.test(descriptors.tokenContract.value))) { + throw new TypeError('operator token contract is not canonical public data'); + } + if (Object.hasOwn(descriptors, 'signature')) { + const signature = descriptors.signature.value; + const signedReceipt = descriptors.algorithm?.value === 'Ed25519' + && typeof descriptors.keyId?.value === 'string' + && HASH_PATTERN.test(descriptors.keyId.value) + && typeof descriptors.receiptHash?.value === 'string' + && RAW_HASH_PATTERN.test(descriptors.receiptHash.value) + && Object.hasOwn(descriptors, 'receipt'); + const signedProjection = descriptors.domain?.value === 'wallet-kernel.projection-export.v1' + && descriptors.algorithm?.value === 'Ed25519' + && typeof descriptors.keyId?.value === 'string' + && HASH_PATTERN.test(descriptors.keyId.value) + && typeof descriptors.projectionHash?.value === 'string' + && HASH_PATTERN.test(descriptors.projectionHash.value) + && Object.hasOwn(descriptors, 'projection'); + if (typeof signature !== 'string' || !SIGNATURE_PATTERN.test(signature) + || (!signedReceipt && !signedProjection)) { + throw new TypeError('operator signature is outside a closed public bundle'); + } + } + const copy = {}; + for (const key of keys) { + copy[key] = projectOperatorPublicResult( + descriptors[key].value, + ancestors, + [...path, key], + state, + ); + } + return Object.freeze(copy); + } finally { + ancestors.delete(value); + } +} + +function ownDataRecord(value, names, label) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError(`${label} must be one plain object`); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== names.length + || keys.some((key) => typeof key !== 'string' || !names.includes(key))) { + throw new TypeError(`${label} has an invalid shape`); + } + const result = {}; + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError(`${label} ${name} must be an enumerable data property`); + } + result[name] = descriptor.value; + } + return result; +} + +function exactFunctionSurface(value, names, label) { + const result = ownDataRecord(value, names, label); + for (const name of names) { + if (typeof result[name] !== 'function' || utilTypes.isProxy(result[name])) { + throw new TypeError(`${label} ${name} must be a non-proxy function`); + } + } + return Object.freeze(result); +} + +function validateOrigin(origin, mode) { + if (typeof origin !== 'string') throw new TypeError('operator origin must be a string'); + let parsed; + try { + parsed = new URL(origin); + } catch { + throw new TypeError('operator origin must be the fixed loopback console origin'); + } + const port = Number(parsed.port); + const validPort = parsed.port !== '' + && Number.isSafeInteger(port) + && port >= 1 + && port <= 65_535 + && String(port) === parsed.port; + if (!['http:', 'https:'].includes(parsed.protocol) + || parsed.hostname !== '127.0.0.1' + || !validPort + || (mode === 'cdp-testnet' && parsed.port !== '8405') + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '' + || parsed.origin !== origin) { + throw new TypeError('operator origin must be the fixed loopback console origin'); + } + return origin; +} + +function validateDependencies(value) { + const dependencies = ownDataRecord(value, [ + 'auth', + 'services', + 'bodyLimits', + 'mode', + 'transport', + 'origin', + ], 'operator API dependencies'); + if (dependencies.mode !== 'deterministic' && dependencies.mode !== 'cdp-testnet') { + throw new TypeError('operator API mode is invalid'); + } + const legalTransport = (dependencies.mode === 'deterministic' + && dependencies.transport === 'loopback-demo') + || (dependencies.mode === 'cdp-testnet' + && ['unix', 'socket-activated-loopback'].includes(dependencies.transport)); + if (!legalTransport) throw new TypeError('operator API transport is invalid for its mode'); + const limits = ownDataRecord(dependencies.bodyLimits, ['jsonBytes'], 'operator body limits'); + if (!Number.isSafeInteger(limits.jsonBytes) + || limits.jsonBytes < 64 + || limits.jsonBytes > 1_048_576) { + throw new TypeError('operator JSON body limit is invalid'); + } + return Object.freeze({ + auth: exactFunctionSurface(dependencies.auth, AUTH_METHODS, 'operator auth'), + services: exactFunctionSurface(dependencies.services, SERVICE_METHODS, 'operator services'), + jsonBytes: limits.jsonBytes, + mode: dependencies.mode, + transport: dependencies.transport, + origin: validateOrigin(dependencies.origin, dependencies.mode), + }); +} + +function canonicalIdentifier(value, label) { + if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) { + fail('OPERATOR_IDENTIFIER', `${label} must be one bounded canonical identifier`); + } + return value; +} + +function canonicalHash(value, label) { + if (typeof value !== 'string' || !HASH_PATTERN.test(value)) { + fail('OPERATOR_BODY_SCHEMA', `${label} must be one canonical SHA-256 hash`); + } + return value; +} + +function canonicalTransaction(value, label) { + if (typeof value !== 'string' || !TRANSACTION_PATTERN.test(value)) { + fail('OPERATOR_BODY_SCHEMA', `${label} must be one canonical lowercase transaction hash`); + } + return value; +} + +function urlFor(context) { + return new URL(context.req.url); +} + +function requireCanonicalPath(context) { + if (urlFor(context).pathname.includes('%')) { + fail('OPERATOR_IDENTIFIER', 'operator route identifiers must not be percent-encoded'); + } +} + +function requireNoQuery(context) { + if (urlFor(context).search !== '') { + fail('OPERATOR_QUERY_SCHEMA', 'operator route does not accept query parameters'); + } +} + +function requireNoBody(context) { + const request = context.req.raw; + const declared = request.headers.get('content-length'); + if (request.body !== null + || (declared !== null && declared !== '0') + || request.headers.has('transfer-encoding') + || request.headers.has('content-type')) { + fail('OPERATOR_BODY_FORBIDDEN', 'operator route does not accept a request body'); + } +} + +function declaredLength(request, maximum) { + const value = request.headers.get('content-length'); + if (value === null) return null; + if (!/^(0|[1-9][0-9]*)$/.test(value) || !Number.isSafeInteger(Number(value))) { + fail('OPERATOR_BODY_SCHEMA', 'Content-Length must be canonical bounded decimal text'); + } + const length = Number(value); + if (length > maximum) { + fail('OPERATOR_BODY_TOO_LARGE', 'operator request body exceeds its byte limit'); + } + return length; +} + +async function readBoundedJson(request, maximum) { + if (request.headers.get('content-type') !== 'application/json') { + fail('OPERATOR_CONTENT_TYPE', 'operator mutation requires application/json'); + } + const expectedLength = declaredLength(request, maximum); + if (request.body === null) fail('OPERATOR_BODY_SCHEMA', 'operator request body is required'); + + const chunks = []; + let length = 0; + const reader = request.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) { + fail('OPERATOR_BODY_SCHEMA', 'operator request body stream is invalid'); + } + length += value.byteLength; + if (length > maximum) { + await reader.cancel(); + fail('OPERATOR_BODY_TOO_LARGE', 'operator request body exceeds its byte limit'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + if (expectedLength !== null && expectedLength !== length) { + fail('OPERATOR_BODY_SCHEMA', 'operator request body length changed'); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + let text; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + fail('OPERATOR_BODY_SCHEMA', 'operator request body must be valid UTF-8 JSON'); + } + let parsed; + try { + parsed = JSON.parse(text); + } catch { + fail('OPERATOR_BODY_SCHEMA', 'operator request body must be valid JSON'); + } + try { + if (canonicalJson(parsed) !== text) { + fail('OPERATOR_BODY_SCHEMA', 'operator request body must be exact canonical JSON'); + } + } catch (error) { + if (error instanceof KernelError && error.code === 'OPERATOR_BODY_SCHEMA') throw error; + fail('OPERATOR_BODY_SCHEMA', 'operator request body must be inert canonical JSON'); + } + return parsed; +} + +function exactBody(value, required, optional = []) { + return exactRecord( + value, + required, + optional, + 'OPERATOR_BODY_SCHEMA', + 'operator request body', + ); +} + +function responseStatus(error) { + const code = error instanceof KernelError + && typeof error.code === 'string' + && PUBLIC_KERNEL_CODES.has(error.code) + ? error.code + : null; + if (code === null) return 500; + if (code === 'OPERATOR_UNAUTHORIZED') return 401; + if (code === 'OPERATOR_CAPACITY') return 429; + if (code === 'OPERATOR_BODY_TOO_LARGE') return 413; + if (code.endsWith('_UNKNOWN') || code === 'INTENT_UNKNOWN') return 404; + if (code.includes('CONFLICT') + || code.includes('STALE') + || code.includes('MISMATCH') + || code.endsWith('_BLOCKED') + || code === 'RECOVERY_ONLY_OPERATION_FORBIDDEN' + || code === 'RECONCILIATION_STATE' + || code === 'WALLET_ROTATION_REQUIRES_OFFLINE_RESTART') return 409; + if (code === 'RECEIPT_PARITY_REQUIRED' + || code === 'AUTHORITY_UNHEALTHY' + || code === 'AUTHORITY_RECOVERY_REQUIRED') return 503; + return 400; +} + +function publicError(error) { + if (error instanceof KernelError + && typeof error.code === 'string' + && PUBLIC_KERNEL_CODES.has(error.code)) { + const status = responseStatus(error); + let message = 'operator request was rejected'; + if (status === 401) message = 'operator authentication failed'; + if (status === 404) message = 'operator resource was not found'; + if (status === 409) message = 'operator confirmation conflicts with current authority'; + if (status === 413) message = 'operator request body exceeds its byte limit'; + if (status === 503) message = 'wallet authority requires recovery before this operation'; + return Object.freeze({ code: error.code, message }); + } + return Object.freeze({ code: 'OPERATOR_INTERNAL', message: 'operator request failed' }); +} + +export function createOperatorApp(value) { + const { + auth, + services, + jsonBytes, + transport, + origin, + } = validateDependencies(value); + const combinedDemoChannel = transport === 'loopback-demo'; + const adminChannel = transport === 'unix' || combinedDemoChannel; + const consoleChannel = transport === 'socket-activated-loopback' || combinedDemoChannel; + const app = new Hono({ strict: true }); + + app.use('*', async (context, next) => { + context.header('Cache-Control', 'no-store'); + let requestOrigin; + try { + requestOrigin = new URL(context.req.url).origin; + } catch { + fail('OPERATOR_UNAUTHORIZED', 'operator authentication failed'); + } + if (requestOrigin !== origin) { + fail('OPERATOR_UNAUTHORIZED', 'operator authentication failed'); + } + if (context.req.method === 'HEAD') { + return context.body(null, 404); + } + await next(); + }); + + app.onError((error, context) => { + const projected = publicError(error); + return context.json({ ok: false, error: projected }, responseStatus(error)); + }); + app.notFound((context) => context.json({ + ok: false, + error: { + code: 'OPERATOR_ROUTE_NOT_FOUND', + message: 'operator route does not exist', + }, + }, 404)); + + const authenticate = async (context, { mutation, requiredChannel = null }) => { + let principal; + const hasBearer = context.req.raw.headers.has('authorization'); + const hasCookie = context.req.raw.headers.has('cookie'); + if (requiredChannel === 'admin') { + if (!adminChannel) fail('OPERATOR_UNAUTHORIZED', 'operator authentication failed'); + principal = await auth.authenticateBearer(context.req.raw, { transport }); + } else if (combinedDemoChannel && hasBearer !== hasCookie) { + principal = hasBearer + ? await auth.authenticateBearer(context.req.raw, { transport }) + : await auth.authenticateBrowser(context.req.raw, { mutation }); + } else if (transport === 'socket-activated-loopback') { + principal = await auth.authenticateBrowser(context.req.raw, { mutation }); + } else if (transport === 'unix') { + principal = await auth.authenticateBearer(context.req.raw, { transport }); + } else { + fail('OPERATOR_UNAUTHORIZED', 'operator authentication failed'); + } + const operatorHashDescriptor = principal && typeof principal === 'object' + ? Object.getOwnPropertyDescriptor(principal, 'operatorIdHash') + : null; + if (!principal || typeof principal !== 'object' || Array.isArray(principal) + || utilTypes.isProxy(principal) + || Object.getPrototypeOf(principal) !== Object.prototype + || Reflect.ownKeys(principal).length !== 1 + || !operatorHashDescriptor?.enumerable + || !Object.hasOwn(operatorHashDescriptor, 'value') + || typeof operatorHashDescriptor.value !== 'string' + || !HASH_PATTERN.test(operatorHashDescriptor.value)) { + fail('OPERATOR_UNAUTHORIZED', 'operator authentication failed'); + } + return Object.freeze({ + operatorIdHash: operatorHashDescriptor.value, + }); + }; + + const readMutation = async (context, required, optional = []) => { + requireCanonicalPath(context); + requireNoQuery(context); + return exactBody(await readBoundedJson(context.req.raw, jsonBytes), required, optional); + }; + + const success = (context, data) => context.json({ + ok: true, + data: projectOperatorPublicResult(data), + }); + + app.post('/operator/v1/browser-launch', async (context) => { + if (!adminChannel) fail('OPERATOR_UNAUTHORIZED', 'browser launch requires the admin channel'); + requireNoQuery(context); + requireNoBody(context); + await authenticate(context, { mutation: true, requiredChannel: 'admin' }); + return success(context, await auth.issueBrowserLaunch({ transport })); + }); + + app.post('/operator/v1/session', async (context) => { + if (!consoleChannel) fail('OPERATOR_UNAUTHORIZED', 'session exchange requires console channel'); + requireNoQuery(context); + declaredLength(context.req.raw, jsonBytes); + return await auth.exchangeBrowserSession(context.req.raw); + }); + + app.delete('/operator/v1/session', async (context) => { + if (!consoleChannel) fail('OPERATOR_UNAUTHORIZED', 'session deletion requires console channel'); + requireNoQuery(context); + requireNoBody(context); + return await auth.revokeBrowserSession(context.req.raw); + }); + + app.get('/operator/v1/overview', async (context) => { + requireNoQuery(context); + await authenticate(context, { mutation: false }); + return success(context, await services.overview({})); + }); + + app.get('/operator/v1/policies', async (context) => { + requireNoQuery(context); + await authenticate(context, { mutation: false }); + return success(context, await services.listPolicies({})); + }); + + app.post('/operator/v1/policies/validate', async (context) => { + await authenticate(context, { mutation: true }); + const body = await readMutation(context, ['document']); + const policy = validatePolicyDocument(body.document); + return success(context, Object.freeze({ + policy, + policyHash: sha256(canonicalJson(policy)), + })); + }); + + app.post('/operator/v1/policies/apply', async (context) => { + await authenticate(context, { mutation: true }); + const body = await readMutation(context, ['document', 'expectedPolicyHash']); + const expectedPolicyHash = canonicalHash(body.expectedPolicyHash, 'expected policy hash'); + const document = validatePolicyDocument(body.document); + const actualPolicyHash = sha256(canonicalJson(document)); + if (actualPolicyHash !== expectedPolicyHash) { + fail('POLICY_CONFIRMATION_STALE', 'displayed policy hash differs from submitted policy'); + } + const wallet = await services.walletIdentity({}); + if (!wallet || typeof wallet !== 'object' || Array.isArray(wallet) + || utilTypes.isProxy(wallet) || Object.getPrototypeOf(wallet) !== Object.prototype + || Reflect.ownKeys(wallet).length !== 1 || !Object.hasOwn(wallet, 'address') + || typeof wallet.address !== 'string' || !ADDRESS_PATTERN.test(wallet.address)) { + fail('OPERATOR_SERVICE_RESULT', 'running wallet identity is invalid'); + } + if (wallet.address !== document.wallet) { + fail( + 'WALLET_ROTATION_REQUIRES_OFFLINE_RESTART', + 'policy wallet differs from the running wallet identity', + ); + } + return success(context, await services.applyPolicy(Object.freeze({ + document, + expectedPolicyHash, + }))); + }); + + app.post('/operator/v1/agents/:agentInstanceId/revoke', async (context) => { + const principal = await authenticate(context, { mutation: true }); + requireCanonicalPath(context); + const agentInstanceId = canonicalIdentifier(context.req.param('agentInstanceId'), 'agent ID'); + const body = await readMutation(context, ['expectedEnrollmentHash']); + return success(context, await services.revokeAgent(Object.freeze({ + agentInstanceId, + expectedEnrollmentHash: canonicalHash(body.expectedEnrollmentHash, 'enrollment hash'), + operatorIdHash: principal.operatorIdHash, + }))); + }); + + app.post('/operator/v1/sessions/:sessionId/transition-policy', async (context) => { + await authenticate(context, { mutation: true }); + requireCanonicalPath(context); + const sessionId = canonicalIdentifier(context.req.param('sessionId'), 'session ID'); + const body = await readMutation(context, ['targetPolicyHash', 'expectedSessionHash']); + return success(context, await services.transitionSessionPolicy(Object.freeze({ + sessionId, + targetPolicyHash: canonicalHash(body.targetPolicyHash, 'target policy hash'), + expectedSessionHash: canonicalHash(body.expectedSessionHash, 'session hash'), + }))); + }); + + app.post('/operator/v1/sessions/:sessionId/close', async (context) => { + await authenticate(context, { mutation: true }); + requireCanonicalPath(context); + const sessionId = canonicalIdentifier(context.req.param('sessionId'), 'session ID'); + const body = await readMutation(context, ['expectedSessionHash']); + return success(context, await services.closeSession(Object.freeze({ + sessionId, + expectedSessionHash: canonicalHash(body.expectedSessionHash, 'session hash'), + }))); + }); + + app.get('/operator/v1/approvals', async (context) => { + await authenticate(context, { mutation: false }); + const entries = [...urlFor(context).searchParams.entries()]; + if (entries.length > 1 + || (entries.length === 1 + && (entries[0][0] !== 'state' || !APPROVAL_STATES.has(entries[0][1])))) { + fail('OPERATOR_QUERY_SCHEMA', 'approval query is invalid'); + } + return success(context, await services.listApprovals({ + state: entries.length === 0 ? null : entries[0][1], + })); + }); + + app.post('/operator/v1/approvals/:approvalId/approve', async (context) => { + const principal = await authenticate(context, { mutation: true }); + requireCanonicalPath(context); + const approvalId = canonicalIdentifier(context.req.param('approvalId'), 'approval ID'); + const body = await readMutation(context, ['expectedIntentHash']); + return success(context, await services.approvePending(Object.freeze({ + approvalId, + expectedIntentHash: canonicalHash(body.expectedIntentHash, 'intent hash'), + operatorIdHash: principal.operatorIdHash, + }))); + }); + + app.post('/operator/v1/approvals/:approvalId/deny', async (context) => { + const principal = await authenticate(context, { mutation: true }); + requireCanonicalPath(context); + const approvalId = canonicalIdentifier(context.req.param('approvalId'), 'approval ID'); + const body = await readMutation(context, ['expectedIntentHash', 'reasonCode']); + if (body.reasonCode !== 'OPERATOR_DENIED') { + fail('APPROVAL_DENIAL_REASON', 'operator denial reason must be OPERATOR_DENIED'); + } + return success(context, await services.denyPending(Object.freeze({ + approvalId, + expectedIntentHash: canonicalHash(body.expectedIntentHash, 'intent hash'), + operatorIdHash: principal.operatorIdHash, + reasonCode: body.reasonCode, + }))); + }); + + app.get('/operator/v1/receipts', async (context) => { + requireNoQuery(context); + await authenticate(context, { mutation: false }); + return success(context, await services.listReceipts({})); + }); + + app.get('/operator/v1/receipts/:receiptId', async (context) => { + requireCanonicalPath(context); + requireNoQuery(context); + await authenticate(context, { mutation: false }); + return success(context, await services.getReceipt({ + receiptId: canonicalIdentifier(context.req.param('receiptId'), 'receipt ID'), + })); + }); + + app.post( + '/operator/v1/reconciliations/:intentId/:kind/abandon-candidate', + async (context) => { + const principal = await authenticate(context, { mutation: true }); + requireCanonicalPath(context); + const intentId = canonicalIdentifier(context.req.param('intentId'), 'intent ID'); + const kind = context.req.param('kind'); + if (!ABANDON_KINDS.has(kind)) { + fail('RECONCILIATION_KIND', 'candidate abandonment kind is invalid'); + } + const body = await readMutation(context, ['expectedIntentHash', 'expectedCaseHash']); + return success(context, await services.abandonCandidate(Object.freeze({ + intentId, + kind, + operatorIdHash: principal.operatorIdHash, + expectedIntentHash: canonicalHash(body.expectedIntentHash, 'intent hash'), + expectedCaseHash: canonicalHash(body.expectedCaseHash, 'case hash'), + }))); + }, + ); + + app.post('/operator/v1/reconciliations/:intentId/:kind', async (context) => { + const principal = await authenticate(context, { mutation: true }); + requireCanonicalPath(context); + const intentId = canonicalIdentifier(context.req.param('intentId'), 'intent ID'); + const kind = context.req.param('kind'); + if (!RECONCILIATION_KINDS.has(kind)) { + fail('RECONCILIATION_KIND', 'reconciliation kind is invalid'); + } + if (kind === 'payment') { + const body = await readMutation( + context, + ['expectedIntentHash', 'expectedCaseHash'], + ['paymentTransactionId'], + ); + return success(context, await services.reconcilePayment(Object.freeze({ + intentId, + operatorIdHash: principal.operatorIdHash, + expectedIntentHash: canonicalHash(body.expectedIntentHash, 'intent hash'), + expectedPaymentCaseHash: canonicalHash(body.expectedCaseHash, 'payment case hash'), + paymentTransactionId: Object.hasOwn(body, 'paymentTransactionId') + ? canonicalTransaction(body.paymentTransactionId, 'payment transaction ID') + : null, + }))); + } + if (kind === 'execution') { + const body = await readMutation(context, ['expectedIntentHash', 'expectedCaseHash']); + return success(context, await services.reconcileExecution(Object.freeze({ + intentId, + operatorIdHash: principal.operatorIdHash, + expectedIntentHash: canonicalHash(body.expectedIntentHash, 'intent hash'), + expectedExecutionCaseHash: canonicalHash(body.expectedCaseHash, 'execution case hash'), + }))); + } + const body = await readMutation( + context, + ['expectedIntentHash', 'expectedCaseHash', 'refundTransactionId'], + ); + return success(context, await services.reconcileRefundObservation(Object.freeze({ + intentId, + operatorIdHash: principal.operatorIdHash, + expectedIntentHash: canonicalHash(body.expectedIntentHash, 'intent hash'), + expectedRefundCaseHash: canonicalHash(body.expectedCaseHash, 'refund case hash'), + refundTransactionId: canonicalTransaction(body.refundTransactionId, 'refund transaction ID'), + }))); + }); + + app.get('/operator/v1/exports/:sessionId', async (context) => { + requireCanonicalPath(context); + requireNoQuery(context); + await authenticate(context, { mutation: false }); + return success(context, await services.exportSession({ + sessionId: canonicalIdentifier(context.req.param('sessionId'), 'session ID'), + })); + }); + + app.get('/operator/v1/receipt-public-key', async (context) => { + requireNoQuery(context); + await authenticate(context, { mutation: false }); + return success(context, await services.receiptPublicKey({})); + }); + + return app; +} diff --git a/spikes/pi-wielder/src/operator/auth.mjs b/spikes/pi-wielder/src/operator/auth.mjs new file mode 100644 index 0000000..54f0178 --- /dev/null +++ b/spikes/pi-wielder/src/operator/auth.mjs @@ -0,0 +1,467 @@ +import crypto from 'node:crypto'; + +import { + canonicalJson, + KernelError, + sha256, +} from '../kernel/canonical.mjs'; +import { loadOrInitializePrivateFile } from '../kernel/secure-storage.mjs'; + +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const FORWARDED_HEADERS = Object.freeze([ + 'forwarded', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', +]); +const COOKIE_NAME = 'wallet_kernel_session'; +const LAUNCH_TTL_MS = 60_000; +const DEFAULT_SESSION_TTL_MS = 900_000; +const MAX_LAUNCHES = 128; +const MAX_SESSIONS = 128; +const MAX_EXCHANGE_BYTES = 512; +const AUTH_OPTION_FIELDS = Object.freeze([ + 'token', + 'mode', + 'origin', + 'now', + 'randomBytes', + 'sessionTtlMs', +]); + +function unauthorized() { + throw new KernelError('OPERATOR_UNAUTHORIZED', 'Operator authentication failed'); +} + +function configuration(message) { + throw new KernelError('OPERATOR_CONFIGURATION_INVALID', message); +} + +function captureOptions(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) { + configuration('Operator authentication options must be one plain object'); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string' || !AUTH_OPTION_FIELDS.includes(key))) { + configuration('Operator authentication options contain an unknown field'); + } + const captured = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + configuration('Operator authentication options must contain only data fields'); + } + captured[key] = descriptor.value; + } + return captured; +} + +function decodeToken(value, label = 'operator credential') { + if (typeof value !== 'string' || !TOKEN_PATTERN.test(value)) { + throw new Error(`${label} must be exactly 32 canonical base64url bytes`); + } + const bytes = Buffer.from(value, 'base64url'); + if (bytes.length !== 32 || bytes.toString('base64url') !== value) { + bytes.fill(0); + throw new Error(`${label} must be exactly 32 canonical base64url bytes`); + } + return bytes; +} + +function opaqueValue(randomBytes, label) { + let bytes; + try { + bytes = Buffer.from(randomBytes(32)); + } catch { + configuration(`${label} randomness failed`); + } + if (bytes.length !== 32) { + bytes.fill(0); + configuration(`${label} requires exactly 32 random bytes`); + } + const value = bytes.toString('base64url'); + bytes.fill(0); + if (!TOKEN_PATTERN.test(value)) configuration(`${label} encoding failed`); + return value; +} + +function digestBytes(domain, value) { + return crypto.createHash('sha256') + .update(domain, 'utf8') + .update(Buffer.from([0])) + .update(value, 'utf8') + .digest(); +} + +function digestKey(domain, value) { + const digest = digestBytes(domain, value); + try { + return digest.toString('hex'); + } finally { + digest.fill(0); + } +} + +function constantDigestMatch(domain, candidate, expected) { + const actual = digestBytes(domain, candidate); + try { + return crypto.timingSafeEqual(actual, expected); + } finally { + actual.fill(0); + } +} + +function validateOrigin(value) { + if (typeof value !== 'string') configuration('Operator origin must be canonical'); + let parsed; + try { + parsed = new URL(value); + } catch { + configuration('Operator origin must be canonical'); + } + if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + || parsed.username !== '' || parsed.password !== '' + || parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '' + || parsed.origin !== value) { + configuration('Operator origin must be one canonical HTTP origin'); + } + return parsed.origin; +} + +function nowValue(now) { + let value; + try { + value = now(); + } catch { + configuration('Operator clock failed'); + } + if (!Number.isSafeInteger(value) || value < 0) { + configuration('Operator clock must return nonnegative integer milliseconds'); + } + return value; +} + +function assertTransport(mode, transport) { + const expected = mode === 'cdp-testnet' ? 'unix' : 'loopback-demo'; + if (transport !== expected) unauthorized(); +} + +function rejectForwarding(headers) { + if (FORWARDED_HEADERS.some((name) => headers.has(name))) unauthorized(); +} + +function requestUrl(request, origin, pathname) { + let parsed; + try { + parsed = new URL(request.url); + } catch { + unauthorized(); + } + if (parsed.origin !== origin || parsed.pathname !== pathname + || parsed.search !== '' || parsed.hash !== '') { + unauthorized(); + } +} + +function suspiciousBearerQuery(request, ownerDigest) { + let parsed; + try { + parsed = new URL(request.url); + } catch { + return true; + } + const forbidden = new Set(['access_token', 'authorization', 'bearer', 'owner', 'token']); + for (const [key, value] of parsed.searchParams) { + if (forbidden.has(key.toLowerCase())) return true; + if (constantDigestMatch('wallet-kernel.operator-bearer.v1', value, ownerDigest)) return true; + } + return false; +} + +function pruneExpired(records, nowMs) { + for (const [key, value] of records) { + if (nowMs >= value.expiresAtMs) { + value.csrfDigest?.fill(0); + records.delete(key); + } + } +} + +async function boundedRequestText(request) { + const declared = request.headers.get('content-length'); + if (declared !== null) { + if (!/^(0|[1-9][0-9]*)$/.test(declared) + || Number(declared) > MAX_EXCHANGE_BYTES) unauthorized(); + } + if (!request.body) unauthorized(); + const reader = request.body.getReader(); + const chunks = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const bytes = Buffer.from(value); + length += bytes.length; + if (length > MAX_EXCHANGE_BYTES) unauthorized(); + chunks.push(bytes); + } + } catch (error) { + if (error instanceof KernelError) throw error; + unauthorized(); + } + let bytes = Buffer.concat(chunks, length); + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + unauthorized(); + } finally { + bytes.fill(0); + for (const chunk of chunks) chunk.fill(0); + } +} + +function exactSessionCookie(headers) { + const cookie = headers.get('cookie'); + if (cookie === null) unauthorized(); + const match = /^wallet_kernel_session=([A-Za-z0-9_-]{43})$/.exec(cookie); + if (!match) unauthorized(); + try { + const bytes = decodeToken(match[1], 'browser session'); + bytes.fill(0); + } catch { + unauthorized(); + } + return match[1]; +} + +function sessionCookie(value, secure) { + return `${COOKIE_NAME}=${value}; HttpOnly; SameSite=Strict; Path=/operator${ + secure ? '; Secure' : '' + }`; +} + +function clearedSessionCookie(secure) { + return `${COOKIE_NAME}=; Max-Age=0; HttpOnly; SameSite=Strict; Path=/operator${ + secure ? '; Secure' : '' + }`; +} + +export function loadOrCreateOperatorToken({ + filePath, + pathTrust, + randomBytes = crypto.randomBytes, +} = {}) { + if (typeof randomBytes !== 'function') { + throw new Error('Operator token randomness must be a function'); + } + return loadOrInitializePrivateFile({ + filePath, + label: 'Operator token', + createBytes: () => Buffer.from(opaqueValue(randomBytes, 'Operator token'), 'ascii'), + validateBytes(bytes) { + if (bytes.length !== 43 || bytes.some((byte) => byte > 0x7f)) { + throw new Error('Operator token must be exactly 43 ASCII bytes'); + } + const value = bytes.toString('ascii'); + const decoded = decodeToken(value, 'Operator token'); + decoded.fill(0); + return value; + }, + randomBytes, + pathTrust, + }); +} + +export function createOperatorAuth(options) { + const captured = captureOptions(options); + const mode = captured.mode; + if (mode !== 'deterministic' && mode !== 'cdp-testnet') { + configuration('Operator mode must be deterministic or cdp-testnet'); + } + const origin = validateOrigin(captured.origin); + const now = captured.now ?? Date.now; + const randomBytes = captured.randomBytes ?? crypto.randomBytes; + const sessionTtlMs = captured.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS; + if (typeof now !== 'function' || typeof randomBytes !== 'function' + || !Number.isSafeInteger(sessionTtlMs) || sessionTtlMs < 1 + || sessionTtlMs > 86_400_000) { + configuration('Operator runtime options are invalid'); + } + + let ownerBytes; + try { + ownerBytes = decodeToken(captured.token); + } catch { + configuration('Operator credential is invalid'); + } + const ownerDigest = digestBytes('wallet-kernel.operator-bearer.v1', captured.token); + const operatorIdHash = sha256(Buffer.concat([ + Buffer.from('wallet-kernel.operator-id.v1\0', 'utf8'), + ownerBytes, + ])); + ownerBytes.fill(0); + const principal = Object.freeze({ operatorIdHash }); + const launches = new Map(); + const sessions = new Map(); + const secure = origin.startsWith('https://'); + let lastNowMs = -1; + + function currentTime() { + const value = nowValue(now); + if (value < lastNowMs) configuration('Operator clock regressed'); + lastNowMs = value; + return value; + } + + function authenticateBearer(request, { transport } = {}) { + assertTransport(mode, transport); + if (!(request instanceof Request)) unauthorized(); + rejectForwarding(request.headers); + if (request.headers.has('cookie') || suspiciousBearerQuery(request, ownerDigest)) unauthorized(); + const authorization = request.headers.get('authorization'); + const match = typeof authorization === 'string' + ? /^Bearer ([A-Za-z0-9_-]{43})$/.exec(authorization) + : null; + const candidate = match?.[1] ?? ''; + const valid = constantDigestMatch( + 'wallet-kernel.operator-bearer.v1', + candidate, + ownerDigest, + ); + if (!match || !valid) unauthorized(); + return principal; + } + + function issueBrowserLaunch({ transport } = {}) { + assertTransport(mode, transport); + const nowMs = currentTime(); + pruneExpired(launches, nowMs); + if (launches.size >= MAX_LAUNCHES) { + throw new KernelError('OPERATOR_CAPACITY', 'Browser launch capacity is full'); + } + const capability = opaqueValue(randomBytes, 'Browser launch'); + const key = digestKey('wallet-kernel.browser-launch.v1', capability); + if (launches.has(key)) configuration('Browser launch randomness collided'); + const expiresAtMs = nowMs + LAUNCH_TTL_MS; + if (!Number.isSafeInteger(expiresAtMs)) configuration('Browser launch expiry overflowed'); + launches.set(key, Object.freeze({ expiresAtMs, origin })); + return Object.freeze({ + url: `${origin}/operator/#launch=${capability}`, + expiresAt: new Date(expiresAtMs).toISOString(), + }); + } + + async function exchangeBrowserSession(request) { + if (!(request instanceof Request) || request.method !== 'POST') unauthorized(); + requestUrl(request, origin, '/operator/v1/session'); + rejectForwarding(request.headers); + if (request.headers.has('authorization') || request.headers.has('cookie') + || request.headers.get('origin') !== origin + || request.headers.get('content-type') !== 'application/json') { + unauthorized(); + } + const body = await boundedRequestText(request); + let value; + try { + value = JSON.parse(body); + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype + || Reflect.ownKeys(value).length !== 1 + || !Object.hasOwn(value, 'launchToken') + || canonicalJson(value) !== body) { + unauthorized(); + } + } catch (error) { + if (error instanceof KernelError) throw error; + unauthorized(); + } + let launchBytes; + try { + launchBytes = decodeToken(value.launchToken, 'browser launch'); + } catch { + unauthorized(); + } finally { + launchBytes?.fill(0); + } + const nowMs = currentTime(); + pruneExpired(launches, nowMs); + pruneExpired(sessions, nowMs); + const launchKey = digestKey('wallet-kernel.browser-launch.v1', value.launchToken); + const launch = launches.get(launchKey); + if (!launch) unauthorized(); + launches.delete(launchKey); + if (nowMs >= launch.expiresAtMs || launch.origin !== origin) unauthorized(); + if (sessions.size >= MAX_SESSIONS) { + throw new KernelError('OPERATOR_CAPACITY', 'Browser session capacity is full'); + } + + const sessionValue = opaqueValue(randomBytes, 'Browser session'); + const csrfValue = opaqueValue(randomBytes, 'Browser CSRF'); + const sessionKey = digestKey('wallet-kernel.browser-session.v1', sessionValue); + if (sessions.has(sessionKey)) configuration('Browser session randomness collided'); + const csrfDigest = digestBytes('wallet-kernel.browser-csrf.v1', csrfValue); + const expiresAtMs = nowMs + sessionTtlMs; + if (!Number.isSafeInteger(expiresAtMs)) configuration('Browser session expiry overflowed'); + sessions.set(sessionKey, Object.freeze({ csrfDigest, expiresAtMs, origin })); + return new Response(null, { + status: 204, + headers: { + 'cache-control': 'no-store', + 'set-cookie': sessionCookie(sessionValue, secure), + 'x-csrf-token': csrfValue, + }, + }); + } + + function authenticateBrowser(request, { mutation } = {}) { + if (!(request instanceof Request) || typeof mutation !== 'boolean') unauthorized(); + rejectForwarding(request.headers); + if (request.headers.has('authorization')) unauthorized(); + const sessionValue = exactSessionCookie(request.headers); + const nowMs = currentTime(); + pruneExpired(sessions, nowMs); + const sessionKey = digestKey('wallet-kernel.browser-session.v1', sessionValue); + const session = sessions.get(sessionKey); + if (!session || nowMs >= session.expiresAtMs || session.origin !== origin) unauthorized(); + if (mutation) { + const csrf = request.headers.get('x-csrf-token') ?? ''; + const csrfValid = constantDigestMatch( + 'wallet-kernel.browser-csrf.v1', + csrf, + session.csrfDigest, + ); + if (request.headers.get('origin') !== origin || !TOKEN_PATTERN.test(csrf) || !csrfValid) { + unauthorized(); + } + } else if (request.headers.has('origin') && request.headers.get('origin') !== origin) { + unauthorized(); + } + return principal; + } + + function revokeBrowserSession(request) { + authenticateBrowser(request, { mutation: true }); + const sessionValue = exactSessionCookie(request.headers); + const key = digestKey('wallet-kernel.browser-session.v1', sessionValue); + const session = sessions.get(key); + sessions.delete(key); + session?.csrfDigest.fill(0); + return new Response(null, { + status: 204, + headers: { + 'cache-control': 'no-store', + 'set-cookie': clearedSessionCookie(secure), + }, + }); + } + + return Object.freeze({ + authenticateBearer, + authenticateBrowser, + exchangeBrowserSession, + issueBrowserLaunch, + revokeBrowserSession, + }); +} diff --git a/spikes/pi-wielder/src/operator/cli.mjs b/spikes/pi-wielder/src/operator/cli.mjs new file mode 100644 index 0000000..993325e --- /dev/null +++ b/spikes/pi-wielder/src/operator/cli.mjs @@ -0,0 +1,1037 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { types as utilTypes } from 'node:util'; + +import { canonicalJson } from '../kernel/canonical.mjs'; +import { runOfflineBootstrap } from '../offline-bootstrap.mjs'; +import { projectOperatorPublicResult } from './api.mjs'; + +const ORIGIN = 'http://127.0.0.1:8405'; +const MAXIMUM_ARGUMENTS = 64; +const MAXIMUM_ARGUMENT_BYTES = 4_096; +const MAXIMUM_RESPONSE_BYTES = 1_048_576; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/; +const SIGNED_RECEIPT_HASH_PATTERN = /^[0-9a-f]{64}$/; +const TRANSACTION_PATTERN = /^0x[0-9a-f]{64}$/; +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/; +const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; +const USAGE = 'usage: wallet-kernel [options]'; +const FORBIDDEN_OUTPUT_KEY_FRAGMENT = /raw|secret|credential|privatekey|seedphrase|mnemonic|password|token|bearer|authorization|prompt|body|content|evidence|payload|exception|stack|paymentpayload|paymentheader|apikey|signature/; +const PUBLIC_HASH_KEYS = new Set([ + 'ancestorMetadataHash', 'authorityMetadataHash', 'caseHash', 'closedSessionHash', + 'correlationHash', 'credentialDigest', 'credentialHash', 'credentialMetadataHash', 'enrollmentHash', + 'environmentMetadataHash', 'eventHash', 'eventHeadHash', 'executionCaseHash', + 'expectedCaseHash', 'expectedEnrollmentHash', 'expectedIntentHash', + 'expectedPolicyHash', 'expectedReportHash', 'expectedSessionHash', 'intentHash', + 'keyId', 'metadataHash', 'nodeExecutableHash', 'paymentCaseHash', 'policyHash', + 'previousEventHash', 'projectionHash', 'receiptHash', 'refundCaseHash', + 'releaseManifestHash', 'releaseTreeHash', 'replacementSessionHash', 'requestHash', + 'requestUrlHash', 'resourceHash', 'responseHash', 'serviceArtifactsHash', + 'sessionHash', 'supersedesReceiptHash', 'systemdEffectiveConfigHash', + 'transactionHash', +]); +const CANONICAL_SIGNATURE_PATTERN = /^(?:[A-Za-z0-9+/]{4}){21}[A-Za-z0-9+/][AQgw]==$/; + +const KNOWN_WALLET_KERNEL_ENVIRONMENT = new Set([ + 'WALLET_KERNEL_MODE', + 'WALLET_KERNEL_DB_FILE', + 'WALLET_KERNEL_RECEIPT_KEY_FILE', + 'WALLET_KERNEL_OPERATOR_TOKEN_FILE', + 'WALLET_KERNEL_TRUSTED_ANCESTOR', + 'WALLET_KERNEL_EXPECTED_AGENT_UID', + 'WALLET_KERNEL_EXPECTED_AGENT_GID', + 'WALLET_KERNEL_POLICY_FILE', + 'WALLET_KERNEL_ROUTE_FILE', + 'WALLET_KERNEL_PORT', + 'WALLET_KERNEL_OPERATOR_PORT', + 'WALLET_KERNEL_OPERATOR_SOCKET_FILE', + 'WALLET_KERNEL_ENROLLMENT_INBOX', + 'WALLET_KERNEL_AGENT_RUN_OUTBOX', + 'WALLET_KERNEL_RELEASE_ROOT', + 'WALLET_KERNEL_RELEASE_MANIFEST', + 'WALLET_KERNEL_SERVICE_DEFINITION_FILE', + 'WALLET_KERNEL_SOCKET_DEFINITION_FILE', + 'WALLET_KERNEL_ENV_FILE', + 'WALLET_KERNEL_EVIDENCE_ROOT', + 'WALLET_KERNEL_ISOLATION_REPORT_FILE', + 'WALLET_KERNEL_BASE_SEPOLIA_RPC_URL', +]); + +class CliError extends Error { + constructor(code, { usage = false } = {}) { + super(code); + this.name = 'CliError'; + this.code = code; + this.usage = usage; + } +} + +function fail(code, options) { + throw new CliError(code, options); +} + +function usage() { + fail('CLI_USAGE', { usage: true }); +} + +function isPlainDataObject(value) { + return value !== null + && typeof value === 'object' + && !Array.isArray(value) + && !utilTypes.isProxy(value) + && (Object.getPrototypeOf(value) === Object.prototype + || Object.getPrototypeOf(value) === null); +} + +function ownDataDescriptors(value, code) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) fail(code); + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Reflect.ownKeys(value).some((key) => typeof key !== 'string' + || !descriptors[key]?.enumerable + || !Object.hasOwn(descriptors[key], 'value'))) { + fail(code); + } + return descriptors; +} + +function captureArgv(argv) { + if (utilTypes.isProxy(argv) || !Array.isArray(argv) + || Object.getPrototypeOf(argv) !== Array.prototype + || argv.length > MAXIMUM_ARGUMENTS) usage(); + const descriptors = Object.getOwnPropertyDescriptors(argv); + const keys = Reflect.ownKeys(argv); + const length = descriptors.length?.value; + if (!Number.isSafeInteger(length) || keys.length !== length + 1) usage(); + const captured = []; + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) usage(); + const value = descriptor.value; + if (typeof value !== 'string' || value.length === 0 || value.includes('\0') + || Buffer.byteLength(value, 'utf8') > MAXIMUM_ARGUMENT_BYTES) usage(); + captured.push(value); + } + return captured; +} + +function captureEnvironment(env) { + const descriptors = ownDataDescriptors(env, 'CLI_CONFIG_INVALID'); + for (const [key, descriptor] of Object.entries(descriptors)) { + if (key.startsWith('WALLET_KERNEL_') && !KNOWN_WALLET_KERNEL_ENVIRONMENT.has(key)) { + fail('CLI_CONFIG_INVALID'); + } + if (typeof descriptor.value !== 'string') fail('CLI_CONFIG_INVALID'); + } + return descriptors; +} + +function environmentValue(descriptors, key, { required = false } = {}) { + const value = descriptors[key]?.value; + if (value === undefined || value === '') { + if (required) fail('CLI_CONFIG_INVALID'); + return null; + } + if (value.includes('\0')) fail('CLI_CONFIG_INVALID'); + return value; +} + +function canonicalAbsolutePath(value, code) { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0') + || !path.isAbsolute(value) || path.resolve(value) !== value + || (value !== path.parse(value).root && value.endsWith(path.sep))) { + fail(code); + } + return value; +} + +function captureConfig(env) { + const descriptors = captureEnvironment(env); + const mode = environmentValue(descriptors, 'WALLET_KERNEL_MODE', { required: true }); + if (mode !== 'deterministic' && mode !== 'cdp-testnet') fail('CLI_CONFIG_INVALID'); + const tokenPath = canonicalAbsolutePath( + environmentValue(descriptors, 'WALLET_KERNEL_OPERATOR_TOKEN_FILE', { required: true }), + 'CLI_CONFIG_INVALID', + ); + const databaseValue = environmentValue(descriptors, 'WALLET_KERNEL_DB_FILE'); + const databasePath = databaseValue === null + ? null + : canonicalAbsolutePath(databaseValue, 'CLI_CONFIG_INVALID'); + const receiptKeyValue = environmentValue(descriptors, 'WALLET_KERNEL_RECEIPT_KEY_FILE'); + const receiptKeyPath = receiptKeyValue === null + ? null + : canonicalAbsolutePath(receiptKeyValue, 'CLI_CONFIG_INVALID'); + const trustedAncestorValue = environmentValue(descriptors, 'WALLET_KERNEL_TRUSTED_ANCESTOR'); + const trustedAncestor = trustedAncestorValue === null + ? null + : canonicalAbsolutePath(trustedAncestorValue, 'CLI_CONFIG_INVALID'); + const enrollmentInboxValue = environmentValue(descriptors, 'WALLET_KERNEL_ENROLLMENT_INBOX'); + const enrollmentInboxPath = enrollmentInboxValue === null + ? null + : canonicalAbsolutePath(enrollmentInboxValue, 'CLI_CONFIG_INVALID'); + const socketValue = environmentValue(descriptors, 'WALLET_KERNEL_OPERATOR_SOCKET_FILE'); + const operatorSocketPath = socketValue === null + ? null + : canonicalAbsolutePath(socketValue, 'CLI_CONFIG_INVALID'); + const operatorPort = environmentValue(descriptors, 'WALLET_KERNEL_OPERATOR_PORT') ?? '8405'; + if (operatorPort !== '8405') fail('CLI_CONFIG_INVALID'); + if ((mode === 'cdp-testnet') !== (operatorSocketPath !== null)) { + fail('CLI_CONFIG_INVALID'); + } + const parseIdentity = (key, fallback) => { + const value = environmentValue(descriptors, key); + if (value === null) return fallback; + if (!/^[1-9][0-9]*$/.test(value)) fail('CLI_CONFIG_INVALID'); + const numeric = Number(value); + if (!Number.isSafeInteger(numeric) || String(numeric) !== value) { + fail('CLI_CONFIG_INVALID'); + } + return numeric; + }; + const kernelUid = currentUid(); + if (typeof process.getgid !== 'function') fail('CLI_CONFIG_INVALID'); + const kernelGid = process.getgid(); + if (!Number.isSafeInteger(kernelGid) || kernelGid <= 0) fail('CLI_CONFIG_INVALID'); + const expectedAgentUid = parseIdentity('WALLET_KERNEL_EXPECTED_AGENT_UID', kernelUid); + const expectedAgentGid = parseIdentity('WALLET_KERNEL_EXPECTED_AGENT_GID', kernelGid); + return Object.freeze({ + mode, + databasePath, + receiptKeyPath, + operatorTokenPath: tokenPath, + operatorSocketPath, + origin: ORIGIN, + trustedAncestor, + enrollmentInboxPath, + expectedAgentUid, + expectedAgentGid, + kernelUid, + kernelGid, + }); +} + +function currentUid() { + if (typeof process.getuid !== 'function') fail('OPERATOR_CHANNEL_INVALID'); + return process.getuid(); +} + +function statIdentity(stat) { + return Object.freeze({ dev: stat.dev, ino: stat.ino }); +} + +function sameIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function openPrivateParent(filePath, code) { + const parentPath = path.dirname(filePath); + let descriptor; + try { + descriptor = fs.openSync( + parentPath, + fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW, + ); + const descriptorStat = fs.fstatSync(descriptor, { bigint: true }); + const pathStat = fs.lstatSync(parentPath, { bigint: true }); + if (!descriptorStat.isDirectory() || pathStat.isSymbolicLink() + || !pathStat.isDirectory() + || !sameIdentity(statIdentity(descriptorStat), statIdentity(pathStat)) + || Number(descriptorStat.uid) !== currentUid() + || Number(descriptorStat.mode & 0o777n) !== 0o700) { + fail(code); + } + const identity = statIdentity(descriptorStat); + const revalidate = () => { + const held = fs.fstatSync(descriptor, { bigint: true }); + const current = fs.lstatSync(parentPath, { bigint: true }); + if (!held.isDirectory() || current.isSymbolicLink() || !current.isDirectory() + || !sameIdentity(identity, statIdentity(held)) + || !sameIdentity(identity, statIdentity(current)) + || Number(held.uid) !== currentUid() + || Number(held.mode & 0o777n) !== 0o700) { + fail(code); + } + }; + revalidate(); + return Object.freeze({ parentPath, descriptor, revalidate }); + } catch (error) { + if (descriptor !== undefined) { + try { fs.closeSync(descriptor); } catch {} + } + if (error instanceof CliError) throw error; + fail(code); + } +} + +function readOperatorToken(filePath) { + const guard = openPrivateParent(filePath, 'OPERATOR_TOKEN_INVALID'); + let descriptor; + let bytes; + try { + descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + let stat = fs.fstatSync(descriptor, { bigint: true }); + const pathStat = fs.lstatSync(filePath, { bigint: true }); + if (!stat.isFile() || pathStat.isSymbolicLink() || !pathStat.isFile() + || !sameIdentity(statIdentity(stat), statIdentity(pathStat)) + || Number(stat.uid) !== currentUid() + || Number(stat.mode & 0o777n) !== 0o600 + || stat.size !== 43n) { + fail('OPERATOR_TOKEN_INVALID'); + } + bytes = Buffer.alloc(43); + let offset = 0; + while (offset < bytes.length) { + const count = fs.readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count === 0) fail('OPERATOR_TOKEN_INVALID'); + offset += count; + } + const overflow = Buffer.alloc(1); + if (fs.readSync(descriptor, overflow, 0, 1, offset) !== 0) { + fail('OPERATOR_TOKEN_INVALID'); + } + stat = fs.fstatSync(descriptor, { bigint: true }); + const finalPathStat = fs.lstatSync(filePath, { bigint: true }); + guard.revalidate(); + if (!sameIdentity(statIdentity(stat), statIdentity(pathStat)) + || !sameIdentity(statIdentity(stat), statIdentity(finalPathStat)) + || Number(stat.mode & 0o777n) !== 0o600) { + fail('OPERATOR_TOKEN_INVALID'); + } + if (bytes.some((byte) => byte > 0x7f)) fail('OPERATOR_TOKEN_INVALID'); + const token = bytes.toString('ascii'); + if (!TOKEN_PATTERN.test(token)) fail('OPERATOR_TOKEN_INVALID'); + const decoded = Buffer.from(token, 'base64url'); + try { + if (decoded.length !== 32 || decoded.toString('base64url') !== token) { + fail('OPERATOR_TOKEN_INVALID'); + } + } finally { + decoded.fill(0); + } + return token; + } catch (error) { + if (error instanceof CliError) throw error; + fail('OPERATOR_TOKEN_INVALID'); + } finally { + if (descriptor !== undefined) { + try { fs.closeSync(descriptor); } catch {} + } + if (bytes) bytes.fill(0); + try { fs.closeSync(guard.descriptor); } catch {} + } +} + +function inspectAdminSocket(socketPath) { + const guard = openPrivateParent(socketPath, 'OPERATOR_CHANNEL_INVALID'); + let socketIdentity; + const revalidate = () => { + guard.revalidate(); + let stat; + try { + stat = fs.lstatSync(socketPath, { bigint: true }); + } catch { + fail('OPERATOR_CHANNEL_INVALID'); + } + if (stat.isSymbolicLink() || !stat.isSocket() + || Number(stat.uid) !== currentUid() + || Number(stat.mode & 0o777n) !== 0o600) { + fail('OPERATOR_CHANNEL_INVALID'); + } + const identity = statIdentity(stat); + if (socketIdentity && !sameIdentity(socketIdentity, identity)) { + fail('OPERATOR_CHANNEL_INVALID'); + } + socketIdentity ??= identity; + guard.revalidate(); + }; + try { + revalidate(); + return Object.freeze({ + revalidate, + close() { fs.closeSync(guard.descriptor); }, + }); + } catch (error) { + try { fs.closeSync(guard.descriptor); } catch {} + throw error; + } +} + +function parseFlags(tokens, start, allowed, required = []) { + const result = {}; + for (let index = start; index < tokens.length; index += 2) { + const flag = tokens[index]; + const value = tokens[index + 1]; + if (!Object.hasOwn(allowed, flag) || value === undefined || value.startsWith('--') + || Object.hasOwn(result, flag)) { + usage(); + } + result[flag] = allowed[flag](value); + } + if (required.some((flag) => !Object.hasOwn(result, flag))) usage(); + return result; +} + +function exactId(value) { + if (!ID_PATTERN.test(value)) usage(); + return value; +} + +function exactHash(value) { + if (!HASH_PATTERN.test(value)) usage(); + return value; +} + +function exactTransaction(value) { + if (!TRANSACTION_PATTERN.test(value)) usage(); + return value; +} + +function inputPath(value) { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0') + || /[\u0000-\u001f\u007f]/u.test(value) + || Buffer.byteLength(value, 'utf8') > MAXIMUM_ARGUMENT_BYTES + || value.startsWith('--') || !path.isAbsolute(value) || path.resolve(value) !== value + || (value !== path.parse(value).root && value.endsWith(path.sep))) { + usage(); + } + return value; +} + +function freezeCommand(value) { + if (value.body && typeof value.body === 'object') Object.freeze(value.body); + if (value.bootstrap && typeof value.bootstrap === 'object') Object.freeze(value.bootstrap); + return Object.freeze(value); +} + +function parseCommand(rawArgv) { + const argv = captureArgv(rawArgv); + const jsonCount = argv.filter((value) => value === '--json').length; + if (jsonCount > 1) usage(); + const json = jsonCount === 1; + const tokens = argv.filter((value) => value !== '--json'); + const [scope, action] = tokens; + let command; + + if (scope === 'preflight' && tokens.length === 1) { + command = { name: 'preflight', offline: true, bootstrap: { name: 'preflight' } }; + } else if (scope === 'agent' && action === 'enroll' && tokens.length >= 3) { + const descriptorPath = inputPath(tokens[2]); + const flags = parseFlags(tokens, 3, { '--confirm': exactHash }, ['--confirm']); + command = { + name: 'agent-enroll', offline: true, + bootstrap: { name: 'agent-enroll', descriptorPath, expectedDescriptorHash: flags['--confirm'] }, + }; + } else if (scope === 'isolation' && action === 'attest' && tokens.length >= 3) { + const reportPath = inputPath(tokens[2]); + const flags = parseFlags(tokens, 3, { '--confirm': exactHash }, ['--confirm']); + command = { + name: 'isolation-attest', offline: true, + bootstrap: { name: 'isolation-attest', reportPath, expectedReportHash: flags['--confirm'] }, + }; + } else if (scope === 'policy' && action === 'validate' && tokens.length === 3) { + command = { + name: 'policy-validate', offline: true, + bootstrap: { name: 'policy-validate', policyPath: inputPath(tokens[2]) }, + }; + } else if (scope === 'policy' && action === 'apply' && tokens.length >= 3) { + const policyPath = inputPath(tokens[2]); + const flags = parseFlags(tokens, 3, { '--confirm': exactHash }, ['--confirm']); + command = { + name: 'policy-apply', offline: true, + bootstrap: { name: 'policy-apply', policyPath, expectedPolicyHash: flags['--confirm'] }, + }; + } else if (scope === 'agent' && action === 'revoke' && tokens.length >= 3) { + const agentInstanceId = exactId(tokens[2]); + const flags = parseFlags(tokens, 3, { '--confirm': exactHash }, ['--confirm']); + command = { + name: 'agent-revoke', method: 'POST', + path: `/operator/v1/agents/${agentInstanceId}/revoke`, + body: { expectedEnrollmentHash: flags['--confirm'] }, + }; + } else if (scope === 'console' && action === 'launch' && tokens.length === 2) { + command = { + name: 'console-launch', method: 'POST', path: '/operator/v1/browser-launch', body: null, + consoleLaunch: true, + }; + } else if (scope === 'sessions' && action === 'transition' && tokens.length >= 3) { + const sessionId = exactId(tokens[2]); + const flags = parseFlags( + tokens, + 3, + { '--to-policy': exactHash, '--confirm': exactHash }, + ['--to-policy', '--confirm'], + ); + command = { + name: 'sessions-transition', method: 'POST', + path: `/operator/v1/sessions/${sessionId}/transition-policy`, + body: { targetPolicyHash: flags['--to-policy'], expectedSessionHash: flags['--confirm'] }, + }; + } else if (scope === 'sessions' && action === 'close' && tokens.length >= 3) { + const sessionId = exactId(tokens[2]); + const flags = parseFlags(tokens, 3, { '--confirm': exactHash }, ['--confirm']); + command = { + name: 'sessions-close', method: 'POST', + path: `/operator/v1/sessions/${sessionId}/close`, + body: { expectedSessionHash: flags['--confirm'] }, + }; + } else if (scope === 'approvals' && action === 'list' && tokens.length >= 2) { + const flags = parseFlags(tokens, 2, { '--state': (value) => { + if (value !== 'pending') usage(); + return value; + } }); + command = { + name: 'approvals-list', method: 'GET', + path: Object.hasOwn(flags, '--state') + ? '/operator/v1/approvals?state=pending' + : '/operator/v1/approvals', + body: null, + }; + } else if (scope === 'approvals' + && (action === 'approve' || action === 'deny') && tokens.length >= 3) { + const approvalId = exactId(tokens[2]); + const allowed = { '--confirm': exactHash }; + if (action === 'deny') { + allowed['--reason'] = (value) => { + if (value !== 'OPERATOR_DENIED') usage(); + return value; + }; + } + const required = action === 'deny' ? ['--confirm', '--reason'] : ['--confirm']; + const flags = parseFlags(tokens, 3, allowed, required); + command = { + name: `approvals-${action}`, method: 'POST', + path: `/operator/v1/approvals/${approvalId}/${action}`, + body: { + expectedIntentHash: flags['--confirm'], + ...(action === 'deny' ? { reasonCode: flags['--reason'] } : {}), + }, + }; + } else if (scope === 'receipts' && action === 'list' && tokens.length === 2) { + command = { + name: 'receipts-list', method: 'GET', path: '/operator/v1/receipts', body: null, + }; + } else if (scope === 'receipts' && action === 'verify' && tokens.length === 3) { + const receiptId = exactId(tokens[2]); + command = { + name: 'receipts-verify', method: 'GET', + path: `/operator/v1/receipts/${receiptId}`, body: null, + }; + } else if (scope === 'reconcile' + && ['payment', 'execution', 'refund-observation'].includes(action) + && tokens.length >= 3) { + const intentId = exactId(tokens[2]); + const allowed = { '--confirm': exactHash, '--confirm-case': exactHash }; + const required = ['--confirm', '--confirm-case']; + if (action === 'payment') allowed['--payment-transaction'] = exactTransaction; + if (action === 'refund-observation') { + allowed['--refund-transaction'] = exactTransaction; + required.push('--refund-transaction'); + } + const flags = parseFlags(tokens, 3, allowed, required); + command = { + name: `reconcile-${action}`, method: 'POST', + path: `/operator/v1/reconciliations/${intentId}/${action}`, + body: { + expectedIntentHash: flags['--confirm'], + expectedCaseHash: flags['--confirm-case'], + ...(Object.hasOwn(flags, '--payment-transaction') + ? { paymentTransactionId: flags['--payment-transaction'] } + : {}), + ...(Object.hasOwn(flags, '--refund-transaction') + ? { refundTransactionId: flags['--refund-transaction'] } + : {}), + }, + }; + } else if (scope === 'reconcile' && action === 'abandon-candidate' && tokens.length >= 3) { + const intentId = exactId(tokens[2]); + const flags = parseFlags(tokens, 3, { + '--kind': (value) => { + if (value !== 'payment' && value !== 'refund-observation') usage(); + return value; + }, + '--confirm': exactHash, + '--confirm-case': exactHash, + }, ['--kind', '--confirm', '--confirm-case']); + command = { + name: 'reconcile-abandon-candidate', method: 'POST', + path: `/operator/v1/reconciliations/${intentId}/${flags['--kind']}/abandon-candidate`, + body: { expectedIntentHash: flags['--confirm'], expectedCaseHash: flags['--confirm-case'] }, + }; + } else if (scope === 'export' && tokens.length >= 2) { + const sessionId = exactId(tokens[1]); + const flags = parseFlags(tokens, 2, { '--output': (value) => canonicalAbsolutePath(value, 'CLI_USAGE') }, ['--output']); + command = { + name: 'export', method: 'GET', path: `/operator/v1/exports/${sessionId}`, body: null, + exportOutputPath: flags['--output'], + }; + } else { + usage(); + } + + return Object.freeze({ command: freezeCommand(command), json }); +} + +function assertSafeData( + value, + ownerToken, + ancestors = new Set(), + state = { nodes: 0 }, + depth = 0, + location = [], +) { + state.nodes += 1; + if (state.nodes > 20_000 || depth > 64) fail('OPERATOR_RESPONSE_UNSAFE'); + if (value === null || typeof value === 'boolean') return; + if (typeof value === 'string') { + if (value.includes(ownerToken)) fail('OPERATOR_RESPONSE_UNSAFE'); + return; + } + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || Object.is(value, -0)) fail('OPERATOR_RESPONSE_UNSAFE'); + return; + } + if (!value || typeof value !== 'object' || utilTypes.isProxy(value) + || ancestors.has(value)) { + fail('OPERATOR_RESPONSE_UNSAFE'); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) fail('OPERATOR_RESPONSE_UNSAFE'); + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Reflect.ownKeys(value).length !== value.length + 1) fail('OPERATOR_RESPONSE_UNSAFE'); + for (let index = 0; index < value.length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail('OPERATOR_RESPONSE_UNSAFE'); + } + assertSafeData(descriptor.value, ownerToken, ancestors, state, depth + 1, location); + } + return; + } + if (!isPlainDataObject(value)) fail('OPERATOR_RESPONSE_UNSAFE'); + const descriptors = ownDataDescriptors(value, 'OPERATOR_RESPONSE_UNSAFE'); + const descriptorKeys = Object.keys(descriptors).sort(); + const exactKeys = (required, optional = []) => { + const allowed = new Set([...required, ...optional]); + return required.every((key) => Object.hasOwn(descriptors, key)) + && descriptorKeys.every((key) => allowed.has(key)); + }; + const signatureValue = descriptors.signature?.value; + const canonicalSignature = typeof signatureValue === 'string' + && CANONICAL_SIGNATURE_PATTERN.test(signatureValue) + && Buffer.from(signatureValue, 'base64').length === 64 + && Buffer.from(signatureValue, 'base64').toString('base64') === signatureValue; + const projectionBundle = exactKeys([ + 'schemaVersion', 'domain', 'projection', 'projectionHash', + 'algorithm', 'keyId', 'publicKeyPem', 'signature', + ]) + && descriptors.schemaVersion.value === 1 + && descriptors.domain.value === 'wallet-kernel.projection-export.v1' + && descriptors.algorithm.value === 'Ed25519' + && HASH_PATTERN.test(descriptors.projectionHash.value) + && HASH_PATTERN.test(descriptors.keyId.value) + && typeof descriptors.publicKeyPem.value === 'string' + && canonicalSignature; + const receiptBundle = exactKeys( + ['receipt', 'receiptHash', 'algorithm', 'keyId', 'signature'], + ['id', 'intentId', 'revision', 'supersedesReceiptHash', 'createdAt'], + ) + && descriptors.algorithm?.value === 'Ed25519' + && typeof descriptors.receiptHash?.value === 'string' + && SIGNED_RECEIPT_HASH_PATTERN.test(descriptors.receiptHash.value) + && typeof descriptors.keyId?.value === 'string' + && HASH_PATTERN.test(descriptors.keyId.value) + && canonicalSignature; + for (const [key, descriptor] of Object.entries(descriptors)) { + const normalizedKey = key.replaceAll(/[-_]/g, '').toLowerCase(); + const hashProjection = PUBLIC_HASH_KEYS.has(key) + && typeof descriptor.value === 'string' + && ((key === 'receiptHash' || key === 'supersedesReceiptHash') + ? SIGNED_RECEIPT_HASH_PATTERN.test(descriptor.value) + : HASH_PATTERN.test(descriptor.value)); + const allowedPublicField = (key === 'tokenContract' + && typeof descriptor.value === 'string' + && /^0x[0-9a-f]{40}$/.test(descriptor.value)) + || (key === 'authorizationState' && typeof descriptor.value === 'boolean') + || (key === 'evidencePath' + && typeof descriptor.value === 'string' + && /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{1,2047}$/.test(descriptor.value)) + || hashProjection; + const projectionSignature = key === 'signature' && projectionBundle; + const receiptSignature = key === 'signature' && receiptBundle; + if ((!allowedPublicField && !projectionSignature && !receiptSignature + && FORBIDDEN_OUTPUT_KEY_FRAGMENT.test(normalizedKey)) + || (normalizedKey === 'signature' && !projectionSignature && !receiptSignature)) { + fail('OPERATOR_RESPONSE_UNSAFE'); + } + assertSafeData( + descriptor.value, + ownerToken, + ancestors, + state, + depth + 1, + [...location, key], + ); + } + } finally { + ancestors.delete(value); + } +} + +function captureResponse(response, ownerToken) { + if (!isPlainDataObject(response)) fail('OPERATOR_RESPONSE_INVALID'); + const descriptors = ownDataDescriptors(response, 'OPERATOR_RESPONSE_INVALID'); + const keys = Object.keys(descriptors).sort(); + if (canonicalJson(keys) !== canonicalJson(['body', 'headers', 'status'])) { + fail('OPERATOR_RESPONSE_INVALID'); + } + const status = descriptors.status.value; + const body = descriptors.body.value; + const headers = descriptors.headers.value; + if (!Number.isSafeInteger(status) || status < 100 || status > 599 + || typeof body !== 'string' || Buffer.byteLength(body, 'utf8') > MAXIMUM_RESPONSE_BYTES + || !isPlainDataObject(headers)) { + fail('OPERATOR_RESPONSE_INVALID'); + } + const headerDescriptors = ownDataDescriptors(headers, 'OPERATOR_RESPONSE_INVALID'); + if (canonicalJson(Object.keys(headerDescriptors).sort()) + !== canonicalJson(['cache-control', 'content-type']) + || headerDescriptors['cache-control'].value !== 'no-store' + || headerDescriptors['content-type'].value !== 'application/json') { + fail('OPERATOR_RESPONSE_INVALID'); + } + let parsed; + try { + parsed = body === '' ? null : JSON.parse(body); + } catch { + fail('OPERATOR_RESPONSE_INVALID'); + } + if (status < 200 || status >= 300) { + if (!isPlainDataObject(parsed)) fail('OPERATOR_RESPONSE_INVALID'); + const envelope = ownDataDescriptors(parsed, 'OPERATOR_RESPONSE_INVALID'); + if (canonicalJson(Object.keys(envelope).sort()) !== canonicalJson(['error', 'ok']) + || envelope.ok.value !== false || !isPlainDataObject(envelope.error.value)) { + fail('OPERATOR_RESPONSE_INVALID'); + } + const error = ownDataDescriptors(envelope.error.value, 'OPERATOR_RESPONSE_INVALID'); + if (canonicalJson(Object.keys(error).sort()) !== canonicalJson(['code', 'message']) + || typeof error.code.value !== 'string' + || !ERROR_CODE_PATTERN.test(error.code.value) + || typeof error.message.value !== 'string' + || Buffer.byteLength(error.message.value, 'utf8') > 512 + || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(error.message.value)) { + fail('OPERATOR_RESPONSE_INVALID'); + } + fail(error.code.value); + } + if (!isPlainDataObject(parsed)) fail('OPERATOR_RESPONSE_INVALID'); + const envelope = ownDataDescriptors(parsed, 'OPERATOR_RESPONSE_INVALID'); + if (canonicalJson(Object.keys(envelope).sort()) !== canonicalJson(['data', 'ok']) + || envelope.ok.value !== true) { + fail('OPERATOR_RESPONSE_INVALID'); + } + let data; + try { + data = projectOperatorPublicResult(envelope.data.value); + } catch { + fail('OPERATOR_RESPONSE_UNSAFE'); + } + assertSafeData(data, ownerToken); + canonicalJson(data); + return data; +} + +function validateConsoleLaunch(value, ownerToken) { + if (!isPlainDataObject(value)) fail('OPERATOR_RESPONSE_INVALID'); + const descriptors = ownDataDescriptors(value, 'OPERATOR_RESPONSE_INVALID'); + if (canonicalJson(Object.keys(descriptors).sort()) !== canonicalJson(['expiresAt', 'url'])) { + fail('OPERATOR_RESPONSE_INVALID'); + } + const url = descriptors.url.value; + const expiresAt = descriptors.expiresAt.value; + if (typeof url !== 'string' || typeof expiresAt !== 'string' + || new Date(Date.parse(expiresAt)).toISOString() !== expiresAt) { + fail('OPERATOR_RESPONSE_INVALID'); + } + let parsed; + try { parsed = new URL(url); } catch { fail('OPERATOR_RESPONSE_INVALID'); } + if (parsed.origin !== ORIGIN || parsed.pathname !== '/operator/' + || parsed.search !== '' || parsed.username !== '' || parsed.password !== '' + || !/^#launch=[A-Za-z0-9_-]{43}$/.test(parsed.hash)) { + fail('OPERATOR_RESPONSE_INVALID'); + } + const launchToken = parsed.hash.slice('#launch='.length); + const decoded = Buffer.from(launchToken, 'base64url'); + if (decoded.length !== 32 || decoded.toString('base64url') !== launchToken + || launchToken === ownerToken) { + fail('OPERATOR_RESPONSE_INVALID'); + } + return Object.freeze({ url, expiresAt }); +} + +function extractErrorCode(error, fallback = 'OPERATOR_REQUEST_FAILED') { + if (error instanceof CliError && ERROR_CODE_PATTERN.test(error.code)) return error.code; + if (typeof error?.code === 'string' && ERROR_CODE_PATTERN.test(error.code) + && !/^E[A-Z0-9]+$/.test(error.code)) { + return error.code; + } + return fallback; +} + +function emitFailure(stderr, json, code, withUsage = false) { + if (json) { + stderr.write(`${canonicalJson({ error: { code }, ok: false })}\n`); + } else { + stderr.write(`error: ${code}\n`); + if (withUsage) stderr.write(`${USAGE}\n`); + } +} + +function emitSuccess(stdout, json, command, result) { + if (command.consoleLaunch) { + const launch = result; + if (json) { + stdout.write(`${canonicalJson({ command: command.name, expiresAt: launch.expiresAt, ok: true, url: launch.url })}\n`); + } else { + stdout.write(`${launch.url}\n`); + } + return; + } + if (command.exportOutputPath) { + if (json) { + stdout.write(`${canonicalJson({ command: command.name, ok: true, written: true })}\n`); + } else { + stdout.write('export: written\n'); + } + return; + } + if (json) { + stdout.write(`${canonicalJson({ command: command.name, ok: true, result })}\n`); + } else { + stdout.write(`${command.name}: ${canonicalJson(result)}\n`); + } +} + +function preflightExport(outputPath) { + const guard = openPrivateParent(outputPath, 'EXPORT_OUTPUT_UNSAFE'); + try { + try { + fs.lstatSync(outputPath, { bigint: true }); + fail('EXPORT_OUTPUT_UNSAFE'); + } catch (error) { + if (error instanceof CliError) throw error; + if (error?.code !== 'ENOENT') fail('EXPORT_OUTPUT_UNSAFE'); + } + guard.revalidate(); + return Object.freeze({ + revalidate: guard.revalidate, + close() { fs.closeSync(guard.descriptor); }, + }); + } catch (error) { + try { fs.closeSync(guard.descriptor); } catch {} + throw error; + } +} + +function writeExclusiveExport(outputPath, value, guard) { + const bytes = Buffer.from(canonicalJson(value), 'utf8'); + let descriptor; + let createdIdentity = null; + try { + guard.revalidate(); + descriptor = fs.openSync( + outputPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o600, + ); + fs.fchmodSync(descriptor, 0o600); + let stat = fs.fstatSync(descriptor, { bigint: true }); + createdIdentity = statIdentity(stat); + if (!stat.isFile() || Number(stat.uid) !== currentUid() + || Number(stat.mode & 0o777n) !== 0o600) { + fail('EXPORT_OUTPUT_UNSAFE'); + } + let offset = 0; + while (offset < bytes.length) { + const count = fs.writeSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count <= 0) fail('EXPORT_OUTPUT_UNSAFE'); + offset += count; + } + fs.fsyncSync(descriptor); + stat = fs.fstatSync(descriptor, { bigint: true }); + const pathStat = fs.lstatSync(outputPath, { bigint: true }); + guard.revalidate(); + if (!sameIdentity(createdIdentity, statIdentity(stat)) + || pathStat.isSymbolicLink() || !pathStat.isFile() + || !sameIdentity(createdIdentity, statIdentity(pathStat)) + || stat.size !== BigInt(bytes.length)) { + fail('EXPORT_OUTPUT_UNSAFE'); + } + } catch (error) { + if (descriptor !== undefined) { + try { fs.closeSync(descriptor); } catch {} + descriptor = undefined; + } + if (createdIdentity !== null) { + try { + const stat = fs.lstatSync(outputPath, { bigint: true }); + if (!stat.isSymbolicLink() && sameIdentity(createdIdentity, statIdentity(stat))) { + fs.unlinkSync(outputPath); + } + } catch {} + } + if (error instanceof CliError) throw error; + fail('EXPORT_OUTPUT_UNSAFE'); + } finally { + if (descriptor !== undefined) { + try { fs.closeSync(descriptor); } catch {} + } + } +} + +async function nodeHttpRequest({ socketPath, origin, method, path: requestPath, headers, body }) { + return new Promise((resolve, reject) => { + const parsedOrigin = new URL(origin); + const options = socketPath === null + ? { + hostname: parsedOrigin.hostname, + port: Number(parsedOrigin.port), + method, + path: requestPath, + headers, + } + : { + socketPath, + method, + path: requestPath, + headers: { ...headers, host: parsedOrigin.host }, + }; + const request = http.request(options, (response) => { + const chunks = []; + let total = 0; + response.on('data', (chunk) => { + total += chunk.length; + if (total > MAXIMUM_RESPONSE_BYTES) { + request.destroy(new Error('bounded operator response exceeded')); + return; + } + chunks.push(chunk); + }); + response.on('end', () => resolve(Object.freeze({ + status: response.statusCode, + headers: Object.freeze({ + 'cache-control': response.headers['cache-control'], + 'content-type': response.headers['content-type'], + }), + body: Buffer.concat(chunks).toString('utf8'), + }))); + }); + request.once('error', reject); + if (body !== null) request.write(body); + request.end(); + }); +} + +export async function runOperatorCli({ + argv, + env, + requestImpl = nodeHttpRequest, + stdout = process.stdout, + stderr = process.stderr, + offlineBootstrap = runOfflineBootstrap, +}) { + let json = false; + let parsed; + let exportGuard = null; + let socketGuard = null; + try { + try { + parsed = parseCommand(argv); + json = parsed.json; + } catch (error) { + json = !utilTypes.isProxy(argv) + && Array.isArray(argv) + && argv.filter((value) => value === '--json').length === 1; + throw error; + } + if (!stdout || typeof stdout.write !== 'function' + || !stderr || typeof stderr.write !== 'function' + || typeof requestImpl !== 'function') { + fail('CLI_CONFIG_INVALID'); + } + const config = captureConfig(env); + if (parsed.command.offline && config.databasePath === null) fail('CLI_CONFIG_INVALID'); + if (parsed.command.exportOutputPath) { + exportGuard = preflightExport(parsed.command.exportOutputPath); + } + const operatorToken = readOperatorToken(config.operatorTokenPath); + + let result; + if (parsed.command.offline) { + if (typeof offlineBootstrap !== 'function') fail('OFFLINE_BOOTSTRAP_UNAVAILABLE'); + result = await offlineBootstrap(Object.freeze({ + command: parsed.command.bootstrap, + config, + operatorToken, + })); + assertSafeData(result, operatorToken); + canonicalJson(result); + } else { + if (config.mode === 'cdp-testnet') { + socketGuard = inspectAdminSocket(config.operatorSocketPath); + socketGuard.revalidate(); + } + const body = parsed.command.body === null ? null : canonicalJson(parsed.command.body); + const headers = Object.freeze({ + accept: 'application/json', + authorization: `Bearer ${operatorToken}`, + ...(body === null ? {} : { 'content-type': 'application/json' }), + }); + const request = Object.freeze({ + socketPath: config.mode === 'cdp-testnet' ? config.operatorSocketPath : null, + origin: config.origin, + method: parsed.command.method, + path: parsed.command.path, + headers, + body, + }); + let rawResponse; + try { + rawResponse = await requestImpl(request); + } catch { + fail('OPERATOR_REQUEST_FAILED'); + } + if (socketGuard) socketGuard.revalidate(); + result = captureResponse(rawResponse, operatorToken); + } + + if (parsed.command.consoleLaunch) { + result = validateConsoleLaunch(result, operatorToken); + } + if (parsed.command.exportOutputPath) { + writeExclusiveExport(parsed.command.exportOutputPath, result, exportGuard); + } + emitSuccess(stdout, json, parsed.command, result); + return 0; + } catch (error) { + const code = extractErrorCode(error); + const withUsage = error instanceof CliError && error.usage; + emitFailure(stderr, json, code, withUsage); + return withUsage ? 2 : 1; + } finally { + if (socketGuard) { + try { socketGuard.close(); } catch {} + } + if (exportGuard) { + try { exportGuard.close(); } catch {} + } + } +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + const exitCode = await runOperatorCli({ argv: process.argv.slice(2), env: process.env }); + process.exitCode = exitCode; +} diff --git a/spikes/pi-wielder/src/operator/console.mjs b/spikes/pi-wielder/src/operator/console.mjs new file mode 100644 index 0000000..1f06e2e --- /dev/null +++ b/spikes/pi-wielder/src/operator/console.mjs @@ -0,0 +1,68 @@ +import fs from 'node:fs'; + +import { Hono } from 'hono'; + +const ASSETS = Object.freeze({ + '/operator/': Object.freeze({ + body: fs.readFileSync(new URL('../../operator-console/index.html', import.meta.url), 'utf8'), + contentType: 'text/html; charset=UTF-8', + }), + '/operator/app.mjs': Object.freeze({ + body: fs.readFileSync(new URL('../../operator-console/app.mjs', import.meta.url), 'utf8'), + contentType: 'text/javascript; charset=UTF-8', + }), + '/operator/styles.css': Object.freeze({ + body: fs.readFileSync(new URL('../../operator-console/styles.css', import.meta.url), 'utf8'), + contentType: 'text/css; charset=UTF-8', + }), +}); + +const SECURITY_HEADERS = Object.freeze({ + 'cache-control': 'no-store', + 'content-security-policy': "default-src 'self'; connect-src 'self'; frame-ancestors 'none'", + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-resource-policy': 'same-origin', + 'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=()', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', +}); + +function captureOperatorApp(options) { + if (!options || typeof options !== 'object' || Array.isArray(options) + || Object.getPrototypeOf(options) !== Object.prototype + || Reflect.ownKeys(options).length !== 1) { + throw new TypeError('Operator console requires exactly one operatorApp'); + } + const descriptor = Object.getOwnPropertyDescriptor(options, 'operatorApp'); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError('Operator console requires one operatorApp data field'); + } + const operatorApp = descriptor.value; + if (!operatorApp || typeof operatorApp.fetch !== 'function' + || !Array.isArray(operatorApp.routes)) { + throw new TypeError('operatorApp must be a Hono application'); + } + return operatorApp; +} + +export function createOperatorConsoleApp(options) { + const operatorApp = captureOperatorApp(options); + const app = new Hono(); + + app.use('*', async (context, next) => { + await next(); + for (const [name, value] of Object.entries(SECURITY_HEADERS)) { + context.header(name, value); + } + }); + + for (const [pathname, asset] of Object.entries(ASSETS)) { + app.get(pathname, (context) => context.body(asset.body, 200, { + 'content-type': asset.contentType, + })); + } + + app.route('/', operatorApp); + app.notFound((context) => context.json({ code: 'OPERATOR_ROUTE_NOT_FOUND' }, 404)); + return app; +} diff --git a/spikes/pi-wielder/src/spend-control-proxy.mjs b/spikes/pi-wielder/src/spend-control-proxy.mjs new file mode 100644 index 0000000..665bb57 --- /dev/null +++ b/spikes/pi-wielder/src/spend-control-proxy.mjs @@ -0,0 +1,1115 @@ +import crypto from 'node:crypto'; +import { types as utilTypes } from 'node:util'; + +import { Hono } from 'hono'; + +import { + canonicalJson, + canonicalTimestamp, + canonicalToken, + exactRecord, + KernelError, +} from './kernel/canonical.mjs'; + +const SHA256 = /^sha256:[0-9a-f]{64}$/; +const RECEIPT_HASH = /^[0-9a-f]{64}$/; +const EVM_TRANSACTION = /^0x[0-9a-f]{64}$/; +const ATOMIC = /^(0|[1-9][0-9]*)$/; +const APPROVAL_POLL_INTERVAL_MS = 25; +const MAXIMUM_CONNECTED_APPROVAL_WAIT_MS = 300_000; +const MAXIMUM_APPROVAL_TRANSITIONS = 8; +const PUBLIC_OUTCOMES = new Set([ + 'completed', + 'upstream_failed', + 'payment_denied', + 'payment_failed', + 'payment_unresolved', + 'payment_rejected', + 'execution_failed', + 'execution_unknown', + 'refunded', +]); +const INTENT_STATES = new Set([ + 'captured', 'challenged', 'approval_pending', 'authorized', 'reserved', + 'signing', 'signed', 'retrying', 'unresolved', 'terminal', +]); +const PUBLIC_ERROR_STATUS = Object.freeze({ + AGENT_UNAUTHORIZED: 401, + AGENT_ENROLLMENT_REQUIRED: 503, + AGENT_ENROLLMENT_AMBIGUOUS: 503, + AGENT_IDENTITY_MISMATCH: 503, + AGENT_AUTHORITY_CORRUPTION: 503, + AGENT_AUTHORITY_UNAVAILABLE: 503, + AGENT_SESSION_UNAVAILABLE: 503, + POLICY_TRANSITION_REQUIRED: 409, + SESSION_POLICY_BLOCKED: 409, + SESSION_AUTHORITY_AMBIGUOUS: 503, + SESSION_CLOSED: 503, + AUTHORITY_UNHEALTHY: 503, + AUTHORITY_RECOVERY_REQUIRED: 503, + RECEIPT_PARITY_REQUIRED: 503, + WALLET_RECOVERY_REQUIRED: 503, + AGENT_ROUTE_NOT_FOUND: 404, + AGENT_IDENTIFIER: 400, + AGENT_QUERY_FORBIDDEN: 400, + AGENT_CONTENT_TYPE: 415, + AGENT_BODY_REQUIRED: 400, + AGENT_BODY_SCHEMA: 400, + AGENT_BODY_TOO_LARGE: 413, + AGENT_CALL_ID_INVALID: 400, + AGENT_PREFER_INVALID: 400, + AGENT_FORBIDDEN_HEADER: 400, + CORRELATION_CONFLICT: 409, + AGENT_RESPONSE_INVALID: 502, + AGENT_READ_NOT_FOUND: 404, + AGENT_REQUEST_ABORTED: 503, + AGENT_APPROVAL_WAIT_TIMEOUT: 503, +}); + +const DEPENDENCY_FIELDS = Object.freeze([ + 'agentAuth', + 'kernel', + 'routes', + 'maximumRequestBytes', +]); + +function captureExactRecord(value, fields, label) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError(`${label} must be one plain object`); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== fields.length + || keys.some((key) => typeof key !== 'string' || !fields.includes(key))) { + throw new TypeError(`${label} has an invalid shape`); + } + const result = {}; + for (const field of fields) { + const descriptor = Object.getOwnPropertyDescriptor(value, field); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new TypeError(`${label} must contain only enumerable data fields`); + } + result[field] = descriptor.value; + } + return result; +} + +function captureClosedRecord(value, required, optional, code, label) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail(code, `${label} must be one plain object`); + } + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(value); + if (required.some((field) => !Object.hasOwn(value, field)) + || keys.some((key) => typeof key !== 'string' || !allowed.has(key))) { + fail(code, `${label} has an invalid shape`); + } + const result = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + fail(code, `${label} must contain only enumerable data fields`); + } + result[key] = descriptor.value; + } + return Object.freeze(result); +} + +function captureMethod(value, name, label) { + if (!value || typeof value !== 'object' || utilTypes.isProxy(value)) { + throw new TypeError(`${label} is invalid`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor || !Object.hasOwn(descriptor, 'value') + || typeof descriptor.value !== 'function' || utilTypes.isProxy(descriptor.value)) { + throw new TypeError(`${label} must expose ${name} as one data method`); + } + return (...args) => Reflect.apply(descriptor.value, value, args); +} + +function captureDependencies(value) { + const input = captureExactRecord(value, DEPENDENCY_FIELDS, 'Spend Control proxy dependencies'); + if (!Number.isSafeInteger(input.maximumRequestBytes) + || input.maximumRequestBytes < 1 + || input.maximumRequestBytes > 1_048_576) { + throw new TypeError('Spend Control maximum request bytes is invalid'); + } + if (!input.routes || typeof input.routes !== 'object' || utilTypes.isProxy(input.routes) + || input.routes.schemaVersion !== 1 || !Array.isArray(input.routes.routes) + || !Object.isFrozen(input.routes) || !Object.isFrozen(input.routes.routes)) { + throw new TypeError('Spend Control routes must be a validated immutable route map'); + } + const routeFields = [ + 'id', 'kind', 'method', 'upstreamUrl', 'resourceDescription', 'resourceMimeType', + 'purposeLabel', 'requestContentTypes', 'maximumRequestBytes', 'maximumResponseBytes', + ]; + for (const route of input.routes.routes) { + if (!route || typeof route !== 'object' || utilTypes.isProxy(route) + || !Object.isFrozen(route) || Reflect.ownKeys(route).length !== routeFields.length + || routeFields.some((field) => { + const descriptor = Object.getOwnPropertyDescriptor(route, field); + return !descriptor?.enumerable || !Object.hasOwn(descriptor, 'value'); + })) { + throw new TypeError('Spend Control routes must contain immutable validated entries'); + } + } + const routeLookup = captureMethod(input.routes, 'get', 'validated route map'); + if (input.routes.routes.some((route) => routeLookup(route.id) !== route)) { + throw new TypeError('Spend Control route lookup differs from its validated entries'); + } + return Object.freeze({ + authenticate: captureMethod(input.agentAuth, 'authenticate', 'agent auth'), + resolveBoundSession: captureMethod( + input.agentAuth, + 'resolveBoundSession', + 'agent auth', + ), + execute: captureMethod(input.kernel, 'execute', 'Wallet Kernel'), + statusByRequestId: captureMethod(input.kernel, 'statusByRequestId', 'Wallet Kernel'), + receiptById: captureMethod(input.kernel, 'receiptById', 'Wallet Kernel'), + route: routeLookup, + maximumRequestBytes: input.maximumRequestBytes, + }); +} + +function fail(code, message) { + throw new KernelError(code, message); +} + +function publicError(error) { + const code = error instanceof KernelError && Object.hasOwn(PUBLIC_ERROR_STATUS, error.code) + ? error.code + : 'AGENT_INTERNAL'; + const message = { + AGENT_UNAUTHORIZED: 'Agent authentication failed', + AGENT_ENROLLMENT_REQUIRED: 'Agent enrollment is required', + AGENT_ENROLLMENT_AMBIGUOUS: 'Agent enrollment authority is unavailable', + AGENT_IDENTITY_MISMATCH: 'Agent identity authority is unavailable', + AGENT_AUTHORITY_CORRUPTION: 'Agent authority is unavailable', + AGENT_AUTHORITY_UNAVAILABLE: 'Agent authority is unavailable', + AGENT_SESSION_UNAVAILABLE: 'Agent Spend Session is unavailable', + POLICY_TRANSITION_REQUIRED: 'Agent Spend Session requires a policy transition', + SESSION_POLICY_BLOCKED: 'Agent Spend Session requires a policy transition', + SESSION_AUTHORITY_AMBIGUOUS: 'Agent Spend Session authority is unavailable', + SESSION_CLOSED: 'Agent Spend Session is unavailable', + AUTHORITY_UNHEALTHY: 'Wallet authority requires recovery', + AUTHORITY_RECOVERY_REQUIRED: 'Wallet authority requires recovery', + RECEIPT_PARITY_REQUIRED: 'Wallet authority requires recovery', + WALLET_RECOVERY_REQUIRED: 'Wallet authority requires recovery', + AGENT_ROUTE_NOT_FOUND: 'Agent route does not exist', + AGENT_IDENTIFIER: 'Agent route identifier is invalid', + AGENT_QUERY_FORBIDDEN: 'Agent routes do not accept query parameters', + AGENT_CONTENT_TYPE: 'Agent request requires application/json', + AGENT_BODY_REQUIRED: 'Agent request body is required', + AGENT_BODY_SCHEMA: 'Agent request body must be one valid JSON object', + AGENT_BODY_TOO_LARGE: 'Agent request body exceeds its byte limit', + AGENT_CALL_ID_INVALID: 'Agent call ID must be one canonical 32-byte token', + AGENT_PREFER_INVALID: 'Agent approval wait preference is invalid', + AGENT_FORBIDDEN_HEADER: 'Agent request contains a forbidden authority header', + CORRELATION_CONFLICT: 'Agent call ID is already bound to a different request', + AGENT_RESPONSE_INVALID: 'Upstream response could not be delivered safely', + AGENT_READ_NOT_FOUND: 'Agent resource was not found', + AGENT_REQUEST_ABORTED: 'Agent request ended before approval completed', + AGENT_APPROVAL_WAIT_TIMEOUT: 'Agent approval wait reached its safety bound', + AGENT_INTERNAL: 'Agent request failed', + }[code]; + return Object.freeze({ code, message }); +} + +function errorStatus(error) { + return error instanceof KernelError && Object.hasOwn(PUBLIC_ERROR_STATUS, error.code) + ? PUBLIC_ERROR_STATUS[error.code] + : 500; +} + +function requestUrl(context) { + try { + return new URL(context.req.url); + } catch { + fail('AGENT_IDENTIFIER', 'agent request URL is invalid'); + } +} + +function requireNoQueryOrEncoding(context) { + const url = requestUrl(context); + if (url.search !== '') fail('AGENT_QUERY_FORBIDDEN', 'agent route query is forbidden'); + if (url.pathname.includes('%') || url.pathname.includes('\\')) { + fail('AGENT_IDENTIFIER', 'agent route identifiers must not be encoded'); + } +} + +function requireRoute(dependencies, routeId, kind) { + let id; + try { + id = canonicalToken(routeId, 'agent route ID', 64); + } catch { + fail('AGENT_IDENTIFIER', 'agent route ID is invalid'); + } + const route = dependencies.route(id); + if (!route || route.kind !== kind || route.id !== id || route.method !== 'POST') { + fail('AGENT_ROUTE_NOT_FOUND', 'agent route does not exist'); + } + return route; +} + +const FORBIDDEN_AUTHORITY_HEADERS = Object.freeze([ + 'payment-required', + 'payment-signature', + 'payment-response', + 'x-payment', + 'x-payment-required', + 'x-payment-response', + 'idempotency-key', + 'x-idempotency-key', + 'x-approval-id', + 'x-spend-session', + 'x-session-id', + 'x-wallet-address', + 'x-wallet-policy', + 'x-wallet-payee', + 'x-wallet-amount', + 'x-target-url', + 'x-http-method', + 'x-correlation-id', + 'x-request-id', +]); + +function normalizedHeader(request, name, maximumBytes) { + const value = request.headers.get(name); + if (value === null) return null; + if (Buffer.byteLength(value, 'utf8') > maximumBytes + || /[\x00-\x1f\x7f]/u.test(value)) { + fail('AGENT_FORBIDDEN_HEADER', 'agent header value is invalid'); + } + const normalized = value.replace(/^[ \t]+|[ \t]+$/gu, ''); + if (normalized.length === 0) fail('AGENT_FORBIDDEN_HEADER', 'agent header value is empty'); + return normalized; +} + +function forwardHeaders(request) { + if (FORBIDDEN_AUTHORITY_HEADERS.some((name) => request.headers.has(name))) { + fail('AGENT_FORBIDDEN_HEADER', 'agent supplied spend authority in a header'); + } + const contentType = normalizedHeader(request, 'content-type', 128); + if (contentType !== 'application/json') { + fail('AGENT_CONTENT_TYPE', 'agent content type must be exact application/json'); + } + const accept = normalizedHeader(request, 'accept', 512); + const userAgent = normalizedHeader(request, 'user-agent', 512); + return Object.freeze({ + ...(accept === null ? {} : { accept }), + 'content-type': contentType, + ...(userAgent === null ? {} : { 'user-agent': userAgent }), + }); +} + +function requiredAgentCallId(request) { + const value = request.headers.get('x-agent-call-id'); + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{43}$/u.test(value)) { + fail('AGENT_CALL_ID_INVALID', 'agent call ID is missing or malformed'); + } + const decoded = Buffer.from(value, 'base64url'); + const canonical = decoded.length === 32 && decoded.toString('base64url') === value; + decoded.fill(0); + if (!canonical) { + fail('AGENT_CALL_ID_INVALID', 'agent call ID is not canonical base64url'); + } + return value; +} + +function requestedApprovalWaitMs(request) { + const value = request.headers.get('prefer'); + if (value === null) return 0; + if (!/^wait=(?:[1-9]|[1-9][0-9]|[12][0-9]{2}|300)$/u.test(value)) { + fail('AGENT_PREFER_INVALID', 'approval wait preference is malformed or out of bounds'); + } + const milliseconds = Number(value.slice('wait='.length)) * 1_000; + if (!Number.isSafeInteger(milliseconds) + || milliseconds < 1_000 + || milliseconds > MAXIMUM_CONNECTED_APPROVAL_WAIT_MS) { + fail('AGENT_PREFER_INVALID', 'approval wait preference is outside its safety bound'); + } + return milliseconds; +} + +function correlationIdForAgentCall(agentCallId) { + return `agent-call:${crypto.createHash('sha256') + .update('wallet-kernel.agent-call.v1\0', 'utf8') + .update(agentCallId, 'ascii') + .digest('base64url')}`; +} + +function declaredLength(request, maximum) { + const value = request.headers.get('content-length'); + if (value === null) return null; + if (!/^(0|[1-9][0-9]*)$/u.test(value) + || !Number.isSafeInteger(Number(value))) { + fail('AGENT_BODY_SCHEMA', 'Content-Length is invalid'); + } + const length = Number(value); + if (length > maximum) fail('AGENT_BODY_TOO_LARGE', 'agent body exceeds its byte limit'); + return length; +} + +function assertDuplicateFreeJson(text) { + let index = 0; + const maximumDepth = 64; + const skipWhitespace = () => { + while (index < text.length && /[\u0009\u000a\u000d\u0020]/u.test(text[index])) index += 1; + }; + const parseString = () => { + if (text[index] !== '"') fail('AGENT_BODY_SCHEMA', 'JSON string is invalid'); + const start = index; + index += 1; + while (index < text.length) { + const character = text[index]; + if (character === '"') { + index += 1; + return JSON.parse(text.slice(start, index)); + } + if (character === '\\') { + index += 1; + if (text[index] === 'u') index += 5; + else index += 1; + } else { + index += 1; + } + } + fail('AGENT_BODY_SCHEMA', 'JSON string is unterminated'); + }; + const parsePrimitive = () => { + const remainder = text.slice(index); + const token = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/u.exec(remainder)?.[0]; + if (!token) fail('AGENT_BODY_SCHEMA', 'JSON primitive is invalid'); + index += token.length; + }; + const parseValue = (depth) => { + if (depth > maximumDepth) fail('AGENT_BODY_SCHEMA', 'JSON nesting exceeds its bound'); + skipWhitespace(); + if (text[index] === '{') { + index += 1; + skipWhitespace(); + const keys = new Set(); + if (text[index] === '}') { index += 1; return; } + while (index < text.length) { + const key = parseString(); + if (keys.has(key)) fail('AGENT_BODY_SCHEMA', 'JSON object contains a duplicate key'); + keys.add(key); + skipWhitespace(); + if (text[index] !== ':') fail('AGENT_BODY_SCHEMA', 'JSON object separator is invalid'); + index += 1; + parseValue(depth + 1); + skipWhitespace(); + if (text[index] === '}') { index += 1; return; } + if (text[index] !== ',') fail('AGENT_BODY_SCHEMA', 'JSON object separator is invalid'); + index += 1; + skipWhitespace(); + } + fail('AGENT_BODY_SCHEMA', 'JSON object is unterminated'); + } + if (text[index] === '[') { + index += 1; + skipWhitespace(); + if (text[index] === ']') { index += 1; return; } + while (index < text.length) { + parseValue(depth + 1); + skipWhitespace(); + if (text[index] === ']') { index += 1; return; } + if (text[index] !== ',') fail('AGENT_BODY_SCHEMA', 'JSON array separator is invalid'); + index += 1; + } + fail('AGENT_BODY_SCHEMA', 'JSON array is unterminated'); + } + if (text[index] === '"') { + parseString(); + return; + } + parsePrimitive(); + }; + parseValue(0); + skipWhitespace(); + if (index !== text.length) fail('AGENT_BODY_SCHEMA', 'JSON has trailing non-whitespace data'); +} + +async function readJsonObjectBody(request, maximum) { + const expectedLength = declaredLength(request, maximum); + if (request.body === null) fail('AGENT_BODY_REQUIRED', 'agent request body is required'); + const chunks = []; + let length = 0; + const reader = request.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) fail('AGENT_BODY_SCHEMA', 'agent body stream is invalid'); + length += value.byteLength; + if (length > maximum) { + await reader.cancel(); + fail('AGENT_BODY_TOO_LARGE', 'agent body exceeds its byte limit'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + if (expectedLength !== null && expectedLength !== length) { + fail('AGENT_BODY_SCHEMA', 'agent body length changed'); + } + const bytes = Buffer.alloc(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + let text; + let parsed; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + parsed = JSON.parse(text); + assertDuplicateFreeJson(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + fail('AGENT_BODY_SCHEMA', 'agent body must be one JSON object'); + } + } catch (error) { + if (error instanceof KernelError && error.code === 'AGENT_BODY_SCHEMA') throw error; + fail('AGENT_BODY_SCHEMA', 'agent body must be valid duplicate-free JSON'); + } + return Buffer.from(canonicalJson(parsed), 'utf8'); +} + +function capturedExecutionResult(value) { + return captureClosedRecord(value, [ + 'requestId', 'status', 'reasonCode', 'receipt', + ], [ + 'upstreamStatus', 'body', 'expiresAt', 'replacementRequestId', 'replacementExpiresAt', + ], 'AGENT_RESPONSE_INVALID', 'Kernel execution result'); +} + +function capturedStatusView(value) { + return captureClosedRecord(value, [ + 'requestId', + 'sellerOrigin', + 'purposeLabel', + 'intentState', + 'approval', + 'outcome', + 'receipt', + 'remainingSessionAtomic', + ], [], 'AGENT_RESPONSE_INVALID', 'Kernel agent status projection'); +} + +function validateStatusBinding(status) { + try { + canonicalToken(status.requestId, 'Kernel request ID'); + canonicalToken(status.purposeLabel, 'Kernel purpose label', 64); + const origin = new URL(status.sellerOrigin); + if (origin.origin !== status.sellerOrigin || origin.pathname !== '/' + || origin.search !== '' || origin.hash !== '' || origin.username || origin.password) { + fail('AGENT_RESPONSE_INVALID', 'Kernel seller origin is invalid'); + } + if (!INTENT_STATES.has(status.intentState) || !ATOMIC.test(status.remainingSessionAtomic)) { + fail('AGENT_RESPONSE_INVALID', 'Kernel status state is invalid'); + } + } catch (error) { + if (error instanceof KernelError && error.code === 'AGENT_RESPONSE_INVALID') throw error; + fail('AGENT_RESPONSE_INVALID', 'Kernel status binding is invalid'); + } + return status; +} + +function canonicalPublicOutcome(value) { + const outcome = exactRecord( + value, + ['status', 'reasonCode', 'revision'], + [], + 'AGENT_RESPONSE_INVALID', + 'Kernel BuyerOutcome projection', + ); + if (!PUBLIC_OUTCOMES.has(outcome.status) + || typeof outcome.reasonCode !== 'string' + || !/^[A-Z][A-Z0-9_]{0,127}$/u.test(outcome.reasonCode) + || !Number.isSafeInteger(outcome.revision) || outcome.revision < 1) { + fail('AGENT_RESPONSE_INVALID', 'Kernel BuyerOutcome projection is invalid'); + } + return outcome; +} + +function projectReceipt(value, remainingSessionAtomic, { sessionId, route = null }) { + if (!ATOMIC.test(remainingSessionAtomic)) { + fail('AGENT_RESPONSE_INVALID', 'Kernel session remainder is invalid'); + } + const bundle = exactRecord(value, [ + 'id', 'intentId', 'revision', 'receipt', 'receiptHash', 'signature', + 'algorithm', 'keyId', 'supersedesReceiptHash', 'createdAt', + ], [], 'AGENT_RESPONSE_INVALID', 'signed receipt bundle'); + const receipt = exactRecord(bundle.receipt, [ + 'schemaVersion', 'receiptId', 'revision', 'issuedAt', 'intent', 'outcome', + 'policy', 'approval', 'payment', 'execution', 'budget', 'reconciliation', + 'refund', 'supersedesReceiptHash', + ], [], 'AGENT_RESPONSE_INVALID', 'signed receipt'); + const intent = exactRecord(receipt.intent, [ + 'id', 'requestId', 'intentHash', 'sessionId', 'sellerOrigin', 'resourcePath', 'purposeLabel', + ], [], 'AGENT_RESPONSE_INVALID', 'signed receipt intent'); + const outcome = exactRecord(receipt.outcome, ['status', 'reasonCode'], [], + 'AGENT_RESPONSE_INVALID', 'signed receipt outcome'); + const execution = exactRecord(receipt.execution, ['state', 'httpStatus', 'responseHash'], [], + 'AGENT_RESPONSE_INVALID', 'signed receipt execution'); + const paymentFields = receipt.payment?.state === 'none' + ? ['state'] + : ['state', 'amountAtomic', 'network', 'asset', 'payTo', 'transactionId']; + const payment = exactRecord(receipt.payment, paymentFields, [], + 'AGENT_RESPONSE_INVALID', 'signed receipt payment'); + const paymentStates = new Set(['none', 'not_signed', 'unresolved', 'rejected', 'settled']); + try { + canonicalToken(bundle.id, 'signed receipt ID'); + canonicalToken(bundle.intentId, 'signed receipt intent ID'); + canonicalToken(receipt.receiptId, 'receipt ID'); + canonicalToken(intent.id, 'receipt intent ID'); + canonicalToken(intent.requestId, 'receipt request ID'); + canonicalToken(intent.sessionId, 'receipt session ID'); + canonicalToken(intent.purposeLabel, 'receipt purpose label', 64); + canonicalTimestamp(receipt.issuedAt, 'receipt issuedAt'); + canonicalTimestamp(bundle.createdAt, 'signed receipt createdAt'); + } catch { + fail('AGENT_RESPONSE_INVALID', 'signed receipt identifiers are invalid'); + } + if (receipt.schemaVersion !== 1 + || bundle.id !== receipt.receiptId + || bundle.intentId !== intent.id + || bundle.revision !== receipt.revision + || bundle.createdAt !== receipt.issuedAt + || bundle.supersedesReceiptHash !== receipt.supersedesReceiptHash + || bundle.algorithm !== 'Ed25519' + || typeof bundle.signature !== 'string' + || Buffer.from(bundle.signature, 'base64').length !== 64 + || Buffer.from(bundle.signature, 'base64').toString('base64') !== bundle.signature + || !SHA256.test(bundle.keyId) + || !RECEIPT_HASH.test(bundle.receiptHash) + || !PUBLIC_OUTCOMES.has(outcome.status) + || typeof outcome.reasonCode !== 'string' + || !/^[A-Z][A-Z0-9_]{0,127}$/u.test(outcome.reasonCode) + || !Number.isSafeInteger(receipt.revision) || receipt.revision < 1 + || ((receipt.revision === 1) !== (receipt.supersedesReceiptHash === null)) + || (receipt.supersedesReceiptHash !== null + && !RECEIPT_HASH.test(receipt.supersedesReceiptHash)) + || intent.sessionId !== sessionId + || !SHA256.test(intent.intentHash) + || typeof intent.sellerOrigin !== 'string' + || new URL(intent.sellerOrigin).origin !== intent.sellerOrigin + || (route !== null + && (new URL(route.upstreamUrl).origin !== intent.sellerOrigin + || new URL(route.upstreamUrl).pathname !== intent.resourcePath + || route.purposeLabel !== intent.purposeLabel)) + || !paymentStates.has(payment.state) + || (payment.state !== 'none' && !ATOMIC.test(payment.amountAtomic)) + || (payment.state !== 'none' + && payment.transactionId !== null + && !EVM_TRANSACTION.test(payment.transactionId)) + || (execution.httpStatus !== null + && (!Number.isSafeInteger(execution.httpStatus) + || execution.httpStatus < 100 || execution.httpStatus > 599))) { + fail('AGENT_RESPONSE_INVALID', 'signed receipt projection is invalid'); + } + const chargedAtomic = payment.state === 'settled' + ? payment.amountAtomic + : (payment.state === 'unresolved' ? null : '0'); + return Object.freeze({ + compact: Object.freeze({ + id: receipt.receiptId, + hash: bundle.receiptHash, + sellerOrigin: intent.sellerOrigin, + chargedAtomic, + remainingSessionAtomic, + terminalState: outcome.status, + transactionPrefix: payment.state === 'none' || payment.transactionId === null + ? null + : payment.transactionId.slice(0, 10), + }), + requestId: intent.requestId, + reasonCode: outcome.reasonCode, + revision: receipt.revision, + httpStatus: execution.httpStatus, + }); +} + +function approvalProjection(value, { allowApproved = false } = {}) { + const approval = exactRecord(value, [ + 'state', 'expiresAt', 'amountAtomic', + ], [], 'AGENT_RESPONSE_INVALID', 'Kernel approval projection'); + if ((approval.state !== 'pending' && !(allowApproved && approval.state === 'approved')) + || !ATOMIC.test(approval.amountAtomic) || BigInt(approval.amountAtomic) < 1n) { + fail('AGENT_RESPONSE_INVALID', 'Kernel approval projection is invalid'); + } + try { + canonicalTimestamp(approval.expiresAt, 'approval expiry'); + } catch { + fail('AGENT_RESPONSE_INVALID', 'Kernel approval expiry is invalid'); + } + return approval; +} + +function terminalHttpStatus(status, upstreamStatus) { + if (status === 'completed' || status === 'refunded') return 200; + if (status === 'payment_denied') return 403; + if (status === 'payment_failed' || status === 'upstream_failed' + || status === 'execution_unknown') return 502; + if (status === 'payment_unresolved') return 503; + if (status === 'payment_rejected') return 402; + if (status === 'execution_failed') { + return Number.isSafeInteger(upstreamStatus) + && upstreamStatus >= 400 && upstreamStatus <= 599 + ? upstreamStatus + : 502; + } + fail('AGENT_RESPONSE_INVALID', 'Kernel BuyerOutcome status is invalid'); +} + +function boundedResponseBody(value, maximum) { + const isBuffer = Buffer.isBuffer(value) && Object.getPrototypeOf(value) === Buffer.prototype; + const isBytes = value instanceof Uint8Array + && Object.getPrototypeOf(value) === Uint8Array.prototype; + if ((!isBuffer && !isBytes) || value.buffer instanceof SharedArrayBuffer + || value.byteLength > maximum) { + fail('AGENT_RESPONSE_INVALID', 'upstream response body is invalid'); + } + return Buffer.from(value); +} + +function validJsonResponseBody(value, maximum) { + const bytes = boundedResponseBody(value, maximum); + try { + const parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + fail('AGENT_RESPONSE_INVALID', 'upstream response body is not one JSON object'); + } + } catch (error) { + if (error instanceof KernelError) throw error; + fail('AGENT_RESPONSE_INVALID', 'upstream response body is invalid JSON'); + } + return bytes; +} + +function validOpenAiEventStreamBody(value, maximum) { + const bytes = boundedResponseBody(value, maximum); + let text; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + fail('AGENT_RESPONSE_INVALID', 'upstream event stream is invalid UTF-8'); + } + const normalized = text.replaceAll('\r\n', '\n'); + if (normalized.includes('\r') || !normalized.endsWith('\n\n')) { + fail('AGENT_RESPONSE_INVALID', 'upstream event stream framing is invalid'); + } + const frames = normalized.slice(0, -2).split('\n\n'); + if (frames.length < 2 || frames.some((frame) => frame.length === 0)) { + fail('AGENT_RESPONSE_INVALID', 'upstream event stream framing is invalid'); + } + let eventCount = 0; + for (let index = 0; index < frames.length; index += 1) { + const frame = frames[index]; + if (frame.includes('\n') || !frame.startsWith('data:')) { + fail('AGENT_RESPONSE_INVALID', 'upstream event stream contains an unsupported field'); + } + let payload = frame.slice('data:'.length); + if (payload.startsWith(' ')) payload = payload.slice(1); + if (payload === '[DONE]') { + if (index !== frames.length - 1 || eventCount === 0) { + fail('AGENT_RESPONSE_INVALID', 'upstream event stream terminator is invalid'); + } + continue; + } + if (index === frames.length - 1) { + fail('AGENT_RESPONSE_INVALID', 'upstream event stream terminator is missing'); + } + try { + const parsed = JSON.parse(payload); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + fail('AGENT_RESPONSE_INVALID', 'upstream event stream data is not one JSON object'); + } + } catch (error) { + if (error instanceof KernelError) throw error; + fail('AGENT_RESPONSE_INVALID', 'upstream event stream data is invalid JSON'); + } + eventCount += 1; + } + return bytes; +} + +function approvalWaitDelay(milliseconds, signal) { + if (signal.aborted) { + fail('AGENT_REQUEST_ABORTED', 'agent disconnected during approval wait'); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(new KernelError( + 'AGENT_REQUEST_ABORTED', + 'agent disconnected during approval wait', + )); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, milliseconds); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +export function createSpendControlProxy(value) { + const dependencies = captureDependencies(value); + const app = new Hono({ strict: true }); + + const secureResponseHeaders = (context) => { + context.header('Cache-Control', 'no-store'); + context.header('X-Content-Type-Options', 'nosniff'); + }; + + app.onError((error, context) => { + secureResponseHeaders(context); + return context.json({ error: publicError(error) }, errorStatus(error)); + }); + app.notFound((context) => { + secureResponseHeaders(context); + return context.json({ + error: { code: 'AGENT_ROUTE_NOT_FOUND', message: 'Agent route does not exist' }, + }, 404); + }); + + const authorize = async (context) => { + secureResponseHeaders(context); + const principal = await dependencies.authenticate(context.req.raw); + const session = await dependencies.resolveBoundSession(principal); + if (!session || typeof session !== 'object' || utilTypes.isProxy(session)) { + throw new KernelError('AGENT_SESSION_UNAVAILABLE', 'Agent Spend Session is unavailable'); + } + const descriptor = Object.getOwnPropertyDescriptor(session, 'id'); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new KernelError('AGENT_SESSION_UNAVAILABLE', 'Agent Spend Session is unavailable'); + } + return Object.freeze({ id: canonicalToken(descriptor.value, 'Spend Session ID') }); + }; + + const executeRoute = (kind) => async (context) => { + const session = await authorize(context); + requireNoQueryOrEncoding(context); + const route = requireRoute(dependencies, context.req.param('routeId'), kind); + const approvalWaitMs = requestedApprovalWaitMs(context.req.raw); + const headers = forwardHeaders(context.req.raw); + const agentCallId = requiredAgentCallId(context.req.raw); + const bodyBytes = await readJsonObjectBody( + context.req.raw, + Math.min(dependencies.maximumRequestBytes, route.maximumRequestBytes), + ); + const correlationId = correlationIdForAgentCall(agentCallId); + const executionInput = () => Object.freeze({ + sessionId: session.id, + routeId: route.id, + request: Object.freeze({ + requestUrl: route.upstreamUrl, + method: route.method, + headers, + bodyBytes: Buffer.from(bodyBytes), + }), + purposeLabel: route.purposeLabel, + correlationId, + }); + const readStatus = async (requestId) => { + const status = validateStatusBinding(capturedStatusView( + await dependencies.statusByRequestId({ sessionId: session.id, requestId }), + )); + if (status.requestId !== requestId + || status.sellerOrigin !== new URL(route.upstreamUrl).origin + || status.purposeLabel !== route.purposeLabel) { + fail('AGENT_RESPONSE_INVALID', 'Kernel execution and status projections disagree'); + } + return status; + }; + const executeExact = async () => { + const result = capturedExecutionResult( + await dependencies.execute(executionInput()), + ); + canonicalToken(result.requestId, 'Kernel request ID'); + return Object.freeze({ result, status: await readStatus(result.requestId) }); + }; + let { result, status } = await executeExact(); + + const approvalHardDeadline = approvalWaitMs === 0 ? null : Date.now() + approvalWaitMs; + let approvalTransitions = 0; + while (result.status === 'payment_approval_required') { + approvalTransitions += 1; + if (approvalTransitions > MAXIMUM_APPROVAL_TRANSITIONS) { + fail('AGENT_APPROVAL_WAIT_TIMEOUT', 'approval transition count reached its safety bound'); + } + if (result.receipt !== null || result.reasonCode !== 'HUMAN_APPROVAL_REQUIRED') { + fail('AGENT_RESPONSE_INVALID', 'Kernel approval projections disagree'); + } + try { + canonicalTimestamp(result.expiresAt, 'Kernel approval result expiry'); + } catch { + fail('AGENT_RESPONSE_INVALID', 'Kernel approval result expiry is invalid'); + } + const requestId = result.requestId; + if (status.outcome !== null) { + if (status.approval !== null || status.receipt === null) { + fail('AGENT_RESPONSE_INVALID', 'Kernel raced terminal approval projection is invalid'); + } + } else { + const approval = approvalProjection(status.approval, { allowApproved: true }); + if (status.receipt !== null || result.expiresAt !== approval.expiresAt) { + fail('AGENT_RESPONSE_INVALID', 'Kernel approval projections disagree'); + } + if (approvalWaitMs === 0) { + return context.json({ + status: 'payment_approval_required', + requestId: result.requestId, + approval: { + expiresAt: approval.expiresAt, + amountAtomic: approval.amountAtomic, + sellerOrigin: new URL(route.upstreamUrl).origin, + purposeLabel: route.purposeLabel, + }, + }, 409); + } + while (status.approval?.state === 'pending') { + const now = Date.now(); + if (now >= Date.parse(approval.expiresAt) + || now >= approvalHardDeadline) break; + await approvalWaitDelay(Math.min( + APPROVAL_POLL_INTERVAL_MS, + Date.parse(approval.expiresAt) - now, + approvalHardDeadline - now, + ), context.req.raw.signal); + status = await readStatus(requestId); + } + if (Date.now() >= approvalHardDeadline + && Date.now() < Date.parse(approval.expiresAt) + && status.outcome === null) { + fail('AGENT_APPROVAL_WAIT_TIMEOUT', 'connected approval wait reached its bound'); + } + } + if (context.req.raw.signal.aborted) { + fail('AGENT_REQUEST_ABORTED', 'agent disconnected before approval resume'); + } + ({ result, status } = await executeExact()); + } + + if (result.status === 'request_in_flight') { + if (status.outcome !== null || status.receipt !== null || result.receipt !== null + || result.reasonCode !== 'REQUEST_IN_FLIGHT') { + fail('AGENT_RESPONSE_INVALID', 'Kernel in-flight projections disagree'); + } + return context.json({ + status: 'request_in_flight', + requestId: result.requestId, + reasonCode: 'REQUEST_IN_FLIGHT', + }, 409); + } + + const outcome = canonicalPublicOutcome(status.outcome); + if (outcome.status !== result.status || outcome.reasonCode !== result.reasonCode + || status.receipt === null || result.receipt === null) { + fail('AGENT_RESPONSE_INVALID', 'Kernel execution and status projections disagree'); + } + const projected = projectReceipt(status.receipt, status.remainingSessionAtomic, { + sessionId: session.id, + route, + }); + const resultProjected = projectReceipt(result.receipt, status.remainingSessionAtomic, { + sessionId: session.id, + route, + }); + const hasUpstreamStatus = Object.hasOwn(result, 'upstreamStatus'); + const hasBody = Object.hasOwn(result, 'body'); + const completedReplay = result.status === 'completed' && !hasUpstreamStatus && !hasBody; + if (projected.compact.hash !== resultProjected.compact.hash + || projected.requestId !== result.requestId + || projected.reasonCode !== result.reasonCode + || projected.revision !== outcome.revision + || (result.status === 'completed' + && (hasUpstreamStatus !== hasBody + || (!completedReplay && (!Number.isSafeInteger(result.upstreamStatus) + || result.upstreamStatus < 200 + || result.upstreamStatus > 299 + || result.upstreamStatus !== projected.httpStatus))))) { + fail('AGENT_RESPONSE_INVALID', 'Kernel signed receipt projection disagrees'); + } + const receipt = projected.compact; + if (result.status !== 'completed') { + return context.json({ + status: result.status, + requestId: result.requestId, + reasonCode: result.reasonCode, + receipt, + }, terminalHttpStatus(result.status, projected.httpStatus)); + } + if (completedReplay) { + return context.json({ + status: 'completed_replay', + terminalStatus: 'completed', + requestId: result.requestId, + reasonCode: result.reasonCode, + projections: { + request: `/agent/v1/intents/${encodeURIComponent(result.requestId)}`, + receipt: `/agent/v1/receipts/${encodeURIComponent(receipt.id)}`, + }, + receipt, + }, 409); + } + const streamRequested = kind === 'openai-chat' + && JSON.parse(bodyBytes.toString('utf8')).stream === true; + const responseBody = streamRequested + ? validOpenAiEventStreamBody(result.body, route.maximumResponseBytes) + : validJsonResponseBody(result.body, route.maximumResponseBytes); + if (kind === 'openai-chat') { + return new Response(responseBody, { + status: 200, + headers: { + 'cache-control': 'no-store', + 'content-type': streamRequested + ? 'text/event-stream; charset=utf-8' + : route.resourceMimeType, + 'x-content-type-options': 'nosniff', + 'x-wallet-receipt-id': receipt.id, + 'x-wallet-terminal-state': receipt.terminalState, + 'x-wallet-charged-atomic': receipt.chargedAtomic ?? 'unknown', + 'x-wallet-session-remaining-atomic': receipt.remainingSessionAtomic, + 'x-wallet-transaction-prefix': receipt.transactionPrefix ?? 'none', + }, + }); + } + return context.json({ + status: 'completed', + requestId: result.requestId, + resource: { + httpStatus: projected.httpStatus, + contentType: route.resourceMimeType, + body: JSON.parse(responseBody.toString('utf8')), + }, + receipt, + }, 200); + }; + app.post('/agent/v1/openai/:routeId/chat/completions', executeRoute('openai-chat')); + app.post('/agent/v1/invoke/:routeId', executeRoute('tool')); + + const requireRead = (context, parameter) => { + requireNoQueryOrEncoding(context); + const identifier = context.req.param(parameter); + try { + canonicalToken(identifier, `agent ${parameter}`, 200); + } catch { + fail('AGENT_IDENTIFIER', 'agent read identifier is invalid'); + } + const request = context.req.raw; + const declared = request.headers.get('content-length'); + if (request.body !== null + || (declared !== null && declared !== '0') + || request.headers.has('content-type') + || request.headers.has('transfer-encoding')) { + fail('AGENT_BODY_SCHEMA', 'agent read route does not accept a body'); + } + return identifier; + }; + + const readProjection = (status, session, { expectedRequestId, expectedReceiptId = null }) => { + validateStatusBinding(status); + if (status.requestId !== expectedRequestId) { + fail('AGENT_READ_NOT_FOUND', 'agent resource is outside its Spend Session'); + } + if (status.outcome === null) { + if (status.receipt !== null && status.approval === null) { + fail('AGENT_RESPONSE_INVALID', 'Kernel nonterminal status projection is invalid'); + } + if (status.approval !== null) { + const approval = approvalProjection(status.approval, { allowApproved: true }); + if (approval.state === 'approved') { + return Object.freeze({ + status: 'request_in_flight', + requestId: status.requestId, + reasonCode: 'APPROVAL_GRANTED_RETRY_REQUIRED', + }); + } + return Object.freeze({ + status: 'payment_approval_required', + requestId: status.requestId, + approval: Object.freeze({ + expiresAt: approval.expiresAt, + amountAtomic: approval.amountAtomic, + sellerOrigin: status.sellerOrigin, + purposeLabel: status.purposeLabel, + }), + }); + } + return Object.freeze({ + status: 'request_in_flight', + requestId: status.requestId, + reasonCode: 'REQUEST_IN_FLIGHT', + }); + } + const outcome = canonicalPublicOutcome(status.outcome); + if (status.receipt === null) { + fail('AGENT_RESPONSE_INVALID', 'Kernel terminal status has no receipt'); + } + const receipt = projectReceipt(status.receipt, status.remainingSessionAtomic, { + sessionId: session.id, + }); + if (receipt.requestId !== status.requestId + || receipt.reasonCode !== outcome.reasonCode + || receipt.revision !== outcome.revision + || receipt.compact.terminalState !== outcome.status + || receipt.compact.sellerOrigin !== status.sellerOrigin + || (expectedReceiptId !== null && receipt.compact.id !== expectedReceiptId)) { + fail('AGENT_RESPONSE_INVALID', 'Kernel status and receipt projections disagree'); + } + return Object.freeze({ + status: outcome.status, + requestId: status.requestId, + reasonCode: outcome.reasonCode, + receipt: receipt.compact, + }); + }; + + const scopedRead = async (action) => { + try { + const value = await action(); + if (value === null) fail('AGENT_READ_NOT_FOUND', 'agent resource was not found'); + return validateStatusBinding(capturedStatusView(value)); + } catch (error) { + if (error instanceof KernelError + && (error.code === 'AGENT_READ_NOT_FOUND' + || error.code?.endsWith('_UNKNOWN') + || error.code?.endsWith('_MISMATCH') + || error.code === 'INTENT_UNKNOWN')) { + fail('AGENT_READ_NOT_FOUND', 'agent resource was not found'); + } + throw error; + } + }; + + app.get('/agent/v1/intents/:requestId', async (context) => { + if (context.req.method !== 'GET') return context.notFound(); + const session = await authorize(context); + const requestId = requireRead(context, 'requestId'); + const status = await scopedRead(() => dependencies.statusByRequestId({ + sessionId: session.id, + requestId, + })); + return context.json(readProjection(status, session, { expectedRequestId: requestId }), 200); + }); + app.get('/agent/v1/receipts/:receiptId', async (context) => { + if (context.req.method !== 'GET') return context.notFound(); + const session = await authorize(context); + const receiptId = requireRead(context, 'receiptId'); + const status = await scopedRead(() => dependencies.receiptById({ + sessionId: session.id, + receiptId, + })); + return context.json(readProjection(status, session, { + expectedRequestId: status.requestId, + expectedReceiptId: receiptId, + }), 200); + }); + + return app; +} diff --git a/spikes/pi-wielder/tests/agent-auth.test.mjs b/spikes/pi-wielder/tests/agent-auth.test.mjs new file mode 100644 index 0000000..d061990 --- /dev/null +++ b/spikes/pi-wielder/tests/agent-auth.test.mjs @@ -0,0 +1,380 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { createAgentAuth } from '../src/agent/auth.mjs'; +import { canonicalJson, KernelError, sha256 } from '../src/kernel/canonical.mjs'; +import { validatePolicyDocument } from '../src/kernel/policy-engine.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const ORIGIN = 'http://127.0.0.1:8505'; +const TOKEN = Buffer.alloc(32, 0x41).toString('base64url'); +const WRONG_TOKEN = Buffer.alloc(32, 0x42).toString('base64url'); +const INSTANCE_ID = Buffer.alloc(16, 0x31).toString('base64url'); +const CREDENTIAL_DIGEST = sha256(Buffer.from(TOKEN, 'base64url')); +const WALLET = '0x1000000000000000000000000000000000000000'; +const POLICY = validatePolicyDocument({ + schemaVersion: 1, + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + wallet: WALLET, + methods: ['POST'], + sellers: [{ + origin: 'https://seller.example', + pathPrefixes: ['/paid/'], + payTo: '0x2000000000000000000000000000000000000000', + evidencePath: '/.well-known/wallet-kernel/evidence', + executionSigner: '0x3000000000000000000000000000000000000000', + refundSigner: '0x4000000000000000000000000000000000000000', + refundSource: '0x5000000000000000000000000000000000000000', + perRequestMaxAtomic: '500000', + autoApproveAtomic: '100000', + humanApproveAtomic: '500000', + sellerSessionMaxAtomic: '1000000', + }], + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '5000000', + challengeMaxAgeMs: 60000, + approvalTtlMs: 300000, + maxPendingApprovals: 20, + defaultAction: 'deny', +}); +const POLICY_VERSION = Object.freeze({ + id: 'policy-1', + hash: sha256(canonicalJson(POLICY)), + policy: POLICY, +}); +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: INSTANCE_ID, + credentialDigest: CREDENTIAL_DIGEST, + agentUid: String(process.getuid()), + agentGid: String(process.getgid()), +}); +const ENROLLMENT_HASH = sha256(canonicalJson(DESCRIPTOR)); + +function activeRow(overrides = {}) { + return { + agent_instance_id: INSTANCE_ID, + credential_digest: CREDENTIAL_DIGEST, + enrollment_hash: ENROLLMENT_HASH, + agent_uid: String(process.getuid()), + agent_gid: String(process.getgid()), + state: 'active', + ...overrides, + }; +} + +function bindingRow(overrides = {}) { + return { + binding_id: 'binding-1', + agent_instance_id: INSTANCE_ID, + credential_digest: CREDENTIAL_DIGEST, + enrollment_hash: ENROLLMENT_HASH, + session_id: 'session-1', + binding_state: 'open', + session_state: 'open', + ...overrides, + }; +} + +function dependencies({ enrollments = [activeRow()], bindings = [bindingRow()], session } = {}) { + const calls = []; + const state = { enrollments, bindings, session }; + return { + calls, + state, + store: Object.freeze({ + readAll(sql, params = []) { + calls.push({ kind: 'readAll', sql, params }); + if (sql.includes('FROM agent_enrollments')) return state.enrollments; + if (sql.includes('FROM agent_session_bindings')) return state.bindings; + throw new Error('unexpected read query'); + }, + }), + intents: Object.freeze({ + getSession(sessionId) { + calls.push({ kind: 'getSession', sessionId }); + return state.session ?? Object.freeze({ + id: 'session-1', + adapterId: `pi:${INSTANCE_ID}`, + agentInstanceId: INSTANCE_ID, + enrollmentHash: ENROLLMENT_HASH, + walletAddress: WALLET, + policyVersionId: POLICY_VERSION.id, + state: 'open', + createdAt: '2026-08-01T12:00:00.000Z', + closedAt: null, + sessionHash: `sha256:${'66'.repeat(32)}`, + }); + }, + }), + }; +} + +function create(overrides = {}) { + const deps = dependencies(overrides); + const auth = createAgentAuth({ + store: deps.store, + intents: deps.intents, + walletIdentity: Object.freeze({ + network: POLICY.network, + address: WALLET, + }), + activePolicy: POLICY_VERSION, + kernelUid: process.getuid(), + kernelGid: process.getgid(), + expectedAgentUid: process.getuid(), + expectedAgentGid: process.getgid(), + mode: 'deterministic', + }); + return { auth, ...deps }; +} + +function request(pathname = '/agent/v1/openai/example-model/chat/completions', options = {}) { + return new Request(`${ORIGIN}${pathname}`, options); +} + +function assertCode(action, code, forbidden = []) { + assert.throws(action, (error) => { + assert.ok(error instanceof KernelError); + assert.equal(error.code, code); + assert.equal(error.cause, undefined); + const serialized = `${String(error)} ${JSON.stringify(error)}`; + for (const value of forbidden) assert.equal(serialized.includes(value), false); + return true; + }); +} + +test('agent auth returns only frozen enrolled public authority and never reads the body', () => { + const { auth } = create(); + assert.deepEqual(Object.keys(auth).sort(), ['authenticate', 'resolveBoundSession']); + let bodyReads = 0; + const body = new ReadableStream({ + pull(controller) { + bodyReads += 1; + controller.enqueue(new TextEncoder().encode('RAW_PROMPT_SENTINEL')); + controller.close(); + }, + }); + const principal = auth.authenticate(request(undefined, { + method: 'POST', + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + body, + duplex: 'half', + })); + assert.equal(bodyReads, 0); + assert.equal(Object.isFrozen(principal), true); + assert.deepEqual(principal, { + agentInstanceId: INSTANCE_ID, + credentialDigest: CREDENTIAL_DIGEST, + enrollmentHash: ENROLLMENT_HASH, + agentUid: String(process.getuid()), + agentGid: String(process.getgid()), + }); + assert.equal(JSON.stringify(principal).includes(TOKEN), false); + assert.equal(Object.hasOwn(principal, 'sessionId'), false); +}); + +test('real SQLite null-prototype rows cross only the closed authentication row boundary', (t) => { + const store = openKernelStore({ filePath: ':memory:', allowMemory: true }); + t.after(() => store.close()); + const quoted = (value) => `'${String(value).replaceAll("'", "''")}'`; + store.execForTest(`INSERT INTO policy_versions + (id, schema_version, canonical_json, policy_hash, predecessor_hash, applied_at) + VALUES (${quoted(POLICY_VERSION.id)}, 1, ${quoted(canonicalJson(POLICY))}, + ${quoted(POLICY_VERSION.hash)}, NULL, '2026-08-01T12:00:00.000Z'); + INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at, closed_at) + VALUES ('session-1', ${quoted(`pi:${INSTANCE_ID}`)}, ${quoted(WALLET)}, + ${quoted(POLICY_VERSION.id)}, 'open', '2026-08-01T12:00:00.000Z', NULL); + INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, + state, enrolled_by_operator_hash, enrolled_at, revoked_by_operator_hash, revoked_at) + VALUES (${quoted(INSTANCE_ID)}, ${quoted(CREDENTIAL_DIGEST)}, ${quoted(ENROLLMENT_HASH)}, + ${quoted(String(process.getuid()))}, ${quoted(String(process.getgid()))}, 'active', + ${quoted(`sha256:${'77'.repeat(32)}`)}, '2026-08-01T12:00:00.000Z', NULL, NULL); + INSERT INTO agent_session_bindings + (id, agent_instance_id, credential_digest, enrollment_hash, session_id, state, + created_at, last_seen_at, closed_at) + VALUES ('binding-1', ${quoted(INSTANCE_ID)}, ${quoted(CREDENTIAL_DIGEST)}, + ${quoted(ENROLLMENT_HASH)}, 'session-1', 'open', + '2026-08-01T12:00:00.000Z', '2026-08-01T12:00:00.000Z', NULL);`); + + assert.equal(Object.getPrototypeOf(store.readAll( + "SELECT agent_instance_id FROM agent_enrollments WHERE state = 'active'", + )[0]), null); + const auth = createAgentAuth({ + store, + intents: Object.freeze({ + getSession() { + return Object.freeze({ + id: 'session-1', adapterId: `pi:${INSTANCE_ID}`, agentInstanceId: INSTANCE_ID, + enrollmentHash: ENROLLMENT_HASH, walletAddress: WALLET, + policyVersionId: POLICY_VERSION.id, state: 'open', + createdAt: '2026-08-01T12:00:00.000Z', closedAt: null, + sessionHash: `sha256:${'66'.repeat(32)}`, + }); + }, + }), + walletIdentity: Object.freeze({ network: POLICY.network, address: WALLET }), + activePolicy: POLICY_VERSION, + kernelUid: process.getuid(), + kernelGid: process.getgid(), + expectedAgentUid: process.getuid(), + expectedAgentGid: process.getgid(), + mode: 'deterministic', + }); + const principal = auth.authenticate(request(undefined, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + })); + assert.equal(auth.resolveBoundSession(principal).id, 'session-1'); +}); + +test('agent credential grammar, channel, query, cookie, and forwarding fail redacted', () => { + const { auth } = create(); + for (const authorization of [ + undefined, + '', + `Bearer ${TOKEN}`, + `WalletKernelAgent ${TOKEN}`, + `WalletKernelAgent ${TOKEN}, WalletKernelAgent ${TOKEN}`, + `WalletKernelAgent ${TOKEN.slice(1)}`, + `WalletKernelAgent ${WRONG_TOKEN}`, + ]) { + assertCode(() => auth.authenticate(request(undefined, { + headers: authorization === undefined ? {} : { authorization }, + })), 'AGENT_UNAUTHORIZED', [TOKEN, WRONG_TOKEN]); + } + for (const [pathname, headers] of [ + [`/agent/v1/intents/value?token=${TOKEN}`, { authorization: `WalletKernelAgent ${TOKEN}` }], + ['/agent/v1/intents/value', { authorization: `WalletKernelAgent ${TOKEN}`, cookie: `agent=${TOKEN}` }], + ['/agent/v1/intents/value', { authorization: `WalletKernelAgent ${TOKEN}`, forwarded: 'for=unix' }], + ['/agent/v1/intents/value', { authorization: `WalletKernelAgent ${TOKEN}`, 'x-forwarded-for': '127.0.0.1' }], + ]) { + assertCode(() => auth.authenticate(request(pathname, { headers })), 'AGENT_UNAUTHORIZED', [TOKEN]); + } +}); + +test('revocation and zero-active recovery state reject before route or body authority', () => { + const value = create(); + value.state.enrollments = []; + assertCode(() => value.auth.authenticate(request('/agent/v1/unknown', { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + })), 'AGENT_ENROLLMENT_REQUIRED', [TOKEN]); + + value.state.enrollments = [activeRow(), activeRow({ + agent_instance_id: Buffer.alloc(16, 0x39).toString('base64url'), + credential_digest: sha256(Buffer.alloc(32, 0x39)), + enrollment_hash: `sha256:${'99'.repeat(32)}`, + })]; + assertCode(() => value.auth.authenticate(request(undefined, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + })), 'AGENT_ENROLLMENT_AMBIGUOUS', [TOKEN]); +}); + +test('startup identity and policy authority fail closed', () => { + const deps = dependencies(); + const base = { + store: deps.store, + intents: deps.intents, + walletIdentity: Object.freeze({ network: POLICY.network, address: WALLET }), + activePolicy: POLICY_VERSION, + kernelUid: process.getuid(), + kernelGid: process.getgid(), + expectedAgentUid: process.getuid(), + expectedAgentGid: process.getgid(), + mode: 'deterministic', + }; + for (const mutation of [ + { expectedAgentUid: process.getuid() + 1 }, + { walletIdentity: Object.freeze({ network: POLICY.network, address: POLICY.sellers[0].payTo }) }, + { activePolicy: Object.freeze({ ...POLICY_VERSION, hash: `sha256:${'99'.repeat(32)}` }) }, + { mode: 'cdp-testnet' }, + { mode: 'unknown' }, + { walletAdapter: {} }, + ]) { + assert.throws(() => createAgentAuth({ ...base, ...mutation })); + } +}); + +test('session resolution is read-only, exact, and immediately revocation-aware', () => { + const value = create(); + const principal = value.auth.authenticate(request(undefined, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + })); + const session = value.auth.resolveBoundSession(principal); + assert.equal(Object.isFrozen(session), true); + assert.equal(session.id, 'session-1'); + assert.equal(session.enrollmentHash, ENROLLMENT_HASH); + assert.equal(value.calls.filter((call) => call.kind === 'getSession').length, 1); + + value.state.enrollments = []; + assertCode(() => value.auth.resolveBoundSession(principal), 'AGENT_ENROLLMENT_REQUIRED'); +}); + +test('closed, ambiguous, mismatched, and policy-blocked bindings cannot silently spend', () => { + const scenarios = [ + { bindings: [], code: 'AGENT_SESSION_UNAVAILABLE' }, + { bindings: [bindingRow(), bindingRow({ binding_id: 'binding-2', session_id: 'session-2' })], code: 'SESSION_AUTHORITY_AMBIGUOUS' }, + { bindings: [bindingRow({ credential_digest: `sha256:${'77'.repeat(32)}` })], code: 'SESSION_AUTHORITY_AMBIGUOUS' }, + { bindings: [bindingRow({ binding_state: 'closed', session_state: 'closed' })], code: 'AGENT_SESSION_UNAVAILABLE' }, + { + bindings: [bindingRow({ session_state: 'policy_blocked' })], + session: Object.freeze({ + id: 'session-1', adapterId: `pi:${INSTANCE_ID}`, agentInstanceId: INSTANCE_ID, + enrollmentHash: ENROLLMENT_HASH, walletAddress: WALLET, + policyVersionId: 'policy-old', state: 'policy_blocked', + createdAt: '2026-08-01T12:00:00.000Z', closedAt: null, + sessionHash: `sha256:${'66'.repeat(32)}`, + }), + code: 'POLICY_TRANSITION_REQUIRED', + }, + ]; + for (const scenario of scenarios) { + const value = create(scenario); + const principal = value.auth.authenticate(request(undefined, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + })); + assertCode(() => value.auth.resolveBoundSession(principal), scenario.code); + } +}); + +test('the same auth instance resolves a guarded replacement on the repository-current policy', () => { + const value = create({ + bindings: [bindingRow({ session_state: 'policy_blocked' })], + session: Object.freeze({ + id: 'session-1', adapterId: `pi:${INSTANCE_ID}`, agentInstanceId: INSTANCE_ID, + enrollmentHash: ENROLLMENT_HASH, walletAddress: WALLET, + policyVersionId: POLICY_VERSION.id, state: 'policy_blocked', + createdAt: '2026-08-01T12:00:00.000Z', closedAt: null, + sessionHash: `sha256:${'66'.repeat(32)}`, + }), + }); + const principal = value.auth.authenticate(request(undefined, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + })); + assertCode(() => value.auth.resolveBoundSession(principal), 'POLICY_TRANSITION_REQUIRED'); + + value.state.bindings = [bindingRow({ + binding_id: 'binding-2', + session_id: 'session-2', + })]; + value.state.session = Object.freeze({ + id: 'session-2', adapterId: `pi:${INSTANCE_ID}`, agentInstanceId: INSTANCE_ID, + enrollmentHash: ENROLLMENT_HASH, walletAddress: WALLET, + policyVersionId: 'policy-2', state: 'open', + createdAt: '2026-08-01T12:05:00.000Z', closedAt: null, + sessionHash: `sha256:${'77'.repeat(32)}`, + }); + const replacement = value.auth.resolveBoundSession(principal); + assert.equal(replacement.id, 'session-2'); + assert.equal(replacement.policyVersionId, 'policy-2'); +}); + +test('source uses fixed-length comparison and zeroes decoded credential bytes', () => { + const source = fs.readFileSync(new URL('../src/agent/auth.mjs', import.meta.url), 'utf8'); + assert.match(source, /timingSafeEqual/); + assert.match(source, /\.fill\(0\)/); + assert.doesNotMatch(source, /console\.|process\.env|localStorage|sessionStorage/); +}); diff --git a/spikes/pi-wielder/tests/agent-credential-cli.test.mjs b/spikes/pi-wielder/tests/agent-credential-cli.test.mjs new file mode 100644 index 0000000..adcfe03 --- /dev/null +++ b/spikes/pi-wielder/tests/agent-credential-cli.test.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { runAgentCredentialCli } from '../src/agent/credential-cli.mjs'; + +const CURRENT_UID = process.getuid(); + +function fixture(t) { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-agent-cli-')), + ); + const privateDirectory = path.join(root, 'private'); + const enrollmentDirectory = path.join(root, 'enrollment'); + fs.mkdirSync(privateDirectory, { mode: 0o700 }); + fs.mkdirSync(enrollmentDirectory, { mode: 0o755 }); + fs.chmodSync(root, 0o700); + fs.chmodSync(privateDirectory, 0o700); + fs.chmodSync(enrollmentDirectory, 0o755); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return Object.freeze({ + credentialPath: path.join(privateDirectory, 'agent.json'), + enrollmentPath: path.join(enrollmentDirectory, 'agent-enrollment.json'), + pathTrust: Object.freeze({ + mode: 'deterministic', + trustedAncestor: root, + agentUid: CURRENT_UID, + }), + }); +} + +function deterministicRandom() { + const values = [0x11, 0x22, 0x33]; + let index = 0; + return (size) => Buffer.alloc(size, values[index++] ?? 0x44); +} + +test('credential init prints only one descriptor SHA-256 and never publishes the token', (t) => { + const value = fixture(t); + const output = []; + const result = runAgentCredentialCli({ + argv: [ + 'init', + '--credential', value.credentialPath, + '--enrollment', value.enrollmentPath, + ], + writeStdout(bytes) { output.push(bytes); }, + dependencies: { + pathTrust: value.pathTrust, + randomBytes: deterministicRandom(), + }, + }); + + assert.equal(result, 0); + assert.equal(output.length, 1); + assert.match(output[0], /^sha256:[0-9a-f]{64}\n$/); + assert.doesNotMatch(output[0], /^sha256:sha256:/); + + const credentialText = fs.readFileSync(value.credentialPath, 'utf8'); + const enrollmentText = fs.readFileSync(value.enrollmentPath, 'utf8'); + const credential = JSON.parse(credentialText); + assert.equal(enrollmentText.includes(credential.token), false); + assert.equal(output[0].includes(credential.token), false); +}); + +test('credential init refuses enrollment overwrite without rotating the existing credential', (t) => { + const value = fixture(t); + const argv = [ + 'init', + '--credential', value.credentialPath, + '--enrollment', value.enrollmentPath, + ]; + runAgentCredentialCli({ + argv, + writeStdout() {}, + dependencies: { + pathTrust: value.pathTrust, + randomBytes: deterministicRandom(), + }, + }); + const credentialBefore = fs.readFileSync(value.credentialPath); + const enrollmentBefore = fs.readFileSync(value.enrollmentPath); + const output = []; + + assert.throws(() => runAgentCredentialCli({ + argv, + writeStdout(bytes) { output.push(bytes); }, + dependencies: { + pathTrust: value.pathTrust, + randomBytes() { throw new Error('must not rotate'); }, + }, + }), (error) => error?.code === 'EEXIST'); + + assert.deepEqual(output, []); + assert.deepEqual(fs.readFileSync(value.credentialPath), credentialBefore); + assert.deepEqual(fs.readFileSync(value.enrollmentPath), enrollmentBefore); +}); + +test('credential CLI accepts only the exact init grammar before touching authority', () => { + const attempts = []; + for (const argv of [ + [], + ['help'], + ['init'], + ['init', '--credential', '/a', '--enrollment', '/b', '--extra', 'x'], + ['init', '--enrollment', '/b', '--credential', '/a'], + ['init', '--credential=/a', '--enrollment=/b'], + ['init', '--credential', '/a', '--credential', '/b', '--enrollment', '/c'], + ]) { + assert.throws(() => runAgentCredentialCli({ + argv, + writeStdout() { attempts.push('stdout'); }, + dependencies: { + pathTrust: Object.freeze({}), + randomBytes() { attempts.push('random'); }, + }, + }), (error) => error?.code === 'AGENT_CREDENTIAL_CLI_USAGE'); + } + assert.deepEqual(attempts, []); +}); + +test('credential CLI source has no Kernel authority, environment, database, or listener path', () => { + const source = fs.readFileSync( + new URL('../src/agent/credential-cli.mjs', import.meta.url), + 'utf8', + ); + assert.match(source, /from '\.\/credential\.mjs'/); + assert.doesNotMatch(source, /process\.env|WALLET_KERNEL_OPERATOR|sqlite|listen\(|createServer|kernel\/wallet-kernel|operator/i); +}); diff --git a/spikes/pi-wielder/tests/agent-credential.test.mjs b/spikes/pi-wielder/tests/agent-credential.test.mjs new file mode 100644 index 0000000..a1974b8 --- /dev/null +++ b/spikes/pi-wielder/tests/agent-credential.test.mjs @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + createAgentEnrollmentDescriptor, + loadOrCreateAgentCredential, + publishAgentEnrollmentDescriptor, +} from '../src/agent/credential.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; + +const CURRENT_UID = process.getuid(); +const CURRENT_GID = process.getgid(); + +function fixture(t) { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-agent-credential-')), + ); + fs.chmodSync(root, 0o700); + const privateParent = path.join(root, 'private'); + const enrollmentInbox = path.join(root, 'enrollment-inbox'); + fs.mkdirSync(privateParent, { mode: 0o700 }); + fs.mkdirSync(enrollmentInbox, { mode: 0o755 }); + fs.chmodSync(privateParent, 0o700); + fs.chmodSync(enrollmentInbox, 0o755); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return Object.freeze({ + root, + credentialPath: path.join(privateParent, 'agent.json'), + enrollmentPath: path.join(enrollmentInbox, 'agent-enrollment.json'), + pathTrust: Object.freeze({ + mode: 'deterministic', + trustedAncestor: root, + agentUid: CURRENT_UID, + }), + }); +} + +function deterministicRandom(sequence = [0x11, 0x22, 0x33, 0x44]) { + let index = 0; + return (size) => Buffer.alloc(size, sequence[index++] ?? 0x55); +} + +test('Pi credential initializes exact canonical private authority and reuses it unchanged', (t) => { + const value = fixture(t); + const calls = []; + const randomBytes = deterministicRandom(); + const credential = loadOrCreateAgentCredential({ + filePath: value.credentialPath, + pathTrust: value.pathTrust, + randomBytes(size) { + calls.push(size); + return randomBytes(size); + }, + }); + assert.deepEqual(Object.keys(credential), ['agentInstanceId', 'schemaVersion', 'token']); + assert.equal(credential.schemaVersion, 1); + assert.match(credential.agentInstanceId, /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/); + assert.match(credential.token, /^[A-Za-z0-9_-]{43}$/); + assert.equal(Buffer.from(credential.agentInstanceId, 'base64url').length, 16); + assert.equal(Buffer.from(credential.token, 'base64url').length, 32); + assert.equal(Buffer.from(credential.agentInstanceId, 'base64url').toString('base64url'), credential.agentInstanceId); + assert.equal(Buffer.from(credential.token, 'base64url').toString('base64url'), credential.token); + assert.equal( + fs.readFileSync(value.credentialPath, 'utf8'), + `${canonicalJson(credential)}\n`, + ); + const stat = fs.lstatSync(value.credentialPath); + assert.equal(stat.isFile(), true); + assert.equal(stat.isSymbolicLink(), false); + assert.equal(stat.uid, CURRENT_UID); + assert.equal(stat.mode & 0o777, 0o600); + assert.deepEqual(calls, [16, 32, 16]); + + const before = fs.statSync(value.credentialPath, { bigint: true }); + const reused = loadOrCreateAgentCredential({ + filePath: value.credentialPath, + pathTrust: value.pathTrust, + randomBytes() { throw new Error('must not rotate an existing Pi credential'); }, + }); + const after = fs.statSync(value.credentialPath, { bigint: true }); + assert.deepEqual(reused, credential); + assert.equal(after.ino, before.ino); + assert.equal(after.mtimeNs, before.mtimeNs); +}); + +test('agent instance generation rejects a base64url-leading authority delimiter', (t) => { + const value = fixture(t); + const calls = []; + const outputs = [ + Buffer.from([0xf8, ...new Array(15).fill(0)]), + Buffer.alloc(16, 0x11), + Buffer.alloc(32, 0x22), + Buffer.alloc(16, 0x33), + ]; + const credential = loadOrCreateAgentCredential({ + filePath: value.credentialPath, + pathTrust: value.pathTrust, + randomBytes(size) { + calls.push(size); + return outputs.shift(); + }, + }); + + assert.match(Buffer.from([0xf8, ...new Array(15).fill(0)]).toString('base64url'), /^-/); + assert.match(credential.agentInstanceId, /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/); + assert.deepEqual(calls, [16, 16, 32, 16]); + + const exhausted = fixture(t); + let attempts = 0; + assert.throws(() => loadOrCreateAgentCredential({ + filePath: exhausted.credentialPath, + pathTrust: exhausted.pathTrust, + randomBytes(size) { + attempts += 1; + return Buffer.from([0xf8, ...new Array(size - 1).fill(0)]); + }, + }), (error) => error?.code === 'AGENT_CREDENTIAL_RANDOMNESS'); + assert.equal(attempts, 128); + assert.equal(fs.existsSync(exhausted.credentialPath), false); +}); + +test('Pi credential rejects noncanonical bytes and authority metadata without repair', (t) => { + for (const bytes of [ + '{}\n', + `${JSON.stringify({ schemaVersion: 1, agentInstanceId: 'A'.repeat(22), token: 'A'.repeat(43), extra: true })}\n`, + `${canonicalJson({ schemaVersion: 1, agentInstanceId: 'A'.repeat(22), token: `${'A'.repeat(42)}B` })}\n`, + `${canonicalJson({ + schemaVersion: 1, + agentInstanceId: Buffer.from([0xf8, ...new Array(15).fill(0)]).toString('base64url'), + token: 'A'.repeat(43), + })}\n`, + canonicalJson({ schemaVersion: 1, agentInstanceId: 'A'.repeat(22), token: 'A'.repeat(43) }), + ` ${canonicalJson({ schemaVersion: 1, agentInstanceId: 'A'.repeat(22), token: 'A'.repeat(43) })}\n`, + ]) { + const value = fixture(t); + fs.writeFileSync(value.credentialPath, bytes, { mode: 0o600 }); + fs.chmodSync(value.credentialPath, 0o600); + assert.throws(() => loadOrCreateAgentCredential({ + filePath: value.credentialPath, + pathTrust: value.pathTrust, + randomBytes() { throw new Error('must not repair'); }, + })); + assert.equal(fs.readFileSync(value.credentialPath, 'utf8'), bytes); + } + + const permissive = fixture(t); + fs.writeFileSync(permissive.credentialPath, `${canonicalJson({ + schemaVersion: 1, + agentInstanceId: Buffer.alloc(16, 1).toString('base64url'), + token: Buffer.alloc(32, 2).toString('base64url'), + })}\n`, { mode: 0o644 }); + fs.chmodSync(permissive.credentialPath, 0o644); + assert.throws(() => loadOrCreateAgentCredential({ + filePath: permissive.credentialPath, + pathTrust: permissive.pathTrust, + })); + + const symlink = fixture(t); + const target = path.join(symlink.root, 'target.json'); + fs.writeFileSync(target, '{}\n', { mode: 0o600 }); + fs.symlinkSync(target, symlink.credentialPath); + assert.throws(() => loadOrCreateAgentCredential({ + filePath: symlink.credentialPath, + pathTrust: symlink.pathTrust, + })); +}); + +test('enrollment descriptor is public, secret-free, and hashes raw credential bytes once', (t) => { + const value = fixture(t); + const credential = loadOrCreateAgentCredential({ + filePath: value.credentialPath, + pathTrust: value.pathTrust, + randomBytes: deterministicRandom(), + }); + const descriptor = createAgentEnrollmentDescriptor({ + credential, + }); + assert.deepEqual(Object.keys(descriptor), [ + 'schemaVersion', + 'agentInstanceId', + 'credentialDigest', + 'agentUid', + 'agentGid', + ]); + assert.equal(descriptor.agentInstanceId, credential.agentInstanceId); + const rawToken = Buffer.from(credential.token, 'base64url'); + assert.equal(descriptor.credentialDigest, sha256(rawToken)); + assert.notEqual(descriptor.credentialDigest, sha256(credential.token)); + assert.equal(JSON.stringify(descriptor).includes(credential.token), false); + assert.equal(JSON.stringify(descriptor).includes('token'), false); + + const published = publishAgentEnrollmentDescriptor({ + filePath: value.enrollmentPath, + credentialPath: value.credentialPath, + descriptor, + pathTrust: value.pathTrust, + }); + const bytes = `${canonicalJson(descriptor)}\n`; + assert.deepEqual(published, Object.freeze({ + descriptor, + descriptorHash: sha256(canonicalJson(descriptor)), + })); + assert.equal(fs.readFileSync(value.enrollmentPath, 'utf8'), bytes); + const stat = fs.lstatSync(value.enrollmentPath); + assert.equal(stat.isFile(), true); + assert.equal(stat.isSymbolicLink(), false); + assert.equal(stat.nlink, 1); + assert.equal(stat.uid, CURRENT_UID); + assert.equal(stat.mode & 0o777, 0o644); + assert.equal(fs.readFileSync(value.enrollmentPath, 'utf8').includes(credential.token), false); + assert.throws(() => publishAgentEnrollmentDescriptor({ + filePath: value.enrollmentPath, + credentialPath: value.credentialPath, + descriptor, + pathTrust: value.pathTrust, + }), (error) => error.code === 'EEXIST'); + assert.equal(fs.readFileSync(value.enrollmentPath, 'utf8'), bytes); +}); + +test('descriptor publication rejects same-parent, extension, noncanonical identity, and hostile shapes', (t) => { + const value = fixture(t); + const credential = loadOrCreateAgentCredential({ + filePath: value.credentialPath, + pathTrust: value.pathTrust, + randomBytes: deterministicRandom(), + }); + const descriptor = createAgentEnrollmentDescriptor({ + credential, + }); + assert.throws(() => publishAgentEnrollmentDescriptor({ + filePath: path.join(path.dirname(value.credentialPath), 'descriptor.json'), + credentialPath: value.credentialPath, + descriptor, + pathTrust: value.pathTrust, + })); + assert.throws(() => publishAgentEnrollmentDescriptor({ + filePath: value.enrollmentPath, + credentialPath: value.credentialPath, + descriptor: { ...descriptor, token: credential.token }, + pathTrust: value.pathTrust, + })); + assert.throws(() => createAgentEnrollmentDescriptor({ + credential, + agentUid: String(CURRENT_UID), + })); + assert.throws(() => createAgentEnrollmentDescriptor(new Proxy({ + credential, + }, {}))); +}); + +test('Pi credential and handoff use their distinct live authority roles', (t) => { + if (process.platform === 'linux') { + t.skip('the live-role wiring assertion is exercised by Linux isolation tests'); + return; + } + const value = fixture(t); + const livePathTrust = Object.freeze({ + mode: 'cdp-testnet', + trustedAncestor: value.root, + agentUid: CURRENT_UID, + }); + assert.throws(() => loadOrCreateAgentCredential({ + filePath: value.credentialPath, + pathTrust: livePathTrust, + randomBytes: deterministicRandom(), + }), /cdp-testnet trusted paths require Linux/); + + const credential = Object.freeze({ + schemaVersion: 1, + agentInstanceId: Buffer.alloc(16, 0x31).toString('base64url'), + token: Buffer.alloc(32, 0x32).toString('base64url'), + }); + const descriptor = createAgentEnrollmentDescriptor({ credential }); + assert.throws(() => publishAgentEnrollmentDescriptor({ + filePath: value.enrollmentPath, + credentialPath: value.credentialPath, + descriptor, + pathTrust: livePathTrust, + }), /cdp-testnet trusted paths require Linux/); +}); diff --git a/spikes/pi-wielder/tests/agent-isolation.test.mjs b/spikes/pi-wielder/tests/agent-isolation.test.mjs new file mode 100644 index 0000000..6cd2fd8 --- /dev/null +++ b/spikes/pi-wielder/tests/agent-isolation.test.mjs @@ -0,0 +1,381 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; +import { + createIsolationAttestationRepository, + hashIsolationMetadata, + validateIsolationMetadata, + validateIsolationReportBytes, +} from '../src/agent/isolation-preflight.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { + buildIsolationReport, + runPrivilegedAgentIsolationPreflight, +} from '../scripts/preflight-agent-isolation.mjs'; +import { dropToAgentIdentity } from '../scripts/agent-isolation-probe-worker.mjs'; +import { + assertReaderEnvironment, + dropToKernelIdentity, + parseReaderArguments, + runDroppedReader, + validateReaderRequest, +} from '../scripts/prelaunch-kernel-reader.mjs'; +import { + assertRootPreflightEnvironment, + parsePreflightArguments, +} from '../scripts/preflight-live-deployment.mjs'; + +const H = (character) => `sha256:${character.repeat(64)}`; +const ENROLLMENT_HASH = H('1'); +const OPERATOR_HASH = H('2'); + +function report(overrides = {}) { + return { + schemaVersion: 1, + enrollmentHash: ENROLLMENT_HASH, + kernelUid: '501', kernelGid: '20', agentUid: '502', agentGid: '20', + authorityMetadataHash: H('3'), credentialMetadataHash: H('4'), + releaseManifestHash: H('5'), releaseTreeHash: H('6'), nodeExecutableHash: H('7'), + serviceArtifactsHash: H('8'), systemdEffectiveConfigHash: H('9'), + environmentMetadataHash: H('a'), + probeResults: { + authorityDirectory: 'EACCES', database: 'EACCES', operatorToken: 'EACCES', + receiptKey: 'EACCES', kernelEnvironment: 'EACCES', agentCredential: 'READABLE', + releaseTreeWrite: 'EACCES', dependencyTreeWrite: 'EACCES', + serviceArtifactsWrite: 'EACCES', kernelEnvironmentParentWrite: 'EACCES', + }, + probedAt: '2026-07-31T12:00:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + ...overrides, + }; +} + +test('pure isolation metadata requires distinct non-root identities and private directions', () => { + const authority = { + role: 'authority', chain: [{ role: 'authority', depth: 0, device: '1', inode: '2', uid: 501, gid: 20, mode: 0o700 }], + leaf: { role: 'authority-leaf', depth: 1, device: '1', inode: '3', uid: 501, gid: 20, mode: 0o600 }, + }; + const credential = { + role: 'credential', chain: [{ role: 'credential', depth: 0, device: '2', inode: '4', uid: 502, gid: 20, mode: 0o700 }], + leaf: { role: 'credential-leaf', depth: 1, device: '2', inode: '5', uid: 502, gid: 20, mode: 0o600 }, + }; + const result = validateIsolationMetadata({ + kernelUid: 501, kernelGid: 20, agentUid: 502, agentGid: 20, + authority, credential, authorityInsideCredential: false, credentialInsideAuthority: false, + }); + assert.equal(result.authorityMetadataHash, hashIsolationMetadata(authority)); + assert.equal(result.credentialMetadataHash, hashIsolationMetadata(credential)); + for (const bad of [ + { kernelUid: 0 }, { agentUid: 0 }, { agentUid: 501 }, + { authorityInsideCredential: true }, { credentialInsideAuthority: true }, + ]) assert.throws(() => validateIsolationMetadata({ + kernelUid: 501, kernelGid: 20, agentUid: 502, agentGid: 20, + authority, credential, authorityInsideCredential: false, credentialInsideAuthority: false, + ...bad, + })); + assert.throws(() => validateIsolationMetadata({ + kernelUid: 501, kernelGid: 20, agentUid: 502, agentGid: 20, + authority: { ...authority, leaf: { ...authority.leaf, mode: 0o640 } }, + credential, authorityInsideCredential: false, credentialInsideAuthority: false, + }), /mode/); +}); + +test('report parser enforces canonical bytes, exact result codes, identity, hash, and half-open expiry', () => { + const bytes = Buffer.from(`${canonicalJson(report())}\n`); + const expectedReportHash = sha256(canonicalJson(report())); + const valid = validateIsolationReportBytes(bytes, { + expectedReportHash, expectedEnrollmentHash: ENROLLMENT_HASH, + expectedKernelUid: '501', expectedKernelGid: '20', + expectedReleaseManifestHash: H('5'), now: () => '2026-07-31T12:14:59.999Z', + }); + assert.equal(valid.reportHash, expectedReportHash); + assert.throws(() => validateIsolationReportBytes(Buffer.from(JSON.stringify(report())), { + expectedReportHash, expectedEnrollmentHash: ENROLLMENT_HASH, + expectedKernelUid: '501', expectedKernelGid: '20', + expectedReleaseManifestHash: H('5'), now: () => '2026-07-31T12:10:00.000Z', + }), /canonical/); + for (const broken of [ + report({ kernelUid: '0' }), report({ agentUid: '501' }), report({ expiresAt: '2026-07-31T12:15:00.001Z' }), + report({ probeResults: { ...report().probeResults, database: 'ENOENT' } }), + ]) assert.throws(() => validateIsolationReportBytes(Buffer.from(`${canonicalJson(broken)}\n`), { + expectedReportHash: sha256(canonicalJson(broken)), expectedEnrollmentHash: ENROLLMENT_HASH, + expectedKernelUid: broken.kernelUid, expectedKernelGid: broken.kernelGid, + expectedReleaseManifestHash: H('5'), now: () => '2026-07-31T12:10:00.000Z', + })); + assert.throws(() => validateIsolationReportBytes(bytes, { + expectedReportHash, expectedEnrollmentHash: ENROLLMENT_HASH, + expectedKernelUid: '501', expectedKernelGid: '20', + expectedReleaseManifestHash: H('5'), now: () => '2026-07-31T12:15:00.000Z', + }), /expired/); +}); + +test('privileged report builder binds deployment hashes and emits only the public report hash', () => { + const value = report(); + const built = buildIsolationReport({ + config: { + schemaVersion: 1, + enrollmentHash: value.enrollmentHash, + kernelUid: value.kernelUid, kernelGid: value.kernelGid, + agentUid: value.agentUid, agentGid: value.agentGid, + authorityMetadataHash: value.authorityMetadataHash, + credentialMetadataHash: value.credentialMetadataHash, + releaseManifestHash: value.releaseManifestHash, + releaseTreeHash: value.releaseTreeHash, + nodeExecutableHash: value.nodeExecutableHash, + serviceArtifactsHash: value.serviceArtifactsHash, + systemdEffectiveConfigHash: value.systemdEffectiveConfigHash, + environmentMetadataHash: value.environmentMetadataHash, + credentialPath: '/private/agent/credential', + protectedReadPaths: {}, writePaths: {}, reportPath: '/private/kernel/report', + }, + probeResults: value.probeResults, + now: () => value.probedAt, + }); + assert.equal(built.reportHash, sha256(canonicalJson(value))); + assert.equal(built.reportBytes.equals(Buffer.from(`${canonicalJson(value)}\n`)), true); +}); + +test('identity-drop helpers clear groups before gid/uid and verify the final identity', () => { + for (const drop of [dropToAgentIdentity, dropToKernelIdentity]) { + const calls = []; + const fake = { + setgroups: (groups) => calls.push(['groups', groups]), + setgid: (gid) => calls.push(['gid', gid]), + setuid: (uid) => calls.push(['uid', uid]), + getuid: () => 501, geteuid: () => 501, + getgid: () => 20, getegid: () => 20, getgroups: () => [20], + }; + drop({ uid: 501, gid: 20, processApi: fake }); + assert.deepEqual(calls, [['groups', []], ['gid', 20], ['uid', 501]]); + } +}); + +test('prelaunch helper executes the dynamic audit only after the exact identity drop', async () => { + const calls = []; + const fake = { + setgroups: (groups) => calls.push(['groups', groups]), + setgid: (gid) => calls.push(['gid', gid]), + setuid: (uid) => calls.push(['uid', uid]), + getuid: () => 501, geteuid: () => 501, + getgid: () => 20, getegid: () => 20, getgroups: () => [20], + }; + const result = await runDroppedReader({ + argv: ['--kernel-uid', '501', '--kernel-gid', '20'], + environment: { NODE_CHANNEL_FD: '3' }, + processApi: fake, + dynamicAudit: async () => { + calls.push(['audit']); + return Object.freeze({ status: 'ready' }); + }, + }); + assert.deepEqual(calls, [ + ['groups', []], ['gid', 20], ['uid', 501], ['audit'], + ]); + assert.deepEqual(result, { status: 'ready' }); +}); + +test('prelaunch protocols reject argument, environment, identity, nonce, and secret-shaped IPC drift', () => { + assert.deepEqual(parseReaderArguments(['--kernel-uid', '501', '--kernel-gid', '20']), { + kernelUid: 501, kernelGid: 20, + }); + assertReaderEnvironment({ NODE_CHANNEL_FD: '3' }); + assert.throws(() => assertReaderEnvironment({ NODE_OPTIONS: '--import=x' }), /environment/); + assert.deepEqual(parsePreflightArguments([ + '--release-manifest', '/opt/wallet/releases/a/manifest.json', + '--kernel-uid', '501', '--kernel-gid', '20', + ]), { + manifestPath: '/opt/wallet/releases/a/manifest.json', kernelUid: '501', kernelGid: '20', + }); + const request = { + nonce: 'n', parentPid: 7, kernelUid: '501', kernelGid: '20', + releaseRoot: '/opt/wallet/releases/a', releaseManifestHash: H('5'), + authorityMetadataHash: H('3'), probeResults: { authorityDirectory: 'EACCES' }, + databasePath: '/private/kernel/kernel.sqlite', + pathTrust: { mode: 'cdp-testnet', trustedAncestor: '/private', kernelUid: 501, agentUid: 502 }, + isolationReportPath: '/private/kernel/report.json', now: '2026-07-31T12:00:00.000Z', + }; + assert.equal(validateReaderRequest(request, { nonce: 'n', parentPid: 7, uid: 501, gid: 20 }), request); + assert.throws(() => validateReaderRequest({ + ...request, probeResults: { receiptKeyPath: '/private/key' }, + }, { nonce: 'n', parentPid: 7, uid: 501, gid: 20 }), /forbidden/); + assert.throws(() => validateReaderRequest({ ...request, nonce: 'wrong' }, { + nonce: 'n', parentPid: 7, uid: 501, gid: 20, + }), /binding/); +}); + +test('root live preflight receives only a safe environment-file pointer, never wallet secrets', () => { + const environmentPath = '/etc/wallet-kernel/kernel.env'; + assert.deepEqual(assertRootPreflightEnvironment({ + PATH: '/usr/bin:/bin', + LANG: 'C.UTF-8', + WALLET_KERNEL_ENV_FILE: environmentPath, + }), { + PATH: '/usr/bin:/bin', + LANG: 'C.UTF-8', + WALLET_KERNEL_ENV_FILE: environmentPath, + }); + + for (const environment of [ + { WALLET_KERNEL_ENV_FILE: environmentPath, CDP_API_KEY_SECRET: 'sentinel' }, + { WALLET_KERNEL_ENV_FILE: environmentPath, CDP_WALLET_SECRET: 'sentinel' }, + { WALLET_KERNEL_ENV_FILE: environmentPath, + WALLET_KERNEL_BASE_SEPOLIA_RPC_URL: 'https://rpc.example/?key=sentinel' }, + { WALLET_KERNEL_ENV_FILE: environmentPath, + WALLET_KERNEL_OPERATOR_TOKEN_FILE: '/private/token' }, + { WALLET_KERNEL_ENV_FILE: environmentPath, NODE_OPTIONS: '--import=/tmp/hook.mjs' }, + ]) assert.throws(() => assertRootPreflightEnvironment(environment), /environment/); + + assert.throws(() => assertRootPreflightEnvironment({ PATH: '/usr/bin:/bin' }), + /environment file pointer/); + assert.throws(() => assertRootPreflightEnvironment({ + WALLET_KERNEL_ENV_FILE: 'relative/kernel.env', + }), /environment file pointer/); +}); + +function repositoryFixture() { + const store = openKernelStore({ filePath: ':memory:', allowMemory: true }); + store.execForTest(`INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, + state, enrolled_by_operator_hash, enrolled_at, revoked_by_operator_hash, revoked_at) + VALUES ('AAAAAAAAAAAAAAAAAAAAAA', '${H('b')}', '${ENROLLMENT_HASH}', '502', '20', + 'active', '${OPERATOR_HASH}', '2026-07-31T11:00:00.000Z', NULL, NULL)`); + return store; +} + +test('repository atomically imports, replays, supersedes, and resolves only the exact current binding', () => { + const store = repositoryFixture(); + let id = 0; + let now = '2026-07-31T12:05:00.000Z'; + const repository = createIsolationAttestationRepository({ + store, now: () => now, idFactory: () => `isolation-${++id}`, + }); + const firstReport = report(); + const firstBytes = Buffer.from(`${canonicalJson(firstReport)}\n`); + const firstHash = sha256(canonicalJson(firstReport)); + const first = repository.importCurrent({ + reportBytes: firstBytes, expectedReportHash: firstHash, operatorIdHash: OPERATOR_HASH, + }); + assert.equal(first.reportHash, firstHash); + assert.deepEqual(repository.importCurrent({ + reportBytes: firstBytes, expectedReportHash: firstHash, operatorIdHash: OPERATOR_HASH, + }), first); + assert.equal(repository.currentFor({ + enrollmentHash: ENROLLMENT_HASH, authorityMetadataHash: H('3'), + releaseManifestHash: H('5'), expectedReportHash: firstHash, + }).reportHash, firstHash); + const secondReport = report({ + authorityMetadataHash: H('c'), probedAt: '2026-07-31T12:06:00.000Z', + expiresAt: '2026-07-31T12:14:00.000Z', + }); + now = '2026-07-31T12:07:00.000Z'; + const secondHash = sha256(canonicalJson(secondReport)); + const second = repository.importCurrent({ + reportBytes: Buffer.from(`${canonicalJson(secondReport)}\n`), + expectedReportHash: secondHash, operatorIdHash: OPERATOR_HASH, + }); + assert.equal(second.reportHash, secondHash); + assert.equal(store.readOne('SELECT state FROM isolation_attestations WHERE report_hash = ?', [firstHash]).state, 'superseded'); + assert.equal(repository.currentFor({ + enrollmentHash: ENROLLMENT_HASH, authorityMetadataHash: H('3'), + releaseManifestHash: H('5'), expectedReportHash: firstHash, + }), null); + now = '2026-07-31T12:14:00.000Z'; + assert.equal(repository.currentFor({ + enrollmentHash: ENROLLMENT_HASH, authorityMetadataHash: H('c'), + releaseManifestHash: H('5'), expectedReportHash: secondHash, + }), null); + store.close(); +}); + +test('repository rejects inactive/mismatched enrollment and rolls back supersession on insert failure', () => { + const store = repositoryFixture(); + const repository = createIsolationAttestationRepository({ + store, now: () => '2026-07-31T12:05:00.000Z', idFactory: () => 'duplicate-id', + }); + const bytes = Buffer.from(`${canonicalJson(report())}\n`); + const hash = sha256(canonicalJson(report())); + repository.importCurrent({ reportBytes: bytes, expectedReportHash: hash, operatorIdHash: OPERATOR_HASH }); + const other = report({ authorityMetadataHash: H('d'), probedAt: '2026-07-31T12:06:00.000Z' }); + assert.throws(() => repository.importCurrent({ + reportBytes: Buffer.from(`${canonicalJson(other)}\n`), + expectedReportHash: sha256(canonicalJson(other)), operatorIdHash: OPERATOR_HASH, + })); + assert.equal(store.readOne('SELECT state FROM isolation_attestations WHERE report_hash = ?', [hash]).state, 'current'); + store.execForTest(`UPDATE agent_enrollments SET state='revoked', + revoked_by_operator_hash='${OPERATOR_HASH}', revoked_at='2026-07-31T12:07:00.000Z'`); + assert.throws(() => repository.importCurrent({ + reportBytes: bytes, expectedReportHash: hash, operatorIdHash: OPERATOR_HASH, + }), /active enrollment/); + store.close(); +}); + +test('real dropped-UID isolation probe is explicit and skipped without safe fixture identities', async (t) => { + if (process.platform === 'win32' || process.getuid?.() !== 0 + || !process.env.WALLET_KERNEL_TEST_AGENT_UID || !process.env.WALLET_KERNEL_TEST_AGENT_GID) { + t.skip('requires root and explicit disposable WALLET_KERNEL_TEST_AGENT_UID/GID'); + return; + } + const agentUid = Number(process.env.WALLET_KERNEL_TEST_AGENT_UID); + const agentGid = Number(process.env.WALLET_KERNEL_TEST_AGENT_GID); + const kernelUid = Number(process.env.WALLET_KERNEL_TEST_KERNEL_UID); + const kernelGid = Number(process.env.WALLET_KERNEL_TEST_KERNEL_GID); + assert.equal([agentUid, agentGid, kernelUid, kernelGid].every((id) => Number.isSafeInteger(id) && id > 0), true); + assert.notEqual(agentUid, kernelUid); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-real-isolation-')); + try { + fs.chmodSync(root, 0o755); + const authority = path.join(root, 'authority'); + const agentPrivate = path.join(root, 'agent-private'); + const reportParent = path.join(root, 'reports'); + const writeParents = {}; + fs.mkdirSync(authority, { mode: 0o700 }); + fs.chownSync(authority, kernelUid, kernelGid); + fs.mkdirSync(agentPrivate, { mode: 0o700 }); + fs.chownSync(agentPrivate, agentUid, agentGid); + fs.mkdirSync(reportParent, { mode: 0o700 }); + fs.chownSync(reportParent, kernelUid, kernelGid); + const protectedReadPaths = { authorityDirectory: authority }; + for (const name of ['database', 'operatorToken', 'receiptKey', 'kernelEnvironment']) { + const target = path.join(authority, name); + fs.writeFileSync(target, name, { mode: 0o600 }); + fs.chownSync(target, kernelUid, kernelGid); + protectedReadPaths[name] = target; + } + const credentialPath = path.join(agentPrivate, 'credential'); + fs.writeFileSync(credentialPath, 'fixture-credential', { mode: 0o600 }); + fs.chownSync(credentialPath, agentUid, agentGid); + for (const name of [ + 'releaseTreeWrite', 'dependencyTreeWrite', 'serviceArtifactsWrite', + 'kernelEnvironmentParentWrite', + ]) { + const parent = path.join(root, name); + fs.mkdirSync(parent, { mode: 0o555 }); + writeParents[name] = path.join(parent, 'must-not-exist'); + } + const config = { + schemaVersion: 1, enrollmentHash: ENROLLMENT_HASH, + kernelUid: String(kernelUid), kernelGid: String(kernelGid), + agentUid: String(agentUid), agentGid: String(agentGid), + authorityMetadataHash: H('3'), credentialMetadataHash: H('4'), + releaseManifestHash: H('5'), releaseTreeHash: H('6'), nodeExecutableHash: H('7'), + serviceArtifactsHash: H('8'), systemdEffectiveConfigHash: H('9'), + environmentMetadataHash: H('a'), credentialPath, protectedReadPaths, + writePaths: writeParents, reportPath: path.join(reportParent, 'isolation-report.json'), + }; + const result = await runPrivilegedAgentIsolationPreflight({ + config, now: () => '2026-07-31T12:00:00.000Z', + }); + assert.equal(result.reportHash.startsWith('sha256:'), true); + const stat = fs.lstatSync(config.reportPath); + assert.equal(stat.uid, kernelUid); + assert.equal(stat.gid, kernelGid); + assert.equal(stat.mode & 0o777, 0o600); + assert.equal(Object.values(writeParents).every((target) => !fs.existsSync(target)), true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/spikes/pi-wielder/tests/base-sepolia-observer.test.mjs b/spikes/pi-wielder/tests/base-sepolia-observer.test.mjs new file mode 100644 index 0000000..179a3da --- /dev/null +++ b/spikes/pi-wielder/tests/base-sepolia-observer.test.mjs @@ -0,0 +1,827 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { + encodeAbiParameters, + encodeEventTopics, + parseAbi, +} from 'viem'; + +import { createBaseSepoliaObserver } from '../src/adapters/base-sepolia-observer.mjs'; +import { + canonicalJson, + frozenCopy, + KernelError, + sha256, +} from '../src/kernel/canonical.mjs'; +import { validatePolicyDocument } from '../src/kernel/policy-engine.mjs'; + +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAYER = '0x1000000000000000000000000000000000000000'; +const PAYEE = '0x2000000000000000000000000000000000000000'; +const REFUND_SOURCE = '0x3000000000000000000000000000000000000000'; +const SIGNER = '0x4000000000000000000000000000000000000000'; +const TX = `0x${'ab'.repeat(32)}`; +const REFUND_TX = `0x${'de'.repeat(32)}`; +const BLOCK_HASH = `0x${'cd'.repeat(32)}`; +const HEAD_HASH = `0x${'ef'.repeat(32)}`; +const NONCE = `0x${'11'.repeat(32)}`; +const AMOUNT = '50000'; +const RECEIPT_BLOCK = 100n; +const HEAD_BLOCK = 101n; +const VALID_BEFORE = 1_785_502_860n; +const NOW = new Date(Number((VALID_BEFORE + 120n) * 1_000n)).toISOString(); +const CREATED_AT = new Date(Number((VALID_BEFORE - 120n) * 1_000n)).toISOString(); + +const USDC_EVENTS = parseAbi([ + 'event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)', + 'event Transfer(address indexed from, address indexed to, uint256 value)', +]); + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function block({ + number = RECEIPT_BLOCK, + hash = BLOCK_HASH, + timestamp = VALID_BEFORE - 10n, +} = {}) { + return { number, hash, timestamp }; +} + +function eventLog({ + eventName, + args, + logIndex, + transactionHash = TX, + blockHash = BLOCK_HASH, + blockNumber = RECEIPT_BLOCK, + address = ASSET, + removed = false, + data, + topics, +} = {}) { + const encodedTopics = topics ?? encodeEventTopics({ abi: USDC_EVENTS, eventName, args }); + const encodedData = data ?? (eventName === 'Transfer' + ? encodeAbiParameters([{ type: 'uint256' }], [BigInt(args.value)]) + : '0x'); + return { + address, + blockHash, + blockNumber, + data: encodedData, + logIndex, + removed, + topics: encodedTopics, + transactionHash, + transactionIndex: 0, + }; +} + +function authorizationLog(overrides = {}) { + return eventLog({ + eventName: 'AuthorizationUsed', + args: { authorizer: PAYER, nonce: NONCE }, + logIndex: 5, + ...overrides, + }); +} + +function transferLog(overrides = {}) { + return eventLog({ + eventName: 'Transfer', + args: { from: PAYER, to: PAYEE, value: BigInt(AMOUNT) }, + logIndex: 4, + ...overrides, + }); +} + +function receipt(overrides = {}) { + return { + blockHash: BLOCK_HASH, + blockNumber: RECEIPT_BLOCK, + contractAddress: null, + cumulativeGasUsed: 1n, + effectiveGasPrice: 1n, + from: PAYER, + gasUsed: 1n, + logs: [transferLog(), authorizationLog()], + logsBloom: `0x${'00'.repeat(256)}`, + status: 'success', + to: ASSET, + transactionHash: TX, + transactionIndex: 0, + type: 'eip1559', + ...overrides, + }; +} + +function makeClient({ + chainId = 84_532, + head = HEAD_BLOCK, + blocks, + transactionReceipt = receipt(), + receiptError = null, + balance = 75_000n, + authorizationState = false, + name = 'USDC', + version = '2', + decimals = 6, + readError = null, +} = {}) { + const calls = []; + const blockCalls = new Map(); + const blockSource = blocks ?? new Map([ + [RECEIPT_BLOCK.toString(), block()], + [HEAD_BLOCK.toString(), block({ number: HEAD_BLOCK, hash: HEAD_HASH })], + ]); + const publicClient = Object.freeze({ + async getChainId(...args) { + calls.push({ method: 'getChainId', args }); + return chainId; + }, + async getBlockNumber(...args) { + calls.push({ method: 'getBlockNumber', args }); + return head; + }, + async getBlock(request) { + calls.push({ method: 'getBlock', request }); + const key = request?.blockNumber?.toString(); + const seen = blockCalls.get(key) ?? 0; + blockCalls.set(key, seen + 1); + const configured = blockSource instanceof Map ? blockSource.get(key) : blockSource[key]; + if (Array.isArray(configured)) return configured[Math.min(seen, configured.length - 1)]; + if (configured instanceof Error) throw configured; + return configured; + }, + async getTransactionReceipt(request) { + calls.push({ method: 'getTransactionReceipt', request }); + if (receiptError) throw receiptError; + return transactionReceipt; + }, + async readContract(request) { + calls.push({ + method: 'readContract', + request: { + address: request?.address, + functionName: request?.functionName, + args: request?.args, + blockNumber: request?.blockNumber, + }, + }); + if (readError) throw readError; + if (request.functionName === 'balanceOf') return balance; + if (request.functionName === 'authorizationState') return authorizationState; + if (request.functionName === 'name') return name; + if (request.functionName === 'version') return version; + if (request.functionName === 'decimals') return decimals; + throw new Error('unexpected fake read'); + }, + }); + return { calls, publicClient }; +} + +function localAttemptHash(binding) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-attempt-binding.v1', + intentHash: binding.intentHash, + challengeHash: binding.challengeHash, + quoteId: binding.quoteId, + paymentPayloadHash: binding.paymentPayloadHash, + paymentHeaderHash: binding.paymentHeaderHash, + network: binding.network, + payer: binding.payer, + payee: binding.payee, + asset: binding.asset, + amountAtomic: binding.amountAtomic, + nonce: binding.nonce, + validAfter: binding.validAfter, + validBefore: binding.validBefore, + })); +} + +function paymentBinding({ candidate = true, ...overrides } = {}) { + const base = { + schemaVersion: 1, + domain: 'wallet-kernel.payment-observation.v1', + intentId: 'intent-1', + intentHash: `sha256:${'01'.repeat(32)}`, + challengeHash: `sha256:${'02'.repeat(32)}`, + quoteId: `sha256:${'03'.repeat(32)}`, + network: NETWORK, + asset: ASSET, + payer: PAYER, + payee: PAYEE, + amountAtomic: AMOUNT, + nonce: NONCE, + validAfter: '0', + validBefore: VALID_BEFORE.toString(), + paymentPayloadHash: `sha256:${'04'.repeat(32)}`, + paymentHeaderHash: `sha256:${'05'.repeat(32)}`, + caseHash: `sha256:${'06'.repeat(32)}`, + candidate: candidate === false ? null : { + id: 'payment-candidate-1', + transactionId: TX, + state: 'pending', + createdAt: CREATED_AT, + }, + ...overrides, + }; + base.localAttemptHash ??= localAttemptHash(base); + return deepFreeze(base); +} + +function policy() { + return validatePolicyDocument({ + schemaVersion: 1, + network: NETWORK, + asset: ASSET, + wallet: PAYER, + methods: ['POST'], + sellers: [{ + origin: 'https://seller.example', + pathPrefixes: ['/paid/'], + payTo: PAYEE, + evidencePath: '/.well-known/wallet-kernel/evidence', + executionSigner: SIGNER, + refundSigner: SIGNER, + refundSource: REFUND_SOURCE, + perRequestMaxAtomic: '500000', + autoApproveAtomic: '100000', + humanApproveAtomic: '500000', + sellerSessionMaxAtomic: '1000000', + }], + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '5000000', + challengeMaxAgeMs: 60_000, + approvalTtlMs: 300_000, + maxPendingApprovals: 20, + defaultAction: 'deny', + }); +} + +function localRefundHash(binding) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-binding.v1', + intentHash: binding.intentHash, + originalTransactionId: binding.originalTransactionId, + refundTransactionId: binding.refundTransactionId, + network: binding.network, + sellerOrigin: binding.sellerOrigin, + asset: binding.asset, + originalPayer: binding.originalPayer, + originalPayee: binding.originalPayee, + refundSource: binding.refundSource, + refundSigner: binding.refundSigner, + amountAtomic: binding.amountAtomic, + })); +} + +function refundBinding(overrides = {}) { + const validatedPolicy = policy(); + const base = { + schemaVersion: 1, + domain: 'wallet-kernel.refund-observation.v1', + intentId: 'intent-1', + intentHash: `sha256:${'01'.repeat(32)}`, + policyVersion: { + id: 'policy-1', + hash: sha256(canonicalJson(validatedPolicy)), + policy: validatedPolicy, + }, + seller: validatedPolicy.sellers[0], + resourcePath: '/paid/infer', + network: NETWORK, + sellerOrigin: 'https://seller.example', + originalTransactionId: TX, + refundTransactionId: REFUND_TX, + asset: ASSET, + originalPayer: PAYER, + originalPayee: PAYEE, + refundSource: REFUND_SOURCE, + refundSigner: SIGNER, + amountAtomic: AMOUNT, + refundId: 'refund-1', + caseHash: `sha256:${'07'.repeat(32)}`, + ...overrides, + }; + base.localRefundBindingHash ??= localRefundHash(base); + return deepFreeze(base); +} + +function observer(client, overrides = {}) { + return createBaseSepoliaObserver({ + publicClient: client.publicClient, + now: () => NOW, + ...overrides, + }); +} + +function assertKernelError(error, code) { + assert.equal(error instanceof KernelError, true); + assert.equal(error.code, code); + return true; +} + +function assertUnknown(value, reasonCode) { + assert.deepEqual(value, { kind: 'unknown', reasonCode }); + assert.equal(Object.isFrozen(value), true); + for (const forbidden of ['error', 'message', 'stack', 'cause', 'response']) { + assert.equal(Object.hasOwn(value, forbidden), false); + } +} + +test('observer exposes only the frozen read-only surface and preflights the exact token domain', async () => { + const client = makeClient(); + const value = observer(client); + assert.equal(Object.isFrozen(value), true); + assert.deepEqual(Object.keys(value), ['preflight', 'fundingStatus', 'observePayment', 'observeRefund']); + for (const forbidden of [ + 'client', 'request', 'wallet', 'signer', 'faucet', 'transfer', + 'sendTransaction', 'writeContract', 'getLogs', + ]) assert.equal(Object.hasOwn(value, forbidden), false); + + assert.deepEqual(await value.preflight(), { + network: NETWORK, + asset: ASSET, + eip712Name: 'USDC', + eip712Version: '2', + decimals: 6, + blockNumber: HEAD_BLOCK.toString(), + blockHash: HEAD_HASH, + }); + assert.equal(Object.isFrozen(await value.preflight()), true); + const reads = client.calls.filter((call) => call.method === 'readContract'); + assert.deepEqual(reads.slice(0, 3).map((call) => ({ + address: call.request.address, + functionName: call.request.functionName, + blockNumber: call.request.blockNumber, + })), [ + { address: ASSET, functionName: 'name', blockNumber: HEAD_BLOCK }, + { address: ASSET, functionName: 'version', blockNumber: HEAD_BLOCK }, + { address: ASSET, functionName: 'decimals', blockNumber: HEAD_BLOCK }, + ]); +}); + +test('preflight rejects wrong chain, token domain, reorg, and provider failures without leakage', async () => { + { + const client = makeClient({ chainId: 1 }); + await assert.rejects(observer(client).preflight(), (error) => ( + assertKernelError(error, 'OBSERVER_PREFLIGHT') + && client.calls.length === 1 + )); + } + for (const options of [ + { name: 'USD Coin' }, + { version: '1' }, + { decimals: 18 }, + { readError: new Error('RPC_SECRET_SENTINEL') }, + { blocks: new Map([[HEAD_BLOCK.toString(), [ + block({ number: HEAD_BLOCK, hash: HEAD_HASH }), + block({ number: HEAD_BLOCK, hash: `0x${'99'.repeat(32)}` }), + ]]]) }, + ]) { + let caught; + try { await observer(makeClient(options)).preflight(); } catch (error) { caught = error; } + assertKernelError(caught, 'OBSERVER_PREFLIGHT'); + assert.equal(String(caught).includes('RPC_SECRET_SENTINEL'), false); + } +}); + +test('constructor validates the injected client, clock, and bounded confirmation depth', () => { + const client = makeClient(); + for (const minimumConfirmations of [0, -1, 1.5, 1_001, '2']) { + assert.throws( + () => observer(client, { minimumConfirmations }), + (error) => assertKernelError(error, 'OBSERVER_CONFIG'), + ); + } + assert.throws( + () => createBaseSepoliaObserver({ publicClient: {}, now: () => NOW }), + (error) => assertKernelError(error, 'OBSERVER_CONFIG'), + ); + assert.throws( + () => createBaseSepoliaObserver({ publicClient: client.publicClient, now: 'clock' }), + (error) => assertKernelError(error, 'OBSERVER_CONFIG'), + ); +}); + +test('factory and observer methods reject argument extension before provider access', async () => { + const client = makeClient({ transactionReceipt: refundReceipt() }); + assert.throws( + () => createBaseSepoliaObserver({ publicClient: client.publicClient, now: () => NOW }, null), + (error) => assertKernelError(error, 'OBSERVER_CONFIG'), + ); + const value = observer(client); + await assert.rejects(value.preflight(null), (error) => assertKernelError(error, 'OBSERVER_INPUT')); + await assert.rejects( + value.fundingStatus({ walletAddress: PAYER, requiredAtomic: AMOUNT }, null), + (error) => assertKernelError(error, 'OBSERVER_INPUT'), + ); + await assert.rejects( + value.observePayment(paymentBinding(), null), + (error) => assertKernelError(error, 'OBSERVER_BINDING'), + ); + await assert.rejects( + value.observeRefund(refundBinding(), null), + (error) => assertKernelError(error, 'OBSERVER_BINDING'), + ); + assert.equal(client.calls.length, 0); +}); + +test('configured confirmation depth governs candidate and expiry observations', async () => { + const confirmed = await observer(makeClient({ head: HEAD_BLOCK + 1n }), { + minimumConfirmations: 3, + }).observePayment(paymentBinding()); + assert.equal(confirmed.kind, 'settled_transfer'); + assert.equal(confirmed.rpcTransferProof.confirmations, 3); + + const insufficient = await observer(makeClient({ transactionReceipt: refundReceipt() }), { + minimumConfirmations: 3, + }).observeRefund(refundBinding()); + assertUnknown(insufficient, 'RPC_CONFIRMATIONS_INSUFFICIENT'); +}); + +test('funding status reads one stable captured block and returns only canonical public facts', async () => { + const sufficientClient = makeClient({ balance: 50_000n }); + const sufficient = await observer(sufficientClient).fundingStatus({ + walletAddress: PAYER, + requiredAtomic: AMOUNT, + }); + assert.deepEqual(sufficient, { + walletAddress: PAYER, + asset: ASSET, + balanceAtomic: AMOUNT, + requiredAtomic: AMOUNT, + status: 'sufficient', + blockNumber: HEAD_BLOCK.toString(), + blockHash: HEAD_HASH, + observedAt: NOW, + }); + assert.equal(Object.isFrozen(sufficient), true); + assert.deepEqual( + sufficientClient.calls.filter((call) => call.method === 'readContract')[0].request, + { address: ASSET, functionName: 'balanceOf', args: [PAYER], blockNumber: HEAD_BLOCK }, + ); + + const insufficient = await observer(makeClient({ balance: 49_999n })).fundingStatus({ + walletAddress: PAYER, + requiredAtomic: AMOUNT, + }); + assert.equal(insufficient.status, 'insufficient'); + assert.equal(insufficient.balanceAtomic, '49999'); +}); + +test('funding rejects hostile input, precision loss, reorg, future blocks, and provider leakage', async () => { + const cases = [ + { walletAddress: `0x${'AB'.repeat(20)}`, requiredAtomic: AMOUNT }, + { walletAddress: PAYER, requiredAtomic: '050000' }, + { walletAddress: PAYER, requiredAtomic: AMOUNT, upstreamUrl: 'https://attacker.example' }, + ]; + for (const input of cases) { + const client = makeClient(); + await assert.rejects(observer(client).fundingStatus(input), (error) => ( + assertKernelError(error, 'OBSERVER_INPUT') && client.calls.length === 0 + )); + } + for (const client of [ + makeClient({ balance: Number.MAX_SAFE_INTEGER }), + makeClient({ readError: new Error('PROVIDER_SECRET_SENTINEL') }), + makeClient({ blocks: new Map([[HEAD_BLOCK.toString(), [ + block({ number: HEAD_BLOCK, hash: HEAD_HASH }), + block({ number: HEAD_BLOCK, hash: `0x${'98'.repeat(32)}` }), + ]]]) }), + makeClient({ blocks: new Map([[HEAD_BLOCK.toString(), block({ + number: HEAD_BLOCK, + hash: HEAD_HASH, + timestamp: VALID_BEFORE + 10_000n, + })]]) }), + ]) { + let caught; + try { + await observer(client).fundingStatus({ walletAddress: PAYER, requiredAtomic: AMOUNT }); + } catch (error) { caught = error; } + assertKernelError(caught, 'OBSERVER_UNAVAILABLE'); + assert.equal(String(caught).includes('PROVIDER_SECRET_SENTINEL'), false); + } +}); + +test('payment observation proves exactly one confirmed authorization and transfer', async () => { + const client = makeClient(); + const result = await observer(client).observePayment(paymentBinding()); + assert.deepEqual(result, { + kind: 'settled_transfer', + rpcTransferProof: { + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: TX, + blockHash: BLOCK_HASH, + blockNumber: RECEIPT_BLOCK.toString(), + transactionStatus: 'success', + confirmations: 2, + transferLogIndex: 4, + authorizationLogIndex: 5, + tokenContract: ASSET, + from: PAYER, + to: PAYEE, + valueAtomic: AMOUNT, + authorizationNonce: NONCE, + observedAt: NOW, + }, + }); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.isFrozen(result.rpcTransferProof), true); + assert.deepEqual(client.calls.map((call) => call.method), [ + 'getTransactionReceipt', 'getBlockNumber', 'getBlock', + ]); + assert.deepEqual(client.calls[0].request, { hash: TX }); +}); + +test('confirmed reverted payment remains a rejected candidate while authorization is still valid', async () => { + const client = makeClient({ transactionReceipt: receipt({ status: 'reverted', logs: [] }) }); + const result = await observer(client).observePayment(paymentBinding()); + assert.deepEqual(result, { + kind: 'payment_candidate_rejected', + rejectionProof: { + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: TX, + blockHash: BLOCK_HASH, + blockNumber: RECEIPT_BLOCK.toString(), + transactionStatus: 'reverted', + confirmations: 2, + reasonCode: 'TRANSACTION_REVERTED', + observedAt: NOW, + }, + }); + assert.equal(client.calls.some((call) => call.request?.functionName === 'authorizationState'), false); +}); + +test('confirmed successful payment candidates with mismatched exact logs are rejected', async () => { + const receipts = [ + receipt({ logs: [transferLog(), authorizationLog({ + args: { authorizer: PAYER, nonce: `0x${'12'.repeat(32)}` }, + })] }), + receipt({ logs: [authorizationLog(), transferLog({ + args: { from: PAYER, to: REFUND_SOURCE, value: BigInt(AMOUNT) }, + })] }), + receipt({ logs: [authorizationLog(), transferLog({ + args: { from: PAYER, to: PAYEE, value: 49_999n }, + })] }), + receipt({ logs: [authorizationLog()] }), + ]; + for (const transactionReceipt of receipts) { + const result = await observer(makeClient({ transactionReceipt })).observePayment(paymentBinding()); + assert.equal(result.kind, 'payment_candidate_rejected'); + assert.equal(result.rejectionProof.reasonCode, 'EXACT_TRANSFER_ABSENT'); + assert.equal(result.rejectionProof.transactionStatus, 'success'); + } +}); + +test('post-expiry false authorization state is the only release-grade no-use observation', async () => { + const expiryBlock = block({ + number: RECEIPT_BLOCK, + hash: BLOCK_HASH, + timestamp: VALID_BEFORE, + }); + const client = makeClient({ + transactionReceipt: null, + authorizationState: false, + blocks: new Map([ + [RECEIPT_BLOCK.toString(), expiryBlock], + [HEAD_BLOCK.toString(), block({ number: HEAD_BLOCK, hash: HEAD_HASH })], + ]), + }); + const result = await observer(client).observePayment(paymentBinding({ candidate: false })); + assert.deepEqual(result, { + kind: 'authorization_unused_after_expiry', + network: NETWORK, + asset: ASSET, + payer: PAYER, + nonce: NONCE, + validBefore: VALID_BEFORE.toString(), + authorizationState: false, + observedBlockNumber: RECEIPT_BLOCK.toString(), + observedBlockHash: BLOCK_HASH, + observedBlockTimestamp: VALID_BEFORE.toString(), + confirmations: 2, + }); + assert.equal(Object.isFrozen(result), true); + const read = client.calls.find((call) => call.request?.functionName === 'authorizationState'); + assert.deepEqual(read.request, { + address: ASSET, + functionName: 'authorizationState', + args: [PAYER, NONCE], + blockNumber: RECEIPT_BLOCK, + }); +}); + +test('used, pre-expiry, missing, and insufficient payment evidence stays unknown', async () => { + const beforeExpiry = block({ + number: RECEIPT_BLOCK, + hash: BLOCK_HASH, + timestamp: VALID_BEFORE - 1n, + }); + for (const { client, reasonCode } of [ + { client: makeClient({ transactionReceipt: null, authorizationState: true, blocks: new Map([ + [RECEIPT_BLOCK.toString(), block({ + number: RECEIPT_BLOCK, hash: BLOCK_HASH, timestamp: VALID_BEFORE, + })], + ]) }), reasonCode: 'AUTHORIZATION_ALREADY_USED' }, + { client: makeClient({ transactionReceipt: null, blocks: new Map([ + [RECEIPT_BLOCK.toString(), beforeExpiry], + ]) }), reasonCode: 'AUTHORIZATION_NOT_EXPIRED' }, + { client: makeClient({ + head: RECEIPT_BLOCK, + transactionReceipt: receipt(), + blocks: new Map([[RECEIPT_BLOCK.toString(), block()]]), + }), reasonCode: 'RPC_CONFIRMATIONS_INSUFFICIENT' }, + { client: makeClient({ + head: RECEIPT_BLOCK - 1n, + transactionReceipt: receipt(), + blocks: new Map(), + }), reasonCode: 'RPC_EVIDENCE_INVALID' }, + ]) { + assertUnknown(await observer(client).observePayment(paymentBinding()), reasonCode); + } +}); + +test('insufficient candidate evidence may still prove unused after an already-expired stable block', async () => { + const client = makeClient({ + head: RECEIPT_BLOCK, + transactionReceipt: receipt(), + authorizationState: false, + blocks: new Map([ + [(RECEIPT_BLOCK - 1n).toString(), block({ + number: RECEIPT_BLOCK - 1n, + hash: `0x${'88'.repeat(32)}`, + timestamp: VALID_BEFORE, + })], + ]), + }); + const result = await observer(client).observePayment(paymentBinding()); + assert.equal(result.kind, 'authorization_unused_after_expiry'); + assert.equal(result.observedBlockNumber, (RECEIPT_BLOCK - 1n).toString()); +}); + +test('payment reorgs, duplicate proof logs, malformed relevant logs, and provider errors are unknown', async () => { + const malformed = authorizationLog(); + malformed.data = '0x12'; + for (const { client, reasonCode } of [ + { client: makeClient({ transactionReceipt: receipt({ + blockHash: `0x${'90'.repeat(32)}`, + }) }), reasonCode: 'RPC_REORG_DETECTED' }, + { client: makeClient({ transactionReceipt: receipt({ + logs: [transferLog(), transferLog({ logIndex: 6 }), authorizationLog()], + }) }), reasonCode: 'RPC_EVIDENCE_INVALID' }, + { client: makeClient({ transactionReceipt: receipt({ + logs: [transferLog(), malformed], + }) }), reasonCode: 'RPC_EVIDENCE_INVALID' }, + { client: makeClient({ + receiptError: new Error('PRIVATE_PROVIDER_RESPONSE'), + }), reasonCode: 'RPC_PROVIDER_UNAVAILABLE' }, + ]) { + const result = await observer(client).observePayment(paymentBinding()); + assertUnknown(result, reasonCode); + assert.equal(JSON.stringify(result).includes('PRIVATE_PROVIDER_RESPONSE'), false); + } +}); + +test('payment bindings are closed, internally hashed, and lowercase before any RPC call', async () => { + const variants = [ + { ...paymentBinding(), candidate: { ...paymentBinding().candidate, transactionId: TX.toUpperCase().replace('0X', '0x') } }, + { ...paymentBinding(), localAttemptHash: `sha256:${'99'.repeat(32)}` }, + { ...paymentBinding(), evidence: { status: 'success' } }, + ]; + for (const binding of variants) { + const client = makeClient(); + await assert.rejects(observer(client).observePayment(binding), (error) => ( + assertKernelError(error, 'OBSERVER_BINDING') && client.calls.length === 0 + )); + } +}); + +function refundTransferLog(overrides = {}) { + return transferLog({ + transactionHash: REFUND_TX, + args: { from: REFUND_SOURCE, to: PAYER, value: BigInt(AMOUNT) }, + ...overrides, + }); +} + +function refundReceipt(overrides = {}) { + return receipt({ + transactionHash: REFUND_TX, + logs: [refundTransferLog()], + ...overrides, + }); +} + +test('refund observation returns only the confirmed independent chain half', async () => { + const client = makeClient({ transactionReceipt: refundReceipt() }); + const result = await observer(client).observeRefund(refundBinding()); + assert.deepEqual(result, { + kind: 'refund_transfer_confirmed', + rpcTransferProof: { + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: REFUND_TX, + blockHash: BLOCK_HASH, + blockNumber: RECEIPT_BLOCK.toString(), + transactionStatus: 'success', + confirmations: 2, + transferLogIndex: 4, + tokenContract: ASSET, + from: REFUND_SOURCE, + to: PAYER, + valueAtomic: AMOUNT, + observedAt: NOW, + }, + }); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.hasOwn(result, 'attestation'), false); + assert.equal(Object.hasOwn(result, 'refundConfirmed'), false); + assert.deepEqual(client.calls[0], { + method: 'getTransactionReceipt', request: { hash: REFUND_TX }, + }); +}); + +test('confirmed reverted or exact-transfer-mismatched refund candidates are rejected only', async () => { + for (const transactionReceipt of [ + refundReceipt({ status: 'reverted', logs: [] }), + refundReceipt({ logs: [refundTransferLog({ + args: { from: PAYEE, to: PAYER, value: BigInt(AMOUNT) }, + })] }), + refundReceipt({ logs: [refundTransferLog({ + args: { from: REFUND_SOURCE, to: PAYER, value: 49_999n }, + })] }), + ]) { + const result = await observer(makeClient({ transactionReceipt })).observeRefund(refundBinding()); + assert.equal(result.kind, 'refund_candidate_rejected'); + assert.equal(result.rejectionProof.reasonCode, + transactionReceipt.status === 'reverted' ? 'TRANSACTION_REVERTED' : 'EXACT_TRANSFER_ABSENT'); + assert.equal(Object.hasOwn(result, 'attestation'), false); + } +}); + +test('missing, insufficient, duplicate, malformed, reorged, or uncertain refund proof stays unknown', async () => { + const malformed = refundTransferLog(); + malformed.data = '0x12'; + for (const { client, reasonCode } of [ + { client: makeClient({ transactionReceipt: null }), reasonCode: 'RPC_RECEIPT_MISSING' }, + { client: makeClient({ + head: RECEIPT_BLOCK, + transactionReceipt: refundReceipt(), + }), reasonCode: 'RPC_CONFIRMATIONS_INSUFFICIENT' }, + { client: makeClient({ transactionReceipt: refundReceipt({ + blockHash: `0x${'90'.repeat(32)}`, + }) }), reasonCode: 'RPC_REORG_DETECTED' }, + { client: makeClient({ transactionReceipt: refundReceipt({ + logs: [refundTransferLog(), refundTransferLog({ logIndex: 6 })], + }) }), reasonCode: 'RPC_EVIDENCE_INVALID' }, + { client: makeClient({ transactionReceipt: refundReceipt({ + logs: [malformed], + }) }), reasonCode: 'RPC_EVIDENCE_INVALID' }, + { client: makeClient({ + receiptError: new Error('REFUND_PROVIDER_SECRET'), + }), reasonCode: 'RPC_PROVIDER_UNAVAILABLE' }, + ]) { + const result = await observer(client).observeRefund(refundBinding()); + assertUnknown(result, reasonCode); + assert.equal(JSON.stringify(result).includes('REFUND_PROVIDER_SECRET'), false); + } +}); + +test('refund binding is closed and revalidates immutable PolicyVersion seller authority', async () => { + const valid = refundBinding(); + const changedSeller = { ...valid.seller, refundSource: PAYEE }; + const variants = [ + { ...valid, seller: changedSeller }, + { ...valid, refundSource: PAYEE }, + { ...valid, resourcePath: '/untrusted/infer' }, + { ...valid, policyVersion: { ...valid.policyVersion, hash: `sha256:${'99'.repeat(32)}` } }, + { ...valid, refundTransactionId: REFUND_TX.toUpperCase().replace('0X', '0x') }, + { ...valid, localRefundBindingHash: `sha256:${'98'.repeat(32)}` }, + { ...valid, rpcEvidence: { transfer: true } }, + ]; + for (const binding of variants) { + const client = makeClient({ transactionReceipt: refundReceipt() }); + await assert.rejects(observer(client).observeRefund(binding), (error) => ( + assertKernelError(error, 'OBSERVER_BINDING') && client.calls.length === 0 + )); + } +}); + +test('observer source has no network construction, write, wallet, key, or environment capability', () => { + const source = fs.readFileSync(new URL('../src/adapters/base-sepolia-observer.mjs', import.meta.url), 'utf8'); + assert.doesNotMatch(source, /process\.env|\bfetch\s*\(|\bhttp\s*\(|createPublicClient|sendTransaction|writeContract|signTypedData|faucet|privateKey/i); +}); diff --git a/spikes/pi-wielder/tests/config.test.mjs b/spikes/pi-wielder/tests/config.test.mjs new file mode 100644 index 0000000..3079608 --- /dev/null +++ b/spikes/pi-wielder/tests/config.test.mjs @@ -0,0 +1,779 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +import { + CONTROL_PLANE_MODES, + loadControlPlaneConfig, + readBoundedRouteDocument, + validateRouteMap, +} from '../src/config.mjs'; +import { KernelError } from '../src/kernel/canonical.mjs'; + +const CHECKOUT_ROOT = fs.realpathSync(fileURLToPath(new URL('../../../', import.meta.url))); +const CURRENT_UID = typeof process.getuid === 'function' ? process.getuid() : 501; +const CURRENT_GID = typeof process.getgid === 'function' ? process.getgid() : 20; + +function writeFixtureFile(filePath, contents = 'fixture\n', mode = 0o600) { + fs.writeFileSync(filePath, contents, { mode }); + fs.chmodSync(filePath, mode); + return filePath; +} + +function makeFixture(t) { + const lexicalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-config-')); + const root = fs.realpathSync(lexicalRoot); + fs.chmodSync(root, 0o700); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const authority = path.join(root, 'authority'); + const release = path.join(root, 'release'); + const socketParent = path.join(root, 'operator'); + const enrollmentInbox = path.join(root, 'enrollment-inbox'); + const agentRunOutbox = path.join(root, 'agent-run-outbox'); + const evidenceRoot = path.join(root, 'evidence'); + for (const directory of [authority, release, socketParent, evidenceRoot]) { + fs.mkdirSync(directory, { mode: 0o700 }); + fs.chmodSync(directory, 0o700); + } + for (const directory of [enrollmentInbox, agentRunOutbox]) { + fs.mkdirSync(directory, { mode: 0o755 }); + fs.chmodSync(directory, 0o755); + } + + const paths = { + database: writeFixtureFile(path.join(authority, 'kernel.sqlite')), + receiptKey: writeFixtureFile(path.join(authority, 'receipt.key')), + operatorToken: writeFixtureFile(path.join(authority, 'operator.token')), + policy: writeFixtureFile(path.join(release, 'policy.json'), '{}\n', 0o600), + route: writeFixtureFile(path.join(release, 'routes.json'), '{}\n', 0o600), + operatorSocket: path.join(socketParent, 'admin.sock'), + enrollmentInbox, + agentRunOutbox, + releaseRoot: release, + releaseManifest: writeFixtureFile(path.join(release, 'manifest.json'), '{}\n', 0o600), + serviceDefinition: writeFixtureFile(path.join(release, 'wallet-kernel.service'), 'fixture\n', 0o600), + socketDefinition: writeFixtureFile(path.join(release, 'wallet-kernel-console.socket'), 'fixture\n', 0o600), + environmentFile: writeFixtureFile(path.join(release, 'wallet-kernel.env'), 'fixture\n', 0o600), + evidenceRoot, + isolationReport: writeFixtureFile(path.join(evidenceRoot, 'isolation.json'), '{}\n', 0o600), + }; + + const env = { + WALLET_KERNEL_MODE: 'deterministic', + WALLET_KERNEL_DB_FILE: paths.database, + WALLET_KERNEL_RECEIPT_KEY_FILE: paths.receiptKey, + WALLET_KERNEL_OPERATOR_TOKEN_FILE: paths.operatorToken, + WALLET_KERNEL_TRUSTED_ANCESTOR: root, + WALLET_KERNEL_EXPECTED_AGENT_UID: '1001', + WALLET_KERNEL_EXPECTED_AGENT_GID: '1002', + WALLET_KERNEL_POLICY_FILE: paths.policy, + WALLET_KERNEL_ROUTE_FILE: paths.route, + WALLET_KERNEL_PORT: '8402', + WALLET_KERNEL_OPERATOR_PORT: '8405', + WALLET_KERNEL_OPERATOR_SOCKET_FILE: paths.operatorSocket, + WALLET_KERNEL_ENROLLMENT_INBOX: paths.enrollmentInbox, + WALLET_KERNEL_AGENT_RUN_OUTBOX: paths.agentRunOutbox, + WALLET_KERNEL_RELEASE_ROOT: paths.releaseRoot, + WALLET_KERNEL_RELEASE_MANIFEST: paths.releaseManifest, + WALLET_KERNEL_SERVICE_DEFINITION_FILE: paths.serviceDefinition, + WALLET_KERNEL_SOCKET_DEFINITION_FILE: paths.socketDefinition, + WALLET_KERNEL_ENV_FILE: paths.environmentFile, + WALLET_KERNEL_EVIDENCE_ROOT: paths.evidenceRoot, + WALLET_KERNEL_ISOLATION_REPORT_FILE: paths.isolationReport, + }; + return { root, paths, env }; +} + +function safeRootOwnedFiles() { + const candidates = [ + '/private/etc/hosts', + '/private/etc/services', + '/private/etc/protocols', + '/private/etc/shells', + '/private/etc/paths', + '/private/etc/passwd', + '/private/etc/group', + '/etc/hosts', + '/etc/services', + '/etc/protocols', + '/etc/shells', + '/etc/paths', + '/etc/passwd', + '/etc/group', + ]; + const files = []; + for (const candidate of candidates) { + try { + const resolved = fs.realpathSync(candidate); + const stat = fs.lstatSync(resolved); + if (!stat.isFile() || stat.uid !== 0 || (stat.mode & 0o022) !== 0) continue; + const descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + fs.closeSync(descriptor); + if (!files.includes(resolved)) files.push(resolved); + } catch {} + } + return files; +} + +function everyAncestorIsLiveSafe(targetPath, allowedUids) { + let current = path.parse(targetPath).root; + for (const part of targetPath.slice(current.length).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink() || (stat.mode & 0o022) !== 0 || !allowedUids.has(stat.uid)) { + return false; + } + } + return true; +} + +function makeLiveFixture(t) { + if (CURRENT_UID === 0 || CURRENT_GID === 0) return null; + const fixture = makeFixture(t); + const rootFiles = safeRootOwnedFiles(); + if (rootFiles.length < 6 + || !everyAncestorIsLiveSafe(fixture.root, new Set([0, CURRENT_UID]))) { + return null; + } + const expectedAgentUid = CURRENT_UID === 65_534 ? CURRENT_UID - 1 : CURRENT_UID + 1; + const expectedAgentGid = CURRENT_GID === 65_534 ? CURRENT_GID - 1 : CURRENT_GID + 1; + const releaseRoot = path.dirname(rootFiles[0]); + if (!rootFiles.slice(0, 4).every((file) => path.dirname(file) === releaseRoot)) return null; + return { + ...fixture, + uid: CURRENT_UID, + gid: CURRENT_GID, + env: { + ...fixture.env, + WALLET_KERNEL_MODE: 'cdp-testnet', + WALLET_KERNEL_TRUSTED_ANCESTOR: path.parse(fixture.root).root, + WALLET_KERNEL_EXPECTED_AGENT_UID: String(expectedAgentUid), + WALLET_KERNEL_EXPECTED_AGENT_GID: String(expectedAgentGid), + WALLET_KERNEL_RELEASE_ROOT: releaseRoot, + WALLET_KERNEL_RELEASE_MANIFEST: rootFiles[0], + WALLET_KERNEL_SERVICE_DEFINITION_FILE: rootFiles[1], + WALLET_KERNEL_SOCKET_DEFINITION_FILE: rootFiles[2], + WALLET_KERNEL_ENV_FILE: rootFiles[3], + WALLET_KERNEL_POLICY_FILE: rootFiles[4], + WALLET_KERNEL_ROUTE_FILE: rootFiles[5], + CDP_API_KEY_ID: 'key-id-sentinel', + CDP_API_KEY_SECRET: 'api-secret-sentinel', + CDP_WALLET_SECRET: 'wallet-secret-sentinel', + CDP_WALLET_NAME: 'pilot-wallet', + WALLET_KERNEL_BASE_SEPOLIA_RPC_URL: 'https://rpc.example/v1/provider-secret?key=query-secret', + }, + }; +} + +test('route documents are read once through a bounded regular-file descriptor', (t) => { + const fixture = makeFixture(t); + const document = routeDocument(); + fs.writeFileSync(fixture.paths.route, JSON.stringify(document)); + assert.deepEqual(readBoundedRouteDocument(fixture.paths.route), document); + + const empty = writeFixtureFile(path.join(fixture.root, 'empty-routes.json'), ''); + assertKernelError(() => readBoundedRouteDocument(empty), 'ROUTE_FILE'); + + const oversized = writeFixtureFile( + path.join(fixture.root, 'oversized-routes.json'), + `{"padding":"${'x'.repeat(65_536)}"}`, + ); + assertKernelError(() => readBoundedRouteDocument(oversized), 'ROUTE_FILE'); + + const hardlink = path.join(fixture.root, 'hardlinked-routes.json'); + fs.linkSync(fixture.paths.route, hardlink); + assertKernelError(() => readBoundedRouteDocument(hardlink), 'ROUTE_FILE'); + + const symlink = path.join(fixture.root, 'symlinked-routes.json'); + fs.symlinkSync(fixture.paths.route, symlink); + assertKernelError(() => readBoundedRouteDocument(symlink), 'ROUTE_FILE'); +}); + +function assertKernelError(action, code) { + assert.throws(action, (error) => ( + error instanceof KernelError && (code === undefined || error.code === code) + )); +} + +test('control-plane modes are closed and frozen', () => { + assert.deepEqual(CONTROL_PLANE_MODES, ['deterministic', 'cdp-testnet']); + assert.equal(Object.isFrozen(CONTROL_PLANE_MODES), true); +}); + +test('deterministic configuration is exact, frozen, and ignores all CDP secrets', (t) => { + const fixture = makeFixture(t); + const secretValues = { + CDP_API_KEY_ID: 'ignored-key-id', + CDP_API_KEY_SECRET: 'ignored-api-secret', + CDP_WALLET_SECRET: 'ignored-wallet-secret', + CDP_WALLET_NAME: 'ignored-wallet-name', + WALLET_KERNEL_BASE_SEPOLIA_RPC_URL: 'not even a URL secret', + }; + const config = loadControlPlaneConfig({ + env: { ...fixture.env, ...secretValues }, + checkoutRoot: CHECKOUT_ROOT, + uid: CURRENT_UID, + gid: CURRENT_GID, + platform: 'darwin', + }); + + assert.deepEqual(config.publicConfig, { + mode: 'deterministic', + agentHost: '127.0.0.1', + agentPort: 8402, + operatorAdminTransport: 'loopback-demo', + operatorSocketPath: null, + operatorConsoleTransport: 'loopback-demo', + operatorConsoleActivationName: null, + operatorHost: '127.0.0.1', + operatorPort: 8405, + databasePath: fixture.env.WALLET_KERNEL_DB_FILE, + policyPath: fixture.env.WALLET_KERNEL_POLICY_FILE, + routePath: fixture.env.WALLET_KERNEL_ROUTE_FILE, + receiptKeyPath: fixture.env.WALLET_KERNEL_RECEIPT_KEY_FILE, + operatorTokenPath: fixture.env.WALLET_KERNEL_OPERATOR_TOKEN_FILE, + enrollmentInboxPath: fixture.env.WALLET_KERNEL_ENROLLMENT_INBOX, + agentRunOutboxPath: fixture.env.WALLET_KERNEL_AGENT_RUN_OUTBOX, + trustedAncestor: null, + releaseRoot: null, + releaseManifestPath: null, + serviceDefinitionPath: null, + socketDefinitionPath: null, + environmentFilePath: null, + evidenceRoot: null, + isolationReportPath: null, + expectedAgentUid: 1001, + expectedAgentGid: 1002, + cdpWalletName: null, + network: 'eip155:84532', + observer: 'deterministic', + }); + assert.equal(Object.isFrozen(config), true); + assert.equal(Object.isFrozen(config.publicConfig), true); + assert.equal(config.assertCredentialPresence(), undefined); + const serialized = JSON.stringify(config); + for (const value of Object.values(secretValues)) assert.equal(serialized.includes(value), false); +}); + +test('cdp-testnet exposes only the exact live public projection', (t) => { + const fixture = makeLiveFixture(t); + if (!fixture) { + t.skip('requires non-root POSIX fixtures below a root-owned non-writable ancestor'); + return; + } + const config = loadControlPlaneConfig({ + env: fixture.env, + checkoutRoot: CHECKOUT_ROOT, + uid: fixture.uid, + gid: fixture.gid, + platform: 'linux', + }); + assert.deepEqual(config.publicConfig, { + mode: 'cdp-testnet', + agentHost: '127.0.0.1', + agentPort: 8402, + operatorAdminTransport: 'unix', + operatorSocketPath: fixture.env.WALLET_KERNEL_OPERATOR_SOCKET_FILE, + operatorConsoleTransport: 'socket-activated-loopback', + operatorConsoleActivationName: 'wallet-kernel-console', + operatorHost: '127.0.0.1', + operatorPort: 8405, + databasePath: fixture.env.WALLET_KERNEL_DB_FILE, + policyPath: fixture.env.WALLET_KERNEL_POLICY_FILE, + routePath: fixture.env.WALLET_KERNEL_ROUTE_FILE, + receiptKeyPath: fixture.env.WALLET_KERNEL_RECEIPT_KEY_FILE, + operatorTokenPath: fixture.env.WALLET_KERNEL_OPERATOR_TOKEN_FILE, + enrollmentInboxPath: fixture.env.WALLET_KERNEL_ENROLLMENT_INBOX, + agentRunOutboxPath: fixture.env.WALLET_KERNEL_AGENT_RUN_OUTBOX, + trustedAncestor: fixture.env.WALLET_KERNEL_TRUSTED_ANCESTOR, + releaseRoot: fixture.env.WALLET_KERNEL_RELEASE_ROOT, + releaseManifestPath: fixture.env.WALLET_KERNEL_RELEASE_MANIFEST, + serviceDefinitionPath: fixture.env.WALLET_KERNEL_SERVICE_DEFINITION_FILE, + socketDefinitionPath: fixture.env.WALLET_KERNEL_SOCKET_DEFINITION_FILE, + environmentFilePath: fixture.env.WALLET_KERNEL_ENV_FILE, + evidenceRoot: fixture.env.WALLET_KERNEL_EVIDENCE_ROOT, + isolationReportPath: fixture.env.WALLET_KERNEL_ISOLATION_REPORT_FILE, + expectedAgentUid: Number(fixture.env.WALLET_KERNEL_EXPECTED_AGENT_UID), + expectedAgentGid: Number(fixture.env.WALLET_KERNEL_EXPECTED_AGENT_GID), + cdpWalletName: 'pilot-wallet', + network: 'eip155:84532', + observer: 'base-sepolia-read-only', + }); + assert.equal(config.assertCredentialPresence(), undefined); + const serialized = JSON.stringify(config); + for (const field of [ + 'CDP_API_KEY_ID', + 'CDP_API_KEY_SECRET', + 'CDP_WALLET_SECRET', + 'WALLET_KERNEL_BASE_SEPOLIA_RPC_URL', + ]) { + assert.equal(serialized.includes(fixture.env[field]), false); + } +}); + +test('modes, identities, ports, and the Kernel environment namespace fail closed', (t) => { + const fixture = makeFixture(t); + const invoke = (overrides = {}, options = {}) => () => loadControlPlaneConfig({ + env: { ...fixture.env, ...overrides }, + checkoutRoot: CHECKOUT_ROOT, + uid: options.uid ?? CURRENT_UID, + gid: options.gid ?? CURRENT_GID, + platform: options.platform ?? 'darwin', + }); + + for (const mode of ['', 'production', 'mainnet', 'eip155:8453', 'CDP-TESTNET']) { + assertKernelError(invoke({ WALLET_KERNEL_MODE: mode }), 'CONFIG_MODE'); + } + for (const [field, value] of [ + ['WALLET_KERNEL_EXPECTED_AGENT_UID', ''], + ['WALLET_KERNEL_EXPECTED_AGENT_UID', '0'], + ['WALLET_KERNEL_EXPECTED_AGENT_UID', '01'], + ['WALLET_KERNEL_EXPECTED_AGENT_UID', '+1'], + ['WALLET_KERNEL_EXPECTED_AGENT_UID', '1.0'], + ['WALLET_KERNEL_EXPECTED_AGENT_GID', '0'], + ['WALLET_KERNEL_EXPECTED_AGENT_GID', '9007199254740992'], + ['WALLET_KERNEL_PORT', '0'], + ['WALLET_KERNEL_PORT', '08042'], + ['WALLET_KERNEL_PORT', '65536'], + ['WALLET_KERNEL_OPERATOR_PORT', '8402'], + ['WALLET_KERNEL_NETWORK', 'eip155:8453'], + ['WALLET_KERNEL_ASSET', '0x0000000000000000000000000000000000000000'], + ['WALLET_KERNEL_AGENT_CREDENTIAL_FILE', '/tmp/forbidden'], + ['WALLET_KERNEL_UNRECOGNIZED', '1'], + ]) { + assertKernelError(invoke({ [field]: value })); + } + assertKernelError(() => loadControlPlaneConfig({ + env: fixture.env, + checkoutRoot: CHECKOUT_ROOT, + uid: CURRENT_UID, + gid: CURRENT_GID, + platform: 'unknown-platform', + }), 'CONFIG_PLATFORM'); + assertKernelError(() => loadControlPlaneConfig({ + env: fixture.env, + checkoutRoot: CHECKOUT_ROOT, + uid: CURRENT_UID, + gid: CURRENT_GID, + platform: 'darwin', + network: 'eip155:8453', + }), 'CONFIG_SCHEMA'); + assertKernelError(() => loadControlPlaneConfig(null), 'CONFIG_SCHEMA'); +}); + +test('live identity, Linux, activation, credential, and loader gates precede composition', (t) => { + const fixture = makeLiveFixture(t); + if (!fixture) { + t.skip('requires non-root POSIX fixtures below a root-owned non-writable ancestor'); + return; + } + const invoke = (env = fixture.env, options = {}) => () => loadControlPlaneConfig({ + env, + checkoutRoot: CHECKOUT_ROOT, + uid: options.uid ?? fixture.uid, + gid: options.gid ?? fixture.gid, + platform: options.platform ?? 'linux', + }); + + assertKernelError(invoke(fixture.env, { platform: 'darwin' }), 'CONFIG_PLATFORM'); + assertKernelError(invoke(fixture.env, { platform: 'win32' }), 'CONFIG_PLATFORM'); + assertKernelError(invoke(fixture.env, { uid: 0 }), 'CONFIG_IDENTITY'); + assertKernelError(invoke(fixture.env, { gid: 0 }), 'CONFIG_IDENTITY'); + assertKernelError(invoke({ + ...fixture.env, + WALLET_KERNEL_EXPECTED_AGENT_UID: String(fixture.uid), + }), 'CONFIG_IDENTITY'); + + for (const field of ['CDP_API_KEY_ID', 'CDP_API_KEY_SECRET', 'CDP_WALLET_SECRET']) { + const missing = { ...fixture.env }; + delete missing[field]; + assertKernelError(invoke(missing), 'CONFIG_CREDENTIALS'); + assertKernelError(invoke({ ...fixture.env, [field]: '' }), 'CONFIG_CREDENTIALS'); + } + assertKernelError(invoke({ ...fixture.env, CDP_WALLET_NAME: '' }), 'CONFIG_WALLET'); + assertKernelError(invoke({ ...fixture.env, WALLET_KERNEL_OPERATOR_PORT: '8406' }), 'CONFIG_ACTIVATION'); + assertKernelError(invoke({ + ...fixture.env, + WALLET_KERNEL_OPERATOR_HOST: '127.0.0.1', + }), 'CONFIG_ENV_UNKNOWN'); + + for (const loaderKey of [ + 'NODE_OPTIONS', + 'NODE_PATH', + 'LD_PRELOAD', + 'LD_AUDIT', + 'DYLD_INSERT_LIBRARIES', + 'GCONV_PATH', + 'GLIBC_TUNABLES', + ]) { + assertKernelError(invoke({ ...fixture.env, [loaderKey]: '' }), 'CONFIG_LOADER_ENV'); + } +}); + +test('deterministic mode ignores loader and CDP-only values instead of serializing them', (t) => { + const fixture = makeFixture(t); + const config = loadControlPlaneConfig({ + env: { + ...fixture.env, + NODE_OPTIONS: '--import=/credential-bearing/path', + LD_PRELOAD: '/credential-bearing/library', + CDP_API_KEY_SECRET: 'deterministic-secret', + CDP_WALLET_SECRET: 'deterministic-wallet-secret', + WALLET_KERNEL_BASE_SEPOLIA_RPC_URL: 'https://user:pass@rpc.example/secret', + }, + checkoutRoot: CHECKOUT_ROOT, + uid: CURRENT_UID, + gid: CURRENT_GID, + platform: 'darwin', + }); + const serialized = JSON.stringify(config); + assert.equal(serialized.includes('credential-bearing'), false); + assert.equal(serialized.includes('deterministic-secret'), false); + assert.equal(serialized.includes('deterministic-wallet-secret'), false); + assert.equal(serialized.includes('user:pass'), false); +}); + +test('live RPC validation is HTTPS and credential-free without exposing its secret URL', (t) => { + const fixture = makeLiveFixture(t); + if (!fixture) { + t.skip('requires non-root POSIX fixtures below a root-owned non-writable ancestor'); + return; + } + for (const rpcUrl of [ + '', + 'http://rpc.example/v1/key', + 'https://user@rpc.example/v1/key', + 'https://:password@rpc.example/v1/key', + 'not-a-url', + ]) { + let caught; + try { + loadControlPlaneConfig({ + env: { ...fixture.env, WALLET_KERNEL_BASE_SEPOLIA_RPC_URL: rpcUrl }, + checkoutRoot: CHECKOUT_ROOT, + uid: fixture.uid, + gid: fixture.gid, + platform: 'linux', + }); + } catch (error) { + caught = error; + } + assert.equal(caught instanceof KernelError, true); + assert.equal(caught.code, 'CONFIG_RPC'); + if (rpcUrl !== '') assert.equal(String(caught).includes(rpcUrl), false); + } +}); + +test('configuration paths are canonical, external, non-symlinked, and non-writable', (t) => { + const fixture = makeFixture(t); + const invoke = (overrides) => () => loadControlPlaneConfig({ + env: { ...fixture.env, ...overrides }, + checkoutRoot: CHECKOUT_ROOT, + uid: CURRENT_UID, + gid: CURRENT_GID, + platform: 'darwin', + }); + + assertKernelError(invoke({ WALLET_KERNEL_DB_FILE: 'relative/kernel.sqlite' }), 'CONFIG_PATH'); + assertKernelError(invoke({ + WALLET_KERNEL_POLICY_FILE: path.join(CHECKOUT_ROOT, 'spikes/pi-wielder/package.json'), + }), 'CONFIG_PATH'); + assertKernelError(invoke({ + WALLET_KERNEL_ROUTE_FILE: `${fixture.root}/release/./routes.json`, + }), 'CONFIG_PATH'); + assertKernelError(invoke({ + WALLET_KERNEL_EVIDENCE_ROOT: fixture.root, + }), 'CONFIG_PATH'); + + const symlink = path.join(fixture.root, 'route-link.json'); + fs.symlinkSync(fixture.paths.route, symlink); + assertKernelError(invoke({ WALLET_KERNEL_ROUTE_FILE: symlink }), 'CONFIG_PATH'); + + const brokenPrivateSymlink = path.join(fixture.root, 'broken-private-link'); + fs.symlinkSync(path.join(fixture.root, 'missing-private-target'), brokenPrivateSymlink); + assertKernelError(invoke({ WALLET_KERNEL_DB_FILE: brokenPrivateSymlink }), 'CONFIG_PATH'); + + const permissive = path.join(fixture.root, 'permissive'); + fs.mkdirSync(permissive, { mode: 0o777 }); + fs.chmodSync(permissive, 0o777); + const permissivePolicy = writeFixtureFile(path.join(permissive, 'policy.json')); + assertKernelError(invoke({ WALLET_KERNEL_POLICY_FILE: permissivePolicy }), 'CONFIG_PATH_MODE'); + + assertKernelError(invoke({ + WALLET_KERNEL_OPERATOR_TOKEN_FILE: fixture.env.WALLET_KERNEL_DB_FILE, + }), 'CONFIG_PATH_COLLISION'); +}); + +test('live requires every deployment path and rejects sticky or non-root trust', (t) => { + const fixture = makeLiveFixture(t); + if (!fixture) { + t.skip('requires non-root POSIX fixtures below a root-owned non-writable ancestor'); + return; + } + const invoke = (env) => () => loadControlPlaneConfig({ + env, + checkoutRoot: CHECKOUT_ROOT, + uid: fixture.uid, + gid: fixture.gid, + platform: 'linux', + }); + for (const field of [ + 'WALLET_KERNEL_OPERATOR_SOCKET_FILE', + 'WALLET_KERNEL_ENROLLMENT_INBOX', + 'WALLET_KERNEL_AGENT_RUN_OUTBOX', + 'WALLET_KERNEL_RELEASE_ROOT', + 'WALLET_KERNEL_RELEASE_MANIFEST', + 'WALLET_KERNEL_SERVICE_DEFINITION_FILE', + 'WALLET_KERNEL_SOCKET_DEFINITION_FILE', + 'WALLET_KERNEL_ENV_FILE', + 'WALLET_KERNEL_EVIDENCE_ROOT', + 'WALLET_KERNEL_ISOLATION_REPORT_FILE', + ]) { + assertKernelError(invoke({ ...fixture.env, [field]: '' }), 'CONFIG_PATH'); + } + assertKernelError(invoke({ + ...fixture.env, + WALLET_KERNEL_TRUSTED_ANCESTOR: fixture.root, + }), 'CONFIG_PATH_OWNER'); + assertKernelError(invoke({ + ...fixture.env, + WALLET_KERNEL_TRUSTED_ANCESTOR: fs.realpathSync('/tmp'), + }), 'CONFIG_PATH_MODE'); + assertKernelError(invoke({ + ...fixture.env, + WALLET_KERNEL_SERVICE_DEFINITION_FILE: fixture.env.WALLET_KERNEL_SOCKET_DEFINITION_FILE, + }), 'CONFIG_PATH_COLLISION'); + assertKernelError(invoke({ + ...fixture.env, + WALLET_KERNEL_ROUTE_FILE: fixture.paths.route, + }), 'CONFIG_PATH_OWNER'); + + const outsideRelease = fs.realpathSync('/usr/bin/true'); + const outsideStat = fs.statSync(outsideRelease); + if (outsideStat.uid === 0 && outsideStat.isFile() && (outsideStat.mode & 0o022) === 0) { + assertKernelError(invoke({ + ...fixture.env, + WALLET_KERNEL_ROUTE_FILE: outsideRelease, + }), 'CONFIG_RELEASE_BOUNDARY'); + } + + fs.chmodSync(path.dirname(fixture.env.WALLET_KERNEL_OPERATOR_SOCKET_FILE), 0o755); + assertKernelError(invoke(fixture.env), 'CONFIG_PATH_MODE'); +}); + +function route(overrides = {}) { + return { + id: 'example-skill', + kind: 'tool', + method: 'POST', + upstreamUrl: 'https://seller.example/paid/skill', + resourceDescription: 'Wallet Kernel example Skill route', + resourceMimeType: 'application/json', + purposeLabel: 'skill.invoke', + requestContentTypes: ['application/json'], + maximumRequestBytes: 262_144, + maximumResponseBytes: 1_048_576, + ...overrides, + }; +} + +function routeDocument(routes = [route()]) { + return { schemaVersion: 1, routes }; +} + +test('route-map validation returns a detached deeply frozen exact-ID registry', () => { + const source = routeDocument([ + route(), + route({ + id: 'example-model', + kind: 'openai-chat', + upstreamUrl: 'https://seller.example/paid/chat/completions', + resourceDescription: 'Wallet Kernel example model route', + purposeLabel: 'model.infer', + }), + ]); + const registry = validateRouteMap({ document: source, mode: 'cdp-testnet' }); + assert.equal(Object.isFrozen(registry), true); + assert.equal(Object.isFrozen(registry.routes), true); + assert.equal(Object.isFrozen(registry.routes[0]), true); + assert.equal(Object.isFrozen(registry.routes[0].requestContentTypes), true); + assert.equal(Object.isFrozen(registry.get), true); + assert.equal(registry.schemaVersion, 1); + assert.equal(registry.get('example-skill'), registry.routes[0]); + assert.equal(registry.get('EXAMPLE-SKILL'), null); + assert.equal(registry.get('https://attacker.example'), null); + source.routes[0].upstreamUrl = 'https://attacker.example/replace'; + source.routes[0].requestContentTypes[0] = 'text/plain'; + assert.equal(registry.get('example-skill').upstreamUrl, 'https://seller.example/paid/skill'); + assert.deepEqual(registry.get('example-skill').requestContentTypes, ['application/json']); + assertKernelError(() => registry.get('example-skill', 'https://attacker.example'), 'ROUTE_LOOKUP'); +}); + +test('deterministic routes permit only literal canonical loopback HTTP', () => { + for (const upstreamUrl of [ + 'http://127.0.0.1:8403/paid/skill', + 'http://[::1]:8403/paid/skill', + 'https://seller.example/paid/skill', + ]) { + assert.doesNotThrow(() => validateRouteMap({ + document: routeDocument([route({ upstreamUrl })]), + mode: 'deterministic', + })); + } + for (const upstreamUrl of [ + 'http://localhost:8403/paid/skill', + 'http://127.0.0.2:8403/paid/skill', + 'http://2130706433:8403/paid/skill', + 'http://127.0.0.01:8403/paid/skill', + 'http://[0:0:0:0:0:0:0:1]:8403/paid/skill', + ]) { + assertKernelError(() => validateRouteMap({ + document: routeDocument([route({ upstreamUrl })]), + mode: 'deterministic', + }), 'ROUTE_URL'); + } +}); + +test('cdp-testnet routes require canonical credential-free queryless HTTPS', () => { + for (const upstreamUrl of [ + 'http://127.0.0.1:8403/paid/skill', + 'https://user@seller.example/paid/skill', + 'https://:password@seller.example/paid/skill', + 'https://seller.example/paid/skill?target=https://attacker.example', + 'https://seller.example/paid/skill#fragment', + 'https://seller.example/paid/%2fescape', + 'https://SELLER.example/paid/skill', + 'https://seller.example:443/paid/skill', + ]) { + assertKernelError(() => validateRouteMap({ + document: routeDocument([route({ upstreamUrl })]), + mode: 'cdp-testnet', + }), 'ROUTE_URL'); + } +}); + +test('route maps are closed, unique, canonical, and bounded', () => { + assertKernelError(() => validateRouteMap({ + document: routeDocument(), + mode: 'cdp-testnet', + upstreamUrl: 'https://attacker.example/override', + }), 'ROUTE_SCHEMA'); + assertKernelError(() => validateRouteMap({ + document: { ...routeDocument(), unknown: true }, + mode: 'cdp-testnet', + }), 'ROUTE_SCHEMA'); + assertKernelError(() => validateRouteMap({ + document: { schemaVersion: 2, routes: [route()] }, + mode: 'cdp-testnet', + }), 'ROUTE_SCHEMA'); + assertKernelError(() => validateRouteMap({ + document: { schemaVersion: 1, routes: [] }, + mode: 'cdp-testnet', + }), 'ROUTE_SCHEMA'); + assertKernelError(() => validateRouteMap({ + document: routeDocument([route(), route()]), + mode: 'cdp-testnet', + }), 'ROUTE_DUPLICATE'); + + for (const id of ['', 'not a route', '../escape', 'A'.repeat(65)]) { + assertKernelError(() => validateRouteMap({ + document: routeDocument([route({ id })]), + mode: 'cdp-testnet', + }), 'ROUTE_ID'); + } + assertKernelError(() => validateRouteMap({ + document: routeDocument(Array.from({ length: 65 }, (_, index) => route({ id: `route-${index}` }))), + mode: 'cdp-testnet', + }), 'ROUTE_LIMIT'); + assertKernelError(() => validateRouteMap({ + document: routeDocument([{ ...route(), unknown: true }]), + mode: 'cdp-testnet', + }), 'ROUTE_SCHEMA'); +}); + +test('route methods, kinds, content types, metadata, and byte limits are closed', () => { + const invalidRoutes = [ + [route({ method: 'GET' }), 'ROUTE_METHOD'], + [route({ method: 'post' }), 'ROUTE_METHOD'], + [route({ kind: 'arbitrary-fetch' }), 'ROUTE_KIND'], + [route({ resourceMimeType: 'text/html' }), 'ROUTE_CONTENT_TYPE'], + [route({ requestContentTypes: ['application/json', 'text/plain'] }), 'ROUTE_CONTENT_TYPE'], + [route({ requestContentTypes: [] }), 'ROUTE_CONTENT_TYPE'], + [route({ resourceDescription: '' }), 'ROUTE_METADATA'], + [route({ resourceDescription: 'x'.repeat(257) }), 'ROUTE_METADATA'], + [route({ purposeLabel: 'not a label' }), 'ROUTE_METADATA'], + [route({ maximumRequestBytes: 0 }), 'ROUTE_BYTES'], + [route({ maximumRequestBytes: 262_145 }), 'ROUTE_BYTES'], + [route({ maximumResponseBytes: 1_048_577 }), 'ROUTE_BYTES'], + [route({ maximumResponseBytes: 1.5 }), 'ROUTE_BYTES'], + ]; + for (const [entry, code] of invalidRoutes) { + assertKernelError(() => validateRouteMap({ + document: routeDocument([entry]), + mode: 'cdp-testnet', + }), code); + } +}); + +test('route-map input rejects proxies, accessors, sparse arrays, and oversized documents', () => { + assertKernelError(() => validateRouteMap({ + document: new Proxy(routeDocument(), {}), + mode: 'cdp-testnet', + }), 'ROUTE_SCHEMA'); + + const accessor = route(); + Object.defineProperty(accessor, 'id', { enumerable: true, get: () => 'example-skill' }); + assertKernelError(() => validateRouteMap({ + document: routeDocument([accessor]), + mode: 'cdp-testnet', + }), 'ROUTE_SCHEMA'); + + let nestedProxyCalls = 0; + const proxiedRoutes = new Proxy([route()], { + get(target, property, receiver) { + nestedProxyCalls += 1; + return Reflect.get(target, property, receiver); + }, + }); + assertKernelError(() => validateRouteMap({ + document: routeDocument(proxiedRoutes), + mode: 'cdp-testnet', + }), 'ROUTE_SCHEMA'); + assert.equal(nestedProxyCalls, 0); + + const contentTypes = []; + let nestedGetterCalls = 0; + Object.defineProperty(contentTypes, '0', { + enumerable: true, + get() { + nestedGetterCalls += 1; + return 'application/json'; + }, + }); + Object.defineProperty(contentTypes, 'length', { value: 1 }); + assertKernelError(() => validateRouteMap({ + document: routeDocument([route({ requestContentTypes: contentTypes })]), + mode: 'cdp-testnet', + }), 'ROUTE_SCHEMA'); + assert.equal(nestedGetterCalls, 0); + + const sparse = new Array(2); + sparse[0] = route(); + assertKernelError(() => validateRouteMap({ + document: routeDocument(sparse), + mode: 'cdp-testnet', + }), 'ROUTE_SCHEMA'); + + assertKernelError(() => validateRouteMap({ + document: routeDocument(Array.from({ length: 64 }, (_, index) => route({ + id: `route-${index}`, + resourceDescription: 'x'.repeat(256), + upstreamUrl: `https://seller.example/${'x'.repeat(700)}-${index}`, + }))), + mode: 'cdp-testnet', + }), 'ROUTE_LIMIT'); +}); diff --git a/spikes/pi-wielder/tests/control-plane.test.mjs b/spikes/pi-wielder/tests/control-plane.test.mjs new file mode 100644 index 0000000..485f3cc --- /dev/null +++ b/spikes/pi-wielder/tests/control-plane.test.mjs @@ -0,0 +1,1075 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +import { + createControlPlane, + startControlPlane, +} from '../src/control-plane.mjs'; +import { canonicalJson, KernelError, sha256 } from '../src/kernel/canonical.mjs'; +import { createAuthorityMutationCoordinator } from '../src/kernel/authority-mutation-coordinator.mjs'; +import { validatePolicyDocument } from '../src/kernel/policy-engine.mjs'; + +const WALLET = '0x1000000000000000000000000000000000000000'; +const SELLER = 'https://seller.example'; +const LOOPBACK_SELLER = 'http://127.0.0.1:9901'; +const HASH = (byte) => `sha256:${byte.repeat(64)}`; +const OPERATOR_HASH = HASH('9'); + +const POLICY = validatePolicyDocument({ + schemaVersion: 1, + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + wallet: WALLET, + methods: ['POST'], + sellers: [{ + origin: SELLER, + pathPrefixes: ['/paid/'], + payTo: '0x2000000000000000000000000000000000000000', + evidencePath: '/.well-known/wallet-kernel/evidence', + executionSigner: '0x3000000000000000000000000000000000000000', + refundSigner: '0x4000000000000000000000000000000000000000', + refundSource: '0x5000000000000000000000000000000000000000', + perRequestMaxAtomic: '500000', + autoApproveAtomic: '100000', + humanApproveAtomic: '500000', + sellerSessionMaxAtomic: '1000000', + }], + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '5000000', + challengeMaxAgeMs: 60_000, + approvalTtlMs: 300_000, + maxPendingApprovals: 20, + defaultAction: 'deny', +}); + +const POLICY_VERSION = Object.freeze({ + id: 'policy-1', + hash: sha256(canonicalJson(POLICY)), + policy: POLICY, +}); + +const ENROLLMENT = Object.freeze({ + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: HASH('a'), + enrollmentHash: HASH('b'), + agentUid: String(process.getuid()), + agentGid: String(process.getgid()), + isolation: 'simulated', +}); + +const ROUTE_DOCUMENT = Object.freeze({ + schemaVersion: 1, + routes: Object.freeze([Object.freeze({ + id: 'example-model', + kind: 'openai-chat', + method: 'POST', + upstreamUrl: `${SELLER}/paid/chat/completions`, + resourceDescription: 'Control plane fixture', + resourceMimeType: 'application/json', + purposeLabel: 'model.infer', + requestContentTypes: Object.freeze(['application/json']), + maximumRequestBytes: 4096, + maximumResponseBytes: 8192, + })]), +}); + +function session(overrides = {}) { + return Object.freeze({ + id: 'session-1', + adapterId: `pi:${ENROLLMENT.agentInstanceId}`, + agentInstanceId: ENROLLMENT.agentInstanceId, + enrollmentHash: ENROLLMENT.enrollmentHash, + walletAddress: WALLET, + policyVersionId: POLICY_VERSION.id, + state: 'open', + createdAt: '2026-08-01T12:00:00.000Z', + closedAt: null, + sessionHash: HASH('c'), + ...overrides, + }); +} + +function binding(overrides = {}) { + return Object.freeze({ + bindingId: 'binding-1', + agentInstanceId: ENROLLMENT.agentInstanceId, + credentialDigest: ENROLLMENT.credentialDigest, + enrollmentHash: ENROLLMENT.enrollmentHash, + state: 'open', + session: session(), + ...overrides, + }); +} + +function revokedEnrollment(operatorIdHash = OPERATOR_HASH) { + return Object.freeze({ + ...ENROLLMENT, + state: 'revoked', + enrolledByOperatorHash: HASH('8'), + enrolledAt: '2026-08-01T12:00:00.000Z', + revokedByOperatorHash: operatorIdHash, + revokedAt: '2026-08-01T12:01:00.000Z', + }); +} + +function closedSession() { + return session({ + state: 'closed', + closedAt: '2026-08-01T12:01:00.000Z', + sessionHash: HASH('e'), + }); +} + +const OPERATOR_READ_NAMES = Object.freeze([ + 'overview', + 'listPolicies', + 'walletIdentity', + 'listApprovals', + 'listReceipts', + 'getReceipt', + 'exportSession', + 'receiptPublicKey', +]); + +function app(label) { + return Object.freeze({ + label, + routes: Object.freeze([]), + fetch() { + return new Response(JSON.stringify({ label }), { + headers: { 'content-type': 'application/json' }, + }); + }, + }); +} + +function fakeListener(label, calls) { + let closed = false; + return Object.freeze({ + label, + async close() { + if (closed) return; + closed = true; + calls.push(`close:${label}`); + }, + }); +} + +function fixture({ + mode = 'deterministic', + enrollment = ENROLLMENT, + bindings = [], + blockedSessionIds = Object.freeze(['session-1']), + policyVersion = POLICY_VERSION, + routeDocument = ROUTE_DOCUMENT, + authorityOverrides = {}, + dependencyOverrides = {}, +} = {}) { + const calls = []; + const captured = {}; + const state = { + enrollment, + bindings, + policyVersion, + session: session({ policyVersionId: policyVersion.id }), + }; + const operatorReads = {}; + for (const name of OPERATOR_READ_NAMES) { + operatorReads[name] = async (input) => { + calls.push(`read:${name}`); + return Object.freeze({ operation: name, ...(input ?? {}) }); + }; + } + operatorReads.walletIdentity = async () => ({ address: WALLET }); + + const kernel = Object.freeze({ + async openOrResumeSession(input) { + calls.push('kernel:openOrResumeSession'); + captured.openInput = input; + if (state.bindings.length === 0) { + state.session = session({ policyVersionId: input.policyVersionId }); + state.bindings = [binding({ session: state.session })]; + } + return state.session; + }, + async applyPolicy(input) { + calls.push('kernel:applyPolicy'); + captured.applyInput = input; + return Object.freeze({ + policyVersion: Object.freeze({ + id: state.policyVersion.id, + schemaVersion: state.policyVersion.policy.schemaVersion, + policy: state.policyVersion.policy, + canonicalJson: canonicalJson(state.policyVersion.policy), + hash: state.policyVersion.hash, + predecessorHash: null, + appliedAt: '2026-08-01T12:01:00.000Z', + }), + blockedSessionIds, + idempotent: false, + }); + }, + async revokeAgent(input) { + calls.push('kernel:revokeAgent'); + return Object.freeze({ + enrollment: revokedEnrollment(input.operatorIdHash), + boundSessionIds: Object.freeze(['session-1']), + }); + }, + async transitionSessionPolicy(input) { + calls.push('kernel:transitionSessionPolicy'); + captured.transitionInput = input; + return Object.freeze({ replacementSession: state.session }); + }, + async closeSession(input) { + calls.push('kernel:closeSession'); + return Object.freeze({ closedSession: closedSession() }); + }, + async approvePending(input) { + calls.push('kernel:approvePending'); + captured.approveInput = input; + return Object.freeze({ + approvalId: 'approval-1', + intentId: 'intent-1', + decision: 'approved', + operatorIdHash: HASH('9'), + intentHash: HASH('d'), + challengeHash: HASH('e'), + quoteId: HASH('f'), + acceptedIndex: 0, + amountCeilingAtomic: '200000', + walletAddress: WALLET, + policyVersionId: POLICY_VERSION.id, + expiresAt: '2026-08-01T12:05:00.000Z', + reasonCode: null, + decidedAt: '2026-08-01T12:01:00.000Z', + consumedAt: null, + }); + }, + async denyPending(input) { + calls.push('kernel:denyPending'); + return Object.freeze({ input }); + }, + async expireDueApprovals() { return Object.freeze([]); }, + async execute() { throw new Error('not used by control-plane tests'); }, + status() { return null; }, + statusByRequestId() { return null; }, + receiptById() { return null; }, + }); + + const reconciler = Object.freeze({ + async reconcilePayment(input) { + calls.push('reconciler:payment'); + return Object.freeze({ input }); + }, + async reconcileExecution(input) { + calls.push('reconciler:execution'); + return Object.freeze({ input }); + }, + async observeRefund(input) { + calls.push('reconciler:refund'); + return Object.freeze({ input }); + }, + async abandonCandidate(input) { + calls.push('reconciler:abandon'); + return Object.freeze({ input }); + }, + }); + + const authority = { + activePolicy() { return state.policyVersion; }, + activeEnrollment() { return state.enrollment; }, + bindingsForEnrollment(input) { + calls.push('authority:bindings'); + captured.bindingInput = input; + return state.bindings; + }, + walletIdentity() { + calls.push('authority:walletIdentity'); + return Object.freeze({ network: 'eip155:84532', address: WALLET }); + }, + operatorAuth: Object.freeze({ opaque: 'auth-fake' }), + operatorReads: Object.freeze(operatorReads), + agentAuthDependencies: Object.freeze({ opaqueAgentAuthDependency: true }), + createKernelDependencies() { + calls.push('authority:kernel-dependencies'); + return Object.freeze({ opaqueKernelDependency: true }); + }, + reconcilerDependencies: Object.freeze({ opaqueReconcilerDependency: true }), + recoveryDependencies: Object.freeze({ opaqueRecoveryDependency: true }), + async recoverySessionCloser(input) { + calls.push('recovery:closeSession'); + return Object.freeze({ closedSession: closedSession() }); + }, + async close() { calls.push('close:authority'); }, + ...authorityOverrides, + }; + + const publicConfig = Object.freeze({ + mode, + agentHost: '127.0.0.1', + agentPort: 8505, + operatorAdminTransport: mode === 'cdp-testnet' ? 'unix' : 'loopback-demo', + operatorSocketPath: mode === 'cdp-testnet' ? '/run/wallet-kernel/admin.sock' : null, + operatorConsoleTransport: mode === 'cdp-testnet' + ? 'socket-activated-loopback' + : 'loopback-demo', + operatorConsoleActivationName: mode === 'cdp-testnet' ? 'wallet-kernel-console' : null, + operatorHost: '127.0.0.1', + operatorPort: 8405, + databasePath: '/authority/kernel.sqlite', + policyPath: '/authority/policy.json', + routePath: '/authority/routes.json', + receiptKeyPath: '/authority/receipt.key', + operatorTokenPath: '/authority/operator.token', + enrollmentInboxPath: null, + agentRunOutboxPath: null, + trustedAncestor: mode === 'cdp-testnet' ? '/authority' : null, + releaseRoot: mode === 'cdp-testnet' ? '/release' : null, + releaseManifestPath: mode === 'cdp-testnet' ? '/release/manifest.json' : null, + serviceDefinitionPath: mode === 'cdp-testnet' ? '/etc/systemd/system/wallet.service' : null, + socketDefinitionPath: mode === 'cdp-testnet' ? '/etc/systemd/system/wallet.socket' : null, + environmentFilePath: mode === 'cdp-testnet' ? '/etc/wallet.env' : null, + evidenceRoot: mode === 'cdp-testnet' ? '/evidence' : null, + isolationReportPath: mode === 'cdp-testnet' ? '/authority/isolation.json' : null, + expectedAgentUid: process.getuid(), + expectedAgentGid: process.getgid(), + cdpWalletName: mode === 'cdp-testnet' ? 'wallet-fixture' : null, + network: 'eip155:84532', + observer: mode === 'cdp-testnet' ? 'base-sepolia-read-only' : 'deterministic', + }); + + let credentialsAsserted = false; + const dependencies = { + checkoutRoot: '/checkout', + loadConfig() { + calls.push('config'); + return Object.freeze({ + publicConfig, + assertCredentialPresence() { + credentialsAsserted = true; + calls.push('credentials'); + }, + }); + }, + readRouteDocument() { + calls.push('routes'); + return routeDocument; + }, + async verifyRelease() { + calls.push('release'); + return Object.freeze({ releaseManifestHash: HASH('d'), deployment: 'verified' }); + }, + async acquireAuthorityLock(input) { + calls.push('lock'); + captured.lockInput = input; + return Object.freeze({ async close() { calls.push('close:lock'); } }); + }, + async openAuthority(input) { + calls.push('open'); + captured.openAuthorityInput = input; + return authority; + }, + async recoverAuthority(input) { + calls.push('recover'); + captured.recoveryInput = input; + return Object.freeze({ ready: true, repairedIntentCount: 0, repairedReceiptCount: 0 }); + }, + createAuthorityMutationCoordinator(input) { + calls.push('coordinator'); + captured.coordinatorInput = input; + captured.coordinatorCount = (captured.coordinatorCount ?? 0) + 1; + return createAuthorityMutationCoordinator(input); + }, + createWalletKernel(input) { + calls.push('kernel:create'); + captured.kernelDependencies = input; + return kernel; + }, + createReconciler(input) { + calls.push('reconciler:create'); + captured.reconcilerDependencies = input; + return reconciler; + }, + createAgentAuth(input) { + calls.push('agent-auth:create'); + captured.agentAuthInput = input; + return Object.freeze({ + authenticate() { return state.enrollment; }, + resolveBoundSession() { + const current = state.bindings[0]?.session; + if (!current || current.state !== 'open') { + throw new KernelError('AGENT_SESSION_UNAVAILABLE', 'unavailable'); + } + return current; + }, + }); + }, + createSpendControlProxy(input) { + calls.push('agent-app:create'); + captured.agentAppInput = input; + return app('agent'); + }, + createOperatorApp(input) { + calls.push(`operator-app:create:${input.transport}`); + captured.operatorApps ??= []; + captured.operatorApps.push(input); + return app(`operator:${input.transport}`); + }, + createOperatorConsoleApp(input) { + calls.push('console-app:create'); + captured.consoleAppInput = input; + return app('console'); + }, + async assertLiveAdmission(input) { + calls.push('live-admission'); + captured.liveAdmissionInput = input; + return Object.freeze({ isolation: 'verified', observer: 'verified' }); + }, + async listenOperatorAdmin(input) { + calls.push('listen:admin'); + captured.adminListen = input; + return fakeListener('admin', calls); + }, + async listenOperatorConsole(input) { + calls.push('listen:console'); + captured.consoleListen = input; + return fakeListener('console', calls); + }, + async listenAgent(input) { + calls.push('listen:agent'); + captured.agentListen = input; + return fakeListener('agent', calls); + }, + async publishReady(input) { + calls.push('ready'); + captured.ready = input; + }, + async prepareStartupReport(input) { + calls.push('startup-report'); + captured.startupReport = input; + }, + scheduleShutdown(operation) { + calls.push('schedule:shutdown'); + queueMicrotask(operation); + }, + ...dependencyOverrides, + }; + + return { + authority, + calls, + captured, + dependencies, + get credentialsAsserted() { return credentialsAsserted; }, + kernel, + reconciler, + state, + }; +} + +function assertCode(error, code) { + assert.ok(error instanceof KernelError, String(error)); + assert.equal(error.code, code); + return true; +} + +test('composition recovers under one lock and injects one coordinator identity into both facades', async () => { + const value = fixture(); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + + assert.equal(value.captured.coordinatorCount, 1); + assert.equal( + value.captured.kernelDependencies.authorityMutationCoordinator, + value.captured.reconcilerDependencies.authorityMutationCoordinator, + ); + assert.equal( + value.captured.kernelDependencies.markAuthorityUnhealthy, + value.captured.reconcilerDependencies.markAuthorityUnhealthy, + ); + assert.equal( + value.captured.coordinatorInput.markAuthorityUnhealthy, + value.captured.kernelDependencies.markAuthorityUnhealthy, + ); + assert.ok(value.calls.indexOf('lock') < value.calls.indexOf('open')); + assert.ok(value.calls.indexOf('open') < value.calls.indexOf('recover')); + assert.ok(value.calls.indexOf('recover') < value.calls.indexOf('coordinator')); + assert.deepEqual(value.captured.openInput, { + agentInstanceId: ENROLLMENT.agentInstanceId, + walletAddress: WALLET, + policyVersionId: POLICY_VERSION.id, + }); + + assert.deepEqual(Object.keys(value.captured.agentAppInput).sort(), [ + 'agentAuth', 'kernel', 'maximumRequestBytes', 'routes', + ]); + for (const application of value.captured.operatorApps) { + assert.deepEqual(Object.keys(application).sort(), [ + 'auth', 'bodyLimits', 'mode', 'origin', 'services', 'transport', + ]); + for (const forbidden of ['store', 'walletAdapter', 'permitAuthority', 'environment']) { + assert.equal(Object.hasOwn(application, forbidden), false); + } + } + assert.equal(plane.health().admission, 'open'); + assert.equal(plane.health().mode, 'normal'); + await plane.close(); + assert.deepEqual(value.calls.slice(-2), ['close:authority', 'close:lock']); +}); + +test('the shared coordinator serializes Kernel, reconciliation phases, and operator mutations', async () => { + const value = fixture(); + await createControlPlane({ env: {}, dependencies: value.dependencies }); + const coordinator = value.captured.kernelDependencies.authorityMutationCoordinator; + const order = []; + let releaseResolver; + const resolver = new Promise((resolve) => { releaseResolver = resolve; }); + + const terminal = coordinator.runExclusive(() => order.push('kernel-terminal')); + const reconciliation = (async () => { + await coordinator.runExclusive(() => order.push('reconcile-prepare')); + order.push('resolver-start'); + await resolver; + order.push('resolver-end'); + await coordinator.runExclusive(() => order.push('reconcile-resolve')); + })(); + const operator = coordinator.runExclusive(() => order.push('operator-mutation')); + await Promise.all([terminal, operator]); + releaseResolver(); + await reconciliation; + + assert.deepEqual(order, [ + 'kernel-terminal', + 'reconcile-prepare', + 'operator-mutation', + 'resolver-start', + 'resolver-end', + 'reconcile-resolve', + ]); +}); + +test('startup honors no-binding, exact-open, and policy-blocked binding rules', async (t) => { + await t.test('no binding creates exactly one session through Kernel', async () => { + const value = fixture({ bindings: [] }); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + assert.equal(value.calls.filter((entry) => entry === 'kernel:openOrResumeSession').length, 1); + await plane.close(); + }); + + await t.test('exact open binding is idempotently resumed through Kernel', async () => { + const value = fixture({ bindings: [binding()] }); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + assert.equal(value.calls.filter((entry) => entry === 'kernel:openOrResumeSession').length, 1); + assert.equal(plane.health().sessionState, 'open'); + await plane.close(); + }); + + await t.test('policy-blocked binding is retained and never opened implicitly', async () => { + const blockedSession = session({ + state: 'policy_blocked', + policyVersionId: 'policy-previous', + }); + const value = fixture({ bindings: [binding({ session: blockedSession })] }); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + assert.equal(value.calls.includes('kernel:openOrResumeSession'), false); + assert.equal(plane.health().sessionState, 'policy_blocked'); + await plane.close(); + }); + + const corruptions = [ + binding({ credentialDigest: HASH('e') }), + binding({ session: session({ walletAddress: '0x9000000000000000000000000000000000000000' }) }), + binding({ session: session({ policyVersionId: 'policy-previous' }) }), + binding({ state: 'closed' }), + ]; + for (const candidate of corruptions) { + await t.test(`fails closed for ${JSON.stringify(candidate).slice(0, 48)}`, async () => { + const value = fixture({ bindings: [candidate] }); + await assert.rejects( + createControlPlane({ env: {}, dependencies: value.dependencies }), + (error) => assertCode(error, 'SESSION_AUTHORITY_AMBIGUOUS'), + ); + assert.deepEqual(value.calls.slice(-2), ['close:authority', 'close:lock']); + assert.equal(value.calls.some((entry) => entry.startsWith('listen:')), false); + }); + } + + await t.test('multiple candidates fail before app construction', async () => { + const value = fixture({ bindings: [binding(), binding({ bindingId: 'binding-2' })] }); + await assert.rejects( + createControlPlane({ env: {}, dependencies: value.dependencies }), + (error) => assertCode(error, 'SESSION_AUTHORITY_AMBIGUOUS'), + ); + assert.equal(value.calls.includes('agent-app:create'), false); + }); +}); + +test('recovery-only composition has no signer/session facade and exposes only recovery mutations', async () => { + const value = fixture({ enrollment: null, bindings: [] }); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + + assert.equal(value.calls.includes('kernel:create'), false); + assert.equal(value.calls.includes('authority:kernel-dependencies'), false); + assert.equal(value.calls.includes('kernel:openOrResumeSession'), false); + assert.equal(value.calls.includes('agent-auth:create'), false); + assert.equal(value.calls.includes('agent-app:create'), false); + assert.equal(value.calls.includes('reconciler:create'), true); + assert.equal(plane.health().mode, 'recovery_only'); + const services = value.captured.operatorApps[0].services; + + for (const name of [ + 'applyPolicy', + 'revokeAgent', + 'transitionSessionPolicy', + 'approvePending', + 'denyPending', + ]) { + await assert.rejects( + services[name]({}), + (error) => assertCode(error, 'RECOVERY_ONLY_OPERATION_FORBIDDEN'), + ); + } + assert.equal(value.calls.some((entry) => entry.startsWith('kernel:')), false); + + await services.reconcilePayment({ intentId: 'intent-1' }); + await services.reconcileExecution({ intentId: 'intent-1' }); + await services.reconcileRefundObservation({ intentId: 'intent-1' }); + await services.abandonCandidate({ intentId: 'intent-1' }); + const closed = await services.closeSession({ sessionId: 'session-1' }); + assert.deepEqual(closed, { session: closedSession() }); + assert.deepEqual(value.calls.filter((entry) => ( + (entry.startsWith('reconciler:') && entry !== 'reconciler:create') + || entry.startsWith('recovery:') + )), [ + 'reconciler:payment', + 'reconciler:execution', + 'reconciler:refund', + 'reconciler:abandon', + 'recovery:closeSession', + ]); + + const response = await plane.apps.agent.fetch(new Request( + 'http://127.0.0.1:8505/agent/v1/invoke/unknown', + { method: 'POST', body: 'RAW_PROMPT_SENTINEL' }, + )); + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { + error: { + code: 'AGENT_ENROLLMENT_REQUIRED', + message: 'Agent enrollment is required', + }, + }); + await plane.close(); +}); + +test('revocation and guarded close expose validated public mutation projections', async () => { + const value = fixture(); + await createControlPlane({ env: {}, dependencies: value.dependencies }); + const services = value.captured.operatorApps[0].services; + + const revoked = await services.revokeAgent({ + agentInstanceId: ENROLLMENT.agentInstanceId, + expectedEnrollmentHash: ENROLLMENT.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + assert.deepEqual(revoked, { + agentEnrollment: { + agentInstanceId: ENROLLMENT.agentInstanceId, + enrollmentHash: ENROLLMENT.enrollmentHash, + agentUid: ENROLLMENT.agentUid, + agentGid: ENROLLMENT.agentGid, + state: 'revoked', + isolation: ENROLLMENT.isolation, + enrolledAt: '2026-08-01T12:00:00.000Z', + revokedAt: '2026-08-01T12:01:00.000Z', + }, + sessions: ['session-1'], + }); + assert.equal(JSON.stringify(revoked).includes(OPERATOR_HASH), false); + assert.equal(JSON.stringify(revoked).includes(ENROLLMENT.credentialDigest), false); + + const closed = await services.closeSession({ + sessionId: 'session-1', + expectedSessionHash: HASH('c'), + }); + assert.deepEqual(closed, { session: closedSession() }); + assert.equal(Object.hasOwn(closed, 'closedSession'), false); +}); + +test('operator transition resolves the active policy hash to the internal version ID at call time', async () => { + const value = fixture(); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + const services = value.captured.operatorApps[0].services; + + await services.transitionSessionPolicy({ + sessionId: 'session-1', + targetPolicyHash: POLICY_VERSION.hash, + expectedSessionHash: HASH('c'), + }); + assert.deepEqual(value.captured.transitionInput, { + sessionId: 'session-1', + targetPolicyVersionId: 'policy-1', + expectedSessionHash: HASH('c'), + }); + assert.equal(Object.hasOwn(value.captured.transitionInput, 'targetPolicyHash'), false); + + await assert.rejects( + services.transitionSessionPolicy({ + sessionId: 'session-1', + targetPolicyHash: HASH('f'), + expectedSessionHash: HASH('c'), + }), + (error) => assertCode(error, 'POLICY_NOT_ACTIVE'), + ); + + const nextPolicy = validatePolicyDocument({ + ...structuredClone(POLICY), + sessionMaxAtomic: '3000000', + }); + value.state.policyVersion = Object.freeze({ + id: 'policy-2', + hash: sha256(canonicalJson(nextPolicy)), + policy: nextPolicy, + }); + await services.transitionSessionPolicy({ + sessionId: 'session-1', + targetPolicyHash: value.state.policyVersion.hash, + expectedSessionHash: HASH('c'), + }); + assert.equal(value.captured.transitionInput.targetPolicyVersionId, 'policy-2'); + await plane.close(); +}); + +test('operator approval mutation strips the stable operator identity before the public boundary', async () => { + const value = fixture(); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + const services = value.captured.operatorApps[0].services; + const input = Object.freeze({ + approvalId: 'approval-1', + expectedIntentHash: HASH('d'), + operatorIdHash: HASH('9'), + }); + + const result = await services.approvePending(input); + + assert.deepEqual(result, { + approvalId: 'approval-1', + intentId: 'intent-1', + decision: 'approved', + intentHash: HASH('d'), + challengeHash: HASH('e'), + quoteId: HASH('f'), + acceptedIndex: 0, + amountAtomic: '200000', + walletAddress: WALLET, + policyVersionId: POLICY_VERSION.id, + expiresAt: '2026-08-01T12:05:00.000Z', + reasonCode: null, + recordedAt: '2026-08-01T12:01:00.000Z', + consumedAt: null, + }); + assert.equal(JSON.stringify(result).includes(HASH('9')), false); + assert.equal(Object.hasOwn(result, 'operatorIdHash'), false); + assert.equal(value.captured.approveInput, input); + await assert.rejects( + services.approvePending({ ...input, operatorIdHash: HASH('8') }), + (error) => assertCode(error, 'APPROVAL_CORRUPTION'), + ); + await plane.close(); +}); + +test('operator policy mutation publishes only the validated public PolicyVersion shape', async () => { + const value = fixture(); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + const services = value.captured.operatorApps[0].services; + const input = Object.freeze({ + document: POLICY, + expectedPolicyHash: POLICY_VERSION.hash, + }); + + const result = await services.applyPolicy(input); + + assert.deepEqual(result, { + policyVersion: { + versionId: POLICY_VERSION.id, + policy: POLICY, + policyHash: POLICY_VERSION.hash, + predecessorHash: null, + createdAt: '2026-08-01T12:01:00.000Z', + active: true, + }, + blockedSessionIds: ['session-1'], + idempotent: false, + }); + assert.equal(JSON.stringify(result).includes('canonicalJson'), false); + assert.equal(value.captured.applyInput, input); + await assert.rejects( + services.applyPolicy({ ...input, expectedPolicyHash: HASH('9') }), + (error) => assertCode(error, 'POLICY_CORRUPTION'), + ); + await plane.close(); +}); + +test('operator policy projection rejects accessor-bearing session arrays without invoking them', async () => { + let accessorReads = 0; + const blockedSessionIds = []; + Object.defineProperty(blockedSessionIds, '0', { + enumerable: true, + configurable: true, + get() { + accessorReads += 1; + return 'session-1'; + }, + }); + blockedSessionIds.length = 1; + const value = fixture({ blockedSessionIds }); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + const services = value.captured.operatorApps[0].services; + + await assert.rejects( + services.applyPolicy({ + document: POLICY, + expectedPolicyHash: POLICY_VERSION.hash, + }), + (error) => assertCode(error, 'POLICY_CORRUPTION'), + ); + assert.equal(accessorReads, 0); + await plane.close(); +}); + +test('routes are cross-checked against active policy method, origin, path, and live HTTPS', async (t) => { + const cases = [ + { + name: 'origin mismatch', + mutate: (route) => ({ ...route, upstreamUrl: 'https://other.example/paid/chat' }), + }, + { + name: 'path mismatch', + mutate: (route) => ({ ...route, upstreamUrl: `${SELLER}/private/chat` }), + }, + { + name: 'method outside policy', + mutate: (route) => ({ ...route, method: 'GET' }), + }, + ]; + for (const { name, mutate } of cases) { + await t.test(name, async () => { + const document = structuredClone(ROUTE_DOCUMENT); + document.routes[0] = mutate(document.routes[0]); + const value = fixture({ routeDocument: document }); + await assert.rejects( + createControlPlane({ env: {}, dependencies: value.dependencies }), + (error) => error instanceof KernelError + && ['ROUTE_POLICY_MISMATCH', 'ROUTE_METHOD'].includes(error.code), + ); + assert.equal(value.calls.includes('agent-app:create'), false); + }); + } + + await t.test('deterministic literal-loopback policy and route are allowed', async () => { + const policy = validatePolicyDocument({ + ...structuredClone(POLICY), + sellers: [{ ...structuredClone(POLICY.sellers[0]), origin: LOOPBACK_SELLER }], + }); + const policyVersion = Object.freeze({ + id: 'policy-loopback', + hash: sha256(canonicalJson(policy)), + policy, + }); + const document = structuredClone(ROUTE_DOCUMENT); + document.routes[0].upstreamUrl = `${LOOPBACK_SELLER}/paid/chat`; + const value = fixture({ policyVersion, routeDocument: document }); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + await plane.close(); + }); + + await t.test('live rejects loopback HTTP policy before authority admission', async () => { + const policy = validatePolicyDocument({ + ...structuredClone(POLICY), + sellers: [{ ...structuredClone(POLICY.sellers[0]), origin: LOOPBACK_SELLER }], + }); + const policyVersion = Object.freeze({ + id: 'policy-loopback', + hash: sha256(canonicalJson(policy)), + policy, + }); + const document = structuredClone(ROUTE_DOCUMENT); + document.routes[0].upstreamUrl = `${LOOPBACK_SELLER}/paid/chat`; + const value = fixture({ mode: 'cdp-testnet', policyVersion, routeDocument: document }); + await assert.rejects( + createControlPlane({ env: {}, dependencies: value.dependencies }), + (error) => assertCode(error, 'ROUTE_URL'), + ); + assert.equal(value.calls.includes('live-admission'), false); + }); +}); + +test('live verifies release before credentials/SQLite and binds admission to recovered authority', async () => { + const value = fixture({ mode: 'cdp-testnet' }); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + + assert.ok(value.calls.indexOf('release') < value.calls.indexOf('credentials')); + assert.ok(value.calls.indexOf('release') < value.calls.indexOf('lock')); + assert.ok(value.calls.indexOf('lock') < value.calls.indexOf('open')); + assert.ok(value.calls.indexOf('recover') < value.calls.indexOf('live-admission')); + assert.ok(value.calls.indexOf('live-admission') < value.calls.indexOf('agent-app:create')); + assert.equal(value.credentialsAsserted, true); + assert.deepEqual(value.captured.liveAdmissionInput.enrollment, { + agentInstanceId: ENROLLMENT.agentInstanceId, + credentialDigest: ENROLLMENT.credentialDigest, + enrollmentHash: ENROLLMENT.enrollmentHash, + agentUid: ENROLLMENT.agentUid, + agentGid: ENROLLMENT.agentGid, + }); + assert.equal( + value.captured.liveAdmissionInput.release.releaseManifestHash, + HASH('d'), + ); + assert.equal(plane.health().deployment, 'verified'); + assert.equal(plane.health().isolation, 'verified'); + await plane.close(); +}); + +test('recovery-only live startup skips agent isolation admission but retains release verification', async () => { + const value = fixture({ mode: 'cdp-testnet', enrollment: null }); + const plane = await createControlPlane({ env: {}, dependencies: value.dependencies }); + assert.equal(value.calls.includes('release'), true); + assert.equal(value.calls.includes('live-admission'), false); + assert.equal(value.calls.includes('kernel:create'), false); + assert.equal(plane.health().mode, 'recovery_only'); + await plane.close(); +}); + +test('fail-stop closes admission before queued callbacks and schedules listener shutdown once', async () => { + const value = fixture(); + const plane = await startControlPlane({ env: {}, dependencies: value.dependencies }); + const coordinator = value.captured.kernelDependencies.authorityMutationCoordinator; + const mark = value.captured.kernelDependencies.markAuthorityUnhealthy; + let callbackRan = false; + + mark('RECEIPT_PARITY_REQUIRED'); + mark('IGNORED_SECOND_REASON'); + await assert.rejects( + coordinator.runExclusive(() => { callbackRan = true; }), + (error) => assertCode(error, 'RECEIPT_PARITY_REQUIRED'), + ); + assert.equal(callbackRan, false); + assert.equal(plane.health().admission, 'closed'); + assert.equal(plane.health().reasonCode, 'RECEIPT_PARITY_REQUIRED'); + assert.equal(value.calls.filter((entry) => entry === 'schedule:shutdown').length, 1); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(value.calls.filter((entry) => entry.startsWith('close:')), [ + 'close:agent', + 'close:console', + 'close:authority', + 'close:lock', + ]); + await plane.close(); +}); + +test('listener lifecycle is ordered, separate, idempotent, and fail-closed', async (t) => { + await t.test('deterministic publishes operator readiness before agent admission', async () => { + const value = fixture(); + const plane = await startControlPlane({ env: {}, dependencies: value.dependencies }); + const sequence = value.calls.filter((entry) => ( + entry.startsWith('listen:') || entry === 'ready' + )); + assert.deepEqual(sequence, ['listen:console', 'listen:agent', 'ready']); + assert.ok(value.calls.indexOf('listen:console') < value.calls.indexOf('startup-report')); + assert.ok(value.calls.indexOf('startup-report') < value.calls.indexOf('listen:agent')); + assert.notEqual(value.captured.consoleListen.app, value.captured.agentListen.app); + assert.equal(value.captured.ready.agentOrigin, 'http://127.0.0.1:8505'); + assert.equal(value.captured.ready.operatorOrigin, 'http://127.0.0.1:8405'); + await plane.close(); + await plane.close(); + assert.equal(value.calls.filter((entry) => entry === 'close:lock').length, 1); + }); + + await t.test('live admin UDS and inherited console precede the agent listener', async () => { + const value = fixture({ mode: 'cdp-testnet' }); + const plane = await startControlPlane({ env: {}, dependencies: value.dependencies }); + const sequence = value.calls.filter((entry) => ( + entry.startsWith('listen:') || entry === 'ready' + )); + assert.deepEqual(sequence, ['listen:admin', 'listen:console', 'listen:agent', 'ready']); + assert.ok(value.calls.indexOf('listen:console') < value.calls.indexOf('startup-report')); + assert.ok(value.calls.indexOf('startup-report') < value.calls.indexOf('listen:agent')); + assert.equal(value.captured.adminListen.socketPath, '/run/wallet-kernel/admin.sock'); + assert.equal(value.captured.consoleListen.activationName, 'wallet-kernel-console'); + await plane.close(); + assert.deepEqual(value.calls.filter((entry) => entry.startsWith('close:')), [ + 'close:agent', + 'close:console', + 'close:admin', + 'close:authority', + 'close:lock', + ]); + }); + + await t.test('partial listener failure unwinds already-owned authority', async () => { + const value = fixture({ + dependencyOverrides: { + async listenAgent() { + value.calls.push('listen:agent'); + const error = new Error('port collision'); + error.code = 'EADDRINUSE'; + throw error; + }, + }, + }); + await assert.rejects( + startControlPlane({ env: {}, dependencies: value.dependencies }), + (error) => error?.code === 'EADDRINUSE', + ); + assert.deepEqual(value.calls.filter((entry) => entry.startsWith('close:')), [ + 'close:console', + 'close:authority', + 'close:lock', + ]); + }); +}); + +test('composition rejects injected coordinator aliases and broad operator service objects', async (t) => { + await t.test('Kernel dependency cannot pre-install another coordinator', async () => { + const value = fixture({ + authorityOverrides: { + createKernelDependencies() { + return Object.freeze({ + opaqueKernelDependency: true, + authorityMutationCoordinator: Object.freeze({}), + }); + }, + }, + }); + await assert.rejects( + createControlPlane({ env: {}, dependencies: value.dependencies }), + (error) => assertCode(error, 'CONTROL_PLANE_AUTHORITY_INJECTION'), + ); + }); + + await t.test('operator read facade rejects a raw store property', async () => { + const reads = Object.fromEntries(OPERATOR_READ_NAMES.map((name) => [name, async () => ({})])); + reads.store = Object.freeze({ mutate() {} }); + const value = fixture({ + authorityOverrides: { operatorReads: Object.freeze(reads) }, + }); + await assert.rejects( + createControlPlane({ env: {}, dependencies: value.dependencies }), + (error) => assertCode(error, 'CONTROL_PLANE_DEPENDENCY'), + ); + assert.equal(value.calls.some((entry) => entry.startsWith('operator-app:create')), false); + }); +}); + +test('uncomposed direct execution fails visibly instead of masquerading as a healthy daemon', () => { + const entrypoint = fileURLToPath(new URL('../src/control-plane.mjs', import.meta.url)); + const child = spawnSync(process.execPath, [entrypoint], { + encoding: 'utf8', + env: Object.freeze({}), + }); + assert.equal(child.status, 1); + assert.equal(child.stdout, ''); + assert.equal(child.stderr, 'CONTROL_PLANE_COMPOSITION_REQUIRED\n'); +}); diff --git a/spikes/pi-wielder/tests/eip3009-exact.test.mjs b/spikes/pi-wielder/tests/eip3009-exact.test.mjs new file mode 100644 index 0000000..e08953d --- /dev/null +++ b/spikes/pi-wielder/tests/eip3009-exact.test.mjs @@ -0,0 +1,477 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { decodePaymentSignatureHeader, encodePaymentSignatureHeader } from '@x402/core/http'; +import { PaymentPayloadV2Schema } from '@x402/core/schemas'; +import { authorizationTypes } from '@x402/evm'; +import { ExactEvmScheme } from '@x402/evm/exact/client'; +import { getAddress, keccak256, toBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { + BASE_SEPOLIA_CAIP2, + BASE_SEPOLIA_USDC, + BASE_SEPOLIA_USDC_EIP712_NAME, + BASE_SEPOLIA_USDC_EIP712_VERSION, + buildEip3009Exact, +} from '../src/adapters/eip3009-exact.mjs'; +import { createAgentEnrollmentRepository } from '../src/kernel/agent-enrollment.mjs'; +import { createPermitAuthority, deriveAuthorizationWindow } from '../src/kernel/authorized-permit.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { createIntentRepository } from '../src/kernel/intent-builder.mjs'; +import { evaluateSpendPolicy } from '../src/kernel/policy-engine.mjs'; +import { createPolicyRepository } from '../src/kernel/policy-repository.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const FIXED_NOW_MS = 1_785_502_800_000; +const NOW = '2026-07-31T13:00:00.000Z'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const ROUTE_URL = 'https://seller.example/paid/infer'; +const ROUTE_METADATA = Object.freeze({ + 'example-skill': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), +}); +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const DESCRIPTOR_HASH = sha256(canonicalJson(DESCRIPTOR)); +const OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; +const fixtureAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-eip3009-golden-test-only')), +); +const PERSISTED_WALLET_ADDRESS = fixtureAccount.address.toLowerCase(); +const BASE_POLICY = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); + +function deepFrozen(value, seen = new Set()) { + if (!value || typeof value !== 'object' || seen.has(value)) return true; + seen.add(value); + return Object.isFrozen(value) + && Reflect.ownKeys(value).every((key) => deepFrozen(value[key], seen)); +} + +function sequenceIds() { + const counts = new Map(); + return (kind) => { + const next = (counts.get(kind) ?? 0) + 1; + counts.set(kind, next); + return `${kind}-${next}`; + }; +} + +function acceptedRequirement(extra = { name: 'USDC', version: '2' }) { + return { + scheme: 'exact', + network: BASE_SEPOLIA_CAIP2, + asset: BASE_SEPOLIA_USDC, + amount: '50000', + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra, + }; +} + +function paymentChallenge({ accepted = acceptedRequirement(), ...overrides } = {}) { + return { + x402Version: 2, + error: 'seller prose must never enter signed or persisted payment bytes', + resource: { + url: ROUTE_URL, + description: ROUTE_METADATA['example-skill'].description, + mimeType: ROUTE_METADATA['example-skill'].mimeType, + }, + accepts: [accepted], + ...overrides, + }; +} + +function createAuthorityFixture(t, { + paymentRequired = paymentChallenge(), + deriveWindow = true, +} = {}) { + const store = openKernelStore({ + filePath: ':memory:', + allowMemory: true, + now: () => NOW, + }); + t.after(() => store.close()); + + const policyDocument = structuredClone(BASE_POLICY); + policyDocument.wallet = PERSISTED_WALLET_ADDRESS; + const policies = createPolicyRepository(store); + const activePolicy = policies.apply(policyDocument, NOW).policyVersion; + createAgentEnrollmentRepository({ store, now: () => NOW }).enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: DESCRIPTOR_HASH, + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const intents = createIntentRepository({ + store, + idFactory: sequenceIds(), + now: () => NOW, + routeMetadata: ROUTE_METADATA, + }); + const session = intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: PERSISTED_WALLET_ADDRESS, + policyVersionId: activePolicy.id, + }); + const persistedIntent = intents.captureIntent({ + sessionId: session.id, + routeId: 'example-skill', + method: 'POST', + requestUrl: ROUTE_URL, + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from('{"prompt":"hash-only fixture"}'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-001', + }); + const attachedIntent = intents.attachChallenge({ + intentId: persistedIntent.id, + paymentRequired, + challengeReceivedAt: NOW, + }); + const policyDecision = evaluateSpendPolicy({ + policy: activePolicy.policy, + policyVersion: { id: activePolicy.id, hash: activePolicy.hash }, + intent: { + id: attachedIntent.id, + method: attachedIntent.method, + requestUrl: ROUTE_URL, + sellerOrigin: attachedIntent.sellerOrigin, + resourcePath: attachedIntent.resourcePath, + walletAddress: attachedIntent.walletAddress, + }, + wallet: { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: PERSISTED_WALLET_ADDRESS, + network: BASE_SEPOLIA_CAIP2, + }, + paymentRequired, + challengeReceivedAtMs: FIXED_NOW_MS, + nowMs: FIXED_NOW_MS, + budgetSnapshot: { + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + pendingApprovalCount: 0, + }, + }); + const persistedDecision = store.transaction((token) => policies.recordDecisionInTransaction( + token, + { + intentId: attachedIntent.id, + policyVersionId: activePolicy.id, + evaluation: policyDecision, + decidedAt: NOW, + }, + )); + + let authorizationWindow = null; + let signingBinding = null; + let permit = null; + let permitAuthority = null; + if (deriveWindow && policyDecision.decision === 'allow') { + authorizationWindow = deriveAuthorizationWindow({ + nowMs: FIXED_NOW_MS, + challengeReceivedAtMs: FIXED_NOW_MS, + challengeMaxAgeMs: activePolicy.policy.challengeMaxAgeMs, + approvalExpiresAt: null, + maxTimeoutSeconds: paymentRequired.accepts[policyDecision.acceptedIndex].maxTimeoutSeconds, + randomBytes(size) { + assert.equal(size, 32); + return Buffer.alloc(32, 0x01); + }, + }); + signingBinding = Object.freeze({ + intentId: attachedIntent.id, + intentHash: attachedIntent.intentHash, + requestUrl: ROUTE_URL, + resourceDescription: ROUTE_METADATA['example-skill'].description, + resourceMimeType: ROUTE_METADATA['example-skill'].mimeType, + challengeHash: persistedDecision.challengeHash, + quoteId: persistedDecision.quoteId, + acceptedIndex: persistedDecision.acceptedIndex, + scheme: 'exact', + network: BASE_SEPOLIA_CAIP2, + asset: BASE_SEPOLIA_USDC, + walletAddress: PERSISTED_WALLET_ADDRESS, + payTo: PAY_TO, + amountAtomic: '50000', + nonce: authorizationWindow.nonce, + validAfter: authorizationWindow.validAfter, + validBefore: authorizationWindow.validBefore, + policyVersionId: activePolicy.id, + }); + permitAuthority = createPermitAuthority(); + permit = permitAuthority.issue(signingBinding); + } + + return Object.freeze({ + activePolicy, + attachedIntent, + authorizationWindow, + paymentRequired, + permit, + permitAuthority, + persistedDecision, + policyDecision, + signingBinding, + }); +} + +function expectedTypedData(binding) { + return { + domain: { + name: BASE_SEPOLIA_USDC_EIP712_NAME, + version: BASE_SEPOLIA_USDC_EIP712_VERSION, + chainId: 84532, + verifyingContract: getAddress(BASE_SEPOLIA_USDC), + }, + types: authorizationTypes, + primaryType: 'TransferWithAuthorization', + message: { + from: getAddress(binding.walletAddress), + to: getAddress(binding.payTo), + value: 50000n, + validAfter: 0n, + validBefore: 1785502860n, + nonce: `0x${'01'.repeat(32)}`, + }, + }; +} + +test('builds and validates the exact pinned EIP-3009 payload from real Kernel authority', async (t) => { + let networkCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + networkCalls += 1; + throw new Error('network forbidden in exact adapter test'); + }; + t.after(() => { globalThis.fetch = originalFetch; }); + + const fixture = createAuthorityFixture(t); + assert.equal(fixture.policyDecision.decision, 'allow'); + assert.equal(fixture.attachedIntent.challengeHash, fixture.persistedDecision.challengeHash); + assert.equal(fixture.signingBinding.intentHash, fixture.attachedIntent.intentHash); + assert.equal(fixture.signingBinding.walletAddress, fixtureAccount.address.toLowerCase()); + assert.equal(fixture.authorizationWindow.validBefore, '1785502860'); + + const consumedBinding = fixture.permitAuthority.verifyAndConsume(fixture.permit); + const exact = buildEip3009Exact({ + binding: consumedBinding, + paymentRequired: fixture.paymentRequired, + nowMs: FIXED_NOW_MS, + }); + assert.deepEqual(exact.typedData, expectedTypedData(consumedBinding)); + assert.ok(deepFrozen(exact.typedData)); + assert.ok(Object.isFrozen(exact)); + + const fixtureSignature = await fixtureAccount.signTypedData(exact.typedData); + const paymentPayload = await exact.assemble(fixtureSignature); + assert.deepEqual(paymentPayload, { + x402Version: 2, + resource: { + url: ROUTE_URL, + description: 'offline fixture', + mimeType: 'application/json', + }, + accepted: acceptedRequirement(), + payload: { + signature: fixtureSignature, + authorization: { + from: PERSISTED_WALLET_ADDRESS, + to: PAY_TO, + value: '50000', + validAfter: '0', + validBefore: '1785502860', + nonce: `0x${'01'.repeat(32)}`, + }, + }, + }); + assert.ok(deepFrozen(paymentPayload)); + assert.equal(JSON.stringify(paymentPayload).includes('seller prose'), false); + assert.equal(networkCalls, 0); +}); + +test('authorization validity is live and bounded by the selected protocol timeout', (t) => { + const fixture = createAuthorityFixture(t); + const exactBoundary = buildEip3009Exact({ + binding: fixture.signingBinding, + paymentRequired: fixture.paymentRequired, + nowMs: FIXED_NOW_MS, + }); + assert.equal(exactBoundary.typedData.message.validBefore, 1_785_502_860n); + + for (const [label, validBefore] of [ + ['already expired', '1785502800'], + ['one second beyond the protocol maximum', '1785502861'], + ['far beyond the protocol maximum', '999999999999999999999999'], + ]) { + assert.throws( + () => buildEip3009Exact({ + binding: { ...fixture.signingBinding, validBefore }, + paymentRequired: fixture.paymentRequired, + nowMs: FIXED_NOW_MS, + }), + (error) => error.code === 'WALLET_BINDING', + label, + ); + } +}); + +test('captures an inert closed challenge snapshot and rejects invalid direct construction', async (t) => { + const fixture = createAuthorityFixture(t); + const exact = buildEip3009Exact({ + binding: fixture.signingBinding, + paymentRequired: fixture.paymentRequired, + nowMs: FIXED_NOW_MS, + }); + const signature = await fixtureAccount.signTypedData(exact.typedData); + fixture.paymentRequired.resource.description = 'mutated after construction'; + fixture.paymentRequired.accepts[0].amount = '999999'; + const assembled = await exact.assemble(signature); + assert.equal(assembled.resource.description, 'offline fixture'); + assert.equal(assembled.accepted.amount, '50000'); + + const validChallenge = paymentChallenge(); + for (const [label, binding, challenge] of [ + ['wrong challenge', { ...fixture.signingBinding, challengeHash: `sha256:${'00'.repeat(32)}` }, validChallenge], + ['wrong quote', { ...fixture.signingBinding, quoteId: `sha256:${'00'.repeat(32)}` }, validChallenge], + ['wrong chain', fixture.signingBinding, paymentChallenge({ + accepted: { ...acceptedRequirement(), network: 'eip155:1' }, + })], + ['wrong asset', fixture.signingBinding, paymentChallenge({ + accepted: { ...acceptedRequirement(), asset: PAY_TO }, + })], + ['malformed nonce', { ...fixture.signingBinding, nonce: `0x${'AA'.repeat(32)}` }, validChallenge], + ['noncanonical validity', { ...fixture.signingBinding, validBefore: '01785502860' }, validChallenge], + ['extension-bearing resource', fixture.signingBinding, paymentChallenge({ + resource: { + url: ROUTE_URL, + description: 'offline fixture', + mimeType: 'application/json', + serviceName: 'untrusted extension', + }, + })], + ['extension-bearing accepted', fixture.signingBinding, paymentChallenge({ + accepted: { ...acceptedRequirement(), facilitator: 'untrusted extension' }, + })], + ]) { + assert.throws( + () => buildEip3009Exact({ binding, paymentRequired: challenge, nowMs: FIXED_NOW_MS }), + (error) => typeof error.code === 'string', + label, + ); + } + + const otherAccount = privateKeyToAccount(keccak256(toBytes('other-wallet-test-only'))); + const wrongSignature = await otherAccount.signTypedData(exact.typedData); + await assert.rejects( + () => exact.assemble(wrongSignature), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD', + ); +}); + +test('the real policy path denies permit2 and token-domain mutations before permit or signer use', async (t) => { + const cases = [ + ['permit2', { name: 'USDC', version: '2', assetTransferMethod: 'permit2' }, 'SCHEME_UNSUPPORTED'], + ['wrong name', { name: 'USD Coin', version: '2' }, 'ASSET_MISMATCH'], + ['wrong version', { name: 'USDC', version: '1' }, 'ASSET_MISMATCH'], + ]; + for (const [label, extra, reasonCode] of cases) { + let signerCalls = 0; + const fixture = createAuthorityFixture(t, { + paymentRequired: paymentChallenge({ accepted: acceptedRequirement(extra) }), + }); + if (fixture.permit !== null) { + const binding = fixture.permitAuthority.verifyAndConsume(fixture.permit); + const exact = buildEip3009Exact({ + binding, + paymentRequired: fixture.paymentRequired, + nowMs: FIXED_NOW_MS, + }); + signerCalls += 1; + await fixtureAccount.signTypedData(exact.typedData); + } + assert.equal(fixture.policyDecision.decision, 'deny', label); + assert.equal(fixture.policyDecision.reasonCode, reasonCode, label); + assert.equal(fixture.permit, null, label); + assert.equal(fixture.permitAuthority, null, label); + assert.equal(signerCalls, 0, label); + } +}); + +test('matches the pinned official x402 EIP-3009 golden and v2 HTTP codec', async (t) => { + const originalNow = Date.now; + const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + Date.now = () => FIXED_NOW_MS; + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + enumerable: true, + value: Object.freeze({ + getRandomValues(bytes) { + bytes.fill(0x01); + return bytes; + }, + }), + }); + t.after(() => { + Date.now = originalNow; + Object.defineProperty(globalThis, 'crypto', originalCrypto); + }); + + for (const extra of [ + { name: 'USDC', version: '2' }, + { name: 'USDC', version: '2', assetTransferMethod: 'eip3009' }, + ]) { + const fixture = createAuthorityFixture(t, { + paymentRequired: paymentChallenge({ accepted: acceptedRequirement(extra) }), + }); + const exact = buildEip3009Exact({ + binding: fixture.signingBinding, + paymentRequired: fixture.paymentRequired, + nowMs: FIXED_NOW_MS, + }); + const fixtureSignature = await fixtureAccount.signTypedData(exact.typedData); + const assembled = await exact.assemble(fixtureSignature); + let signerCalls = 0; + let recordedTypedData = null; + const official = new ExactEvmScheme(Object.freeze({ + address: PERSISTED_WALLET_ADDRESS, + async signTypedData(typedData) { + signerCalls += 1; + recordedTypedData = typedData; + return await fixtureAccount.signTypedData(typedData); + }, + })); + const officialResult = await official.createPaymentPayload( + 2, + fixture.paymentRequired.accepts[0], + ); + + assert.equal(signerCalls, 1); + assert.deepEqual(recordedTypedData, exact.typedData); + assert.deepEqual(officialResult.payload, assembled.payload); + assert.deepEqual(PaymentPayloadV2Schema.parse(assembled), assembled); + assert.deepEqual( + decodePaymentSignatureHeader(encodePaymentSignatureHeader(assembled)), + assembled, + ); + } +}); diff --git a/spikes/pi-wielder/tests/evidence-bundle.test.mjs b/spikes/pi-wielder/tests/evidence-bundle.test.mjs new file mode 100644 index 0000000..bc729a9 --- /dev/null +++ b/spikes/pi-wielder/tests/evidence-bundle.test.mjs @@ -0,0 +1,862 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { createReceiptSigner } from '../src/kernel/receipt-signing.mjs'; +import { + buildEvidenceBundle, + verifyEvidenceBundle, +} from '../src/evidence-bundle.mjs'; + +const NODE = process.execPath; +const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const NOW = '2026-07-31T12:00:01.000Z'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const SELLER = 'https://seller.example'; +const FILES = ['README.md', 'events.jsonl', 'manifest.json', 'report.md', 'summary.json']; +const ENFORCED_PROBES = { + authorityDirectory: 'EACCES', + database: 'EACCES', + operatorToken: 'EACCES', + receiptKey: 'EACCES', + kernelEnvironment: 'EACCES', + agentCredential: 'READABLE', + releaseTreeWrite: 'EACCES', + dependencyTreeWrite: 'EACCES', + serviceArtifactsWrite: 'EACCES', + kernelEnvironmentParentWrite: 'EACCES', +}; + +function temporaryDirectory(t, prefix = 'wallet-kernel-evidence-test-') { + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { force: true, recursive: true })); + return directory; +} + +function signedReceipt(signer, { + id = 'receipt-1', + intentId = 'intent-1', + revision = 1, + supersedesReceiptHash = null, + transactionId = `0x${'ab'.repeat(32)}`, + sessionId = 'session-1', + mutateReceipt = () => {}, +} = {}) { + const receipt = { + schemaVersion: 1, + receiptId: id, + revision, + issuedAt: NOW, + intent: { + id: intentId, + requestId: `request-${intentId}`, + intentHash: sha256(`intent:${intentId}`), + sessionId, + sellerOrigin: SELLER, + resourcePath: '/paid/infer', + purposeLabel: 'skill.invoke', + }, + outcome: { status: 'completed', reasonCode: 'PAYMENT_SETTLED' }, + policy: { versionId: 'policy-1', decision: 'allow', reasonCode: 'WITHIN_AUTO_LIMIT' }, + approval: { state: 'not_required', operatorIdHash: null }, + payment: { + state: 'settled', + amountAtomic: '50000', + network: NETWORK, + asset: ASSET, + payTo: '0x2000000000000000000000000000000000000000', + transactionId, + }, + execution: { + state: 'succeeded', + httpStatus: 200, + responseHash: sha256('sanitized-response'), + }, + budget: { disposition: 'committed', amountAtomic: '50000' }, + reconciliation: null, + refund: null, + supersedesReceiptHash, + }; + mutateReceipt(receipt); + const receiptHash = crypto.createHash('sha256').update(canonicalJson(receipt)).digest('hex'); + return { + id, + intentId, + revision, + receipt, + receiptHash, + signature: signer.signHash(receiptHash), + algorithm: 'Ed25519', + keyId: signer.keyId, + supersedesReceiptHash, + createdAt: NOW, + }; +} + +function signedProjection(signer, { + authorityHead, + policyHash, + receipts, + agentUid = '501', + agentGid = '20', + isolationStatus = 'simulated', + preflightDigest = null, + sessionId = 'session-1', +}) { + const projection = { + schemaVersion: 1, + domain: 'wallet-kernel.sanitized-projection.v1', + sessionHash: sha256(canonicalJson({ + domain: 'wallet-kernel.session-identity.v1', + sessionId, + })), + wallet: { address: WALLET, adapterHash: sha256('adapter') }, + agentEnrollment: { + enrollmentHash: sha256('enrollment'), + identityHash: sha256(canonicalJson({ + domain: 'wallet-kernel.agent-identity.v1', + agentUid, + agentGid, + })), + state: 'active', + }, + isolation: { status: isolationStatus, preflightDigest }, + policies: { + activePolicyHash: policyHash, + sessionPolicyHash: policyHash, + historyHashes: [policyHash], + }, + signedReceipts: receipts, + eventHeadHash: authorityHead, + issuedAt: NOW, + }; + const unsigned = { + schemaVersion: 1, + domain: 'wallet-kernel.projection-export.v1', + projection, + algorithm: 'Ed25519', + keyId: signer.keyId, + publicKeyPem: signer.publicKeyPem, + }; + const projectionHash = sha256(canonicalJson(unsigned)); + return { + ...unsigned, + projectionHash, + signature: signer.signHash(projectionHash.slice('sha256:'.length)), + }; +} + +function projectionSetHash(signedProjections) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.signed-projection-set.v1', + signedProjections, + })); +} + +function fixture({ + signer = createReceiptSigner(), + replacement = false, + mode = 'offline-deterministic', +} = {}) { + const testnet = mode === 'base-sepolia-testnet'; + const authorityHead = sha256(replacement ? 'replacement-authority-head' : 'authority-head'); + const policyHash = sha256(replacement ? 'replacement-policy' : 'policy'); + const receipt = signedReceipt(signer, { + id: replacement ? 'receipt-replacement' : 'receipt-1', + intentId: replacement ? 'intent-replacement' : 'intent-1', + transactionId: `0x${(replacement ? 'cd' : 'ab').repeat(32)}`, + }); + const kernelIdentity = testnet ? { uid: '991', gid: '991' } : { uid: '501', gid: '20' }; + const agentIdentity = testnet ? { uid: '992', gid: '992' } : { uid: '501', gid: '20' }; + const deployment = testnet ? { + status: 'enforced', + releaseManifestDigest: sha256('release-manifest'), + releaseTreeHash: sha256('release-tree'), + serviceArtifactsHash: sha256('service-artifacts'), + systemdEffectiveConfigHash: sha256('systemd-effective'), + } : { + status: 'simulated', + releaseManifestDigest: null, + releaseTreeHash: null, + serviceArtifactsHash: null, + systemdEffectiveConfigHash: null, + }; + const privilegedReport = testnet ? { + schemaVersion: 1, + enrollmentHash: sha256('enrollment'), + kernelUid: kernelIdentity.uid, + kernelGid: kernelIdentity.gid, + agentUid: agentIdentity.uid, + agentGid: agentIdentity.gid, + authorityMetadataHash: sha256('authority-metadata'), + credentialMetadataHash: sha256('credential-metadata'), + releaseManifestHash: deployment.releaseManifestDigest, + releaseTreeHash: deployment.releaseTreeHash, + nodeExecutableHash: sha256('node-executable'), + serviceArtifactsHash: deployment.serviceArtifactsHash, + systemdEffectiveConfigHash: deployment.systemdEffectiveConfigHash, + environmentMetadataHash: sha256('environment-metadata'), + probeResults: ENFORCED_PROBES, + probedAt: '2026-07-31T11:59:00.000Z', + expiresAt: '2026-07-31T12:10:00.000Z', + } : null; + const preflightDigest = privilegedReport === null + ? null + : sha256(canonicalJson(privilegedReport)); + const projection = signedProjection(signer, { + authorityHead, + policyHash, + receipts: [receipt], + agentUid: agentIdentity.uid, + agentGid: agentIdentity.gid, + isolationStatus: testnet ? 'enforced' : 'simulated', + preflightDigest, + }); + const kernelIdentityHash = sha256(canonicalJson({ + domain: 'wallet-kernel.kernel-identity.v1', + kernelUid: kernelIdentity.uid, + kernelGid: kernelIdentity.gid, + })); + return { + receipts: [receipt], + events: [ + { + sequence: 1, + eventType: 'policy.decided', + entityHash: sha256(replacement ? 'replacement-intent' : 'intent-1'), + decision: 'allow', + amountAtomic: null, + transactionId: null, + receiptHash: null, + receiptSignature: null, + }, + { + sequence: 2, + eventType: 'payment.settled', + entityHash: sha256(replacement ? 'replacement-payment' : 'payment-1'), + decision: null, + amountAtomic: '50000', + transactionId: receipt.receipt.payment.transactionId, + receiptHash: null, + receiptSignature: null, + }, + { + sequence: 3, + eventType: 'receipt.issued', + entityHash: sha256(receipt.id), + decision: null, + amountAtomic: null, + transactionId: null, + receiptHash: receipt.receiptHash, + receiptSignature: receipt.signature, + }, + ], + manifestInput: { + schemaVersion: 2, + createdAt: NOW, + mode, + git: { commit: replacement ? 'b'.repeat(40) : 'a'.repeat(40), dirty: !testnet }, + runtime: { nodeVersion: testnet ? 'v24.18.1' : process.version, piVersion: '0.80.6' }, + protocol: { x402Version: 2, network: NETWORK, asset: ASSET }, + wallet: { + provider: testnet ? 'cdp' : 'deterministic', + walletIdHash: sha256(replacement ? 'wallet-replacement' : 'wallet-1'), + address: WALLET, + }, + isolation: { + status: testnet ? 'enforced' : 'simulated', + preflightDigest, + kernelIdentityHash, + agentIdentityHash: projection.projection.agentEnrollment.identityHash, + }, + deployment, + inputs: { policyHash, routeMapHash: sha256('routes'), configHash: sha256('config') }, + source: { + authorityEventHeadHash: authorityHead, + signedProjectionHash: projectionSetHash([projection]), + receiptKeys: [{ + keyId: signer.keyId, + algorithm: 'Ed25519', + publicKeyPem: signer.publicKeyPem, + }], + }, + status: testnet + ? { liveCdp: 'passed', walletFunded: 'sufficient', testnetTransaction: 'settled' } + : { liveCdp: 'not-run', walletFunded: 'not-run', testnetTransaction: 'not-run' }, + identityBindings: { + kernel: kernelIdentity, + agent: agentIdentity, + }, + privilegedReport, + signedProjections: [projection], + }, + }; +} + +function multiSessionFixture({ crossed = false } = {}) { + const signer = createReceiptSigner(); + const input = fixture({ signer }); + const authorityHead = input.manifestInput.source.authorityEventHeadHash; + const policyHash = input.manifestInput.inputs.policyHash; + const first = input.receipts[0]; + const second = signedReceipt(signer, { + id: 'receipt-2', + intentId: 'intent-2', + sessionId: 'session-2', + transactionId: `0x${'cd'.repeat(32)}`, + }); + const shared = { + authorityHead, + policyHash, + agentUid: input.manifestInput.identityBindings.agent.uid, + agentGid: input.manifestInput.identityBindings.agent.gid, + }; + const firstProjection = signedProjection(signer, { + ...shared, + sessionId: 'session-1', + receipts: [crossed ? second : first], + }); + const secondProjection = signedProjection(signer, { + ...shared, + sessionId: 'session-2', + receipts: [crossed ? first : second], + }); + input.receipts = [first, second]; + input.events.push( + { + sequence: 4, + eventType: 'payment.settled', + entityHash: sha256('payment-2'), + decision: null, + amountAtomic: '50000', + transactionId: second.receipt.payment.transactionId, + receiptHash: null, + receiptSignature: null, + }, + { + sequence: 5, + eventType: 'receipt.issued', + entityHash: sha256(second.id), + decision: null, + amountAtomic: null, + transactionId: null, + receiptHash: second.receiptHash, + receiptSignature: second.signature, + }, + ); + input.manifestInput.signedProjections = [firstProjection, secondProjection].sort( + (left, right) => left.projection.sessionHash.localeCompare(right.projection.sessionHash), + ); + input.manifestInput.source.signedProjectionHash = projectionSetHash( + input.manifestInput.signedProjections, + ); + return input; +} + +function build(t, options = {}) { + const parent = temporaryDirectory(t); + const outputDirectory = path.join(parent, 'bundle'); + const input = fixture(options); + const result = buildEvidenceBundle({ outputDirectory, ...input }); + return { ...input, ...result, outputDirectory, parent }; +} + +function rewriteManifest(directory, mutate) { + const manifestPath = path.join(directory, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + mutate(manifest); + const bytes = Buffer.from(`${canonicalJson(manifest)}\n`); + fs.writeFileSync(manifestPath, bytes); + return crypto.createHash('sha256').update(bytes).digest('hex'); +} + +function updateListedFile(directory, filename, bytes) { + fs.writeFileSync(path.join(directory, filename), bytes); + return rewriteManifest(directory, (manifest) => { + const entry = manifest.files.find(({ path: entryPath }) => entryPath === filename); + entry.bytes = bytes.length; + entry.sha256 = crypto.createHash('sha256').update(bytes).digest('hex'); + }); +} + +test('build writes exactly five canonical files and independent verification recomputes evidence', (t) => { + const built = build(t); + assert.deepEqual(fs.readdirSync(built.outputDirectory).sort(), FILES); + assert.match(built.manifestSha256, /^[0-9a-f]{64}$/); + + const verified = verifyEvidenceBundle(built.outputDirectory, { + expectedManifestSha256: built.manifestSha256, + }); + assert.deepEqual(verified, { + valid: true, + mode: 'offline-deterministic', + manifestSha256: built.manifestSha256, + authorityEventHeadHash: built.manifestInput.source.authorityEventHeadHash, + normalizedEvidenceHeadHash: verified.normalizedEvidenceHeadHash, + eventCount: 3, + decisionCount: 1, + latestReceiptSettledGrossAtomic: '50000', + latestReceiptConfirmedRefundAtomic: '0', + latestReceiptUnresolvedExposureAtomic: '0', + transactionCount: 1, + receiptCount: 1, + liveCdp: 'not-run', + walletFunded: 'not-run', + testnetTransaction: 'not-run', + }); + assert.match(verified.normalizedEvidenceHeadHash, /^sha256:[0-9a-f]{64}$/); + assert.match(fs.readFileSync(path.join(built.outputDirectory, 'report.md'), 'utf8'), + /does not recompute the private SQLite authority chain/i); +}); + +test('financial summary uses latest signed receipts instead of repeated lifecycle amounts', (t) => { + const parent = temporaryDirectory(t); + const outputDirectory = path.join(parent, 'bundle'); + const input = fixture(); + input.events[2].sequence = 4; + input.events.splice(2, 0, { + sequence: 3, + eventType: 'budget.committed', + entityHash: sha256('budget-1'), + decision: null, + amountAtomic: '50000', + transactionId: null, + receiptHash: null, + receiptSignature: null, + }); + const built = buildEvidenceBundle({ outputDirectory, ...input }); + const verified = verifyEvidenceBundle(outputDirectory, { + expectedManifestSha256: built.manifestSha256, + }); + assert.deepEqual({ + latestReceiptSettledGrossAtomic: verified.latestReceiptSettledGrossAtomic, + latestReceiptConfirmedRefundAtomic: verified.latestReceiptConfirmedRefundAtomic, + latestReceiptUnresolvedExposureAtomic: verified.latestReceiptUnresolvedExposureAtomic, + }, { + latestReceiptSettledGrossAtomic: '50000', + latestReceiptConfirmedRefundAtomic: '0', + latestReceiptUnresolvedExposureAtomic: '0', + }); + const report = fs.readFileSync(path.join(outputDirectory, 'report.md'), 'utf8'); + assert.doesNotMatch(report, /atomic amount total/i); + assert.match(report, /latest signed receipt revision for each intent/i); +}); + +test('financial summary separates settled gross, confirmed refunds, and unresolved exposure', (t) => { + const signer = createReceiptSigner(); + const input = fixture({ signer }); + const first = input.receipts[0]; + const refundTransactionId = `0x${'cd'.repeat(32)}`; + const refunded = signedReceipt(signer, { + id: 'receipt-2', + intentId: first.intentId, + revision: 2, + supersedesReceiptHash: first.receiptHash, + transactionId: first.receipt.payment.transactionId, + mutateReceipt: (receipt) => { + receipt.outcome = { status: 'refunded', reasonCode: 'REFUND_CONFIRMED' }; + receipt.execution = { + state: 'failed', + httpStatus: 503, + responseHash: null, + }; + receipt.budget = { disposition: 'released', amountAtomic: '50000' }; + receipt.reconciliation = { + kind: 'refund', + outcome: 'refund_confirmed', + operatorIdHash: sha256('refund-operator'), + recordedAt: NOW, + }; + receipt.refund = { + state: 'confirmed', + amountAtomic: '50000', + transactionId: refundTransactionId, + }; + }, + }); + const unresolved = signedReceipt(signer, { + id: 'receipt-3', + intentId: 'intent-2', + transactionId: null, + mutateReceipt: (receipt) => { + receipt.outcome = { status: 'payment_unresolved', reasonCode: 'PAID_RESPONSE_AMBIGUOUS' }; + receipt.payment.state = 'unresolved'; + receipt.execution = { state: 'none', httpStatus: null, responseHash: null }; + receipt.budget = { disposition: 'unresolved', amountAtomic: '50000' }; + }, + }); + input.receipts = [first, refunded, unresolved]; + input.events = [ + input.events[0], + input.events[1], + input.events[2], + { + sequence: 4, + eventType: 'refund.confirmed', + entityHash: sha256('refund-1'), + decision: null, + amountAtomic: '50000', + transactionId: refundTransactionId, + receiptHash: null, + receiptSignature: null, + }, + ...[refunded, unresolved].map((receipt, index) => ({ + sequence: index + 5, + eventType: 'receipt.issued', + entityHash: sha256(receipt.id), + decision: null, + amountAtomic: null, + transactionId: null, + receiptHash: receipt.receiptHash, + receiptSignature: receipt.signature, + })), + ]; + const projection = signedProjection(signer, { + authorityHead: input.manifestInput.source.authorityEventHeadHash, + policyHash: input.manifestInput.inputs.policyHash, + receipts: input.receipts, + }); + input.manifestInput.signedProjections = [projection]; + input.manifestInput.source.signedProjectionHash = projectionSetHash([projection]); + const outputDirectory = path.join(temporaryDirectory(t), 'bundle'); + const built = buildEvidenceBundle({ outputDirectory, ...input }); + const verified = verifyEvidenceBundle(outputDirectory, { + expectedManifestSha256: built.manifestSha256, + }); + assert.deepEqual({ + latestReceiptSettledGrossAtomic: verified.latestReceiptSettledGrossAtomic, + latestReceiptConfirmedRefundAtomic: verified.latestReceiptConfirmedRefundAtomic, + latestReceiptUnresolvedExposureAtomic: verified.latestReceiptUnresolvedExposureAtomic, + }, { + latestReceiptSettledGrossAtomic: '50000', + latestReceiptConfirmedRefundAtomic: '50000', + latestReceiptUnresolvedExposureAtomic: '50000', + }); +}); + +test('normalized evidence rejects an omitted leading or interior authority event', (t) => { + for (const omittedIndex of [0, 1]) { + const input = fixture(); + input.events.splice(omittedIndex, 1); + assert.throws(() => buildEvidenceBundle({ + outputDirectory: path.join(temporaryDirectory(t), 'bundle'), + ...input, + }), { code: 'EVIDENCE_EVENT_SCHEMA' }); + } +}); + +test('v2 evidence authenticates a canonical multi-session projection partition', (t) => { + const outputDirectory = path.join(temporaryDirectory(t), 'bundle'); + const input = multiSessionFixture(); + const built = buildEvidenceBundle({ outputDirectory, ...input }); + const verified = verifyEvidenceBundle(outputDirectory, { + expectedManifestSha256: built.manifestSha256, + }); + assert.equal(verified.receiptCount, 2); + const summary = JSON.parse(fs.readFileSync(path.join(outputDirectory, 'summary.json'))); + assert.equal(summary.schemaVersion, 2); + assert.equal(summary.signedProjections.length, 2); + assert.equal( + summary.signedProjections[0].projection.sessionHash + .localeCompare(summary.signedProjections[1].projection.sessionHash) < 0, + true, + ); +}); + +test('v2 evidence rejects projection omission, duplication, and cross-session receipts', (t) => { + const variants = [ + (input) => { + input.manifestInput.signedProjections.pop(); + input.manifestInput.source.signedProjectionHash = projectionSetHash( + input.manifestInput.signedProjections, + ); + }, + (input) => { + input.manifestInput.signedProjections.push(input.manifestInput.signedProjections[0]); + input.manifestInput.signedProjections.sort((left, right) => ( + left.projection.sessionHash.localeCompare(right.projection.sessionHash) + )); + input.manifestInput.source.signedProjectionHash = projectionSetHash( + input.manifestInput.signedProjections, + ); + }, + ]; + for (const mutate of variants) { + const input = multiSessionFixture(); + mutate(input); + assert.throws(() => buildEvidenceBundle({ + outputDirectory: path.join(temporaryDirectory(t), 'bundle'), + ...input, + }), { code: 'EVIDENCE_PROJECTION_PARTITION' }); + } + assert.throws(() => buildEvidenceBundle({ + outputDirectory: path.join(temporaryDirectory(t), 'bundle'), + ...multiSessionFixture({ crossed: true }), + }), { code: 'EVIDENCE_PROJECTION_PARTITION' }); +}); + +test('normalized evidence rejects transaction reuse instead of scrubbing a duplicate', (t) => { + const input = fixture(); + input.events.push({ + sequence: 4, + eventType: 'payment.settled', + entityHash: sha256('duplicate-payment'), + decision: null, + amountAtomic: '50000', + transactionId: input.events[1].transactionId, + receiptHash: null, + receiptSignature: null, + }); + assert.throws(() => buildEvidenceBundle({ + outputDirectory: path.join(temporaryDirectory(t), 'bundle'), + ...input, + }), { code: 'EVIDENCE_TRANSACTION_REUSE' }); +}); + +test('verification requires the exact out-of-band manifest digest before trusting bundle bytes', (t) => { + const built = build(t); + for (const expectedManifestSha256 of [undefined, null, '', 'A'.repeat(64), '0'.repeat(63)]) { + assert.throws( + () => verifyEvidenceBundle(built.outputDirectory, { expectedManifestSha256 }), + { code: 'EVIDENCE_EXTERNAL_ANCHOR' }, + ); + } + assert.throws( + () => verifyEvidenceBundle(built.outputDirectory, { + expectedManifestSha256: '0'.repeat(64), + }), + { code: 'EVIDENCE_EXTERNAL_ANCHOR' }, + ); + + const manifest = JSON.parse(fs.readFileSync(path.join(built.outputDirectory, 'manifest.json'))); + manifest.wallet.provider = 'substituted'; + fs.writeFileSync( + path.join(built.outputDirectory, 'manifest.json'), + `${canonicalJson(manifest)}\n`, + ); + assert.throws( + () => verifyEvidenceBundle(built.outputDirectory, { + expectedManifestSha256: built.manifestSha256, + }), + { code: 'EVIDENCE_EXTERNAL_ANCHOR' }, + ); +}); + +test('each non-manifest file fails verification when independently mutated', (t) => { + for (const filename of FILES.filter((name) => name !== 'manifest.json')) { + const built = build(t); + fs.appendFileSync(path.join(built.outputDirectory, filename), 'tampered\n'); + assert.throws( + () => verifyEvidenceBundle(built.outputDirectory, { + expectedManifestSha256: built.manifestSha256, + }), + { code: 'EVIDENCE_FILE_HASH' }, + filename, + ); + } +}); + +test('receipt, projection, and normalized-chain verification fail after internally re-anchored corruption', (t) => { + { + const built = build(t); + const summaryPath = path.join(built.outputDirectory, 'summary.json'); + const summary = JSON.parse(fs.readFileSync(summaryPath)); + summary.receipts[0].signature = Buffer.alloc(64, 9).toString('base64'); + const anchor = updateListedFile( + built.outputDirectory, + 'summary.json', + Buffer.from(`${canonicalJson(summary)}\n`), + ); + assert.throws( + () => verifyEvidenceBundle(built.outputDirectory, { expectedManifestSha256: anchor }), + { code: 'EVIDENCE_RECEIPT_SIGNATURE' }, + ); + } + { + const built = build(t); + const summaryPath = path.join(built.outputDirectory, 'summary.json'); + const summary = JSON.parse(fs.readFileSync(summaryPath)); + summary.signedProjections[0].signature = Buffer.alloc(64, 7).toString('base64'); + const anchor = updateListedFile( + built.outputDirectory, + 'summary.json', + Buffer.from(`${canonicalJson(summary)}\n`), + ); + assert.throws( + () => verifyEvidenceBundle(built.outputDirectory, { expectedManifestSha256: anchor }), + { code: 'EVIDENCE_PROJECTION_SIGNATURE' }, + ); + } + { + const built = build(t); + const eventsPath = path.join(built.outputDirectory, 'events.jsonl'); + const events = fs.readFileSync(eventsPath, 'utf8').trimEnd().split('\n').map(JSON.parse); + events[1].eventHash = sha256('forged-chain-head'); + const bytes = Buffer.from(`${events.map(canonicalJson).join('\n')}\n`); + const anchor = updateListedFile(built.outputDirectory, 'events.jsonl', bytes); + assert.throws( + () => verifyEvidenceBundle(built.outputDirectory, { expectedManifestSha256: anchor }), + { code: 'EVIDENCE_EVENT_CHAIN' }, + ); + } +}); + +test('receipt revisions require exact per-intent predecessor links', (t) => { + const signer = createReceiptSigner(); + const input = fixture({ signer }); + const second = signedReceipt(signer, { + id: 'receipt-2', + revision: 2, + supersedesReceiptHash: '0'.repeat(64), + }); + input.receipts.push(second); + input.manifestInput.signedProjections[0].projection.signedReceipts.push(second); + assert.throws( + () => buildEvidenceBundle({ + outputDirectory: path.join(temporaryDirectory(t), 'bundle'), + ...input, + }), + { code: 'EVIDENCE_RECEIPT_REVISION' }, + ); +}); + +test('a substituted key pair or fully replaced bundle cannot satisfy the original trust anchor', (t) => { + const original = build(t); + const replacement = build(t, { signer: createReceiptSigner(), replacement: true }); + assert.notEqual(replacement.manifestSha256, original.manifestSha256); + assert.throws( + () => verifyEvidenceBundle(replacement.outputDirectory, { + expectedManifestSha256: original.manifestSha256, + }), + { code: 'EVIDENCE_EXTERNAL_ANCHOR' }, + ); +}); + +test('offline evidence rejects enforced isolation, deployment hashes, live status, and overwrite', (t) => { + const variants = [ + (input) => { input.manifestInput.isolation.status = 'enforced'; }, + (input) => { input.manifestInput.isolation.preflightDigest = sha256('report'); }, + (input) => { input.manifestInput.deployment.releaseTreeHash = sha256('tree'); }, + (input) => { input.manifestInput.status.liveCdp = 'passed'; }, + ]; + for (const mutate of variants) { + const input = fixture(); + mutate(input); + assert.throws( + () => buildEvidenceBundle({ + outputDirectory: path.join(temporaryDirectory(t), 'bundle'), + ...input, + }), + { code: 'EVIDENCE_MODE_GATE' }, + ); + } + const built = build(t); + assert.throws( + () => buildEvidenceBundle({ outputDirectory: built.outputDirectory, ...fixture() }), + { code: 'EVIDENCE_OUTPUT_EXISTS' }, + ); +}); + +test('testnet evidence requires and re-verifies an unexpired deployment-bound privileged report', (t) => { + const built = build(t, { mode: 'base-sepolia-testnet' }); + const verified = verifyEvidenceBundle(built.outputDirectory, { + expectedManifestSha256: built.manifestSha256, + }); + assert.equal(verified.mode, 'base-sepolia-testnet'); + assert.equal(verified.liveCdp, 'passed'); + assert.equal(verified.walletFunded, 'sufficient'); + assert.equal(verified.testnetTransaction, 'settled'); + + const summaryPath = path.join(built.outputDirectory, 'summary.json'); + const summary = JSON.parse(fs.readFileSync(summaryPath)); + summary.isolationAttestation.expiresAt = NOW; + const reanchored = updateListedFile( + built.outputDirectory, + 'summary.json', + Buffer.from(`${canonicalJson(summary)}\n`), + ); + assert.throws( + () => verifyEvidenceBundle(built.outputDirectory, { + expectedManifestSha256: reanchored, + }), + { code: 'EVIDENCE_MODE_GATE' }, + ); + + for (const mutate of [ + (input) => { input.manifestInput.privilegedReport.expiresAt = NOW; }, + (input) => { input.manifestInput.privilegedReport.releaseTreeHash = sha256('wrong-tree'); }, + (input) => { input.manifestInput.identityBindings.agent.uid = '993'; }, + ]) { + const input = fixture({ mode: 'base-sepolia-testnet' }); + mutate(input); + assert.throws( + () => buildEvidenceBundle({ + outputDirectory: path.join(temporaryDirectory(t), 'bundle'), + ...input, + }), + (error) => ['EVIDENCE_PREFLIGHT', 'EVIDENCE_IDENTITY'].includes(error.code), + ); + } +}); + +test('raw content, credentials, provider failures, private keys, and local paths fail before write', (t) => { + const mutations = [ + (input) => { input.manifestInput.signedProjections[0].projection.prompt = 'pay this'; }, + (input) => { input.manifestInput.signedProjections[0].projection.providerException = 'boom'; }, + (input) => { input.manifestInput.signedProjections[0].projection.agentUid = '501'; }, + (input) => { + input.manifestInput.signedProjections[0].projection.payment = { + signature: Buffer.alloc(64, 4).toString('base64'), + }; + }, + (input) => { + input.manifestInput.signedProjections[0].projection.note = + 'YWdlbnQtY3JlZGVudGlhbC1tYXJrZXI0MgYWdlbnQtY3JlZGVudGlhbA'; + }, + (input) => { input.manifestInput.signedProjections[0].projection.note = '/Users/alice/secret'; }, + (input) => { input.manifestInput.signedProjections[0].projection.note = '-----BEGIN PRIVATE KEY-----'; }, + ]; + for (const mutate of mutations) { + const input = fixture(); + mutate(input); + const outputDirectory = path.join(temporaryDirectory(t), 'bundle'); + assert.throws( + () => buildEvidenceBundle({ outputDirectory, ...input }), + { code: 'EVIDENCE_SANITIZATION' }, + ); + assert.equal(fs.existsSync(outputDirectory), false); + } +}); + +test('verifier CLI requires the external anchor and prints canonical JSON only on success', (t) => { + const built = build(t); + const script = path.join(PACKAGE_ROOT, 'scripts/verify-evidence.mjs'); + const success = spawnSync(NODE, [ + script, + built.outputDirectory, + '--expect-manifest-sha256', + built.manifestSha256, + ], { cwd: PACKAGE_ROOT, encoding: 'utf8' }); + assert.equal(success.status, 0, success.stderr); + const parsed = JSON.parse(success.stdout); + assert.equal(success.stdout, `${canonicalJson(parsed)}\n`); + assert.equal(parsed.valid, true); + + const missing = spawnSync(NODE, [script, built.outputDirectory], { + cwd: PACKAGE_ROOT, + encoding: 'utf8', + }); + assert.equal(missing.status, 2); + assert.equal(missing.stdout, ''); + assert.match(missing.stderr, /EVIDENCE_CLI_ARGUMENTS/); +}); diff --git a/spikes/pi-wielder/tests/evidence-runner.test.mjs b/spikes/pi-wielder/tests/evidence-runner.test.mjs new file mode 100644 index 0000000..ac2fef8 --- /dev/null +++ b/spikes/pi-wielder/tests/evidence-runner.test.mjs @@ -0,0 +1,393 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { + parseRunEvidenceArguments, + runEvidence, + runOfflineEvidence, +} from '../scripts/run-evidence.mjs'; + +const NOW = '2026-08-01T12:00:00.000Z'; +const MANIFEST_HASH = 'a'.repeat(64); +const INVARIANT_IDS = Object.freeze([ + 'allowed-payment-settles-once', + 'policy-denials-never-sign', + 'approval-survives-restart', + 'denial-and-expiry-never-sign', + 'changed-challenge-terminalizes-approval', + 'settled-http-failures-commit-and-block', + 'body-loss-execution-reconciliation', + 'pre-settlement-loss-holds-budget', + 'post-signature-ambiguity-is-unresolved', + 'trusted-settlement-needs-execution-evidence', + 'refund-releases-only-after-full-proof', + 'fresh-process-verifies-authority', + 'pi-carries-no-authority-headers', + 'all-egress-is-loopback', + 'unauthorized-calls-fail-before-body', + 'credential-reattaches-session', + 'tighter-policy-requires-guarded-transition', + 'revocation-recovery-and-replacement', +]); +const CHILD_NAMES = Object.freeze([ + 'model', 'seller', 'bootstrap', 'control-initial', 'control-restarted', + 'pi-tool-approval', 'pi-model-approval', 'control-recovery', + 'bootstrap-replacement', 'control-replacement', 'control-verifier', +]); + +function temporaryDirectory(t) { + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'evidence-runner-'))); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function acceptanceResult(sequence, anchorOutput) { + const eventHeadHash = sha256('authority-head'); + const projectionHash = sha256('projection'); + const receipt = { + id: 'receipt-1', + intentId: 'intent-1', + revision: 1, + receiptHash: 'a'.repeat(64), + signature: 'receipt-signature', + }; + const signedProjection = { + projection: { + eventHeadHash, + sessionHash: sha256('session-1'), + signedReceipts: [receipt], + }, + projectionHash, + }; + return { + summary: { + mode: 'offline-deterministic', + piVersion: '0.80.6', + x402Version: 2, + network: 'eip155:84532', + isolation: 'simulated', + tests: INVARIANT_IDS.length, + passed: INVARIANT_IDS.length, + liveCdp: 'not-run', + testnetTransaction: 'not-run', + }, + evidenceInput: { + acceptance: { + invariants: INVARIANT_IDS.map((id) => ({ + id, + passed: true, + evidenceHash: sha256(`accepted:${id}`), + })), + processExitCodes: Object.fromEntries(CHILD_NAMES.map((name) => [name, 0])), + transactionIds: [`0x${'ab'.repeat(32)}`], + rawSettlementTransactionIds: [`0x${'ab'.repeat(32)}`], + piApprovalResume: Object.fromEntries(['tool', 'model'].map((kind) => [kind, { + pendingObserved: true, + originalRequestHeld: true, + operatorApprovalStatus: 200, + signerDelta: 1, + paidRequestDelta: 1, + duplicatePaymentSignatureDelta: 0, + outputObserved: 'PI_WALLET_OK', + processExitCode: 0, + }])), + }, + sessionProjections: [signedProjection], + authorityReceipts: [receipt], + events: [{ + sequence: 1, + eventType: 'receipt.issued', + receiptHash: receipt.receiptHash, + receiptSignature: receipt.signature, + }], + receiptPublicKeys: [{ keyId: 'key-1', algorithm: 'Ed25519', publicKeyPem: 'public' }], + identityBindings: { + kernel: { uid: '501', gid: '20' }, + agent: { uid: '501', gid: '20' }, + }, + privilegedReport: null, + freshVerification: { + authorityEventChain: true, + projection: true, + receipts: true, + }, + policyHash: sha256('policy'), + routeMapHash: sha256('routes'), + configHash: sha256('config'), + wallet: { + provider: 'deterministic', + walletIdHash: sha256('wallet'), + address: '0x1000000000000000000000000000000000000000', + }, + }, + cleanup: async () => { + assert.equal(fs.existsSync(anchorOutput), true); + sequence.push('cleanup'); + }, + }; +} + +test('offline evidence calls the production acceptance API once, verifies, anchors, then cleans', async (t) => { + const parent = temporaryDirectory(t); + const outputDirectory = path.join(parent, 'bundle'); + const anchorOutput = path.join(parent, 'manifest.sha256'); + const sequence = []; + let calls = 0; + let assembled; + const verified = { + valid: true, + mode: 'offline-deterministic', + manifestSha256: MANIFEST_HASH, + }; + + const result = await runOfflineEvidence({ outputDirectory, anchorOutput }, { + runAcceptance: async ({ authorityDirectory, piExecutable, nodeExecutable }) => { + calls += 1; + sequence.push('acceptance'); + assert.equal(fs.statSync(authorityDirectory).mode & 0o777, 0o700); + assert.equal(fs.readdirSync(authorityDirectory).length, 0); + assert.equal(path.isAbsolute(piExecutable), true); + assert.equal(path.isAbsolute(nodeExecutable), true); + return acceptanceResult(sequence, anchorOutput); + }, + buildBundle: (input) => { + sequence.push('build'); + assembled = input; + return { manifestSha256: MANIFEST_HASH }; + }, + verifyBundle: (directory, options) => { + sequence.push('verify'); + assert.equal(directory, outputDirectory); + assert.deepEqual(options, { expectedManifestSha256: MANIFEST_HASH }); + return verified; + }, + gitState: () => ({ commit: 'b'.repeat(40), dirty: true }), + now: () => NOW, + }); + + assert.equal(calls, 1); + assert.deepEqual(result, verified); + assert.deepEqual(sequence, ['acceptance', 'build', 'verify', 'cleanup']); + assert.equal(fs.readFileSync(anchorOutput, 'utf8'), `${MANIFEST_HASH}\n`); + assert.equal(fs.statSync(anchorOutput).mode & 0o777, 0o600); + assert.equal(assembled.outputDirectory, outputDirectory); + assert.deepEqual(assembled.events, acceptanceResult([], anchorOutput).evidenceInput.events); + assert.deepEqual( + assembled.receipts, + acceptanceResult([], anchorOutput).evidenceInput.authorityReceipts, + ); + const expectedProjections = acceptanceResult([], anchorOutput).evidenceInput.sessionProjections; + assert.deepEqual(assembled.manifestInput, { + schemaVersion: 2, + createdAt: NOW, + mode: 'offline-deterministic', + git: { commit: 'b'.repeat(40), dirty: true }, + runtime: { nodeVersion: process.version, piVersion: '0.80.6' }, + protocol: { + x402Version: 2, + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + }, + wallet: acceptanceResult([], anchorOutput).evidenceInput.wallet, + isolation: { + status: 'simulated', + preflightDigest: null, + kernelIdentityHash: sha256(JSON.stringify({ + domain: 'wallet-kernel.kernel-identity.v1', + kernelGid: '20', + kernelUid: '501', + })), + agentIdentityHash: sha256(JSON.stringify({ + agentGid: '20', + agentUid: '501', + domain: 'wallet-kernel.agent-identity.v1', + })), + }, + deployment: { + status: 'simulated', + releaseManifestDigest: null, + releaseTreeHash: null, + serviceArtifactsHash: null, + systemdEffectiveConfigHash: null, + }, + inputs: { + policyHash: sha256('policy'), + routeMapHash: sha256('routes'), + configHash: sha256('config'), + }, + source: { + authorityEventHeadHash: sha256('authority-head'), + signedProjectionHash: sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.signed-projection-set.v1', + signedProjections: expectedProjections, + })), + receiptKeys: [{ keyId: 'key-1', algorithm: 'Ed25519', publicKeyPem: 'public' }], + }, + status: { liveCdp: 'not-run', walletFunded: 'not-run', testnetTransaction: 'not-run' }, + identityBindings: { + kernel: { uid: '501', gid: '20' }, + agent: { uid: '501', gid: '20' }, + }, + privilegedReport: null, + signedProjections: expectedProjections, + }); +}); + +test('offline evidence refuses an existing external anchor before running acceptance', async (t) => { + const parent = temporaryDirectory(t); + const anchorOutput = path.join(parent, 'manifest.sha256'); + fs.writeFileSync(anchorOutput, 'do-not-replace\n', { mode: 0o600 }); + let calls = 0; + await assert.rejects(runOfflineEvidence({ + outputDirectory: path.join(parent, 'bundle'), + anchorOutput, + }, { + runAcceptance: async () => { calls += 1; }, + }), { code: 'EVIDENCE_ANCHOR_EXISTS' }); + assert.equal(calls, 0); + assert.equal(fs.readFileSync(anchorOutput, 'utf8'), 'do-not-replace\n'); +}); + +test('offline evidence refuses dangling output or anchor symlinks before acceptance', async (t) => { + const parent = temporaryDirectory(t); + const dangling = path.join(parent, 'missing-target'); + const outputLink = path.join(parent, 'bundle'); + const anchorLink = path.join(parent, 'manifest.sha256'); + fs.symlinkSync(dangling, outputLink); + let calls = 0; + await assert.rejects(runOfflineEvidence({ + outputDirectory: outputLink, + anchorOutput: path.join(parent, 'unused-anchor'), + }, { + runAcceptance: async () => { calls += 1; }, + }), { code: 'EVIDENCE_OUTPUT_EXISTS' }); + fs.unlinkSync(outputLink); + fs.symlinkSync(dangling, anchorLink); + await assert.rejects(runOfflineEvidence({ + outputDirectory: path.join(parent, 'unused-bundle'), + anchorOutput: anchorLink, + }, { + runAcceptance: async () => { calls += 1; }, + }), { code: 'EVIDENCE_ANCHOR_EXISTS' }); + assert.equal(calls, 0); +}); + +test('offline evidence rejects a diluted self-consistent acceptance result', async (t) => { + const parent = temporaryDirectory(t); + const anchorOutput = path.join(parent, 'manifest.sha256'); + const diluted = acceptanceResult([], anchorOutput); + diluted.summary.tests = 1; + diluted.summary.passed = 1; + diluted.evidenceInput.acceptance.invariants = diluted.evidenceInput.acceptance.invariants.slice(0, 1); + let builds = 0; + await assert.rejects(runOfflineEvidence({ + outputDirectory: path.join(parent, 'bundle'), + anchorOutput, + }, { + runAcceptance: async () => diluted, + buildBundle: () => { builds += 1; }, + }), { code: 'EVIDENCE_ACCEPTANCE_FAILED' }); + assert.equal(builds, 0); +}); + +test('offline evidence rejects nonzero or incomplete child-process authority before build', async (t) => { + for (const mutate of [ + (result) => { result.evidenceInput.acceptance.processExitCodes.seller = 1; }, + (result) => { delete result.evidenceInput.acceptance.processExitCodes['control-verifier']; }, + ]) { + const parent = temporaryDirectory(t); + const anchorOutput = path.join(parent, 'manifest.sha256'); + const result = acceptanceResult([], anchorOutput); + result.cleanup = async () => {}; + mutate(result); + let builds = 0; + await assert.rejects(runOfflineEvidence({ + outputDirectory: path.join(parent, 'bundle'), + anchorOutput, + }, { + runAcceptance: async () => result, + buildBundle: () => { builds += 1; }, + }), { code: 'EVIDENCE_ACCEPTANCE_RESULT' }); + assert.equal(builds, 0); + } +}); + +test('offline evidence rejects raw transaction reuse and incomplete session partitions', async (t) => { + const mutations = [ + (result) => { + result.evidenceInput.acceptance.rawSettlementTransactionIds.push( + result.evidenceInput.acceptance.rawSettlementTransactionIds[0], + ); + }, + (result) => { result.evidenceInput.sessionProjections.length = 0; }, + (result) => { result.evidenceInput.authorityReceipts.length = 0; }, + ]; + for (const mutate of mutations) { + const parent = temporaryDirectory(t); + const anchorOutput = path.join(parent, 'manifest.sha256'); + const result = acceptanceResult([], anchorOutput); + result.cleanup = async () => {}; + mutate(result); + let builds = 0; + await assert.rejects(runOfflineEvidence({ + outputDirectory: path.join(parent, 'bundle'), + anchorOutput, + }, { + runAcceptance: async () => result, + buildBundle: () => { builds += 1; }, + }), { code: 'EVIDENCE_ACCEPTANCE_RESULT' }); + assert.equal(builds, 0); + } +}); + +test('offline evidence rejects missing or altered pinned Pi approval proof before build', async (t) => { + const mutations = [ + (result) => { delete result.evidenceInput.acceptance.piApprovalResume.model; }, + (result) => { + result.evidenceInput.acceptance.piApprovalResume.tool.originalRequestHeld = false; + }, + ]; + for (const mutate of mutations) { + const parent = temporaryDirectory(t); + const anchorOutput = path.join(parent, 'manifest.sha256'); + const result = acceptanceResult([], anchorOutput); + result.cleanup = async () => {}; + mutate(result); + let builds = 0; + await assert.rejects(runOfflineEvidence({ + outputDirectory: path.join(parent, 'bundle'), + anchorOutput, + }, { + runAcceptance: async () => result, + buildBundle: () => { builds += 1; }, + }), { code: 'EVIDENCE_ACCEPTANCE_RESULT' }); + assert.equal(builds, 0); + } +}); + +test('CLI requires explicit offline paths and testnet remains honestly not-run', async () => { + assert.deepEqual(parseRunEvidenceArguments([ + '--mode', 'offline-deterministic', + '--output', '/tmp/evidence-bundle', + '--anchor-output', '/tmp/evidence-manifest.sha256', + ]), { + mode: 'offline-deterministic', + outputDirectory: '/tmp/evidence-bundle', + anchorOutput: '/tmp/evidence-manifest.sha256', + }); + assert.throws( + () => parseRunEvidenceArguments(['--mode', 'offline-deterministic']), + { code: 'EVIDENCE_RUN_ARGUMENTS' }, + ); + let acceptanceCalls = 0; + await assert.rejects(runEvidence({ mode: 'base-sepolia-testnet' }, { + runAcceptance: async () => { acceptanceCalls += 1; }, + }), { code: 'EVIDENCE_TESTNET_NOT_RUN' }); + assert.equal(acceptanceCalls, 0); +}); diff --git a/spikes/pi-wielder/tests/fixtures/budget-writer.mjs b/spikes/pi-wielder/tests/fixtures/budget-writer.mjs new file mode 100644 index 0000000..288b4ac --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/budget-writer.mjs @@ -0,0 +1,49 @@ +import fs from 'node:fs'; + +import { createBudgetLedger } from '../../src/kernel/budget-ledger.mjs'; +import { canonicalTimestamp } from '../../src/kernel/canonical.mjs'; +import { openKernelStore } from '../../src/kernel/sqlite-store.mjs'; + +const arguments_ = process.argv.slice(2); +if (arguments_.length !== 7) { + throw new Error( + 'usage: budget-writer.mjs databasePath trustedAncestor intentId amountAtomic fixedNow readyFile releaseFile', + ); +} +const [ + databasePath, + trustedAncestor, + intentId, + amountAtomic, + fixedNow, + readyFile, + releaseFile, +] = arguments_; +const timestamp = canonicalTimestamp(fixedNow, 'budget writer fixed time'); +const now = () => timestamp; +const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor, + kernelUid: process.getuid(), + agentUid: process.getuid(), +}); + +const store = openKernelStore({ filePath: databasePath, pathTrust, now }); +let result; +try { + fs.writeFileSync(readyFile, 'ready', { flag: 'wx', mode: 0o600 }); + const signal = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(releaseFile)) Atomics.wait(signal, 0, 0, 5); + + const ledger = createBudgetLedger({ store, now }); + try { + ledger.reserve({ intentId, amountAtomic }); + result = 'reserved'; + } catch (error) { + if (error?.code !== 'LIMIT_EXCEEDED') throw error; + result = 'LIMIT_EXCEEDED'; + } +} finally { + store.close(); +} +process.stdout.write(`${result}\n`); diff --git a/spikes/pi-wielder/tests/fixtures/control-plane-process.mjs b/spikes/pi-wielder/tests/fixtures/control-plane-process.mjs new file mode 100644 index 0000000..0e8d1ac --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/control-plane-process.mjs @@ -0,0 +1,941 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; + +import { serve } from '@hono/node-server'; +import { keccak256, toBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { createDeterministicWalletAdapter } from '../../src/adapters/deterministic-wallet-adapter.mjs'; +import { createSellerEvidenceResolver } from '../../src/adapters/seller-evidence-resolver.mjs'; +import { createX402V2Transport } from '../../src/adapters/x402-v2-transport.mjs'; +import { createAgentEnrollmentRepository } from '../../src/kernel/agent-enrollment.mjs'; +import { createApprovalQueue } from '../../src/kernel/approval-queue.mjs'; +import { acquireAuthorityLock } from '../../src/kernel/authority-lock.mjs'; +import { createPermitAuthority } from '../../src/kernel/authorized-permit.mjs'; +import { createBudgetLedger } from '../../src/kernel/budget-ledger.mjs'; +import { canonicalJson, sha256 } from '../../src/kernel/canonical.mjs'; +import { createIntentRepository } from '../../src/kernel/intent-builder.mjs'; +import { validatePolicyDocument } from '../../src/kernel/policy-engine.mjs'; +import { createPolicyRepository } from '../../src/kernel/policy-repository.mjs'; +import { createProjectionExporter } from '../../src/kernel/projection-exporter.mjs'; +import { loadOrCreateReceiptSigner } from '../../src/kernel/receipt-signing.mjs'; +import { recoverKernelAuthority } from '../../src/kernel/recovery.mjs'; +import { createSignedReceiptRepository } from '../../src/kernel/signed-receipts.mjs'; +import { openKernelStore } from '../../src/kernel/sqlite-store.mjs'; +import { createOperatorAuth, loadOrCreateOperatorToken } from '../../src/operator/auth.mjs'; +import { startControlPlane } from '../../src/control-plane.mjs'; + +const NETWORK = 'eip155:84532'; +const HASH = /^sha256:[0-9a-f]{64}$/u; +const CONFIG_FIELDS = Object.freeze([ + 'schemaVersion', 'authorityDirectory', 'databasePath', 'receiptKeyPath', + 'operatorTokenPath', 'policyPath', 'routePath', 'kernelStatePath', + 'expectedAgentUid', 'expectedAgentGid', 'sellerOrigin', +]); +const buyerAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-deterministic-adapter-test-only')), +); +const WALLET_ADDRESS = buyerAccount.address.toLowerCase(); + +function fixtureError(code, message) { + return Object.assign(new Error(message), { code }); +} + +function exactRecord(value, fields, label) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype + || Reflect.ownKeys(value).length !== fields.length + || fields.some((field) => !Object.hasOwn(value, field))) { + throw fixtureError('PROCESS_CONFIG_INVALID', `${label} is invalid`); + } + return Object.fromEntries(fields.map((field) => [field, value[field]])); +} + +function absolutePath(value, authorityDirectory, label) { + if (typeof value !== 'string' || !path.isAbsolute(value) + || path.resolve(value) !== value || value.includes('\0')) { + throw fixtureError('PROCESS_CONFIG_INVALID', `${label} is invalid`); + } + const relative = path.relative(authorityDirectory, value); + if (relative === '' || relative === '..' || path.isAbsolute(relative) + || relative.startsWith(`..${path.sep}`)) { + throw fixtureError('PROCESS_CONFIG_INVALID', `${label} leaves authority directory`); + } + return value; +} + +function loadFixtureConfig(filePath) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath) + || path.resolve(filePath) !== filePath) { + throw fixtureError('PROCESS_CONFIG_INVALID', 'fixture config path is invalid'); + } + const stat = fs.lstatSync(filePath, { bigint: true }); + const uid = BigInt(process.getuid()); + if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== uid + || (stat.mode & 0o7777n) !== 0o600n || stat.nlink !== 1n || stat.size > 65_536n) { + throw fixtureError('PROCESS_CONFIG_INVALID', 'fixture config authority is invalid'); + } + const parsed = exactRecord( + JSON.parse(fs.readFileSync(filePath, 'utf8')), + CONFIG_FIELDS, + 'fixture config', + ); + if (parsed.schemaVersion !== 1 || !Number.isSafeInteger(parsed.expectedAgentUid) + || parsed.expectedAgentUid <= 0 || !Number.isSafeInteger(parsed.expectedAgentGid) + || parsed.expectedAgentGid <= 0) { + throw fixtureError('PROCESS_CONFIG_INVALID', 'fixture config values are invalid'); + } + const authorityDirectory = parsed.authorityDirectory; + if (typeof authorityDirectory !== 'string' || !path.isAbsolute(authorityDirectory) + || path.resolve(authorityDirectory) !== authorityDirectory) { + throw fixtureError('PROCESS_CONFIG_INVALID', 'authority directory is invalid'); + } + const directoryStat = fs.lstatSync(authorityDirectory, { bigint: true }); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() + || directoryStat.uid !== uid || (directoryStat.mode & 0o7777n) !== 0o700n) { + throw fixtureError('PROCESS_CONFIG_INVALID', 'authority directory authority is invalid'); + } + for (const field of [ + 'databasePath', 'receiptKeyPath', 'operatorTokenPath', 'policyPath', + 'routePath', 'kernelStatePath', + ]) absolutePath(parsed[field], authorityDirectory, field); + const seller = new URL(parsed.sellerOrigin); + if (seller.protocol !== 'http:' || seller.hostname !== '127.0.0.1' + || seller.port === '' || seller.pathname !== '/' || seller.origin !== parsed.sellerOrigin) { + throw fixtureError('PROCESS_CONFIG_INVALID', 'seller origin is invalid'); + } + return Object.freeze(parsed); +} + +function pathTrust(config) { + return Object.freeze({ + mode: 'deterministic', + trustedAncestor: config.authorityDirectory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); +} + +function readCanonicalFile(filePath, maximumBytes) { + const bytes = fs.readFileSync(filePath); + if (bytes.length === 0 || bytes.length > maximumBytes) { + throw fixtureError('PROCESS_CONFIG_INVALID', 'canonical input size is invalid'); + } + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + const value = JSON.parse(text); + if (`${canonicalJson(value)}\n` !== text) { + throw fixtureError('PROCESS_CONFIG_INVALID', 'input is not canonical JSON plus newline'); + } + return value; +} + +function readDescriptor(filePath) { + const stat = fs.lstatSync(filePath, { bigint: true }); + if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== BigInt(process.getuid()) + || (stat.mode & 0o7777n) !== 0o644n || stat.nlink !== 1n || stat.size > 4_096n) { + throw fixtureError('AGENT_DESCRIPTOR_PATH', 'enrollment descriptor authority is invalid'); + } + return readCanonicalFile(filePath, 4_096); +} + +function currentTimestamp() { + return new Date().toISOString(); +} + +function operatorHash(token) { + return sha256(Buffer.concat([ + Buffer.from('wallet-kernel.fixture-bootstrap-operator.v1\0', 'utf8'), + Buffer.from(token, 'ascii'), + ])); +} + +async function bootstrap(config, enrollmentPath) { + const trust = pathTrust(config); + const lock = acquireAuthorityLock({ + databasePath: config.databasePath, + role: 'bootstrap', + pathTrust: trust, + }); + let store; + try { + const operatorToken = loadOrCreateOperatorToken({ + filePath: config.operatorTokenPath, + pathTrust: trust, + }); + loadOrCreateReceiptSigner(config.receiptKeyPath, { pathTrust: trust }); + store = openKernelStore({ + filePath: config.databasePath, + pathTrust: trust, + now: currentTimestamp, + }); + const policy = validatePolicyDocument(readCanonicalFile(config.policyPath, 65_536)); + const policies = createPolicyRepository(store); + const applied = policies.apply(policy, currentTimestamp()).policyVersion; + const descriptor = readDescriptor(enrollmentPath); + const enrollment = createAgentEnrollmentRepository({ store, now: currentTimestamp }).enroll({ + descriptor, + expectedDescriptorHash: sha256(canonicalJson(descriptor)), + operatorIdHash: operatorHash(operatorToken), + mode: 'deterministic', + kernelUid: process.getuid(), + kernelGid: process.getgid(), + expectedAgentUid: config.expectedAgentUid, + expectedAgentGid: config.expectedAgentGid, + }); + if (typeof process.send === 'function') { + process.send({ + type: 'bootstrap-complete', + descriptorHash: enrollment.enrollmentHash, + policyHash: applied.hash, + }); + } + } finally { + store?.close(); + lock.close(); + } +} + +async function reservePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw fixtureError('PROCESS_LISTEN', 'port unavailable'); + const port = address.port; + await new Promise((resolve) => server.close(resolve)); + return port; +} + +function listener({ app, host, port }) { + return new Promise((resolve, reject) => { + let settled = false; + const server = serve({ + fetch: app.fetch, + hostname: host, + port, + overrideGlobalObjects: false, + }, () => { + settled = true; + resolve(Object.freeze({ + close: () => new Promise((done, failed) => server.close((error) => ( + error ? failed(error) : done() + ))), + })); + }); + server.once('error', (error) => { + if (!settled) reject(error); + }); + }); +} + +function idFactory(store) { + let sequence = Number(store.readOne('SELECT COALESCE(MAX(sequence), 0) AS value FROM events').value); + return (kind) => `${kind}:${++sequence}`; +} + +function routeMetadata(routes) { + return Object.freeze(Object.fromEntries(routes.routes.map((route) => [ + route.id, + Object.freeze({ description: route.resourceDescription, mimeType: route.resourceMimeType }), + ]))); +} + +function bindingRows(store, intents, enrollment) { + return store.readAll(`SELECT * FROM agent_session_bindings + WHERE agent_instance_id = ? AND enrollment_hash = ? AND state = 'open' + ORDER BY id`, [enrollment.agentInstanceId, enrollment.enrollmentHash]).map((row) => ( + Object.freeze({ + bindingId: row.id, + agentInstanceId: row.agent_instance_id, + credentialDigest: row.credential_digest, + enrollmentHash: row.enrollment_hash, + state: row.state, + session: intents.getSession(row.session_id), + }) + )); +} + +function sessionRows(store, intents) { + return store.readAll('SELECT id FROM spend_sessions ORDER BY created_at, id') + .map((row) => intents.getSession(row.id)); +} + +function publicPolicy(version, activeId) { + return Object.freeze({ + versionId: version.id, + policy: version.policy, + policyHash: version.hash, + predecessorHash: version.predecessorHash, + createdAt: version.appliedAt, + active: version.id === activeId, + }); +} + +function publicApproval(record) { + return Object.freeze({ + approvalId: record.approvalId, + intentId: record.intentId, + decision: record.decision, + intentHash: record.intentHash, + challengeHash: record.challengeHash, + quoteId: record.quoteId, + acceptedIndex: record.acceptedIndex, + amountAtomic: record.amountCeilingAtomic, + walletAddress: record.walletAddress, + policyVersionId: record.policyVersionId, + expiresAt: record.expiresAt, + reasonCode: record.reasonCode, + recordedAt: record.decidedAt, + }); +} + +function allReceipts(store, receipts) { + const result = []; + for (const row of store.readAll('SELECT DISTINCT intent_id FROM signed_receipts ORDER BY intent_id')) { + const current = receipts.latest(row.intent_id); + if (current) result.push(current); + } + return Object.freeze(result); +} + +function normalizedEvents(store) { + return Object.freeze(store.events().map((row) => { + let data = {}; + try { data = JSON.parse(row.data_json); } catch {} + const amountAtomic = [ + data.amountAtomic, data.amountCeilingAtomic, data.reservedAtomic, + data.committedAtomic, data.releasedAtomic, + ].find((value) => typeof value === 'string' && /^(?:0|[1-9][0-9]*)$/u.test(value)) ?? null; + const transactionId = [ + data.transactionId, data.paymentTransactionId, data.refundTransactionId, + ].find((value) => typeof value === 'string' && /^0x[0-9a-f]{64}$/u.test(value)) ?? null; + return Object.freeze({ + id: String(row.sequence), + kind: row.event_type, + hash: sha256(canonicalJson({ + domain: 'wallet-kernel.normalized-entity.v1', + entityType: row.entity_type, + entityId: row.entity_id, + })), + decision: typeof data.decision === 'string' ? data.decision : null, + amountAtomic, + transactionId, + receiptHash: typeof data.receiptHash === 'string' ? data.receiptHash : null, + eventHash: row.event_hash, + previousEventHash: row.previous_hash, + createdAt: row.created_at, + }); + })); +} + +function persistedNumber(value, label) { + const number = typeof value === 'bigint' ? Number(value) : value; + if (!Number.isSafeInteger(number) || number < 0) { + throw fixtureError('PROCESS_AUTHORITY', `${label} is invalid`); + } + return number; +} + +function evidenceDigest(value) { + return value === null ? null : sha256(value); +} + +function paymentCaseHash(authority, candidates) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-reconciliation-case.v1', + intentId: authority.intent.id, + intentHash: authority.intent.intent_hash, + attemptState: authority.attempt.state, + budgetState: authority.budget.state, + buyerOutcomeRevision: persistedNumber(authority.outcome.revision, 'buyer outcome revision'), + history: candidates.map((row) => ({ + id: row.id, + transactionId: row.transaction_id, + state: row.state, + evidenceHash: evidenceDigest(row.evidence_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + })); +} + +function executionCaseHash(authority, execution, resolution) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.execution-reconciliation-case.v1', + intentId: authority.intent.id, + intentHash: authority.intent.intent_hash, + transactionId: authority.attempt.transaction_id, + execution: { + state: execution.state, + httpStatus: execution.http_status === null + ? null + : persistedNumber(execution.http_status, 'execution HTTP status'), + responseHash: execution.response_hash, + metadataHash: sha256(execution.metadata_json), + recordedAt: execution.recorded_at, + }, + resolution: { + state: resolution.state, + reasonCode: resolution.reason_code, + openedAt: resolution.opened_at, + }, + buyerOutcomeRevision: persistedNumber(authority.outcome.revision, 'buyer outcome revision'), + })); +} + +function refundCaseHash(authority, execution, resolution, refunds) { + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-observation-case.v1', + intentId: authority.intent.id, + intentHash: authority.intent.intent_hash, + originalTransactionId: authority.attempt.transaction_id, + executionState: execution.state, + resolutionState: resolution.state, + buyerOutcomeRevision: persistedNumber(authority.outcome.revision, 'buyer outcome revision'), + history: refunds.map((row) => ({ + id: row.id, + originalTransactionId: row.original_transaction_id, + amountAtomic: row.amount_atomic, + state: row.state, + refundTransactionId: row.refund_transaction_id, + evidenceHash: evidenceDigest(row.evidence_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + })); +} + +function reconciliationCases(store) { + const result = []; + const intents = store.readAll('SELECT * FROM spend_intents ORDER BY created_at, id'); + for (const intent of intents) { + const attempt = store.readOne('SELECT * FROM payment_attempts WHERE intent_id = ?', [intent.id]); + const budget = store.readOne('SELECT * FROM budget_reservations WHERE intent_id = ?', [intent.id]); + const outcome = store.readOne('SELECT * FROM buyer_outcomes WHERE intent_id = ?', [intent.id]); + if (!attempt || !budget || !outcome) continue; + const authority = Object.freeze({ intent, attempt, budget, outcome }); + if (intent.state === 'unresolved' && attempt.state === 'unresolved' + && budget.state === 'unresolved' && outcome.status === 'payment_unresolved') { + const candidates = store.readAll( + 'SELECT * FROM payment_reconciliation_candidates WHERE intent_id = ? ORDER BY rowid', + [intent.id], + ); + result.push(Object.freeze({ + kind: 'payment', + intentId: intent.id, + intentHash: intent.intent_hash, + caseHash: paymentCaseHash(authority, candidates), + paymentTransactionId: candidates.find((row) => row.state === 'pending')?.transaction_id ?? null, + })); + continue; + } + const execution = store.readOne('SELECT * FROM execution_outcomes WHERE intent_id = ?', [intent.id]); + const resolution = store.readOne( + 'SELECT * FROM execution_resolutions WHERE intent_id = ?', + [intent.id], + ); + if (!execution || !resolution) continue; + if (outcome.status === 'execution_unknown' && execution.state === 'unknown' + && resolution.state === 'reconciliation_required') { + result.push(Object.freeze({ + kind: 'execution', + intentId: intent.id, + intentHash: intent.intent_hash, + caseHash: executionCaseHash(authority, execution, resolution), + transactionId: attempt.transaction_id, + })); + continue; + } + if (outcome.status === 'execution_failed' && execution.state === 'failed' + && resolution.state === 'refund_pending') { + const refunds = store.readAll('SELECT * FROM refunds WHERE intent_id = ? ORDER BY rowid', [intent.id]); + const open = refunds.find((row) => row.state === 'pending' || row.state === 'unresolved'); + result.push(Object.freeze({ + kind: 'refund-observation', + intentId: intent.id, + intentHash: intent.intent_hash, + caseHash: refundCaseHash(authority, execution, resolution, refunds), + originalTransactionId: attempt.transaction_id, + refundTransactionId: open?.refund_transaction_id ?? null, + })); + } + } + return Object.freeze(result); +} + +async function rpcResult(url) { + try { + const response = await fetch(url, { + method: 'GET', + redirect: 'manual', + credentials: 'omit', + signal: AbortSignal.timeout(2_000), + }); + if (response.status !== 200 + || !response.headers.get('content-type')?.toLowerCase().startsWith('application/json')) { + return null; + } + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length === 0 || bytes.length > 16_384) return null; + try { + const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + return value?.jsonrpc === '2.0' && value?.id === 1 ? value.result ?? null : null; + } finally { + bytes.fill(0); + } + } catch { + return null; + } +} + +function rejectedRefundProof(proof) { + if (!proof) return null; + return Object.freeze({ + source: proof.source, + network: proof.network, + transactionId: proof.transactionId, + blockHash: proof.blockHash, + blockNumber: proof.blockNumber, + transactionStatus: proof.transactionStatus, + confirmations: proof.confirmations, + reasonCode: proof.transactionStatus === 'reverted' + ? 'TRANSACTION_REVERTED' + : 'EXACT_TRANSFER_ABSENT', + observedAt: proof.observedAt, + }); +} + +function refundTransferProof(proof) { + if (!proof) return null; + return Object.freeze({ + source: proof.source, + network: proof.network, + transactionId: proof.transactionId, + blockHash: proof.blockHash, + blockNumber: proof.blockNumber, + transactionStatus: proof.transactionStatus, + confirmations: proof.confirmations, + transferLogIndex: proof.transferLogIndex, + tokenContract: proof.tokenContract, + from: proof.from, + to: proof.to, + valueAtomic: proof.valueAtomic, + observedAt: proof.observedAt, + }); +} + +function isExactRefundTransfer(proof, binding) { + return proof?.transactionStatus === 'success' + && proof.network === binding.network + && proof.transactionId === binding.refundTransactionId + && proof.tokenContract === binding.asset + && proof.from === binding.refundSource + && proof.to === binding.originalPayer + && proof.valueAtomic === binding.amountAtomic; +} + +function kernelStateGuard(filePath) { + const descriptor = fs.openSync(filePath, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW); + const stat = fs.fstatSync(descriptor, { bigint: true }); + if (!stat.isFile() || stat.uid !== BigInt(process.getuid()) + || (stat.mode & 0o7777n) !== 0o600n || stat.nlink !== 1n) { + fs.closeSync(descriptor); + throw fixtureError('PROCESS_AUTHORITY', 'kernel fixture state is invalid'); + } + let state; + try { + const bytes = fs.readFileSync(descriptor, { encoding: 'utf8' }); + state = bytes.length === 0 ? { signerCalls: 0, transportErrors: [] } : JSON.parse(bytes); + } catch { + fs.closeSync(descriptor); + throw fixtureError('PROCESS_AUTHORITY', 'kernel fixture state is corrupt'); + } + const incrementSigner = () => { + state = { ...state, signerCalls: state.signerCalls + 1 }; + const bytes = Buffer.from(`${JSON.stringify(state)}\n`, 'utf8'); + fs.ftruncateSync(descriptor, 0); + fs.writeSync(descriptor, bytes, 0, bytes.length, 0); + fs.fsyncSync(descriptor); + bytes.fill(0); + }; + const recordTransportError = (operation, code) => { + const safeCode = typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,127}$/u.test(code) + ? code + : 'TRANSPORT_INTERNAL'; + state = { + ...state, + transportErrors: [...(state.transportErrors ?? []), { operation, code: safeCode }].slice(-100), + }; + const bytes = Buffer.from(`${JSON.stringify(state)}\n`, 'utf8'); + fs.ftruncateSync(descriptor, 0); + fs.writeSync(descriptor, bytes, 0, bytes.length, 0); + fs.fsyncSync(descriptor); + bytes.fill(0); + }; + return Object.freeze({ + incrementSigner, + recordTransportError, + close: () => fs.closeSync(descriptor), + }); +} + +function observedTransport(kernelState) { + const transport = createX402V2Transport({ + fetchImpl: fetch, + mode: 'deterministic', + limits: { + requestTimeoutMs: 2_000, + maximumResponseBytes: 1_048_576, + maximumPaymentHeaderBytes: 16_384, + }, + }); + const observed = (operation) => async (...args) => { + try { + return await transport[operation](...args); + } catch (error) { + kernelState.recordTransportError(operation, error?.code); + throw error; + } + }; + return Object.freeze({ + probe: observed('probe'), + encodePayment: (...args) => { + try { + return transport.encodePayment(...args); + } catch (error) { + kernelState.recordTransportError('encodePayment', error?.code); + throw error; + } + }, + retryPaid: observed('retryPaid'), + }); +} + +function unknownResolver() { + return Object.freeze({ kind: 'unknown', reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED' }); +} + +function buildDependencies(config, endpoints) { + const trust = pathTrust(config); + const origin = `http://127.0.0.1:${endpoints.operatorPort}`; + return Object.freeze({ + deterministicEndpoints: Object.freeze(endpoints), + loadConfig() { + return Object.freeze({ + publicConfig: Object.freeze({ + mode: 'deterministic', + agentHost: '127.0.0.1', + agentPort: endpoints.agentPort, + operatorAdminTransport: 'loopback-demo', + operatorSocketPath: null, + operatorConsoleTransport: 'loopback-demo', + operatorConsoleActivationName: null, + operatorHost: '127.0.0.1', + operatorPort: endpoints.operatorPort, + databasePath: config.databasePath, + policyPath: config.policyPath, + routePath: config.routePath, + receiptKeyPath: config.receiptKeyPath, + operatorTokenPath: config.operatorTokenPath, + enrollmentInboxPath: null, + agentRunOutboxPath: null, + trustedAncestor: null, + releaseRoot: null, + releaseManifestPath: null, + serviceDefinitionPath: null, + socketDefinitionPath: null, + environmentFilePath: null, + evidenceRoot: null, + isolationReportPath: null, + expectedAgentUid: config.expectedAgentUid, + expectedAgentGid: config.expectedAgentGid, + cdpWalletName: null, + network: NETWORK, + observer: 'deterministic', + }), + assertCredentialPresence() {}, + }); + }, + readRouteDocument(filePath) { + if (filePath !== config.routePath) throw fixtureError('PROCESS_CONFIG_INVALID', 'route path changed'); + return readCanonicalFile(filePath, 65_536); + }, + acquireAuthorityLock({ config: publicConfig, role }) { + return acquireAuthorityLock({ + databasePath: publicConfig.databasePath, + role, + pathTrust: trust, + }); + }, + async openAuthority({ routes }) { + const now = currentTimestamp; + const store = openKernelStore({ + filePath: config.databasePath, + pathTrust: trust, + now, + }); + const ids = idFactory(store); + const policies = createPolicyRepository(store); + const enrollments = createAgentEnrollmentRepository({ store, now }); + const intents = createIntentRepository({ + store, + idFactory: ids, + now, + routeMetadata: routeMetadata(routes), + allowLoopbackHttp: true, + }); + const budgets = createBudgetLedger({ store, now }); + const approvals = createApprovalQueue({ store, idFactory: ids, now }); + const signer = loadOrCreateReceiptSigner(config.receiptKeyPath, { pathTrust: trust }); + const receipts = createSignedReceiptRepository({ + store, + signer, + idFactory: ids, + now, + }); + const exporter = createProjectionExporter({ store, receipts, signer, now }); + const token = loadOrCreateOperatorToken({ + filePath: config.operatorTokenPath, + pathTrust: trust, + }); + const operatorAuth = createOperatorAuth({ mode: 'deterministic', origin, token }); + const kernelState = kernelStateGuard(config.kernelStatePath); + const sellerEvidence = createSellerEvidenceResolver({ + fetchImpl: fetch, + mode: 'deterministic', + now, + limits: { requestTimeoutMs: 5_000, maximumResponseBytes: 16_384 }, + }); + const resolver = Object.freeze({ + async observePayment(binding) { + if (binding.candidate === null) return unknownResolver(); + const proof = await rpcResult( + `${config.sellerOrigin}/fixture/v1/payment-proof/${binding.candidate.transactionId}`, + ); + return proof === null + ? Object.freeze({ kind: 'unknown', reasonCode: 'RPC_PROVIDER_UNAVAILABLE' }) + : Object.freeze({ kind: 'settled_transfer', rpcTransferProof: proof }); + }, + observeExecution: (binding) => sellerEvidence.observeExecution(binding), + async observeRefund(binding) { + const proof = await rpcResult( + `${config.sellerOrigin}/fixture/v1/refund-proof/${binding.refundTransactionId}`, + ); + if (proof === null) { + return Object.freeze({ kind: 'unknown', reasonCode: 'RPC_PROVIDER_UNAVAILABLE' }); + } + if (!isExactRefundTransfer(proof, binding)) { + return Object.freeze({ + kind: 'refund_candidate_rejected', + rejectionProof: rejectedRefundProof(proof), + }); + } + const attested = await sellerEvidence.observeRefund(binding); + if (attested.kind !== 'refund_attested') return attested; + return Object.freeze({ + kind: 'refund_attested_and_confirmed', + attestation: attested.attestation, + attestationHash: attested.attestationHash, + rpcTransferProof: refundTransferProof(proof), + }); + }, + }); + const currentSessionId = () => store.readOne(`SELECT id FROM spend_sessions + WHERE state IN ('open','policy_blocked') ORDER BY created_at DESC, id DESC LIMIT 1`)?.id ?? null; + const receiptKey = () => Object.freeze({ + algorithm: signer.algorithm, + keyId: signer.keyId, + publicKeyPem: signer.publicKeyPem, + }); + const operatorReads = Object.freeze({ + async overview() { + const active = policies.active(); + const sessions = sessionRows(store, intents); + const sessionId = currentSessionId(); + return Object.freeze({ + status: 'ready', + deployment: 'simulated', + isolation: 'simulated', + wallet: Object.freeze({ address: WALLET_ADDRESS, network: NETWORK }), + policyVersion: publicPolicy(active, active.id), + sessions: Object.freeze(sessions), + approvals: Object.freeze(approvals.list({ limit: 1_000 }).map(publicApproval)), + receipts: allReceipts(store, receipts), + reconciliations: reconciliationCases(store), + events: normalizedEvents(store), + projection: sessionId === null ? null : exporter.exportSigned({ sessionId }), + }); + }, + async listPolicies() { + const active = policies.active(); + return Object.freeze({ items: Object.freeze(policies.history().map( + (version) => publicPolicy(version, active.id), + )) }); + }, + async walletIdentity() { return Object.freeze({ address: WALLET_ADDRESS }); }, + async listApprovals({ state }) { + return Object.freeze({ + items: Object.freeze(approvals.list({ + limit: 1_000, + ...(state === null ? {} : { state }), + }).map(publicApproval)), + }); + }, + async listReceipts() { return Object.freeze({ items: allReceipts(store, receipts) }); }, + async getReceipt({ receiptId }) { + const row = store.readOne(`SELECT spend_intents.session_id FROM signed_receipts + JOIN spend_intents ON spend_intents.id = signed_receipts.intent_id + WHERE signed_receipts.id = ?`, [receiptId]); + if (!row) return null; + return receipts.list({ sessionId: row.session_id, limit: 1_000 }) + .find((entry) => entry.id === receiptId) ?? null; + }, + async exportSession({ sessionId }) { + return exporter.exportSigned({ sessionId }); + }, + async receiptPublicKey() { return receiptKey(); }, + }); + const authority = { + activePolicy: () => policies.active(), + activeEnrollment: () => enrollments.active(), + bindingsForEnrollment(input) { + return bindingRows(store, intents, input); + }, + walletIdentity: () => Object.freeze({ + provider: 'deterministic-test', + walletId: 'wallet-process-fixture', + address: WALLET_ADDRESS, + network: NETWORK, + }), + operatorAuth, + operatorReads, + agentAuthDependencies: Object.freeze({ store, intents }), + async createKernelDependencies() { + const permitAuthority = createPermitAuthority(); + const walletAdapter = createDeterministicWalletAdapter({ + identity: { + provider: 'deterministic-test', + walletId: 'wallet-process-fixture', + address: WALLET_ADDRESS, + network: NETWORK, + }, + verifyAndConsume: permitAuthority.verifyAndConsume, + async signTypedData(typedData) { + kernelState.incrementSigner(); + return await buyerAccount.signTypedData(typedData); + }, + nowMs: Date.now, + }); + return Object.freeze({ + store, + policies, + enrollments, + intents, + budgets, + approvals, + receipts, + permitAuthority, + walletAdapter, + transport: observedTransport(kernelState), + now, + idFactory: ids, + randomBytes: crypto.randomBytes, + faultInjector() {}, + }); + }, + reconcilerDependencies: Object.freeze({ + store, budgets, receipts, resolver, now, idFactory: ids, + }), + recoveryDependencies: Object.freeze({ store, intents, budgets, approvals, receipts, now }), + recoverySessionCloser(input) { + return store.transaction((tokenValue) => intents.closeBoundSessionInTransaction( + tokenValue, + input, + )); + }, + async waitForUnsignedWork() {}, + async close() { + kernelState.close(); + store.close(); + }, + }; + return Object.freeze(authority); + }, + recoverAuthority: (dependencies) => recoverKernelAuthority(dependencies), + listenOperatorConsole: listener, + listenAgent: listener, + async publishReady(message) { + if (typeof process.send === 'function') process.send(message); + }, + }); +} + +async function serveControlPlane(config) { + const endpoints = Object.freeze({ + agentHost: '127.0.0.1', + agentPort: await reservePort(), + operatorHost: '127.0.0.1', + operatorPort: await reservePort(), + }); + if (endpoints.agentPort === endpoints.operatorPort) { + throw fixtureError('PROCESS_LISTEN', 'ephemeral ports collided'); + } + const env = Object.freeze({ WALLET_KERNEL_MODE: 'deterministic' }); + const plane = await startControlPlane({ + env, + dependencies: buildDependencies(config, endpoints), + }); + let closing = false; + const close = async (code = 0) => { + if (closing) return; + closing = true; + await plane.close(); + if (typeof process.disconnect === 'function' && process.connected) process.disconnect(); + process.exitCode = code; + }; + process.on('message', (message) => { + if (message && typeof message === 'object' && message.type === 'shutdown') void close(0); + }); + process.once('SIGINT', () => { void close(0); }); + process.once('SIGTERM', () => { void close(0); }); +} + +function parseArguments(argv) { + if (argv.length === 3 && argv[0] === '--serve' && argv[1] === '--config') { + return Object.freeze({ mode: 'serve', configPath: argv[2], enrollmentPath: null }); + } + if (argv.length === 5 && argv[0] === '--bootstrap' && argv[1] === '--config' + && argv[3] === '--enrollment') { + return Object.freeze({ mode: 'bootstrap', configPath: argv[2], enrollmentPath: argv[4] }); + } + throw fixtureError('PROCESS_USAGE', 'control-plane fixture usage is invalid'); +} + +try { + const args = parseArguments(process.argv.slice(2)); + const config = loadFixtureConfig(args.configPath); + if (config.sellerOrigin === null || config.sellerOrigin === undefined) { + throw fixtureError('PROCESS_CONFIG_INVALID', 'seller origin is missing'); + } + if (args.mode === 'bootstrap') { + await bootstrap(config, args.enrollmentPath); + if (typeof process.disconnect === 'function' && process.connected) process.disconnect(); + } else { + await serveControlPlane(config); + } +} catch (error) { + const code = typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{0,127}$/u.test(error.code) + ? error.code + : 'CONTROL_PLANE_PROCESS_FAILED'; + if (typeof process.send === 'function') process.send({ type: 'fatal', code }); + process.stderr.write(`${code}\n`); + process.exitCode = 1; + if (typeof process.disconnect === 'function' && process.connected) process.disconnect(); +} diff --git a/spikes/pi-wielder/tests/fixtures/kernel-crash-worker.mjs b/spikes/pi-wielder/tests/fixtures/kernel-crash-worker.mjs new file mode 100644 index 0000000..2473f07 --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/kernel-crash-worker.mjs @@ -0,0 +1,371 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { createAgentEnrollmentRepository } from '../../src/kernel/agent-enrollment.mjs'; +import { createApprovalQueue } from '../../src/kernel/approval-queue.mjs'; +import { acquireAuthorityLock } from '../../src/kernel/authority-lock.mjs'; +import { createAuthorityMutationCoordinator } from '../../src/kernel/authority-mutation-coordinator.mjs'; +import { createPermitAuthority } from '../../src/kernel/authorized-permit.mjs'; +import { createBudgetLedger } from '../../src/kernel/budget-ledger.mjs'; +import { canonicalJson, sha256 } from '../../src/kernel/canonical.mjs'; +import { createIntentRepository } from '../../src/kernel/intent-builder.mjs'; +import { createPolicyRepository } from '../../src/kernel/policy-repository.mjs'; +import { loadOrCreateReceiptSigner } from '../../src/kernel/receipt-signing.mjs'; +import { createSignedReceiptRepository } from '../../src/kernel/signed-receipts.mjs'; +import { openKernelStore } from '../../src/kernel/sqlite-store.mjs'; +import { + createWalletKernel, + KERNEL_FAULT_POINTS, +} from '../../src/kernel/wallet-kernel.mjs'; + +const NOW = '2026-08-01T12:00:00.000Z'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const SELLER = 'https://seller.example'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const BASE_POLICY = JSON.parse(fs.readFileSync( + new URL('../../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); +const PAYLOAD_FIELDS = Object.freeze([ + 'databasePath', + 'directory', + 'receiptKeyPath', + 'signerCountPath', + 'transportCountPath', + 'faultPoint', +]); + +function report(message) { + if (typeof process.send === 'function' && process.connected) process.send(message); + else process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function exactPayload(text) { + const value = JSON.parse(text); + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype + || Reflect.ownKeys(value).length !== PAYLOAD_FIELDS.length + || PAYLOAD_FIELDS.some((field) => !Object.hasOwn(value, field)) + || !KERNEL_FAULT_POINTS.includes(value.faultPoint)) { + throw new Error('crash-worker payload does not match the closed fixture schema'); + } + for (const field of PAYLOAD_FIELDS.filter((name) => name !== 'faultPoint')) { + if (typeof value[field] !== 'string' || !path.isAbsolute(value[field])) { + throw new Error(`crash-worker ${field} must be an absolute path`); + } + } + const realDirectory = fs.realpathSync(value.directory); + for (const field of PAYLOAD_FIELDS.filter( + (name) => name !== 'directory' && name !== 'faultPoint', + )) { + if (fs.realpathSync(path.dirname(value[field])) !== realDirectory) { + throw new Error(`crash-worker ${field} must be a direct child of the trusted directory`); + } + } + return Object.freeze({ ...value }); +} + +function assertOwnerOnlyRegularFile(filePath) { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink() + || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600) { + throw new Error(`${filePath} must be an owner-only regular file`); + } +} + +function updateCounter(filePath, expectedFields, field) { + assertOwnerOnlyRegularFile(filePath); + const value = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype + || Reflect.ownKeys(value).length !== expectedFields.length + || expectedFields.some((name) => !Object.hasOwn(value, name)) + || expectedFields.some((name) => !Number.isSafeInteger(value[name]) || value[name] < 0)) { + throw new Error(`${filePath} counter schema is invalid`); + } + value[field] += 1; + const descriptor = fs.openSync( + filePath, + fs.constants.O_WRONLY | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW, + ); + try { + fs.writeFileSync(descriptor, `${JSON.stringify(value)}\n`, 'utf8'); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + return value[field]; +} + +function waitForRun(timeoutMilliseconds = 15_000) { + return new Promise((resolve, reject) => { + const onMessage = (message) => { + if (!message || message.type !== 'run') return; + clearTimeout(timer); + process.off('message', onMessage); + process.off('disconnect', onDisconnect); + resolve(); + }; + const onDisconnect = () => { + clearTimeout(timer); + process.off('message', onMessage); + reject(new Error('crash-worker parent disconnected before run authorization')); + }; + const timer = setTimeout(() => { + process.off('message', onMessage); + process.off('disconnect', onDisconnect); + reject(new Error('crash-worker timed out waiting for run authorization')); + }, timeoutMilliseconds); + process.on('message', onMessage); + process.once('disconnect', onDisconnect); + }); +} + +function sequenceIds() { + const counts = new Map(); + return (kind) => { + const next = (counts.get(kind) ?? 0) + 1; + counts.set(kind, next); + return `${kind}-${next}`; + }; +} + +function ordinaryRequest(faultPoint) { + return { + requestUrl: `${SELLER}/paid/infer`, + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from(canonicalJson({ faultPoint })), + }; +} + +function paymentRequired() { + return Object.freeze({ + x402Version: 2, + resource: Object.freeze({ + url: `${SELLER}/paid/infer`, + description: 'offline fixture', + mimeType: 'application/json', + }), + accepts: Object.freeze([Object.freeze({ + scheme: 'exact', + network: NETWORK, + asset: ASSET, + amount: '50000', + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: Object.freeze({ name: 'USDC', version: '2' }), + })]), + }); +} + +function signedPaymentPayload(challenge) { + return Object.freeze({ + x402Version: 2, + resource: challenge.resource, + accepted: challenge.accepts[0], + payload: Object.freeze({ + signature: `0x${'11'.repeat(65)}`, + authorization: Object.freeze({ + from: WALLET, + to: PAY_TO, + value: '50000', + validAfter: '0', + validBefore: String(Math.floor(Date.parse(NOW) / 1_000) + 60), + nonce: `0x${'11'.repeat(32)}`, + }), + }), + }); +} + +const payload = exactPayload(process.argv[2]); +const trust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: payload.directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), +}); +let authority; +let store; + +try { + authority = acquireAuthorityLock({ + databasePath: payload.databasePath, + role: 'kernel', + pathTrust: trust, + }); + const signer = loadOrCreateReceiptSigner(payload.receiptKeyPath, { pathTrust: trust }); + store = openKernelStore({ + filePath: payload.databasePath, + pathTrust: trust, + now: () => NOW, + }); + const ids = sequenceIds(); + const policies = createPolicyRepository(store); + const policyVersion = policies.apply(structuredClone(BASE_POLICY), NOW).policyVersion; + const enrollments = createAgentEnrollmentRepository({ store, now: () => NOW }); + enrollments.enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const intents = createIntentRepository({ + store, + idFactory: ids, + now: () => NOW, + routeMetadata: Object.freeze({ + 'paid-infer': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), + }), + }); + const budgets = createBudgetLedger({ store, now: () => NOW }); + const approvals = createApprovalQueue({ store, idFactory: ids, now: () => NOW }); + const receipts = createSignedReceiptRepository({ + store, + signer, + idFactory: ids, + now: () => NOW, + }); + let healthy = true; + const markAuthorityUnhealthy = () => { healthy = false; }; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + if (!healthy) { + const error = new Error('receipt parity is required'); + error.code = 'RECEIPT_PARITY_REQUIRED'; + throw error; + } + }, + markAuthorityUnhealthy, + }); + const challenge = paymentRequired(); + const paymentPayload = signedPaymentPayload(challenge); + const walletAdapter = Object.freeze({ + async walletIdentity() { + updateCounter( + payload.signerCountPath, + ['walletIdentity', 'signX402Exact'], + 'walletIdentity', + ); + return Object.freeze({ + provider: 'deterministic', + walletId: 'wallet-1', + address: WALLET, + network: NETWORK, + }); + }, + async signX402Exact() { + updateCounter( + payload.signerCountPath, + ['walletIdentity', 'signX402Exact'], + 'signX402Exact', + ); + return Object.freeze({ paymentPayload }); + }, + }); + const transport = Object.freeze({ + async probe() { + updateCounter( + payload.transportCountPath, + ['probe', 'encodePayment', 'retryPaid'], + 'probe', + ); + return Object.freeze({ kind: 'payment_required', paymentRequired: challenge }); + }, + encodePayment() { + updateCounter( + payload.transportCountPath, + ['probe', 'encodePayment', 'retryPaid'], + 'encodePayment', + ); + return 'restart-matrix-payment-header'; + }, + async retryPaid({ binding }) { + updateCounter( + payload.transportCountPath, + ['probe', 'encodePayment', 'retryPaid'], + 'retryPaid', + ); + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('restart-matrix-settlement', 'ascii')), + success: true, + transaction: `0x${'ef'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 200, + body: Buffer.from('restart-matrix-ok'), + executionState: 'succeeded', + }); + }, + }); + const kernel = createWalletKernel({ + store, + policies, + enrollments, + intents, + budgets, + approvals, + receipts, + permitAuthority: createPermitAuthority(), + walletAdapter, + transport, + authorityMutationCoordinator: coordinator, + markAuthorityUnhealthy, + now: () => NOW, + idFactory: ids, + randomBytes: (size) => Buffer.alloc(size, 0x11), + faultInjector(point) { + if (point === payload.faultPoint) process.abort(); + }, + }); + const session = await kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: policyVersion.id, + }); + + report({ type: 'ready', faultPoint: payload.faultPoint }); + await waitForRun(); + await kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(payload.faultPoint), + purposeLabel: 'skill.invoke', + correlationId: `restart-${payload.faultPoint}`, + }); + throw new Error(`fault injector did not abort at ${payload.faultPoint}`); +} catch (error) { + report({ + type: 'error', + code: error?.code ?? null, + name: error?.name ?? null, + message: error?.message ?? String(error), + }); + process.exitCode = 1; +} finally { + store?.close(); + authority?.close(); +} diff --git a/spikes/pi-wielder/tests/fixtures/loopback-only-preload.cjs b/spikes/pi-wielder/tests/fixtures/loopback-only-preload.cjs new file mode 100644 index 0000000..b529d33 --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/loopback-only-preload.cjs @@ -0,0 +1,137 @@ +'use strict'; + +const dns = require('node:dns'); +const dnsPromises = require('node:dns/promises'); +const fs = require('node:fs'); +const net = require('node:net'); +const path = require('node:path'); +const tls = require('node:tls'); + +const LOOPBACK = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']); +const LOG_PATH = process.env.WALLET_KERNEL_EGRESS_LOG_FILE; + +function fixtureError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +function openLog() { + if (typeof LOG_PATH !== 'string' || LOG_PATH.length === 0 + || !path.isAbsolute(LOG_PATH) || path.resolve(LOG_PATH) !== LOG_PATH) { + throw fixtureError('EGRESS_LOG_INVALID', 'egress log path must be canonical and absolute'); + } + const descriptor = fs.openSync( + LOG_PATH, + fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW, + ); + try { + const stat = fs.fstatSync(descriptor, { bigint: true }); + const uid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : stat.uid; + if (!stat.isFile() || stat.uid !== uid || (stat.mode & 0o7777n) !== 0o600n + || stat.nlink !== 1n) { + throw fixtureError('EGRESS_LOG_INVALID', 'egress log authority is invalid'); + } + return descriptor; + } catch (error) { + fs.closeSync(descriptor); + throw error; + } +} + +const logDescriptor = openLog(); + +function sanitizedDestination(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > 255) return 'invalid'; + const lowered = value.toLowerCase(); + if (!/^[a-z0-9.:[\]_-]+$/u.test(lowered)) return 'invalid'; + return lowered; +} + +function hostnameFromConnectArguments(args) { + const first = args[0]; + if (first && typeof first === 'object') { + if (typeof first.path === 'string') return 'unix-socket'; + return first.host ?? first.hostname ?? 'localhost'; + } + if (typeof first === 'string') return 'unix-socket'; + return typeof args[1] === 'string' ? args[1] : 'localhost'; +} + +function recordAndThrow(operation, destination) { + const record = JSON.stringify({ + destination: sanitizedDestination(destination), + operation, + }); + fs.writeSync(logDescriptor, `${record}\n`, null, 'utf8'); + throw fixtureError( + 'EXTERNAL_EGRESS_FORBIDDEN', + 'fixture processes may connect only to literal loopback destinations', + ); +} + +function assertLoopback(operation, destination) { + const normalized = sanitizedDestination(destination); + if (!LOOPBACK.has(normalized)) recordAndThrow(operation, normalized); +} + +function wrapConnect(target, name, operation) { + const original = target[name]; + Object.defineProperty(target, name, { + configurable: true, + enumerable: true, + writable: true, + value: function loopbackOnlyConnect(...args) { + assertLoopback(operation, hostnameFromConnectArguments(args)); + return Reflect.apply(original, this, args); + }, + }); +} + +wrapConnect(net, 'connect', 'net.connect'); +wrapConnect(net, 'createConnection', 'net.createConnection'); +wrapConnect(tls, 'connect', 'tls.connect'); + +for (const name of [ + 'lookup', 'resolve', 'resolve4', 'resolve6', 'resolveAny', 'resolveCaa', + 'resolveCname', 'resolveMx', 'resolveNaptr', 'resolveNs', 'resolvePtr', + 'resolveSoa', 'resolveSrv', 'resolveTxt', 'reverse', +]) { + if (typeof dns[name] !== 'function') continue; + const original = dns[name]; + Object.defineProperty(dns, name, { + configurable: true, + enumerable: true, + writable: true, + value: function loopbackOnlyDns(destination, ...args) { + assertLoopback(`dns.${name}`, destination); + return Reflect.apply(original, this, [destination, ...args]); + }, + }); +} + +for (const name of [ + 'lookup', 'resolve', 'resolve4', 'resolve6', 'resolveAny', 'resolveCaa', + 'resolveCname', 'resolveMx', 'resolveNaptr', 'resolveNs', 'resolvePtr', + 'resolveSoa', 'resolveSrv', 'resolveTxt', 'reverse', +]) { + if (typeof dnsPromises[name] !== 'function') continue; + const original = dnsPromises[name]; + Object.defineProperty(dnsPromises, name, { + configurable: true, + enumerable: true, + writable: true, + value: function loopbackOnlyDnsPromise(destination, ...args) { + assertLoopback(`dns.promises.${name}`, destination); + return Reflect.apply(original, this, [destination, ...args]); + }, + }); +} + +process.once('exit', () => { + try { + fs.closeSync(logDescriptor); + } catch { + // Process teardown is already terminal and the log never carries credentials. + } +}); diff --git a/spikes/pi-wielder/tests/fixtures/pi-client-process.mjs b/spikes/pi-wielder/tests/fixtures/pi-client-process.mjs new file mode 100644 index 0000000..39b0d32 --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/pi-client-process.mjs @@ -0,0 +1,227 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); +const PI_BIN = path.resolve(ROOT, 'node_modules/.bin/pi'); +const EXTENSION = path.resolve(ROOT, 'pi-extension/x402.ts'); +const TOTAL_DEADLINE_MS = 30_000; +const TERMINATION_GRACE_MS = 2_000; +const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; + +function fail(code) { + const error = new Error(code); + error.code = code; + throw error; +} + +function canonicalPath(value, label, { executable = false } = {}) { + if (typeof value !== 'string' || !path.isAbsolute(value) + || path.resolve(value) !== value || value.includes('\0')) fail(label); + const stat = fs.lstatSync(value, { bigint: true }); + if (stat.isSymbolicLink() || (executable ? !stat.isFile() : !(stat.isFile() || stat.isDirectory()))) { + fail(label); + } + return value; +} + +function repositoryPiExecutable() { + const link = fs.lstatSync(PI_BIN, { bigint: true }); + const expected = path.resolve( + ROOT, + 'node_modules/@earendil-works/pi-coding-agent/dist/cli.js', + ); + const resolved = fs.realpathSync(PI_BIN); + const target = fs.lstatSync(resolved, { bigint: true }); + if (!link.isSymbolicLink() || resolved !== expected || !target.isFile()) { + fail('PI_EXECUTABLE_INVALID'); + } + return PI_BIN; +} + +function loopbackOrigin(value) { + let parsed; + try { parsed = new URL(value); } catch { fail('PI_KERNEL_ORIGIN_INVALID'); } + if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' + || parsed.port === '' || parsed.pathname !== '/' || parsed.search !== '' + || parsed.hash !== '' || parsed.origin !== value) fail('PI_KERNEL_ORIGIN_INVALID'); + return value; +} + +function boundedToken(value, code) { + if (typeof value !== 'string' || !TOKEN.test(value)) fail(code); + return value; +} + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function childEnvironment() { + const temporaryPiDirectory = canonicalPath( + process.env.WALLET_KERNEL_FIXTURE_PI_DIRECTORY, + 'PI_DIRECTORY_INVALID', + ); + const agentCredentialFile = canonicalPath( + process.env.WALLET_KERNEL_AGENT_CREDENTIAL_FILE, + 'PI_AGENT_CREDENTIAL_PATH', + { executable: true }, + ); + const loopbackOnlyPreload = canonicalPath( + process.env.WALLET_KERNEL_FIXTURE_PRELOAD, + 'PI_PRELOAD_INVALID', + { executable: true }, + ); + const egressLog = canonicalPath( + process.env.WALLET_KERNEL_EGRESS_LOG_FILE, + 'PI_EGRESS_LOG_INVALID', + { executable: true }, + ); + const origin = loopbackOrigin(process.env.WALLET_KERNEL_ORIGIN); + return Object.freeze({ + LANG: 'C.UTF-8', + PATH: path.dirname(process.execPath), + PI_OFFLINE: '1', + PI_CODING_AGENT_DIR: temporaryPiDirectory, + WALLET_KERNEL_ORIGIN: origin, + WALLET_KERNEL_AGENT_CREDENTIAL_FILE: agentCredentialFile, + WALLET_KERNEL_PROVIDER_NAME: boundedToken( + process.env.WALLET_KERNEL_PROVIDER_NAME ?? 'wallet-kernel-e2e', + 'PI_PROVIDER_INVALID', + ), + WALLET_KERNEL_MODEL_NAME: boundedToken( + process.env.WALLET_KERNEL_MODEL_NAME ?? 'scripted-local', + 'PI_MODEL_INVALID', + ), + WALLET_KERNEL_MODEL_ROUTE: boundedToken( + process.env.WALLET_KERNEL_MODEL_ROUTE ?? 'example-model', + 'PI_MODEL_ROUTE_INVALID', + ), + WALLET_KERNEL_SKILL_ROUTE: boundedToken( + process.env.WALLET_KERNEL_SKILL_ROUTE ?? 'example-skill', + 'PI_SKILL_ROUTE_INVALID', + ), + WALLET_KERNEL_EGRESS_LOG_FILE: egressLog, + NODE_OPTIONS: `--require=${loopbackOnlyPreload}`, + }); +} + +function exactVersion(env) { + const result = spawnSync(PI_BIN, ['--version'], { + cwd: ROOT, + env, + encoding: 'utf8', + timeout: 5_000, + }); + if (result.status !== 0 || result.signal !== null + || result.stderr !== '' || result.stdout.trim() !== '0.80.6') { + fail('PI_VERSION_MISMATCH'); + } + return '0.80.6'; +} + +function terminateGroup(child, signal) { + if (!child.pid) return; + try { + process.kill(-child.pid, signal); + } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } +} + +async function runPi() { + repositoryPiExecutable(); + canonicalPath(EXTENSION, 'PI_EXTENSION_INVALID', { executable: true }); + const env = childEnvironment(); + const piVersion = exactVersion(env); + const scriptedPrompt = 'Use invoke_skill once with input commercial acceptance, then report the final result.'; + const child = spawn(PI_BIN, [ + '-p', scriptedPrompt, + '--no-session', + '--no-context-files', + '--no-skills', + '--no-prompt-templates', + '--no-themes', + '--no-extensions', + '--no-builtin-tools', + '--no-approve', + '--offline', + '-e', EXTENSION, + '--provider', 'wallet-kernel-e2e', + '--model', 'scripted-local', + ], { + cwd: ROOT, + env, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + let stdoutBytes = 0; + let stderrBytes = 0; + const capture = (chunks, maximum, kind) => (chunk) => { + const current = kind === 'stdout' ? stdoutBytes : stderrBytes; + const next = current + chunk.length; + if (next > maximum) { + terminateGroup(child, 'SIGTERM'); + return; + } + chunks.push(chunk); + if (kind === 'stdout') stdoutBytes = next; + else stderrBytes = next; + }; + child.stdout.on('data', capture(stdout, 2_097_152, 'stdout')); + child.stderr.on('data', capture(stderr, 1_048_576, 'stderr')); + + let deadline; + let grace; + let deadlineExpired = false; + const result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve({ code, signal })); + deadline = setTimeout(() => { + deadlineExpired = true; + terminateGroup(child, 'SIGTERM'); + grace = setTimeout(() => terminateGroup(child, 'SIGKILL'), TERMINATION_GRACE_MS); + grace.unref(); + }, TOTAL_DEADLINE_MS); + deadline.unref(); + }); + clearTimeout(deadline); + clearTimeout(grace); + const stdoutBytesValue = Buffer.concat(stdout); + const stderrBytesValue = Buffer.concat(stderr); + const output = new TextDecoder('utf-8', { fatal: false }).decode(stdoutBytesValue); + const outputObserved = output.includes('PI_WALLET_OK') ? 'PI_WALLET_OK' : 'missing'; + const success = !deadlineExpired && result.code === 0 && result.signal === null + && outputObserved === 'PI_WALLET_OK'; + const message = Object.freeze({ + type: 'result', + exitCode: success ? 0 : (deadlineExpired ? 124 : (result.code ?? 1)), + signal: result.signal, + piVersion, + outputObserved, + stdoutHash: sha256(stdoutBytesValue), + stderrHash: sha256(stderrBytesValue), + failureCode: success ? null : (deadlineExpired ? 'PROCESS_DEADLINE' : 'PI_PROCESS_FAILED'), + }); + stdoutBytesValue.fill(0); + stderrBytesValue.fill(0); + for (const chunk of stdout) chunk.fill(0); + for (const chunk of stderr) chunk.fill(0); + if (typeof process.send === 'function') process.send(message); + if (!success) process.stderr.write(`${message.failureCode}\n`); + process.exitCode = message.exitCode; + if (typeof process.disconnect === 'function' && process.connected) process.disconnect(); +} + +try { + await runPi(); +} catch (error) { + const code = typeof error?.code === 'string' ? error.code : 'PI_PROCESS_FAILED'; + if (typeof process.send === 'function') process.send({ type: 'fatal', code }); + process.stderr.write(`${code}\n`); + process.exitCode = 1; + if (typeof process.disconnect === 'function' && process.connected) process.disconnect(); +} diff --git a/spikes/pi-wielder/tests/fixtures/pi-model-process.mjs b/spikes/pi-wielder/tests/fixtures/pi-model-process.mjs new file mode 100644 index 0000000..2deb699 --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/pi-model-process.mjs @@ -0,0 +1,201 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; + +const MAXIMUM_BODY_BYTES = 1_048_576; +const STATE_FILE = process.env.WALLET_KERNEL_FIXTURE_STATE_FILE; + +function fatal(code) { + if (typeof process.send === 'function') process.send({ type: 'fatal', code }); + process.exitCode = 1; +} + +function requireStateFile(filePath) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath) + || path.resolve(filePath) !== filePath) { + throw Object.assign(new Error('model state path is invalid'), { code: 'FIXTURE_CONFIG' }); + } + const descriptor = fs.openSync( + filePath, + fs.constants.O_RDWR | fs.constants.O_NOFOLLOW, + ); + const stat = fs.fstatSync(descriptor, { bigint: true }); + const uid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : stat.uid; + if (!stat.isFile() || stat.uid !== uid || (stat.mode & 0o7777n) !== 0o600n + || stat.nlink !== 1n || stat.size > 65_536n) { + fs.closeSync(descriptor); + throw Object.assign(new Error('model state authority is invalid'), { + code: 'FIXTURE_AUTHORITY', + }); + } + return descriptor; +} + +const stateDescriptor = requireStateFile(STATE_FILE); +let state = Object.freeze({ + requestCount: 0, + forbiddenAuthorityHeaderCount: 0, + requestHashes: Object.freeze([]), + toolResultObserved: false, +}); + +function persistState() { + const bytes = Buffer.from(`${JSON.stringify(state)}\n`, 'utf8'); + fs.ftruncateSync(stateDescriptor, 0); + fs.writeSync(stateDescriptor, bytes, 0, bytes.length, 0); + fs.fsyncSync(stateDescriptor); + bytes.fill(0); +} + +function forbiddenAuthorityHeaderCount(request) { + const forbidden = [ + 'authorization', 'cookie', 'payment-required', 'payment-signature', + 'payment-response', 'x-approval-id', 'x-idempotency-key', 'x-session-id', + 'x-spend-session', 'x-wallet-address', 'x-wallet-policy', + ]; + return forbidden.reduce((count, name) => count + (request.headers[name] === undefined ? 0 : 1), 0); +} + +async function readBody(request) { + const chunks = []; + let total = 0; + for await (const chunk of request) { + total += chunk.length; + if (total > MAXIMUM_BODY_BYTES) { + throw Object.assign(new Error('model request exceeded its bound'), { + code: 'FIXTURE_BODY_TOO_LARGE', + }); + } + chunks.push(chunk); + } + const bytes = Buffer.concat(chunks); + let parsed; + try { + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch { + throw Object.assign(new Error('model request is malformed'), { code: 'FIXTURE_BODY' }); + } + return Object.freeze({ bytes, parsed }); +} + +function chunk(id, delta, finishReason = null) { + return JSON.stringify({ + id, + object: 'chat.completion.chunk', + created: 1_785_600_000, + model: 'scripted-local', + choices: [{ index: 0, delta, finish_reason: finishReason }], + }); +} + +function sendStreaming(response, ordinal) { + const id = `chatcmpl-wallet-kernel-${ordinal}`; + response.writeHead(200, { + 'cache-control': 'no-store', + connection: 'close', + 'content-type': 'text/event-stream; charset=utf-8', + 'x-content-type-options': 'nosniff', + }); + if (ordinal === 1) { + response.write(`data: ${chunk(id, { + role: 'assistant', + tool_calls: [{ + index: 0, + id: 'call_invoke_skill_once', + type: 'function', + function: { + name: 'invoke_skill', + arguments: '{"input":"commercial acceptance"}', + }, + }], + })}\n\n`); + response.write(`data: ${chunk(id, {}, 'tool_calls')}\n\n`); + } else { + response.write(`data: ${chunk(id, { role: 'assistant', content: 'PI_WALLET_OK' })}\n\n`); + response.write(`data: ${chunk(id, {}, 'stop')}\n\n`); + } + response.end('data: [DONE]\n\n'); +} + +function sendJson(response, status, value) { + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json', + 'x-content-type-options': 'nosniff', + }); + response.end(JSON.stringify(value)); +} + +const server = http.createServer(async (request, response) => { + try { + if (request.method !== 'POST' + || !new Set(['/chat/completions', '/v1/chat/completions']).has(request.url)) { + sendJson(response, 404, { error: { code: 'MODEL_ROUTE_NOT_FOUND' } }); + return; + } + if (request.headers['content-type'] !== 'application/json') { + sendJson(response, 415, { error: { code: 'MODEL_CONTENT_TYPE' } }); + return; + } + const authorityHeaders = forbiddenAuthorityHeaderCount(request); + const { bytes, parsed } = await readBody(request); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) + || parsed.model !== 'scripted-local' || parsed.stream !== true + || !Array.isArray(parsed.messages)) { + sendJson(response, 400, { error: { code: 'MODEL_REQUEST_SCHEMA' } }); + return; + } + const ordinal = state.requestCount + 1; + const toolResultObserved = ordinal > 1 && parsed.messages.some((message) => ( + message && typeof message === 'object' && message.role === 'tool' + )); + state = Object.freeze({ + requestCount: ordinal, + forbiddenAuthorityHeaderCount: state.forbiddenAuthorityHeaderCount + authorityHeaders, + requestHashes: Object.freeze([ + ...state.requestHashes, + `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`, + ]), + toolResultObserved: state.toolResultObserved || toolResultObserved, + }); + persistState(); + sendStreaming(response, ordinal); + } catch (error) { + sendJson(response, error?.code === 'FIXTURE_BODY_TOO_LARGE' ? 413 : 400, { + error: { code: typeof error?.code === 'string' ? error.code : 'MODEL_REQUEST_FAILED' }, + }); + } +}); + +let closing = false; +async function close(code = 0) { + if (closing) return; + closing = true; + await new Promise((resolve) => server.close(resolve)); + fs.closeSync(stateDescriptor); + if (typeof process.disconnect === 'function' && process.connected) process.disconnect(); + process.exitCode = code; +} + +process.on('message', (message) => { + if (message && typeof message === 'object' && message.type === 'shutdown') { + void close(0); + } +}); +process.once('SIGINT', () => { void close(0); }); +process.once('SIGTERM', () => { void close(0); }); + +server.once('error', (error) => { + fatal(typeof error?.code === 'string' ? error.code : 'MODEL_LISTEN_FAILED'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + fatal('MODEL_LISTEN_FAILED'); + return; + } + if (typeof process.send === 'function') { + process.send({ type: 'ready', origin: `http://127.0.0.1:${address.port}` }); + } +}); diff --git a/spikes/pi-wielder/tests/fixtures/x402-v2-resource.mjs b/spikes/pi-wielder/tests/fixtures/x402-v2-resource.mjs new file mode 100644 index 0000000..f5579c9 --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/x402-v2-resource.mjs @@ -0,0 +1,141 @@ +import crypto from 'node:crypto'; + +import { + encodePaymentRequiredHeader, + encodePaymentResponseHeader, + encodePaymentSignatureHeader, +} from '@x402/core/http'; + +function deepFreeze(value, seen = new WeakSet()) { + if (value && typeof value === 'object' && !seen.has(value)) { + seen.add(value); + for (const key of Reflect.ownKeys(value)) deepFreeze(value[key], seen); + Object.freeze(value); + } + return value; +} + +function digestAscii(value) { + return `sha256:${crypto.createHash('sha256').update(Buffer.from(value, 'ascii')).digest('hex')}`; +} + +export const PAYMENT_REQUIRED = deepFreeze({ + x402Version: 2, + error: 'Payment required', + resource: { + url: 'https://seller.example/paid/infer', + description: 'offline fixture', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + amount: '50000', + payTo: '0x2000000000000000000000000000000000000000', + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], +}); + +export const PAYMENT_PAYLOAD = deepFreeze({ + x402Version: 2, + resource: { + url: PAYMENT_REQUIRED.resource.url, + description: PAYMENT_REQUIRED.resource.description, + mimeType: PAYMENT_REQUIRED.resource.mimeType, + }, + accepted: PAYMENT_REQUIRED.accepts[0], + payload: { + signature: `0x${'11'.repeat(65)}`, + authorization: { + from: '0x1000000000000000000000000000000000000000', + to: PAYMENT_REQUIRED.accepts[0].payTo, + value: PAYMENT_REQUIRED.accepts[0].amount, + validAfter: '0', + validBefore: '1785502860', + nonce: `0x${'01'.repeat(32)}`, + }, + }, +}); + +export const PAYMENT_RESPONSE = deepFreeze({ + success: true, + transaction: `0x${'AB'.repeat(32)}`, + network: PAYMENT_REQUIRED.accepts[0].network, + payer: PAYMENT_PAYLOAD.payload.authorization.from.toUpperCase().replace('0X', '0x'), + amount: PAYMENT_REQUIRED.accepts[0].amount, +}); + +export const PAYMENT_REQUIRED_HEADER = encodePaymentRequiredHeader(PAYMENT_REQUIRED); +export const PAYMENT_SIGNATURE_HEADER = encodePaymentSignatureHeader(PAYMENT_PAYLOAD); +export const PAYMENT_RESPONSE_HEADER = encodePaymentResponseHeader(PAYMENT_RESPONSE); +export const PAYMENT_HASH = digestAscii(PAYMENT_SIGNATURE_HEADER); + +export const SETTLEMENT = deepFreeze({ + source: 'x402-payment-response', + headerHash: digestAscii(PAYMENT_RESPONSE_HEADER), + success: true, + transaction: PAYMENT_RESPONSE.transaction.toLowerCase(), + network: PAYMENT_RESPONSE.network, + payer: PAYMENT_PAYLOAD.payload.authorization.from, + amountAtomic: PAYMENT_RESPONSE.amount, + paymentHash: PAYMENT_HASH, +}); + +export const SETTLEMENT_FIXTURE = SETTLEMENT; + +function requestBodyBytes(body) { + if (body === undefined || body === null) return Buffer.alloc(0); + if (typeof body === 'string') return Buffer.from(body); + if (body instanceof Uint8Array) return Buffer.from(body); + throw new TypeError('fixture received an unsupported request body'); +} + +export function createResourceFetch({ + status = 200, + body = Buffer.from('{"ok":true}'), + paymentResponseHeader = PAYMENT_RESPONSE_HEADER, + challengeBody = Buffer.alloc(0), +} = {}) { + const calls = []; + const fetchImpl = async (url, init = {}) => { + const headers = new Headers(init.headers); + calls.push(Object.freeze({ + url: String(url), + method: init.method, + redirect: init.redirect, + credentials: init.credentials, + headers: Object.freeze(Object.fromEntries(headers.entries())), + bodyBytes: requestBodyBytes(init.body), + })); + + if (calls.length === 1) { + if (headers.has('payment-signature')) { + throw new Error('unpaid fixture request carried PAYMENT-SIGNATURE'); + } + return new Response(challengeBody, { + status: 402, + headers: { 'PAYMENT-REQUIRED': PAYMENT_REQUIRED_HEADER }, + }); + } + if (calls.length === 2) { + if (headers.get('payment-signature') !== PAYMENT_SIGNATURE_HEADER) { + throw new Error('paid fixture request did not carry the exact signature header'); + } + return new Response(body, { + status, + headers: paymentResponseHeader === null + ? {} + : { 'PAYMENT-RESPONSE': paymentResponseHeader }, + }); + } + throw new Error('x402 fixture forbids a third request'); + }; + + return Object.freeze({ + fetchImpl, + calls, + callCount: () => calls.length, + }); +} diff --git a/spikes/pi-wielder/tests/fixtures/x402-v2-seller-process.mjs b/spikes/pi-wielder/tests/fixtures/x402-v2-seller-process.mjs new file mode 100644 index 0000000..12c2d27 --- /dev/null +++ b/spikes/pi-wielder/tests/fixtures/x402-v2-seller-process.mjs @@ -0,0 +1,579 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; + +import { + decodePaymentSignatureHeader, + encodePaymentRequiredHeader, + encodePaymentResponseHeader, +} from '@x402/core/http'; +import { keccak256, toBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { canonicalJson, sha256 } from '../../src/kernel/canonical.mjs'; + +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const REFUND_SOURCE = '0x5000000000000000000000000000000000000000'; +const MAXIMUM_BODY_BYTES = 1_048_576; +const EVIDENCE_PATH = '/.well-known/wallet-kernel/evidence'; +const STATE_FILE = process.env.WALLET_KERNEL_FIXTURE_STATE_FILE; +const MODEL_ORIGIN = process.env.WALLET_KERNEL_FIXTURE_MODEL_ORIGIN; + +const executionAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-seller-execution-test-only')), +); +const refundAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-seller-refund-test-only')), +); + +function derivedTransaction(label) { + return `0x${crypto.createHash('sha256').update(label, 'utf8').digest('hex')}`; +} + +const WRONG_REFUND_TRANSACTION_ID = derivedTransaction('wallet-kernel-e2e:refund:wrong'); + +function refundTransactionIdFor(originalTransactionId) { + return derivedTransaction(`wallet-kernel-e2e:refund:confirmed:${originalTransactionId}`); +} + +function publicTransactions() { + const paymentTransactionIds = [...new Set(state.transactionIds)]; + return Object.freeze({ + payments: Object.freeze(paymentTransactionIds.map((paymentTransactionId) => Object.freeze({ + paymentTransactionId, + refundTransactionId: refundTransactionIdFor(paymentTransactionId), + }))), + wrongRefundTransactionId: WRONG_REFUND_TRANSACTION_ID, + }); +} + +function fixtureError(code, message) { + return Object.assign(new Error(message), { code }); +} + +function requireStateFile(filePath) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath) + || path.resolve(filePath) !== filePath) { + throw fixtureError('FIXTURE_CONFIG', 'seller state path is invalid'); + } + const descriptor = fs.openSync(filePath, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW); + const stat = fs.fstatSync(descriptor, { bigint: true }); + const uid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : stat.uid; + if (!stat.isFile() || stat.uid !== uid || (stat.mode & 0o7777n) !== 0o600n + || stat.nlink !== 1n || stat.size > 262_144n) { + fs.closeSync(descriptor); + throw fixtureError('FIXTURE_AUTHORITY', 'seller state authority is invalid'); + } + return descriptor; +} + +function validateModelOrigin(value) { + let parsed; + try { parsed = new URL(value); } catch { + throw fixtureError('FIXTURE_CONFIG', 'model origin is invalid'); + } + if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' + || parsed.port === '' || parsed.pathname !== '/' || parsed.search !== '' + || parsed.hash !== '' || parsed.origin !== value) { + throw fixtureError('FIXTURE_CONFIG', 'model origin must be exact IPv4 loopback'); + } + return value; +} + +const stateDescriptor = requireStateFile(STATE_FILE); +const modelOrigin = validateModelOrigin(MODEL_ORIGIN); +let sellerOrigin; +const evidenceResponses = new Map(); +let state = Object.freeze({ + requestCount: 0, + unpaidRequestCount: 0, + paidRequestCount: 0, + paymentSignatureCount: 0, + duplicatePaymentSignatureCount: 0, + forbiddenForwardedHeaderCount: 0, + pathCounts: Object.freeze({}), + paymentHeaderHashes: Object.freeze([]), + transactionIds: Object.freeze([]), + payments: Object.freeze({}), + evidenceRequestCount: 0, + refundProofRequestCount: 0, +}); + +function persistState() { + const bytes = Buffer.from(`${JSON.stringify(state)}\n`, 'utf8'); + fs.ftruncateSync(stateDescriptor, 0); + fs.writeSync(stateDescriptor, bytes, 0, bytes.length, 0); + fs.fsyncSync(stateDescriptor); + bytes.fill(0); +} + +function nextState(patch) { + state = Object.freeze({ ...state, ...patch }); + persistState(); +} + +function rawHeaderCount(request, target) { + let count = 0; + for (let index = 0; index < request.rawHeaders.length; index += 2) { + if (request.rawHeaders[index].toLowerCase() === target) count += 1; + } + return count; +} + +function forbiddenHeaderCount(request) { + const forbidden = new Set([ + 'authorization', 'cookie', 'cookie2', 'forwarded', 'proxy-authorization', + 'x-approval-id', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', + 'x-idempotency-key', 'x-session-id', 'x-spend-session', 'x-wallet-address', + 'x-wallet-policy', + ]); + return Object.keys(request.headers).reduce((count, name) => ( + forbidden.has(name) ? count + 1 : count + ), 0); +} + +async function readBody(request) { + const chunks = []; + let total = 0; + for await (const chunk of request) { + total += chunk.length; + if (total > MAXIMUM_BODY_BYTES) throw fixtureError('FIXTURE_BODY_TOO_LARGE', 'body too large'); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function routeDescription(pathname) { + if (pathname === '/paid/chat/completions') return 'Wallet Kernel e2e model route'; + if (pathname === '/paid/skill') return 'Wallet Kernel e2e Skill route'; + return `Wallet Kernel e2e ${pathname.slice(pathname.lastIndexOf('/') + 1)} route`; +} + +function scenarioFor(pathname) { + if (pathname === '/paid/chat/completions') return 'model'; + if (pathname === '/paid/skill') return 'skill'; + if (pathname.startsWith('/paid/scenario/')) return pathname.slice('/paid/scenario/'.length); + if (pathname.startsWith('/untrusted/')) return 'untrusted'; + return null; +} + +function amountFor(scenario) { + if (scenario === 'over-budget') return '600000'; + if (new Set(['approval', 'approval-model', 'changed-challenge']).has(scenario)) { + return '200000'; + } + return '50000'; +} + +function challengeFor(pathname, scenario) { + const pathCount = state.pathCounts[pathname] ?? 0; + const changed = scenario === 'changed-challenge' && pathCount >= 2; + const amount = changed ? '200001' : amountFor(scenario); + return Object.freeze({ + x402Version: 2, + error: 'Payment required', + resource: Object.freeze({ + url: `${sellerOrigin}${pathname}`, + description: routeDescription(pathname), + mimeType: 'application/json', + }), + accepts: Object.freeze([Object.freeze({ + scheme: 'exact', + network: NETWORK, + asset: ASSET, + amount, + payTo: scenario === 'untrusted' + ? '0x9000000000000000000000000000000000000000' + : PAY_TO, + maxTimeoutSeconds: 60, + extra: Object.freeze({ name: 'USDC', version: '2' }), + })]), + }); +} + +function sendJson(response, status, value, headers = {}) { + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json', + 'x-content-type-options': 'nosniff', + ...headers, + }); + response.end(JSON.stringify(value)); +} + +function sendChallenge(response, challenge) { + sendJson(response, 402, { error: 'PAYMENT_REQUIRED' }, { + 'payment-required': encodePaymentRequiredHeader(challenge), + }); +} + +function capturePayment(request, challenge, pathname) { + if (rawHeaderCount(request, 'payment-signature') !== 1) { + throw fixtureError('PAYMENT_SIGNATURE_COUNT', 'paid request requires exactly one signature'); + } + const rawHeader = request.headers['payment-signature']; + if (typeof rawHeader !== 'string') { + throw fixtureError('PAYMENT_SIGNATURE_INVALID', 'payment signature is invalid'); + } + let payment; + try { payment = decodePaymentSignatureHeader(rawHeader); } catch { + throw fixtureError('PAYMENT_SIGNATURE_INVALID', 'payment signature is invalid'); + } + const accepted = payment?.accepted; + const authorization = payment?.payload?.authorization; + if (payment?.x402Version !== 2 + || payment?.resource?.url !== challenge.resource.url + || accepted?.scheme !== 'exact' + || accepted?.network !== NETWORK + || accepted?.asset?.toLowerCase() !== ASSET + || accepted?.amount !== challenge.accepts[0].amount + || accepted?.payTo?.toLowerCase() !== PAY_TO + || authorization?.from?.toLowerCase() === undefined + || authorization?.to?.toLowerCase() !== PAY_TO + || authorization?.value !== challenge.accepts[0].amount + || typeof authorization?.nonce !== 'string') { + throw fixtureError('PAYMENT_SIGNATURE_BINDING', 'payment signature binding is invalid'); + } + const paymentHeaderHash = sha256(Buffer.from(rawHeader, 'ascii')); + const duplicate = state.paymentHeaderHashes.includes(paymentHeaderHash); + const transactionId = derivedTransaction( + `wallet-kernel-e2e:${pathname}:${paymentHeaderHash}`, + ); + const record = Object.freeze({ + amountAtomic: accepted.amount, + authorizationNonce: authorization.nonce.toLowerCase(), + from: authorization.from.toLowerCase(), + observedAt: new Date().toISOString(), + paymentHeaderHash, + to: authorization.to.toLowerCase(), + transactionId, + }); + nextState({ + paidRequestCount: state.paidRequestCount + 1, + paymentSignatureCount: state.paymentSignatureCount + 1, + duplicatePaymentSignatureCount: state.duplicatePaymentSignatureCount + (duplicate ? 1 : 0), + paymentHeaderHashes: Object.freeze([...state.paymentHeaderHashes, paymentHeaderHash]), + transactionIds: Object.freeze([...state.transactionIds, transactionId]), + payments: Object.freeze({ ...state.payments, [transactionId]: record }), + }); + return record; +} + +function paymentResponse(payment, success = true) { + return encodePaymentResponseHeader(success + ? { + success: true, + transaction: payment.transactionId, + network: NETWORK, + payer: payment.from, + amount: payment.amountAtomic, + } + : { + success: false, + transaction: payment.transactionId, + network: NETWORK, + errorReason: 'fixture rejection', + }); +} + +async function forwardModel(body, response, settlementHeader) { + const upstream = await fetch(`${modelOrigin}/chat/completions`, { + method: 'POST', + redirect: 'manual', + credentials: 'omit', + headers: { accept: 'text/event-stream', 'content-type': 'application/json' }, + body, + }); + const bytes = Buffer.from(await upstream.arrayBuffer()); + const headers = { + 'cache-control': 'no-store', + 'content-type': upstream.headers.get('content-type') ?? 'text/event-stream; charset=utf-8', + 'x-content-type-options': 'nosniff', + }; + if (typeof settlementHeader === 'string') headers['payment-response'] = settlementHeader; + response.writeHead(upstream.status, headers); + response.end(bytes); +} + +function rpcProof(payment, transactionId) { + return Object.freeze({ + jsonrpc: '2.0', + id: 1, + result: Object.freeze({ + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId, + blockHash: derivedTransaction(`block:${transactionId}`), + blockNumber: '1234571', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 4, + authorizationLogIndex: 5, + tokenContract: ASSET, + from: payment.from, + to: payment.to, + valueAtomic: payment.amountAtomic, + authorizationNonce: payment.authorizationNonce, + observedAt: payment.observedAt, + }), + }); +} + +async function evidenceResponse(body) { + let request; + try { request = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)); } catch { + throw fixtureError('EVIDENCE_REQUEST_INVALID', 'evidence request is malformed'); + } + if (request?.schemaVersion !== 1 || request?.sellerOrigin !== sellerOrigin + || typeof request?.intentHash !== 'string') { + throw fixtureError('EVIDENCE_REQUEST_INVALID', 'evidence request binding is invalid'); + } + const cacheKey = canonicalJson(request); + const cached = evidenceResponses.get(cacheKey); + if (cached) return cached; + const issuedAtMs = Date.now() - 1_000; + const issuedAt = new Date(issuedAtMs).toISOString(); + const expiresAt = new Date(issuedAtMs + 10 * 60_000).toISOString(); + let unsigned; + let account; + if (request.kind === 'execution' && typeof request.transactionId === 'string') { + const payment = state.payments[request.transactionId]; + if (!payment) throw fixtureError('EVIDENCE_NOT_FOUND', 'payment is unknown'); + unsigned = { + schemaVersion: 1, + domain: 'wallet-kernel.execution.v1', + network: NETWORK, + sellerOrigin, + intentHash: request.intentHash, + transactionId: request.transactionId, + outcome: 'succeeded', + httpStatus: 200, + responseHash: sha256(Buffer.from('{"fixture":"execution-observed"}', 'utf8')), + issuedAt, + expiresAt, + signer: executionAccount.address.toLowerCase(), + }; + account = executionAccount; + } else if (request.kind === 'refund' + && typeof request.originalTransactionId === 'string' + && request.refundTransactionId === refundTransactionIdFor(request.originalTransactionId)) { + const payment = state.payments[request.originalTransactionId]; + if (!payment) throw fixtureError('EVIDENCE_NOT_FOUND', 'original payment is unknown'); + unsigned = { + schemaVersion: 1, + domain: 'wallet-kernel.refund.v1', + network: NETWORK, + sellerOrigin, + intentHash: request.intentHash, + originalTransactionId: request.originalTransactionId, + refundTransactionId: request.refundTransactionId, + asset: ASSET, + originalPayer: payment.from, + originalPayee: payment.to, + refundSource: REFUND_SOURCE, + amountAtomic: payment.amountAtomic, + issuedAt, + expiresAt, + signer: refundAccount.address.toLowerCase(), + }; + account = refundAccount; + } else { + throw fixtureError('EVIDENCE_NOT_FOUND', 'evidence is unavailable'); + } + const signature = await account.signMessage({ + message: { raw: Buffer.from(canonicalJson(unsigned), 'utf8') }, + }); + const evidence = Object.freeze({ ...unsigned, signature }); + evidenceResponses.set(cacheKey, evidence); + return evidence; +} + +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, sellerOrigin ?? 'http://127.0.0.1'); + if (url.search !== '' || url.hash !== '') { + sendJson(response, 400, { error: { code: 'FIXTURE_URL_INVALID' } }); + return; + } + if (request.method === 'GET' && url.pathname === '/fixture/v1/public-transactions') { + sendJson(response, 200, publicTransactions()); + return; + } + if (request.method === 'GET' && url.pathname.startsWith('/fixture/v1/payment-proof/')) { + const transactionId = url.pathname.slice('/fixture/v1/payment-proof/'.length); + const payment = state.payments[transactionId]; + if (!payment) { + sendJson(response, 404, { error: { code: 'FIXTURE_PAYMENT_NOT_FOUND' } }); + return; + } + sendJson(response, 200, rpcProof(payment, transactionId)); + return; + } + if (request.method === 'GET' && url.pathname.startsWith('/fixture/v1/refund-proof/')) { + const transactionId = url.pathname.slice('/fixture/v1/refund-proof/'.length); + nextState({ refundProofRequestCount: state.refundProofRequestCount + 1 }); + if (transactionId === WRONG_REFUND_TRANSACTION_ID) { + const firstPayment = state.payments[state.transactionIds[0]]; + sendJson(response, 200, rpcProof({ + ...firstPayment, + amountAtomic: '1', + from: REFUND_SOURCE, + to: firstPayment?.from ?? '0x1000000000000000000000000000000000000000', + }, transactionId)); + return; + } + const originalTransactionId = Object.keys(state.payments).find( + (candidate) => refundTransactionIdFor(candidate) === transactionId, + ); + if (originalTransactionId !== undefined) { + const payment = state.payments[originalTransactionId]; + sendJson(response, 200, rpcProof({ + ...payment, + from: REFUND_SOURCE, + to: payment.from, + }, transactionId)); + return; + } + sendJson(response, 404, { error: { code: 'FIXTURE_REFUND_NOT_FOUND' } }); + return; + } + if (request.method === 'POST' && url.pathname === EVIDENCE_PATH) { + const body = await readBody(request); + nextState({ evidenceRequestCount: state.evidenceRequestCount + 1 }); + try { + sendJson(response, 200, await evidenceResponse(body)); + } catch (error) { + sendJson(response, error?.code === 'EVIDENCE_NOT_FOUND' ? 404 : 400, { + error: { code: typeof error?.code === 'string' ? error.code : 'EVIDENCE_FAILED' }, + }); + } + return; + } + + const scenario = scenarioFor(url.pathname); + if (request.method !== 'POST' || scenario === null) { + sendJson(response, 404, { error: { code: 'FIXTURE_ROUTE_NOT_FOUND' } }); + return; + } + const body = await readBody(request); + const signatureCount = rawHeaderCount(request, 'payment-signature'); + const pathCount = (state.pathCounts[url.pathname] ?? 0) + 1; + nextState({ + requestCount: state.requestCount + 1, + forbiddenForwardedHeaderCount: state.forbiddenForwardedHeaderCount + + forbiddenHeaderCount(request), + pathCounts: Object.freeze({ ...state.pathCounts, [url.pathname]: pathCount }), + ...(signatureCount === 0 ? { unpaidRequestCount: state.unpaidRequestCount + 1 } : {}), + }); + const challenge = challengeFor(url.pathname, scenario); + if (signatureCount === 0) { + if (scenario === 'free-model') { + await forwardModel(body, response, null); + return; + } + sendChallenge(response, challenge); + return; + } + const payment = capturePayment(request, challenge, url.pathname); + const settlementHeader = paymentResponse(payment, scenario !== 'success-false'); + + if (scenario === 'pre-header-loss' || scenario === 'trusted-settlement') { + request.socket.destroy(); + return; + } + if (scenario === 'delayed') { + const timer = setTimeout(() => sendJson(response, 200, { delayed: true }, { + 'payment-response': settlementHeader, + }), 10_000); + timer.unref(); + return; + } + if (scenario === 'second-402') { + sendChallenge(response, challenge); + return; + } + if (scenario === 'malformed-settlement') { + sendJson(response, 200, { malformed: true }, { 'payment-response': 'not-base64' }); + return; + } + if (scenario === 'success-false' || scenario === 'explicit-rejection') { + sendJson(response, 402, { rejected: true }, { 'payment-response': paymentResponse(payment, false) }); + return; + } + if (scenario === 'body-loss' || scenario === 'delivery-loss') { + response.writeHead(200, { + 'content-length': '128', + 'content-type': 'application/json', + 'payment-response': settlementHeader, + }); + response.flushHeaders(); + response.write('{"partial":'); + const timer = setTimeout(() => response.destroy(), 25); + timer.unref(); + return; + } + const status = new Map([ + ['settled-302', 302], ['settled-404', 404], ['settled-500', 500], + ]).get(scenario) ?? 200; + if (scenario === 'model' || scenario === 'approval-model') { + await forwardModel(body, response, settlementHeader); + return; + } + sendJson(response, status, scenario === 'skill' + ? { output: 'SKILL_TOOL_OK' } + : { scenario, settled: true }, { + 'payment-response': settlementHeader, + ...(status === 302 ? { location: 'https://external.invalid/forbidden-redirect' } : {}), + }); + } catch (error) { + if (!response.headersSent) { + sendJson(response, error?.code === 'FIXTURE_BODY_TOO_LARGE' ? 413 : 400, { + error: { code: typeof error?.code === 'string' ? error.code : 'FIXTURE_REQUEST_FAILED' }, + }); + } else { + response.destroy(); + } + } +}); + +let closing = false; +async function close(code = 0) { + if (closing) return; + closing = true; + await new Promise((resolve) => server.close(resolve)); + fs.closeSync(stateDescriptor); + if (typeof process.disconnect === 'function' && process.connected) process.disconnect(); + process.exitCode = code; +} + +process.on('message', (message) => { + if (message && typeof message === 'object' && message.type === 'shutdown') void close(0); +}); +process.once('SIGINT', () => { void close(0); }); +process.once('SIGTERM', () => { void close(0); }); +server.once('error', (error) => { + if (typeof process.send === 'function') { + process.send({ type: 'fatal', code: typeof error?.code === 'string' ? error.code : 'SELLER_LISTEN_FAILED' }); + } + process.exitCode = 1; +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + process.exitCode = 1; + return; + } + sellerOrigin = `http://127.0.0.1:${address.port}`; + if (typeof process.send === 'function') { + process.send({ + type: 'ready', + origin: sellerOrigin, + executionSigner: executionAccount.address.toLowerCase(), + refundSigner: refundAccount.address.toLowerCase(), + refundSource: REFUND_SOURCE, + }); + } +}); diff --git a/spikes/pi-wielder/tests/kernel-agent-enrollment.test.mjs b/spikes/pi-wielder/tests/kernel-agent-enrollment.test.mjs new file mode 100644 index 0000000..2299999 --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-agent-enrollment.test.mjs @@ -0,0 +1,527 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { canonicalJson, KernelError, sha256 } from '../src/kernel/canonical.mjs'; +import { createAgentEnrollmentRepository } from '../src/kernel/agent-enrollment.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const NOW = '2026-07-31T12:00:00.000Z'; +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const DESCRIPTOR_HASH = sha256(canonicalJson(DESCRIPTOR)); +const OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; + +function memoryStore() { + return openKernelStore({ + filePath: ':memory:', + allowMemory: true, + now: () => NOW, + }); +} + +function assertKernelError(operation, expectedCode) { + assert.throws(operation, (error) => { + assert.ok(error instanceof KernelError); + assert.equal(error.code, expectedCode); + return true; + }); +} + +function enroll(enrollments, overrides = {}) { + return enrollments.enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: DESCRIPTOR_HASH, + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + ...overrides, + }); +} + +function replacementDescriptor(overrides = {}) { + return { + ...DESCRIPTOR, + agentInstanceId: 'AQEBAQEBAQEBAQEBAQEBAQ', + credentialDigest: `sha256:${'12'.repeat(32)}`, + ...overrides, + }; +} + +test('enrolls one exact non-secret agent identity', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const enrollments = createAgentEnrollmentRepository({ store, now: () => NOW }); + + const enrolled = enroll(enrollments); + + assert.equal(enrolled.enrollmentHash, DESCRIPTOR_HASH); + assert.equal(enrolled.isolation, 'pending_verification'); + const replay = enroll(enrollments); + assert.deepEqual(replay, enrolled); + assert.equal(store.events().filter((row) => row.event_type === 'agent.enrolled').length, 1); + const active = enrollments.active(); + assert.equal(active.credentialDigest, DESCRIPTOR.credentialDigest); + assert.deepEqual(enrollments.get(DESCRIPTOR.agentInstanceId), active); + assert.ok(Object.isFrozen(active)); +}); + +test('revocation atomically removes enrollment authority and supersedes its attestation', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const enrollments = createAgentEnrollmentRepository({ store, now: () => NOW }); + const enrolled = enroll(enrollments); + store.execForTest(` + INSERT INTO policy_versions + (id, schema_version, canonical_json, policy_hash, applied_at) + VALUES ('policy-1', 1, '{}', 'policy-hash-1', '${NOW}'); + INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at) + VALUES ('session-1', 'pi:${DESCRIPTOR.agentInstanceId}', + '0x1000000000000000000000000000000000000000', 'policy-1', 'open', '${NOW}'); + INSERT INTO agent_session_bindings + (id, agent_instance_id, credential_digest, enrollment_hash, session_id, + state, created_at, last_seen_at) + VALUES ('binding-1', '${DESCRIPTOR.agentInstanceId}', '${DESCRIPTOR.credentialDigest}', + '${enrolled.enrollmentHash}', 'session-1', 'open', '${NOW}', '${NOW}'); + INSERT INTO isolation_attestations + (id, report_hash, enrollment_hash, report_json, state, + imported_by_operator_hash, probed_at, expires_at, imported_at) + VALUES ('attestation-1', 'sha256:${'ef'.repeat(32)}', '${enrolled.enrollmentHash}', + '{}', 'current', '${OPERATOR_HASH}', '${NOW}', '2026-08-01T12:00:00.000Z', '${NOW}'); + INSERT INTO spend_intents + (id, request_id, session_id, enrollment_hash, route_id, method, + request_url_hash, seller_origin, resource_path, body_hash, + header_allowlist_hash, ordinary_fingerprint, purpose_label, + correlation_id, idempotency_key, wallet_address, intent_hash, + state, created_at, updated_at) + VALUES ('intent-unresolved', 'request-unresolved', 'session-1', + '${enrolled.enrollmentHash}', 'route-1', 'POST', 'url-hash', + 'https://seller.example', '/paid/infer', 'body-hash', 'header-hash', + 'fingerprint-unresolved', 'skill.invoke', 'correlation-unresolved', + 'wk_${'11'.repeat(32)}', '0x1000000000000000000000000000000000000000', + 'intent-hash-unresolved', 'unresolved', '${NOW}', '${NOW}'); + INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, updated_at) + VALUES ('intent-unresolved', 'session-1', 'https://seller.example', + '0', '0', '0', '50000', 'unresolved', '${NOW}'); + `); + + assertKernelError(() => enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: `sha256:${'00'.repeat(32)}`, + operatorIdHash: OPERATOR_HASH, + }), 'AGENT_ENROLLMENT_STALE'); + + const revoked = enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + + assert.deepEqual(revoked.boundSessionIds, ['session-1']); + assert.equal(revoked.enrollment.state, 'revoked'); + assert.equal(enrollments.active(), null); + assert.equal(store.readOne( + 'SELECT state FROM spend_sessions WHERE id = ?', ['session-1'], + ).state, 'open'); + const unresolved = store.readOne(`SELECT state, unresolved_atomic + FROM budget_reservations WHERE intent_id = ?`, ['intent-unresolved']); + assert.equal(unresolved.state, 'unresolved'); + assert.equal(unresolved.unresolved_atomic, '50000'); + const attestation = store.readOne( + 'SELECT state, superseded_at FROM isolation_attestations WHERE id = ?', + ['attestation-1'], + ); + assert.equal(attestation.state, 'superseded'); + assert.equal(attestation.superseded_at, NOW); + assert.equal(store.events().filter((row) => row.event_type === 'agent.revoked').length, 1); + assert.equal(store.events().filter( + (row) => row.event_type === 'isolation.attestation_superseded', + ).length, 1); +}); + +test('descriptor and OS identity inputs are closed, canonical, and non-secret', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const enrollments = createAgentEnrollmentRepository({ store, now: () => NOW }); + const invalidDescriptors = [ + [{ ...DESCRIPTOR, token: 'RAW_AGENT_TOKEN_SENTINEL' }, 'AGENT_DESCRIPTOR_SCHEMA'], + [Object.fromEntries(Object.entries(DESCRIPTOR).filter(([key]) => key !== 'agentGid')), + 'AGENT_DESCRIPTOR_SCHEMA'], + [{ ...DESCRIPTOR, schemaVersion: 2 }, 'AGENT_DESCRIPTOR_VERSION'], + [{ ...DESCRIPTOR, agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAA' }, 'AGENT_INSTANCE_ID'], + [{ ...DESCRIPTOR, agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA==' }, 'AGENT_INSTANCE_ID'], + [{ ...DESCRIPTOR, credentialDigest: `sha256:${'AB'.repeat(32)}` }, + 'AGENT_CREDENTIAL_DIGEST'], + [{ ...DESCRIPTOR, agentUid: '0501' }, 'AGENT_IDENTITY'], + [{ ...DESCRIPTOR, agentUid: '0' }, 'AGENT_IDENTITY'], + [{ ...DESCRIPTOR, agentUid: String(Number.MAX_SAFE_INTEGER + 1) }, 'AGENT_IDENTITY'], + [{ ...DESCRIPTOR, agentGid: '0' }, 'AGENT_IDENTITY'], + ]; + for (const [descriptor, code] of invalidDescriptors) { + assertKernelError(() => enroll(enrollments, { + descriptor, + expectedDescriptorHash: sha256(canonicalJson(descriptor)), + }), code); + } + + let getterCalls = 0; + const accessorDescriptor = { ...DESCRIPTOR }; + Object.defineProperty(accessorDescriptor, 'agentUid', { + enumerable: true, + get() { + getterCalls += 1; + return '501'; + }, + }); + assertKernelError(() => enroll(enrollments, { + descriptor: accessorDescriptor, + expectedDescriptorHash: DESCRIPTOR_HASH, + }), 'AGENT_ENROLLMENT_SCHEMA'); + assert.equal(getterCalls, 0); + + assertKernelError(() => enroll(enrollments, { + expectedDescriptorHash: `sha256:${'00'.repeat(32)}`, + }), 'AGENT_DESCRIPTOR_HASH'); + assertKernelError(() => enroll(enrollments, { operatorIdHash: 'operator' }), + 'AGENT_ENROLLMENT_SCHEMA'); + assertKernelError(() => enroll(enrollments, { expectedAgentUid: 502 }), + 'AGENT_IDENTITY_MISMATCH'); + assertKernelError(() => enroll(enrollments, { kernelUid: 501 }), + 'AGENT_IDENTITY_NOT_ISOLATED'); + assert.equal(store.readOne('SELECT COUNT(*) AS count FROM agent_enrollments').count, 0n); + assert.equal(JSON.stringify(store.events()).includes('RAW_AGENT_TOKEN_SENTINEL'), false); +}); + +test('deterministic same-UID enrollment is explicit, simulated, and exact replay is idempotent', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const enrollments = createAgentEnrollmentRepository({ store, now: () => NOW }); + const first = enroll(enrollments, { + mode: 'deterministic', + kernelUid: 501, + kernelGid: 20, + }); + const replay = enroll(enrollments, { + mode: 'deterministic', + kernelUid: 501, + kernelGid: 20, + }); + + assert.deepEqual(replay, first); + assert.equal(first.isolation, 'simulated'); + assert.equal(store.readOne('SELECT COUNT(*) AS count FROM agent_enrollments').count, 1n); + const events = store.events().filter((row) => row.event_type === 'agent.enrolled'); + assert.equal(events.length, 1); + assert.equal(JSON.parse(events[0].data_json).isolation, 'simulated'); + + assertKernelError(() => enroll(enrollments, { + mode: 'deterministic', + kernelUid: 502, + kernelGid: 20, + }), 'AGENT_DETERMINISTIC_FIXTURE'); + + assertKernelError(() => enroll(enrollments, { + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + }), 'AGENT_ENROLLMENT_CONFLICT'); + + const different = replacementDescriptor(); + assertKernelError(() => enroll(enrollments, { + descriptor: different, + expectedDescriptorHash: sha256(canonicalJson(different)), + mode: 'deterministic', + kernelUid: 501, + kernelGid: 20, + }), 'AGENT_ENROLLMENT_CONFLICT'); +}); + +test('live enrollment permits the pinned macOS-compatible shared primary GID', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const enrollments = createAgentEnrollmentRepository({ store, now: () => NOW }); + + const enrolled = enroll(enrollments, { + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 20, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + + assert.equal(enrolled.agentUid, '501'); + assert.equal(enrolled.agentGid, '20'); + assert.equal(enrolled.isolation, 'pending_verification'); +}); + +test('revocation rejects a regressed Kernel clock without changing authority', (t) => { + let clock = NOW; + const store = openKernelStore({ + filePath: ':memory:', + allowMemory: true, + now: () => clock, + }); + t.after(() => store.close()); + const enrollments = createAgentEnrollmentRepository({ store, now: () => clock }); + const enrolled = enroll(enrollments); + const eventsBefore = store.events(); + clock = '2026-07-31T11:59:59.999Z'; + + assertKernelError(() => enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }), 'AGENT_ENROLLMENT_TIME'); + assert.equal(enrollments.active().enrollmentHash, enrolled.enrollmentHash); + assert.deepEqual(store.events(), eventsBefore); +}); + +test('replacement enrollment waits for every revoked binding to close', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const enrollments = createAgentEnrollmentRepository({ store, now: () => NOW }); + const enrolled = enroll(enrollments); + store.execForTest(` + INSERT INTO policy_versions + (id, schema_version, canonical_json, policy_hash, applied_at) + VALUES ('policy-1', 1, '{}', 'policy-hash-1', '${NOW}'); + INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at) + VALUES ('session-1', 'pi:${DESCRIPTOR.agentInstanceId}', + '0x1000000000000000000000000000000000000000', 'policy-1', 'open', '${NOW}'); + INSERT INTO agent_session_bindings + (id, agent_instance_id, credential_digest, enrollment_hash, session_id, + state, created_at, last_seen_at) + VALUES ('binding-1', '${DESCRIPTOR.agentInstanceId}', '${DESCRIPTOR.credentialDigest}', + '${enrolled.enrollmentHash}', 'session-1', 'open', '${NOW}', '${NOW}'); + `); + enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + const different = replacementDescriptor(); + const replacementInput = { + descriptor: different, + expectedDescriptorHash: sha256(canonicalJson(different)), + }; + + assertKernelError(() => enroll(enrollments, replacementInput), 'AGENT_ENROLLMENT_BOUND'); + store.execForTest(` + UPDATE agent_session_bindings + SET state = 'closed' WHERE id = 'binding-1'; + UPDATE spend_sessions + SET state = 'closed' WHERE id = 'session-1'; + `); + assertKernelError(() => enroll(enrollments, replacementInput), + 'AGENT_ENROLLMENT_CORRUPTION'); + store.execForTest(` + UPDATE agent_session_bindings + SET closed_at = '${NOW}' WHERE id = 'binding-1'; + UPDATE spend_sessions + SET closed_at = '${NOW}' WHERE id = 'session-1'; + `); + const replacement = enroll(enrollments, replacementInput); + assert.equal(replacement.enrollmentHash, replacementInput.expectedDescriptorHash); + assert.equal(enrollments.active().agentInstanceId, different.agentInstanceId); +}); + +test('a current attestation forged onto a revoked epoch is semantic corruption', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const enrollments = createAgentEnrollmentRepository({ store, now: () => NOW }); + const enrolled = enroll(enrollments); + enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + store.execForTest(` + INSERT INTO isolation_attestations + (id, report_hash, enrollment_hash, report_json, state, + imported_by_operator_hash, probed_at, expires_at, imported_at) + VALUES ('forged-current', 'sha256:${'34'.repeat(32)}', '${enrolled.enrollmentHash}', + '{}', 'current', '${OPERATOR_HASH}', '${NOW}', + '2026-08-01T12:00:00.000Z', '${NOW}'); + `); + + assertKernelError(() => enrollments.active(), 'AGENT_ENROLLMENT_CORRUPTION'); + const different = replacementDescriptor(); + assertKernelError(() => enroll(enrollments, { + descriptor: different, + expectedDescriptorHash: sha256(canonicalJson(different)), + }), 'AGENT_ENROLLMENT_CORRUPTION'); +}); + +test('active enrollment reads are one revocation-safe snapshot', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const revoker = createAgentEnrollmentRepository({ store, now: () => NOW }); + const enrolled = enroll(revoker); + let revocationWon = false; + const winRevocation = () => { + if (revocationWon) return; + revocationWon = true; + revoker.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + }; + const racingStore = Object.freeze({ + ...store, + transaction(operation) { + winRevocation(); + return store.transaction(operation); + }, + readAll(sql, parameters) { + const rows = store.readAll(sql, parameters); + if (/agent_enrollments WHERE state = 'active'/.test(sql)) winRevocation(); + return rows; + }, + }); + const reader = createAgentEnrollmentRepository({ store: racingStore, now: () => NOW }); + + assert.equal(reader.active(), null); + assert.equal(revocationWon, true); +}); + +test('creation-event authority is exact and enrollment replay does not resample time', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + let clockCalls = 0; + const enrollments = createAgentEnrollmentRepository({ + store, + now: () => { + clockCalls += 1; + return clockCalls === 1 ? NOW : 'not-a-timestamp'; + }, + }); + const first = enroll(enrollments); + assert.deepEqual(enroll(enrollments), first); + assert.equal(clockCalls, 1); + + const event = store.readOne(`SELECT sequence, data_json FROM events + WHERE event_type = ?`, ['agent.enrolled']); + const data = JSON.parse(event.data_json); + store.execForTest(`UPDATE events SET data_json = '${canonicalJson({ + ...data, + injected: true, + }).replaceAll("'", "''")}' WHERE sequence = ${event.sequence}`); + assertKernelError(() => enrollments.get(DESCRIPTOR.agentInstanceId), + 'AGENT_ENROLLMENT_CORRUPTION'); +}); + +test('a revoked historical epoch cannot be silently re-enrolled', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const enrollments = createAgentEnrollmentRepository({ store, now: () => NOW }); + const enrolled = enroll(enrollments); + enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + + assertKernelError(() => enroll(enrollments), 'AGENT_REVOKED'); +}); + +test('file-backed revoke survives reopen with history retained and no current attestation', (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-enrollment-')); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const filePath = path.join(directory, 'kernel.sqlite'); + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); + const firstStore = openKernelStore({ filePath, pathTrust, now: () => NOW }); + const first = createAgentEnrollmentRepository({ store: firstStore, now: () => NOW }); + const enrolled = enroll(first); + firstStore.execForTest?.('SELECT 1'); + first.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + firstStore.close(); + + const reopenedStore = openKernelStore({ filePath, pathTrust, now: () => NOW }); + t.after(() => reopenedStore.close()); + const reopened = createAgentEnrollmentRepository({ store: reopenedStore, now: () => NOW }); + assert.equal(reopened.active(), null); + assert.equal(reopened.get(DESCRIPTOR.agentInstanceId).state, 'revoked'); + assert.equal(reopenedStore.readAll( + "SELECT id FROM isolation_attestations WHERE state = 'current'", + ).length, 0); + assert.equal(reopenedStore.events().filter((row) => row.event_type === 'agent.revoked').length, 1); +}); + +test('enrollment and revocation event faults roll back their whole authority mutation', (t) => { + const store = memoryStore(); + t.after(() => store.close()); + const faultingStore = (eventType) => Object.freeze({ + ...store, + within(token, operation) { + return store.within(token, ({ db, appendEvent }) => operation({ + db, + appendEvent(event) { + if (event.eventType === eventType) throw new Error(`fault:${eventType}`); + return appendEvent(event); + }, + })); + }, + }); + + const failedEnrollments = createAgentEnrollmentRepository({ + store: faultingStore('agent.enrolled'), + now: () => NOW, + }); + assert.throws(() => enroll(failedEnrollments), /fault:agent\.enrolled/); + assert.equal(store.readOne('SELECT COUNT(*) AS count FROM agent_enrollments').count, 0n); + assert.equal(store.events().length, 0); + + const normal = createAgentEnrollmentRepository({ store, now: () => NOW }); + const enrolled = enroll(normal); + store.execForTest(`INSERT INTO isolation_attestations + (id, report_hash, enrollment_hash, report_json, state, + imported_by_operator_hash, probed_at, expires_at, imported_at) + VALUES ('attestation-rollback', 'sha256:${'ef'.repeat(32)}', + '${enrolled.enrollmentHash}', '{}', 'current', '${OPERATOR_HASH}', '${NOW}', + '2026-08-01T12:00:00.000Z', '${NOW}')`); + const failedRevocations = createAgentEnrollmentRepository({ + store: faultingStore('agent.revoked'), + now: () => NOW, + }); + assert.throws(() => failedRevocations.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }), /fault:agent\.revoked/); + assert.equal(store.readOne( + 'SELECT state FROM agent_enrollments WHERE agent_instance_id = ?', + [DESCRIPTOR.agentInstanceId], + ).state, 'active'); + assert.equal(store.readOne( + 'SELECT state FROM isolation_attestations WHERE id = ?', ['attestation-rollback'], + ).state, 'current'); + assert.equal(store.events().filter((row) => row.event_type === 'agent.revoked').length, 0); + assert.equal(store.events().filter( + (row) => row.event_type === 'isolation.attestation_superseded', + ).length, 0); +}); diff --git a/spikes/pi-wielder/tests/kernel-approvals.test.mjs b/spikes/pi-wielder/tests/kernel-approvals.test.mjs new file mode 100644 index 0000000..3b99803 --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-approvals.test.mjs @@ -0,0 +1,1081 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createAgentEnrollmentRepository } from '../src/kernel/agent-enrollment.mjs'; +import { createApprovalQueue } from '../src/kernel/approval-queue.mjs'; +import { canonicalJson, KernelError, sha256 } from '../src/kernel/canonical.mjs'; +import { createIntentRepository } from '../src/kernel/intent-builder.mjs'; +import { evaluateSpendPolicy } from '../src/kernel/policy-engine.mjs'; +import { createPolicyRepository } from '../src/kernel/policy-repository.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const NOW = '2026-07-31T12:00:00.000Z'; +const APPROVED_AT = '2026-07-31T12:01:00.000Z'; +const EXPIRES_AT = '2026-07-31T12:05:00.000Z'; +const AFTER_EXPIRY = '2026-07-31T12:05:00.001Z'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const SELLER = 'https://seller.example'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const OPERATOR_HASH = `sha256:${'aa'.repeat(32)}`; +const ENROLLMENT_OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const DESCRIPTOR_HASH = sha256(canonicalJson(DESCRIPTOR)); +const ROUTE_METADATA = Object.freeze({ + 'paid-infer': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), +}); +const BASE_POLICY = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); + +function policyDocument(overrides = {}) { + const document = structuredClone(BASE_POLICY); + document.challengeMaxAgeMs = 600_000; + document.approvalTtlMs = 300_000; + document.maxPendingApprovals = 20; + Object.assign(document, overrides); + return document; +} + +function paymentRequired(amountAtomic = '250000') { + return { + x402Version: 2, + error: 'seller prose is excluded from the durable projection', + resource: { + url: `${SELLER}/paid/infer`, + description: 'offline fixture', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: NETWORK, + asset: ASSET, + amount: amountAtomic, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], + }; +} + +function sequenceIds() { + const counts = new Map(); + const calls = []; + const factory = (kind) => { + calls.push(kind); + const next = (counts.get(kind) ?? 0) + 1; + counts.set(kind, next); + return `${kind}-${next}`; + }; + factory.calls = calls; + return factory; +} + +function fileAuthority(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-approvals-')); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return Object.freeze({ + databasePath: path.join(directory, 'kernel.sqlite'), + pathTrust: Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }), + }); +} + +function setup(t, { + authority = null, + clock = { value: NOW }, + ids = sequenceIds(), + policy = policyDocument(), +} = {}) { + const store = openKernelStore(authority ? { + filePath: authority.databasePath, + pathTrust: authority.pathTrust, + now: () => clock.value, + } : { + filePath: ':memory:', + allowMemory: true, + now: () => clock.value, + }); + t.after(() => { + try { store.close(); } catch {} + }); + const policies = createPolicyRepository(store); + const activePolicy = policies.apply(policy, NOW).policyVersion; + const enrollments = createAgentEnrollmentRepository({ store, now: () => clock.value }); + enrollments.enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: DESCRIPTOR_HASH, + operatorIdHash: ENROLLMENT_OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const intents = createIntentRepository({ + store, + idFactory: ids, + now: () => clock.value, + routeMetadata: ROUTE_METADATA, + }); + const session = intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: activePolicy.id, + }); + const approvals = createApprovalQueue({ store, idFactory: ids, now: () => clock.value }); + return { + activePolicy, + approvals, + clock, + ids, + intents, + policies, + session, + store, + }; +} + +function createApprovalRequiredIntent(context, label, { + amountAtomic = '250000', + challengeReceivedAt = NOW, + decidedAt = NOW, + pendingApprovalCount, + requestApproval = true, +} = {}) { + context.clock.value = challengeReceivedAt; + const requestUrl = `${SELLER}/paid/infer`; + const request = { + routeId: 'paid-infer', + method: 'POST', + requestUrl, + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from(canonicalJson({ label }), 'utf8'), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-${label}`, + }; + const captured = context.intents.captureIntent({ + sessionId: context.session.id, + ...request, + }); + const challenge = paymentRequired(amountAtomic); + context.intents.attachChallenge({ + intentId: captured.id, + paymentRequired: challenge, + challengeReceivedAt, + }); + const evaluation = evaluateSpendPolicy({ + policy: context.activePolicy.policy, + policyVersion: { id: context.activePolicy.id, hash: context.activePolicy.hash }, + intent: { + id: captured.id, + method: request.method, + requestUrl, + sellerOrigin: SELLER, + resourcePath: '/paid/infer', + walletAddress: WALLET, + }, + wallet: { + provider: 'deterministic', + walletId: 'buyer-a', + address: WALLET, + network: NETWORK, + }, + paymentRequired: challenge, + challengeReceivedAtMs: Date.parse(challengeReceivedAt), + nowMs: Date.parse(decidedAt), + budgetSnapshot: { + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + pendingApprovalCount: pendingApprovalCount ?? Number(context.store.readOne( + "SELECT COUNT(*) AS count FROM approvals WHERE decision = 'pending'", + ).count), + }, + }); + assert.equal(evaluation.decision, 'approval_required'); + context.clock.value = decidedAt; + context.store.transaction((token) => context.policies.recordDecisionInTransaction(token, { + intentId: captured.id, + policyVersionId: context.activePolicy.id, + evaluation, + decidedAt, + })); + const requestBinding = Object.freeze({ + intentId: captured.id, + intentHash: captured.intentHash, + challengeHash: evaluation.challengeHash, + quoteId: evaluation.quoteId, + amountCeilingAtomic: amountAtomic, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + acceptedIndex: evaluation.acceptedIndex, + }); + let approval = null; + if (requestApproval) { + approval = context.approvals.request(requestBinding); + context.intents.transition({ + intentId: captured.id, + expectedState: 'challenged', + nextState: 'approval_pending', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + }); + } + return Object.freeze({ approval, captured, evaluation, requestBinding }); +} + +function approvalBinding(record) { + return Object.freeze({ + intentId: record.intentId, + intentHash: record.intentHash, + challengeHash: record.challengeHash, + quoteId: record.quoteId, + amountCeilingAtomic: record.amountCeilingAtomic, + walletAddress: record.walletAddress, + policyVersionId: record.policyVersionId, + acceptedIndex: record.acceptedIndex, + expiresAt: record.expiresAt, + }); +} + +function approve(context, approval) { + context.clock.value = APPROVED_AT; + return context.approvals.approve({ + approvalId: approval.approvalId, + expectedIntentHash: approval.intentHash, + operatorIdHash: OPERATOR_HASH, + }); +} + +function insertReservation(context, token, intentId) { + return context.store.within(token, ({ db }) => db.prepare(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, updated_at) + VALUES (?, ?, ?, '250000', '0', '0', '0', 'reserved', ?)`) + .run(intentId, context.session.id, SELLER, context.clock.value)); +} + +function assertKernelError(operation, expectedCode) { + assert.throws(operation, (error) => { + assert.ok(error instanceof KernelError); + assert.equal(error.code, expectedCode); + return true; + }); +} + +function authoritySnapshot(context) { + return { + approvals: context.store.readAll('SELECT * FROM approvals ORDER BY id'), + events: context.store.events(), + reservations: context.store.readAll('SELECT * FROM budget_reservations ORDER BY intent_id'), + }; +} + +test('approval binds every authority field, stores only an operator hash, and survives reopen', (t) => { + const authority = fileAuthority(t); + const clock = { value: NOW }; + const first = setup(t, { authority, clock }); + const { approval } = createApprovalRequiredIntent(first, 'durable'); + + assert.deepEqual(approvalBinding(approval), { + intentId: approval.intentId, + intentHash: approval.intentHash, + challengeHash: approval.challengeHash, + quoteId: approval.quoteId, + amountCeilingAtomic: '250000', + walletAddress: WALLET, + policyVersionId: first.activePolicy.id, + acceptedIndex: 0, + expiresAt: EXPIRES_AT, + }); + assert.equal(Object.isFrozen(approval), true); + assert.equal(approval.decision, 'pending'); + + const approved = approve(first, approval); + assert.equal(approved.operatorIdHash, OPERATOR_HASH); + assert.equal(approved.decidedAt, APPROVED_AT); + assert.equal(approved.reasonCode, null); + const raw = first.store.readOne('SELECT * FROM approvals WHERE id = ?', [approval.approvalId]); + assert.equal(raw.operator_id_hash, OPERATOR_HASH); + assert.equal(Object.values(raw).filter((value) => typeof value === 'string') + .some((value) => value.includes('RAW_OPERATOR_SENTINEL')), false); + first.store.close(); + + const reopenedStore = openKernelStore({ + filePath: authority.databasePath, + pathTrust: authority.pathTrust, + now: () => clock.value, + }); + t.after(() => reopenedStore.close()); + const reopened = createApprovalQueue({ + store: reopenedStore, + idFactory: sequenceIds(), + now: () => clock.value, + }); + assert.deepEqual(reopened.get(approval.approvalId), approved); + assert.equal(reopenedStore.verifyEventChain(), true); +}); + +test('approval rows require one exact authority-bound append-only lifecycle', (t) => { + const missingTransitionContext = setup(t); + const missingTransition = createApprovalRequiredIntent( + missingTransitionContext, + 'missing-approved-event', + ).approval; + missingTransitionContext.store.transaction((token) => missingTransitionContext.store.within( + token, + ({ db }) => db.prepare(`UPDATE approvals + SET decision = 'approved', operator_id_hash = ?, decided_at = ? + WHERE id = ?`).run( + OPERATOR_HASH, + APPROVED_AT, + missingTransition.approvalId, + ), + )); + assertKernelError( + () => missingTransitionContext.approvals.get(missingTransition.approvalId), + 'APPROVAL_CORRUPTION', + ); + + const duplicateContext = setup(t); + const duplicate = createApprovalRequiredIntent(duplicateContext, 'duplicate-approved-event') + .approval; + approve(duplicateContext, duplicate); + const approvedEvent = duplicateContext.store.events().find( + (event) => event.entity_id === duplicate.approvalId + && event.event_type === 'approval.approved', + ); + duplicateContext.store.transaction((token) => duplicateContext.store.within( + token, + ({ appendEvent }) => appendEvent({ + entityType: 'approval', + entityId: duplicate.approvalId, + eventType: 'approval.approved', + data: JSON.parse(approvedEvent.data_json), + }), + )); + assertKernelError( + () => duplicateContext.approvals.get(duplicate.approvalId), + 'APPROVAL_CORRUPTION', + ); + + const missingRequestContext = setup(t); + const missingRequest = createApprovalRequiredIntent(missingRequestContext, 'missing-request-event') + .approval; + missingRequestContext.store.transaction((token) => missingRequestContext.store.within( + token, + ({ db }) => db.prepare(`DELETE FROM events + WHERE entity_type = 'approval' AND entity_id = ? + AND event_type = 'approval.requested'`).run(missingRequest.approvalId), + )); + assertKernelError( + () => missingRequestContext.approvals.get(missingRequest.approvalId), + 'APPROVAL_CORRUPTION', + ); + + const outOfOrderContext = setup(t); + const outOfOrder = createApprovalRequiredIntent(outOfOrderContext, 'out-of-order-events').approval; + approve(outOfOrderContext, outOfOrder); + outOfOrderContext.store.transaction((token) => outOfOrderContext.store.within( + token, + ({ db }) => db.prepare(`UPDATE events SET sequence = -1 + WHERE entity_type = 'approval' AND entity_id = ? + AND event_type = 'approval.approved'`).run(outOfOrder.approvalId), + )); + assertKernelError( + () => outOfOrderContext.approvals.get(outOfOrder.approvalId), + 'APPROVAL_CORRUPTION', + ); + + const mismatchedContext = setup(t); + const mismatched = createApprovalRequiredIntent(mismatchedContext, 'mismatched-event').approval; + mismatchedContext.clock.value = APPROVED_AT; + mismatchedContext.store.transaction((token) => mismatchedContext.store.within( + token, + ({ db, appendEvent }) => { + db.prepare(`UPDATE approvals + SET decision = 'approved', operator_id_hash = ?, decided_at = ? + WHERE id = ?`).run(OPERATOR_HASH, APPROVED_AT, mismatched.approvalId); + appendEvent({ + entityType: 'approval', + entityId: mismatched.approvalId, + eventType: 'approval.approved', + data: { message: 'approved', intentId: mismatched.intentId }, + }); + }, + )); + assertKernelError( + () => mismatchedContext.approvals.get(mismatched.approvalId), + 'APPROVAL_CORRUPTION', + ); +}); + +test('approval clocks never regress from the requested event or an approved predecessor', (t) => { + const requestAt = (context, label, requestedAt) => { + const candidate = createApprovalRequiredIntent(context, label, { requestApproval: false }); + context.clock.value = requestedAt; + return context.approvals.request(candidate.requestBinding); + }; + const requestedAt = '2026-07-31T12:00:30.000Z'; + const regressedAt = '2026-07-31T12:00:15.000Z'; + + const approveContext = setup(t); + const approvable = requestAt(approveContext, 'approve-request-clock', requestedAt); + approveContext.clock.value = regressedAt; + const beforeApprove = authoritySnapshot(approveContext); + assertKernelError(() => approveContext.approvals.approve({ + approvalId: approvable.approvalId, + expectedIntentHash: approvable.intentHash, + operatorIdHash: OPERATOR_HASH, + }), 'APPROVAL_TIME'); + assert.deepEqual(authoritySnapshot(approveContext), beforeApprove); + + const denyContext = setup(t); + const deniable = requestAt(denyContext, 'deny-request-clock', requestedAt); + denyContext.clock.value = regressedAt; + const beforeDeny = authoritySnapshot(denyContext); + assertKernelError(() => denyContext.store.transaction( + (token) => denyContext.approvals.denyForIntentInTransaction(token, { + approvalId: deniable.approvalId, + intentId: deniable.intentId, + expectedIntentHash: deniable.intentHash, + operatorIdHash: OPERATOR_HASH, + reasonCode: 'OPERATOR_DENIED', + }), + ), 'APPROVAL_TIME'); + assert.deepEqual(authoritySnapshot(denyContext), beforeDeny); + + const pendingCancelContext = setup(t); + const pendingCancellable = requestAt( + pendingCancelContext, + 'cancel-request-clock', + requestedAt, + ); + pendingCancelContext.clock.value = regressedAt; + const beforePendingCancel = authoritySnapshot(pendingCancelContext); + assertKernelError(() => pendingCancelContext.store.transaction( + (token) => pendingCancelContext.approvals.cancelForIntentInTransaction(token, { + intentId: pendingCancellable.intentId, + reasonCode: 'SESSION_CLOSED', + }), + ), 'APPROVAL_TIME'); + assert.deepEqual(authoritySnapshot(pendingCancelContext), beforePendingCancel); + + const approvedCancelContext = setup(t); + const approvedCancellable = createApprovalRequiredIntent( + approvedCancelContext, + 'cancel-approved-clock', + ).approval; + approve(approvedCancelContext, approvedCancellable); + approvedCancelContext.clock.value = '2026-07-31T12:00:30.000Z'; + const beforeApprovedCancel = authoritySnapshot(approvedCancelContext); + assertKernelError(() => approvedCancelContext.store.transaction( + (token) => approvedCancelContext.approvals.cancelForIntentInTransaction(token, { + intentId: approvedCancellable.intentId, + reasonCode: 'POLICY_SUPERSEDED', + }), + ), 'APPROVAL_TIME'); + assert.deepEqual(authoritySnapshot(approvedCancelContext), beforeApprovedCancel); + assert.equal( + approvedCancelContext.approvals.get(approvedCancellable.approvalId).decidedAt, + APPROVED_AT, + ); + + const consumeContext = setup(t); + const consumable = createApprovalRequiredIntent(consumeContext, 'consume-approved-clock') + .approval; + const approved = approve(consumeContext, consumable); + consumeContext.clock.value = '2026-07-31T12:00:30.000Z'; + const beforeConsume = authoritySnapshot(consumeContext); + assertKernelError(() => consumeContext.store.transaction( + (token) => consumeContext.approvals.consumeForInTransaction( + token, + approvalBinding(approved), + ), + ), 'APPROVAL_TIME'); + assert.deepEqual(authoritySnapshot(consumeContext), beforeConsume); +}); + +test('consumption and its caller-owned reservation commit or roll back together exactly once', (t) => { + const context = setup(t); + const { approval } = createApprovalRequiredIntent(context, 'consume'); + const approved = approve(context, approval); + const binding = approvalBinding(approved); + const before = authoritySnapshot(context); + + assert.throws(() => context.store.transaction((token) => { + assert.equal(context.approvals.consumeForInTransaction(token, binding).decision, 'consumed'); + insertReservation(context, token, approval.intentId); + throw new Error('aggregate fault'); + }), /aggregate fault/); + assert.deepEqual(authoritySnapshot(context), before); + + const consumed = context.store.transaction((token) => { + const result = context.approvals.consumeForInTransaction(token, binding); + insertReservation(context, token, approval.intentId); + return result; + }); + assert.equal(consumed.decision, 'consumed'); + assert.equal(consumed.consumedAt, APPROVED_AT); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [approval.intentId], + ).state, 'reserved'); + + const after = authoritySnapshot(context); + assert.equal(context.store.transaction( + (token) => context.approvals.consumeForInTransaction(token, binding), + ), null); + assert.deepEqual(authoritySnapshot(context), after); + assert.equal(context.store.events().filter( + (event) => event.event_type === 'approval.consumed', + ).length, 1); +}); + +test('every approval binding substitution fails atomically', (t) => { + const context = setup(t); + const { approval } = createApprovalRequiredIntent(context, 'substitution'); + const approved = approve(context, approval); + const binding = approvalBinding(approved); + const substitutions = { + intentId: 'intent-substituted', + intentHash: `sha256:${'01'.repeat(32)}`, + challengeHash: `sha256:${'02'.repeat(32)}`, + quoteId: `sha256:${'03'.repeat(32)}`, + amountCeilingAtomic: '250001', + walletAddress: '0x3000000000000000000000000000000000000000', + policyVersionId: 'policy-substituted', + acceptedIndex: 1, + expiresAt: '2026-07-31T12:05:00.001Z', + }; + + for (const [field, value] of Object.entries(substitutions)) { + const before = authoritySnapshot(context); + assertKernelError(() => context.store.transaction( + (token) => context.approvals.consumeForInTransaction(token, { + ...binding, + [field]: value, + }), + ), 'APPROVAL_BINDING_MISMATCH'); + assert.deepEqual(authoritySnapshot(context), before, field); + } +}); + +test('pending, denied, expired, consumed, and unknown approvals never authorize', (t) => { + const pendingContext = setup(t); + const { approval: pending } = createApprovalRequiredIntent(pendingContext, 'pending'); + const pendingBefore = authoritySnapshot(pendingContext); + assert.equal(pendingContext.store.transaction( + (token) => pendingContext.approvals.consumeForInTransaction( + token, + approvalBinding(pending), + ), + ), null); + assert.deepEqual(authoritySnapshot(pendingContext), pendingBefore); + + const deniedContext = setup(t); + const { approval: denied } = createApprovalRequiredIntent(deniedContext, 'denied'); + deniedContext.clock.value = APPROVED_AT; + deniedContext.store.transaction((token) => deniedContext.approvals.denyForIntentInTransaction( + token, + { + approvalId: denied.approvalId, + intentId: denied.intentId, + expectedIntentHash: denied.intentHash, + operatorIdHash: OPERATOR_HASH, + reasonCode: 'OPERATOR_DENIED', + }, + )); + assert.equal(deniedContext.store.transaction( + (token) => deniedContext.approvals.consumeForInTransaction( + token, + approvalBinding(denied), + ), + ), null); + + const consumedContext = setup(t); + const { approval: consumedApproval } = createApprovalRequiredIntent(consumedContext, 'consumed'); + const consumedBinding = approvalBinding(approve(consumedContext, consumedApproval)); + consumedContext.store.transaction( + (token) => consumedContext.approvals.consumeForInTransaction(token, consumedBinding), + ); + assert.equal(consumedContext.store.transaction( + (token) => consumedContext.approvals.consumeForInTransaction(token, consumedBinding), + ), null); + + const expiredContext = setup(t); + const { approval: expiring } = createApprovalRequiredIntent(expiredContext, 'expired'); + approve(expiredContext, expiring); + expiredContext.clock.value = AFTER_EXPIRY; + assert.equal(expiredContext.store.transaction( + (token) => expiredContext.approvals.consumeForInTransaction( + token, + approvalBinding(expiring), + ), + ), null); + assert.equal(expiredContext.approvals.get(expiring.approvalId).decision, 'expired'); + + const unknown = { ...approvalBinding(pending), intentId: 'intent-unknown' }; + assertKernelError(() => pendingContext.store.transaction( + (token) => pendingContext.approvals.consumeForInTransaction(token, unknown), + ), 'APPROVAL_BINDING_MISMATCH'); + assert.equal(pendingContext.approvals.get('approval-unknown'), null); +}); + +test('expiry during consumption stays inside the caller terminal-outcome aggregate', (t) => { + const context = setup(t); + const { approval } = createApprovalRequiredIntent(context, 'expiry-aggregate'); + approve(context, approval); + const binding = approvalBinding(approval); + context.clock.value = EXPIRES_AT; + const before = authoritySnapshot(context); + + assert.throws(() => context.store.transaction((token) => { + assert.equal(context.approvals.consumeForInTransaction(token, binding), null); + context.intents.transitionInTransaction(token, { + intentId: approval.intentId, + expectedState: 'approval_pending', + nextState: 'terminal', + reasonCode: 'APPROVAL_EXPIRED', + }); + context.store.within(token, ({ db }) => db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_denied', 'APPROVAL_EXPIRED', 1, ?)`) + .run(approval.intentId, context.clock.value)); + throw new Error('aggregate fault'); + }), /aggregate fault/); + assert.deepEqual(authoritySnapshot(context), before); + assert.equal(context.approvals.get(approval.approvalId).decision, 'approved'); + + context.store.transaction((token) => { + assert.equal(context.approvals.consumeForInTransaction(token, binding), null); + context.intents.transitionInTransaction(token, { + intentId: approval.intentId, + expectedState: 'approval_pending', + nextState: 'terminal', + reasonCode: 'APPROVAL_EXPIRED', + }); + context.store.within(token, ({ db }) => db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_denied', 'APPROVAL_EXPIRED', 1, ?)`) + .run(approval.intentId, context.clock.value)); + }); + assert.equal(context.approvals.get(approval.approvalId).decision, 'expired'); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [approval.intentId], + ).state, 'terminal'); + assert.equal(context.store.readOne( + 'SELECT status FROM buyer_outcomes WHERE intent_id = ?', [approval.intentId], + ).status, 'payment_denied'); +}); + +test('capacity is atomic and denial or expiry releases exactly one pending slot', (t) => { + const context = setup(t, { policy: policyDocument({ maxPendingApprovals: 2 }) }); + const first = createApprovalRequiredIntent(context, 'capacity-1').approval; + const second = createApprovalRequiredIntent(context, 'capacity-2').approval; + const third = createApprovalRequiredIntent(context, 'capacity-3', { + pendingApprovalCount: 0, + requestApproval: false, + }); + const before = authoritySnapshot(context); + assertKernelError(() => context.approvals.request(third.requestBinding), 'APPROVAL_CAPACITY'); + assert.deepEqual(authoritySnapshot(context), before); + + context.clock.value = APPROVED_AT; + context.store.transaction((token) => context.approvals.denyForIntentInTransaction(token, { + approvalId: first.approvalId, + intentId: first.intentId, + expectedIntentHash: first.intentHash, + operatorIdHash: OPERATOR_HASH, + reasonCode: 'OPERATOR_DENIED', + })); + assert.equal(context.approvals.request(third.requestBinding).decision, 'pending'); + assert.equal(context.store.readOne( + "SELECT COUNT(*) AS count FROM approvals WHERE decision = 'pending'", + ).count, 2n); + + const expiryContext = setup(t, { policy: policyDocument({ maxPendingApprovals: 1 }) }); + const due = createApprovalRequiredIntent(expiryContext, 'capacity-expire').approval; + const blocked = createApprovalRequiredIntent(expiryContext, 'capacity-blocked', { + pendingApprovalCount: 0, + requestApproval: false, + }); + assertKernelError(() => expiryContext.approvals.request(blocked.requestBinding), + 'APPROVAL_CAPACITY'); + expiryContext.clock.value = EXPIRES_AT; + expiryContext.store.transaction((token) => expiryContext.approvals.expireForIntentInTransaction( + token, + { + approvalId: due.approvalId, + intentId: due.intentId, + expectedIntentHash: due.intentHash, + at: EXPIRES_AT, + }, + )); + const replacement = createApprovalRequiredIntent(expiryContext, 'capacity-replacement', { + challengeReceivedAt: AFTER_EXPIRY, + decidedAt: AFTER_EXPIRY, + pendingApprovalCount: 0, + requestApproval: false, + }); + assert.equal(expiryContext.approvals.request(replacement.requestBinding).decision, 'pending'); +}); + +test('retry and due discovery are bounded, stable, read-only, and agent-ID independent', (t) => { + const context = setup(t); + const first = createApprovalRequiredIntent(context, 'discover-1').approval; + context.clock.value = '2026-07-31T12:00:01.000Z'; + const second = createApprovalRequiredIntent(context, 'discover-2', { + challengeReceivedAt: context.clock.value, + decidedAt: context.clock.value, + }).approval; + + assert.deepEqual( + context.approvals.findRetryable({ + sessionId: context.session.id, + intentHash: first.intentHash, + }), + first, + ); + assert.equal(context.approvals.findRetryable({ + sessionId: 'session-other', + intentHash: first.intentHash, + }), null); + assert.equal(context.approvals.findRetryable({ + sessionId: context.session.id, + intentHash: `sha256:${'99'.repeat(32)}`, + }), null); + + approve(context, first); + assert.equal(context.approvals.findRetryable({ + sessionId: context.session.id, + intentHash: first.intentHash, + }).decision, 'approved'); + const before = authoritySnapshot(context); + assert.deepEqual(context.approvals.listDue({ at: EXPIRES_AT, limit: 10 }), [{ + approvalId: first.approvalId, + intentId: first.intentId, + intentHash: first.intentHash, + }]); + assert.deepEqual(authoritySnapshot(context), before); + assert.deepEqual(context.approvals.listDue({ + at: '2026-07-31T12:05:01.000Z', + limit: 1, + }), [{ + approvalId: first.approvalId, + intentId: first.intentId, + intentHash: first.intentHash, + }]); + assert.equal(second.expiresAt, '2026-07-31T12:05:01.000Z'); +}); + +test('terminal non-matchable approved history is never retryable or due authority', (t) => { + const context = setup(t); + const { approval, captured } = createApprovalRequiredIntent(context, 'approved-history'); + const approved = approve(context, approval); + context.intents.transition({ + intentId: captured.id, + expectedState: 'approval_pending', + nextState: 'terminal', + reasonCode: 'UPSTREAM_TRANSPORT_FAILURE', + }); + const before = authoritySnapshot(context); + + assert.deepEqual(context.approvals.get(approved.approvalId), approved); + assert.deepEqual(context.approvals.list({ state: 'approved', limit: 10 }), [approved]); + assert.equal(context.approvals.findRetryable({ + sessionId: context.session.id, + intentHash: approved.intentHash, + }), null); + assert.deepEqual(context.approvals.listDue({ at: AFTER_EXPIRY, limit: 10 }), []); + assert.deepEqual(authoritySnapshot(context), before); +}); + +test('request derives immutable expiry, exact replay is idempotent, and conflicts do not mutate', (t) => { + const ids = sequenceIds(); + const context = setup(t, { ids }); + const candidate = createApprovalRequiredIntent(context, 'idempotent', { + requestApproval: false, + }); + const first = context.approvals.request(candidate.requestBinding); + const events = context.store.events(); + const calls = [...ids.calls]; + context.clock.value = APPROVED_AT; + assert.deepEqual(context.approvals.request(candidate.requestBinding), first); + assert.deepEqual(context.store.events(), events); + assert.deepEqual(ids.calls, calls); + assert.equal(first.expiresAt, EXPIRES_AT); + + assertKernelError(() => context.approvals.request({ + ...candidate.requestBinding, + expiresAt: '2099-01-01T00:00:00.000Z', + }), 'APPROVAL_SCHEMA'); + for (const [field, value] of [ + ['intentHash', `sha256:${'44'.repeat(32)}`], + ['challengeHash', `sha256:${'55'.repeat(32)}`], + ['quoteId', `sha256:${'66'.repeat(32)}`], + ['amountCeilingAtomic', '250001'], + ['walletAddress', '0x3000000000000000000000000000000000000000'], + ['policyVersionId', 'policy-other'], + ['acceptedIndex', 1], + ]) { + const before = authoritySnapshot(context); + assertKernelError(() => context.approvals.request({ + ...candidate.requestBinding, + [field]: value, + }), 'APPROVAL_BINDING_MISMATCH'); + assert.deepEqual(authoritySnapshot(context), before, field); + } +}); + +test('challenge lifetime can be the tighter immutable approval deadline', (t) => { + const context = setup(t, { + policy: policyDocument({ challengeMaxAgeMs: 60_000, approvalTtlMs: 300_000 }), + }); + const candidate = createApprovalRequiredIntent(context, 'challenge-deadline', { + requestApproval: false, + }); + context.clock.value = '2026-07-31T12:00:30.000Z'; + + const approval = context.approvals.request(candidate.requestBinding); + + assert.equal(approval.expiresAt, '2026-07-31T12:01:00.000Z'); +}); + +test('requestInTransaction shares its caller aggregate and rolls creation back on fault', (t) => { + const context = setup(t); + const candidate = createApprovalRequiredIntent(context, 'scoped-request', { + requestApproval: false, + }); + const before = authoritySnapshot(context); + + assert.throws(() => context.store.transaction((token) => { + context.approvals.requestInTransaction(token, candidate.requestBinding); + throw new Error('request aggregate fault'); + }), /request aggregate fault/); + assert.deepEqual(authoritySnapshot(context), before); + + const created = context.store.transaction( + (token) => context.approvals.requestInTransaction(token, candidate.requestBinding), + ); + assert.equal(created.decision, 'pending'); + assert.equal(context.store.events().filter( + (event) => event.event_type === 'approval.requested', + ).length, 1); +}); + +test('request rejects a persisted approval decision retargeted to an incompatible offer', (t) => { + const context = setup(t); + const candidate = createApprovalRequiredIntent(context, 'retargeted-offer', { + requestApproval: false, + }); + const row = context.store.readOne( + 'SELECT challenge_projection_json FROM spend_intents WHERE id = ?', + [candidate.captured.id], + ); + const projection = JSON.parse(row.challenge_projection_json); + projection.accepts.unshift({ + ...projection.accepts[0], + scheme: 'subscription', + }); + const projectionJson = canonicalJson(projection); + const challengeHash = sha256(projectionJson); + const quoteId = sha256(canonicalJson({ challengeHash, acceptedIndex: 0 })); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE spend_intents + SET challenge_projection_json = ?, challenge_hash = ? WHERE id = ?`).run( + projectionJson, + challengeHash, + candidate.captured.id, + ); + db.prepare(`UPDATE policy_decisions + SET challenge_hash = ?, accepted_index = 0, quote_id = ? WHERE intent_id = ?`).run( + challengeHash, + quoteId, + candidate.captured.id, + ); + })); + + const before = authoritySnapshot(context); + assertKernelError(() => context.approvals.request({ + ...candidate.requestBinding, + challengeHash, + quoteId, + acceptedIndex: 0, + }), 'APPROVAL_CORRUPTION'); + assert.deepEqual(authoritySnapshot(context), before); +}); + +test('operator transitions match the displayed hash inside their transaction', (t) => { + const approveContext = setup(t); + const { approval } = createApprovalRequiredIntent(approveContext, 'approve-confirmation'); + approveContext.clock.value = APPROVED_AT; + const before = authoritySnapshot(approveContext); + assertKernelError(() => approveContext.approvals.approve({ + approvalId: approval.approvalId, + expectedIntentHash: `sha256:${'77'.repeat(32)}`, + operatorIdHash: OPERATOR_HASH, + }), 'APPROVAL_BINDING_MISMATCH'); + assert.deepEqual(authoritySnapshot(approveContext), before); + assertKernelError(() => approveContext.approvals.approve({ + approvalId: approval.approvalId, + expectedIntentHash: approval.intentHash, + operatorIdHash: OPERATOR_HASH, + operatorIdentity: 'RAW_OPERATOR_SENTINEL', + }), 'APPROVAL_DECISION_SCHEMA'); + assert.equal(approveContext.store.events().some( + (event) => event.data_json.includes('RAW_OPERATOR_SENTINEL'), + ), false); + assert.equal(approveContext.approvals.approve({ + approvalId: approval.approvalId, + expectedIntentHash: approval.intentHash, + operatorIdHash: OPERATOR_HASH, + }).decision, 'approved'); + const after = authoritySnapshot(approveContext); + assertKernelError(() => approveContext.approvals.approve({ + approvalId: approval.approvalId, + expectedIntentHash: approval.intentHash, + operatorIdHash: OPERATOR_HASH, + }), 'APPROVAL_STATE_CONFLICT'); + assert.deepEqual(authoritySnapshot(approveContext), after); + + const denyContext = setup(t); + const { approval: denied } = createApprovalRequiredIntent(denyContext, 'deny-confirmation'); + denyContext.clock.value = APPROVED_AT; + assertKernelError(() => denyContext.store.transaction( + (token) => denyContext.approvals.denyForIntentInTransaction(token, { + approvalId: denied.approvalId, + intentId: denied.intentId, + expectedIntentHash: `sha256:${'88'.repeat(32)}`, + operatorIdHash: OPERATOR_HASH, + reasonCode: 'OPERATOR_DENIED', + }), + ), 'APPROVAL_BINDING_MISMATCH'); + assert.equal(denyContext.store.transaction( + (token) => denyContext.approvals.denyForIntentInTransaction(token, { + approvalId: denied.approvalId, + intentId: denied.intentId, + expectedIntentHash: denied.intentHash, + operatorIdHash: OPERATOR_HASH, + reasonCode: 'OPERATOR_DENIED', + }), + ).decision, 'denied'); +}); + +test('denial, expiry, and cancellation are aggregate-scoped and never alter consumed authority', (t) => { + const context = setup(t); + const denied = createApprovalRequiredIntent(context, 'scope-deny').approval; + assert.equal(Object.hasOwn(context.approvals, 'deny'), false); + assert.equal(Object.hasOwn(context.approvals, 'expire'), false); + assert.equal(Object.hasOwn(context.approvals, 'cancel'), false); + context.clock.value = APPROVED_AT; + const before = authoritySnapshot(context); + assert.throws(() => context.store.transaction((token) => { + context.approvals.denyForIntentInTransaction(token, { + approvalId: denied.approvalId, + intentId: denied.intentId, + expectedIntentHash: denied.intentHash, + operatorIdHash: OPERATOR_HASH, + reasonCode: 'OPERATOR_DENIED', + }); + throw new Error('denial fault'); + }), /denial fault/); + assert.deepEqual(authoritySnapshot(context), before); + + const consumedApproval = createApprovalRequiredIntent(context, 'scope-consumed').approval; + const consumedBinding = approvalBinding(approve(context, consumedApproval)); + context.store.transaction( + (token) => context.approvals.consumeForInTransaction(token, consumedBinding), + ); + const consumedBefore = authoritySnapshot(context); + assert.equal(context.store.transaction( + (token) => context.approvals.cancelForIntentInTransaction(token, { + intentId: consumedApproval.intentId, + reasonCode: 'SESSION_CLOSED', + }), + ), null); + assert.deepEqual(authoritySnapshot(context), consumedBefore); + + const cancellable = createApprovalRequiredIntent(context, 'scope-cancel').approval; + const cancelled = context.store.transaction( + (token) => context.approvals.cancelForIntentInTransaction(token, { + intentId: cancellable.intentId, + reasonCode: 'APPROVAL_CHALLENGE_CHANGED', + }), + ); + assert.equal(cancelled.decision, 'cancelled'); + assert.equal(cancelled.reasonCode, 'APPROVAL_CHALLENGE_CHANGED'); + assertKernelError(() => context.store.transaction( + (token) => context.approvals.cancelForIntentInTransaction(token, { + intentId: denied.intentId, + reasonCode: 'NOT_ALLOWED', + }), + ), 'APPROVAL_CANCEL_REASON'); +}); + +test('scoped methods authenticate the opaque token before parsing hostile input', (t) => { + const context = setup(t); + let getterCalls = 0; + const hostile = {}; + Object.defineProperty(hostile, 'intentId', { + enumerable: true, + get() { + getterCalls += 1; + throw new Error('caller getter ran'); + }, + }); + const fake = Object.freeze(Object.create(null)); + + for (const invoke of [ + () => context.approvals.requestInTransaction(fake, hostile), + () => context.approvals.consumeForInTransaction(fake, hostile), + () => context.approvals.denyForIntentInTransaction(fake, hostile), + () => context.approvals.expireForIntentInTransaction(fake, hostile), + () => context.approvals.cancelForIntentInTransaction(fake, hostile), + ]) { + assert.throws(invoke, /invalid authority transaction/); + } + assert.equal(getterCalls, 0); + + let stale; + context.store.transaction((token) => { stale = token; }); + assert.throws(() => context.approvals.requestInTransaction(stale, hostile), + /invalid authority transaction/); + assert.equal(getterCalls, 0); +}); + +test('get/list inputs and outputs are closed, bounded, immutable, and stable', (t) => { + const context = setup(t); + const first = createApprovalRequiredIntent(context, 'list-1').approval; + const second = createApprovalRequiredIntent(context, 'list-2').approval; + approve(context, second); + + assert.deepEqual(context.approvals.list({ state: 'pending', limit: 10 }), [first]); + assert.deepEqual(context.approvals.list({ limit: 1 }), [first]); + assert.equal(Object.isFrozen(context.approvals.list({ limit: 10 })), true); + assert.equal(Object.isFrozen(context.approvals.list({ limit: 10 })[0]), true); + assertKernelError(() => context.approvals.list({ state: 'unknown', limit: 10 }), + 'APPROVAL_LIST_SCHEMA'); + assertKernelError(() => context.approvals.list({ limit: 0 }), 'APPROVAL_LIST_SCHEMA'); + assertKernelError(() => context.approvals.list({ limit: 10, injected: true }), + 'APPROVAL_LIST_SCHEMA'); + assertKernelError(() => context.approvals.get('not valid'), 'TOKEN_FORMAT'); +}); diff --git a/spikes/pi-wielder/tests/kernel-authority-coordinator.test.mjs b/spikes/pi-wielder/tests/kernel-authority-coordinator.test.mjs new file mode 100644 index 0000000..1348a8b --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-authority-coordinator.test.mjs @@ -0,0 +1,449 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { KernelError } from '../src/kernel/canonical.mjs'; +import { createAuthorityMutationCoordinator } from '../src/kernel/authority-mutation-coordinator.mjs'; + +function assertRejectedCode(promise, code) { + return assert.rejects(promise, (error) => { + assert.equal(error instanceof KernelError, true); + assert.equal(error.code, code); + return true; + }); +} + +test('exposes only one frozen runExclusive method and returns a synchronous result', async () => { + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() {}, + markAuthorityUnhealthy() {}, + }); + + assert.equal(Object.isFrozen(coordinator), true); + assert.deepEqual(Reflect.ownKeys(coordinator), ['runExclusive']); + assert.equal(typeof coordinator.runExclusive, 'function'); + assert.equal(coordinator.runExclusive.length, 1); + assert.equal(await coordinator.runExclusive(() => 'done'), 'done'); +}); + +test('accepts exactly one function without consulting admission for malformed calls', async () => { + let gateChecks = 0; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + gateChecks += 1; + }, + markAuthorityUnhealthy() {}, + }); + + await assert.rejects(coordinator.runExclusive(), TypeError); + await assert.rejects(coordinator.runExclusive(null), TypeError); + await assert.rejects(coordinator.runExclusive(() => undefined, 'extra'), TypeError); + assert.equal(gateChecks, 0); +}); + +test('runs callbacks in exact FIFO order and advances followers after an earlier throw', async () => { + const trace = []; + let gateChecks = 0; + let second; + let third; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + gateChecks += 1; + }, + markAuthorityUnhealthy() {}, + }); + + const first = coordinator.runExclusive(() => { + trace.push('first:enter'); + second = coordinator.runExclusive(() => { + trace.push('second:enter'); + trace.push('second:exit'); + return 'second-result'; + }); + third = coordinator.runExclusive(() => { + trace.push('third:enter'); + trace.push('third:exit'); + return 'third-result'; + }); + trace.push('first:exit'); + throw new Error('first failed'); + }); + + await assert.rejects(first, /first failed/); + assert.equal(await second, 'second-result'); + assert.equal(await third, 'third-result'); + assert.equal(gateChecks, 3); + assert.deepEqual(trace, [ + 'first:enter', + 'first:exit', + 'second:enter', + 'second:exit', + 'third:enter', + 'third:exit', + ]); +}); + +test('queued callbacks recheck a closed admission gate only at queue head and perform zero writes', async () => { + const trace = []; + const writes = []; + let admissionOpen = true; + let second; + let third; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + trace.push(`gate:${admissionOpen ? 'open' : 'closed'}`); + if (!admissionOpen) throw new KernelError('ADMISSION_CLOSED', 'admission is closed'); + }, + markAuthorityUnhealthy() {}, + }); + + await coordinator.runExclusive(() => { + trace.push('first:enter'); + second = coordinator.runExclusive(() => writes.push('second')); + third = coordinator.runExclusive(() => writes.push('third')); + admissionOpen = false; + trace.push('first:closed-gate'); + }); + + await assertRejectedCode(second, 'ADMISSION_CLOSED'); + await assertRejectedCode(third, 'ADMISSION_CLOSED'); + assert.deepEqual(writes, []); + assert.deepEqual(trace, [ + 'gate:open', + 'first:enter', + 'first:closed-gate', + 'gate:closed', + 'gate:closed', + ]); +}); + +test('a returned Promise synchronously fail-stops authority before releasing a follower', async () => { + const trace = []; + const writes = []; + let admissionOpen = true; + let follower; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + trace.push(`gate:${admissionOpen ? 'open' : 'closed'}`); + if (!admissionOpen) throw new KernelError('ADMISSION_CLOSED', 'admission is closed'); + }, + markAuthorityUnhealthy(reasonCode) { + trace.push(`mark:${reasonCode}`); + admissionOpen = false; + }, + }); + + const violating = coordinator.runExclusive(() => { + trace.push('violating:enter'); + follower = coordinator.runExclusive(() => writes.push('follower')); + trace.push('violating:return-promise'); + return Promise.resolve('escaped'); + }); + + assert.deepEqual(trace, [ + 'gate:open', + 'violating:enter', + 'violating:return-promise', + 'mark:AUTHORITY_COORDINATOR_ASYNC_CALLBACK', + 'gate:closed', + ]); + await assertRejectedCode(violating, 'AUTHORITY_COORDINATOR_ASYNC_CALLBACK'); + await assertRejectedCode(follower, 'ADMISSION_CLOSED'); + assert.deepEqual(writes, []); +}); + +test('custom thenables are invariant violations while inert then data remains synchronous', async () => { + const reasons = []; + const customCoordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() {}, + markAuthorityUnhealthy(reasonCode) { + reasons.push(reasonCode); + }, + }); + const customThenable = Object.freeze({ + then(resolve) { + resolve('escaped'); + }, + }); + + await assertRejectedCode( + customCoordinator.runExclusive(() => customThenable), + 'AUTHORITY_COORDINATOR_ASYNC_CALLBACK', + ); + assert.deepEqual(reasons, ['AUTHORITY_COORDINATOR_ASYNC_CALLBACK']); + + const inert = Object.freeze({ then: null, value: 'synchronous' }); + const inertCoordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() {}, + markAuthorityUnhealthy() {}, + }); + assert.equal(await inertCoordinator.runExclusive(() => inert), inert); +}); + +test('a native Promise cannot hide its async identity behind inert own then data', async () => { + const reasons = []; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() {}, + markAuthorityUnhealthy(reasonCode) { + reasons.push(reasonCode); + }, + }); + const disguisedPromise = Promise.resolve('escaped'); + Object.defineProperty(disguisedPromise, 'then', { + configurable: true, + value: null, + }); + + await assertRejectedCode( + coordinator.runExclusive(() => disguisedPromise), + 'AUTHORITY_COORDINATOR_ASYNC_CALLBACK', + ); + assert.deepEqual(reasons, ['AUTHORITY_COORDINATOR_ASYNC_CALLBACK']); +}); + +test('detects accessor and proxy thenable returns without invoking hostile traps', async () => { + async function assertHostileReturn(resultFactory, getTrapCalls) { + const reasons = []; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() {}, + markAuthorityUnhealthy(reasonCode) { + reasons.push(reasonCode); + }, + }); + + await assertRejectedCode( + coordinator.runExclusive(() => resultFactory()), + 'AUTHORITY_COORDINATOR_ASYNC_CALLBACK', + ); + assert.deepEqual(reasons, ['AUTHORITY_COORDINATOR_ASYNC_CALLBACK']); + assert.equal(getTrapCalls(), 0); + } + + let accessorCalls = 0; + const accessorThenable = {}; + Object.defineProperty(accessorThenable, 'then', { + get() { + accessorCalls += 1; + return () => undefined; + }, + }); + await assertHostileReturn(() => accessorThenable, () => accessorCalls); + + let proxyTrapCalls = 0; + const proxyThenable = new Proxy({ then() {} }, { + get(target, property, receiver) { + proxyTrapCalls += 1; + return Reflect.get(target, property, receiver); + }, + getOwnPropertyDescriptor(target, property) { + proxyTrapCalls += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + getPrototypeOf(target) { + proxyTrapCalls += 1; + return Reflect.getPrototypeOf(target); + }, + }); + await assertHostileReturn(() => proxyThenable, () => proxyTrapCalls); + + const { proxy: revokedThenable, revoke } = Proxy.revocable({ then() {} }, {}); + revoke(); + await assertHostileReturn(() => revokedThenable, () => 0); + + let prototypeTrapCalls = 0; + const proxyPrototype = new Proxy({ then() {} }, { + getOwnPropertyDescriptor(target, property) { + prototypeTrapCalls += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + getPrototypeOf(target) { + prototypeTrapCalls += 1; + return Reflect.getPrototypeOf(target); + }, + }); + const inheritedProxyThenable = Object.create(proxyPrototype); + await assertHostileReturn(() => inheritedProxyThenable, () => prototypeTrapCalls); +}); + +test('an async invariant remains internally fail-closed if the injected marker does not close the gate', async () => { + const trace = []; + const writes = []; + let follower; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + trace.push('gate:open'); + }, + markAuthorityUnhealthy(reasonCode) { + trace.push(`mark:${reasonCode}`); + }, + }); + + const violating = coordinator.runExclusive(() => { + follower = coordinator.runExclusive(() => writes.push('follower')); + return { then() {} }; + }); + + await assertRejectedCode(violating, 'AUTHORITY_COORDINATOR_ASYNC_CALLBACK'); + await assertRejectedCode(follower, 'AUTHORITY_COORDINATOR_ASYNC_CALLBACK'); + assert.deepEqual(writes, []); + assert.deepEqual(trace, [ + 'gate:open', + 'mark:AUTHORITY_COORDINATOR_ASYNC_CALLBACK', + 'gate:open', + ]); +}); + +test('a throwing fail-stop hook is preserved as cause while the slot still releases fail-closed', async () => { + const trace = []; + const writes = []; + const markerFailure = new Error('fail-stop hook failed'); + let follower; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + trace.push('gate:open'); + }, + markAuthorityUnhealthy(reasonCode) { + trace.push(`mark:${reasonCode}`); + throw markerFailure; + }, + }); + + const violating = coordinator.runExclusive(() => { + follower = coordinator.runExclusive(() => writes.push('follower')); + return Promise.resolve(); + }); + + const [violatingResult, followerResult] = await Promise.allSettled([violating, follower]); + for (const result of [violatingResult, followerResult]) { + assert.equal(result.status, 'rejected'); + assert.equal(result.reason instanceof KernelError, true); + assert.equal(result.reason.code, 'AUTHORITY_COORDINATOR_ASYNC_CALLBACK'); + assert.equal(result.reason.cause, markerFailure); + } + assert.deepEqual(writes, []); + assert.deepEqual(trace, [ + 'gate:open', + 'mark:AUTHORITY_COORDINATOR_ASYNC_CALLBACK', + 'gate:open', + ]); +}); + +test('a mutation enqueued by the terminal fault hook waits for the receipt commit', async () => { + const trace = []; + let follower; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + trace.push('gate:open'); + }, + markAuthorityUnhealthy() {}, + }); + + await coordinator.runExclusive(() => { + trace.push('terminal:domain-committed'); + const postDomainPreReceiptFaultHook = () => { + trace.push('fault-hook:enter'); + follower = coordinator.runExclusive(() => trace.push('follower:wrote')); + trace.push('fault-hook:exit'); + }; + postDomainPreReceiptFaultHook(); + trace.push('terminal:receipt-committed'); + }); + await follower; + + assert.deepEqual(trace, [ + 'gate:open', + 'terminal:domain-committed', + 'fault-hook:enter', + 'fault-hook:exit', + 'terminal:receipt-committed', + 'gate:open', + 'follower:wrote', + ]); +}); + +test('a terminal fault hook can close admission synchronously before its queued follower advances', async () => { + const trace = []; + let admissionOpen = true; + let follower; + const coordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + trace.push(`gate:${admissionOpen ? 'open' : 'closed'}`); + if (!admissionOpen) throw new KernelError('RECEIPT_PARITY_REQUIRED', 'receipt missing'); + }, + markAuthorityUnhealthy() {}, + }); + + const terminal = coordinator.runExclusive(() => { + trace.push('terminal:domain-committed'); + try { + const postDomainPreReceiptFaultHook = () => { + trace.push('fault-hook:enter'); + follower = coordinator.runExclusive(() => trace.push('follower:wrote')); + throw new Error('receipt signing fault'); + }; + postDomainPreReceiptFaultHook(); + trace.push('terminal:receipt-committed'); + } catch (error) { + admissionOpen = false; + trace.push('terminal:admission-closed'); + throw error; + } + }); + + await assert.rejects(terminal, /receipt signing fault/); + await assertRejectedCode(follower, 'RECEIPT_PARITY_REQUIRED'); + assert.deepEqual(trace, [ + 'gate:open', + 'terminal:domain-committed', + 'fault-hook:enter', + 'terminal:admission-closed', + 'gate:closed', + ]); +}); + +test('rejects malformed dependency records without invoking accessors or proxies', () => { + const valid = { + assertAdmissionOpen() {}, + markAuthorityUnhealthy() {}, + }; + const missingGate = { ...valid }; + delete missingGate.assertAdmissionOpen; + const missingFailStop = { ...valid }; + delete missingFailStop.markAuthorityUnhealthy; + const accessor = { ...valid }; + let accessorCalls = 0; + Object.defineProperty(accessor, 'assertAdmissionOpen', { + enumerable: true, + get() { + accessorCalls += 1; + return () => undefined; + }, + }); + let proxyTrapCalls = 0; + const proxy = new Proxy(valid, { + get(target, property, receiver) { + proxyTrapCalls += 1; + return Reflect.get(target, property, receiver); + }, + ownKeys(target) { + proxyTrapCalls += 1; + return Reflect.ownKeys(target); + }, + }); + + for (const options of [ + undefined, + null, + [], + missingGate, + missingFailStop, + { ...valid, extra: true }, + { ...valid, assertAdmissionOpen: true }, + { ...valid, markAuthorityUnhealthy: true }, + accessor, + proxy, + ]) { + assert.throws(() => createAuthorityMutationCoordinator(options), TypeError); + } + assert.equal(accessorCalls, 0); + assert.equal(proxyTrapCalls, 0); +}); diff --git a/spikes/pi-wielder/tests/kernel-budget.test.mjs b/spikes/pi-wielder/tests/kernel-budget.test.mjs new file mode 100644 index 0000000..95c0347 --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-budget.test.mjs @@ -0,0 +1,3185 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { createAgentEnrollmentRepository } from '../src/kernel/agent-enrollment.mjs'; +import { WalletSigningError } from '../src/adapters/wallet-adapter-contract.mjs'; +import { createBudgetLedger } from '../src/kernel/budget-ledger.mjs'; +import { canonicalJson, KernelError, sha256 } from '../src/kernel/canonical.mjs'; +import { createIntentRepository } from '../src/kernel/intent-builder.mjs'; +import { + evaluateSpendPolicy, + projectPaymentRequired, +} from '../src/kernel/policy-engine.mjs'; +import { createPolicyRepository } from '../src/kernel/policy-repository.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const NOW = '2026-07-31T12:00:00.000Z'; +const AFTER_EXPIRY = '2026-07-31T13:02:00.000Z'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const SELLER = 'https://seller.example'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const DESCRIPTOR_HASH = sha256(canonicalJson(DESCRIPTOR)); +const OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; +const ROUTE_METADATA = Object.freeze({ + 'paid-infer': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), +}); +const BASE_POLICY = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); +const RACE_FIXTURE = fileURLToPath(new URL('./fixtures/budget-writer.mjs', import.meta.url)); +const ZERO_BUDGET = Object.freeze({ + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + walletBlocked: false, +}); + +function testPolicy(overrides = {}) { + const document = structuredClone(BASE_POLICY); + Object.assign(document, overrides); + document.sellers[0] = { + ...document.sellers[0], + perRequestMaxAtomic: '1000000', + autoApproveAtomic: '1000000', + humanApproveAtomic: '1000000', + sellerSessionMaxAtomic: '1000000', + ...(overrides.seller ?? {}), + }; + delete document.seller; + document.sessionMaxAtomic = overrides.sessionMaxAtomic ?? '2000000'; + document.rolling24hMaxAtomic = overrides.rolling24hMaxAtomic ?? '5000000'; + return document; +} + +function paymentRequired(amountAtomic) { + return { + x402Version: 2, + error: 'seller prose is not persisted', + resource: { + url: `${SELLER}/paid/infer`, + description: 'offline fixture', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: NETWORK, + asset: ASSET, + amount: amountAtomic, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], + }; +} + +function sequenceIds() { + const counts = new Map(); + return (kind) => { + const next = (counts.get(kind) ?? 0) + 1; + counts.set(kind, next); + return `${kind}-${next}`; + }; +} + +function authority(t, prefix = 'wallet-kernel-budget-') { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.chmodSync(directory, 0o700); + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); + t.after(() => fs.rmSync(directory, { force: true, recursive: true })); + return { + directory, + databasePath: path.join(directory, 'kernel.sqlite'), + pathTrust, + }; +} + +function setup(t, { + fileAuthority = null, + policyDocument = testPolicy(), + now = () => NOW, +} = {}) { + const store = openKernelStore(fileAuthority ? { + filePath: fileAuthority.databasePath, + pathTrust: fileAuthority.pathTrust, + now, + } : { + filePath: ':memory:', + allowMemory: true, + now, + }); + t.after(() => { + try { store.close(); } catch {} + }); + const policies = createPolicyRepository(store); + const activePolicy = policies.apply(policyDocument, NOW).policyVersion; + const enrollments = createAgentEnrollmentRepository({ store, now }); + const enrolled = enrollments.enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: DESCRIPTOR_HASH, + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const intents = createIntentRepository({ + store, + idFactory: sequenceIds(), + now, + routeMetadata: ROUTE_METADATA, + }); + const session = intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: activePolicy.id, + }); + const ledger = createBudgetLedger({ store, now }); + return { + activePolicy, + enrolled, + enrollments, + intents, + ledger, + now, + policies, + policyDocument, + session, + store, + }; +} + +function rotateSessionPolicy(context) { + const document = testPolicy(); + document.challengeMaxAgeMs += 1; + const policyVersion = context.policies.apply( + document, + '2026-07-31T12:01:00.000Z', + ).policyVersion; + const blocked = context.intents.getSession(context.session.id); + assert.equal(blocked.state, 'policy_blocked'); + const transitioned = context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: policyVersion.id, + expectedSessionHash: blocked.sessionHash, + }) + )); + return Object.freeze({ + ...context, + activePolicy: policyVersion, + policyDocument: document, + session: transitioned.replacementSession, + }); +} + +function requestFor(label) { + return { + routeId: 'paid-infer', + method: 'POST', + requestUrl: `${SELLER}/paid/infer`, + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from(canonicalJson({ label }), 'utf8'), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-${label}`, + }; +} + +function authorizeIntent(context, { + amountAtomic, + label, + budgetSnapshot, +} = {}) { + const request = requestFor(label); + const captured = context.intents.captureIntent({ + sessionId: context.session.id, + ...request, + }); + const challenge = paymentRequired(amountAtomic); + context.intents.attachChallenge({ + intentId: captured.id, + paymentRequired: challenge, + challengeReceivedAt: NOW, + }); + const snapshot = budgetSnapshot ?? context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }); + const evaluation = evaluateSpendPolicy({ + policy: context.activePolicy.policy, + policyVersion: { + id: context.activePolicy.id, + hash: context.activePolicy.hash, + }, + intent: { + id: captured.id, + method: request.method, + requestUrl: request.requestUrl, + sellerOrigin: SELLER, + resourcePath: '/paid/infer', + walletAddress: WALLET, + }, + wallet: { + provider: 'deterministic', + walletId: 'buyer-a', + address: WALLET, + network: NETWORK, + }, + paymentRequired: challenge, + challengeReceivedAtMs: Date.parse(NOW), + nowMs: Date.parse(NOW), + budgetSnapshot: { + sellerSessionExposureAtomic: snapshot.sellerSessionExposureAtomic, + sessionExposureAtomic: snapshot.sessionExposureAtomic, + rolling24hExposureAtomic: snapshot.rolling24hExposureAtomic, + pendingApprovalCount: 0, + }, + }); + assert.equal(evaluation.decision, 'allow'); + context.store.transaction((token) => context.policies.recordDecisionInTransaction(token, { + intentId: captured.id, + policyVersionId: context.activePolicy.id, + evaluation, + decidedAt: NOW, + })); + context.intents.transition({ + intentId: captured.id, + expectedState: 'challenged', + nextState: 'authorized', + reasonCode: 'POLICY_ALLOWED', + }); + return Object.freeze({ + challenge, + decision: evaluation, + id: captured.id, + request, + }); +} + +function transitionToRetrying(context, intentId) { + for (const [expectedState, nextState] of [ + ['authorized', 'reserved'], + ['reserved', 'signing'], + ['signing', 'signed'], + ['signed', 'retrying'], + ]) { + context.intents.transition({ + intentId, + expectedState, + nextState, + reasonCode: `TEST_${nextState.toUpperCase()}`, + }); + } +} + +function seedRetryingPaymentAttempt(context, intent, { + paymentHeader = 'fixture-payment-header', +} = {}) { + const transitionAt = context.store.readOne( + 'SELECT updated_at FROM budget_reservations WHERE intent_id = ?', [intent.id], + )?.updated_at ?? NOW; + const paymentHash = sha256(Buffer.from(paymentHeader, 'ascii')); + const projection = projectPaymentRequired(intent.challenge); + const nonce = `0x${sha256(canonicalJson({ intentId: intent.id })).slice('sha256:'.length)}`; + const paymentPayload = { + x402Version: 2, + resource: intent.challenge.resource, + accepted: intent.challenge.accepts[0], + payload: { + signature: `0x${'11'.repeat(65)}`, + authorization: { + from: WALLET, + to: PAY_TO, + value: intent.decision.amountCeilingAtomic, + validAfter: '0', + validBefore: '1785502860', + nonce, + }, + }, + }; + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + payment_payload_json, payment_header, payment_hash, quote_id, nonce, + valid_after, valid_before, signing_claimed_at, signed_at, retry_started_at, + created_at, updated_at) + VALUES (?, ?, 'retrying', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + `payment-${intent.id}`, + intent.id, + canonicalJson(projection), + intent.decision.acceptedIndex, + canonicalJson(paymentPayload), + paymentHeader, + paymentHash, + intent.decision.quoteId, + nonce, + '0', + '1785502860', + transitionAt, + transitionAt, + transitionAt, + transitionAt, + transitionAt, + ); + })); + transitionToRetrying(context, intent.id); + return Object.freeze({ paymentHash, paymentHeader }); +} + +function seedClaimOnlySigningPaymentAttempt(context, intent, { + nonce = `0x${sha256(canonicalJson({ claimOnlyIntentId: intent.id })).slice('sha256:'.length)}`, + validAfter = '1785502800', + validBefore = '1785502860', + signingClaimedAt = NOW, +} = {}) { + const projection = projectPaymentRequired(intent.challenge); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, nonce, valid_after, valid_before, signing_claimed_at, + created_at, updated_at) + VALUES (?, ?, 'signing', ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + `payment-${intent.id}`, + intent.id, + canonicalJson(projection), + intent.decision.acceptedIndex, + intent.decision.quoteId, + nonce, + validAfter, + validBefore, + signingClaimedAt, + NOW, + signingClaimedAt, + ); + })); + context.intents.transition({ + intentId: intent.id, + expectedState: 'authorized', + nextState: 'reserved', + reasonCode: 'TEST_RESERVED', + }); + context.intents.transition({ + intentId: intent.id, + expectedState: 'reserved', + nextState: 'signing', + reasonCode: 'TEST_SIGNING', + }); + return Object.freeze({ nonce, signingClaimedAt, validAfter, validBefore }); +} + +function seedReservedPaymentAttempt(context, intent) { + const projection = projectPaymentRequired(intent.challenge); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, created_at, updated_at) + VALUES (?, ?, 'reserved', ?, ?, ?, ?, ?)`).run( + `payment-${intent.id}`, + intent.id, + canonicalJson(projection), + intent.decision.acceptedIndex, + intent.decision.quoteId, + NOW, + NOW, + ); + })); + context.intents.transition({ + intentId: intent.id, + expectedState: 'authorized', + nextState: 'reserved', + reasonCode: 'TEST_RESERVED', + }); +} + +function seedSignedPaymentAttempt(context, intent, { + paymentHeader = 'fixture-signed-payment-header', +} = {}) { + const paymentHash = sha256(Buffer.from(paymentHeader, 'ascii')); + const projection = projectPaymentRequired(intent.challenge); + const nonce = `0x${sha256(canonicalJson({ signedIntentId: intent.id })).slice('sha256:'.length)}`; + const paymentPayload = { + x402Version: 2, + resource: intent.challenge.resource, + accepted: intent.challenge.accepts[0], + payload: { + signature: `0x${'11'.repeat(65)}`, + authorization: { + from: WALLET, + to: PAY_TO, + value: intent.decision.amountCeilingAtomic, + validAfter: '0', + validBefore: '1785502860', + nonce, + }, + }, + }; + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + payment_payload_json, payment_header, payment_hash, quote_id, nonce, + valid_after, valid_before, signing_claimed_at, signed_at, created_at, updated_at) + VALUES (?, ?, 'signed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + `payment-${intent.id}`, + intent.id, + canonicalJson(projection), + intent.decision.acceptedIndex, + canonicalJson(paymentPayload), + paymentHeader, + paymentHash, + intent.decision.quoteId, + nonce, + '0', + '1785502860', + NOW, + NOW, + NOW, + NOW, + ); + })); + for (const [expectedState, nextState] of [ + ['authorized', 'reserved'], + ['reserved', 'signing'], + ['signing', 'signed'], + ]) { + context.intents.transition({ + intentId: intent.id, + expectedState, + nextState, + reasonCode: `TEST_${nextState.toUpperCase()}`, + }); + } + return Object.freeze({ paymentHash, paymentHeader }); +} + +function settlementEvidence({ intent, paymentHash, transactionPair = 'aa' }) { + return Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from(`settlement-${intent.id}`, 'ascii')), + success: true, + transaction: `0x${transactionPair.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: intent.decision.amountCeilingAtomic, + paymentHash, + }); +} + +function assertKernelError(operation, code) { + assert.throws(operation, (error) => { + assert.ok(error instanceof KernelError); + assert.equal(error.code, code); + return true; + }); +} + +function assertConserved(context, intentId) { + const row = context.store.readOne( + 'SELECT * FROM budget_reservations WHERE intent_id = ?', [intentId], + ); + assert.ok(row); + const ceiling = context.store.readOne( + 'SELECT amount_ceiling_atomic FROM policy_decisions WHERE intent_id = ?', [intentId], + ).amount_ceiling_atomic; + const total = BigInt(row.reserved_atomic) + + BigInt(row.committed_atomic) + + BigInt(row.released_atomic) + + BigInt(row.unresolved_atomic); + assert.equal(total.toString(), ceiling); + for (const value of [ + row.reserved_atomic, + row.committed_atomic, + row.released_atomic, + row.unresolved_atomic, + ]) assert.equal(typeof value, 'string'); +} + +function commitIntent(context, intent, transactionPair = 'aa') { + context.ledger.reserve({ + intentId: intent.id, + amountAtomic: intent.decision.amountCeilingAtomic, + }); + const signed = seedRetryingPaymentAttempt(context, intent); + const evidence = settlementEvidence({ intent, paymentHash: signed.paymentHash, transactionPair }); + context.ledger.commit({ intentId: intent.id, settlementEvidence: evidence }); + return Object.freeze({ evidence, signed }); +} + +function makeSignedUnresolved(context, intent, reasonCode = 'PAID_RESPONSE_AMBIGUOUS') { + context.ledger.reserve({ + intentId: intent.id, + amountAtomic: intent.decision.amountCeilingAtomic, + }); + const signed = seedRetryingPaymentAttempt(context, intent); + context.store.transaction((token) => { + const held = context.ledger.holdUnresolvedInTransaction(token, { + intentId: intent.id, + reasonCode, + }); + context.store.within(token, ({ db }) => { + const changed = db.prepare(`UPDATE payment_attempts + SET state = 'unresolved', reason_code = ?, updated_at = ? + WHERE intent_id = ? AND state = 'retrying'`).run(reasonCode, NOW, intent.id); + assert.equal(changed.changes, 1n); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_unresolved', ?, 1, ?)`).run(intent.id, reasonCode, NOW); + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'retrying', + nextState: 'unresolved', + reasonCode, + }); + return held; + }); + return signed; +} + +function localAttemptHash(context, intentId) { + const attempt = context.store.readOne( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', [intentId], + ); + const intent = context.store.readOne( + 'SELECT * FROM spend_intents WHERE id = ?', [intentId], + ); + const decision = context.store.readOne( + 'SELECT * FROM policy_decisions WHERE intent_id = ?', [intentId], + ); + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-attempt-binding.v1', + intentHash: intent.intent_hash, + challengeHash: intent.challenge_hash, + quoteId: attempt.quote_id, + paymentPayloadHash: sha256(attempt.payment_payload_json), + paymentHeaderHash: attempt.payment_hash, + network: context.activePolicy.policy.network, + payer: WALLET, + payee: PAY_TO, + asset: context.activePolicy.policy.asset, + amountAtomic: decision.amount_ceiling_atomic, + nonce: attempt.nonce, + validAfter: attempt.valid_after, + validBefore: attempt.valid_before, + })); +} + +function localRefundHash(context, intentId, refundTransactionId) { + const attempt = context.store.readOne( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', [intentId], + ); + const intent = context.store.readOne( + 'SELECT * FROM spend_intents WHERE id = ?', [intentId], + ); + const decision = context.store.readOne( + 'SELECT amount_ceiling_atomic FROM policy_decisions WHERE intent_id = ?', [intentId], + ); + const seller = context.activePolicy.policy.sellers[0]; + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-binding.v1', + intentHash: intent.intent_hash, + originalTransactionId: attempt.transaction_id, + refundTransactionId, + network: context.activePolicy.policy.network, + sellerOrigin: SELLER, + asset: context.activePolicy.policy.asset, + originalPayer: WALLET, + originalPayee: PAY_TO, + refundSource: seller.refundSource, + refundSigner: seller.refundSigner, + amountAtomic: decision.amount_ceiling_atomic, + })); +} + +function refundAttestation(context, intentId, originalTransactionId, refundTransactionId) { + const intent = context.store.readOne( + 'SELECT * FROM spend_intents WHERE id = ?', [intentId], + ); + const decision = context.store.readOne( + 'SELECT amount_ceiling_atomic FROM policy_decisions WHERE intent_id = ?', [intentId], + ); + const seller = context.activePolicy.policy.sellers[0]; + return Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.refund.v1', + network: context.activePolicy.policy.network, + sellerOrigin: SELLER, + intentHash: intent.intent_hash, + originalTransactionId, + refundTransactionId, + asset: context.activePolicy.policy.asset, + originalPayer: WALLET, + originalPayee: PAY_TO, + refundSource: seller.refundSource, + amountAtomic: decision.amount_ceiling_atomic, + issuedAt: NOW, + expiresAt: '2026-07-31T12:15:00.000Z', + signer: seller.refundSigner, + }); +} + +function insertReconciliation(context, { + id, + intentId, + kind, + outcome, + evidence, + recordedAt = NOW, +}) { + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + id, + intentId, + kind, + outcome, + canonicalJson(evidence), + OPERATOR_HASH, + recordedAt, + ); + })); +} + +function insertPaymentCandidate(context, { + intentId, + transactionId, + id = `candidate-${intentId}`, +}) { + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO payment_reconciliation_candidates + (id, intent_id, transaction_id, state, created_at, updated_at) + VALUES (?, ?, ?, 'pending', ?, ?)`).run( + id, + intentId, + transactionId, + NOW, + NOW, + ); + })); + return id; +} + +function settledTransferProof(context, intent, transactionId) { + return Object.freeze({ + kind: 'settled_transfer', + transactionId, + rpcProofHash: sha256(canonicalJson({ fixture: `settled-${intent.id}` })), + localAttemptHash: localAttemptHash(context, intent.id), + }); +} + +function unusedAuthorizationProof(context, intent) { + const attempt = context.store.readOne( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', [intent.id], + ); + return Object.freeze({ + kind: 'authorization_unused_after_expiry', + network: NETWORK, + asset: ASSET, + payer: WALLET, + nonce: attempt.nonce, + validBefore: attempt.valid_before, + authorizationState: false, + observedBlockNumber: '1234570', + observedBlockHash: `0x${'ef'.repeat(32)}`, + observedBlockTimestamp: attempt.valid_before, + confirmations: 3, + }); +} + +function eventHead(context) { + return context.store.events().map((event) => event.event_hash); +} + +function plainRow(row) { + return row === undefined ? undefined : { ...row }; +} + +function childResult(child) { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal, stderr, stdout })); + }); +} + +async function waitForFiles(files, timeoutMilliseconds = 5_000) { + const deadline = Date.now() + timeoutMilliseconds; + while (!files.every((file) => fs.existsSync(file))) { + if (Date.now() >= deadline) throw new Error('timed out waiting for budget writers'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +test('conserves the full ceiling through reserve, commit, release, and unresolved hold', (t) => { + const context = setup(t); + assert.deepEqual(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }), { + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + walletBlocked: false, + }); + + const first = authorizeIntent(context, { amountAtomic: '250000', label: 'first' }); + context.ledger.reserve({ intentId: first.id, amountAtomic: '250000' }); + assertConserved(context, first.id); + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).sessionExposureAtomic, '250000'); + const signed = seedRetryingPaymentAttempt(context, first); + context.ledger.commit({ + intentId: first.id, + settlementEvidence: settlementEvidence({ intent: first, paymentHash: signed.paymentHash }), + }); + assertConserved(context, first.id); + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).rolling24hExposureAtomic, '250000'); + + const second = authorizeIntent(context, { amountAtomic: '300000', label: 'second' }); + context.ledger.reserve({ intentId: second.id, amountAtomic: '300000' }); + assertConserved(context, second.id); + context.ledger.release({ intentId: second.id, reasonCode: 'SIGNER_REJECTED' }); + assertConserved(context, second.id); + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).sessionExposureAtomic, '250000'); + + const third = authorizeIntent(context, { amountAtomic: '400000', label: 'third' }); + context.ledger.reserve({ intentId: third.id, amountAtomic: '400000' }); + assertConserved(context, third.id); + context.ledger.holdUnresolved({ + intentId: third.id, + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + }); + assertConserved(context, third.id); + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + + const fourth = authorizeIntent(context, { amountAtomic: '1', label: 'fourth' }); + assertKernelError( + () => context.ledger.reserve({ intentId: fourth.id, amountAtomic: '1' }), + 'WALLET_UNRESOLVED', + ); + + for (const row of context.store.readAll('SELECT * FROM budget_reservations ORDER BY rowid')) { + const total = BigInt(row.reserved_atomic) + + BigInt(row.committed_atomic) + + BigInt(row.released_atomic) + + BigInt(row.unresolved_atomic); + const ceiling = context.store.readOne( + 'SELECT amount_ceiling_atomic FROM policy_decisions WHERE intent_id = ?', + [row.intent_id], + ).amount_ceiling_atomic; + assert.equal(total.toString(), ceiling); + for (const value of [ + row.reserved_atomic, + row.committed_atomic, + row.released_atomic, + row.unresolved_atomic, + ]) assert.equal(typeof value, 'string'); + } + assert.equal(context.store.verifyEventChain(), true); +}); + +test('a Spend Intent cannot reserve twice and an amount cannot substitute its decision ceiling', (t) => { + const context = setup(t); + const intent = authorizeIntent(context, { amountAtomic: '250000', label: 'duplicate' }); + assertKernelError( + () => context.ledger.reserve({ intentId: intent.id, amountAtomic: '249999' }), + 'BUDGET_AMOUNT_MISMATCH', + ); + context.ledger.reserve({ intentId: intent.id, amountAtomic: '250000' }); + assertKernelError( + () => context.ledger.reserve({ intentId: intent.id, amountAtomic: '250000' }), + 'BUDGET_ALREADY_RESERVED', + ); +}); + +test('reserved budgets expose only exact in-flight PaymentAttempt states', async (t) => { + const snapshotAt = '2026-07-31T12:00:02.000Z'; + const before = '2026-07-31T11:59:59.000Z'; + const after = '2026-07-31T12:00:01.000Z'; + + const fixture = (st, state, label) => { + const context = setup(st); + const intent = authorizeIntent(context, { + amountAtomic: '100', + label: `reserved-attempt-${state}-${label}`, + }); + context.ledger.reserve({ intentId: intent.id, amountAtomic: '100' }); + if (state === 'reserved') seedReservedPaymentAttempt(context, intent); + else if (state === 'signing') seedClaimOnlySigningPaymentAttempt(context, intent); + else if (state === 'signed') seedSignedPaymentAttempt(context, intent); + else if (state === 'retrying') seedRetryingPaymentAttempt(context, intent); + return { context, intent }; + }; + + const assertVisible = (context, intent) => { + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: snapshotAt, + }).sessionExposureAtomic, '100'); + assertKernelError(() => context.ledger.reserve({ + intentId: intent.id, + amountAtomic: '100', + }), 'BUDGET_ALREADY_RESERVED'); + }; + + const assertCorrupt = (context, intent) => { + assertKernelError(() => context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: snapshotAt, + }), 'BUDGET_CORRUPTION'); + assertKernelError(() => context.ledger.reserve({ + intentId: intent.id, + amountAtomic: '100', + }), 'BUDGET_CORRUPTION'); + }; + + await t.test('no PaymentAttempt is visible before its aggregate insert', (st) => { + const { context, intent } = fixture(st, 'none', 'legal'); + assertVisible(context, intent); + }); + + for (const state of ['reserved', 'signing', 'signed', 'retrying']) { + await t.test(`${state} exact material is visible`, (st) => { + const { context, intent } = fixture(st, state, 'legal'); + assertVisible(context, intent); + }); + } + + const corruptions = { + reserved: [ + ['challenge binding substitution', "quote_id = 'quote-substituted'"], + ['created before reservation', `created_at = '${before}'`], + ['updatedAt detached from reservation', `updated_at = '${after}'`], + ['unexpected terminal reason', "reason_code = 'UNEXPECTED_REASON'"], + ], + signing: [ + ['partial signing claim', 'nonce = NULL'], + ['signing claim before creation', `signing_claimed_at = '${before}'`], + ['updatedAt detached from signing claim', `updated_at = '${after}'`], + ['unexpected terminal reason', "reason_code = 'UNEXPECTED_REASON'"], + ], + signed: [ + ['payment hash substitution', `payment_hash = 'sha256:${'00'.repeat(32)}'`], + ['signedAt before signing claim', `signed_at = '${before}'`], + ['updatedAt detached from signed transition', `updated_at = '${after}'`], + ['unexpected terminal reason', "reason_code = 'UNEXPECTED_REASON'"], + ], + retrying: [ + ['missing retry timestamp', 'retry_started_at = NULL'], + ['retry before signed bytes', `retry_started_at = '${before}'`], + ['updatedAt detached from retry transition', `updated_at = '${after}'`], + ['unexpected terminal reason', "reason_code = 'UNEXPECTED_REASON'"], + ], + }; + + for (const [state, cases] of Object.entries(corruptions)) { + for (const [name, assignment] of cases) { + await t.test(`${state}: ${name}`, (st) => { + const { context, intent } = fixture(st, state, name.replaceAll(' ', '-')); + context.store.execForTest(`UPDATE payment_attempts SET ${assignment} + WHERE intent_id = '${intent.id}'`); + assertCorrupt(context, intent); + }); + } + } + + for (const illegalState of ['unresolved', 'rejected', 'settled']) { + await t.test(`reserved budget rejects ${illegalState} PaymentAttempt`, (st) => { + const sourceState = illegalState === 'rejected' ? 'reserved' : 'retrying'; + const { context, intent } = fixture(st, sourceState, `illegal-${illegalState}`); + if (illegalState === 'unresolved') { + context.store.execForTest(`UPDATE payment_attempts + SET state = 'unresolved', reason_code = 'PAID_RESPONSE_AMBIGUOUS' + WHERE intent_id = '${intent.id}'`); + } else if (illegalState === 'rejected') { + context.store.execForTest(`UPDATE payment_attempts + SET state = 'rejected', reason_code = 'SIGNER_REJECTED' + WHERE intent_id = '${intent.id}'`); + } else { + context.store.execForTest(`UPDATE payment_attempts SET state = 'settled', + settlement_json = '{}', transaction_id = '0x${'ab'.repeat(32)}', + settled_at = '${NOW}' WHERE intent_id = '${intent.id}'`); + } + assertCorrupt(context, intent); + }); + } +}); + +test('reservation rejects a local clock before its immutable challenge and decision', (t) => { + let clock = NOW; + const context = setup(t, { now: () => clock }); + const intent = authorizeIntent(context, { amountAtomic: '100', label: 'regressed-clock' }); + const before = eventHead(context); + clock = '2026-07-31T11:59:59.999Z'; + assertKernelError(() => context.ledger.reserve({ + intentId: intent.id, + amountAtomic: '100', + }), 'BUDGET_TIME'); + assert.equal(context.store.readOne( + 'SELECT intent_id FROM budget_reservations WHERE intent_id = ?', [intent.id], + ), undefined); + assert.deepEqual(eventHead(context), before); +}); + +test('seller, session, and rolling ceilings accept exact totals and reject one atomic over', (t) => { + const cases = [ + { + label: 'seller', + policy: testPolicy({ + seller: { sellerSessionMaxAtomic: '1000000' }, + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '5000000', + }), + }, + { + label: 'session', + policy: testPolicy({ + seller: { sellerSessionMaxAtomic: '2000000' }, + sessionMaxAtomic: '1000000', + rolling24hMaxAtomic: '5000000', + }), + }, + { + label: 'rolling', + policy: testPolicy({ + seller: { sellerSessionMaxAtomic: '2000000' }, + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '1000000', + }), + }, + ]; + + for (const fixture of cases) { + const context = setup(t, { policyDocument: fixture.policy }); + const first = authorizeIntent(context, { + amountAtomic: '600000', + label: `${fixture.label}-first`, + budgetSnapshot: ZERO_BUDGET, + }); + const exact = authorizeIntent(context, { + amountAtomic: '400000', + label: `${fixture.label}-exact`, + budgetSnapshot: ZERO_BUDGET, + }); + const over = authorizeIntent(context, { + amountAtomic: '1', + label: `${fixture.label}-over`, + budgetSnapshot: ZERO_BUDGET, + }); + context.ledger.reserve({ intentId: first.id, amountAtomic: '600000' }); + context.ledger.reserve({ intentId: exact.id, amountAtomic: '400000' }); + assertKernelError( + () => context.ledger.reserve({ intentId: over.id, amountAtomic: '1' }), + 'LIMIT_EXCEEDED', + ); + const snapshot = context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }); + assert.equal(snapshot.sellerSessionExposureAtomic, '1000000'); + assert.equal(snapshot.sessionExposureAtomic, '1000000'); + assert.equal(snapshot.rolling24hExposureAtomic, '1000000'); + } +}); + +test('rolling exposure excludes the exact 24-hour boundary but retains every active hold', (t) => { + let clock = NOW; + const context = setup(t, { now: () => clock }); + const boundary = authorizeIntent(context, { + amountAtomic: '100', + label: 'boundary', + budgetSnapshot: ZERO_BUDGET, + }); + const inside = authorizeIntent(context, { + amountAtomic: '200', + label: 'inside', + budgetSnapshot: ZERO_BUDGET, + }); + const oldActive = authorizeIntent(context, { + amountAtomic: '300', + label: 'old-active', + budgetSnapshot: ZERO_BUDGET, + }); + context.ledger.reserve({ intentId: oldActive.id, amountAtomic: '300' }); + commitIntent(context, boundary, 'b1'); + clock = '2026-07-31T12:00:00.001Z'; + commitIntent(context, inside, 'b2'); + const snapshot = context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: '2026-08-01T12:00:00.000Z', + }); + assert.deepEqual(snapshot, { + sellerSessionExposureAtomic: '600', + sessionExposureAtomic: '600', + rolling24hExposureAtomic: '500', + walletBlocked: false, + }); +}); + +test('execution and refund blockers are wallet-wide and both must close', (t) => { + const refundContext = setup(t); + const paid = authorizeIntent(refundContext, { + amountAtomic: '250000', + label: 'blocked-refund-paid', + }); + const { evidence } = commitIntent(refundContext, paid); + const target = authorizeIntent(refundContext, { + amountAtomic: '1', + label: 'blocked-refund-target', + budgetSnapshot: ZERO_BUDGET, + }); + refundContext.store.transaction((token) => refundContext.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'failed', 500, ?, ?, ?)`).run( + paid.id, + sha256(Buffer.from('failed response')), + canonicalJson({ source: 'test' }), + NOW, + ); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at) + VALUES (?, 'refund_pending', 'UPSTREAM_FAILED', 1, ?)`).run(paid.id, NOW); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', ?, ?)`).run( + `refund-${paid.id}`, + paid.id, + evidence.transaction, + paid.decision.amountCeilingAtomic, + NOW, + NOW, + ); + })); + + assert.equal(refundContext.ledger.snapshot({ + sessionId: refundContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + assertKernelError( + () => refundContext.ledger.reserve({ intentId: target.id, amountAtomic: '1' }), + 'WALLET_RESOLUTION_REQUIRED', + ); + + refundContext.store.transaction((token) => refundContext.store.within(token, ({ db }) => { + db.prepare(`UPDATE refunds + SET state = 'confirmed', evidence_json = ?, refund_transaction_id = ?, updated_at = ? + WHERE intent_id = ?`).run( + canonicalJson({ source: 'test-only' }), + `0x${'ef'.repeat(32)}`, + NOW, + paid.id, + ); + })); + assertKernelError(() => refundContext.ledger.snapshot({ + sessionId: refundContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + + const splitContext = setup(t); + const splitPaid = authorizeIntent(splitContext, { + amountAtomic: '100', + label: 'split-resolution-paid', + }); + const splitPayment = commitIntent(splitContext, splitPaid, 'dd'); + splitContext.store.transaction((token) => splitContext.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'failed', 500, ?, ?, ?)`).run( + splitPaid.id, + sha256(Buffer.from('split failed response')), + canonicalJson({ source: 'test' }), + NOW, + ); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at, resolved_at) + VALUES (?, 'resolved', 'UPSTREAM_FAILED', 0, ?, ?)`).run(splitPaid.id, NOW, NOW); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, created_at, updated_at) + VALUES (?, ?, ?, ?, 'rejected', ?, ?)`).run( + `refund-${splitPaid.id}`, + splitPaid.id, + splitPayment.evidence.transaction, + splitPaid.decision.amountCeilingAtomic, + NOW, + NOW, + ); + })); + assertKernelError(() => splitContext.ledger.snapshot({ + sessionId: splitContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + + const unknownContext = setup(t); + const unknown = authorizeIntent(unknownContext, { + amountAtomic: '100', + label: 'blocked-unknown-paid', + }); + commitIntent(unknownContext, unknown, 'de'); + const unknownTarget = authorizeIntent(unknownContext, { + amountAtomic: '1', + label: 'blocked-unknown-target', + budgetSnapshot: ZERO_BUDGET, + }); + unknownContext.store.transaction((token) => unknownContext.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'unknown', NULL, NULL, ?, ?)`).run( + unknown.id, + canonicalJson({ source: 'test' }), + NOW, + ); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at) + VALUES (?, 'reconciliation_required', 'EXECUTION_UNKNOWN', 1, ?)`).run( + unknown.id, + NOW, + ); + })); + assert.equal(unknownContext.ledger.snapshot({ + sessionId: unknownContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + assertKernelError( + () => unknownContext.ledger.reserve({ intentId: unknownTarget.id, amountAtomic: '1' }), + 'WALLET_RESOLUTION_REQUIRED', + ); + unknownContext.store.transaction((token) => unknownContext.store.within(token, ({ db }) => { + db.prepare(`UPDATE execution_outcomes + SET state = 'succeeded', http_status = 200, response_hash = ?, recorded_at = ? + WHERE intent_id = ?`).run( + sha256(Buffer.from('late execution evidence')), + NOW, + unknown.id, + ); + })); + assertKernelError(() => unknownContext.ledger.snapshot({ + sessionId: unknownContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + unknownContext.store.transaction((token) => unknownContext.store.within(token, ({ db }) => { + db.prepare(`UPDATE execution_resolutions + SET state = 'resolved', blocks_wallet = 0, resolved_at = ? + WHERE intent_id = ?`).run(NOW, unknown.id); + })); + assertKernelError(() => unknownContext.ledger.snapshot({ + sessionId: unknownContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + assert.equal(unknownContext.store.readOne( + 'SELECT intent_id FROM budget_reservations WHERE intent_id = ?', [unknownTarget.id], + ), undefined); +}); + +test('a blocker in a replacement Spend Session blocks every session for the wallet', (t) => { + const originalContext = setup(t); + const replacementContext = rotateSessionPolicy(originalContext); + const held = authorizeIntent(replacementContext, { + amountAtomic: '100', + label: 'replacement-session-hold', + }); + makeSignedUnresolved(replacementContext, held); + + assert.equal(originalContext.ledger.snapshot({ + sessionId: originalContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + const target = authorizeIntent(replacementContext, { + amountAtomic: '1', + label: 'replacement-session-target', + budgetSnapshot: ZERO_BUDGET, + }); + assertKernelError(() => replacementContext.ledger.reserve({ + intentId: target.id, + amountAtomic: '1', + }), 'WALLET_UNRESOLVED'); + assert.equal(replacementContext.store.readOne( + 'SELECT intent_id FROM budget_reservations WHERE intent_id = ?', [target.id], + ), undefined); +}); + +test('file-backed snapshots survive reopen without numeric monetary projections', (t) => { + const fileAuthority = authority(t, 'wallet-kernel-budget-reopen-'); + const context = setup(t, { fileAuthority }); + const intent = authorizeIntent(context, { amountAtomic: '250000', label: 'reopen' }); + context.ledger.reserve({ intentId: intent.id, amountAtomic: '250000' }); + const before = context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }); + context.store.close(); + + const reopened = openKernelStore({ + filePath: fileAuthority.databasePath, + pathTrust: fileAuthority.pathTrust, + now: () => NOW, + }); + try { + const after = createBudgetLedger({ store: reopened, now: () => NOW }).snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }); + assert.deepEqual(after, before); + for (const [key, value] of Object.entries(after)) { + if (key.endsWith('Atomic')) assert.equal(typeof value, 'string'); + } + } finally { + reopened.close(); + } +}); + +test('two processes cannot oversubscribe one seller/session ceiling', async (t) => { + const fileAuthority = authority(t, 'wallet-kernel-budget-race-'); + const context = setup(t, { fileAuthority }); + const first = authorizeIntent(context, { + amountAtomic: '600000', + label: 'race-first', + budgetSnapshot: ZERO_BUDGET, + }); + const second = authorizeIntent(context, { + amountAtomic: '600000', + label: 'race-second', + budgetSnapshot: ZERO_BUDGET, + }); + context.store.close(); + + const releaseFile = path.join(fileAuthority.directory, 'release'); + const readyFiles = [ + path.join(fileAuthority.directory, 'ready-first'), + path.join(fileAuthority.directory, 'ready-second'), + ]; + const children = [first, second].map((intent, index) => spawn(process.execPath, [ + '--no-warnings', + RACE_FIXTURE, + fileAuthority.databasePath, + fileAuthority.directory, + intent.id, + '600000', + NOW, + readyFiles[index], + releaseFile, + ], { stdio: ['ignore', 'pipe', 'pipe'] })); + const resultsPromise = Promise.all(children.map(childResult)); + await waitForFiles(readyFiles); + fs.writeFileSync(releaseFile, 'release', { flag: 'wx', mode: 0o600 }); + const results = await resultsPromise; + for (const result of results) { + assert.equal(result.code, 0, result.stderr); + assert.equal(result.signal, null); + assert.equal(result.stderr, ''); + } + assert.deepEqual( + results.map((result) => result.stdout.trim()).sort(), + ['LIMIT_EXCEEDED', 'reserved'], + ); + + const reopened = openKernelStore({ + filePath: fileAuthority.databasePath, + pathTrust: fileAuthority.pathTrust, + now: () => NOW, + }); + try { + const rows = reopened.readAll('SELECT * FROM budget_reservations ORDER BY intent_id'); + assert.equal(rows.length, 1); + assert.equal(rows[0].reserved_atomic, '600000'); + assert.equal(reopened.verifyEventChain(), true); + } finally { + reopened.close(); + } +}); + +test('commit is exact-replay idempotent and rejects state, binding, and transaction reuse', (t) => { + const context = setup(t); + const first = authorizeIntent(context, { amountAtomic: '250000', label: 'commit-first' }); + const committed = commitIntent(context, first, 'aa'); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, transaction_id, settlement_json FROM payment_attempts + WHERE intent_id = ?`, [first.id], + )), { + state: 'settled', + transaction_id: committed.evidence.transaction, + settlement_json: canonicalJson(committed.evidence), + }); + assertConserved(context, first.id); + const eventsAfterCommit = context.store.events(); + const replay = context.ledger.commit({ + intentId: first.id, + settlementEvidence: committed.evidence, + }); + assert.equal(replay.state, 'committed'); + assert.deepEqual(context.store.events(), eventsAfterCommit); + assertKernelError(() => context.ledger.commit({ + intentId: first.id, + settlementEvidence: Object.freeze({ + ...committed.evidence, + transaction: `0x${'bb'.repeat(32)}`, + }), + }), 'BUDGET_IDEMPOTENCY_CONFLICT'); + + const second = authorizeIntent(context, { + amountAtomic: '250000', + label: 'commit-second', + budgetSnapshot: ZERO_BUDGET, + }); + context.ledger.reserve({ intentId: second.id, amountAtomic: '250000' }); + const signedSecond = seedRetryingPaymentAttempt(context, second); + assertKernelError(() => context.ledger.commit({ + intentId: second.id, + settlementEvidence: Object.freeze({ + ...settlementEvidence({ intent: second, paymentHash: signedSecond.paymentHash }), + transaction: `0x${'AA'.repeat(32)}`, + }), + }), 'TRANSACTION_REUSED'); + + const missing = authorizeIntent(context, { + amountAtomic: '1', + label: 'commit-missing', + budgetSnapshot: ZERO_BUDGET, + }); + context.ledger.reserve({ intentId: missing.id, amountAtomic: '1' }); + transitionToRetrying(context, missing.id); + assertKernelError(() => context.ledger.commit({ + intentId: missing.id, + settlementEvidence: settlementEvidence({ + intent: missing, + paymentHash: `sha256:${'12'.repeat(32)}`, + transactionPair: 'cc', + }), + }), 'PAYMENT_ATTEMPT_MISSING'); + + const bound = authorizeIntent(context, { + amountAtomic: '1', + label: 'commit-bound', + budgetSnapshot: ZERO_BUDGET, + }); + context.ledger.reserve({ intentId: bound.id, amountAtomic: '1' }); + const signedBound = seedRetryingPaymentAttempt(context, bound); + assertKernelError(() => context.ledger.commit({ + intentId: bound.id, + settlementEvidence: Object.freeze({ + ...settlementEvidence({ + intent: bound, + paymentHash: signedBound.paymentHash, + transactionPair: 'dd', + }), + payer: '0x4000000000000000000000000000000000000000', + }), + }), 'SETTLEMENT_BINDING_MISMATCH'); + assertKernelError(() => context.ledger.commit({ + intentId: bound.id, + settlementEvidence: Object.freeze({ + ...settlementEvidence({ + intent: bound, + paymentHash: signedBound.paymentHash, + transactionPair: 'dd', + }), + amountAtomic: '2', + }), + }), 'SETTLEMENT_BINDING_MISMATCH'); + assertKernelError(() => context.ledger.commit({ + intentId: bound.id, + settlementEvidence: Object.freeze({ + ...settlementEvidence({ + intent: bound, + paymentHash: signedBound.paymentHash, + transactionPair: 'dd', + }), + injected: true, + }), + }), 'SETTLEMENT_EVIDENCE'); + + const wrongStateContext = setup(t); + const wrongState = authorizeIntent(wrongStateContext, { + amountAtomic: '1', + label: 'commit-wrong-attempt-state', + budgetSnapshot: ZERO_BUDGET, + }); + wrongStateContext.ledger.reserve({ intentId: wrongState.id, amountAtomic: '1' }); + const signedWrongState = seedRetryingPaymentAttempt(wrongStateContext, wrongState); + wrongStateContext.store.transaction((token) => wrongStateContext.store.within(token, ({ db }) => { + db.prepare("UPDATE payment_attempts SET state = 'signed' WHERE intent_id = ?") + .run(wrongState.id); + })); + assertKernelError(() => wrongStateContext.ledger.commit({ + intentId: wrongState.id, + settlementEvidence: settlementEvidence({ + intent: wrongState, + paymentHash: signedWrongState.paymentHash, + transactionPair: 'de', + }), + }), 'BUDGET_CORRUPTION'); + assert.equal(wrongStateContext.store.readOne( + 'SELECT transaction_id FROM payment_attempts WHERE intent_id = ?', [wrongState.id], + ).transaction_id, null); + assert.equal(wrongStateContext.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [wrongState.id], + ).state, 'reserved'); + + const atomic = authorizeIntent(context, { + amountAtomic: '1', + label: 'commit-atomic-attempt', + budgetSnapshot: ZERO_BUDGET, + }); + context.ledger.reserve({ intentId: atomic.id, amountAtomic: '1' }); + const signedAtomic = seedRetryingPaymentAttempt(context, atomic); + const atomicEvidence = settlementEvidence({ + intent: atomic, + paymentHash: signedAtomic.paymentHash, + transactionPair: 'df', + }); + const beforeAtomic = eventHead(context); + assert.throws(() => context.store.transaction((token) => { + context.ledger.commitInTransaction(token, { + intentId: atomic.id, + settlementEvidence: atomicEvidence, + }); + throw new Error('commit aggregate rollback'); + }), /commit aggregate rollback/); + assert.deepEqual(eventHead(context), beforeAtomic); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, transaction_id, settlement_json FROM payment_attempts + WHERE intent_id = ?`, [atomic.id], + )), { state: 'retrying', transaction_id: null, settlement_json: null }); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [atomic.id], + ).state, 'reserved'); + assertConserved(context, atomic.id); +}); + +test('snapshot and exact commit replay reject every persisted payment chronology break', async (t) => { + const before = '2026-07-31T11:59:59.000Z'; + const after = '2026-07-31T12:00:01.000Z'; + const snapshotAt = '2026-07-31T12:00:02.000Z'; + const corruptions = [ + { + name: 'signedAt before its signing claim', + assignment: `signed_at = '${before}'`, + replayCode: 'BUDGET_CORRUPTION', + }, + { + name: 'signedAt after updatedAt', + assignment: `signed_at = '${after}'`, + replayCode: 'BUDGET_CORRUPTION', + }, + { + name: 'retryStartedAt before signedAt', + assignment: `retry_started_at = '${before}'`, + replayCode: 'BUDGET_CORRUPTION', + }, + { + name: 'retryStartedAt after updatedAt', + assignment: `retry_started_at = '${after}'`, + replayCode: 'BUDGET_CORRUPTION', + }, + { + name: 'settledAt before retryStartedAt', + assignment: `settled_at = '${before}'`, + replayCode: 'BUDGET_CORRUPTION', + }, + { + name: 'settledAt after updatedAt', + assignment: `settled_at = '${after}'`, + replayCode: 'BUDGET_CORRUPTION', + }, + { + name: 'settled transition detached from its commit event', + assignment: `settled_at = '${after}', updated_at = '${after}'`, + replayCode: 'BUDGET_CORRUPTION', + }, + ]; + + const corruptedCommitted = (st, corruption, operation) => { + const context = setup(st); + const intent = authorizeIntent(context, { + amountAtomic: '100', + label: `chronology-${operation}-${corruption.name.replaceAll(' ', '-')}`, + }); + const committed = commitIntent(context, intent, '4a'); + context.store.execForTest(`UPDATE payment_attempts SET ${corruption.assignment} + WHERE intent_id = '${intent.id}'`); + return { committed, context, intent }; + }; + + for (const corruption of corruptions) { + await t.test(`${corruption.name}: snapshot`, (st) => { + const { context } = corruptedCommitted(st, corruption, 'snapshot'); + assertKernelError(() => context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: snapshotAt, + }), 'BUDGET_CORRUPTION'); + }); + + await t.test(`${corruption.name}: exact commit replay`, (st) => { + const { committed, context, intent } = corruptedCommitted(st, corruption, 'replay'); + assertKernelError(() => context.ledger.commit({ + intentId: intent.id, + settlementEvidence: committed.evidence, + }), corruption.replayCode); + }); + } +}); + +test('snapshots reject unresolved and trusted-rejected attempt chronology corruption', async (t) => { + const before = '2026-07-31T11:59:59.000Z'; + const corruptions = [ + { + name: 'signedAt after updatedAt', + assignment(after) { return `signed_at = '${after}'`; }, + }, + { + name: 'retryStartedAt before signedAt', + assignment() { return `retry_started_at = '${before}'`; }, + }, + { + name: 'updatedAt detached from terminal transition', + assignment(after) { return `updated_at = '${after}'`; }, + }, + ]; + + const unresolvedFixture = (st, corruption) => { + const context = setup(st); + const intent = authorizeIntent(context, { + amountAtomic: '100', + label: `unresolved-history-${corruption.name.replaceAll(' ', '-')}`, + }); + makeSignedUnresolved(context, intent); + const after = '2026-07-31T12:00:01.000Z'; + context.store.execForTest(`UPDATE payment_attempts + SET ${corruption.assignment(after)} WHERE intent_id = '${intent.id}'`); + return { context, snapshotAt: '2026-07-31T12:00:02.000Z' }; + }; + + const rejectedFixture = (st, corruption) => { + let clock = NOW; + const context = setup(st, { now: () => clock }); + const intent = authorizeIntent(context, { + amountAtomic: '100', + label: `rejected-history-${corruption.name.replaceAll(' ', '-')}`, + }); + makeSignedUnresolved(context, intent); + insertPaymentCandidate(context, { + intentId: intent.id, + transactionId: `0x${'62'.repeat(32)}`, + }); + insertReconciliation(context, { + id: `rejected-history-proof-${intent.id}`, + intentId: intent.id, + kind: 'payment', + outcome: 'rejected', + evidence: unusedAuthorizationProof(context, intent), + recordedAt: AFTER_EXPIRY, + }); + clock = AFTER_EXPIRY; + context.store.transaction((token) => { + context.ledger.resolvePaymentInTransaction(token, { + intentId: intent.id, + outcome: 'rejected', + evidenceId: `rejected-history-proof-${intent.id}`, + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode: 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + }); + }); + const after = '2026-07-31T13:02:01.000Z'; + context.store.execForTest(`UPDATE payment_attempts + SET ${corruption.assignment(after)} WHERE intent_id = '${intent.id}'`); + return { context, snapshotAt: '2026-07-31T13:02:02.000Z' }; + }; + + for (const [state, fixture] of [ + ['unresolved', unresolvedFixture], + ['trusted-rejected', rejectedFixture], + ]) { + for (const corruption of corruptions) { + await t.test(`${state}: ${corruption.name}`, (st) => { + const { context, snapshotAt } = fixture(st, corruption); + assertKernelError(() => context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: snapshotAt, + }), 'BUDGET_CORRUPTION'); + }); + } + } +}); + +test('release and unresolved hold have exact replay semantics and never trust shaped errors', (t) => { + const context = setup(t); + const released = authorizeIntent(context, { amountAtomic: '100', label: 'released' }); + context.ledger.reserve({ intentId: released.id, amountAtomic: '100' }); + const releasedProjection = context.ledger.release({ + intentId: released.id, + reasonCode: 'UNSIGNED_CANCELLED', + }); + const eventsAfterRelease = context.store.events(); + assert.equal(releasedProjection.state, 'released'); + assert.deepEqual(context.ledger.release({ + intentId: released.id, + reasonCode: 'UNSIGNED_CANCELLED', + }), releasedProjection); + assert.deepEqual(context.store.events(), eventsAfterRelease); + assertKernelError(() => context.ledger.release({ + intentId: released.id, + reasonCode: 'DIFFERENT_REASON', + }), 'BUDGET_IDEMPOTENCY_CONFLICT'); + + const shaped = authorizeIntent(context, { amountAtomic: '300', label: 'shaped' }); + context.ledger.reserve({ intentId: shaped.id, amountAtomic: '300' }); + const held = authorizeIntent(context, { amountAtomic: '200', label: 'held' }); + context.ledger.reserve({ intentId: held.id, amountAtomic: '200' }); + const heldProjection = context.ledger.holdUnresolved({ + intentId: held.id, + reasonCode: 'SIGNATURE_MAY_EXIST', + }); + const eventsAfterHold = context.store.events(); + assert.equal(heldProjection.state, 'unresolved'); + assert.deepEqual(context.ledger.holdUnresolved({ + intentId: held.id, + reasonCode: 'SIGNATURE_MAY_EXIST', + }), heldProjection); + assert.deepEqual(context.store.events(), eventsAfterHold); + assertKernelError(() => context.ledger.holdUnresolved({ + intentId: held.id, + reasonCode: 'DIFFERENT_REASON', + }), 'BUDGET_IDEMPOTENCY_CONFLICT'); + assertKernelError(() => context.ledger.release({ + intentId: held.id, + reasonCode: 'UNSAFE_RELEASE', + }), 'BUDGET_RELEASE_UNSAFE'); + + const beforeRows = context.store.readAll( + 'SELECT * FROM budget_reservations WHERE intent_id = ?', [shaped.id], + ); + const beforeEvents = context.store.events(); + assertKernelError(() => context.store.transaction((token) => ( + context.ledger.releaseInTransaction(token, { + intentId: shaped.id, + reasonCode: 'SIGNER_REJECTED', + preSignRejection: { + code: 'WALLET_PRE_SIGN_REJECTED', + signatureMayExist: false, + }, + }) + )), 'BUDGET_RELEASE_UNSAFE'); + assert.deepEqual(context.store.readAll( + 'SELECT * FROM budget_reservations WHERE intent_id = ?', [shaped.id], + ), beforeRows); + assert.deepEqual(context.store.events(), beforeEvents); +}); + +test('claim-only signing releases only for the exact imported pre-sign rejection type', (t) => { + const context = setup(t); + const intent = authorizeIntent(context, { + amountAtomic: '100', + label: 'typed-pre-sign-release', + }); + context.ledger.reserve({ intentId: intent.id, amountAtomic: '100' }); + const claim = seedClaimOnlySigningPaymentAttempt(context, intent); + const rejection = new WalletSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'deterministic validation failed before the signer boundary', + { signatureMayExist: false }, + ); + + const beforeRollback = { + attempt: plainRow(context.store.readOne( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', [intent.id], + )), + budget: plainRow(context.store.readOne( + 'SELECT * FROM budget_reservations WHERE intent_id = ?', [intent.id], + )), + events: eventHead(context), + }; + assert.throws(() => context.store.transaction((token) => { + context.ledger.releaseInTransaction(token, { + intentId: intent.id, + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + preSignRejection: rejection, + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'signing', + nextState: 'unresolved', + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + }); + throw new Error('typed release rollback sentinel'); + }), /typed release rollback sentinel/); + assert.deepEqual(plainRow(context.store.readOne( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', [intent.id], + )), beforeRollback.attempt); + assert.deepEqual(plainRow(context.store.readOne( + 'SELECT * FROM budget_reservations WHERE intent_id = ?', [intent.id], + )), beforeRollback.budget); + assert.deepEqual(eventHead(context), beforeRollback.events); + + const released = context.store.transaction((token) => { + const result = context.ledger.releaseInTransaction(token, { + intentId: intent.id, + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + preSignRejection: rejection, + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'signing', + nextState: 'unresolved', + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + }); + return result; + }); + assert.equal(released.state, 'released'); + assertConserved(context, intent.id); + assert.deepEqual(plainRow(context.store.readOne(`SELECT state, reason_code, nonce, + valid_after, valid_before, signing_claimed_at, payment_payload_json, + payment_header, payment_hash, signed_at, retry_started_at, settlement_json, + transaction_id, settled_at + FROM payment_attempts WHERE intent_id = ?`, [intent.id])), { + state: 'rejected', + reason_code: 'WALLET_PRE_SIGN_REJECTED', + nonce: claim.nonce, + valid_after: claim.validAfter, + valid_before: claim.validBefore, + signing_claimed_at: claim.signingClaimedAt, + payment_payload_json: null, + payment_header: null, + payment_hash: null, + signed_at: null, + retry_started_at: null, + settlement_json: null, + transaction_id: null, + settled_at: null, + }); + assert.equal(context.intents.getIntent(intent.id).state, 'terminal'); + const releaseEvents = context.store.events().filter( + (event) => event.entity_type === 'budget_reservation' + && event.entity_id === intent.id + && event.event_type === 'budget.released', + ); + assert.equal(releaseEvents.length, 1); +}); + +test('claim-only signing rejects shaped, subclassed, ambiguous, and mismatched release proof', (t) => { + const context = setup(t); + class DerivedSigningError extends WalletSigningError {} + const proofs = [ + { + name: 'plain shaped object', + value: { code: 'WALLET_PRE_SIGN_REJECTED', signatureMayExist: false }, + }, + { + name: 'forged prototype object', + value: Object.assign(Object.create(WalletSigningError.prototype), { + code: 'WALLET_PRE_SIGN_REJECTED', + signatureMayExist: false, + }), + }, + { + name: 'native Error with forged exact prototype', + value: (() => { + const error = Object.assign(new Error('forged native error'), { + code: 'WALLET_PRE_SIGN_REJECTED', + signatureMayExist: false, + }); + Object.setPrototypeOf(error, WalletSigningError.prototype); + return error; + })(), + }, + { + name: 'subclass instance', + value: new DerivedSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'subclassed rejection', + { signatureMayExist: false }, + ), + }, + { + name: 'subclass with normalized exact prototype', + value: (() => { + const error = new DerivedSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'prototype-normalized subclass', + { signatureMayExist: false }, + ); + Object.setPrototypeOf(error, WalletSigningError.prototype); + return error; + })(), + }, + { + name: 'may-exist rejection', + value: new WalletSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'signer boundary entered', + { signatureMayExist: true }, + ), + }, + { + name: 'wrong typed code', + value: new WalletSigningError( + 'WALLET_SIGNATURE_AMBIGUOUS', + 'ambiguous signer result', + { signatureMayExist: false }, + ), + }, + ]; + + for (const [index, proof] of proofs.entries()) { + const intent = authorizeIntent(context, { + amountAtomic: '100', + label: `unsafe-pre-sign-${index}`, + }); + context.ledger.reserve({ intentId: intent.id, amountAtomic: '100' }); + seedClaimOnlySigningPaymentAttempt(context, intent); + const before = eventHead(context); + assertKernelError(() => context.store.transaction((token) => ( + context.ledger.releaseInTransaction(token, { + intentId: intent.id, + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + preSignRejection: proof.value, + }) + )), 'BUDGET_RELEASE_UNSAFE'); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', [intent.id], + ).state, 'signing', proof.name); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [intent.id], + ).state, 'reserved', proof.name); + assert.deepEqual(eventHead(context), before, proof.name); + } + + const mismatchedReason = authorizeIntent(context, { + amountAtomic: '100', + label: 'typed-proof-reason-mismatch', + }); + context.ledger.reserve({ intentId: mismatchedReason.id, amountAtomic: '100' }); + seedClaimOnlySigningPaymentAttempt(context, mismatchedReason); + assertKernelError(() => context.store.transaction((token) => ( + context.ledger.releaseInTransaction(token, { + intentId: mismatchedReason.id, + reasonCode: 'SIGNER_REJECTED', + preSignRejection: new WalletSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'exact typed rejection', + { signatureMayExist: false }, + ), + }) + )), 'BUDGET_RELEASE_UNSAFE'); + + for (const scoped of [false, true]) { + const missingProof = authorizeIntent(context, { + amountAtomic: '100', + label: `typed-proof-missing-${scoped ? 'scoped' : 'standalone'}`, + }); + context.ledger.reserve({ intentId: missingProof.id, amountAtomic: '100' }); + const operation = () => scoped + ? context.store.transaction((token) => context.ledger.releaseInTransaction(token, { + intentId: missingProof.id, + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + })) + : context.ledger.release({ + intentId: missingProof.id, + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + }); + assertKernelError(operation, 'BUDGET_RELEASE_UNSAFE'); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [missingProof.id], + ).state, 'reserved'); + } +}); + +test('snapshots revalidate the retained claim-only release history', (t) => { + const corruptions = [ + { + name: 'deleted claim-only attempt', + mutate(store, intentId) { + store.execForTest(`DELETE FROM payment_attempts WHERE intent_id = '${intentId}'`); + }, + }, + { + name: 'cleared claim window', + mutate(store, intentId) { + store.execForTest(`UPDATE payment_attempts + SET nonce = NULL, valid_after = NULL, valid_before = NULL, signing_claimed_at = NULL + WHERE intent_id = '${intentId}'`); + }, + }, + { + name: 'substituted release reason', + mutate(store, intentId) { + store.execForTest(`UPDATE payment_attempts SET reason_code = 'SIGNER_REJECTED' + WHERE intent_id = '${intentId}'`); + }, + }, + { + name: 'attempt update detached from release event', + mutate(store, intentId) { + store.execForTest(`UPDATE payment_attempts + SET updated_at = '2026-07-31T12:00:01.000Z' + WHERE intent_id = '${intentId}'`); + }, + }, + { + name: 'claim timestamp after terminal release', + mutate(store, intentId) { + store.execForTest(`UPDATE payment_attempts + SET signing_claimed_at = '2026-07-31T12:00:01.000Z' + WHERE intent_id = '${intentId}'`); + }, + }, + ]; + for (const corruption of corruptions) { + const context = setup(t); + const intent = authorizeIntent(context, { + amountAtomic: '100', + label: `history-${corruption.name.replaceAll(' ', '-')}`, + }); + context.ledger.reserve({ intentId: intent.id, amountAtomic: '100' }); + seedClaimOnlySigningPaymentAttempt(context, intent); + context.store.transaction((token) => context.ledger.releaseInTransaction(token, { + intentId: intent.id, + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + preSignRejection: new WalletSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'exact typed rejection', + { signatureMayExist: false }, + ), + })); + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, false); + corruption.mutate(context.store, intent.id); + assertKernelError(() => context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + } +}); + +test('claim and payload field groups fail closed when partial or attached to an illegal state', (t) => { + const exactRejection = new WalletSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'exact typed rejection', + { signatureMayExist: false }, + ); + for (const [index, [name, corrupt]] of [ + ['partial claim', (context, intent) => { + context.store.execForTest( + `UPDATE payment_attempts SET valid_before = NULL WHERE intent_id = '${intent.id}'`, + ); + }], + ['partial payload', (context, intent) => { + context.store.execForTest( + `UPDATE payment_attempts SET payment_payload_json = '{}' WHERE intent_id = '${intent.id}'`, + ); + }], + ['full signed payload while state remains signing', (context, intent) => { + const claim = context.store.readOne( + 'SELECT nonce, valid_after, valid_before FROM payment_attempts WHERE intent_id = ?', + [intent.id], + ); + const paymentHeader = 'claim-bound-but-premature-signed-header'; + const paymentPayload = { + x402Version: 2, + resource: intent.challenge.resource, + accepted: intent.challenge.accepts[0], + payload: { + signature: `0x${'11'.repeat(65)}`, + authorization: { + from: WALLET, + to: PAY_TO, + value: intent.decision.amountCeilingAtomic, + validAfter: claim.valid_after, + validBefore: claim.valid_before, + nonce: claim.nonce, + }, + }, + }; + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE payment_attempts + SET payment_payload_json = ?, payment_header = ?, payment_hash = ?, signed_at = ? + WHERE intent_id = ?`).run( + canonicalJson(paymentPayload), + paymentHeader, + sha256(Buffer.from(paymentHeader, 'ascii')), + NOW, + intent.id, + ); + })); + }], + ].entries()) { + const context = setup(t); + const intent = authorizeIntent(context, { + amountAtomic: '100', + label: `claim-corruption-${index}-${name.replaceAll(' ', '-')}`, + }); + context.ledger.reserve({ intentId: intent.id, amountAtomic: '100' }); + seedClaimOnlySigningPaymentAttempt(context, intent); + corrupt(context, intent); + assertKernelError(() => context.store.transaction((token) => ( + context.ledger.releaseInTransaction(token, { + intentId: intent.id, + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + preSignRejection: exactRejection, + }) + )), 'BUDGET_CORRUPTION'); + } +}); + +test('scoped operations require a live opaque token and roll back with their caller', (t) => { + let clockReads = 0; + const context = setup(t, { now: () => { + clockReads += 1; + return NOW; + } }); + const intent = authorizeIntent(context, { amountAtomic: '100', label: 'scoped' }); + const eventsBefore = context.store.events(); + assert.throws(() => context.store.transaction((token) => { + context.ledger.reserveInTransaction(token, { + intentId: intent.id, + amountAtomic: '100', + }); + throw new Error('rollback sentinel'); + }), /rollback sentinel/); + assert.equal(context.store.readOne( + 'SELECT intent_id FROM budget_reservations WHERE intent_id = ?', [intent.id], + ), undefined); + assert.deepEqual(context.store.events(), eventsBefore); + + const forged = Object.freeze(Object.create(null)); + let attackerInputReads = 0; + const attackerInput = new Proxy(Object.create(null), { + ownKeys() { + attackerInputReads += 1; + throw new Error('attacker input was inspected'); + }, + getOwnPropertyDescriptor() { + attackerInputReads += 1; + throw new Error('attacker input was inspected'); + }, + }); + const beforeForged = { + clockReads, + events: eventHead(context), + }; + for (const operation of [ + context.ledger.snapshotInTransaction, + context.ledger.reserveInTransaction, + context.ledger.commitInTransaction, + context.ledger.releaseInTransaction, + context.ledger.holdUnresolvedInTransaction, + context.ledger.resolvePaymentInTransaction, + context.ledger.recordConfirmedRefundInTransaction, + ]) { + assert.throws(() => operation(forged, attackerInput), /invalid authority transaction/); + } + assert.equal(attackerInputReads, 0); + assert.equal(clockReads, beforeForged.clockReads); + assert.deepEqual(eventHead(context), beforeForged.events); + let stale; + context.store.transaction((token) => { + stale = token; + return context.ledger.snapshotInTransaction(token, { + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }); + }); + const beforeStale = { clockReads, events: eventHead(context) }; + assert.throws(() => context.ledger.reserveInTransaction(stale, { + intentId: 'A'.repeat(100_000), + amountAtomic: '9'.repeat(100_000), + }), /invalid authority transaction/); + assert.equal(clockReads, beforeStale.clockReads); + assert.deepEqual(eventHead(context), beforeStale.events); +}); + +test('a prior signing claim can commit and unsigned work can release after rotation and revocation', (t) => { + const context = setup(t); + const signed = authorizeIntent(context, { amountAtomic: '250000', label: 'epoch-signed' }); + context.ledger.reserve({ intentId: signed.id, amountAtomic: '250000' }); + const signedAttempt = seedRetryingPaymentAttempt(context, signed); + const unsigned = authorizeIntent(context, { + amountAtomic: '100', + label: 'epoch-unsigned', + budgetSnapshot: ZERO_BUDGET, + }); + context.ledger.reserve({ intentId: unsigned.id, amountAtomic: '100' }); + + const rotated = testPolicy(); + rotated.challengeMaxAgeMs += 1; + context.policies.apply(rotated, '2026-07-31T12:00:01.000Z'); + context.enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + + assert.equal(context.ledger.commit({ + intentId: signed.id, + settlementEvidence: settlementEvidence({ + intent: signed, + paymentHash: signedAttempt.paymentHash, + transactionPair: '9a', + }), + }).state, 'committed'); + assert.equal(context.ledger.release({ + intentId: unsigned.id, + reasonCode: 'AGENT_REVOKED', + }).state, 'released'); + assert.equal(context.store.verifyEventChain(), true); +}); + +test('trusted settled reconciliation binds proof and atomically preserves the outer aggregate', (t) => { + const context = setup(t); + const intent = authorizeIntent(context, { + amountAtomic: '250000', + label: 'settled-reconciliation', + }); + makeSignedUnresolved(context, intent); + const transactionId = `0x${'31'.repeat(32)}`; + const candidateId = insertPaymentCandidate(context, { intentId: intent.id, transactionId }); + insertReconciliation(context, { + id: 'settled-generic-proof', + intentId: intent.id, + kind: 'payment', + outcome: 'settled', + evidence: { source: 'operator-assertion', transactionId }, + }); + + const beforeRejectedProof = eventHead(context); + assertKernelError(() => context.store.transaction((token) => { + const budget = context.ledger.resolvePaymentInTransaction(token, { + intentId: intent.id, + outcome: 'settled', + evidenceId: 'settled-generic-proof', + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode: 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + }); + return budget; + }), 'RECONCILIATION_EVIDENCE_MISMATCH'); + assert.deepEqual(eventHead(context), beforeRejectedProof); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [intent.id], + ).state, 'unresolved'); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', [intent.id], + ).state, 'unresolved'); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [intent.id], + ).state, 'unresolved'); + + const proof = settledTransferProof(context, intent, transactionId); + insertReconciliation(context, { + id: 'settled-exact-proof', + intentId: intent.id, + kind: 'payment', + outcome: 'settled', + evidence: proof, + }); + const result = context.store.transaction((token) => { + const budget = context.ledger.resolvePaymentInTransaction(token, { + intentId: intent.id, + outcome: 'settled', + evidenceId: 'settled-exact-proof', + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode: 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + }); + return budget; + }); + assert.equal(result.state, 'committed'); + assert.equal(result.committedAtomic, '250000'); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, transaction_id, settlement_json, reason_code + FROM payment_attempts WHERE intent_id = ?`, [intent.id], + )), { + state: 'settled', + transaction_id: transactionId, + settlement_json: canonicalJson(proof), + reason_code: 'TRUSTED_RECONCILIATION', + }); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, evidence_json FROM payment_reconciliation_candidates + WHERE id = ?`, [candidateId], + )), { state: 'confirmed', evidence_json: canonicalJson(proof) }); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, metadata_json FROM execution_outcomes WHERE intent_id = ?`, [intent.id], + )), { + state: 'unknown', + metadata_json: canonicalJson({ + reasonCode: 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + reconciliationEvidenceId: 'settled-exact-proof', + }), + }); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, reason_code, blocks_wallet, resolved_at + FROM execution_resolutions WHERE intent_id = ?`, [intent.id], + )), { + state: 'reconciliation_required', + reason_code: 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + blocks_wallet: 1n, + resolved_at: null, + }); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT status, reason_code, revision FROM buyer_outcomes WHERE intent_id = ?`, [intent.id], + )), { + status: 'execution_unknown', + reason_code: 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + revision: 2n, + }); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, retry_matchable FROM spend_intents WHERE id = ?`, [intent.id], + )), { state: 'terminal', retry_matchable: 0n }); + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + + const afterResolution = eventHead(context); + assert.deepEqual(context.ledger.resolvePayment({ + intentId: intent.id, + outcome: 'settled', + evidenceId: 'settled-exact-proof', + }), result); + assert.deepEqual(eventHead(context), afterResolution); + assert.equal(context.store.verifyEventChain(), true); +}); + +test('trusted rejected reconciliation needs exact post-expiry proof and revalidates replay', (t) => { + let clock = NOW; + const context = setup(t, { now: () => clock }); + const intent = authorizeIntent(context, { + amountAtomic: '250000', + label: 'rejected-reconciliation', + }); + makeSignedUnresolved(context, intent); + const transactionId = `0x${'32'.repeat(32)}`; + const candidateId = insertPaymentCandidate(context, { intentId: intent.id, transactionId }); + const proof = unusedAuthorizationProof(context, intent); + insertReconciliation(context, { + id: 'rejected-pre-expiry-proof', + intentId: intent.id, + kind: 'payment', + outcome: 'rejected', + evidence: { + ...proof, + observedBlockTimestamp: (BigInt(proof.validBefore) - 1n).toString(), + }, + }); + const beforePreExpiry = eventHead(context); + assertKernelError(() => context.store.transaction((token) => { + context.ledger.resolvePaymentInTransaction(token, { + intentId: intent.id, + outcome: 'rejected', + evidenceId: 'rejected-pre-expiry-proof', + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode: 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + }); + }), 'RECONCILIATION_EVIDENCE_MISMATCH'); + assert.deepEqual(eventHead(context), beforePreExpiry); + + insertReconciliation(context, { + id: 'rejected-exact-proof', + intentId: intent.id, + kind: 'payment', + outcome: 'rejected', + evidence: proof, + recordedAt: AFTER_EXPIRY, + }); + clock = AFTER_EXPIRY; + const result = context.store.transaction((token) => { + const budget = context.ledger.resolvePaymentInTransaction(token, { + intentId: intent.id, + outcome: 'rejected', + evidenceId: 'rejected-exact-proof', + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode: 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + }); + return budget; + }); + assert.equal(result.state, 'released'); + assert.equal(result.releasedAtomic, '250000'); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, reason_code, transaction_id, settlement_json, settled_at + FROM payment_attempts WHERE intent_id = ?`, [intent.id], + )), { + state: 'rejected', + reason_code: 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + transaction_id: null, + settlement_json: null, + settled_at: null, + }); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, evidence_json FROM payment_reconciliation_candidates + WHERE id = ?`, [candidateId], + )), { state: 'rejected', evidence_json: canonicalJson(proof) }); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT status, reason_code, revision FROM buyer_outcomes WHERE intent_id = ?`, [intent.id], + )), { + status: 'payment_rejected', + reason_code: 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + revision: 2n, + }); + assert.equal(context.store.readOne( + 'SELECT intent_id FROM execution_outcomes WHERE intent_id = ?', [intent.id], + ), undefined); + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, false); + + const afterResolution = eventHead(context); + assert.deepEqual(context.ledger.resolvePayment({ + intentId: intent.id, + outcome: 'rejected', + evidenceId: 'rejected-exact-proof', + }), result); + assert.deepEqual(eventHead(context), afterResolution); + + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare('UPDATE reconciliations SET evidence_json = ? WHERE id = ?').run( + canonicalJson({ ...proof, confirmations: 0 }), + 'rejected-exact-proof', + ); + })); + assertKernelError(() => context.ledger.resolvePayment({ + intentId: intent.id, + outcome: 'rejected', + evidenceId: 'rejected-exact-proof', + }), 'RECONCILIATION_EVIDENCE_MISMATCH'); + assert.deepEqual(eventHead(context), afterResolution); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare('UPDATE reconciliations SET evidence_json = ? WHERE id = ?').run( + canonicalJson(proof), + 'rejected-exact-proof', + ); + })); + + const attempt = context.store.readOne( + 'SELECT payment_hash FROM payment_attempts WHERE intent_id = ?', [intent.id], + ); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare('UPDATE payment_attempts SET payment_hash = ? WHERE intent_id = ?').run( + sha256(Buffer.from('corrupt-payment-header')), + intent.id, + ); + })); + assertKernelError(() => context.ledger.resolvePayment({ + intentId: intent.id, + outcome: 'rejected', + evidenceId: 'rejected-exact-proof', + }), 'BUDGET_CORRUPTION'); + assert.deepEqual(eventHead(context), afterResolution); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare('UPDATE payment_attempts SET payment_hash = ? WHERE intent_id = ?').run( + attempt.payment_hash, + intent.id, + ); + })); + assert.equal(context.store.verifyEventChain(), true); +}); + +test('confirmed refund is evidence-bound, atomic, and releases committed exposure once', (t) => { + const context = setup(t); + const intent = authorizeIntent(context, { amountAtomic: '250000', label: 'full-refund' }); + const { evidence } = commitIntent(context, intent, '33'); + const refundTransactionId = `0x${'34'.repeat(32)}`; + context.store.transaction((token) => { + context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'failed', 500, ?, ?, ?)`).run( + intent.id, + sha256(Buffer.from('failed execution')), + canonicalJson({ source: 'test' }), + NOW, + ); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at) + VALUES (?, 'refund_pending', 'UPSTREAM_FAILED', 1, ?)`).run(intent.id, NOW); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + refund_transaction_id, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)`).run( + `refund-${intent.id}`, + intent.id, + evidence.transaction, + intent.decision.amountCeilingAtomic, + refundTransactionId, + NOW, + NOW, + ); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'execution_failed', 'UPSTREAM_FAILED', 1, ?)`).run(intent.id, NOW); + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'retrying', + nextState: 'terminal', + reasonCode: 'UPSTREAM_FAILED', + }); + }); + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + + insertReconciliation(context, { + id: 'refund-generic-proof', + intentId: intent.id, + kind: 'refund', + outcome: 'refund_confirmed', + evidence: { transactionId: refundTransactionId, source: 'operator-assertion' }, + }); + const beforeBadRefund = eventHead(context); + assertKernelError(() => context.ledger.recordConfirmedRefund({ + intentId: intent.id, + evidenceId: 'refund-generic-proof', + refundTransactionId, + }), 'REFUND_EVIDENCE_MISMATCH'); + assert.deepEqual(eventHead(context), beforeBadRefund); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [intent.id], + ).state, 'committed'); + + const attestation = refundAttestation( + context, + intent.id, + evidence.transaction, + refundTransactionId, + ); + const proof = Object.freeze({ + kind: 'refund_attested_and_confirmed', + originalTransactionId: evidence.transaction, + refundTransactionId, + attestationHash: sha256(canonicalJson(attestation)), + attestation, + rpcProofHash: sha256(canonicalJson({ fixture: `refund-rpc-${intent.id}` })), + localRefundBindingHash: localRefundHash(context, intent.id, refundTransactionId), + }); + insertReconciliation(context, { + id: 'refund-exact-proof', + intentId: intent.id, + kind: 'refund', + outcome: 'refund_confirmed', + evidence: proof, + }); + const result = context.ledger.recordConfirmedRefund({ + intentId: intent.id, + evidenceId: 'refund-exact-proof', + refundTransactionId, + }); + assert.equal(result.state, 'released'); + assert.equal(result.releasedAtomic, '250000'); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, evidence_json, original_transaction_id, refund_transaction_id + FROM refunds WHERE intent_id = ?`, [intent.id], + )), { + state: 'confirmed', + evidence_json: canonicalJson(proof), + original_transaction_id: evidence.transaction, + refund_transaction_id: refundTransactionId, + }); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT state, blocks_wallet, resolved_at FROM execution_resolutions + WHERE intent_id = ?`, [intent.id], + )), { state: 'resolved', blocks_wallet: 0n, resolved_at: NOW }); + assert.deepEqual(plainRow(context.store.readOne( + `SELECT status, reason_code, revision FROM buyer_outcomes WHERE intent_id = ?`, [intent.id], + )), { status: 'refunded', reason_code: 'REFUND_CONFIRMED', revision: 2n }); + assert.equal(context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, false); + + const afterRefund = eventHead(context); + assert.deepEqual(context.ledger.recordConfirmedRefund({ + intentId: intent.id, + evidenceId: 'refund-exact-proof', + refundTransactionId, + }), result); + assert.deepEqual(eventHead(context), afterRefund); + assert.equal(context.store.verifyEventChain(), true); +}); + +test('refund-pending snapshots retain a full block with only abandoned or rejected history', async (t) => { + for (const historicalState of ['abandoned', 'rejected']) { + await t.test(historicalState, (st) => { + const fileAuthority = authority(st, `wallet-kernel-refund-history-${historicalState}-`); + const context = setup(st, { fileAuthority }); + const intent = authorizeIntent(context, { + amountAtomic: '250000', + label: `refund-history-${historicalState}`, + }); + const { evidence } = commitIntent(context, intent, historicalState === 'abandoned' ? '71' : '72'); + const refundTransactionId = `0x${(historicalState === 'abandoned' ? '73' : '74').repeat(32)}`; + context.store.transaction((token) => { + context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'failed', 500, NULL, '{}', ?)`).run(intent.id, NOW); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at, resolved_at) + VALUES (?, 'refund_pending', 'REFUND_UNRESOLVED', 1, ?, NULL)`).run( + intent.id, + NOW, + ); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + evidence_json, refund_transaction_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + `refund-history-${historicalState}`, + intent.id, + evidence.transaction, + intent.decision.amountCeilingAtomic, + historicalState, + historicalState === 'rejected' + ? canonicalJson({ kind: 'refund_candidate_rejected', fixture: true }) + : null, + refundTransactionId, + NOW, + NOW, + ); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'execution_failed', 'REFUND_UNRESOLVED', 1, ?)`).run(intent.id, NOW); + }); + context.intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'retrying', + nextState: 'terminal', + reasonCode: 'REFUND_UNRESOLVED', + }); + }); + + const snapshot = context.ledger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }); + assert.equal(snapshot.walletBlocked, true); + assert.equal(snapshot.sessionExposureAtomic, intent.decision.amountCeilingAtomic); + assert.equal(context.store.readOne( + `SELECT COUNT(*) AS count FROM refunds + WHERE intent_id = ? AND state IN ('pending','unresolved')`, + [intent.id], + ).count, 0n); + assertConserved(context, intent.id); + + context.store.close(); + const reopenedStore = openKernelStore({ + filePath: fileAuthority.databasePath, + pathTrust: fileAuthority.pathTrust, + now: () => NOW, + }); + st.after(() => reopenedStore.close()); + const reopenedLedger = createBudgetLedger({ store: reopenedStore, now: () => NOW }); + const reopenedSnapshot = reopenedLedger.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }); + assert.equal(reopenedSnapshot.walletBlocked, true); + assert.equal(reopenedSnapshot.sessionExposureAtomic, intent.decision.amountCeilingAtomic); + }); + } +}); + +test('trusted reconciliation times cannot backdate committed spend or release a future refund', (t) => { + const evidenceTime = '2026-08-01T13:00:00.000Z'; + let settledClock = NOW; + const settledContext = setup(t, { now: () => settledClock }); + const unsettled = authorizeIntent(settledContext, { + amountAtomic: '100', + label: 'settled-clock-order', + }); + makeSignedUnresolved(settledContext, unsettled); + const transactionId = `0x${'3d'.repeat(32)}`; + insertPaymentCandidate(settledContext, { intentId: unsettled.id, transactionId }); + insertReconciliation(settledContext, { + id: 'future-settlement-proof', + intentId: unsettled.id, + kind: 'payment', + outcome: 'settled', + evidence: settledTransferProof(settledContext, unsettled, transactionId), + recordedAt: evidenceTime, + }); + settledClock = '2026-07-31T12:00:01.000Z'; + const beforeSettlement = eventHead(settledContext); + assertKernelError(() => settledContext.ledger.resolvePayment({ + intentId: unsettled.id, + outcome: 'settled', + evidenceId: 'future-settlement-proof', + }), 'BUDGET_TIME'); + assert.deepEqual(eventHead(settledContext), beforeSettlement); + assert.equal(settledContext.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [unsettled.id], + ).state, 'unresolved'); + assert.equal(settledContext.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', [unsettled.id], + ).state, 'unresolved'); + + settledClock = evidenceTime; + settledContext.store.transaction((token) => { + settledContext.ledger.resolvePaymentInTransaction(token, { + intentId: unsettled.id, + outcome: 'settled', + evidenceId: 'future-settlement-proof', + }); + settledContext.intents.transitionInTransaction(token, { + intentId: unsettled.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode: 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + }); + }); + assert.equal(settledContext.store.readOne( + 'SELECT committed_at FROM budget_reservations WHERE intent_id = ?', [unsettled.id], + ).committed_at, evidenceTime); + assert.equal(settledContext.ledger.snapshot({ + sessionId: settledContext.session.id, + sellerOrigin: SELLER, + at: evidenceTime, + }).rolling24hExposureAtomic, '100'); + + let refundClock = NOW; + const refundContext = setup(t, { now: () => refundClock }); + const paid = authorizeIntent(refundContext, { + amountAtomic: '100', + label: 'refund-clock-order', + }); + const committed = commitIntent(refundContext, paid, '3e'); + const refundTransactionId = `0x${'3f'.repeat(32)}`; + refundContext.store.transaction((token) => { + refundContext.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'failed', 500, ?, ?, ?)`).run( + paid.id, + sha256(Buffer.from('future refund failed execution')), + canonicalJson({ source: 'test' }), + NOW, + ); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at) + VALUES (?, 'refund_pending', 'UPSTREAM_FAILED', 1, ?)`).run(paid.id, NOW); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + refund_transaction_id, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)`).run( + `refund-${paid.id}`, + paid.id, + committed.evidence.transaction, + paid.decision.amountCeilingAtomic, + refundTransactionId, + NOW, + NOW, + ); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'execution_failed', 'UPSTREAM_FAILED', 1, ?)`).run(paid.id, NOW); + }); + refundContext.intents.transitionInTransaction(token, { + intentId: paid.id, + expectedState: 'retrying', + nextState: 'terminal', + reasonCode: 'UPSTREAM_FAILED', + }); + }); + const attestation = refundAttestation( + refundContext, + paid.id, + committed.evidence.transaction, + refundTransactionId, + ); + const refundProof = Object.freeze({ + kind: 'refund_attested_and_confirmed', + originalTransactionId: committed.evidence.transaction, + refundTransactionId, + attestationHash: sha256(canonicalJson(attestation)), + attestation, + rpcProofHash: sha256(canonicalJson({ fixture: 'future-refund-rpc' })), + localRefundBindingHash: localRefundHash(refundContext, paid.id, refundTransactionId), + }); + insertReconciliation(refundContext, { + id: 'future-refund-proof', + intentId: paid.id, + kind: 'refund', + outcome: 'refund_confirmed', + evidence: refundProof, + recordedAt: evidenceTime, + }); + refundClock = '2026-07-31T12:00:01.000Z'; + const beforeRefund = eventHead(refundContext); + assertKernelError(() => refundContext.ledger.recordConfirmedRefund({ + intentId: paid.id, + evidenceId: 'future-refund-proof', + refundTransactionId, + }), 'BUDGET_TIME'); + assert.deepEqual(eventHead(refundContext), beforeRefund); + assert.equal(refundContext.store.readOne( + 'SELECT state FROM refunds WHERE intent_id = ?', [paid.id], + ).state, 'pending'); + assert.equal(refundContext.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [paid.id], + ).state, 'committed'); + + refundClock = evidenceTime; + assert.equal(refundContext.ledger.recordConfirmedRefund({ + intentId: paid.id, + evidenceId: 'future-refund-proof', + refundTransactionId, + }).state, 'released'); + assert.equal(refundContext.store.readOne( + 'SELECT updated_at FROM refunds WHERE intent_id = ?', [paid.id], + ).updated_at, evidenceTime); +}); + +test('snapshots fail closed when reservation authority or event-bound history is hidden', (t) => { + const reboundContext = setup(t); + const rebound = authorizeIntent(reboundContext, { + amountAtomic: '250000', + label: 'corrupt-session-rebind', + }); + reboundContext.ledger.reserve({ intentId: rebound.id, amountAtomic: '250000' }); + reboundContext.store.transaction((token) => reboundContext.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at, closed_at) + VALUES ('foreign-session', 'pi:foreign-agent', ?, ?, 'closed', ?, ?)`).run( + '0x9000000000000000000000000000000000000000', + reboundContext.activePolicy.id, + NOW, + NOW, + ); + db.prepare('UPDATE budget_reservations SET session_id = ? WHERE intent_id = ?').run( + 'foreign-session', + rebound.id, + ); + })); + assertKernelError(() => reboundContext.ledger.snapshot({ + sessionId: reboundContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + assert.equal(reboundContext.store.verifyEventChain(), true); + + const walletRebindContext = setup(t); + const walletRebound = authorizeIntent(walletRebindContext, { + amountAtomic: '250000', + label: 'corrupt-wallet-rebind', + }); + commitIntent(walletRebindContext, walletRebound, '3c'); + walletRebindContext.store.transaction((token) => { + walletRebindContext.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'succeeded', 200, ?, ?, ?)`).run( + walletRebound.id, + sha256(Buffer.from('wallet-rebind terminal response')), + canonicalJson({ source: 'test' }), + NOW, + ); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'completed', 'EXECUTION_SUCCEEDED', 1, ?)`).run( + walletRebound.id, + NOW, + ); + }); + walletRebindContext.intents.transitionInTransaction(token, { + intentId: walletRebound.id, + expectedState: 'retrying', + nextState: 'terminal', + reasonCode: 'EXECUTION_SUCCEEDED', + }); + }); + const replacementContext = rotateSessionPolicy(walletRebindContext); + const walletRebindTarget = authorizeIntent(replacementContext, { + amountAtomic: '1', + label: 'corrupt-wallet-rebind-target', + budgetSnapshot: ZERO_BUDGET, + }); + walletRebindContext.store.transaction((token) => ( + walletRebindContext.store.within(token, ({ db }) => { + const substitutedWallet = '0x9000000000000000000000000000000000000000'; + db.prepare('UPDATE spend_sessions SET wallet_address = ? WHERE id = ?').run( + substitutedWallet, + walletRebindContext.session.id, + ); + db.prepare('UPDATE spend_intents SET wallet_address = ? WHERE id = ?').run( + substitutedWallet, + walletRebound.id, + ); + }) + )); + assertKernelError(() => replacementContext.ledger.snapshot({ + sessionId: replacementContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + assertKernelError(() => replacementContext.ledger.reserve({ + intentId: walletRebindTarget.id, + amountAtomic: '1', + }), 'BUDGET_CORRUPTION'); + assert.equal(walletRebindContext.store.verifyEventChain(), true); + + const missingDecisionContext = setup(t); + const missingDecision = authorizeIntent(missingDecisionContext, { + amountAtomic: '250000', + label: 'corrupt-missing-decision', + }); + missingDecisionContext.ledger.reserve({ + intentId: missingDecision.id, + amountAtomic: '250000', + }); + missingDecisionContext.store.transaction((token) => ( + missingDecisionContext.store.within(token, ({ db }) => { + db.prepare('DELETE FROM policy_decisions WHERE intent_id = ?').run(missingDecision.id); + }) + )); + assertKernelError(() => missingDecisionContext.ledger.snapshot({ + sessionId: missingDecisionContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + assert.equal(missingDecisionContext.store.verifyEventChain(), true); + + const missingReservationContext = setup(t); + const missingReservation = authorizeIntent(missingReservationContext, { + amountAtomic: '250000', + label: 'corrupt-missing-reservation', + }); + commitIntent(missingReservationContext, missingReservation, '3b'); + const missingReservationTarget = authorizeIntent(missingReservationContext, { + amountAtomic: '1', + label: 'corrupt-missing-reservation-target', + budgetSnapshot: ZERO_BUDGET, + }); + missingReservationContext.store.transaction((token) => ( + missingReservationContext.store.within(token, ({ db }) => { + db.prepare('DELETE FROM budget_reservations WHERE intent_id = ?').run( + missingReservation.id, + ); + }) + )); + assertKernelError(() => missingReservationContext.ledger.snapshot({ + sessionId: missingReservationContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + assertKernelError(() => missingReservationContext.ledger.reserve({ + intentId: missingReservationTarget.id, + amountAtomic: '1', + }), 'BUDGET_CORRUPTION'); + assert.equal(missingReservationContext.store.verifyEventChain(), true); + + const backdatedContext = setup(t); + const backdated = authorizeIntent(backdatedContext, { + amountAtomic: '250000', + label: 'corrupt-committed-at', + }); + commitIntent(backdatedContext, backdated, '35'); + backdatedContext.store.transaction((token) => backdatedContext.store.within(token, ({ db }) => { + db.prepare('UPDATE budget_reservations SET committed_at = ? WHERE intent_id = ?').run( + '2026-07-01T12:00:00.000Z', + backdated.id, + ); + })); + assertKernelError(() => backdatedContext.ledger.snapshot({ + sessionId: backdatedContext.session.id, + sellerOrigin: SELLER, + at: NOW, + }), 'BUDGET_CORRUPTION'); + assert.equal(backdatedContext.store.verifyEventChain(), true); +}); + +test('payment reconciliation rejects case variants and cross-table transaction reuse', (t) => { + const caseContext = setup(t); + const target = authorizeIntent(caseContext, { amountAtomic: '100', label: 'case-target' }); + makeSignedUnresolved(caseContext, target); + const other = authorizeIntent(caseContext, { + amountAtomic: '100', + label: 'case-other', + budgetSnapshot: ZERO_BUDGET, + }); + seedRetryingPaymentAttempt(caseContext, other); + const transactionId = `0x${'ab'.repeat(32)}`; + insertPaymentCandidate(caseContext, { intentId: target.id, transactionId }); + insertPaymentCandidate(caseContext, { + intentId: other.id, + transactionId: `0x${'AB'.repeat(32)}`, + }); + insertReconciliation(caseContext, { + id: 'case-variant-proof', + intentId: target.id, + kind: 'payment', + outcome: 'settled', + evidence: settledTransferProof(caseContext, target, transactionId), + }); + const beforeCaseVariant = eventHead(caseContext); + assertKernelError(() => caseContext.ledger.resolvePayment({ + intentId: target.id, + outcome: 'settled', + evidenceId: 'case-variant-proof', + }), 'TRANSACTION_BINDING_CORRUPTION'); + assert.deepEqual(eventHead(caseContext), beforeCaseVariant); + assert.equal(caseContext.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [target.id], + ).state, 'unresolved'); + + const reuseContext = setup(t); + const reused = authorizeIntent(reuseContext, { amountAtomic: '100', label: 'reuse-target' }); + makeSignedUnresolved(reuseContext, reused); + const reusedTransactionId = `0x${'36'.repeat(32)}`; + insertPaymentCandidate(reuseContext, { + intentId: reused.id, + transactionId: reusedTransactionId, + }); + const foreignIntent = authorizeIntent(reuseContext, { + amountAtomic: '1', + label: 'reuse-foreign-owner', + budgetSnapshot: ZERO_BUDGET, + }); + reuseContext.store.transaction((token) => reuseContext.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + refund_transaction_id, created_at, updated_at) + VALUES ('foreign-refund', ?, ?, '1', 'abandoned', ?, ?, ?)`).run( + foreignIntent.id, + `0x${'37'.repeat(32)}`, + reusedTransactionId, + NOW, + NOW, + ); + })); + insertReconciliation(reuseContext, { + id: 'cross-table-reuse-proof', + intentId: reused.id, + kind: 'payment', + outcome: 'settled', + evidence: settledTransferProof(reuseContext, reused, reusedTransactionId), + }); + const beforeReuse = eventHead(reuseContext); + assertKernelError(() => reuseContext.ledger.resolvePayment({ + intentId: reused.id, + outcome: 'settled', + evidenceId: 'cross-table-reuse-proof', + }), 'TRANSACTION_REUSED'); + assert.deepEqual(eventHead(reuseContext), beforeReuse); + assert.equal(reuseContext.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [reused.id], + ).state, 'unresolved'); +}); + +test('first trusted transitions reject rows finalized outside their atomic mutation', (t) => { + const paymentContext = setup(t); + const payment = authorizeIntent(paymentContext, { + amountAtomic: '100', + label: 'preconfirmed-payment', + }); + makeSignedUnresolved(paymentContext, payment); + const paymentTransactionId = `0x${'38'.repeat(32)}`; + const candidateId = insertPaymentCandidate(paymentContext, { + intentId: payment.id, + transactionId: paymentTransactionId, + }); + const paymentProof = settledTransferProof( + paymentContext, + payment, + paymentTransactionId, + ); + paymentContext.store.transaction((token) => paymentContext.store.within(token, ({ db }) => { + db.prepare(`UPDATE payment_reconciliation_candidates + SET state = 'confirmed', evidence_json = ? WHERE id = ?`).run( + canonicalJson(paymentProof), + candidateId, + ); + })); + insertReconciliation(paymentContext, { + id: 'preconfirmed-payment-proof', + intentId: payment.id, + kind: 'payment', + outcome: 'settled', + evidence: paymentProof, + }); + const beforePayment = eventHead(paymentContext); + assertKernelError(() => paymentContext.ledger.resolvePayment({ + intentId: payment.id, + outcome: 'settled', + evidenceId: 'preconfirmed-payment-proof', + }), 'RECONCILIATION_EVIDENCE_MISMATCH'); + assert.deepEqual(eventHead(paymentContext), beforePayment); + assert.equal(paymentContext.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [payment.id], + ).state, 'unresolved'); + + const refundContext = setup(t); + const paid = authorizeIntent(refundContext, { + amountAtomic: '100', + label: 'preconfirmed-refund', + }); + const committed = commitIntent(refundContext, paid, '39'); + const refundTransactionId = `0x${'3a'.repeat(32)}`; + refundContext.store.transaction((token) => refundContext.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'failed', 500, ?, ?, ?)`).run( + paid.id, + sha256(Buffer.from('preconfirmed failed execution')), + canonicalJson({ source: 'test' }), + NOW, + ); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at) + VALUES (?, 'refund_pending', 'UPSTREAM_FAILED', 1, ?)`).run(paid.id, NOW); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + refund_transaction_id, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)`).run( + `refund-${paid.id}`, + paid.id, + committed.evidence.transaction, + paid.decision.amountCeilingAtomic, + refundTransactionId, + NOW, + NOW, + ); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'execution_failed', 'UPSTREAM_FAILED', 1, ?)`).run(paid.id, NOW); + })); + const attestation = refundAttestation( + refundContext, + paid.id, + committed.evidence.transaction, + refundTransactionId, + ); + const refundProof = Object.freeze({ + kind: 'refund_attested_and_confirmed', + originalTransactionId: committed.evidence.transaction, + refundTransactionId, + attestationHash: sha256(canonicalJson(attestation)), + attestation, + rpcProofHash: sha256(canonicalJson({ fixture: 'preconfirmed-refund-rpc' })), + localRefundBindingHash: localRefundHash(refundContext, paid.id, refundTransactionId), + }); + refundContext.store.transaction((token) => refundContext.store.within(token, ({ db }) => { + db.prepare(`UPDATE refunds SET state = 'confirmed', evidence_json = ? + WHERE intent_id = ?`).run(canonicalJson(refundProof), paid.id); + })); + insertReconciliation(refundContext, { + id: 'preconfirmed-refund-proof', + intentId: paid.id, + kind: 'refund', + outcome: 'refund_confirmed', + evidence: refundProof, + }); + const beforeRefund = eventHead(refundContext); + assertKernelError(() => refundContext.ledger.recordConfirmedRefund({ + intentId: paid.id, + evidenceId: 'preconfirmed-refund-proof', + refundTransactionId, + }), 'BUDGET_CORRUPTION'); + assert.deepEqual(eventHead(refundContext), beforeRefund); + assert.equal(refundContext.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [paid.id], + ).state, 'committed'); +}); diff --git a/spikes/pi-wielder/tests/kernel-intent.test.mjs b/spikes/pi-wielder/tests/kernel-intent.test.mjs new file mode 100644 index 0000000..f2ecd28 --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-intent.test.mjs @@ -0,0 +1,2060 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { Worker } from 'node:worker_threads'; + +import { createAgentEnrollmentRepository } from '../src/kernel/agent-enrollment.mjs'; +import { canonicalJson, KernelError, sha256 } from '../src/kernel/canonical.mjs'; +import { + canonicalIntentFingerprint, + FORBIDDEN_AGENT_HEADERS, + createIntentRepository, +} from '../src/kernel/intent-builder.mjs'; +import { createPolicyRepository } from '../src/kernel/policy-repository.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const NOW = '2026-07-31T12:00:00.000Z'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const DESCRIPTOR_HASH = sha256(canonicalJson(DESCRIPTOR)); +const OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; +const ROUTE_METADATA = Object.freeze({ + 'example-skill': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), +}); +const POLICY = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); + +function sequenceIds() { + const counts = new Map(); + return (kind) => { + const next = (counts.get(kind) ?? 0) + 1; + counts.set(kind, next); + return `${kind}-${next}`; + }; +} + +function trackedIds() { + const calls = []; + const factory = (kind) => { + calls.push(kind); + return `${kind}-${calls.filter((entry) => entry === kind).length}`; + }; + factory.calls = calls; + return factory; +} + +function setup(t, { + idFactory = sequenceIds(), + allowLoopbackHttp = false, + now = () => NOW, +} = {}) { + const store = openKernelStore({ + filePath: ':memory:', + allowMemory: true, + now, + }); + t.after(() => store.close()); + const activePolicy = createPolicyRepository(store).apply(POLICY, NOW).policyVersion; + const enrolled = createAgentEnrollmentRepository({ store, now }).enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: DESCRIPTOR_HASH, + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const intents = createIntentRepository({ + store, + idFactory, + now, + allowLoopbackHttp, + routeMetadata: ROUTE_METADATA, + }); + return { activePolicy, enrolled, intents, store }; +} + +function ordinaryRequest(overrides = {}) { + return { + routeId: 'example-skill', + method: 'POST', + requestUrl: 'https://seller.example/paid/infer', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + bodyBytes: Buffer.from('{"prompt":"redacted after hashing"}'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-001', + ...overrides, + }; +} + +function requestWithoutCorrelation(overrides = {}) { + const request = ordinaryRequest(overrides); + delete request.correlationId; + return request; +} + +function assertKernelError(operation, expectedCode) { + assert.throws(operation, (error) => { + assert.ok(error instanceof KernelError); + assert.equal(error.code, expectedCode); + return true; + }); +} + +function openSession(context) { + return context.intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); +} + +function paymentRequired(overrides = {}) { + return { + x402Version: 2, + error: 'seller prose', + resource: { + url: 'https://seller.example/paid/infer', + description: 'offline fixture', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + amount: '50000', + payTo: '0x2000000000000000000000000000000000000000', + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], + ...overrides, + }; +} + +function capturedContext(t, request = ordinaryRequest()) { + const context = setup(t); + const session = openSession(context); + const intent = context.intents.captureIntent({ sessionId: session.id, ...request }); + return { ...context, session, intent, request }; +} + +function terminalCloseContext(t) { + const context = capturedContext(t); + context.intents.transition({ + intentId: context.intent.id, + expectedState: 'captured', + nextState: 'terminal', + reasonCode: 'TEST_TERMINAL', + }); + context.store.execForTest(`INSERT INTO buyer_outcomes( + intent_id, status, reason_code, revision, recorded_at + ) + VALUES ('${context.intent.id}', 'payment_denied', 'TEST_TERMINAL', 1, '${NOW}'); + `); + return context; +} + +function blockedSessionContext(t) { + const context = setup(t); + const session = openSession(context); + const nextPolicy = structuredClone(POLICY); + nextPolicy.sellers[0].autoApproveAtomic = '50000'; + const targetPolicy = createPolicyRepository(context.store).apply( + nextPolicy, + '2026-07-31T12:01:00.000Z', + ).policyVersion; + const blockedSession = context.intents.getSession(session.id); + assert.equal(blockedSession.state, 'policy_blocked'); + return { ...context, session, blockedSession, targetPolicy }; +} + +function sqlText(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function nextWorkerMessage(worker) { + return new Promise((resolve, reject) => { + const cleanup = () => { + worker.off('message', onMessage); + worker.off('error', onError); + worker.off('exit', onExit); + }; + const onMessage = (message) => { + cleanup(); + resolve(message); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + const onExit = (code) => { + cleanup(); + reject(new Error(`capture worker exited before replying with code ${code}`)); + }; + worker.once('message', onMessage); + worker.once('error', onError); + worker.once('exit', onExit); + }); +} + +test('opens a wallet-bound Spend Session and persists an exact privacy-safe Spend Intent', (t) => { + const { activePolicy, enrolled, intents, store } = setup(t); + + const session = intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: activePolicy.id, + }); + const intent = intents.captureIntent({ sessionId: session.id, ...ordinaryRequest() }); + + assert.equal(session.id, 'session-1'); + assert.match(session.sessionHash, /^sha256:[0-9a-f]{64}$/); + assert.equal(intent.id, 'intent-1'); + assert.equal(intent.requestId, 'request-1'); + assert.equal(intent.enrollmentHash, enrolled.enrollmentHash); + assert.equal(intent.routeId, 'example-skill'); + assert.equal(intent.sellerOrigin, 'https://seller.example'); + assert.equal(intent.resourcePath, '/paid/infer'); + assert.match(intent.requestUrlHash, /^sha256:[0-9a-f]{64}$/); + assert.match(intent.bodyHash, /^sha256:[0-9a-f]{64}$/); + assert.match(intent.headerAllowlistHash, /^sha256:[0-9a-f]{64}$/); + assert.match(intent.intentHash, /^sha256:[0-9a-f]{64}$/); + assert.match(intent.idempotencyKey, /^wk_[0-9a-f]{64}$/); + assert.equal(intent.intentHash, sha256(canonicalJson({ + requestId: intent.requestId, + sessionId: intent.sessionId, + enrollmentHash: intent.enrollmentHash, + routeId: intent.routeId, + method: intent.method, + requestUrlHash: intent.requestUrlHash, + sellerOrigin: intent.sellerOrigin, + resourcePath: intent.resourcePath, + bodyHash: intent.bodyHash, + headerAllowlistHash: intent.headerAllowlistHash, + purposeLabel: intent.purposeLabel, + correlationId: intent.correlationId, + walletAddress: intent.walletAddress, + policyVersionId: activePolicy.id, + }))); + const captureEvent = store.events().find((row) => row.event_type === 'intent.captured'); + assert.equal(JSON.parse(captureEvent.data_json).sellerOrigin, intent.sellerOrigin); + assert.equal(JSON.parse(captureEvent.data_json).resourcePath, intent.resourcePath); + const persisted = store.readOne('SELECT * FROM spend_intents WHERE id = ?', [intent.id]); + assert.equal(Object.values(persisted).map(String).join('\n').includes('redacted after hashing'), false); +}); + +test('Spend Session admission rejects base64url instance IDs that are not canonical tokens', (t) => { + const { activePolicy, intents } = setup(t); + for (const agentInstanceId of [ + `_${'A'.repeat(21)}`, + `-${'A'.repeat(21)}`, + ]) { + assertKernelError(() => intents.openOrResumeSession({ + agentInstanceId, + walletAddress: WALLET, + policyVersionId: activePolicy.id, + }), 'AGENT_INSTANCE_ID'); + } +}); + +test('agent-controlled payment headers and hostile header shapes never create an intent', (t) => { + const context = setup(t); + const session = openSession(context); + for (const header of FORBIDDEN_AGENT_HEADERS) { + for (const suppliedName of [header, header.toUpperCase()]) { + assertKernelError(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ + headers: { [suppliedName]: 'forbidden' }, + correlationId: `header-${header.replaceAll('-', '_')}`, + }), + }), 'AGENT_HEADER_FORBIDDEN'); + } + } + for (const headers of [ + { Accept: 'application/json', accept: 'application/json' }, + { 'accept\r\nx-injected': 'application/json' }, + { accept: 'application/json\r\nx-injected: yes' }, + { accept: 'application/json\nx-injected: yes' }, + { 'x-not-allowlisted': 'value' }, + { accept: ['application/json'] }, + ]) { + assertKernelError(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ headers, correlationId: 'hostile-header' }), + }), headers['x-not-allowlisted'] ? 'AGENT_HEADER_UNSUPPORTED' : 'AGENT_HEADER_SCHEMA'); + } + let getterCalls = 0; + const accessorHeaders = {}; + Object.defineProperty(accessorHeaders, 'accept', { + enumerable: true, + get() { + getterCalls += 1; + return 'application/json'; + }, + }); + assertKernelError(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ headers: accessorHeaders }), + }), 'AGENT_HEADER_SCHEMA'); + assert.equal(getterCalls, 0); + assertKernelError(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ + headers: new Proxy({ accept: 'application/json' }, {}), + }), + }), 'AGENT_HEADER_SCHEMA'); + + const canonical = canonicalIntentFingerprint(requestWithoutCorrelation({ + headers: { Accept: ' application/json ', 'Content-Type': ' application/json ' }, + })); + const reordered = canonicalIntentFingerprint(requestWithoutCorrelation({ + headers: { 'content-type': 'application/json', accept: 'application/json' }, + })); + assert.equal(canonical.headerAllowlistHash, reordered.headerAllowlistHash); + assert.equal(canonical.ordinaryFingerprint, reordered.ordinaryFingerprint); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 0n); +}); + +test('session and request schemas reject caller authority and unsafe URLs without sentinel persistence', (t) => { + const context = setup(t); + assertKernelError(() => context.intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + sessionId: 'caller-session', + }), 'SESSION_SCHEMA'); + const session = openSession(context); + const unsafeUrls = [ + 'https://user:pass@seller.example/paid/infer', + 'http://seller.example/paid/infer', + 'http://localhost:8787/paid/infer', + 'http://2130706433:8787/paid/infer', + 'http://127.0.0.1:8787/paid/infer', + 'https://seller.example/paid/infer#fragment', + 'https://seller.example/paid/infer#', + 'https://seller.example/paid/infer?prompt=RAW_PROMPT_SENTINEL', + 'https://seller.example/paid/infer?', + ]; + for (const requestUrl of unsafeUrls) { + assertKernelError(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ requestUrl }), + }), 'REQUEST_URL'); + } + for (const routeId of ['', 'not a token']) { + assertKernelError(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ routeId }), + }), 'ROUTE_ID'); + } + assertKernelError(() => context.intents.captureIntent({ + sessionId: 'unknown-session', + ...ordinaryRequest(), + }), 'SESSION_UNKNOWN'); + const serialized = canonicalJson({ + rows: context.store.readAll('SELECT * FROM spend_intents'), + events: context.store.events().map((row) => JSON.parse(row.data_json)), + }); + assert.equal(serialized.includes('RAW_PROMPT_SENTINEL'), false); +}); + +test('loopback HTTP is explicit and accepts only canonical literal addresses', (t) => { + const context = setup(t, { allowLoopbackHttp: true }); + const session = openSession(context); + for (const [index, requestUrl] of [ + 'http://127.0.0.1:8787/paid/infer', + 'http://[::1]:8787/paid/infer', + ].entries()) { + const captured = context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ requestUrl, correlationId: `loopback-${index}` }), + }); + assert.equal(captured.requestUrlHash, sha256(requestUrl)); + } + for (const requestUrl of [ + 'http://localhost:8787/paid/infer', + 'http://127.1:8787/paid/infer', + 'http://2130706433:8787/paid/infer', + 'http://0177.0.0.1:8787/paid/infer', + 'http://[0:0:0:0:0:0:0:1]:8787/paid/infer', + 'http://192.168.1.1:8787/paid/infer', + 'http://user:pass@127.0.0.1:8787/paid/infer', + 'http://127.0.0.1:8787/paid/infer?raw=1', + 'http://[::1]:8787/paid/infer#fragment', + ]) { + assertKernelError(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ requestUrl, correlationId: 'bad-loopback' }), + }), 'REQUEST_URL'); + } +}); + +test('correlation and active fingerprint layers collapse retries without aliasing mutations', (t) => { + const context = setup(t); + const session = openSession(context); + const originalRequest = ordinaryRequest(); + const first = context.intents.captureIntent({ sessionId: session.id, ...originalRequest }); + const exact = context.intents.captureIntent({ sessionId: session.id, ...originalRequest }); + const otherCorrelation = context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ correlationId: 'pi-call-002' }), + }); + assert.deepEqual(exact, first); + assert.deepEqual(otherCorrelation, first); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'intent.captured', + ).length, 1); + assert.equal(context.intents.matchRetry({ + sessionId: session.id, + request: originalRequest, + }), first.id); + assert.equal(context.intents.matchRetry({ + sessionId: session.id, + request: ordinaryRequest({ + bodyBytes: Buffer.from('changed'), + correlationId: 'changed-body', + }), + }), null); + assertKernelError(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ bodyBytes: Buffer.from('changed') }), + }), 'CORRELATION_CONFLICT'); + + context.intents.transition({ + intentId: first.id, + expectedState: 'captured', + nextState: 'terminal', + reasonCode: 'TEST_TERMINAL', + }); + assert.equal(context.intents.matchRetry({ + sessionId: session.id, + request: originalRequest, + }), first.id); + assertKernelError(() => context.intents.matchRetry({ + sessionId: session.id, + request: ordinaryRequest({ + correlationId: originalRequest.correlationId, + purposeLabel: 'different.purpose', + }), + }), 'CORRELATION_CONFLICT'); +}); + +test('correlation-less fingerprint replay does not resample Kernel-issued IDs', (t) => { + const idFactory = trackedIds(); + const context = setup(t, { idFactory }); + const session = openSession(context); + const request = requestWithoutCorrelation(); + const first = context.intents.captureIntent({ sessionId: session.id, ...request }); + const callsAfterFirst = [...idFactory.calls]; + const replay = context.intents.captureIntent({ sessionId: session.id, ...request }); + + assert.equal(replay.id, first.id); + assert.equal(replay.requestId, first.requestId); + assert.deepEqual(idFactory.calls, callsAfterFirst); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 1n); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'intent.captured', + ).length, 1); + assert.equal(context.intents.matchRetry({ sessionId: session.id, request }), first.id); + assert.equal(context.intents.matchRetry({ sessionId: 'another-session', request }), null); + assert.equal(context.intents.matchRetry({ + sessionId: session.id, + request: requestWithoutCorrelation({ bodyBytes: Buffer.from('changed') }), + }), null); + assert.equal(context.intents.matchRetry({ + sessionId: session.id, + request: requestWithoutCorrelation({ purposeLabel: 'inference.invoke' }), + }), null); +}); + +test('an ordinary follower correlation remains durably bound after terminal release', (t) => { + const context = setup(t); + const session = openSession(context); + const first = context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ correlationId: 'pi-call-primary' }), + }); + const followerRequest = ordinaryRequest({ correlationId: 'pi-call-follower' }); + const follower = context.intents.captureIntent({ sessionId: session.id, ...followerRequest }); + assert.equal(follower.id, first.id); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'intent.correlation_bound', + ).length, 1); + + context.intents.transition({ + intentId: first.id, + expectedState: 'captured', + nextState: 'terminal', + reasonCode: 'TEST_TERMINAL', + }); + assert.equal(context.intents.matchRetry({ + sessionId: session.id, + request: followerRequest, + }), first.id); + assert.equal(context.intents.captureIntent({ + sessionId: session.id, + ...followerRequest, + }).id, first.id); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 1n); + + const later = context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ correlationId: 'pi-call-later' }), + }); + assert.notEqual(later.id, first.id); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 2n); +}); + +test('capture owns its byte snapshot and simultaneous ordinary followers share one intent', async (t) => { + const context = setup(t); + const session = openSession(context); + const body = Buffer.from('{"prompt":"owned snapshot"}'); + const original = Buffer.from(body); + const first = context.intents.captureIntent({ + sessionId: session.id, + ...requestWithoutCorrelation({ bodyBytes: body }), + }); + body.fill(0x78); + assert.equal(context.intents.matchRetry({ + sessionId: session.id, + request: requestWithoutCorrelation({ bodyBytes: original }), + }), first.id); + + const [left, right] = await Promise.all([ + Promise.resolve().then(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ correlationId: 'parallel-left' }), + })), + Promise.resolve().then(() => context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ correlationId: 'parallel-right' }), + })), + ]); + assert.equal(left.id, right.id); + assert.equal(left.requestId, right.requestId); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 2n); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'intent.captured', + ).length, 2); +}); + +test('captureIntentInTransaction succeeds only inside its live authority scope', (t) => { + const context = setup(t); + const session = openSession(context); + const captured = context.store.transaction((token) => ( + context.intents.captureIntentInTransaction(token, { + sessionId: session.id, + ...ordinaryRequest({ correlationId: 'scoped-capture' }), + }) + )); + + assert.equal(context.intents.getIntent(captured.id).id, captured.id); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 1n); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'intent.captured', + ).length, 1); + + let staleToken; + context.store.transaction((token) => { + staleToken = token; + }); + for (const token of [Object.freeze(Object.create(null)), staleToken]) { + assert.throws(() => context.intents.captureIntentInTransaction(token, { + sessionId: session.id, + ...ordinaryRequest({ correlationId: 'invalid-scope-capture' }), + }), /invalid authority transaction/); + } + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 1n); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'intent.captured', + ).length, 1); +}); + +test('captureIntentInTransaction rolls intent, request authority, and event back with its owner', (t) => { + const context = setup(t); + const session = openSession(context); + const eventsBefore = context.store.events().length; + + assert.throws(() => context.store.transaction((token) => { + const captured = context.intents.captureIntentInTransaction(token, { + sessionId: session.id, + ...ordinaryRequest({ correlationId: 'rolled-back-capture' }), + }); + assert.equal(captured.state, 'captured'); + throw new Error('outer aggregate fault'); + }), /outer aggregate fault/); + + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 0n); + assert.equal(context.store.readOne( + 'SELECT COUNT(DISTINCT request_id) AS count FROM spend_intents', + ).count, 0n); + assert.equal(context.store.events().length, eventsBefore); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'intent.captured', + ).length, 0); +}); + +test('challenge attachment is canonical, one-way, active-epoch bound, and transaction scoped', (t) => { + const context = setup(t); + const session = openSession(context); + const intent = context.intents.captureIntent({ sessionId: session.id, ...ordinaryRequest() }); + const challenge = paymentRequired(); + const attached = context.intents.attachChallenge({ + intentId: intent.id, + paymentRequired: challenge, + challengeReceivedAt: NOW, + }); + assert.equal(attached.state, 'challenged'); + assert.match(attached.challengeHash, /^sha256:[0-9a-f]{64}$/); + assert.equal(attached.challengeProjectionJson.includes(challenge.resource.url), false); + assert.equal(attached.challengeProjectionJson.includes(challenge.error), false); + assert.equal(JSON.parse(attached.challengeProjectionJson).resource.urlHash, + sha256(challenge.resource.url)); + const events = context.store.events().filter( + (row) => row.event_type === 'intent.challenge_attached', + ); + const replay = context.intents.attachChallenge({ + intentId: intent.id, + paymentRequired: { ...challenge, error: 'different unbound seller prose' }, + challengeReceivedAt: NOW, + }); + assert.deepEqual(replay, attached); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'intent.challenge_attached', + ).length, events.length); + + const changed = paymentRequired({ + accepts: [{ ...challenge.accepts[0], amount: '50001' }], + }); + assertKernelError(() => context.intents.attachChallenge({ + intentId: intent.id, + paymentRequired: changed, + challengeReceivedAt: NOW, + }), 'CHALLENGE_CHANGED'); + assert.throws(() => context.intents.attachChallengeInTransaction( + Object.freeze(Object.create(null)), + { intentId: intent.id, paymentRequired: challenge, challengeReceivedAt: NOW }, + ), /invalid authority transaction/); + + const second = context.intents.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ correlationId: 'challenge-rollback', bodyBytes: Buffer.from('two') }), + }); + assert.throws(() => context.store.transaction((token) => { + context.intents.attachChallengeInTransaction(token, { + intentId: second.id, + paymentRequired: challenge, + challengeReceivedAt: NOW, + }); + throw new Error('aggregate fault'); + }), /aggregate fault/); + assert.equal(context.intents.getIntent(second.id).state, 'captured'); + assert.equal(context.intents.getIntent(second.id).challengeHash, null); +}); + +test('challenge attachment binds the intent URL and rejects a revoked enrollment even on replay', (t) => { + const context = setup(t); + const session = openSession(context); + const intent = context.intents.captureIntent({ sessionId: session.id, ...ordinaryRequest() }); + assertKernelError(() => context.intents.attachChallenge({ + intentId: intent.id, + paymentRequired: paymentRequired({ + resource: { + url: 'https://seller.example/paid/other', + description: 'offline fixture', + mimeType: 'application/json', + }, + }), + challengeReceivedAt: NOW, + }), 'CHALLENGE_RESOURCE_MISMATCH'); + context.intents.attachChallenge({ + intentId: intent.id, + paymentRequired: paymentRequired(), + challengeReceivedAt: NOW, + }); + createAgentEnrollmentRepository({ store: context.store, now: () => NOW }).revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + assertKernelError(() => context.intents.attachChallenge({ + intentId: intent.id, + paymentRequired: paymentRequired(), + challengeReceivedAt: NOW, + }), 'AGENT_REVOKED'); +}); + +test('challenge persistence requires exact operator-owned route metadata', (t) => { + const context = setup(t); + const session = openSession(context); + const intent = context.intents.captureIntent({ sessionId: session.id, ...ordinaryRequest() }); + for (const resource of [ + { + url: 'https://seller.example/paid/infer', + description: 'RAW_PROMPT_SENTINEL', + mimeType: 'application/json', + }, + { + url: 'https://seller.example/paid/infer', + description: 'offline fixture', + mimeType: 'text/plain', + }, + ]) { + assertKernelError(() => context.intents.attachChallenge({ + intentId: intent.id, + paymentRequired: paymentRequired({ resource }), + challengeReceivedAt: NOW, + }), 'CHALLENGE_RESOURCE_METADATA_MISMATCH'); + } + assert.equal(context.intents.getIntent(intent.id).state, 'captured'); + const persistedText = [ + ...context.store.readAll('SELECT * FROM spend_intents') + .flatMap((row) => Object.values(row).map(String)), + ...context.store.events().map((event) => event.data_json), + ].join('\n'); + assert.equal(persistedText.includes('RAW_PROMPT_SENTINEL'), false); + + const repositoryWithoutRouteMap = createIntentRepository({ + store: context.store, + idFactory: sequenceIds(), + now: () => NOW, + }); + assertKernelError(() => repositoryWithoutRouteMap.attachChallenge({ + intentId: intent.id, + paymentRequired: paymentRequired(), + challengeReceivedAt: NOW, + }), 'ROUTE_METADATA_REQUIRED'); +}); + +test('intent transitions enforce the strict graph and terminal retry release', (t) => { + const context = setup(t); + const session = openSession(context); + const intent = context.intents.captureIntent({ sessionId: session.id, ...ordinaryRequest() }); + context.intents.attachChallenge({ + intentId: intent.id, + paymentRequired: paymentRequired(), + challengeReceivedAt: NOW, + }); + assertKernelError(() => context.intents.transition({ + intentId: intent.id, + expectedState: 'challenged', + nextState: 'signed', + reasonCode: 'ILLEGAL_TEST', + }), 'INTENT_TRANSITION'); + let current = context.intents.transition({ + intentId: intent.id, + expectedState: 'challenged', + nextState: 'authorized', + reasonCode: 'POLICY_ALLOWED', + }); + for (const [expectedState, nextState] of [ + ['authorized', 'reserved'], + ['reserved', 'signing'], + ['signing', 'signed'], + ['signed', 'retrying'], + ['retrying', 'unresolved'], + ['unresolved', 'terminal'], + ]) { + current = context.intents.transition({ + intentId: intent.id, + expectedState, + nextState, + reasonCode: `TO_${nextState.toUpperCase()}`, + }); + assert.equal(current.state, nextState); + } + assert.equal(current.retryMatchable, false); + assertKernelError(() => context.intents.transition({ + intentId: intent.id, + expectedState: 'unresolved', + nextState: 'terminal', + reasonCode: 'STALE_REPLAY', + }), 'INTENT_STATE_CONFLICT'); + assert.throws(() => context.intents.transitionInTransaction( + Object.freeze(Object.create(null)), + { + intentId: intent.id, + expectedState: 'terminal', + nextState: 'terminal', + reasonCode: 'FORGED', + }, + ), /invalid authority transaction/); +}); + +test('guarded session close is hash-bound, atomic, replayable, and supports revoked cleanup', (t) => { + const context = setup(t); + const session = openSession(context); + assertKernelError(() => context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: `sha256:${'00'.repeat(32)}`, + }) + )), 'SESSION_CONFIRMATION_STALE'); + createAgentEnrollmentRepository({ store: context.store, now: () => NOW }).revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + const closed = context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + }) + )); + assert.equal(closed.closedSession.state, 'closed'); + const replay = context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + }) + )); + assert.deepEqual(replay, closed); + assert.throws(() => context.intents.closeBoundSessionInTransaction( + Object.freeze(Object.create(null)), + { sessionId: session.id, expectedSessionHash: session.sessionHash }, + ), /invalid authority transaction/); +}); + +test('policy-blocked session transition closes the old pair and opens one active replacement', (t) => { + const context = setup(t); + const session = openSession(context); + const nextPolicy = structuredClone(POLICY); + nextPolicy.sellers[0].autoApproveAtomic = '50000'; + const applied = createPolicyRepository(context.store).apply( + nextPolicy, + '2026-07-31T12:01:00.000Z', + ); + const blocked = context.intents.getSession(session.id); + assert.equal(blocked.state, 'policy_blocked'); + const transitioned = context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: session.id, + targetPolicyVersionId: applied.policyVersion.id, + expectedSessionHash: blocked.sessionHash, + }) + )); + assert.equal(transitioned.previousSession.state, 'closed'); + assert.equal(transitioned.replacementSession.state, 'open'); + assert.equal(transitioned.replacementSession.policyVersionId, applied.policyVersion.id); + const replay = context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: session.id, + targetPolicyVersionId: applied.policyVersion.id, + expectedSessionHash: blocked.sessionHash, + }) + )); + assert.deepEqual(replay, transitioned); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'session.policy_transitioned', + ).length, 1); +}); + +test('policy transition rejects stale confirmation and fake or stale transaction scopes', (t) => { + const context = blockedSessionContext(t); + const eventsBefore = context.store.events().length; + const transition = (token, expectedSessionHash = context.blockedSession.sessionHash) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash, + }) + ); + + assertKernelError(() => context.store.transaction((token) => ( + transition(token, sha256('stale policy transition confirmation')) + )), 'SESSION_CONFIRMATION_STALE'); + + let staleToken; + context.store.transaction((token) => { + staleToken = token; + }); + for (const token of [Object.freeze(Object.create(null)), staleToken]) { + assert.throws(() => transition(token), /invalid authority transaction/); + } + + assert.equal(context.intents.getSession(context.session.id).state, 'policy_blocked'); + const unchangedPair = context.store.readOne(`SELECT spend_sessions.state AS session_state, + agent_session_bindings.state AS binding_state + FROM spend_sessions JOIN agent_session_bindings + ON agent_session_bindings.session_id = spend_sessions.id + WHERE spend_sessions.id = ?`, [context.session.id]); + assert.equal(unchangedPair.session_state, 'policy_blocked'); + assert.equal(unchangedPair.binding_state, 'open'); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_sessions').count, 1n); + assert.equal(context.store.events().length, eventsBefore); + assert.equal(context.store.events().filter((row) => [ + 'session.binding_closed', + 'session.policy_transitioned', + ].includes(row.event_type)).length, 0); +}); + +test('policy transition rolls the old close, replacement pair, and events back with its owner', (t) => { + const context = blockedSessionContext(t); + const eventsBefore = context.store.events().length; + + assert.throws(() => context.store.transaction((token) => { + const provisional = context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash: context.blockedSession.sessionHash, + }); + assert.equal(provisional.previousSession.state, 'closed'); + assert.equal(provisional.replacementSession.state, 'open'); + throw new Error('outer aggregate fault'); + }), /outer aggregate fault/); + + assert.equal(context.intents.getSession(context.session.id).state, 'policy_blocked'); + const restoredPair = context.store.readOne(`SELECT spend_sessions.state AS session_state, + agent_session_bindings.state AS binding_state + FROM spend_sessions JOIN agent_session_bindings + ON agent_session_bindings.session_id = spend_sessions.id + WHERE spend_sessions.id = ?`, [context.session.id]); + assert.equal(restoredPair.session_state, 'policy_blocked'); + assert.equal(restoredPair.binding_state, 'open'); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_sessions').count, 1n); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM agent_session_bindings', + ).count, 1n); + assert.equal(context.store.events().length, eventsBefore); + assert.equal(context.store.events().filter((row) => [ + 'session.binding_closed', + 'session.policy_transitioned', + ].includes(row.event_type)).length, 0); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'session.started', + ).length, 1); +}); + +test('policy transition exact replay retains the original replacement after a later policy blocks it', (t) => { + const context = blockedSessionContext(t); + const transitioned = context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash: context.blockedSession.sessionHash, + }) + )); + const laterPolicy = structuredClone(POLICY); + laterPolicy.sellers[0].autoApproveAtomic = '40000'; + createPolicyRepository(context.store).apply( + laterPolicy, + '2026-07-31T12:02:00.000Z', + ); + assert.equal( + context.intents.getSession(transitioned.replacementSession.id).state, + 'policy_blocked', + ); + + const replay = context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash: context.blockedSession.sessionHash, + }) + )); + assert.deepEqual(replay, transitioned); + assert.equal(replay.replacementSession.state, 'open'); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'session.policy_transitioned', + ).length, 1); +}); + +test('policy transition exact replay retains the original replacement after enrollment revocation', (t) => { + const context = blockedSessionContext(t); + const transitioned = context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash: context.blockedSession.sessionHash, + }) + )); + createAgentEnrollmentRepository({ store: context.store, now: () => NOW }).revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + + const replay = context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash: context.blockedSession.sessionHash, + }) + )); + assert.deepEqual(replay, transitioned); + assert.equal(context.store.events().filter( + (row) => row.event_type === 'session.policy_transitioned', + ).length, 1); +}); + +test('session close refuses nonterminal intent ambiguity and rolls back with its owner', (t) => { + const context = setup(t); + const session = openSession(context); + const intent = context.intents.captureIntent({ sessionId: session.id, ...ordinaryRequest() }); + assertKernelError(() => context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + }) + )), 'SESSION_MONETARY_AMBIGUITY'); + + context.intents.transition({ + intentId: intent.id, + expectedState: 'captured', + nextState: 'terminal', + reasonCode: 'TEST_TERMINAL', + }); + context.store.execForTest(`INSERT INTO buyer_outcomes( + intent_id, status, reason_code, revision, recorded_at + ) VALUES ('${intent.id}', 'payment_denied', 'TEST_TERMINAL', 1, '${NOW}')`); + assert.throws(() => context.store.transaction((token) => { + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + }); + throw new Error('aggregate fault'); + }), /aggregate fault/); + assert.equal(context.intents.getSession(session.id).state, 'open'); +}); + +test('capture commits before any injected transport probe', (t) => { + const context = setup(t); + const session = openSession(context); + const callerBody = Buffer.from('{"prompt":"owned outbound snapshot"}'); + const originalBody = Buffer.from(callerBody); + let observed; + const transport = { + probe({ intent, bodyBytes }) { + callerBody.fill(0x78); + observed = context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [intent.id], + )?.state; + assert.deepEqual(bodyBytes, originalBody); + assert.equal(sha256(bodyBytes), intent.bodyHash); + return 'unpaid'; + }, + }; + const captureThenProbe = (request) => { + const bodyBytes = Buffer.from(request.bodyBytes); + const intent = context.intents.captureIntent({ + sessionId: session.id, + ...request, + bodyBytes, + }); + return transport.probe({ intent, bodyBytes }); + }; + assert.equal(captureThenProbe(ordinaryRequest({ bodyBytes: callerBody })), 'unpaid'); + assert.equal(observed, 'captured'); +}); + +test('file-backed reopen resumes one exact authority and retains only request hashes', (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-intent-reopen-')); + fs.chmodSync(directory, 0o700); + const databasePath = path.join(directory, 'kernel.sqlite'); + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); + let store; + t.after(() => { + try { store?.close(); } catch {} + fs.rmSync(directory, { force: true, recursive: true }); + }); + store = openKernelStore({ filePath: databasePath, pathTrust, now: () => NOW }); + const activePolicy = createPolicyRepository(store).apply(POLICY, NOW).policyVersion; + createAgentEnrollmentRepository({ store, now: () => NOW }).enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: DESCRIPTOR_HASH, + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const firstRepository = createIntentRepository({ + store, + idFactory: sequenceIds(), + now: () => NOW, + }); + const firstSession = firstRepository.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: activePolicy.id, + }); + const firstIntent = firstRepository.captureIntent({ + sessionId: firstSession.id, + ...ordinaryRequest({ + bodyBytes: Buffer.from('{"prompt":"RAW_PROMPT_SENTINEL"}'), + }), + }); + store.close(); + + let clockCalls = 0; + const reopenedIds = trackedIds(); + store = openKernelStore({ filePath: databasePath, pathTrust, now: () => NOW }); + const reopened = createIntentRepository({ + store, + idFactory: reopenedIds, + now: () => { + clockCalls += 1; + return 'not-a-timestamp'; + }, + }); + const resumed = reopened.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: activePolicy.id, + }); + const retained = reopened.getIntent(firstIntent.id); + + assert.deepEqual(resumed, firstSession); + assert.equal(retained.requestUrlHash, firstIntent.requestUrlHash); + assert.equal(retained.bodyHash, firstIntent.bodyHash); + assert.equal(retained.headerAllowlistHash, firstIntent.headerAllowlistHash); + assert.deepEqual(reopenedIds.calls, []); + assert.equal(clockCalls, 0); + assert.equal(store.readOne('SELECT COUNT(*) AS count FROM spend_sessions').count, 1n); + assert.equal(store.events().filter((row) => row.event_type === 'session.started').length, 1); + assert.equal(store.readOne('SELECT COUNT(*) AS count FROM budget_reservations').count, 0n); + const persistedText = [ + ...store.readAll('SELECT * FROM spend_intents'), + ...store.events(), + ].flatMap((row) => Object.values(row).map(String)).join('\n'); + assert.equal(persistedText.includes('RAW_PROMPT_SENTINEL'), false); + store.close(); + store = null; + for (const name of fs.readdirSync(directory)) { + assert.equal(fs.readFileSync(path.join(directory, name)).includes('RAW_PROMPT_SENTINEL'), false); + } +}); + +test('two file-backed worker stores race one initial capture to one durable winner', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-intent-race-')); + fs.chmodSync(directory, 0o700); + const databasePath = path.join(directory, 'kernel.sqlite'); + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); + const workers = []; + let verifier; + t.after(async () => { + try { verifier?.close(); } catch {} + await Promise.all(workers.map(async (worker) => { + try { await worker.terminate(); } catch {} + })); + fs.rmSync(directory, { force: true, recursive: true }); + }); + + let initializer = openKernelStore({ filePath: databasePath, pathTrust, now: () => NOW }); + const activePolicy = createPolicyRepository(initializer).apply(POLICY, NOW).policyVersion; + createAgentEnrollmentRepository({ store: initializer, now: () => NOW }).enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: DESCRIPTOR_HASH, + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const session = createIntentRepository({ + store: initializer, + idFactory: sequenceIds(), + now: () => NOW, + }).openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: activePolicy.id, + }); + initializer.close(); + initializer = null; + + const gate = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); + const workerSource = ` + import { parentPort, threadId, workerData } from 'node:worker_threads'; + + const { openKernelStore } = await import(workerData.storeModule); + const { createIntentRepository } = await import(workerData.intentModule); + const counts = new Map(); + const idFactory = (kind) => { + const next = (counts.get(kind) ?? 0) + 1; + counts.set(kind, next); + return kind + '-' + next; + }; + const store = openKernelStore({ + filePath: workerData.databasePath, + pathTrust: Object.freeze(workerData.pathTrust), + now: () => workerData.now, + }); + const repository = createIntentRepository({ + store, + idFactory, + now: () => workerData.now, + }); + parentPort.postMessage({ type: 'ready', threadId }); + parentPort.once('message', () => { + let reply; + try { + const barrier = new Int32Array(workerData.gate); + Atomics.add(barrier, 0, 1); + Atomics.notify(barrier, 0); + while (Atomics.load(barrier, 0) < 2) { + if (Atomics.wait(barrier, 0, 1, 5000) === 'timed-out') { + throw new Error('initial-capture race barrier timed out'); + } + } + const intent = repository.captureIntent({ + sessionId: workerData.sessionId, + routeId: workerData.request.routeId, + method: workerData.request.method, + requestUrl: workerData.request.requestUrl, + headers: workerData.request.headers, + bodyBytes: Buffer.from(workerData.bodyBase64, 'base64'), + purposeLabel: workerData.request.purposeLabel, + }); + reply = { + type: 'result', + ok: true, + threadId, + id: intent.id, + requestId: intent.requestId, + intentHash: intent.intentHash, + }; + } catch (error) { + reply = { + type: 'result', + ok: false, + threadId, + errorCode: error?.code ?? null, + errorMessage: error?.message ?? String(error), + errorStack: error?.stack ?? null, + }; + } finally { + store.close(); + } + parentPort.postMessage(reply); + parentPort.close(); + }); + `; + const workerUrl = new URL( + `data:text/javascript;charset=utf-8,${encodeURIComponent(workerSource)}`, + ); + const workerData = { + storeModule: new URL('../src/kernel/sqlite-store.mjs', import.meta.url).href, + intentModule: new URL('../src/kernel/intent-builder.mjs', import.meta.url).href, + databasePath, + pathTrust, + now: NOW, + gate, + sessionId: session.id, + request: requestWithoutCorrelation(), + bodyBase64: ordinaryRequest().bodyBytes.toString('base64'), + }; + delete workerData.request.bodyBytes; + workers.push( + new Worker(workerUrl, { workerData }), + new Worker(workerUrl, { workerData }), + ); + + const ready = await Promise.all(workers.map((worker) => nextWorkerMessage(worker))); + assert.deepEqual(ready.map((message) => message.type), ['ready', 'ready']); + const resultPromises = workers.map((worker) => nextWorkerMessage(worker)); + for (const worker of workers) worker.postMessage('capture'); + const results = await Promise.all(resultPromises); + for (const result of results) { + assert.equal(result.ok, true, result.errorStack ?? result.errorMessage); + } + assert.notEqual(results[0].threadId, results[1].threadId); + assert.equal(results[0].id, results[1].id); + assert.equal(results[0].requestId, results[1].requestId); + assert.equal(results[0].intentHash, results[1].intentHash); + + verifier = openKernelStore({ filePath: databasePath, pathTrust, now: () => NOW }); + assert.equal(verifier.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 1n); + assert.equal(verifier.readOne( + 'SELECT COUNT(DISTINCT request_id) AS count FROM spend_intents', + ).count, 1n); + assert.equal(verifier.events().filter( + (row) => row.event_type === 'intent.captured', + ).length, 1); +}); + +test('changed wallet, policy, or enrollment epoch never resumes an existing session', (t) => { + const walletContext = setup(t); + const walletSession = openSession(walletContext); + assertKernelError(() => walletContext.intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: '0x4000000000000000000000000000000000000000', + policyVersionId: walletContext.activePolicy.id, + }), 'POLICY_WALLET_MISMATCH'); + assert.equal(walletContext.store.readOne('SELECT COUNT(*) AS count FROM spend_sessions').count, 1n); + assert.equal(walletContext.intents.getSession(walletSession.id).id, walletSession.id); + + const policyContext = setup(t); + const policySession = openSession(policyContext); + const nextPolicy = structuredClone(POLICY); + nextPolicy.sellers[0].autoApproveAtomic = '50000'; + const nextVersion = createPolicyRepository(policyContext.store).apply( + nextPolicy, + '2026-07-31T12:01:00.000Z', + ).policyVersion; + assertKernelError(() => policyContext.intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: nextVersion.id, + }), 'AGENT_SESSION_UNAVAILABLE'); + assert.equal(policyContext.intents.getSession(policySession.id).state, 'policy_blocked'); + + const enrollmentContext = setup(t); + const enrollmentSession = openSession(enrollmentContext); + createAgentEnrollmentRepository({ + store: enrollmentContext.store, + now: () => NOW, + }).revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrollmentContext.enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + assertKernelError(() => enrollmentContext.intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: enrollmentContext.activePolicy.id, + }), 'AGENT_REVOKED'); + assert.equal(enrollmentContext.intents.getSession(enrollmentSession.id).id, enrollmentSession.id); +}); + +for (const scenario of [ + { + name: 'closed session', + code: 'SESSION_CLOSED', + mutate(context, session) { + const intentId = context.store.readOne( + 'SELECT id FROM spend_intents WHERE session_id = ?', + [session.id], + ).id; + context.intents.transition({ + intentId, + expectedState: 'captured', + nextState: 'terminal', + reasonCode: 'TEST_TERMINAL', + }); + context.store.execForTest(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES ('${intentId}', 'payment_denied', 'TEST_TERMINAL', 1, '${NOW}')`); + context.store.transaction((token) => context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + })); + }, + }, + { + name: 'policy-blocked session', + code: 'SESSION_POLICY_BLOCKED', + mutate(context) { + const nextPolicy = structuredClone(POLICY); + nextPolicy.sellers[0].autoApproveAtomic = '50000'; + createPolicyRepository(context.store).apply(nextPolicy, '2026-07-31T12:01:00.000Z'); + }, + }, + { + name: 'revoked enrollment', + code: 'AGENT_REVOKED', + mutate(context) { + createAgentEnrollmentRepository({ store: context.store, now: () => NOW }).revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrolled.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + }, + }, + { + name: 'corrupted session pair', + code: 'SESSION_AUTHORITY_AMBIGUOUS', + mutate(context, session) { + context.store.execForTest(`UPDATE spend_sessions + SET adapter_id = 'pi:BBBBBBBBBBBBBBBBBBBBBB' WHERE id = '${session.id}'`); + }, + }, +]) { + test(`matchRetry rejects ${scenario.name}`, (t) => { + const context = setup(t); + const session = openSession(context); + const request = ordinaryRequest(); + context.intents.captureIntent({ sessionId: session.id, ...request }); + scenario.mutate(context, session); + assertKernelError(() => context.intents.matchRetry({ + sessionId: session.id, + request, + }), scenario.code); + }); +} + +test('matchRetry performs its authority check and lookup in one transaction', (t) => { + const context = setup(t); + const session = openSession(context); + const request = ordinaryRequest(); + const intent = context.intents.captureIntent({ sessionId: session.id, ...request }); + let transactionCount = 0; + let transactionDepth = 0; + const guardedStore = Object.freeze({ + transaction(operation) { + transactionCount += 1; + return context.store.transaction((token) => { + transactionDepth += 1; + try { + return operation(token); + } finally { + transactionDepth -= 1; + } + }); + }, + within: context.store.within, + readOne(...args) { + if (transactionDepth === 0) throw new Error('read escaped authoritative transaction'); + return context.store.readOne(...args); + }, + readAll(...args) { + if (transactionDepth === 0) throw new Error('read escaped authoritative transaction'); + return context.store.readAll(...args); + }, + }); + const repository = createIntentRepository({ + store: guardedStore, + idFactory: sequenceIds(), + now: () => NOW, + }); + + assert.equal(repository.matchRetry({ sessionId: session.id, request }), intent.id); + assert.equal(transactionCount, 1); +}); + +for (const corruption of [ + { + name: 'request/body hashes', + sql: `body_hash = 'sha256:${'00'.repeat(32)}'`, + }, + { + name: 'intent hash', + sql: `intent_hash = 'sha256:${'01'.repeat(32)}'`, + }, + { + name: 'idempotency binding', + sql: `idempotency_key = 'wk_${'02'.repeat(32)}'`, + }, + { + name: 'state/challenge tuple', + sql: "state = 'challenged'", + }, + { + name: 'partial challenge columns', + sql: "state = 'challenged', challenge_projection_json = '{}'", + }, + { + name: 'canonical timestamps', + sql: "updated_at = 'not-a-timestamp'", + }, + { + name: 'monotonic timestamps', + sql: "created_at = '2026-07-31T12:01:00.000Z', updated_at = '2026-07-31T12:00:00.000Z'", + }, +]) { + test(`getIntent fails closed on persisted ${corruption.name} corruption`, (t) => { + const context = capturedContext(t); + context.store.execForTest(`UPDATE spend_intents SET ${corruption.sql} + WHERE id = '${context.intent.id}'`); + assertKernelError(() => context.intents.getIntent(context.intent.id), 'INTENT_CORRUPTION'); + }); +} + +for (const operation of [ + { + name: 'getIntent', + invoke(context) { + return context.intents.getIntent(context.intent.id); + }, + }, + { + name: 'captureIntent replay', + invoke(context) { + return context.intents.captureIntent({ + sessionId: context.session.id, + ...context.request, + }); + }, + }, + { + name: 'matchRetry', + invoke(context) { + return context.intents.matchRetry({ + sessionId: context.session.id, + request: context.request, + }); + }, + }, + { + name: 'attachChallenge', + invoke(context) { + return context.intents.attachChallenge({ + intentId: context.intent.id, + paymentRequired: paymentRequired(), + challengeReceivedAt: NOW, + }); + }, + }, + { + name: 'transition', + invoke(context) { + return context.intents.transition({ + intentId: context.intent.id, + expectedState: 'captured', + nextState: 'terminal', + reasonCode: 'CORRUPTION_TEST', + }); + }, + }, +]) { + test(`${operation.name} validates the complete persisted intent binding`, (t) => { + const context = capturedContext(t); + context.store.execForTest(`UPDATE spend_intents + SET intent_hash = 'sha256:${'03'.repeat(32)}' + WHERE id = '${context.intent.id}'`); + assertKernelError(() => operation.invoke(context), 'INTENT_CORRUPTION'); + }); +} + +test('openOrResume rejects an open session paired to a closed binding before ID allocation', (t) => { + const idFactory = trackedIds(); + const context = setup(t, { idFactory }); + const session = openSession(context); + context.store.execForTest(`UPDATE agent_session_bindings + SET state = 'closed', closed_at = '${NOW}' WHERE session_id = '${session.id}'`); + const callsBeforeReplay = [...idFactory.calls]; + + assertKernelError(() => context.intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }), 'SESSION_AUTHORITY_AMBIGUOUS'); + assert.deepEqual(idFactory.calls, callsBeforeReplay); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_sessions').count, 1n); +}); + +test('openOrResume rejects an extra malformed live-adapter session pair', (t) => { + const context = setup(t); + const session = openSession(context); + context.store.execForTest(` + INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at) + SELECT 'session-extra', adapter_id, wallet_address, policy_version_id, + 'policy_blocked', created_at + FROM spend_sessions WHERE id = '${session.id}'; + INSERT INTO agent_session_bindings + (id, agent_instance_id, credential_digest, enrollment_hash, session_id, + state, created_at, last_seen_at, closed_at) + VALUES ('binding-extra', '${DESCRIPTOR.agentInstanceId}', + '${DESCRIPTOR.credentialDigest}', '${context.enrolled.enrollmentHash}', + 'session-extra', 'closed', '${NOW}', '${NOW}', '${NOW}'); + `); + + assertKernelError(() => context.intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }), 'SESSION_AUTHORITY_AMBIGUOUS'); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_sessions').count, 2n); +}); + +test('Kernel-issued session ID collisions retry without reopening a historical authority', (t) => { + const context = setup(t); + const first = openSession(context); + context.store.transaction((token) => context.intents.closeBoundSessionInTransaction(token, { + sessionId: first.id, + expectedSessionHash: first.sessionHash, + })); + const candidates = ['session-1', 'session-2']; + const collisionRepository = createIntentRepository({ + store: context.store, + idFactory: (kind) => (kind === 'session' ? candidates.shift() : `${kind}-collision`), + now: () => '2026-07-31T12:02:00.000Z', + }); + + const replacement = collisionRepository.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + assert.equal(replacement.id, 'session-2'); + assert.equal(context.intents.getSession(first.id).state, 'closed'); + assert.equal(replacement.state, 'open'); +}); + +test('Kernel-issued intent/request ID collisions retry without aliasing another request', (t) => { + const context = setup(t); + const session = openSession(context); + const first = context.intents.captureIntent({ sessionId: session.id, ...ordinaryRequest() }); + const candidates = { + intent: ['intent-1', 'intent-2'], + request: ['request-1', 'request-2'], + }; + const collisionRepository = createIntentRepository({ + store: context.store, + idFactory: (kind) => candidates[kind]?.shift() ?? `${kind}-collision`, + now: () => '2026-07-31T12:02:00.000Z', + }); + const second = collisionRepository.captureIntent({ + sessionId: session.id, + ...ordinaryRequest({ + bodyBytes: Buffer.from('{"different":true}'), + correlationId: 'pi-call-collision', + }), + }); + + assert.equal(second.id, 'intent-2'); + assert.equal(second.requestId, 'request-2'); + assert.notEqual(second.intentHash, first.intentHash); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM spend_intents').count, 2n); + assert.equal(context.intents.getIntent(first.id).correlationId, 'pi-call-001'); +}); + +const CLOSE_BLOCKERS = [ + ...['pending'].map((state) => ({ + name: `${state} approval`, + insert(context) { + context.store.execForTest(`INSERT INTO approvals + (id, intent_id, decision, intent_hash, challenge_hash, quote_id, + accepted_index, amount_ceiling_atomic, wallet_address, policy_version_id, + expires_at) + VALUES ('approval-${state}', '${context.intent.id}', '${state}', + '${context.intent.intentHash}', 'sha256:${'11'.repeat(32)}', + 'sha256:${'12'.repeat(32)}', 0, '50000', '${WALLET}', + '${context.activePolicy.id}', '2026-07-31T12:05:00.000Z')`); + }, + })), + ...['reserved', 'unresolved'].map((state) => ({ + name: `${state} budget reservation`, + insert(context) { + context.store.execForTest(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, updated_at) + VALUES ('${context.intent.id}', '${context.session.id}', 'https://seller.example', + '${state === 'reserved' ? '50000' : '0'}', '0', '0', + '${state === 'unresolved' ? '50000' : '0'}', '${state}', '${NOW}')`); + }, + })), + ...['reserved', 'signing', 'signed', 'retrying', 'unresolved'].map((state) => ({ + name: `${state} payment attempt`, + insert(context) { + context.store.execForTest(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, created_at, updated_at) + VALUES ('payment-${state}', '${context.intent.id}', '${state}', '{}', 0, + 'sha256:${'13'.repeat(32)}', '${NOW}', '${NOW}')`); + }, + })), + { + name: 'pending payment reconciliation candidate', + insert(context) { + context.store.execForTest(` + INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, created_at, updated_at) + VALUES ('payment-rejected', '${context.intent.id}', 'rejected', '{}', 0, + 'sha256:${'14'.repeat(32)}', '${NOW}', '${NOW}'); + INSERT INTO payment_reconciliation_candidates + (id, intent_id, transaction_id, state, created_at, updated_at) + VALUES ('candidate-1', '${context.intent.id}', + '0x${'15'.repeat(32)}', 'pending', '${NOW}', '${NOW}'); + `); + }, + }, + ...['refund_pending', 'reconciliation_required'].map((state) => ({ + name: `${state} execution resolution`, + insert(context) { + context.store.execForTest(` + INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES ('${context.intent.id}', 'failed', 500, + 'sha256:${'16'.repeat(32)}', '{}', '${NOW}'); + INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at) + VALUES ('${context.intent.id}', '${state}', 'TEST_OPEN_CASE', 1, '${NOW}'); + `); + }, + })), + { + name: 'unresolved execution outcome without a resolution row', + insert(context) { + context.store.execForTest(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, committed_at, updated_at) + VALUES ('${context.intent.id}', '${context.session.id}', 'https://seller.example', + '0', '50000', '0', '0', 'committed', '${NOW}', '${NOW}'); + INSERT INTO execution_outcomes + (intent_id, state, metadata_json, recorded_at) + VALUES ('${context.intent.id}', 'unknown', '{}', '${NOW}')`); + }, + }, + { + name: 'latest unresolved reconciliation', + insert(context) { + context.store.execForTest(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-unresolved', '${context.intent.id}', 'payment', + 'unresolved', '{}', '${OPERATOR_HASH}', '${NOW}')`); + }, + }, + ...['pending', 'unresolved'].map((state) => ({ + name: `${state} refund`, + insert(context) { + context.store.execForTest(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + created_at, updated_at) + VALUES ('refund-${state}', '${context.intent.id}', + '0x${'17'.repeat(32)}', '50000', '${state}', '${NOW}', '${NOW}')`); + }, + })), +]; + +for (const blocker of CLOSE_BLOCKERS) { + test(`guarded close rejects ${blocker.name}`, (t) => { + const context = terminalCloseContext(t); + blocker.insert(context); + assertKernelError(() => context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: context.session.id, + expectedSessionHash: context.session.sessionHash, + }) + )), 'SESSION_MONETARY_AMBIGUITY'); + assert.equal(context.intents.getSession(context.session.id).state, 'open'); + }); +} + +test('guarded close ignores an approved row retained behind a terminal non-matchable intent', (t) => { + const context = terminalCloseContext(t); + context.store.execForTest(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES ('${context.intent.id}', 'unknown', NULL, NULL, + '{"reasonCode":"UPSTREAM_TRANSPORT_FAILURE"}', '${NOW}'); + INSERT INTO approvals + (id, intent_id, decision, operator_id_hash, intent_hash, challenge_hash, quote_id, + accepted_index, amount_ceiling_atomic, wallet_address, policy_version_id, + expires_at, decided_at) + VALUES ('approval-approved-history', '${context.intent.id}', 'approved', + '${OPERATOR_HASH}', '${context.intent.intentHash}', 'sha256:${'11'.repeat(32)}', + 'sha256:${'12'.repeat(32)}', 0, '50000', '${WALLET}', + '${context.activePolicy.id}', '2026-07-31T12:05:00.000Z', '${NOW}')`); + + const closed = context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: context.session.id, + expectedSessionHash: context.session.sessionHash, + }) + )); + + assert.equal(closed.closedSession.state, 'closed'); + assert.equal(context.store.readOne( + "SELECT decision FROM approvals WHERE id = 'approval-approved-history'", + ).decision, 'approved'); +}); + +test('exact close replay is event-bound and never consults a now-invalid clock', (t) => { + let clockValue = NOW; + const context = setup(t, { now: () => clockValue }); + const session = openSession(context); + const closed = context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + }) + )); + clockValue = 'not-a-timestamp'; + const replay = context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + }) + )); + assert.deepEqual(replay, closed); + assert.equal(context.store.events().filter((row) => row.event_type === 'session.closed').length, 1); +}); + +test('close replay rejects a canonical command event with unknown fields', (t) => { + const context = setup(t); + const session = openSession(context); + context.store.transaction((token) => context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + })); + const event = context.store.readOne(`SELECT sequence, data_json FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ?`, [ + 'spend_session', + session.id, + 'session.closed', + ]); + const tampered = canonicalJson({ ...JSON.parse(event.data_json), injected: true }); + context.store.execForTest(`UPDATE events SET data_json = '${tampered}' + WHERE sequence = ${event.sequence}`); + + assertKernelError(() => context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + }) + )), 'SESSION_AUTHORITY_AMBIGUOUS'); +}); + +test('policy-transition replay rejects a canonical command event with unknown fields', (t) => { + const context = setup(t); + const session = openSession(context); + const nextPolicy = structuredClone(POLICY); + nextPolicy.sellers[0].autoApproveAtomic = '50000'; + const target = createPolicyRepository(context.store).apply( + nextPolicy, + '2026-07-31T12:01:00.000Z', + ).policyVersion; + const blocked = context.intents.getSession(session.id); + context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: session.id, + targetPolicyVersionId: target.id, + expectedSessionHash: blocked.sessionHash, + }) + )); + const event = context.store.readOne(`SELECT sequence, data_json FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ?`, [ + 'spend_session', + session.id, + 'session.policy_transitioned', + ]); + const tampered = canonicalJson({ ...JSON.parse(event.data_json), injected: true }); + context.store.execForTest(`UPDATE events SET data_json = '${tampered}' + WHERE sequence = ${event.sequence}`); + + assertKernelError(() => context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: session.id, + targetPolicyVersionId: target.id, + expectedSessionHash: blocked.sessionHash, + }) + )), 'SESSION_AUTHORITY_AMBIGUOUS'); +}); + +function mutateLifecycleEvent(context, eventSpec, mutation) { + const event = context.store.readOne(`SELECT sequence, entity_type, entity_id, + event_type, data_json, previous_hash, event_hash, created_at + FROM events WHERE entity_type = ? AND entity_id = ? AND event_type = ?`, [ + eventSpec.entityType, + eventSpec.entityId, + eventSpec.eventType, + ]); + assert.ok(event, `${eventSpec.eventType} fixture event must exist`); + if (mutation === 'deleted') { + context.store.execForTest(`DELETE FROM events WHERE sequence = ${event.sequence}`); + return; + } + if (mutation === 'extra') { + const extraHash = sha256(canonicalJson({ + domain: 'wallet-kernel.test.extra-lifecycle-event', + eventType: event.event_type, + sequence: String(event.sequence), + })); + context.store.execForTest(`INSERT INTO events( + entity_type, entity_id, event_type, data_json, previous_hash, event_hash, created_at + ) VALUES ( + ${sqlText(event.entity_type)}, + ${sqlText(event.entity_id)}, + ${sqlText(event.event_type)}, + ${sqlText(event.data_json)}, + ${event.previous_hash === null ? 'NULL' : sqlText(event.previous_hash)}, + ${sqlText(extraHash)}, + ${sqlText(event.created_at)} + )`); + return; + } + const data = JSON.parse(event.data_json); + if (event.event_type === 'session.binding_closed') { + data.reasonCode = 'FORGED_CLOSE_REASON'; + } else if (event.event_type === 'session.closed') { + data.closedAt = '2026-07-31T12:00:01.000Z'; + } else { + data.replacementSessionHash = sha256('forged replacement relationship'); + } + context.store.execForTest(`UPDATE events + SET data_json = ${sqlText(canonicalJson(data))} + WHERE sequence = ${event.sequence}`); +} + +for (const eventName of ['session.binding_closed', 'session.closed']) { + for (const mutation of ['deleted', 'extra', 'tampered']) { + test(`getSession and close replay reject ${mutation} ${eventName} lifecycle evidence`, (t) => { + const context = setup(t); + const session = openSession(context); + context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + }) + )); + const bindingId = context.store.readOne( + 'SELECT id FROM agent_session_bindings WHERE session_id = ?', + [session.id], + ).id; + mutateLifecycleEvent(context, { + entityType: eventName === 'session.binding_closed' + ? 'session_binding' + : 'spend_session', + entityId: eventName === 'session.binding_closed' ? bindingId : session.id, + eventType: eventName, + }, mutation); + + assertKernelError(() => context.intents.getSession(session.id), + 'SESSION_AUTHORITY_AMBIGUOUS'); + assertKernelError(() => context.store.transaction((token) => ( + context.intents.closeBoundSessionInTransaction(token, { + sessionId: session.id, + expectedSessionHash: session.sessionHash, + }) + )), 'SESSION_AUTHORITY_AMBIGUOUS'); + }); + } +} + +for (const eventName of ['session.binding_closed', 'session.policy_transitioned']) { + for (const mutation of ['deleted', 'extra', 'tampered']) { + test(`getSession and policy replay reject ${mutation} ${eventName} lifecycle evidence`, (t) => { + const context = blockedSessionContext(t); + context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash: context.blockedSession.sessionHash, + }) + )); + const bindingId = context.store.readOne( + 'SELECT id FROM agent_session_bindings WHERE session_id = ?', + [context.session.id], + ).id; + mutateLifecycleEvent(context, { + entityType: eventName === 'session.binding_closed' + ? 'session_binding' + : 'spend_session', + entityId: eventName === 'session.binding_closed' ? bindingId : context.session.id, + eventType: eventName, + }, mutation); + + assertKernelError(() => context.intents.getSession(context.session.id), + 'SESSION_AUTHORITY_AMBIGUOUS'); + assertKernelError(() => context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash: context.blockedSession.sessionHash, + }) + )), 'SESSION_AUTHORITY_AMBIGUOUS'); + }); + } +} + +test('policy replay rejects an internally valid replacement forged outside its transition', (t) => { + const context = blockedSessionContext(t); + const transitioned = context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash: context.blockedSession.sessionHash, + }) + )); + const replacement = transitioned.replacementSession; + const binding = context.store.readOne( + 'SELECT * FROM agent_session_bindings WHERE session_id = ?', + [replacement.id], + ); + const forgedCreatedAt = '2026-07-31T12:00:01.000Z'; + const forgedHash = sha256(canonicalJson({ + session: { + id: replacement.id, + adapterId: replacement.adapterId, + walletAddress: replacement.walletAddress, + policyVersionId: replacement.policyVersionId, + state: 'open', + createdAt: forgedCreatedAt, + closedAt: null, + }, + binding: { + id: binding.id, + agentInstanceId: binding.agent_instance_id, + credentialDigest: binding.credential_digest, + enrollmentHash: binding.enrollment_hash, + sessionId: replacement.id, + state: 'open', + createdAt: forgedCreatedAt, + lastSeenAt: forgedCreatedAt, + closedAt: null, + }, + })); + const startedEvent = context.store.readOne(`SELECT sequence, data_json FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ?`, [ + 'spend_session', + replacement.id, + 'session.started', + ]); + const openedEvent = context.store.readOne(`SELECT sequence, data_json FROM events + WHERE entity_type = ? AND entity_id = ? AND event_type = ?`, [ + 'session_binding', + binding.id, + 'session.binding_opened', + ]); + const forgedStarted = { + ...JSON.parse(startedEvent.data_json), + createdAt: forgedCreatedAt, + sessionHash: forgedHash, + }; + const forgedOpened = { + ...JSON.parse(openedEvent.data_json), + createdAt: forgedCreatedAt, + }; + context.store.execForTest(` + UPDATE spend_sessions SET created_at = ${sqlText(forgedCreatedAt)} + WHERE id = ${sqlText(replacement.id)}; + UPDATE agent_session_bindings + SET created_at = ${sqlText(forgedCreatedAt)}, + last_seen_at = ${sqlText(forgedCreatedAt)} + WHERE id = ${sqlText(binding.id)}; + UPDATE events SET data_json = ${sqlText(canonicalJson(forgedStarted))} + WHERE sequence = ${startedEvent.sequence}; + UPDATE events SET data_json = ${sqlText(canonicalJson(forgedOpened))} + WHERE sequence = ${openedEvent.sequence}; + `); + + assert.equal(context.intents.getSession(replacement.id).sessionHash, forgedHash); + assertKernelError(() => context.intents.getSession(context.session.id), + 'SESSION_AUTHORITY_AMBIGUOUS'); + assertKernelError(() => context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: context.session.id, + targetPolicyVersionId: context.targetPolicy.id, + expectedSessionHash: context.blockedSession.sessionHash, + }) + )), 'SESSION_AUTHORITY_AMBIGUOUS'); +}); diff --git a/spikes/pi-wielder/tests/kernel-permit.test.mjs b/spikes/pi-wielder/tests/kernel-permit.test.mjs new file mode 100644 index 0000000..d69ab6f --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-permit.test.mjs @@ -0,0 +1,533 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +import { canonicalJson, KernelError, sha256 } from '../src/kernel/canonical.mjs'; +import { + createPermitAuthority, + deriveAuthorizationWindow, +} from '../src/kernel/authorized-permit.mjs'; + +const INTENT_HASH = sha256(canonicalJson({ fixture: 'intent-1' })); +const CHALLENGE_HASH = sha256(canonicalJson({ fixture: 'challenge-1' })); +const QUOTE_ID = sha256(canonicalJson({ challengeHash: CHALLENGE_HASH, acceptedIndex: 0 })); +const PERMIT_FIELDS = Object.freeze([ + 'intentId', + 'intentHash', + 'challengeHash', + 'quoteId', + 'acceptedIndex', + 'requestUrl', + 'resourceDescription', + 'resourceMimeType', + 'scheme', + 'network', + 'asset', + 'walletAddress', + 'payTo', + 'amountAtomic', + 'validAfter', + 'validBefore', + 'nonce', + 'policyVersionId', +]); +const BINDING = Object.freeze({ + intentId: 'intent-1', + intentHash: INTENT_HASH, + challengeHash: CHALLENGE_HASH, + quoteId: QUOTE_ID, + acceptedIndex: 0, + requestUrl: 'https://seller.example/paid/infer', + resourceDescription: 'offline fixture', + resourceMimeType: 'application/json', + scheme: 'exact', + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + walletAddress: '0x1000000000000000000000000000000000000000', + payTo: '0x2000000000000000000000000000000000000000', + amountAtomic: '50000', + validAfter: '0', + validBefore: '1785502860', + nonce: `0x${'01'.repeat(32)}`, + policyVersionId: 'policy-1', +}); + +function assertKernelError(operation, expectedCode) { + assert.throws(operation, (error) => { + assert.equal(error instanceof KernelError, true); + assert.equal(error.code, expectedCode); + return true; + }); +} + +function issueAndConsume(binding = BINDING) { + const authority = createPermitAuthority(); + return authority.verifyAndConsume(authority.issue(binding)); +} + +test('issues a minimal frozen permit and consumes its deeply frozen exact binding once', () => { + const authority = createPermitAuthority(); + const source = { ...BINDING }; + + const permit = authority.issue(source); + source.amountAtomic = '999999'; + + assert.equal(Object.isFrozen(authority), true); + assert.deepEqual(Object.keys(authority), ['issue', 'verifyAndConsume']); + assert.equal(Object.isFrozen(permit), true); + assert.deepEqual(Object.keys(permit), ['kind', 'intentId']); + assert.deepEqual(permit, { kind: 'AuthorizedPermit', intentId: 'intent-1' }); + assert.equal(JSON.stringify(permit), '{"kind":"AuthorizedPermit","intentId":"intent-1"}'); + + const { verifyAndConsume } = authority; + const verified = verifyAndConsume(permit); + assert.notEqual(verified, source); + assert.deepEqual(Object.keys(verified), PERMIT_FIELDS); + assert.deepEqual(verified, BINDING); + assert.equal(Object.isFrozen(verified), true); + assert.throws(() => { + verified.amountAtomic = '1'; + }, TypeError); + assert.throws( + () => verifyAndConsume(permit), + /AuthorizedPermit already consumed/, + ); +}); + +test('rejects copied, serialized, cloned, plain, cross-authority, and proxy permit forgeries', () => { + const authority = createPermitAuthority(); + const permit = authority.issue(BINDING); + const otherAuthorityPermit = createPermitAuthority().issue(BINDING); + const copies = [ + Object.freeze({ ...permit }), + JSON.parse(JSON.stringify(permit)), + structuredClone(permit), + { kind: 'AuthorizedPermit', intentId: 'intent-1' }, + otherAuthorityPermit, + null, + 'AuthorizedPermit', + ]; + + for (const copy of copies) { + assert.throws(() => authority.verifyAndConsume(copy), /AuthorizedPermit is forged/); + } + + let trapCalls = 0; + const proxy = new Proxy({ kind: 'AuthorizedPermit', intentId: 'intent-1' }, { + get() { + trapCalls += 1; + return Reflect.get(...arguments); + }, + getPrototypeOf() { + trapCalls += 1; + return Reflect.getPrototypeOf(...arguments); + }, + }); + assert.throws(() => authority.verifyAndConsume(proxy), /AuthorizedPermit is forged/); + assert.equal(trapCalls, 0); + + assert.deepEqual(authority.verifyAndConsume(permit), BINDING); +}); + +test('WeakMap and WeakSet prototype poisoning cannot forge or revive a permit', () => { + const targets = [ + [WeakMap.prototype, 'set'], + [WeakMap.prototype, 'get'], + [WeakMap.prototype, 'delete'], + [WeakSet.prototype, 'has'], + [WeakSet.prototype, 'add'], + ]; + const originals = targets.map(([prototype, name]) => Object.freeze({ + descriptor: Object.getOwnPropertyDescriptor(prototype, name), + name, + prototype, + })); + let verified; + let replayError; + let forgedError; + try { + Object.defineProperty(WeakMap.prototype, 'set', { + ...Object.getOwnPropertyDescriptor(WeakMap.prototype, 'set'), + value() { return this; }, + }); + Object.defineProperty(WeakMap.prototype, 'get', { + ...Object.getOwnPropertyDescriptor(WeakMap.prototype, 'get'), + value() { return BINDING; }, + }); + Object.defineProperty(WeakMap.prototype, 'delete', { + ...Object.getOwnPropertyDescriptor(WeakMap.prototype, 'delete'), + value() { return true; }, + }); + Object.defineProperty(WeakSet.prototype, 'has', { + ...Object.getOwnPropertyDescriptor(WeakSet.prototype, 'has'), + value() { return false; }, + }); + Object.defineProperty(WeakSet.prototype, 'add', { + ...Object.getOwnPropertyDescriptor(WeakSet.prototype, 'add'), + value() { return this; }, + }); + + const authority = createPermitAuthority(); + const permit = authority.issue(BINDING); + verified = authority.verifyAndConsume(permit); + try { + authority.verifyAndConsume(permit); + } catch (error) { + replayError = error; + } + try { + authority.verifyAndConsume(Object.freeze({ ...permit })); + } catch (error) { + forgedError = error; + } + } finally { + for (const { descriptor, name, prototype } of originals) { + Object.defineProperty(prototype, name, descriptor); + } + } + + assert.deepEqual(verified, BINDING); + assert.match(replayError?.message ?? '', /AuthorizedPermit already consumed/); + assert.match(forgedError?.message ?? '', /AuthorizedPermit is forged/); + for (const { descriptor, name, prototype } of originals) { + assert.deepEqual(Object.getOwnPropertyDescriptor(prototype, name), descriptor); + } +}); + +test('a permit serialized by a fresh process is forged in this process', () => { + const moduleUrl = new URL('../src/kernel/authorized-permit.mjs', import.meta.url).href; + const child = spawnSync(process.execPath, [ + '--input-type=module', + '--eval', + `import { createPermitAuthority } from ${JSON.stringify(moduleUrl)}; + const authority = createPermitAuthority(); + process.stdout.write(JSON.stringify(authority.issue(${JSON.stringify(BINDING)})));`, + ], { encoding: 'utf8' }); + + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stderr, ''); + const foreignPermit = JSON.parse(child.stdout); + const authority = createPermitAuthority(); + assert.throws( + () => authority.verifyAndConsume(foreignPermit), + /AuthorizedPermit is forged/, + ); +}); + +test('the authority copies a reordered binding into one canonical closed surface', () => { + const reversed = Object.fromEntries(Object.entries(BINDING).reverse()); + const verified = issueAndConsume(reversed); + + assert.deepEqual(Object.keys(verified), PERMIT_FIELDS); + assert.deepEqual(verified, BINDING); + assert.equal(Object.isFrozen(verified), true); +}); + +test('permit identity binds resource metadata and policy identity against substitution', () => { + const authority = createPermitAuthority(); + const source = { ...BINDING }; + const permit = authority.issue(source); + const alternate = { + ...BINDING, + resourceDescription: 'substituted fixture', + resourceMimeType: 'application/cbor', + policyVersionId: 'policy-2', + }; + const alternatePermit = authority.issue(alternate); + + source.resourceDescription = alternate.resourceDescription; + source.resourceMimeType = alternate.resourceMimeType; + source.policyVersionId = alternate.policyVersionId; + + assert.throws(() => authority.verifyAndConsume(Object.freeze({ + ...permit, + resourceDescription: alternate.resourceDescription, + })), /AuthorizedPermit is forged/); + assert.deepEqual(authority.verifyAndConsume(permit), BINDING); + assert.deepEqual(authority.verifyAndConsume(alternatePermit), alternate); +}); + +test('rejects non-plain, accessor, missing, extra, and noncanonical permit bindings', () => { + let getterCalls = 0; + const accessor = { ...BINDING }; + Object.defineProperty(accessor, 'intentId', { + enumerable: true, + get() { + getterCalls += 1; + return 'intent-1'; + }, + }); + const inherited = Object.assign(Object.create({ secret: 'value' }), BINDING); + const withSymbol = { ...BINDING, [Symbol('secret')]: 'value' }; + const nonenumerable = Object.defineProperty({ ...BINDING }, 'secret', { + enumerable: false, + value: 'value', + }); + const missing = { ...BINDING }; + delete missing.quoteId; + const missingResourceDescription = { ...BINDING }; + delete missingResourceDescription.resourceDescription; + const missingResourceMimeType = { ...BINDING }; + delete missingResourceMimeType.resourceMimeType; + const missingPolicyVersionId = { ...BINDING }; + delete missingPolicyVersionId.policyVersionId; + + for (const binding of [ + null, + [], + accessor, + inherited, + withSymbol, + nonenumerable, + missing, + missingResourceDescription, + missingResourceMimeType, + missingPolicyVersionId, + { ...BINDING, extra: true }, + ]) { + assertKernelError(() => createPermitAuthority().issue(binding), 'PERMIT_BINDING'); + } + assert.equal(getterCalls, 0); + + const invalidFields = [ + ['intentId', ''], + ['intentId', '-intent'], + ['intentHash', `sha256:${'AB'.repeat(32)}`], + ['challengeHash', `sha256:${'0g'.repeat(32)}`], + ['quoteId', 'quote-1'], + ['acceptedIndex', -1], + ['acceptedIndex', 0.5], + ['acceptedIndex', '0'], + ['requestUrl', 'http://seller.example/paid/infer'], + ['requestUrl', 'https://user@seller.example/paid/infer'], + ['requestUrl', 'https://seller.example/paid/../infer'], + ['requestUrl', 'https://seller.example/paid/infer#fragment'], + ['resourceDescription', ''], + ['resourceDescription', 'control\ntext'], + ['resourceDescription', 'x'.repeat(1_025)], + ['resourceMimeType', 'application'], + ['resourceMimeType', 'application/json; charset=utf-8'], + ['resourceMimeType', 'x'.repeat(201)], + ['scheme', 'EXACT'], + ['network', 'base-sepolia'], + ['asset', `0x${'AB'.repeat(20)}`], + ['walletAddress', `0x${'01'.repeat(19)}`], + ['payTo', `0x${'gg'.repeat(20)}`], + ['amountAtomic', '0'], + ['amountAtomic', '050000'], + ['validAfter', '1'], + ['validBefore', '01785502860'], + ['validBefore', '0'], + ['nonce', `0x${'AB'.repeat(32)}`], + ['nonce', `0x${'01'.repeat(31)}`], + ['policyVersionId', ''], + ['policyVersionId', '-policy'], + ]; + for (const [field, value] of invalidFields) { + assertKernelError( + () => createPermitAuthority().issue({ ...BINDING, [field]: value }), + 'PERMIT_BINDING', + ); + } +}); + +test('issue and consume emit no logs and expose no persistence or transport hooks', () => { + const originalLog = console.log; + const originalError = console.error; + const calls = []; + console.log = (...parts) => calls.push(['log', ...parts]); + console.error = (...parts) => calls.push(['error', ...parts]); + try { + const authority = createPermitAuthority(); + const permit = authority.issue(BINDING); + assert.deepEqual(authority.verifyAndConsume(permit), BINDING); + assert.deepEqual(Object.keys(authority), ['issue', 'verifyAndConsume']); + assert.deepEqual(Object.keys(permit), ['kind', 'intentId']); + } finally { + console.log = originalLog; + console.error = originalError; + } + assert.deepEqual(calls, []); +}); + +test('derives the protocol, challenge, or approval deadline as the exact minimum', () => { + const cases = [ + { + label: 'protocol', + input: { + nowMs: 100_000, + challengeReceivedAtMs: 90_000, + challengeMaxAgeMs: 120_000, + approvalExpiresAt: '1970-01-01T00:03:50.000Z', + maxTimeoutSeconds: 60, + }, + validBefore: '160', + }, + { + label: 'challenge', + input: { + nowMs: 100_900, + challengeReceivedAtMs: 90_100, + challengeMaxAgeMs: 30_000, + approvalExpiresAt: '1970-01-01T00:02:10.000Z', + maxTimeoutSeconds: 60, + }, + validBefore: '120', + }, + { + label: 'approval', + input: { + nowMs: 100_000, + challengeReceivedAtMs: 90_000, + challengeMaxAgeMs: 120_000, + approvalExpiresAt: '1970-01-01T00:01:55.999Z', + maxTimeoutSeconds: 60, + }, + validBefore: '115', + }, + ]; + + for (const fixture of cases) { + let calls = 0; + const random = Buffer.from('01'.repeat(32), 'hex'); + const result = deriveAuthorizationWindow({ + ...fixture.input, + randomBytes: (size) => { + calls += 1; + assert.equal(size, 32, fixture.label); + return random; + }, + }); + random.fill(0xff); + + assert.deepEqual(result, { + nonce: `0x${'01'.repeat(32)}`, + validAfter: '0', + validBefore: fixture.validBefore, + }, fixture.label); + assert.equal(Object.isFrozen(result), true, fixture.label); + assert.equal(calls, 1, fixture.label); + } +}); + +test('authorization seconds truncate subsecond values and the no-approval fixture stays plus 60', () => { + let calls = 0; + const result = deriveAuthorizationWindow({ + nowMs: 1_785_502_800_999, + challengeReceivedAtMs: 1_785_502_800_999, + challengeMaxAgeMs: 60_000, + approvalExpiresAt: null, + maxTimeoutSeconds: 60, + randomBytes(size) { + calls += 1; + assert.equal(size, 32); + return new Uint8Array(32).fill(2); + }, + }); + + assert.deepEqual(result, { + nonce: `0x${'02'.repeat(32)}`, + validAfter: '0', + validBefore: '1785502860', + }); + assert.equal(calls, 1); +}); + +test('rejects exhausted windows before requesting randomness', () => { + for (const input of [ + { + nowMs: 120_000, + challengeReceivedAtMs: 90_000, + challengeMaxAgeMs: 30_999, + approvalExpiresAt: null, + maxTimeoutSeconds: 60, + }, + { + nowMs: 120_999, + challengeReceivedAtMs: 120_000, + challengeMaxAgeMs: 60_000, + approvalExpiresAt: '1970-01-01T00:02:00.999Z', + maxTimeoutSeconds: 60, + }, + ]) { + let calls = 0; + assertKernelError(() => deriveAuthorizationWindow({ + ...input, + randomBytes() { + calls += 1; + return Buffer.alloc(32); + }, + }), 'AUTHORIZATION_WINDOW'); + assert.equal(calls, 0); + } +}); + +test('rejects wrong-length or non-byte randomness after exactly one call', () => { + const randomValues = [Buffer.alloc(31), Buffer.alloc(33), new Uint8Array(31), '00'.repeat(32)]; + for (const randomValue of randomValues) { + let calls = 0; + assertKernelError(() => deriveAuthorizationWindow({ + nowMs: 100_000, + challengeReceivedAtMs: 100_000, + challengeMaxAgeMs: 60_000, + approvalExpiresAt: null, + maxTimeoutSeconds: 60, + randomBytes(size) { + calls += 1; + assert.equal(size, 32); + return randomValue; + }, + }), 'AUTHORIZATION_RANDOMNESS'); + assert.equal(calls, 1); + } +}); + +test('authorization-window input is closed, inert, and canonical', () => { + const valid = { + nowMs: 100_000, + challengeReceivedAtMs: 100_000, + challengeMaxAgeMs: 60_000, + approvalExpiresAt: null, + maxTimeoutSeconds: 60, + randomBytes: () => Buffer.alloc(32, 3), + }; + let getterCalls = 0; + const accessor = { ...valid }; + Object.defineProperty(accessor, 'nowMs', { + enumerable: true, + get() { + getterCalls += 1; + return 100_000; + }, + }); + const missing = { ...valid }; + delete missing.randomBytes; + + for (const input of [null, [], accessor, missing, { ...valid, extra: true }]) { + assertKernelError(() => deriveAuthorizationWindow(input), 'AUTHORIZATION_WINDOW'); + } + assert.equal(getterCalls, 0); + + const invalidFields = [ + ['nowMs', -1], + ['nowMs', 1.5], + ['nowMs', Number.MAX_SAFE_INTEGER + 1], + ['challengeReceivedAtMs', -1], + ['challengeReceivedAtMs', 100_001], + ['challengeMaxAgeMs', 0], + ['challengeMaxAgeMs', 1.5], + ['approvalExpiresAt', '1970-01-01T00:02:40Z'], + ['approvalExpiresAt', 'not-a-time'], + ['maxTimeoutSeconds', 0], + ['maxTimeoutSeconds', 3601], + ['maxTimeoutSeconds', 1.5], + ['maxTimeoutSeconds', '60'], + ['randomBytes', null], + ]; + for (const [field, value] of invalidFields) { + assertKernelError( + () => deriveAuthorizationWindow({ ...valid, [field]: value }), + 'AUTHORIZATION_WINDOW', + ); + } +}); diff --git a/spikes/pi-wielder/tests/kernel-receipts.test.mjs b/spikes/pi-wielder/tests/kernel-receipts.test.mjs new file mode 100644 index 0000000..1ee6c10 --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-receipts.test.mjs @@ -0,0 +1,2748 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { sha256 } from '../src/kernel/canonical.mjs'; +import { + canonicalJson, + createReceiptSigner, + loadOrCreateReceiptSigner, + verifySignedReceipt, +} from '../src/kernel/receipt-signing.mjs'; +import { createSignedReceiptRepository } from '../src/kernel/signed-receipts.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const NOW = '2026-07-31T12:00:01.000Z'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const SELLER = 'https://seller.example'; + +function authority(t, prefix = 'wallet-kernel-receipts-') { + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + fs.chmodSync(directory, 0o700); + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); + t.after(() => fs.rmSync(directory, { force: true, recursive: true })); + return { + databasePath: path.join(directory, 'kernel.sqlite'), + directory, + keyPath: path.join(directory, 'receipt-key.pem'), + pathTrust, + }; +} + +function receiptFixture() { + return { + schemaVersion: 1, + receiptId: 'receipt-1', + revision: 1, + issuedAt: NOW, + intent: { + id: 'intent-1', + requestId: 'request-1', + intentHash: sha256(canonicalJson({ fixture: 'intent-1' })), + sessionId: 'session-1', + sellerOrigin: SELLER, + resourcePath: '/paid/infer', + purposeLabel: 'skill.invoke', + }, + outcome: { status: 'completed', reasonCode: 'PAYMENT_SETTLED' }, + policy: { versionId: 'policy-1', decision: 'allow', reasonCode: 'WITHIN_AUTO_LIMIT' }, + approval: { state: 'not_required', operatorIdHash: null }, + payment: { + state: 'settled', + amountAtomic: '50000', + network: NETWORK, + asset: ASSET, + payTo: PAY_TO, + transactionId: `0x${'ab'.repeat(32)}`, + }, + execution: { + state: 'succeeded', + httpStatus: 200, + responseHash: sha256(Buffer.from('{"ok":true}', 'utf8')), + }, + budget: { disposition: 'committed', amountAtomic: '50000' }, + reconciliation: null, + refund: null, + supersedesReceiptHash: null, + }; +} + +function challengeProjection(amountAtomic = '50000') { + return { + x402Version: 2, + resource: { + urlHash: sha256(`${SELLER}/paid/infer`), + description: 'not included in a buyer receipt', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: NETWORK, + asset: ASSET, + amount: amountAtomic, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], + }; +} + +function seedBaseAuthority(store) { + store.transaction((token) => store.within(token, ({ db }) => { + db.prepare(`INSERT INTO policy_versions + (id, schema_version, canonical_json, policy_hash, predecessor_hash, applied_at) + VALUES ('policy-1', 1, '{}', ?, NULL, ?)`).run(sha256('policy-1'), NOW); + db.prepare(`INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, + state, enrolled_by_operator_hash, enrolled_at) + VALUES ('agent-1', ?, ?, '501', '20', 'active', ?, ?)`).run( + sha256('credential-1'), sha256('enrollment-1'), sha256('operator-1'), NOW, + ); + db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at, closed_at) + VALUES ('session-1', 'pi:agent-1', ?, 'policy-1', 'open', ?, NULL)`).run(WALLET, NOW); + })); +} + +function setupRepository(t, { + clock = { value: NOW }, + fileAuthority = null, + signer = createReceiptSigner(), + seedAuthority = true, +} = {}) { + const store = openKernelStore(fileAuthority ? { + filePath: fileAuthority.databasePath, + pathTrust: fileAuthority.pathTrust, + now: () => clock.value, + } : { + filePath: ':memory:', + allowMemory: true, + now: () => clock.value, + }); + t.after(() => store.close()); + let receiptId = 0; + const receipts = createSignedReceiptRepository({ + store, + signer, + idFactory: () => `receipt-${++receiptId}`, + now: () => clock.value, + }); + if (seedAuthority) seedBaseAuthority(store); + return { clock, receipts, signer, store }; +} + +function seedIntent(store, { + id = 'intent-1', + requestId = 'request-1', + state = 'terminal', + challenge = null, +} = {}) { + const intentHash = sha256(canonicalJson({ fixture: id })); + const challengeJson = challenge === null ? null : canonicalJson(challenge); + const challengeHash = challenge === null ? null : sha256(challengeJson); + store.transaction((token) => store.within(token, ({ db }) => db.prepare(`INSERT INTO spend_intents + (id, request_id, session_id, enrollment_hash, route_id, method, request_url_hash, + seller_origin, resource_path, body_hash, header_allowlist_hash, ordinary_fingerprint, + purpose_label, correlation_id, idempotency_key, wallet_address, intent_hash, + challenge_projection_json, challenge_hash, challenge_received_at, state, created_at, updated_at) + VALUES (?, ?, 'session-1', ?, 'paid-infer', 'POST', ?, ?, '/paid/infer', ?, ?, ?, + 'skill.invoke', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + id, + requestId, + sha256('enrollment-1'), + sha256(`${id}:url`), + SELLER, + sha256(`${id}:body`), + sha256(`${id}:headers`), + sha256(`${id}:ordinary`), + `correlation-${id}`, + `idempotency-${id}`, + WALLET, + intentHash, + challengeJson, + challengeHash, + challenge === null ? null : NOW, + state, + NOW, + NOW, + ))); + return { challengeHash, id, intentHash, requestId }; +} + +function seedSettledSuccess(context) { + const challenge = challengeProjection(); + const intent = seedIntent(context.store, { challenge }); + const responseHash = sha256(Buffer.from('{"ok":true}', 'utf8')); + const paymentHeader = 'settled-fixture-payment-header'; + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO policy_decisions + (intent_id, policy_version_id, decision, reason_code, challenge_hash, + accepted_index, quote_id, amount_ceiling_atomic, decided_at) + VALUES (?, 'policy-1', 'allow', 'WITHIN_AUTO_LIMIT', ?, 0, 'quote-1', '50000', ?)`) + .run(intent.id, intent.challengeHash, NOW); + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + payment_payload_json, payment_header, payment_hash, quote_id, nonce, + valid_after, valid_before, signing_claimed_at, signed_at, retry_started_at, + settlement_json, transaction_id, settled_at, created_at, updated_at) + VALUES ('payment-1', ?, 'settled', ?, 0, ?, ?, ?, 'quote-1', ?, ?, ?, ?, ?, ?, + '{}', ?, ?, ?, ?)`) + .run( + intent.id, + canonicalJson(challenge), + canonicalJson({ paymentSignature: 'settled-fixture-signature' }), + paymentHeader, + sha256(paymentHeader), + `0x${'11'.repeat(32)}`, + '1785502800', + '1785502860', + NOW, + NOW, + NOW, + `0x${'ab'.repeat(32)}`, + NOW, + NOW, + NOW, + ); + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'succeeded', 200, ?, '{}', ?)`).run(intent.id, responseHash, NOW); + db.prepare(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, committed_at, updated_at) + VALUES (?, 'session-1', ?, '0', '50000', '0', '0', 'committed', ?, ?)`) + .run(intent.id, SELLER, NOW, NOW); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'completed', 'PAYMENT_SETTLED', 1, ?)`).run(intent.id, NOW); + })); + return { ...intent, responseHash }; +} + +function seedScenario(context, scenario) { + const needsChallenge = Boolean( + scenario.policyDecision || scenario.paymentState || scenario.approvalState, + ); + const challenge = needsChallenge ? challengeProjection() : null; + const intent = seedIntent(context.store, { + id: scenario.intentId ?? 'intent-1', + requestId: scenario.requestId ?? `request-${scenario.intentId ?? '1'}`, + challenge, + state: scenario.intentState ?? 'terminal', + }); + const operatorIdHash = sha256('authenticated-operator'); + context.store.transaction((token) => context.store.within(token, ({ db, appendEvent }) => { + if (scenario.policyDecision) { + db.prepare(`INSERT INTO policy_decisions + (intent_id, policy_version_id, decision, reason_code, challenge_hash, + accepted_index, quote_id, amount_ceiling_atomic, decided_at) + VALUES (?, 'policy-1', ?, ?, ?, 0, 'quote-1', '50000', ?)`) + .run( + intent.id, + scenario.policyDecision, + scenario.policyReason ?? 'WITHIN_AUTO_LIMIT', + intent.challengeHash, + NOW, + ); + } + if (scenario.approvalState) { + db.prepare(`INSERT INTO approvals + (id, intent_id, decision, operator_id_hash, intent_hash, challenge_hash, + quote_id, accepted_index, amount_ceiling_atomic, wallet_address, + policy_version_id, expires_at, reason_code, decided_at, consumed_at) + VALUES ('approval-1', ?, ?, ?, ?, ?, 'quote-1', 0, '50000', ?, + 'policy-1', '2026-07-31T12:05:00.000Z', ?, ?, NULL)`) + .run( + intent.id, + scenario.approvalState, + scenario.operatorHash === false ? null : operatorIdHash, + intent.intentHash, + intent.challengeHash, + WALLET, + scenario.approvalReason ?? scenario.reasonCode, + ['pending'].includes(scenario.approvalState) ? null : NOW, + ); + } + if (scenario.paymentState) { + const transactionId = scenario.paymentState === 'settled' + ? `0x${'ab'.repeat(32)}` + : null; + const claimOnly = scenario.claimOnly === true; + const hasSignedPayload = scenario.unsignedAttempt !== true + && scenario.paymentState !== 'reserved' + && scenario.paymentState !== 'signing'; + const hasSigningClaim = claimOnly || hasSignedPayload; + const hasRetry = ['retrying', 'settled'].includes(scenario.paymentState); + const settlementJson = scenario.paymentState === 'settled' ? '{}' : null; + const paymentReasonCode = scenario.paymentReasonCode + ?? (['unresolved', 'rejected'].includes(scenario.paymentState) + ? scenario.reasonCode + : null); + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + payment_payload_json, payment_header, payment_hash, quote_id, transaction_id, + reason_code, nonce, valid_after, valid_before, signing_claimed_at, signed_at, + retry_started_at, settlement_json, settled_at, created_at, updated_at) + VALUES ('payment-1', ?, ?, ?, 0, ?, ?, ?, 'quote-1', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + intent.id, + scenario.paymentState, + canonicalJson(challenge), + hasSignedPayload ? canonicalJson({ + paymentSignature: 'RAW_PAYMENT_SIGNATURE_SENTINEL', + cdpCredential: 'RAW_CDP_CREDENTIAL_SENTINEL', + }) : null, + hasSignedPayload ? 'RAW_PAYMENT_HEADER_SENTINEL' : null, + hasSignedPayload ? sha256('RAW_PAYMENT_HEADER_SENTINEL') : null, + transactionId, + paymentReasonCode, + hasSigningClaim ? `0x${'11'.repeat(32)}` : null, + hasSigningClaim ? '1785502800' : null, + hasSigningClaim ? '1785502860' : null, + hasSigningClaim ? NOW : null, + hasSignedPayload ? NOW : null, + hasRetry ? NOW : null, + settlementJson, + transactionId === null ? null : NOW, + NOW, + NOW, + ); + } + if (scenario.executionState) { + const httpStatus = scenario.executionState === 'succeeded' + ? 200 + : scenario.executionState === 'failed' + ? (Object.hasOwn(scenario, 'httpStatus') ? scenario.httpStatus : 503) + : (Object.hasOwn(scenario, 'httpStatus') ? scenario.httpStatus : null); + const responseHash = Object.hasOwn(scenario, 'executionResponseHash') + ? scenario.executionResponseHash + : scenario.executionState === 'unknown' + ? null + : sha256(Buffer.from(`response:${scenario.name}`, 'utf8')); + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, ?, ?, ?, ?, ?)`) + .run( + intent.id, + scenario.executionState, + httpStatus, + responseHash, + canonicalJson({ providerException: 'RAW_PROVIDER_EXCEPTION_SENTINEL' }), + NOW, + ); + } + if (scenario.budgetState) { + const values = { + reserved: ['50000', '0', '0', '0'], + committed: ['0', '50000', '0', '0'], + released: ['0', '0', '50000', '0'], + unresolved: ['0', '0', '0', '50000'], + }[scenario.budgetState]; + db.prepare(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, committed_at, updated_at) + VALUES (?, 'session-1', ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + intent.id, + SELLER, + ...values, + scenario.budgetState, + scenario.budgetState === 'committed' ? NOW : null, + NOW, + ); + } + if (scenario.refundState) { + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, evidence_json, + refund_transaction_id, created_at, updated_at) + VALUES ('refund-1', ?, ?, '50000', ?, ?, ?, ?, ?)`) + .run( + intent.id, + `0x${'ab'.repeat(32)}`, + scenario.refundState, + canonicalJson({ providerException: 'RAW_REFUND_EVIDENCE_SENTINEL' }), + scenario.refundState === 'confirmed' ? `0x${'cd'.repeat(32)}` : null, + NOW, + NOW, + ); + } + if (scenario.reconciliationKind) { + db.prepare(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-1', ?, ?, ?, ?, ?, ?)`) + .run( + intent.id, + scenario.reconciliationKind, + scenario.reconciliationOutcome, + canonicalJson({ providerException: 'RAW_RECONCILIATION_EVIDENCE_SENTINEL' }), + operatorIdHash, + NOW, + ); + } + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, ?, ?, 1, ?)`).run( + intent.id, + scenario.status, + scenario.reasonCode, + NOW, + ); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.fixture_recorded', + data: { + body: 'RAW_BODY_SENTINEL', + prompt: 'RAW_PROMPT_SENTINEL', + operatorToken: 'RAW_OPERATOR_TOKEN_SENTINEL', + stack: 'RAW_STACK_SENTINEL', + sellerText: 'RAW_SELLER_ERROR_SENTINEL', + paymentHeader: 'RAW_PAYMENT_HEADER_SENTINEL', + }, + }); + })); + return { intent, operatorIdHash }; +} + +function signReceipt(signer, receipt) { + const receiptHash = crypto.createHash('sha256').update(canonicalJson(receipt)).digest('hex'); + return { + receipt, + receiptHash, + signature: signer.signHash(receiptHash), + algorithm: signer.algorithm, + keyId: signer.keyId, + }; +} + +function signedRecordForReceipt(signer, receipt) { + return { + id: receipt.receiptId, + intentId: receipt.intent.id, + revision: receipt.revision, + ...signReceipt(signer, receipt), + supersedesReceiptHash: receipt.supersedesReceiptHash, + createdAt: receipt.issuedAt, + }; +} + +function waitForExit(child) { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(stdout.trim()); + else reject(new Error(`receipt-key worker exited ${code ?? signal}: ${stderr}`)); + }); + }); +} + +test('generic Ed25519 receipts verify with public trust and reject every mutation', () => { + const signer = createReceiptSigner(); + const receipt = receiptFixture(); + const bundle = signReceipt(signer, receipt); + const trust = { publicKeyPem: signer.publicKeyPem, keyId: signer.keyId }; + assert.equal(verifySignedReceipt(bundle, trust), true); + + for (const mutate of [ + (copy) => { copy.receipt.outcome.reasonCode = 'MUTATED'; }, + (copy) => { copy.signature = `${copy.signature.slice(0, -2)}AA`; }, + (copy) => { copy.receipt.unexpected = true; }, + (copy) => { copy.receipt.payment.amountAtomic = '050000'; }, + ]) { + const copy = structuredClone(bundle); + mutate(copy); + assert.equal(verifySignedReceipt(copy, trust), false); + } + + const wrong = createReceiptSigner(); + assert.equal(verifySignedReceipt(bundle, { + publicKeyPem: wrong.publicKeyPem, + keyId: wrong.keyId, + }), false); +}); + +test('persistent receipt key initialization is atomic, stable, and fail-closed', async (t) => { + const fixture = authority(t); + const first = loadOrCreateReceiptSigner(fixture.keyPath, { pathTrust: fixture.pathTrust }); + const reopened = loadOrCreateReceiptSigner(fixture.keyPath, { pathTrust: fixture.pathTrust }); + assert.equal(first.keyId, reopened.keyId); + assert.equal(first.persistent, true); + assert.equal(fs.statSync(fixture.keyPath).mode & 0o777, 0o600); + + const rsaPrivateKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) + .privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + for (const invalid of [ + '', + 'not a key\n', + `${fs.readFileSync(fixture.keyPath, 'utf8')}not-whitespace`, + rsaPrivateKey, + ]) { + const invalidFixture = authority(t, 'wallet-kernel-invalid-receipt-key-'); + fs.writeFileSync(invalidFixture.keyPath, invalid, { mode: 0o600 }); + assert.throws( + () => loadOrCreateReceiptSigner(invalidFixture.keyPath, { + pathTrust: invalidFixture.pathTrust, + }), + /empty|invalid|trailing|Ed25519|private key/i, + ); + assert.equal(fs.readFileSync(invalidFixture.keyPath, 'utf8'), invalid); + } + + const raced = authority(t, 'wallet-kernel-receipt-key-race-'); + const moduleUrl = new URL('../src/kernel/receipt-signing.mjs', import.meta.url).href; + const worker = [ + `import { loadOrCreateReceiptSigner } from ${JSON.stringify(moduleUrl)};`, + `const pathTrust = Object.freeze(${JSON.stringify(raced.pathTrust)});`, + `process.stdout.write(loadOrCreateReceiptSigner(${JSON.stringify(raced.keyPath)}, { pathTrust }).keyId);`, + ].join('\n'); + const children = [0, 1].map(() => spawn(process.execPath, [ + '--input-type=module', '-e', worker, + ], { stdio: ['ignore', 'pipe', 'pipe'] })); + const keyIds = await Promise.all(children.map(waitForExit)); + assert.equal(keyIds[0], keyIds[1]); + assert.equal(loadOrCreateReceiptSigner(raced.keyPath, { + pathTrust: raced.pathTrust, + }).keyId, keyIds[0]); + assert.deepEqual( + fs.readdirSync(raced.directory).filter((name) => name.includes('.tmp-')), + [], + ); +}); + +test('repository signs the exact closed terminal projection and exposes verified immutable records', (t) => { + const context = setupRepository(t); + seedSettledSuccess(context); + + assert.equal(Object.isFrozen(context.receipts), true); + assert.deepEqual(Object.keys(context.receipts), [ + 'issueForTerminal', + 'issueRevisionForTerminal', + 'issueMissingTerminalReceipts', + 'assertParity', + 'assertParityInTransaction', + 'assertRecoverableParityInTransaction', + 'latest', + 'list', + 'verify', + ]); + + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.deepEqual(record.receipt, receiptFixture()); + assert.equal(record.id, 'receipt-1'); + assert.equal(record.intentId, 'intent-1'); + assert.equal(record.revision, 1); + assert.equal(context.receipts.verify(record), true); + assert.equal(Object.isFrozen(record), true); + assert.equal(Object.isFrozen(record.receipt), true); + assert.deepEqual(context.receipts.latest('intent-1'), record); + assert.deepEqual(context.receipts.list({ sessionId: 'session-1', limit: 10 }), [record]); + assert.deepEqual(context.receipts.issueForTerminal({ intentId: 'intent-1' }), record); + assert.equal(context.store.events().filter( + (event) => event.event_type === 'receipt.issued', + ).length, 1); + + for (const mutate of [ + (copy) => { copy.receipt.outcome.status = 'refunded'; }, + (copy) => { copy.receipt.payment.amountAtomic = '050000'; }, + (copy) => { copy.receipt.secret = 'raw-provider-output'; }, + (copy) => { copy.signature = `${copy.signature.slice(0, -2)}AA`; }, + ]) { + const changed = structuredClone(record); + mutate(changed); + assert.equal(context.receipts.verify(changed), false); + } + + const maliciousReceipt = { ...structuredClone(record.receipt), secret: 'signed-but-forbidden' }; + const malicious = { + ...record, + ...signReceipt(context.signer, maliciousReceipt), + receipt: maliciousReceipt, + }; + assert.equal(context.receipts.verify(malicious), false); +}); + +test('every closed terminal buyer outcome receives one redacted authoritative receipt', async (t) => { + const scenarios = [ + { + name: 'ordinary non-402 success', status: 'completed', reasonCode: 'ORDINARY_SUCCESS', + executionState: 'succeeded', expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'ordinary non-402 HTTP failure', status: 'upstream_failed', + reasonCode: 'ORDINARY_HTTP_FAILURE', executionState: 'failed', httpStatus: 503, + expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'unpaid transport timeout', status: 'upstream_failed', + reasonCode: 'UPSTREAM_TRANSPORT_FAILURE', executionState: 'unknown', + expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'malformed payment challenge', status: 'payment_denied', + reasonCode: 'PAYMENT_CHALLENGE_MALFORMED', expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'oversized payment challenge', status: 'payment_denied', + reasonCode: 'PAYMENT_CHALLENGE_OVERSIZED', expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'expired payment challenge', status: 'payment_denied', + reasonCode: 'PAYMENT_CHALLENGE_EXPIRED', expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'policy denied', status: 'payment_denied', reasonCode: 'POLICY_DENIED', + policyDecision: 'deny', policyReason: 'POLICY_DENIED', expectedPayment: 'none', + expectedBudget: null, + }, + { + name: 'approval denied', status: 'payment_denied', reasonCode: 'OPERATOR_DENIED', + policyDecision: 'approval_required', policyReason: 'HUMAN_APPROVAL_REQUIRED', + approvalState: 'denied', expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'approval expired', status: 'payment_denied', reasonCode: 'APPROVAL_EXPIRED', + policyDecision: 'approval_required', policyReason: 'HUMAN_APPROVAL_REQUIRED', + approvalState: 'expired', operatorHash: false, expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'approval challenge changed', status: 'payment_denied', + reasonCode: 'APPROVAL_CHALLENGE_CHANGED', policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', approvalState: 'cancelled', + expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'policy transition cancels unsigned work', status: 'payment_denied', + reasonCode: 'POLICY_SUPERSEDED', policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', approvalState: 'cancelled', + expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'guarded session close cancels unsigned work', status: 'payment_denied', + reasonCode: 'SESSION_CLOSED', policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', approvalState: 'cancelled', + expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'unsigned signing failure', status: 'payment_failed', reasonCode: 'SIGNER_REJECTED', + policyDecision: 'allow', paymentState: 'rejected', unsignedAttempt: true, + budgetState: 'released', + expectedPayment: 'not_signed', expectedBudget: 'released', + }, + { + name: 'typed pre-signer rejection', status: 'payment_failed', + reasonCode: 'WALLET_PRE_SIGN_REJECTED', policyDecision: 'allow', + paymentState: 'rejected', unsignedAttempt: true, claimOnly: true, + budgetState: 'released', expectedPayment: 'not_signed', expectedBudget: 'released', + }, + { + name: 'nonce collision releases unsigned work', status: 'payment_failed', + reasonCode: 'NONCE_COLLISION', policyDecision: 'allow', paymentState: 'rejected', + unsignedAttempt: true, budgetState: 'released', expectedPayment: 'not_signed', + expectedBudget: 'released', + }, + { + name: 'revocation releases unsigned work', status: 'payment_denied', + reasonCode: 'AGENT_REVOKED', policyDecision: 'allow', paymentState: 'rejected', + unsignedAttempt: true, budgetState: 'released', expectedPayment: 'not_signed', + expectedBudget: 'released', + }, + { + name: 'wallet blocker releases unsigned work', status: 'payment_denied', + reasonCode: 'WALLET_RECOVERY_REQUIRED', policyDecision: 'allow', + paymentState: 'rejected', unsignedAttempt: true, budgetState: 'released', + expectedPayment: 'not_signed', expectedBudget: 'released', + }, + { + name: 'signed payment unresolved', status: 'payment_unresolved', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', policyDecision: 'allow', + paymentState: 'unresolved', budgetState: 'unresolved', intentState: 'unresolved', + expectedPayment: 'unresolved', expectedBudget: 'unresolved', + }, + { + name: 'ambiguous wallet signature', status: 'payment_unresolved', + reasonCode: 'WALLET_SIGNATURE_AMBIGUOUS', policyDecision: 'allow', + paymentState: 'unresolved', budgetState: 'unresolved', intentState: 'unresolved', + expectedPayment: 'unresolved', expectedBudget: 'unresolved', + }, + { + name: 'recovery abandons captured unsigned work', status: 'upstream_failed', + reasonCode: 'RECOVERY_ABANDONED_UNSIGNED', expectedPayment: 'none', + expectedBudget: null, + }, + { + name: 'recovery abandons challenged unsigned work', status: 'payment_failed', + reasonCode: 'RECOVERY_ABANDONED_UNSIGNED', policyDecision: 'allow', + expectedPayment: 'none', expectedBudget: null, + }, + { + name: 'recovery retains ambiguous payment hold', status: 'payment_unresolved', + reasonCode: 'RECOVERY_PAYMENT_AMBIGUOUS', policyDecision: 'allow', + paymentState: 'unresolved', budgetState: 'unresolved', intentState: 'unresolved', + expectedPayment: 'unresolved', expectedBudget: 'unresolved', + }, + { + name: 'payment settled and execution succeeded', status: 'completed', + reasonCode: 'PAYMENT_SETTLED', policyDecision: 'allow', paymentState: 'settled', + executionState: 'succeeded', budgetState: 'committed', expectedPayment: 'settled', + expectedBudget: 'committed', + }, + { + name: 'payment settled and execution failed', status: 'execution_failed', + reasonCode: 'UPSTREAM_HTTP_FAILURE', policyDecision: 'allow', paymentState: 'settled', + executionState: 'failed', budgetState: 'committed', expectedPayment: 'settled', + expectedBudget: 'committed', refundState: 'pending', + }, + { + name: 'payment settled and execution unknown', status: 'execution_unknown', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', policyDecision: 'allow', paymentState: 'settled', + executionState: 'unknown', budgetState: 'committed', expectedPayment: 'settled', + expectedBudget: 'committed', + }, + { + name: 'recovery supplies missing execution as unknown', status: 'execution_unknown', + reasonCode: 'RECOVERY_EXECUTION_MISSING', policyDecision: 'allow', + paymentState: 'settled', executionState: 'unknown', budgetState: 'committed', + expectedPayment: 'settled', expectedBudget: 'committed', + }, + { + name: 'refund unresolved', status: 'execution_failed', reasonCode: 'REFUND_UNRESOLVED', + policyDecision: 'allow', paymentState: 'settled', executionState: 'failed', + budgetState: 'committed', refundState: 'unresolved', expectedPayment: 'settled', + expectedBudget: 'committed', + }, + ]; + + for (const scenario of scenarios) { + await t.test(scenario.name, (st) => { + const context = setupRepository(st); + const { operatorIdHash } = seedScenario(context, scenario); + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.equal(record.receipt.outcome.status, scenario.status); + assert.equal(record.receipt.outcome.reasonCode, scenario.reasonCode); + assert.equal(record.receipt.payment.state, scenario.expectedPayment); + assert.equal(record.receipt.budget?.disposition ?? null, scenario.expectedBudget); + assert.equal(record.receipt.execution.state, scenario.executionState ?? 'none'); + assert.equal(record.receipt.policy?.decision ?? null, scenario.policyDecision ?? null); + assert.equal(record.receipt.approval.state, scenario.approvalState ?? 'not_required'); + if (scenario.approvalState === 'denied') { + assert.equal(record.receipt.approval.operatorIdHash, operatorIdHash); + } + assert.equal(context.receipts.verify(record), true); + assert.equal(context.receipts.assertParity(), true); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', ['intent-1'], + ).count, 1n); + const serialized = canonicalJson(record.receipt); + for (const forbidden of [ + 'RAW_SELLER_ERROR_SENTINEL', + 'RAW_BODY_SENTINEL', + 'RAW_PROMPT_SENTINEL', + 'RAW_OPERATOR_TOKEN_SENTINEL', + 'RAW_CDP_CREDENTIAL_SENTINEL', + 'RAW_PAYMENT_SIGNATURE_SENTINEL', + 'RAW_PAYMENT_HEADER_SENTINEL', + 'RAW_STACK_SENTINEL', + 'RAW_PROVIDER_EXCEPTION_SENTINEL', + 'RAW_REFUND_EVIDENCE_SENTINEL', + 'RAW_RECONCILIATION_EVIDENCE_SENTINEL', + ]) assert.equal(serialized.includes(forbidden), false); + }); + } +}); + +test('unsigned rejected attempts project not_signed only from durable absence of signed bytes', async (t) => { + const scenarios = [ + { + reasonCode: 'NONCE_COLLISION', + status: 'payment_failed', + claimOnly: false, + }, + { + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + status: 'payment_failed', + claimOnly: true, + }, + { + reasonCode: 'AGENT_REVOKED', + status: 'payment_denied', + claimOnly: false, + }, + { + reasonCode: 'WALLET_RECOVERY_REQUIRED', + status: 'payment_denied', + claimOnly: false, + }, + ]; + + for (const scenario of scenarios) { + await t.test(scenario.reasonCode, (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: scenario.reasonCode, + status: scenario.status, + reasonCode: scenario.reasonCode, + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + claimOnly: scenario.claimOnly, + budgetState: 'released', + }); + const raw = context.store.readOne(`SELECT payment_payload_json, payment_header, + payment_hash, signed_at, nonce, valid_after, valid_before, signing_claimed_at + FROM payment_attempts WHERE intent_id = 'intent-1'`); + assert.deepEqual( + [raw.payment_payload_json, raw.payment_header, raw.payment_hash, raw.signed_at], + [null, null, null, null], + ); + assert.equal(raw.signing_claimed_at !== null, scenario.claimOnly); + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.equal(record.receipt.payment.state, 'not_signed'); + assert.equal(record.receipt.payment.transactionId, null); + assert.equal(record.receipt.budget.disposition, 'released'); + assert.equal(context.receipts.verify(record), true); + }); + } +}); + +test('terminal unresolved receipts require the durable PaymentAttempt unresolved state', async (t) => { + for (const paymentState of ['signing', 'signed', 'retrying']) { + await t.test(`${paymentState} remains nonterminal`, (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: `nonterminal-${paymentState}`, + status: 'payment_unresolved', + reasonCode: 'WALLET_SIGNATURE_AMBIGUOUS', + policyDecision: 'allow', + paymentState, + unsignedAttempt: paymentState === 'signing', + claimOnly: paymentState === 'signing', + budgetState: 'unresolved', + intentState: 'unresolved', + }); + if (paymentState === 'retrying') { + context.store.execForTest(`UPDATE payment_attempts SET retry_started_at = '${NOW}' + WHERE intent_id = 'intent-1'`); + } + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /state|terminal|unresolved|payment|projection/i, + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts', + ).count, 0n); + }); + } + + for (const shape of [ + { name: 'claim-only unresolved', unsignedAttempt: true, claimOnly: true }, + { name: 'full-signed unresolved', unsignedAttempt: false, claimOnly: false }, + ]) { + await t.test(shape.name, (st) => { + const context = setupRepository(st); + seedScenario(context, { + ...shape, + status: 'payment_unresolved', + reasonCode: 'WALLET_SIGNATURE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'unresolved', + budgetState: 'unresolved', + intentState: 'unresolved', + }); + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.equal(record.receipt.payment.state, 'unresolved'); + assert.equal(record.receipt.budget.disposition, 'unresolved'); + assert.equal(context.receipts.verify(record), true); + }); + } +}); + +test('receipt issuance rejects terminal PaymentAttempt chronology corruption by state', async (t) => { + const cases = [ + { + state: 'unresolved', + seed(context) { + seedScenario(context, { + name: 'chronology-unresolved', + status: 'payment_unresolved', + reasonCode: 'WALLET_SIGNATURE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'unresolved', + budgetState: 'unresolved', + intentState: 'unresolved', + }); + context.store.execForTest(`UPDATE payment_attempts SET retry_started_at = '${NOW}' + WHERE intent_id = 'intent-1'`); + }, + corrupt(store) { + store.execForTest(`UPDATE payment_attempts + SET retry_started_at = '2026-07-31T11:59:59.000Z' + WHERE intent_id = 'intent-1'`); + }, + }, + { + state: 'rejected', + seed(context) { + seedScenario(context, { + name: 'chronology-rejected', + status: 'payment_failed', + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + claimOnly: true, + budgetState: 'released', + }); + }, + corrupt(store) { + store.execForTest(`UPDATE payment_attempts + SET signing_claimed_at = '2026-07-31T12:00:02.000Z' + WHERE intent_id = 'intent-1'`); + }, + }, + { + state: 'settled', + seed(context) { + seedSettledSuccess(context); + }, + corrupt(store) { + store.execForTest(`UPDATE payment_attempts + SET settled_at = '2026-07-31T11:59:59.000Z' + WHERE intent_id = 'intent-1'`); + }, + }, + ]; + + for (const entry of cases) { + await t.test(entry.state, (st) => { + const context = setupRepository(st); + entry.seed(context); + entry.corrupt(context.store); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /chronology|time|attempt|payment|corrupt/i, + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts', + ).count, 0n); + }); + } +}); + +test('receipt reads and parity reproject current terminal PaymentAttempt chronology', async (t) => { + const cases = [ + { + state: 'unresolved', + seed(context) { + seedScenario(context, { + name: 'read-chronology-unresolved', + status: 'payment_unresolved', + reasonCode: 'WALLET_SIGNATURE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'unresolved', + budgetState: 'unresolved', + intentState: 'unresolved', + }); + context.store.execForTest(`UPDATE payment_attempts SET retry_started_at = '${NOW}' + WHERE intent_id = 'intent-1'`); + }, + corrupt(store) { + store.execForTest(`UPDATE payment_attempts + SET retry_started_at = '2026-07-31T11:59:59.000Z' + WHERE intent_id = 'intent-1'`); + }, + }, + { + state: 'rejected', + seed(context) { + seedScenario(context, { + name: 'read-chronology-rejected', + status: 'payment_failed', + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + claimOnly: true, + budgetState: 'released', + }); + }, + corrupt(store) { + store.execForTest(`UPDATE payment_attempts + SET signing_claimed_at = '2026-07-31T12:00:02.000Z' + WHERE intent_id = 'intent-1'`); + }, + }, + { + state: 'settled', + seed(context) { + seedSettledSuccess(context); + }, + corrupt(store) { + store.execForTest(`UPDATE payment_attempts + SET settled_at = '2026-07-31T11:59:59.000Z' + WHERE intent_id = 'intent-1'`); + }, + }, + ]; + + for (const entry of cases) { + await t.test(entry.state, (st) => { + const context = setupRepository(st); + entry.seed(context); + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.equal(context.receipts.verify(record), true); + entry.corrupt(context.store); + assert.throws( + () => context.receipts.latest('intent-1'), + /chronology|time|attempt|payment|corrupt|parity/i, + ); + assert.throws( + () => context.receipts.list({ sessionId: 'session-1', limit: 10 }), + /chronology|time|attempt|payment|corrupt|parity/i, + ); + assert.throws( + () => context.receipts.assertParity(), + /chronology|time|attempt|payment|corrupt|parity/i, + ); + }); + } +}); + +test('receipt issuance rejects substituted reasons and contradictory claim or signed-byte groups', async (t) => { + const contradictions = [ + { + name: 'typed pre-sign rejection lost its retained signing claim', + scenario: { + status: 'payment_failed', + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + budgetState: 'released', + }, + mutate() {}, + }, + { + name: 'nonce collision improperly retained an uncommitted signing claim', + scenario: { + status: 'payment_failed', + reasonCode: 'NONCE_COLLISION', + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + claimOnly: true, + budgetState: 'released', + }, + mutate() {}, + }, + { + name: 'unsigned attempt reason differs from BuyerOutcome', + scenario: { + status: 'payment_failed', + reasonCode: 'NONCE_COLLISION', + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + budgetState: 'released', + }, + mutate(store) { + store.execForTest(`UPDATE payment_attempts SET reason_code = 'AGENT_REVOKED' + WHERE intent_id = 'intent-1'`); + }, + }, + { + name: 'claim-only rejection has a partial durable window', + scenario: { + status: 'payment_failed', + reasonCode: 'WALLET_PRE_SIGN_REJECTED', + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + claimOnly: true, + budgetState: 'released', + }, + mutate(store) { + store.execForTest(`UPDATE payment_attempts SET valid_before = NULL + WHERE intent_id = 'intent-1'`); + }, + }, + { + name: 'reserved attempt contains signed bytes without a claim', + scenario: { + status: 'payment_failed', + reasonCode: 'SIGNER_REJECTED', + policyDecision: 'allow', + paymentState: 'reserved', + budgetState: 'released', + }, + mutate(store) { + store.execForTest(`UPDATE payment_attempts + SET payment_payload_json = '{}', payment_header = 'forged-header', + payment_hash = '${sha256('forged-header')}', signed_at = '${NOW}' + WHERE intent_id = 'intent-1'`); + }, + }, + { + name: 'released terminal attempt remains reserved', + scenario: { + status: 'payment_failed', + reasonCode: 'NONCE_COLLISION', + policyDecision: 'allow', + paymentState: 'reserved', + budgetState: 'released', + }, + mutate() {}, + }, + { + name: 'signed unresolved attempt lost its signing claim', + scenario: { + status: 'payment_unresolved', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'unresolved', + budgetState: 'unresolved', + intentState: 'unresolved', + }, + mutate(store) { + store.execForTest(`UPDATE payment_attempts + SET nonce = NULL, valid_after = NULL, valid_before = NULL, signing_claimed_at = NULL + WHERE intent_id = 'intent-1'`); + }, + }, + ]; + + for (const contradiction of contradictions) { + await t.test(contradiction.name, (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: contradiction.name, + ...contradiction.scenario, + }); + contradiction.mutate(context.store); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /payment|claim|signed|reason|projection|corrupt/i, + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts', + ).count, 0n); + }); + } +}); + +test('CHALLENGE_EXPIRED distinguishes policy denial from exact pre-claim release', async (t) => { + const releasedShapes = [ + { + name: 'automatic authority', + policyDecision: 'allow', + }, + { + name: 'consumed approval authority', + policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', + approvalState: 'consumed', + }, + ]; + for (const shape of releasedShapes) { + await t.test(`pre-claim release: ${shape.name}`, (st) => { + const context = setupRepository(st); + seedScenario(context, { + ...shape, + name: `challenge-expired-${shape.name}`, + status: 'payment_denied', + reasonCode: 'CHALLENGE_EXPIRED', + paymentState: 'rejected', + unsignedAttempt: true, + budgetState: 'released', + }); + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.equal(record.receipt.outcome.reasonCode, 'CHALLENGE_EXPIRED'); + assert.equal(record.receipt.payment.state, 'not_signed'); + assert.equal(record.receipt.budget.disposition, 'released'); + assert.deepEqual(record.receipt.execution, { + state: 'none', + httpStatus: null, + responseHash: null, + }); + assert.equal(record.receipt.reconciliation, null); + assert.equal(record.receipt.refund, null); + assert.equal(context.receipts.verify(record), true); + assert.equal(context.receipts.assertParity(), true); + }); + } + + await t.test('policy denial retains its no-spend meaning', (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: 'challenge-expired-policy-denial', + status: 'payment_denied', + reasonCode: 'CHALLENGE_EXPIRED', + policyDecision: 'deny', + policyReason: 'CHALLENGE_EXPIRED', + }); + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.equal(record.receipt.policy.decision, 'deny'); + assert.deepEqual(record.receipt.payment, { state: 'none' }); + assert.equal(record.receipt.budget, null); + assert.equal(context.receipts.verify(record), true); + }); + + const contradictions = [ + { + name: 'signing claim exists', + claimOnly: true, + }, + { + name: 'PaymentAttempt reason differs', + mutate(store) { + store.execForTest(`UPDATE payment_attempts SET reason_code = 'SIGNER_REJECTED' + WHERE intent_id = 'intent-1'`); + }, + }, + { + name: 'released budget is missing', + budgetState: null, + }, + { + name: 'budget remains unresolved', + budgetState: 'unresolved', + }, + { + name: 'execution aftermath exists', + executionState: 'unknown', + }, + { + name: 'reconciliation aftermath exists', + reconciliationKind: 'payment', + reconciliationOutcome: 'unresolved', + }, + { + name: 'refund aftermath exists', + refundState: 'unresolved', + }, + ]; + for (const contradiction of contradictions) { + await t.test(contradiction.name, (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: `challenge-expired-${contradiction.name}`, + status: 'payment_denied', + reasonCode: 'CHALLENGE_EXPIRED', + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + budgetState: 'released', + ...contradiction, + }); + contradiction.mutate?.(context.store); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /claim|reason|budget|execution|reconciliation|refund|projection|authority|contradict/i, + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts', + ).count, 0n); + }); + } +}); + +test('policy and session cancellation accept every safe unsigned terminal authority shape', async (t) => { + const validShapes = [ + { + name: 'automatic before reservation', + policyDecision: 'allow', + expectedApproval: 'not_required', + expectedPayment: 'none', + }, + { + name: 'pending approval cancelled before reservation', + policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', + approvalState: 'cancelled', + operatorHash: false, + expectedApproval: 'cancelled', + expectedPayment: 'none', + }, + { + name: 'automatic unsigned reservation released', + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + budgetState: 'released', + expectedApproval: 'not_required', + expectedPayment: 'not_signed', + }, + { + name: 'consumed approval remains consumed after unsigned release', + policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', + approvalState: 'consumed', + paymentState: 'rejected', + unsignedAttempt: true, + budgetState: 'released', + expectedApproval: 'consumed', + expectedPayment: 'not_signed', + }, + ]; + for (const reasonCode of ['POLICY_SUPERSEDED', 'SESSION_CLOSED']) { + for (const shape of validShapes) { + await t.test(`${reasonCode}: ${shape.name}`, (st) => { + const context = setupRepository(st); + seedScenario(context, { + ...shape, + name: `${reasonCode}-${shape.name}`, + status: 'payment_denied', + reasonCode, + }); + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.equal(record.receipt.approval.state, shape.expectedApproval); + assert.equal(record.receipt.payment.state, shape.expectedPayment); + assert.equal( + record.receipt.budget?.disposition ?? null, + shape.expectedPayment === 'none' ? null : 'released', + ); + assert.equal(context.receipts.verify(record), true); + }); + } + } +}); + +test('pre-decision session cancellation requires an exact empty spend-authority projection', async (t) => { + for (const reasonCode of ['POLICY_SUPERSEDED', 'SESSION_CLOSED']) { + await t.test(`${reasonCode}: captured before decision`, (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: `${reasonCode}-captured-before-decision`, + status: 'payment_denied', + reasonCode, + }); + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.equal(record.receipt.policy, null); + assert.deepEqual(record.receipt.approval, { + state: 'not_required', + operatorIdHash: null, + }); + assert.deepEqual(record.receipt.payment, { state: 'none' }); + assert.equal(record.receipt.budget, null); + assert.deepEqual(record.receipt.execution, { + state: 'none', + httpStatus: null, + responseHash: null, + }); + assert.equal(context.receipts.verify(record), true); + }); + } + + const contradictions = [ + { + name: 'unrelated deny decision', + policyDecision: 'deny', + policyReason: 'POLICY_DENIED', + }, + { + name: 'payment evidence without a decision', + paymentState: 'rejected', + unsignedAttempt: true, + }, + { + name: 'budget evidence without a decision', + budgetState: 'released', + }, + { + name: 'execution evidence without a decision', + executionState: 'unknown', + httpStatus: 206, + }, + ]; + for (const contradiction of contradictions) { + await t.test(contradiction.name, (st) => { + const context = setupRepository(st); + seedScenario(context, { + ...contradiction, + name: `pre-decision-${contradiction.name}`, + status: 'payment_denied', + reasonCode: 'SESSION_CLOSED', + }); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /policy|payment|budget|execution|projection|authority|contradict/i, + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts', + ).count, 0n); + }); + } +}); + +test('policy and session cancellation reject nonterminal approval and sensitive aftermath', async (t) => { + const contradictions = [ + { + name: 'pending approval was not cancelled', + policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', + approvalState: 'pending', + operatorHash: false, + }, + { + name: 'approved approval was not cancelled', + policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', + approvalState: 'approved', + }, + { + name: 'consumed approval lost its mandatory reservation', + policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', + approvalState: 'consumed', + }, + { + name: 'signed authorization cannot be called unsigned', + policyDecision: 'allow', + paymentState: 'rejected', + budgetState: 'released', + }, + { + name: 'payment without its released budget', + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + }, + { + name: 'released budget without its payment attempt', + policyDecision: 'allow', + budgetState: 'released', + }, + { + name: 'execution aftermath survives cancellation', + policyDecision: 'allow', + executionState: 'unknown', + httpStatus: 206, + }, + { + name: 'reconciliation aftermath survives cancellation', + policyDecision: 'allow', + reconciliationKind: 'execution', + reconciliationOutcome: 'execution_unknown', + }, + ]; + + for (const contradiction of contradictions) { + await t.test(contradiction.name, (st) => { + const context = setupRepository(st); + seedScenario(context, { + ...contradiction, + status: 'payment_denied', + reasonCode: 'SESSION_CLOSED', + }); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /projection|contradict|authority|reconciliation|payment/i, + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts', + ).count, 0n); + }); + } +}); + +test('policy and session cancellation reject every claim-only payment attempt', async (t) => { + for (const reasonCode of ['POLICY_SUPERSEDED', 'SESSION_CLOSED']) { + await t.test(reasonCode, (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: `${reasonCode}-claim-only`, + status: 'payment_denied', + reasonCode, + policyDecision: 'allow', + paymentState: 'rejected', + unsignedAttempt: true, + claimOnly: true, + budgetState: 'released', + }); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /claim|unsigned|payment|projection|contradict/i, + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts', + ).count, 0n); + }); + } +}); + +test('execution projection retains partial evidence while its explicit state remains authoritative', async (t) => { + const partialResponseHash = sha256('partial-response-bytes'); + const validExecutions = [ + { + name: 'redirect failed without a body hash', + status: 'execution_failed', + reasonCode: 'UPSTREAM_HTTP_FAILURE', + executionState: 'failed', + httpStatus: 302, + executionResponseHash: null, + refundState: 'pending', + }, + { + name: 'server failed without a body hash', + status: 'execution_failed', + reasonCode: 'UPSTREAM_HTTP_FAILURE', + executionState: 'failed', + httpStatus: 599, + executionResponseHash: null, + refundState: 'pending', + }, + { + name: 'unknown with status only', + status: 'execution_unknown', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + executionState: 'unknown', + httpStatus: 206, + executionResponseHash: null, + }, + { + name: 'unknown with response hash only', + status: 'execution_unknown', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + executionState: 'unknown', + httpStatus: null, + executionResponseHash: partialResponseHash, + }, + { + name: 'unknown with independently known status and hash', + status: 'execution_unknown', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + executionState: 'unknown', + httpStatus: 299, + executionResponseHash: partialResponseHash, + }, + ]; + + for (const scenario of validExecutions) { + await t.test(scenario.name, (st) => { + const context = setupRepository(st); + seedScenario(context, { + ...scenario, + policyDecision: 'allow', + paymentState: 'settled', + budgetState: 'committed', + }); + const record = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.deepEqual(record.receipt.execution, { + state: scenario.executionState, + httpStatus: scenario.httpStatus, + responseHash: scenario.executionResponseHash, + }); + assert.equal(context.receipts.verify(record), true); + }); + } + + const invalidExecutions = [ + { + name: 'failed without a known status', + httpStatus: null, + executionResponseHash: null, + }, + { + name: 'failed with a 2xx status', + httpStatus: 299, + executionResponseHash: null, + }, + { + name: 'failed with a malformed optional hash', + httpStatus: 500, + executionResponseHash: 'not-a-hash', + }, + ]; + for (const scenario of invalidExecutions) { + await t.test(scenario.name, (st) => { + const context = setupRepository(st); + seedScenario(context, { + ...scenario, + status: 'execution_failed', + reasonCode: 'UPSTREAM_HTTP_FAILURE', + policyDecision: 'allow', + paymentState: 'settled', + executionState: 'failed', + budgetState: 'committed', + refundState: 'pending', + }); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /status|hash|projection|fields/i, + ); + }); + } + + for (const httpStatus of [300, 503, 599]) { + await t.test(`unknown cannot retain definitive failure status ${httpStatus}`, (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: `unknown-status-${httpStatus}`, + status: 'execution_unknown', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'settled', + executionState: 'unknown', + httpStatus, + executionResponseHash: partialResponseHash, + budgetState: 'committed', + }); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /status|unknown|projection|execution/i, + ); + }); + } +}); + +test('trusted payment, execution, and refund facts create exact superseding revisions', async (t) => { + await t.test('payment reconciliation', (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: 'initial unresolved payment', + status: 'payment_unresolved', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'unresolved', + budgetState: 'unresolved', + intentState: 'unresolved', + expectedPayment: 'unresolved', + expectedBudget: 'unresolved', + }); + const first = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + assert.equal(context.store.transaction( + (token) => context.receipts.assertParityInTransaction(token), + ), true); + + context.store.transaction((token) => context.store.within(token, ({ db }) => { + assert.equal(context.receipts.assertParityInTransaction(token), true); + db.prepare(`UPDATE payment_attempts + SET state = 'settled', retry_started_at = ?, settlement_json = '{}', + transaction_id = ?, reason_code = 'TRUSTED_RECONCILIATION', + settled_at = ?, updated_at = ? WHERE intent_id = ?`) + .run(NOW, `0x${'ab'.repeat(32)}`, NOW, NOW, 'intent-1'); + db.prepare(`UPDATE budget_reservations + SET unresolved_atomic = '0', committed_atomic = '50000', state = 'committed', + committed_at = ?, updated_at = ? WHERE intent_id = ?`).run(NOW, NOW, 'intent-1'); + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'unknown', NULL, NULL, '{}', ?)`).run('intent-1', NOW); + db.prepare(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-payment', ?, 'payment', 'settled', '{}', ?, ?)`) + .run('intent-1', sha256('operator-payment'), NOW); + db.prepare(`UPDATE spend_intents SET state = 'terminal', updated_at = ? WHERE id = ?`) + .run(NOW, 'intent-1'); + db.prepare(`UPDATE buyer_outcomes + SET status = 'execution_unknown', reason_code = 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + revision = 2, recorded_at = ? WHERE intent_id = ?`).run(NOW, 'intent-1'); + })); + assert.throws(() => context.receipts.assertParity(), /missing.*current|parity/i); + assert.throws(() => context.receipts.issueRevisionForTerminal({ + intentId: 'intent-1', supersedesReceiptHash: '0'.repeat(64), + }), /predecessor/i); + const second = context.receipts.issueRevisionForTerminal({ + intentId: 'intent-1', supersedesReceiptHash: first.receiptHash, + }); + assert.equal(second.revision, 2); + assert.equal(second.receipt.supersedesReceiptHash, first.receiptHash); + assert.equal(second.receipt.outcome.status, 'execution_unknown'); + assert.equal(second.receipt.reconciliation.kind, 'payment'); + assert.equal(second.receipt.reconciliation.outcome, 'settled'); + assert.equal(second.receipt.payment.state, 'settled'); + assert.equal(second.receipt.budget.disposition, 'committed'); + assert.equal(context.receipts.verify(first), true); + assert.equal(context.receipts.verify(second), true); + assert.equal(context.receipts.assertParity(), true); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-1' }), + /initial.*revision 1/i, + ); + }); + + await t.test('rejected payment reconciliation', (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: 'initial unresolved authorization', + status: 'payment_unresolved', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'unresolved', + budgetState: 'unresolved', + intentState: 'unresolved', + expectedPayment: 'unresolved', + expectedBudget: 'unresolved', + }); + const first = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + assert.equal(context.receipts.assertParityInTransaction(token), true); + db.prepare(`UPDATE payment_attempts SET state = 'rejected', reason_code = ?, + updated_at = ? WHERE intent_id = ?`).run( + 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', NOW, 'intent-1', + ); + db.prepare(`UPDATE budget_reservations + SET unresolved_atomic = '0', released_atomic = '50000', state = 'released', + updated_at = ? WHERE intent_id = ?`).run(NOW, 'intent-1'); + db.prepare(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-rejected-payment', ?, 'payment', 'rejected', '{}', ?, ?)`).run( + 'intent-1', sha256('operator-rejected-payment'), NOW, + ); + db.prepare(`UPDATE spend_intents SET state = 'terminal', updated_at = ? WHERE id = ?`) + .run(NOW, 'intent-1'); + db.prepare(`UPDATE buyer_outcomes + SET status = 'payment_rejected', reason_code = 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + revision = 2, recorded_at = ? WHERE intent_id = ?`).run(NOW, 'intent-1'); + })); + const second = context.receipts.issueRevisionForTerminal({ + intentId: 'intent-1', + supersedesReceiptHash: first.receiptHash, + }); + assert.equal(second.revision, 2); + assert.equal(second.receipt.outcome.status, 'payment_rejected'); + assert.equal(second.receipt.payment.state, 'rejected'); + assert.equal(second.receipt.budget.disposition, 'released'); + assert.equal(second.receipt.reconciliation.kind, 'payment'); + assert.equal(second.receipt.reconciliation.outcome, 'rejected'); + assert.equal(context.receipts.verify(second), true); + assert.equal(context.receipts.assertParity(), true); + }); + + await t.test('execution reconciliation', (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: 'initial unknown execution', + status: 'execution_unknown', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'settled', + executionState: 'unknown', + budgetState: 'committed', + expectedPayment: 'settled', + expectedBudget: 'committed', + }); + const first = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + const responseHash = sha256(Buffer.from('{"reconciled":true}', 'utf8')); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + assert.equal(context.receipts.assertParityInTransaction(token), true); + db.prepare(`UPDATE execution_outcomes + SET state = 'succeeded', http_status = 200, response_hash = ?, recorded_at = ? + WHERE intent_id = ?`).run(responseHash, NOW, 'intent-1'); + db.prepare(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-execution', ?, 'execution', 'execution_succeeded', '{}', ?, ?)`) + .run('intent-1', sha256('operator-execution'), NOW); + db.prepare(`UPDATE buyer_outcomes + SET status = 'completed', reason_code = 'EXECUTION_RECONCILED_SUCCEEDED', + revision = 2, recorded_at = ? WHERE intent_id = ?`).run(NOW, 'intent-1'); + })); + const second = context.receipts.issueRevisionForTerminal({ + intentId: 'intent-1', supersedesReceiptHash: first.receiptHash, + }); + assert.equal(second.receipt.execution.state, 'succeeded'); + assert.equal(second.receipt.execution.responseHash, responseHash); + assert.equal(second.receipt.reconciliation.kind, 'execution'); + assert.equal(second.receipt.outcome.status, 'completed'); + assert.equal(context.receipts.assertParity(), true); + }); + + await t.test('confirmed refund', (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: 'initial unresolved refund', + status: 'execution_failed', + reasonCode: 'REFUND_UNRESOLVED', + policyDecision: 'allow', + paymentState: 'settled', + executionState: 'failed', + budgetState: 'committed', + refundState: 'unresolved', + expectedPayment: 'settled', + expectedBudget: 'committed', + }); + const first = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + assert.equal(context.receipts.assertParityInTransaction(token), true); + db.prepare(`UPDATE refunds SET state = 'confirmed', refund_transaction_id = ?, + evidence_json = '{}', updated_at = ? WHERE intent_id = ?`) + .run(`0x${'cd'.repeat(32)}`, NOW, 'intent-1'); + db.prepare(`UPDATE budget_reservations + SET committed_atomic = '0', released_atomic = '50000', state = 'released', + updated_at = ? WHERE intent_id = ?`).run(NOW, 'intent-1'); + db.prepare(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-refund', ?, 'refund', 'refund_confirmed', '{}', ?, ?)`) + .run('intent-1', sha256('operator-refund'), NOW); + db.prepare(`UPDATE buyer_outcomes + SET status = 'refunded', reason_code = 'REFUND_CONFIRMED', revision = 2, + recorded_at = ? WHERE intent_id = ?`).run(NOW, 'intent-1'); + })); + const second = context.receipts.issueRevisionForTerminal({ + intentId: 'intent-1', supersedesReceiptHash: first.receiptHash, + }); + assert.equal(second.receipt.refund.state, 'confirmed'); + assert.equal(second.receipt.refund.transactionId, `0x${'cd'.repeat(32)}`); + assert.equal(second.receipt.budget.disposition, 'released'); + assert.equal(second.receipt.outcome.status, 'refunded'); + assert.equal(second.receipt.reconciliation.kind, 'refund'); + assert.equal(context.receipts.assertParity(), true); + assert.deepEqual( + context.receipts.list({ sessionId: 'session-1', limit: 10 }).map((row) => row.revision), + [2, 1], + ); + const originalEventData = context.store.readOne( + "SELECT data_json FROM events WHERE entity_type = 'signed_receipt' AND entity_id = ?", + [second.id], + ).data_json; + const wrongPredecessorReceipt = structuredClone(second.receipt); + wrongPredecessorReceipt.supersedesReceiptHash = 'd'.repeat(64); + const wrongPredecessor = { + ...second, + ...signReceipt(context.signer, wrongPredecessorReceipt), + receipt: wrongPredecessorReceipt, + supersedesReceiptHash: wrongPredecessorReceipt.supersedesReceiptHash, + }; + const wrongEventData = canonicalJson({ + ...JSON.parse(originalEventData), + receiptHash: wrongPredecessor.receiptHash, + supersedesReceiptHash: wrongPredecessor.supersedesReceiptHash, + }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE signed_receipts SET receipt_json = ?, receipt_hash = ?, signature = ?, + supersedes_receipt_hash = ? WHERE id = ?`).run( + canonicalJson(wrongPredecessorReceipt), + wrongPredecessor.receiptHash, + wrongPredecessor.signature, + wrongPredecessor.supersedesReceiptHash, + second.id, + ); + db.prepare("UPDATE events SET data_json = ? WHERE entity_type = 'signed_receipt' AND entity_id = ?") + .run(wrongEventData, second.id); + })); + assert.throws(() => context.receipts.latest('intent-1'), /history|parity|predecessor/i); + assert.throws( + () => context.receipts.list({ sessionId: 'session-1', limit: 10 }), + /history|parity|predecessor/i, + ); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE signed_receipts SET receipt_json = ?, receipt_hash = ?, signature = ?, + supersedes_receipt_hash = ? WHERE id = ?`).run( + canonicalJson(second.receipt), + second.receiptHash, + second.signature, + second.supersedesReceiptHash, + second.id, + ); + db.prepare("UPDATE events SET data_json = ? WHERE entity_type = 'signed_receipt' AND entity_id = ?") + .run(originalEventData, second.id); + })); + context.store.execForTest("DELETE FROM events WHERE entity_type = 'signed_receipt' AND entity_id = 'receipt-1'"); + context.store.execForTest("DELETE FROM signed_receipts WHERE intent_id = 'intent-1' AND revision = 1"); + assert.throws(() => context.receipts.latest('intent-1'), /history|parity|revision/i); + assert.throws( + () => context.receipts.list({ sessionId: 'session-1', limit: 10 }), + /history|parity|revision/i, + ); + }); +}); + +test('Task 11 candidate rejection and attested execution failure have exact revisions', async (t) => { + await t.test('payment candidate rejection retains the full unresolved hold', (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: 'candidate-rejection-predecessor', + status: 'payment_unresolved', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'unresolved', + budgetState: 'unresolved', + intentState: 'unresolved', + }); + const first = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + const candidateTransactionId = `0x${'31'.repeat(32)}`; + const rejectionEvidence = canonicalJson({ + kind: 'payment_candidate_rejected', + transactionId: candidateTransactionId, + reasonCode: 'TRANSACTION_REVERTED', + rpcProofHash: sha256('candidate-rejection-proof'), + }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + assert.equal(context.receipts.assertParityInTransaction(token), true); + db.prepare(`INSERT INTO payment_reconciliation_candidates + (id, intent_id, transaction_id, state, evidence_json, created_at, updated_at) + VALUES ('candidate-1', 'intent-1', ?, 'pending', ?, ?, ?)`).run( + candidateTransactionId, + rejectionEvidence, + NOW, + NOW, + ); + db.prepare(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-candidate-rejected', 'intent-1', 'payment', 'unresolved', + ?, ?, ?)`).run(rejectionEvidence, sha256('candidate-rejection-operator'), NOW); + db.prepare(`UPDATE buyer_outcomes + SET reason_code = 'PAYMENT_CANDIDATE_REJECTED', revision = 2, recorded_at = ? + WHERE intent_id = 'intent-1'`).run(NOW); + db.prepare(`UPDATE payment_attempts + SET reason_code = 'PAYMENT_CANDIDATE_REJECTED' + WHERE intent_id = 'intent-1' AND state = 'unresolved'`).run(); + })); + assert.throws(() => context.receipts.issueRevisionForTerminal({ + intentId: 'intent-1', supersedesReceiptHash: first.receiptHash, + }), /candidate|rejected|projection|history/i); + + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE payment_reconciliation_candidates + SET state = 'rejected' WHERE id = 'candidate-1'`).run(); + })); + const second = context.receipts.issueRevisionForTerminal({ + intentId: 'intent-1', supersedesReceiptHash: first.receiptHash, + }); + assert.equal(second.revision, 2); + assert.equal(second.receipt.outcome.status, 'payment_unresolved'); + assert.equal(second.receipt.outcome.reasonCode, 'PAYMENT_CANDIDATE_REJECTED'); + assert.equal(second.receipt.payment.state, 'unresolved'); + assert.equal(second.receipt.budget.disposition, 'unresolved'); + assert.equal(second.receipt.reconciliation.kind, 'payment'); + assert.equal(second.receipt.reconciliation.outcome, 'unresolved'); + assert.equal(context.receipts.assertParity(), true); + }); + + await t.test('verified execution failure opens one full refund-pending revision', (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: 'execution-failure-predecessor', + status: 'execution_unknown', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + policyDecision: 'allow', + paymentState: 'settled', + executionState: 'unknown', + budgetState: 'committed', + }); + const first = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + assert.equal(context.receipts.assertParityInTransaction(token), true); + db.prepare(`UPDATE execution_outcomes + SET state = 'failed', http_status = 503, response_hash = NULL, recorded_at = ? + WHERE intent_id = 'intent-1'`).run(NOW); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at, resolved_at) + VALUES ('intent-1', 'refund_pending', 'REFUND_UNRESOLVED', 1, ?, NULL)`).run(NOW); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, + evidence_json, refund_transaction_id, created_at, updated_at) + VALUES ('refund-attested-failure', 'intent-1', ?, '1', 'pending', NULL, NULL, ?, ?)`).run( + `0x${'ab'.repeat(32)}`, + NOW, + NOW, + ); + db.prepare(`INSERT INTO reconciliations + (id, intent_id, kind, outcome, evidence_json, operator_id_hash, recorded_at) + VALUES ('reconciliation-execution-failed', 'intent-1', 'execution', + 'execution_failed', '{}', ?, ?)`).run(sha256('execution-failed-operator'), NOW); + db.prepare(`UPDATE buyer_outcomes + SET status = 'execution_failed', reason_code = 'REFUND_UNRESOLVED', + revision = 2, recorded_at = ? WHERE intent_id = 'intent-1'`).run(NOW); + })); + assert.throws(() => context.receipts.issueRevisionForTerminal({ + intentId: 'intent-1', supersedesReceiptHash: first.receiptHash, + }), /refund|amount|projection/i); + + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE refunds SET amount_atomic = '50000' + WHERE id = 'refund-attested-failure'`).run(); + })); + const second = context.receipts.issueRevisionForTerminal({ + intentId: 'intent-1', supersedesReceiptHash: first.receiptHash, + }); + assert.equal(second.revision, 2); + assert.equal(second.receipt.outcome.status, 'execution_failed'); + assert.equal(second.receipt.outcome.reasonCode, 'REFUND_UNRESOLVED'); + assert.equal(second.receipt.execution.state, 'failed'); + assert.equal(second.receipt.refund.state, 'pending'); + assert.equal(second.receipt.refund.amountAtomic, '50000'); + assert.equal(second.receipt.budget.disposition, 'committed'); + assert.equal(second.receipt.reconciliation.kind, 'execution'); + assert.equal(second.receipt.reconciliation.outcome, 'execution_failed'); + assert.equal(context.receipts.assertParity(), true); + }); + + for (const scenario of [ + { + name: 'pending refund candidate', + reasonCode: 'UPSTREAM_HTTP_FAILURE', + refundState: 'pending', + }, + { + name: 'legacy unresolved refund candidate', + reasonCode: 'REFUND_UNRESOLVED', + refundState: 'unresolved', + }, + ]) { + await t.test(`${scenario.name} binding and abandonment do not revise its receipt`, (st) => { + const context = setupRepository(st); + seedScenario(context, { + name: scenario.name, + status: 'execution_failed', + reasonCode: scenario.reasonCode, + policyDecision: 'allow', + paymentState: 'settled', + executionState: 'failed', + budgetState: 'committed', + refundState: scenario.refundState, + }); + const first = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + const transactionId = `0x${'45'.repeat(32)}`; + + context.store.transaction((token) => context.store.within(token, ({ db }) => { + assert.equal(context.receipts.assertParityInTransaction(token), true); + db.prepare(`UPDATE refunds + SET refund_transaction_id = ?, evidence_json = NULL, updated_at = ? + WHERE intent_id = 'intent-1'`).run(transactionId, NOW); + })); + assert.equal(context.receipts.assertParity(), true); + assert.equal(context.receipts.latest('intent-1').receiptHash, first.receiptHash); + assert.deepEqual(context.receipts.latest('intent-1').receipt.refund, { + state: scenario.refundState, + amountAtomic: '50000', + transactionId: null, + }); + + context.store.transaction((token) => context.store.within(token, ({ db }) => { + assert.equal(context.receipts.assertParityInTransaction(token), true); + db.prepare(`UPDATE refunds + SET state = 'abandoned', updated_at = ? + WHERE intent_id = 'intent-1' AND refund_transaction_id = ?`).run( + NOW, + transactionId, + ); + })); + assert.equal(context.receipts.assertParity(), true); + assert.equal(context.receipts.latest('intent-1').receiptHash, first.receiptHash); + assert.deepEqual(context.receipts.latest('intent-1').receipt.refund, { + state: scenario.refundState, + amountAtomic: '50000', + transactionId: null, + }); + }); + } +}); + +test('reopen recovery signs one exact missing revision without replaying money or events', (t) => { + const fileAuthority = authority(t, 'wallet-kernel-receipt-recovery-'); + const signer = loadOrCreateReceiptSigner(fileAuthority.keyPath, { + pathTrust: fileAuthority.pathTrust, + }); + const failingSigner = Object.freeze({ + algorithm: signer.algorithm, + keyId: signer.keyId, + persistent: true, + publicKeyPem: signer.publicKeyPem, + signHash() { throw new Error('injected post-domain receipt signing failure'); }, + }); + const first = setupRepository(t, { fileAuthority, signer: failingSigner }); + seedScenario(first, { + name: 'recovery settlement', + status: 'completed', + reasonCode: 'PAYMENT_SETTLED', + policyDecision: 'allow', + paymentState: 'settled', + executionState: 'succeeded', + budgetState: 'committed', + expectedPayment: 'settled', + expectedBudget: 'committed', + }); + const beforeEvents = first.store.events().map((row) => ({ + sequence: row.sequence, + eventHash: row.event_hash, + eventType: row.event_type, + })); + const beforeMoney = first.store.readOne(`SELECT state, committed_atomic, released_atomic, + unresolved_atomic, committed_at FROM budget_reservations WHERE intent_id = ?`, ['intent-1']); + const beforePayment = first.store.readOne(`SELECT state, transaction_id, settled_at + FROM payment_attempts WHERE intent_id = ?`, ['intent-1']); + + assert.throws( + () => first.receipts.issueForTerminal({ intentId: 'intent-1' }), + /injected post-domain receipt signing failure/, + ); + assert.equal(first.store.readOne('SELECT COUNT(*) AS count FROM signed_receipts').count, 0n); + assert.deepEqual(first.store.events().map((row) => ({ + sequence: row.sequence, + eventHash: row.event_hash, + eventType: row.event_type, + })), beforeEvents); + first.store.close(); + + const reopened = setupRepository(t, { + fileAuthority, + signer, + seedAuthority: false, + }); + assert.throws(() => reopened.receipts.assertParity(), /missing.*current|parity/i); + const repaired = reopened.receipts.issueMissingTerminalReceipts(); + assert.equal(repaired.length, 1); + assert.equal(repaired[0].revision, 1); + assert.equal(reopened.receipts.verify(repaired[0]), true); + assert.equal(reopened.receipts.assertParity(), true); + assert.deepEqual(reopened.receipts.issueMissingTerminalReceipts(), []); + assert.deepEqual(reopened.store.readOne(`SELECT state, committed_atomic, released_atomic, + unresolved_atomic, committed_at FROM budget_reservations WHERE intent_id = ?`, ['intent-1']), beforeMoney); + assert.deepEqual(reopened.store.readOne(`SELECT state, transaction_id, settled_at + FROM payment_attempts WHERE intent_id = ?`, ['intent-1']), beforePayment); + const afterEvents = reopened.store.events().map((row) => ({ + sequence: row.sequence, + eventHash: row.event_hash, + eventType: row.event_type, + })); + assert.deepEqual(afterEvents.slice(0, beforeEvents.length), beforeEvents); + assert.deepEqual(afterEvents.slice(beforeEvents.length).map((event) => event.eventType), [ + 'receipt.issued', + ]); + assert.equal(reopened.store.readOne( + 'SELECT COUNT(*) AS count FROM buyer_outcomes WHERE intent_id = ?', ['intent-1'], + ).count, 1n); + assert.equal(reopened.store.verifyEventChain(), true); +}); + +test('parity binds every signed receipt to one exact receipt event and fails closed on history gaps', (t) => { + const missingEvent = setupRepository(t); + seedSettledSuccess(missingEvent); + missingEvent.receipts.issueForTerminal({ intentId: 'intent-1' }); + missingEvent.store.execForTest("DELETE FROM events WHERE event_type = 'receipt.issued'"); + assert.throws(() => missingEvent.receipts.assertParity(), /receipt.*event|parity/i); + assert.throws(() => missingEvent.receipts.latest('intent-1'), /receipt.*event|parity/i); + assert.throws( + () => missingEvent.receipts.list({ sessionId: 'session-1', limit: 10 }), + /receipt.*event|parity/i, + ); + assert.throws( + () => missingEvent.receipts.issueForTerminal({ intentId: 'intent-1' }), + /receipt.*event|parity/i, + ); + + const skippedRevision = setupRepository(t); + seedSettledSuccess(skippedRevision); + skippedRevision.receipts.issueForTerminal({ intentId: 'intent-1' }); + skippedRevision.store.execForTest( + "UPDATE buyer_outcomes SET revision = 3, reason_code = 'IMPOSSIBLE_GAP' WHERE intent_id = 'intent-1'", + ); + assert.throws( + () => skippedRevision.receipts.issueMissingTerminalReceipts(), + /cannot be reconstructed|revision/i, + ); + assert.equal(skippedRevision.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', ['intent-1'], + ).count, 1n); + + const substitutedOutcome = setupRepository(t); + seedSettledSuccess(substitutedOutcome); + substitutedOutcome.receipts.issueForTerminal({ intentId: 'intent-1' }); + substitutedOutcome.store.execForTest( + "UPDATE buyer_outcomes SET reason_code = 'SUBSTITUTED_REASON' WHERE intent_id = 'intent-1'", + ); + assert.throws( + () => substitutedOutcome.receipts.assertParity(), + /disagrees|parity|projection|reason/i, + ); +}); + +test('closed projections reject contradictory authority, impossible clocks, and re-signed invalid shapes', (t) => { + const clock = { value: NOW }; + const signer = createReceiptSigner(); + let signCalls = 0; + const countingSigner = Object.freeze({ + ...signer, + signHash(hashHex) { + signCalls += 1; + return signer.signHash(hashHex); + }, + }); + const regressed = setupRepository(t, { clock, signer: countingSigner }); + seedSettledSuccess(regressed); + clock.value = '2026-07-31T12:00:00.000Z'; + assert.throws( + () => regressed.receipts.issueForTerminal({ intentId: 'intent-1' }), + /predates|time|issuedAt/i, + ); + assert.equal(signCalls, 0); + assert.equal(regressed.store.readOne('SELECT COUNT(*) AS count FROM signed_receipts').count, 0n); + + const contradictory = setupRepository(t); + seedSettledSuccess(contradictory); + contradictory.store.execForTest(`UPDATE buyer_outcomes + SET status = 'refunded', reason_code = 'REFUND_CONFIRMED' WHERE intent_id = 'intent-1'`); + assert.throws( + () => contradictory.receipts.issueForTerminal({ intentId: 'intent-1' }), + /projection|outcome|refund|contradict/i, + ); + assert.equal(contradictory.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts', + ).count, 0n); + + const valid = setupRepository(t, { signer }); + seedSettledSuccess(valid); + const record = valid.receipts.issueForTerminal({ intentId: 'intent-1' }); + for (const mutate of [ + (receipt) => { receipt.payment.state = 'unresolved'; }, + (receipt) => { + receipt.execution = { state: 'none', httpStatus: 200, responseHash: null }; + }, + (receipt) => { + receipt.outcome = { status: 'completed', reasonCode: 'PAYMENT_SETTLED' }; + receipt.execution = { + state: 'failed', + httpStatus: 500, + responseHash: sha256('failed'), + }; + }, + (receipt) => { + receipt.outcome = { status: 'execution_unknown', reasonCode: 'PAID_RESPONSE_AMBIGUOUS' }; + receipt.execution = { + state: 'unknown', + httpStatus: 503, + responseHash: sha256('known-failure'), + }; + }, + ]) { + const receipt = structuredClone(record.receipt); + mutate(receipt); + const resigned = { ...record, ...signReceipt(signer, receipt), receipt }; + assert.equal(valid.receipts.verify(resigned), false); + } +}); + +test('terminal reason codes enforce their exact closed authority and execution projections', (t) => { + const signer = createReceiptSigner(); + const context = setupRepository(t, { signer }); + const noPayment = ({ status, reasonCode, execution }) => { + const receipt = receiptFixture(); + receipt.outcome = { status, reasonCode }; + receipt.policy = null; + receipt.approval = { state: 'not_required', operatorIdHash: null }; + receipt.payment = { state: 'none' }; + receipt.execution = execution; + receipt.budget = null; + receipt.reconciliation = null; + receipt.refund = null; + return receipt; + }; + const noExecution = { state: 'none', httpStatus: null, responseHash: null }; + const ordinarySuccess = noPayment({ + status: 'completed', + reasonCode: 'ORDINARY_SUCCESS', + execution: { + state: 'succeeded', + httpStatus: 200, + responseHash: sha256('ordinary-success'), + }, + }); + const ordinaryHttpFailure = noPayment({ + status: 'upstream_failed', + reasonCode: 'ORDINARY_HTTP_FAILURE', + execution: { + state: 'failed', + httpStatus: 503, + responseHash: sha256('ordinary-http-failure'), + }, + }); + const transportFailure = noPayment({ + status: 'upstream_failed', + reasonCode: 'UPSTREAM_TRANSPORT_FAILURE', + execution: { state: 'unknown', httpStatus: null, responseHash: null }, + }); + const approvedRetryTransportFailure = structuredClone(transportFailure); + approvedRetryTransportFailure.policy = { + versionId: 'policy-1', + decision: 'approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + }; + approvedRetryTransportFailure.approval = { + state: 'approved', + operatorIdHash: sha256('approved-retry-operator'), + }; + const malformedChallenge = noPayment({ + status: 'payment_denied', + reasonCode: 'PAYMENT_CHALLENGE_MALFORMED', + execution: noExecution, + }); + const policyDenied = noPayment({ + status: 'payment_denied', + reasonCode: 'POLICY_DENIED', + execution: noExecution, + }); + policyDenied.policy = { + versionId: 'policy-1', + decision: 'deny', + reasonCode: 'POLICY_DENIED', + }; + const operatorDenied = noPayment({ + status: 'payment_denied', + reasonCode: 'OPERATOR_DENIED', + execution: noExecution, + }); + operatorDenied.policy = { + versionId: 'policy-1', + decision: 'approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + }; + operatorDenied.approval = { state: 'denied', operatorIdHash: sha256('operator-denied') }; + const approvalExpired = structuredClone(operatorDenied); + approvalExpired.outcome.reasonCode = 'APPROVAL_EXPIRED'; + approvalExpired.approval = { state: 'expired', operatorIdHash: null }; + const cancelled = structuredClone(operatorDenied); + cancelled.outcome.reasonCode = 'APPROVAL_CHALLENGE_CHANGED'; + cancelled.approval = { state: 'cancelled', operatorIdHash: null }; + const signingFailed = receiptFixture(); + signingFailed.outcome = { status: 'payment_failed', reasonCode: 'SIGNER_REJECTED' }; + signingFailed.payment = { + ...signingFailed.payment, + state: 'not_signed', + transactionId: null, + }; + signingFailed.execution = noExecution; + signingFailed.budget = { disposition: 'released', amountAtomic: '50000' }; + const abandonedBeforeApproval = noPayment({ + status: 'payment_failed', + reasonCode: 'RECOVERY_ABANDONED_UNSIGNED', + execution: noExecution, + }); + abandonedBeforeApproval.policy = { + versionId: 'policy-1', + decision: 'approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + }; + + const contradictions = [ + { + name: 'unknown stable-looking reason', + valid: ordinarySuccess, + mutate(receipt) { receipt.outcome.reasonCode = 'UNKNOWN_TERMINAL_REASON'; }, + }, + { + name: 'ordinary success paired with HTTP-failure reason', + valid: ordinarySuccess, + mutate(receipt) { receipt.outcome.reasonCode = 'ORDINARY_HTTP_FAILURE'; }, + }, + { + name: 'ordinary HTTP failure with transport-unknown execution', + valid: ordinaryHttpFailure, + mutate(receipt) { + receipt.execution = { state: 'unknown', httpStatus: null, responseHash: null }; + }, + }, + { + name: 'ordinary HTTP failure with redirect status', + valid: ordinaryHttpFailure, + mutate(receipt) { receipt.execution.httpStatus = 302; }, + }, + { + name: 'transport failure inventing an HTTP response', + valid: transportFailure, + mutate(receipt) { + receipt.execution = { + state: 'failed', + httpStatus: 503, + responseHash: sha256('invented-transport-response'), + }; + }, + }, + { + name: 'approved retry transport failure cannot cancel historical approval', + valid: approvedRetryTransportFailure, + mutate(receipt) { + receipt.approval = { state: 'cancelled', operatorIdHash: null }; + }, + }, + { + name: 'approved retry transport failure cannot consume spend authority', + valid: approvedRetryTransportFailure, + mutate(receipt) { receipt.approval.state = 'consumed'; }, + }, + { + name: 'approved retry transport failure requires its approval-required decision', + valid: approvedRetryTransportFailure, + mutate(receipt) { receipt.policy = null; }, + }, + { + name: 'approved retry transport failure cannot invent payment authority', + valid: approvedRetryTransportFailure, + mutate(receipt) { + receipt.payment = { + state: 'not_signed', + amountAtomic: '50000', + network: NETWORK, + asset: ASSET, + payTo: PAY_TO, + transactionId: null, + }; + receipt.budget = { disposition: 'released', amountAtomic: '50000' }; + }, + }, + { + name: 'pre-policy malformed challenge with invented policy decision', + valid: malformedChallenge, + mutate(receipt) { + receipt.policy = { + versionId: 'policy-1', decision: 'deny', reasonCode: 'POLICY_DENIED', + }; + }, + }, + { + name: 'policy denial without its deny decision', + valid: policyDenied, + mutate(receipt) { receipt.policy = null; }, + }, + { + name: 'operator denial without authenticated denied approval', + valid: operatorDenied, + mutate(receipt) { receipt.approval = { state: 'expired', operatorIdHash: null }; }, + }, + { + name: 'approval expiry mislabeled as cancellation', + valid: approvalExpired, + mutate(receipt) { receipt.approval = { state: 'cancelled', operatorIdHash: null }; }, + }, + { + name: 'guarded cancellation retaining unsigned payment authority', + valid: cancelled, + mutate(receipt) { + receipt.payment = { + state: 'not_signed', + amountAtomic: '50000', + network: NETWORK, + asset: ASSET, + payTo: PAY_TO, + transactionId: null, + }; + receipt.budget = { disposition: 'released', amountAtomic: '50000' }; + }, + }, + { + name: 'signer failure without its released reservation', + valid: signingFailed, + mutate(receipt) { + receipt.payment = { state: 'none' }; + receipt.budget = null; + }, + }, + { + name: 'recovery-before-approval cannot invent a denied approval', + valid: abandonedBeforeApproval, + mutate(receipt) { + receipt.approval = { state: 'denied', operatorIdHash: sha256('invented-operator') }; + }, + }, + ]; + + for (const contradiction of contradictions) { + assert.equal( + context.receipts.verify(signedRecordForReceipt(signer, contradiction.valid)), + true, + `${contradiction.name} valid control`, + ); + const invalid = structuredClone(contradiction.valid); + contradiction.mutate(invalid); + assert.equal( + context.receipts.verify(signedRecordForReceipt(signer, invalid)), + false, + contradiction.name, + ); + } + + let signCalls = 0; + const countingSigner = Object.freeze({ + ...signer, + signHash(hashHex) { + signCalls += 1; + return signer.signHash(hashHex); + }, + }); + const persisted = setupRepository(t, { signer: countingSigner }); + seedScenario(persisted, { + name: 'unknown reason authority row', + status: 'completed', + reasonCode: 'UNKNOWN_TERMINAL_REASON', + executionState: 'succeeded', + expectedPayment: 'none', + expectedBudget: null, + }); + assert.throws( + () => persisted.receipts.issueForTerminal({ intentId: 'intent-1' }), + /reason|projection|unsupported/i, + ); + assert.equal(signCalls, 0); +}); + +test('standalone verification closes revision, refund, and reconciliation semantics', (t) => { + const signer = createReceiptSigner(); + const context = setupRepository(t, { signer }); + const expectInvalid = (name, validReceipt, mutate) => { + assert.equal( + context.receipts.verify(signedRecordForReceipt(signer, validReceipt)), + true, + `${name} valid control`, + ); + const invalid = structuredClone(validReceipt); + mutate(invalid); + assert.equal( + context.receipts.verify(signedRecordForReceipt(signer, invalid)), + false, + name, + ); + }; + + expectInvalid('revision one cannot claim a predecessor', receiptFixture(), (receipt) => { + receipt.supersedesReceiptHash = 'a'.repeat(64); + }); + expectInvalid('revision two requires a predecessor', { + ...receiptFixture(), + revision: 2, + supersedesReceiptHash: 'a'.repeat(64), + }, (receipt) => { + receipt.supersedesReceiptHash = null; + }); + + const confirmedRefund = receiptFixture(); + confirmedRefund.revision = 2; + confirmedRefund.supersedesReceiptHash = 'a'.repeat(64); + confirmedRefund.outcome = { status: 'refunded', reasonCode: 'REFUND_CONFIRMED' }; + confirmedRefund.execution = { + state: 'failed', + httpStatus: 503, + responseHash: sha256('failed-before-refund'), + }; + confirmedRefund.budget = { disposition: 'released', amountAtomic: '50000' }; + confirmedRefund.reconciliation = { + kind: 'refund', + outcome: 'refund_confirmed', + operatorIdHash: sha256('refund-operator'), + recordedAt: NOW, + }; + confirmedRefund.refund = { + state: 'confirmed', + amountAtomic: '50000', + transactionId: `0x${'cd'.repeat(32)}`, + }; + for (const [name, mutate] of [ + ['confirmed refund requires its transaction', (receipt) => { + receipt.refund.transactionId = null; + }], + ['confirmed refund amount equals payment and budget', (receipt) => { + receipt.refund.amountAtomic = '40000'; + }], + ['confirmed refund transaction differs from the original payment', (receipt) => { + receipt.refund.transactionId = receipt.payment.transactionId; + }], + ['confirmed refund requires matching reconciliation', (receipt) => { + receipt.reconciliation = null; + }], + ['confirmed refund cannot be an initial revision', (receipt) => { + receipt.revision = 1; + receipt.supersedesReceiptHash = null; + }], + ]) expectInvalid(name, confirmedRefund, mutate); + + const pendingRefund = structuredClone(confirmedRefund); + pendingRefund.revision = 1; + pendingRefund.supersedesReceiptHash = null; + pendingRefund.outcome = { status: 'execution_failed', reasonCode: 'UPSTREAM_HTTP_FAILURE' }; + pendingRefund.budget = { disposition: 'committed', amountAtomic: '50000' }; + pendingRefund.reconciliation = null; + pendingRefund.refund = { state: 'pending', amountAtomic: '50000', transactionId: null }; + for (const [name, mutate] of [ + ['pending refund cannot expose a transaction', (receipt) => { + receipt.refund.transactionId = `0x${'ef'.repeat(32)}`; + }], + ['pending refund amount equals committed payment', (receipt) => { + receipt.refund.amountAtomic = '40000'; + }], + ['pending refund keeps its initial failure reason', (receipt) => { + receipt.outcome.reasonCode = 'REFUND_UNRESOLVED'; + }], + ]) expectInvalid(name, pendingRefund, mutate); + + const unresolvedRefund = structuredClone(pendingRefund); + unresolvedRefund.outcome.reasonCode = 'REFUND_UNRESOLVED'; + unresolvedRefund.refund.state = 'unresolved'; + expectInvalid('unresolved refund cannot masquerade as pending', unresolvedRefund, (receipt) => { + receipt.outcome.reasonCode = 'UPSTREAM_HTTP_FAILURE'; + }); + + const paymentReconciliation = receiptFixture(); + paymentReconciliation.revision = 2; + paymentReconciliation.supersedesReceiptHash = 'b'.repeat(64); + paymentReconciliation.outcome = { + status: 'execution_unknown', + reasonCode: 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + }; + paymentReconciliation.execution = { state: 'unknown', httpStatus: null, responseHash: null }; + paymentReconciliation.reconciliation = { + kind: 'payment', + outcome: 'settled', + operatorIdHash: sha256('payment-reconciliation-operator'), + recordedAt: NOW, + }; + for (const [name, mutate] of [ + ['trusted reconciliation requires a superseding revision', (receipt) => { + receipt.revision = 1; + receipt.supersedesReceiptHash = null; + }], + ['reconciliation kind closes its outcome vocabulary', (receipt) => { + receipt.reconciliation = { ...receipt.reconciliation, kind: 'refund' }; + }], + ['settled payment reconciliation requires settled payment', (receipt) => { + receipt.reconciliation = { ...receipt.reconciliation, outcome: 'rejected' }; + }], + ]) expectInvalid(name, paymentReconciliation, mutate); + + const executionReconciliation = receiptFixture(); + executionReconciliation.revision = 2; + executionReconciliation.supersedesReceiptHash = 'c'.repeat(64); + executionReconciliation.outcome = { + status: 'completed', + reasonCode: 'EXECUTION_RECONCILED_SUCCEEDED', + }; + executionReconciliation.reconciliation = { + kind: 'execution', + outcome: 'execution_succeeded', + operatorIdHash: sha256('execution-reconciliation-operator'), + recordedAt: NOW, + }; + expectInvalid('execution reconciliation must agree with execution', executionReconciliation, + (receipt) => { + receipt.reconciliation = { ...receipt.reconciliation, outcome: 'execution_failed' }; + }); +}); + +test('approved-retry transport failure issues only its narrow no-spend receipt projection', (t) => { + const context = setupRepository(t); + seedScenario(context, { + name: 'approved-retry-transport-failure', + status: 'upstream_failed', + reasonCode: 'UPSTREAM_TRANSPORT_FAILURE', + policyDecision: 'approval_required', + policyReason: 'HUMAN_APPROVAL_REQUIRED', + approvalState: 'approved', + approvalReason: null, + executionState: 'unknown', + httpStatus: null, + expectedPayment: 'none', + expectedBudget: null, + }); + + const signed = context.receipts.issueForTerminal({ intentId: 'intent-1' }); + + assert.equal(context.receipts.verify(signed), true); + assert.deepEqual(signed.receipt.policy, { + versionId: 'policy-1', + decision: 'approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + }); + assert.equal(signed.receipt.approval.state, 'approved'); + assert.match(signed.receipt.approval.operatorIdHash, /^sha256:[0-9a-f]{64}$/); + assert.deepEqual(signed.receipt.payment, { state: 'none' }); + assert.equal(signed.receipt.budget, null); + assert.deepEqual(signed.receipt.execution, { + state: 'unknown', + httpStatus: null, + responseHash: null, + }); + assert.equal(signed.receipt.reconciliation, null); + assert.equal(signed.receipt.refund, null); + assert.equal(context.receipts.assertParity(), true); +}); + +test('normal issuance preserves global parity and multi-gap recovery is all-or-nothing', (t) => { + const context = setupRepository(t); + for (const [intentId, status, reasonCode, executionState] of [ + ['intent-a', 'completed', 'ORDINARY_SUCCESS', 'succeeded'], + ['intent-b', 'upstream_failed', 'UPSTREAM_TRANSPORT_FAILURE', 'unknown'], + ]) seedScenario(context, { + intentId, + requestId: `request-${intentId}`, + name: intentId, + status, + reasonCode, + executionState, + expectedPayment: 'none', + expectedBudget: null, + }); + assert.throws( + () => context.receipts.issueForTerminal({ intentId: 'intent-a' }), + /parity|missing.*current/i, + ); + assert.equal(context.store.readOne('SELECT COUNT(*) AS count FROM signed_receipts').count, 0n); + const repaired = context.receipts.issueMissingTerminalReceipts(); + assert.equal(repaired.length, 2); + assert.equal(context.receipts.assertParity(), true); + + const baseSigner = createReceiptSigner(); + let calls = 0; + const secondFailure = Object.freeze({ + ...baseSigner, + signHash(hashHex) { + calls += 1; + if (calls === 2) throw new Error('second recovery signature failed'); + return baseSigner.signHash(hashHex); + }, + }); + const rollback = setupRepository(t, { signer: secondFailure }); + for (const intentId of ['intent-a', 'intent-b']) seedScenario(rollback, { + intentId, + requestId: `request-${intentId}`, + name: intentId, + status: 'completed', + reasonCode: 'ORDINARY_SUCCESS', + executionState: 'succeeded', + expectedPayment: 'none', + expectedBudget: null, + }); + const eventsBefore = rollback.store.events().length; + assert.throws( + () => rollback.receipts.issueMissingTerminalReceipts(), + /second recovery signature failed/, + ); + assert.equal(rollback.store.readOne('SELECT COUNT(*) AS count FROM signed_receipts').count, 0n); + assert.equal(rollback.store.events().length, eventsBefore); +}); + +test('repository authenticates the injected Ed25519 signer identity before use', (t) => { + const store = openKernelStore({ filePath: ':memory:', allowMemory: true, now: () => NOW }); + t.after(() => store.close()); + const signer = createReceiptSigner(); + assert.throws(() => createSignedReceiptRepository({ + store, + signer: Object.freeze({ ...signer, keyId: sha256('substituted-key-id') }), + idFactory: () => 'receipt-1', + now: () => NOW, + }), /key ID|SPKI|signer/i); + const rsa = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + assert.throws(() => createSignedReceiptRepository({ + store, + signer: Object.freeze({ + algorithm: 'Ed25519', + keyId: signer.keyId, + publicKeyPem: rsa.publicKey.export({ type: 'spki', format: 'pem' }).toString(), + signHash: signer.signHash, + }), + idFactory: () => 'receipt-1', + now: () => NOW, + }), /Ed25519|signer/i); +}); diff --git a/spikes/pi-wielder/tests/kernel-reconciliation.test.mjs b/spikes/pi-wielder/tests/kernel-reconciliation.test.mjs new file mode 100644 index 0000000..cecf948 --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-reconciliation.test.mjs @@ -0,0 +1,1654 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { createAgentEnrollmentRepository } from '../src/kernel/agent-enrollment.mjs'; +import { createBudgetLedger } from '../src/kernel/budget-ledger.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { createIntentRepository } from '../src/kernel/intent-builder.mjs'; +import { evaluateSpendPolicy, projectPaymentRequired } from '../src/kernel/policy-engine.mjs'; +import { createPolicyRepository } from '../src/kernel/policy-repository.mjs'; +import { createReceiptSigner } from '../src/kernel/receipt-signing.mjs'; +import { createReconciler } from '../src/kernel/recovery.mjs'; +import { createSignedReceiptRepository } from '../src/kernel/signed-receipts.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const NOW = '2026-07-31T12:10:00.000Z'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const SELLER = 'https://seller.example'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const REFUND_SOURCE = '0x3000000000000000000000000000000000000000'; +const OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const BASE_POLICY = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); + +function sequenceIds() { + const counts = new Map(); + return (kind) => { + const next = (counts.get(kind) ?? 0) + 1; + counts.set(kind, next); + return `${kind}-${next}`; + }; +} + +function paymentRequired(amount = '50000') { + return { + x402Version: 2, + error: 'not persisted', + resource: { + url: `${SELLER}/paid/infer`, + description: 'offline fixture', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: NETWORK, + asset: ASSET, + amount, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], + }; +} + +function currentPaymentCaseHash(store, intentId) { + const intent = store.readOne('SELECT * FROM spend_intents WHERE id = ?', [intentId]); + const attempt = store.readOne('SELECT * FROM payment_attempts WHERE intent_id = ?', [intentId]); + const budget = store.readOne('SELECT * FROM budget_reservations WHERE intent_id = ?', [intentId]); + const outcome = store.readOne('SELECT * FROM buyer_outcomes WHERE intent_id = ?', [intentId]); + const history = store.readAll(`SELECT * FROM payment_reconciliation_candidates + WHERE intent_id = ? ORDER BY rowid`, [intentId]); + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-reconciliation-case.v1', + intentId, + intentHash: intent.intent_hash, + attemptState: attempt.state, + budgetState: budget.state, + buyerOutcomeRevision: Number(outcome.revision), + history: history.map((row) => ({ + id: row.id, + transactionId: row.transaction_id, + state: row.state, + evidenceHash: row.evidence_json === null ? null : sha256(row.evidence_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + })); +} + +function bindExpectedIntentHash(reconciler, expectedIntentHash) { + return Object.freeze({ + reconcilePayment: (input) => reconciler.reconcilePayment({ + expectedIntentHash, + ...input, + }), + reconcileExecution: (input) => reconciler.reconcileExecution({ + expectedIntentHash, + ...input, + }), + observeRefund: (input) => reconciler.observeRefund({ + expectedIntentHash, + ...input, + }), + abandonCandidate: (input) => reconciler.abandonCandidate(input), + }); +} + +function setupUnresolved(t, observePayment, { + observeExecution = () => ({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + observeRefund = () => ({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + minimumConfirmations, +} = {}) { + const clock = { value: NOW }; + const now = () => clock.value; + const idFactory = sequenceIds(); + const store = openKernelStore({ filePath: ':memory:', allowMemory: true, now }); + t.after(() => store.close()); + const policyDocument = structuredClone(BASE_POLICY); + policyDocument.sellers[0].autoApproveAtomic = '1000000'; + policyDocument.sellers[0].perRequestMaxAtomic = '1000000'; + policyDocument.sellers[0].humanApproveAtomic = '1000000'; + policyDocument.sellers[0].sellerSessionMaxAtomic = '1000000'; + policyDocument.sessionMaxAtomic = '2000000'; + policyDocument.rolling24hMaxAtomic = '5000000'; + const policies = createPolicyRepository(store); + const policyVersion = policies.apply(policyDocument, NOW).policyVersion; + const enrollments = createAgentEnrollmentRepository({ store, now }); + enrollments.enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const intents = createIntentRepository({ + store, + idFactory, + now, + routeMetadata: Object.freeze({ + 'paid-infer': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), + }), + }); + const session = intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: policyVersion.id, + }); + const request = { + routeId: 'paid-infer', + method: 'POST', + requestUrl: `${SELLER}/paid/infer`, + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from('{}'), + purposeLabel: 'skill.invoke', + correlationId: 'reconcile-fixture', + }; + const intent = intents.captureIntent({ sessionId: session.id, ...request }); + const challenge = paymentRequired(); + intents.attachChallenge({ intentId: intent.id, paymentRequired: challenge, challengeReceivedAt: NOW }); + const evaluation = evaluateSpendPolicy({ + policy: policyVersion.policy, + policyVersion: { id: policyVersion.id, hash: policyVersion.hash }, + intent: { + id: intent.id, + method: request.method, + requestUrl: request.requestUrl, + sellerOrigin: SELLER, + resourcePath: '/paid/infer', + walletAddress: WALLET, + }, + wallet: { provider: 'deterministic', walletId: 'buyer', address: WALLET, network: NETWORK }, + paymentRequired: challenge, + challengeReceivedAtMs: Date.parse(NOW), + nowMs: Date.parse(NOW), + budgetSnapshot: { + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + pendingApprovalCount: 0, + }, + }); + store.transaction((token) => policies.recordDecisionInTransaction(token, { + intentId: intent.id, + policyVersionId: policyVersion.id, + evaluation, + decidedAt: NOW, + })); + intents.transition({ + intentId: intent.id, + expectedState: 'challenged', + nextState: 'authorized', + reasonCode: 'POLICY_ALLOWED', + }); + const budgets = createBudgetLedger({ store, now }); + budgets.reserve({ intentId: intent.id, amountAtomic: evaluation.amountCeilingAtomic }); + const projection = projectPaymentRequired(challenge); + const nonce = `0x${'11'.repeat(32)}`; + const paymentHeader = 'integration-payment-header'; + const paymentPayload = { + x402Version: 2, + resource: challenge.resource, + accepted: challenge.accepts[0], + payload: { + signature: `0x${'22'.repeat(65)}`, + authorization: { + from: WALLET, + to: PAY_TO, + value: evaluation.amountCeilingAtomic, + validAfter: '0', + validBefore: '1785502860', + nonce, + }, + }, + }; + store.transaction((token) => store.within(token, ({ db }) => { + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + payment_payload_json, payment_header, payment_hash, quote_id, nonce, + valid_after, valid_before, signing_claimed_at, signed_at, retry_started_at, + created_at, updated_at) + VALUES ('payment-1', ?, 'retrying', ?, 0, ?, ?, ?, ?, ?, '0', '1785502860', + ?, ?, ?, ?, ?)`).run( + intent.id, + canonicalJson(projection), + canonicalJson(paymentPayload), + paymentHeader, + sha256(Buffer.from(paymentHeader, 'ascii')), + evaluation.quoteId, + nonce, + NOW, + NOW, + NOW, + NOW, + NOW, + ); + })); + for (const [expectedState, nextState] of [ + ['authorized', 'reserved'], ['reserved', 'signing'], ['signing', 'signed'], ['signed', 'retrying'], + ]) { + intents.transition({ + intentId: intent.id, + expectedState, + nextState, + reasonCode: `TEST_${nextState.toUpperCase()}`, + }); + } + store.transaction((token) => { + budgets.holdUnresolvedInTransaction(token, { + intentId: intent.id, + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + }); + store.within(token, ({ db, appendEvent }) => { + db.prepare(`UPDATE payment_attempts + SET state = 'unresolved', reason_code = 'PAID_RESPONSE_AMBIGUOUS' + WHERE intent_id = ? AND state = 'retrying'`).run(intent.id); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_unresolved', 'PAID_RESPONSE_AMBIGUOUS', 1, ?)`).run(intent.id, NOW); + appendEvent({ + entityType: 'buyer_outcome', + entityId: intent.id, + eventType: 'buyer_outcome.recorded', + data: { + status: 'payment_unresolved', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + revision: 1, + recordedAt: NOW, + }, + }); + }); + intents.transitionInTransaction(token, { + intentId: intent.id, + expectedState: 'retrying', + nextState: 'unresolved', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + }); + }); + const receipts = createSignedReceiptRepository({ + store, + signer: createReceiptSigner(), + idFactory, + now, + }); + receipts.issueForTerminal({ intentId: intent.id }); + let leaseDepth = 0; + const authorityMutationCoordinator = Object.freeze({ + runExclusive(operation) { + leaseDepth += 1; + try { return Promise.resolve(operation()); } finally { leaseDepth -= 1; } + }, + }); + const resolver = Object.freeze({ + observePayment: (binding) => { + assert.equal(leaseDepth, 0); + return observePayment(binding); + }, + observeExecution: (binding) => { + assert.equal(leaseDepth, 0); + assertPersistedSellerAuthority(binding); + return observeExecution(binding); + }, + observeRefund: (binding) => { + assert.equal(leaseDepth, 0); + assertPersistedSellerAuthority(binding); + return observeRefund(binding); + }, + }); + const rawReconciler = createReconciler({ + store, + budgets, + receipts, + resolver, + now, + idFactory, + authorityMutationCoordinator, + markAuthorityUnhealthy: () => undefined, + ...(minimumConfirmations === undefined ? {} : { minimumConfirmations }), + }); + const reconciler = bindExpectedIntentHash(rawReconciler, intent.intentHash); + return { budgets, clock, intent, receipts, reconciler, session, store }; +} + +function assertPersistedSellerAuthority(binding) { + assert.equal(binding.resourcePath, '/paid/infer'); + assert.equal(binding.policyVersion.id.startsWith('policy-'), true); + assert.equal( + binding.policyVersion.hash, + sha256(canonicalJson(binding.policyVersion.policy)), + ); + assert.deepEqual(binding.seller, binding.policyVersion.policy.sellers[0]); + assert.equal(binding.seller.origin, binding.sellerOrigin); + assert.equal( + binding.seller.pathPrefixes.some((prefix) => binding.resourcePath.startsWith(prefix)), + true, + ); + assert.equal(Object.isFrozen(binding.policyVersion), true); + assert.equal(Object.isFrozen(binding.policyVersion.policy), true); + assert.equal(Object.isFrozen(binding.seller), true); +} + +function settledPaymentObservation(binding) { + return Object.freeze({ + kind: 'settled_transfer', + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: binding.candidate.transactionId, + blockHash: `0x${'71'.repeat(32)}`, + blockNumber: '1234571', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 4, + authorizationLogIndex: 5, + tokenContract: ASSET, + from: WALLET, + to: PAY_TO, + valueAtomic: '50000', + authorizationNonce: `0x${'11'.repeat(32)}`, + observedAt: NOW, + }), + }); +} + +function failedExecutionObservation(binding) { + const attestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.execution.v1', + network: NETWORK, + sellerOrigin: SELLER, + intentHash: binding.intentHash, + transactionId: binding.transactionId, + outcome: 'failed', + httpStatus: 503, + responseHash: null, + issuedAt: '2026-07-31T12:09:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + signer: PAY_TO, + }); + return Object.freeze({ + kind: 'execution_attested', + attestation, + attestationHash: sha256(canonicalJson(attestation)), + }); +} + +async function setupRefundPending(t, observeRefund) { + const context = setupUnresolved(t, settledPaymentObservation, { + observeExecution: failedExecutionObservation, + observeRefund, + }); + const payment = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: `0x${'72'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }); + const execution = await context.reconciler.reconcileExecution({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedExecutionCaseHash: payment.executionCaseHash, + }); + return { context, refundCaseHash: execution.refundCaseHash }; +} + +test('trusted reconciler exposes only the four operator workflows', () => { + const noop = () => undefined; + const reconciler = createReconciler({ + store: Object.freeze({ transaction: noop, within: noop }), + budgets: Object.freeze({ + resolvePaymentInTransaction: noop, + recordConfirmedRefundInTransaction: noop, + }), + receipts: Object.freeze({ + assertParityInTransaction: noop, + issueRevisionForTerminal: noop, + latest: noop, + }), + resolver: Object.freeze({ + observePayment: noop, + observeExecution: noop, + observeRefund: noop, + }), + now: () => '2026-07-31T12:10:00.000Z', + idFactory: (kind) => `${kind}-1`, + authorityMutationCoordinator: Object.freeze({ runExclusive: noop }), + markAuthorityUnhealthy: noop, + }); + + assert.deepEqual(Object.keys(reconciler), [ + 'reconcilePayment', + 'reconcileExecution', + 'observeRefund', + 'abandonCandidate', + ]); + assert.ok(Object.isFrozen(reconciler)); + for (const method of Object.values(reconciler)) assert.equal(typeof method, 'function'); +}); + +test('trusted reconciler rejects dependency and request extension fields', async () => { + const noop = () => undefined; + assert.throws(() => createReconciler({}), TypeError); + const reconciler = createReconciler({ + store: Object.freeze({ transaction: noop, within: noop }), + budgets: Object.freeze({ + resolvePaymentInTransaction: noop, + recordConfirmedRefundInTransaction: noop, + }), + receipts: Object.freeze({ + assertParityInTransaction: noop, + issueRevisionForTerminal: noop, + latest: noop, + }), + resolver: Object.freeze({ + observePayment: noop, + observeExecution: noop, + observeRefund: noop, + }), + now: () => '2026-07-31T12:10:00.000Z', + idFactory: (kind) => `${kind}-1`, + authorityMutationCoordinator: Object.freeze({ runExclusive: noop }), + markAuthorityUnhealthy: noop, + }); + + await assert.rejects( + reconciler.reconcilePayment({ + intentId: 'intent-1', + operatorIdHash: `sha256:${'ab'.repeat(32)}`, + expectedIntentHash: `sha256:${'ef'.repeat(32)}`, + paymentTransactionId: null, + expectedPaymentCaseHash: `sha256:${'cd'.repeat(32)}`, + evidence: {}, + }), + (error) => error?.code === 'RECONCILIATION_INPUT', + ); + await assert.rejects( + reconciler.reconcilePayment({ + intentId: 'intent-1', + operatorIdHash: `sha256:${'ab'.repeat(32)}`, + expectedPaymentCaseHash: `sha256:${'cd'.repeat(32)}`, + }), + (error) => error?.code === 'RECONCILIATION_INPUT', + ); + assert.throws( + () => createReconciler({ + store: Object.freeze({ transaction: noop, within: noop }), + budgets: Object.freeze({ + resolvePaymentInTransaction: noop, + recordConfirmedRefundInTransaction: noop, + }), + receipts: Object.freeze({ + assertParityInTransaction: noop, + issueRevisionForTerminal: noop, + latest: noop, + }), + resolver: Object.freeze({ + observePayment: noop, + observeExecution: noop, + observeRefund: noop, + }), + now: () => '2026-07-31T12:10:00.000Z', + idFactory: (kind) => `${kind}-1`, + authorityMutationCoordinator: Object.freeze({ runExclusive: noop }), + markAuthorityUnhealthy: noop, + minimumConfirmations: 0, + }), + TypeError, + ); +}); + +test('stale intent hashes reject payment, execution, and refund before evidence or mutation', async (t) => { + const calls = { payment: 0, execution: 0, refund: 0 }; + const context = setupUnresolved(t, (binding) => { + calls.payment += 1; + return settledPaymentObservation(binding); + }, { + observeExecution: (binding) => { + calls.execution += 1; + return failedExecutionObservation(binding); + }, + observeRefund: () => { + calls.refund += 1; + return Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }); + }, + }); + const staleIntentHash = `sha256:${'ee'.repeat(32)}`; + assert.notEqual(staleIntentHash, context.intent.intentHash); + + let eventCount = context.store.events().length; + await assert.rejects( + context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: staleIntentHash, + paymentTransactionId: `0x${'29'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }), + (error) => error?.code === 'RECONCILIATION_CONFLICT', + ); + assert.equal(calls.payment, 0); + assert.equal(context.store.events().length, eventCount); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM payment_reconciliation_candidates WHERE intent_id = ?', + [context.intent.id], + ).count, 0n); + + const payment = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: `0x${'2c'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }); + eventCount = context.store.events().length; + await assert.rejects( + context.reconciler.reconcileExecution({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: staleIntentHash, + expectedExecutionCaseHash: payment.executionCaseHash, + }), + (error) => error?.code === 'RECONCILIATION_CONFLICT', + ); + assert.equal(calls.execution, 0); + assert.equal(context.store.events().length, eventCount); + + const execution = await context.reconciler.reconcileExecution({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedExecutionCaseHash: payment.executionCaseHash, + }); + eventCount = context.store.events().length; + const refundCount = context.store.readOne( + 'SELECT COUNT(*) AS count FROM refunds WHERE intent_id = ?', + [context.intent.id], + ).count; + await assert.rejects( + context.reconciler.observeRefund({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: staleIntentHash, + refundTransactionId: `0x${'2d'.repeat(32)}`, + expectedRefundCaseHash: execution.refundCaseHash, + }), + (error) => error?.code === 'RECONCILIATION_CONFLICT', + ); + assert.equal(calls.refund, 0); + assert.equal(context.store.events().length, eventCount); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM refunds WHERE intent_id = ?', + [context.intent.id], + ).count, refundCount); +}); + +test('confirmation depth defaults to two and honors a stricter configured threshold', async (t) => { + const defaultContext = setupUnresolved(t, (binding) => { + const observation = settledPaymentObservation(binding); + return Object.freeze({ + ...observation, + rpcTransferProof: Object.freeze({ ...observation.rpcTransferProof, confirmations: 2 }), + }); + }); + const defaultResult = await defaultContext.reconciler.reconcilePayment({ + intentId: defaultContext.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: `0x${'2a'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(defaultContext.store, defaultContext.intent.id), + }); + assert.equal(defaultResult.status, 'execution_unknown'); + + let confirmations = 3; + const strictContext = setupUnresolved(t, (binding) => { + const observation = settledPaymentObservation(binding); + return Object.freeze({ + ...observation, + rpcTransferProof: Object.freeze({ ...observation.rpcTransferProof, confirmations }), + }); + }, { minimumConfirmations: 4 }); + const strictRequest = { + intentId: strictContext.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: `0x${'2b'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(strictContext.store, strictContext.intent.id), + }; + await assert.rejects( + strictContext.reconciler.reconcilePayment(strictRequest), + (error) => error?.code === 'RECONCILIATION_MISMATCH', + ); + assert.equal(strictContext.store.readOne( + 'SELECT COUNT(*) AS count FROM reconciliations WHERE intent_id = ?', + [strictContext.intent.id], + ).count, 0n); + + confirmations = 4; + strictRequest.expectedPaymentCaseHash = currentPaymentCaseHash( + strictContext.store, + strictContext.intent.id, + ); + const strictResult = await strictContext.reconciler.reconcilePayment(strictRequest); + assert.equal(strictResult.status, 'execution_unknown'); +}); + +test('payment candidate observation runs outside leases and abandonment only rotates history', async (t) => { + let observations = 0; + const context = setupUnresolved(t, () => { + observations += 1; + return Object.freeze({ kind: 'unknown', reasonCode: 'RPC_RECEIPT_MISSING' }); + }); + const initialCaseHash = currentPaymentCaseHash(context.store, context.intent.id); + const firstTransactionId = `0x${'31'.repeat(32)}`; + const first = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: firstTransactionId, + expectedPaymentCaseHash: initialCaseHash, + }); + + assert.equal(first.status, 'payment_unresolved'); + assert.notEqual(first.paymentCaseHash, initialCaseHash); + assert.equal(observations, 1); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', + [context.intent.id], + ).state, 'unresolved'); + assert.equal(context.store.readOne( + 'SELECT revision FROM buyer_outcomes WHERE intent_id = ?', + [context.intent.id], + ).revision, 1n); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', + [context.intent.id], + ).count, 1n); + + const abandoned = await context.reconciler.abandonCandidate({ + intentId: context.intent.id, + kind: 'payment', + operatorIdHash: OPERATOR_HASH, + expectedCaseHash: first.paymentCaseHash, + }); + assert.notEqual(abandoned.caseHash, first.paymentCaseHash); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_reconciliation_candidates WHERE transaction_id = ?', + [firstTransactionId], + ).state, 'abandoned'); + assert.equal(context.store.readOne( + 'SELECT revision FROM buyer_outcomes WHERE intent_id = ?', + [context.intent.id], + ).revision, 1n); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', + [context.intent.id], + ).count, 1n); + + const secondTransactionId = `0x${'32'.repeat(32)}`; + const second = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: secondTransactionId, + expectedPaymentCaseHash: abandoned.caseHash, + }); + assert.equal(second.status, 'payment_unresolved'); + assert.equal(observations, 2); + assert.deepEqual(context.store.readAll(`SELECT transaction_id, state + FROM payment_reconciliation_candidates WHERE intent_id = ? ORDER BY rowid`, [context.intent.id]) + .map((row) => ({ transactionId: row.transaction_id, state: row.state })), [ + { transactionId: firstTransactionId, state: 'abandoned' }, + { transactionId: secondTransactionId, state: 'pending' }, + ]); + assert.equal(context.store.verifyEventChain(), true); +}); + +test('a concurrent abandonment makes the resolver result stale with zero resolution writes', async (t) => { + let context; + context = setupUnresolved(t, async (binding) => { + await context.reconciler.abandonCandidate({ + intentId: binding.intentId, + kind: 'payment', + operatorIdHash: OPERATOR_HASH, + expectedCaseHash: binding.caseHash, + }); + return settledPaymentObservation(binding); + }); + const transactionId = `0x${'33'.repeat(32)}`; + + await assert.rejects( + context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: transactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }), + (error) => error?.code === 'RECONCILIATION_CONFLICT', + ); + + assert.equal(context.store.readOne( + 'SELECT state FROM payment_reconciliation_candidates WHERE transaction_id = ?', + [transactionId], + ).state, 'abandoned'); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM reconciliations WHERE intent_id = ?', + [context.intent.id], + ).count, 0n); + assert.equal(context.store.readOne( + 'SELECT revision FROM buyer_outcomes WHERE intent_id = ?', + [context.intent.id], + ).revision, 1n); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', + [context.intent.id], + ).count, 1n); + assert.equal(context.store.verifyEventChain(), true); +}); + +test('candidate abandonment rejects a clock behind persisted payment and refund authority', async (t) => { + await t.test('payment candidate', async (st) => { + const context = setupUnresolved(st, () => Object.freeze({ + kind: 'unknown', + reasonCode: 'RPC_RECEIPT_MISSING', + })); + const transactionId = `0x${'34'.repeat(32)}`; + const pending = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: transactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }); + context.clock.value = '2026-07-31T12:09:59.999Z'; + const beforeEvents = context.store.events().length; + + await assert.rejects( + context.reconciler.abandonCandidate({ + intentId: context.intent.id, + kind: 'payment', + operatorIdHash: OPERATOR_HASH, + expectedCaseHash: pending.paymentCaseHash, + }), + (error) => error?.code === 'RECONCILIATION_TIME', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_reconciliation_candidates WHERE transaction_id = ?', + [transactionId], + ).state, 'pending'); + assert.equal(context.store.events().length, beforeEvents); + }); + + await t.test('refund candidate', async (st) => { + const context = setupUnresolved(st, settledPaymentObservation, { + observeExecution: failedExecutionObservation, + }); + const payment = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: `0x${'35'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }); + const execution = await context.reconciler.reconcileExecution({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedExecutionCaseHash: payment.executionCaseHash, + }); + const refundTransactionId = `0x${'36'.repeat(32)}`; + const pending = await context.reconciler.observeRefund({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + refundTransactionId, + expectedRefundCaseHash: execution.refundCaseHash, + }); + context.clock.value = '2026-07-31T12:09:59.999Z'; + const beforeEvents = context.store.events().length; + + await assert.rejects( + context.reconciler.abandonCandidate({ + intentId: context.intent.id, + kind: 'refund-observation', + operatorIdHash: OPERATOR_HASH, + expectedCaseHash: pending.refundCaseHash, + }), + (error) => error?.code === 'RECONCILIATION_TIME', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM refunds WHERE refund_transaction_id = ?', + [refundTransactionId], + ).state, 'pending'); + assert.equal(context.store.events().length, beforeEvents); + }); +}); + +test('settled payment observation commits through the trusted budget API and signs revision two', async (t) => { + let conflictingReplay = false; + const context = setupUnresolved(t, (binding) => Object.freeze({ + kind: 'settled_transfer', + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: binding.candidate.transactionId, + blockHash: `0x${(conflictingReplay ? 'ce' : 'cd').repeat(32)}`, + blockNumber: '1234567', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 4, + authorizationLogIndex: 5, + tokenContract: ASSET, + from: WALLET, + to: PAY_TO, + valueAtomic: '50000', + authorizationNonce: `0x${'11'.repeat(32)}`, + observedAt: NOW, + }), + })); + const transactionId = `0x${'41'.repeat(32)}`; + const request = { + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: transactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }; + const result = await context.reconciler.reconcilePayment(request); + + assert.equal(result.status, 'execution_unknown'); + assert.equal(result.reasonCode, 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN'); + assert.equal(result.receipt.revision, 2); + assert.deepEqual({ + intent: context.store.readOne('SELECT state FROM spend_intents WHERE id = ?', [context.intent.id]).state, + payment: context.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', [context.intent.id], + ).state, + budget: context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [context.intent.id], + ).state, + execution: context.store.readOne( + 'SELECT state FROM execution_outcomes WHERE intent_id = ?', [context.intent.id], + ).state, + resolution: context.store.readOne( + 'SELECT state FROM execution_resolutions WHERE intent_id = ?', [context.intent.id], + ).state, + }, { + intent: 'terminal', + payment: 'settled', + budget: 'committed', + execution: 'unknown', + resolution: 'reconciliation_required', + }); + assert.equal(context.receipts.assertParity(), true); + assert.equal(context.store.verifyEventChain(), true); + assert.deepEqual( + context.store.events() + .filter((event) => event.entity_id === context.intent.id) + .map((event) => event.event_type) + .filter((eventType) => eventType === 'execution.recorded' + || eventType === 'execution_resolution.opened'), + ['execution.recorded', 'execution_resolution.opened'], + ); + + const eventCount = context.store.events().length; + const replay = await context.reconciler.reconcilePayment(request); + assert.equal(replay.status, result.status); + assert.equal(replay.reasonCode, result.reasonCode); + assert.equal(replay.executionCaseHash, result.executionCaseHash); + assert.equal(replay.receipt.receiptHash, result.receipt.receiptHash); + assert.equal(context.store.events().length, eventCount); + + conflictingReplay = true; + await assert.rejects( + context.reconciler.reconcilePayment(request), + (error) => error?.code === 'RECONCILIATION_CONFLICT', + ); + assert.equal(context.store.events().length, eventCount); +}); + +test('conclusive candidate rejection rotates the case but preserves the signed hold', async (t) => { + let observations = 0; + const context = setupUnresolved(t, (binding) => { + observations += 1; + if (observations > 2) { + return Object.freeze({ kind: 'unknown', reasonCode: 'RPC_RECEIPT_MISSING' }); + } + return Object.freeze({ + kind: 'payment_candidate_rejected', + rejectionProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: binding.candidate.transactionId, + blockHash: `0x${'51'.repeat(32)}`, + blockNumber: '1234568', + transactionStatus: 'reverted', + confirmations: 3, + reasonCode: 'TRANSACTION_REVERTED', + observedAt: NOW, + }), + }); + }); + const transactionId = `0x${'52'.repeat(32)}`; + const request = { + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: transactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }; + const result = await context.reconciler.reconcilePayment(request); + + assert.equal(result.status, 'payment_unresolved'); + assert.equal(result.reasonCode, 'PAYMENT_CANDIDATE_REJECTED'); + assert.equal(result.receipt.revision, 2); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_reconciliation_candidates WHERE transaction_id = ?', + [transactionId], + ).state, 'rejected'); + assert.deepEqual({ + payment: context.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', [context.intent.id], + ).state, + budget: context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [context.intent.id], + ).state, + outcome: context.store.readOne( + 'SELECT reason_code FROM buyer_outcomes WHERE intent_id = ?', [context.intent.id], + ).reason_code, + }, { + payment: 'unresolved', + budget: 'unresolved', + outcome: 'PAYMENT_CANDIDATE_REJECTED', + }); + assert.equal(context.budgets.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + assert.equal(context.receipts.assertParity(), true); + + const eventCount = context.store.events().length; + const replay = await context.reconciler.reconcilePayment(request); + assert.equal(replay.status, result.status); + assert.equal(replay.reasonCode, result.reasonCode); + assert.equal(replay.paymentCaseHash, result.paymentCaseHash); + assert.equal(replay.receipt.receiptHash, result.receipt.receiptHash); + assert.equal(context.store.events().length, eventCount); + + const replacementTransactionId = `0x${'53'.repeat(32)}`; + const replacement = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: replacementTransactionId, + expectedPaymentCaseHash: result.paymentCaseHash, + }); + assert.equal(replacement.status, 'payment_unresolved'); + assert.equal(replacement.receipt, null); + assert.deepEqual(context.store.readAll(`SELECT state, transaction_id + FROM payment_reconciliation_candidates WHERE intent_id = ? ORDER BY rowid`, + [context.intent.id]).map((row) => ({ + state: row.state, + transactionId: row.transaction_id, + })), [ + { state: 'rejected', transactionId }, + { state: 'pending', transactionId: replacementTransactionId }, + ]); + assert.equal(context.store.readOne( + 'SELECT revision FROM buyer_outcomes WHERE intent_id = ?', [context.intent.id], + ).revision, 2n); + assert.equal(context.receipts.assertParity(), true); +}); + +test('only exact post-expiry unused authorization releases a signed payment hold', async (t) => { + let conflictingReplay = false; + const context = setupUnresolved(t, () => Object.freeze({ + kind: 'authorization_unused_after_expiry', + network: NETWORK, + asset: ASSET, + payer: WALLET, + nonce: `0x${'11'.repeat(32)}`, + validBefore: '1785502860', + authorizationState: false, + observedBlockNumber: '1234570', + observedBlockHash: `0x${(conflictingReplay ? '55' : '54').repeat(32)}`, + observedBlockTimestamp: '1785502920', + confirmations: 3, + })); + context.clock.value = '2026-07-31T13:02:00.000Z'; + const request = { + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }; + const result = await context.reconciler.reconcilePayment(request); + assert.equal(result.status, 'payment_rejected'); + assert.equal(result.reasonCode, 'AUTHORIZATION_UNUSED_AFTER_EXPIRY'); + assert.equal(result.receipt.revision, 2); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [context.intent.id], + ).state, 'released'); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', [context.intent.id], + ).state, 'rejected'); + + const eventCount = context.store.events().length; + const replay = await context.reconciler.reconcilePayment(request); + assert.equal(replay.receipt.receiptHash, result.receipt.receiptHash); + assert.equal(context.store.events().length, eventCount); + conflictingReplay = true; + await assert.rejects( + context.reconciler.reconcilePayment(request), + (error) => error?.code === 'RECONCILIATION_CONFLICT', + ); + assert.equal(context.store.events().length, eventCount); +}); + +test('post-expiry unused authorization terminally rejects a retained pending candidate', async (t) => { + let observationCount = 0; + const context = setupUnresolved(t, () => { + observationCount += 1; + if (observationCount === 1) { + return Object.freeze({ kind: 'unknown', reasonCode: 'RPC_RECEIPT_MISSING' }); + } + return Object.freeze({ + kind: 'authorization_unused_after_expiry', + network: NETWORK, + asset: ASSET, + payer: WALLET, + nonce: `0x${'11'.repeat(32)}`, + validBefore: '1785502860', + authorizationState: false, + observedBlockNumber: '1234571', + observedBlockHash: `0x${'56'.repeat(32)}`, + observedBlockTimestamp: '1785502920', + confirmations: 3, + }); + }); + const transactionId = `0x${'57'.repeat(32)}`; + const pending = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: transactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }); + context.clock.value = '2026-07-31T13:02:00.000Z'; + + const result = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedPaymentCaseHash: pending.paymentCaseHash, + }); + assert.equal(result.status, 'payment_rejected'); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_reconciliation_candidates WHERE transaction_id = ?', + [transactionId], + ).state, 'rejected'); + assert.equal(context.store.events().filter((event) => ( + event.entity_type === 'payment_reconciliation_candidate' + && event.event_type === 'payment.candidate_rejected' + )).length, 1); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [context.intent.id], + ).state, 'released'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('verified execution failure opens one blocking full-refund case and revision', async (t) => { + let conflictingReplay = false; + const context = setupUnresolved( + t, + (binding) => Object.freeze({ + kind: 'settled_transfer', + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: binding.candidate.transactionId, + blockHash: `0x${'61'.repeat(32)}`, + blockNumber: '1234570', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 4, + authorizationLogIndex: 5, + tokenContract: ASSET, + from: WALLET, + to: PAY_TO, + valueAtomic: '50000', + authorizationNonce: `0x${'11'.repeat(32)}`, + observedAt: NOW, + }), + }), + { + observeExecution: (binding) => { + const attestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.execution.v1', + network: NETWORK, + sellerOrigin: SELLER, + intentHash: binding.intentHash, + transactionId: binding.transactionId, + outcome: 'failed', + httpStatus: 503, + responseHash: null, + issuedAt: conflictingReplay + ? '2026-07-31T12:08:00.000Z' + : '2026-07-31T12:09:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + signer: PAY_TO, + }); + return Object.freeze({ + kind: 'execution_attested', + attestation, + attestationHash: sha256(canonicalJson(attestation)), + }); + }, + }, + ); + const payment = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: `0x${'62'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }); + const request = { + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedExecutionCaseHash: payment.executionCaseHash, + }; + const result = await context.reconciler.reconcileExecution(request); + + assert.equal(result.status, 'execution_failed'); + assert.equal(result.reasonCode, 'REFUND_UNRESOLVED'); + assert.equal(result.receipt.revision, 3); + assert.equal(context.store.readOne( + 'SELECT state FROM execution_outcomes WHERE intent_id = ?', [context.intent.id], + ).state, 'failed'); + assert.equal(context.store.readOne( + 'SELECT state FROM execution_resolutions WHERE intent_id = ?', [context.intent.id], + ).state, 'refund_pending'); + assert.deepEqual(context.store.readOne( + `SELECT amount_atomic, state, refund_transaction_id FROM refunds + WHERE intent_id = ?`, [context.intent.id], + ), Object.assign(Object.create(null), { + amount_atomic: '50000', + state: 'pending', + refund_transaction_id: null, + })); + assert.equal(context.budgets.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + assert.equal(context.receipts.assertParity(), true); + assert.equal(context.store.events().some((event) => ( + event.entity_id === context.intent.id + && event.event_type === 'execution_resolution.opened' + && JSON.parse(event.data_json).state === 'refund_pending' + )), true); + assert.equal(context.store.events().some((event) => ( + event.event_type === 'refund.opened' + && JSON.parse(event.data_json).intentId === context.intent.id + )), true); + + const eventCount = context.store.events().length; + const replay = await context.reconciler.reconcileExecution(request); + assert.equal(replay.status, result.status); + assert.equal(replay.reasonCode, result.reasonCode); + assert.equal(replay.refundCaseHash, result.refundCaseHash); + assert.equal(replay.receipt.receiptHash, result.receipt.receiptHash); + assert.equal(context.store.events().length, eventCount); + conflictingReplay = true; + await assert.rejects( + context.reconciler.reconcileExecution(request), + (error) => error?.code === 'RECONCILIATION_CONFLICT', + ); + assert.equal(context.store.events().length, eventCount); +}); + +test('rejected refund candidate signs one revision and permits a named replacement', async (t) => { + let observations = 0; + const { context, refundCaseHash } = await setupRefundPending(t, (binding) => { + observations += 1; + if (observations > 2) { + return Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }); + } + return Object.freeze({ + kind: 'refund_candidate_rejected', + rejectionProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: binding.refundTransactionId, + blockHash: `0x${'73'.repeat(32)}`, + blockNumber: '1234572', + transactionStatus: 'reverted', + confirmations: 3, + reasonCode: 'TRANSACTION_REVERTED', + observedAt: NOW, + }), + }); + }); + const rejectedTransactionId = `0x${'74'.repeat(32)}`; + const request = { + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + refundTransactionId: rejectedTransactionId, + expectedRefundCaseHash: refundCaseHash, + }; + const rejected = await context.reconciler.observeRefund(request); + + assert.equal(rejected.status, 'execution_failed'); + assert.equal(rejected.reasonCode, 'REFUND_UNRESOLVED'); + assert.equal(rejected.receipt.revision, 4); + assert.equal(rejected.receipt.receipt.refund.state, 'rejected'); + assert.equal(context.store.readOne( + 'SELECT state FROM refunds WHERE refund_transaction_id = ?', [rejectedTransactionId], + ).state, 'rejected'); + assert.equal(context.budgets.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + + const eventCount = context.store.events().length; + const replay = await context.reconciler.observeRefund(request); + assert.equal(replay.status, rejected.status); + assert.equal(replay.reasonCode, rejected.reasonCode); + assert.equal(replay.refundCaseHash, rejected.refundCaseHash); + assert.equal(replay.receipt.receiptHash, rejected.receipt.receiptHash); + assert.equal(context.store.events().length, eventCount); + + const replacementTransactionId = `0x${'75'.repeat(32)}`; + const replacement = await context.reconciler.observeRefund({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + refundTransactionId: replacementTransactionId, + expectedRefundCaseHash: rejected.refundCaseHash, + }); + assert.equal(replacement.status, 'execution_failed'); + assert.equal(replacement.receipt, null); + assert.deepEqual(context.store.readAll(`SELECT state, refund_transaction_id + FROM refunds WHERE intent_id = ? ORDER BY rowid`, [context.intent.id]) + .map((row) => ({ state: row.state, transactionId: row.refund_transaction_id })), [ + { state: 'rejected', transactionId: rejectedTransactionId }, + { state: 'pending', transactionId: replacementTransactionId }, + ]); + assert.equal(context.receipts.assertParity(), true); +}); + +test('unknown refund candidates remain blocking through abandonment and replacement', async (t) => { + const { context, refundCaseHash } = await setupRefundPending( + t, + () => Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + ); + const abandonedTransactionId = `0x${'76'.repeat(32)}`; + const pending = await context.reconciler.observeRefund({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + refundTransactionId: abandonedTransactionId, + expectedRefundCaseHash: refundCaseHash, + }); + assert.equal(pending.status, 'execution_failed'); + assert.equal(pending.receipt, null); + + const abandoned = await context.reconciler.abandonCandidate({ + intentId: context.intent.id, + kind: 'refund-observation', + operatorIdHash: OPERATOR_HASH, + expectedCaseHash: pending.refundCaseHash, + }); + assert.notEqual(abandoned.caseHash, pending.refundCaseHash); + assert.equal(context.store.readOne( + 'SELECT state FROM refunds WHERE refund_transaction_id = ?', [abandonedTransactionId], + ).state, 'abandoned'); + assert.equal(context.budgets.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + + const replacementTransactionId = `0x${'77'.repeat(32)}`; + const replacement = await context.reconciler.observeRefund({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + refundTransactionId: replacementTransactionId, + expectedRefundCaseHash: abandoned.caseHash, + }); + assert.equal(replacement.receipt, null); + assert.deepEqual(context.store.readAll(`SELECT state, refund_transaction_id + FROM refunds WHERE intent_id = ? ORDER BY rowid`, [context.intent.id]) + .map((row) => ({ state: row.state, transactionId: row.refund_transaction_id })), [ + { state: 'abandoned', transactionId: abandonedTransactionId }, + { state: 'pending', transactionId: replacementTransactionId }, + ]); + assert.equal(context.store.readOne( + 'SELECT revision FROM buyer_outcomes WHERE intent_id = ?', [context.intent.id], + ).revision, 3n); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', [context.intent.id], + ).count, 3n); + assert.equal(context.receipts.assertParity(), true); + assert.equal(context.store.verifyEventChain(), true); +}); + +test('confirmed full refund releases the block and signs the exact revision', async (t) => { + let conflictingReplay = false; + const { context, refundCaseHash } = await setupRefundPending(t, (binding) => { + const attestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.refund.v1', + network: NETWORK, + sellerOrigin: SELLER, + intentHash: binding.intentHash, + originalTransactionId: binding.originalTransactionId, + refundTransactionId: binding.refundTransactionId, + asset: ASSET, + originalPayer: WALLET, + originalPayee: PAY_TO, + refundSource: REFUND_SOURCE, + amountAtomic: '50000', + issuedAt: '2026-07-31T12:09:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + signer: PAY_TO, + }); + return Object.freeze({ + kind: 'refund_attested_and_confirmed', + attestation, + attestationHash: sha256(canonicalJson(attestation)), + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: binding.refundTransactionId, + blockHash: `0x${(conflictingReplay ? '7b' : '78').repeat(32)}`, + blockNumber: '1234573', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 6, + tokenContract: ASSET, + from: REFUND_SOURCE, + to: WALLET, + valueAtomic: '50000', + observedAt: NOW, + }), + }); + }); + const refundTransactionId = `0x${'79'.repeat(32)}`; + const request = { + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + refundTransactionId, + expectedRefundCaseHash: refundCaseHash, + }; + const result = await context.reconciler.observeRefund(request); + + assert.equal(result.status, 'refunded'); + assert.equal(result.reasonCode, 'REFUND_CONFIRMED'); + assert.equal(result.receipt.revision, 4); + assert.deepEqual({ + refund: context.store.readOne( + 'SELECT state FROM refunds WHERE refund_transaction_id = ?', [refundTransactionId], + ).state, + budget: context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [context.intent.id], + ).state, + resolution: context.store.readOne( + 'SELECT state FROM execution_resolutions WHERE intent_id = ?', [context.intent.id], + ).state, + outcome: context.store.readOne( + 'SELECT status FROM buyer_outcomes WHERE intent_id = ?', [context.intent.id], + ).status, + }, { + refund: 'confirmed', + budget: 'released', + resolution: 'resolved', + outcome: 'refunded', + }); + assert.equal(result.receipt.receipt.refund.transactionId, refundTransactionId); + assert.equal(context.budgets.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, false); + assert.equal(context.receipts.assertParity(), true); + assert.equal(context.store.events().some((event) => ( + event.entity_id === context.intent.id + && event.event_type === 'execution_resolution.resolved' + )), true); + assert.equal(context.store.events().some((event) => ( + event.event_type === 'refund.confirmed' + && JSON.parse(event.data_json).intentId === context.intent.id + )), true); + assert.equal(context.store.verifyEventChain(), true); + + const eventCount = context.store.events().length; + const replay = await context.reconciler.observeRefund(request); + assert.equal(replay.status, result.status); + assert.equal(replay.reasonCode, result.reasonCode); + assert.equal(replay.receipt.receiptHash, result.receipt.receiptHash); + assert.equal(context.store.events().length, eventCount); + conflictingReplay = true; + await assert.rejects( + context.reconciler.observeRefund(request), + (error) => error?.code === 'RECONCILIATION_CONFLICT', + ); + assert.equal(context.store.events().length, eventCount); +}); + +test('refund evidence expires exactly at the post-resolver boundary', async (t) => { + let context; + const pending = await setupRefundPending(t, (binding) => { + context.clock.value = '2026-07-31T12:15:00.000Z'; + const attestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.refund.v1', + network: NETWORK, + sellerOrigin: SELLER, + intentHash: binding.intentHash, + originalTransactionId: binding.originalTransactionId, + refundTransactionId: binding.refundTransactionId, + asset: ASSET, + originalPayer: WALLET, + originalPayee: PAY_TO, + refundSource: REFUND_SOURCE, + amountAtomic: '50000', + issuedAt: '2026-07-31T12:09:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + signer: PAY_TO, + }); + return Object.freeze({ + kind: 'refund_attested_and_confirmed', + attestation, + attestationHash: sha256(canonicalJson(attestation)), + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: NETWORK, + transactionId: binding.refundTransactionId, + blockHash: `0x${'7e'.repeat(32)}`, + blockNumber: '1234574', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 7, + tokenContract: ASSET, + from: REFUND_SOURCE, + to: WALLET, + valueAtomic: '50000', + observedAt: NOW, + }), + }); + }); + context = pending.context; + const refundTransactionId = `0x${'7f'.repeat(32)}`; + + await assert.rejects( + context.reconciler.observeRefund({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + refundTransactionId, + expectedRefundCaseHash: pending.refundCaseHash, + }), + (error) => error?.code === 'RECONCILIATION_MISMATCH', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM refunds WHERE refund_transaction_id = ?', [refundTransactionId], + ).state, 'pending'); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM reconciliations WHERE intent_id = ?', [context.intent.id], + ).count, 2n); +}); + +test('verified successful execution resolves its case without inventing output', async (t) => { + const responseHash = sha256(Buffer.from('{"ok":true}', 'utf8')); + let conflictingReplay = false; + const context = setupUnresolved(t, settledPaymentObservation, { + observeExecution: (binding) => { + const attestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.execution.v1', + network: NETWORK, + sellerOrigin: SELLER, + intentHash: binding.intentHash, + transactionId: binding.transactionId, + outcome: 'succeeded', + httpStatus: 200, + responseHash, + issuedAt: conflictingReplay + ? '2026-07-31T12:08:00.000Z' + : '2026-07-31T12:09:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + signer: PAY_TO, + }); + return Object.freeze({ + kind: 'execution_attested', + attestation, + attestationHash: sha256(canonicalJson(attestation)), + }); + }, + }); + const payment = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: `0x${'7a'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }); + const request = { + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedExecutionCaseHash: payment.executionCaseHash, + }; + const result = await context.reconciler.reconcileExecution(request); + + assert.equal(result.status, 'completed'); + assert.equal(result.reasonCode, 'EXECUTION_RECONCILED_SUCCEEDED'); + assert.equal(result.receipt.revision, 3); + assert.equal(result.receipt.receipt.execution.responseHash, responseHash); + assert.equal(context.store.readOne( + 'SELECT state FROM execution_resolutions WHERE intent_id = ?', [context.intent.id], + ).state, 'resolved'); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM refunds WHERE intent_id = ?', [context.intent.id], + ).count, 0n); + assert.equal(context.budgets.snapshot({ + sessionId: context.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, false); + assert.equal(context.receipts.assertParity(), true); + assert.equal(context.store.events().some((event) => ( + event.entity_id === context.intent.id + && event.event_type === 'execution_resolution.resolved' + )), true); + + const eventCount = context.store.events().length; + const replay = await context.reconciler.reconcileExecution(request); + assert.equal(replay.status, result.status); + assert.equal(replay.reasonCode, result.reasonCode); + assert.equal(replay.receipt.receiptHash, result.receipt.receiptHash); + assert.equal(context.store.events().length, eventCount); + conflictingReplay = true; + await assert.rejects( + context.reconciler.reconcileExecution(request), + (error) => error?.code === 'RECONCILIATION_CONFLICT', + ); + assert.equal(context.store.events().length, eventCount); +}); + +test('execution evidence expires exactly at its boundary after a long-running resolver call', async (t) => { + let context; + context = setupUnresolved(t, settledPaymentObservation, { + observeExecution: (binding) => { + context.clock.value = '2026-07-31T12:15:00.000Z'; + return failedExecutionObservation(binding); + }, + }); + const payment = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: `0x${'7c'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }); + + await assert.rejects( + context.reconciler.reconcileExecution({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedExecutionCaseHash: payment.executionCaseHash, + }), + (error) => error?.code === 'RECONCILIATION_MISMATCH', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM execution_outcomes WHERE intent_id = ?', [context.intent.id], + ).state, 'unknown'); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM reconciliations WHERE intent_id = ?', [context.intent.id], + ).count, 1n); +}); + +test('resolver clock regression fails closed before reconciliation mutation', async (t) => { + let context; + context = setupUnresolved(t, settledPaymentObservation, { + observeExecution: (binding) => { + context.clock.value = '2026-07-31T12:09:59.999Z'; + return failedExecutionObservation(binding); + }, + }); + const payment = await context.reconciler.reconcilePayment({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + paymentTransactionId: `0x${'7d'.repeat(32)}`, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, context.intent.id), + }); + + await assert.rejects( + context.reconciler.reconcileExecution({ + intentId: context.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedExecutionCaseHash: payment.executionCaseHash, + }), + (error) => error?.code === 'RECONCILIATION_TIME', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM execution_outcomes WHERE intent_id = ?', [context.intent.id], + ).state, 'unknown'); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM reconciliations WHERE intent_id = ?', [context.intent.id], + ).count, 1n); +}); diff --git a/spikes/pi-wielder/tests/kernel-recovery.test.mjs b/spikes/pi-wielder/tests/kernel-recovery.test.mjs new file mode 100644 index 0000000..63144b2 --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-recovery.test.mjs @@ -0,0 +1,1981 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createIsolationAttestationRepository } from '../src/agent/isolation-preflight.mjs'; +import { createAgentEnrollmentRepository } from '../src/kernel/agent-enrollment.mjs'; +import { createApprovalQueue } from '../src/kernel/approval-queue.mjs'; +import { createBudgetLedger } from '../src/kernel/budget-ledger.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { createIntentRepository } from '../src/kernel/intent-builder.mjs'; +import { evaluateSpendPolicy } from '../src/kernel/policy-engine.mjs'; +import { createPolicyRepository } from '../src/kernel/policy-repository.mjs'; +import { createReceiptSigner } from '../src/kernel/receipt-signing.mjs'; +import { createReconciler, recoverKernelAuthority } from '../src/kernel/recovery.mjs'; +import { createSignedReceiptRepository } from '../src/kernel/signed-receipts.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const NOW = '2026-07-31T12:00:00.000Z'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const SELLER = 'https://seller.example'; +const OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const BASE_POLICY = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); +const CLOCKS = new WeakMap(); + +function sequenceIds() { + let value = 0; + return (kind) => `${kind}-${++value}`; +} + +function setup(t, { + fileAuthority = null, + signer = createReceiptSigner(), +} = {}) { + const clock = { value: NOW }; + const store = openKernelStore(fileAuthority ? { + filePath: fileAuthority.databasePath, + pathTrust: fileAuthority.pathTrust, + now: () => clock.value, + } : { + filePath: ':memory:', + allowMemory: true, + now: () => clock.value, + }); + t.after(() => store.close()); + const now = () => clock.value; + const intents = createIntentRepository({ + store, + idFactory: sequenceIds(), + now, + routeMetadata: Object.freeze({ + 'paid-infer': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), + }), + }); + const receipts = createSignedReceiptRepository({ + store, + signer, + idFactory: sequenceIds(), + now, + }); + const dependencies = { + store, + intents, + budgets: createBudgetLedger({ store, now }), + approvals: createApprovalQueue({ store, idFactory: sequenceIds(), now }), + receipts, + now, + }; + CLOCKS.set(dependencies, clock); + return dependencies; +} + +function temporaryFileAuthority(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-recovery-')); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return Object.freeze({ + databasePath: path.join(directory, 'kernel.sqlite'), + pathTrust: Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }), + }); +} + +function enforcedIsolationReport(enrollmentHash, { + expiresAt = '2026-07-31T12:15:00.000Z', +} = {}) { + return { + schemaVersion: 1, + enrollmentHash, + kernelUid: '502', + kernelGid: '20', + agentUid: DESCRIPTOR.agentUid, + agentGid: DESCRIPTOR.agentGid, + authorityMetadataHash: sha256('authority metadata'), + credentialMetadataHash: sha256('credential metadata'), + releaseManifestHash: sha256('release manifest'), + releaseTreeHash: sha256('release tree'), + nodeExecutableHash: sha256('node executable'), + serviceArtifactsHash: sha256('service artifacts'), + systemdEffectiveConfigHash: sha256('systemd effective config'), + environmentMetadataHash: sha256('environment metadata'), + probeResults: { + authorityDirectory: 'EACCES', + database: 'EACCES', + operatorToken: 'EACCES', + receiptKey: 'EACCES', + kernelEnvironment: 'EACCES', + agentCredential: 'READABLE', + releaseTreeWrite: 'EACCES', + dependencyTreeWrite: 'EACCES', + serviceArtifactsWrite: 'EACCES', + kernelEnvironmentParentWrite: 'EACCES', + }, + probedAt: NOW, + expiresAt, + }; +} + +function insertCurrentIsolation(context, enrollmentHash, report) { + const reportJson = canonicalJson(report); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO isolation_attestations + (id, report_hash, enrollment_hash, report_json, state, + imported_by_operator_hash, probed_at, expires_at, imported_at, superseded_at) + VALUES ('isolation-current', ?, ?, ?, 'current', ?, ?, ?, ?, NULL)`).run( + sha256(reportJson), + enrollmentHash, + reportJson, + OPERATOR_HASH, + report.probedAt, + report.expiresAt, + NOW, + ); + })); +} + +function seedCapturedAuthority(context) { + const policies = createPolicyRepository(context.store); + const policyVersion = policies.apply(structuredClone(BASE_POLICY), NOW).policyVersion; + const enrollments = createAgentEnrollmentRepository({ store: context.store, now: context.now }); + enrollments.enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const session = context.intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: policyVersion.id, + }); + const request = { + routeId: 'paid-infer', + method: 'POST', + requestUrl: `${SELLER}/paid/infer`, + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from('{}'), + purposeLabel: 'skill.invoke', + correlationId: 'recovery-fixture', + }; + const intent = context.intents.captureIntent({ sessionId: session.id, ...request }); + return { intent, policies, policyVersion, request, session }; +} + +function attachDecision(context, seeded, amount) { + const paymentRequired = { + x402Version: 2, + error: 'not persisted', + resource: { + url: `${SELLER}/paid/infer`, + description: 'offline fixture', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: seeded.policyVersion.policy.network, + asset: seeded.policyVersion.policy.asset, + amount, + payTo: seeded.policyVersion.policy.sellers[0].payTo, + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], + }; + context.intents.attachChallenge({ + intentId: seeded.intent.id, + paymentRequired, + challengeReceivedAt: NOW, + }); + seeded.challenge = paymentRequired; + const evaluation = evaluateSpendPolicy({ + policy: seeded.policyVersion.policy, + policyVersion: { id: seeded.policyVersion.id, hash: seeded.policyVersion.hash }, + intent: { + id: seeded.intent.id, + method: seeded.request.method, + requestUrl: seeded.request.requestUrl, + sellerOrigin: SELLER, + resourcePath: '/paid/infer', + walletAddress: WALLET, + }, + wallet: { + provider: 'deterministic', + walletId: 'buyer', + address: WALLET, + network: seeded.policyVersion.policy.network, + }, + paymentRequired, + challengeReceivedAtMs: Date.parse(NOW), + nowMs: Date.parse(NOW), + budgetSnapshot: { + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + pendingApprovalCount: 0, + }, + }); + context.store.transaction((token) => seeded.policies.recordDecisionInTransaction(token, { + intentId: seeded.intent.id, + policyVersionId: seeded.policyVersion.id, + evaluation, + decidedAt: NOW, + })); + return evaluation; +} + +function seedPaymentCrashGap(context, state) { + const seeded = seedCapturedAuthority(context); + const evaluation = attachDecision(context, seeded, '50000'); + assert.equal(evaluation.decision, 'allow'); + context.intents.transition({ + intentId: seeded.intent.id, + expectedState: 'challenged', + nextState: 'authorized', + reasonCode: 'POLICY_ALLOWED', + }); + context.budgets.reserve({ + intentId: seeded.intent.id, + amountAtomic: evaluation.amountCeilingAtomic, + }); + const projectionJson = context.store.readOne( + 'SELECT challenge_projection_json FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).challenge_projection_json; + const projection = JSON.parse(projectionJson); + const nonce = `0x${'11'.repeat(32)}`; + const paymentHeader = 'recovery-signed-payment-header'; + const paymentPayloadJson = canonicalJson({ + x402Version: 2, + resource: seeded.challenge.resource, + accepted: seeded.challenge.accepts[0], + payload: { + signature: `0x${'22'.repeat(65)}`, + authorization: { + from: WALLET, + to: projection.accepts[0].payTo, + value: evaluation.amountCeilingAtomic, + validAfter: '0', + validBefore: '1785502860', + nonce, + }, + }, + }); + const claimed = state !== 'reserved'; + const signed = state === 'signed' || state === 'retrying'; + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + payment_payload_json, payment_header, payment_hash, quote_id, nonce, + valid_after, valid_before, signing_claimed_at, signed_at, retry_started_at, + created_at, updated_at) + VALUES ('payment-recovery', ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + seeded.intent.id, + state, + projectionJson, + signed ? paymentPayloadJson : null, + signed ? paymentHeader : null, + signed ? sha256(Buffer.from(paymentHeader, 'ascii')) : null, + evaluation.quoteId, + claimed ? nonce : null, + claimed ? '0' : null, + claimed ? '1785502860' : null, + claimed ? NOW : null, + signed ? NOW : null, + state === 'retrying' ? NOW : null, + NOW, + NOW, + ); + })); + const transitions = [ + ['authorized', 'reserved'], + ['reserved', 'signing'], + ['signing', 'signed'], + ['signed', 'retrying'], + ]; + const finalIndex = transitions.findIndex(([, next]) => next === state); + for (const [expectedState, nextState] of transitions.slice(0, finalIndex + 1)) { + context.intents.transition({ + intentId: seeded.intent.id, + expectedState, + nextState, + reasonCode: `TEST_${nextState.toUpperCase()}`, + }); + } + return { ...seeded, paymentHeader, paymentPayloadJson }; +} + +function seedPendingApproval(context) { + const seeded = seedCapturedAuthority(context); + const evaluation = attachDecision(context, seeded, '200000'); + assert.equal(evaluation.decision, 'approval_required'); + const approval = context.approvals.request(Object.freeze({ + intentId: seeded.intent.id, + intentHash: seeded.intent.intentHash, + challengeHash: evaluation.challengeHash, + quoteId: evaluation.quoteId, + amountCeilingAtomic: evaluation.amountCeilingAtomic, + walletAddress: WALLET, + policyVersionId: seeded.policyVersion.id, + acceptedIndex: evaluation.acceptedIndex, + })); + context.intents.transition({ + intentId: seeded.intent.id, + expectedState: 'challenged', + nextState: 'approval_pending', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + }); + return { ...seeded, approval }; +} + +function currentPaymentCaseHash(store, intentId) { + const intent = store.readOne('SELECT * FROM spend_intents WHERE id = ?', [intentId]); + const attempt = store.readOne('SELECT * FROM payment_attempts WHERE intent_id = ?', [intentId]); + const budget = store.readOne('SELECT * FROM budget_reservations WHERE intent_id = ?', [intentId]); + const outcome = store.readOne('SELECT * FROM buyer_outcomes WHERE intent_id = ?', [intentId]); + const history = store.readAll(`SELECT * FROM payment_reconciliation_candidates + WHERE intent_id = ? ORDER BY rowid`, [intentId]); + return sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.payment-reconciliation-case.v1', + intentId, + intentHash: intent.intent_hash, + attemptState: attempt.state, + budgetState: budget.state, + buyerOutcomeRevision: Number(outcome.revision), + history: history.map((row) => ({ + id: row.id, + transactionId: row.transaction_id, + state: row.state, + evidenceHash: row.evidence_json === null ? null : sha256(row.evidence_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + })); +} + +function rewriteAndResealEvents(store, rewrite) { + store.transaction((token) => store.within(token, ({ db }) => { + rewrite(db); + const rows = db.prepare('SELECT * FROM events ORDER BY sequence').all(); + for (const row of rows) { + db.prepare(`UPDATE events + SET previous_hash = NULL, event_hash = ? + WHERE sequence = ?`).run(`temporary-event-${row.sequence}`, row.sequence); + } + let previousHash = null; + for (const row of rows) { + const eventHash = sha256(canonicalJson({ + entityType: row.entity_type, + entityId: row.entity_id, + eventType: row.event_type, + data: JSON.parse(row.data_json), + previousHash, + createdAt: row.created_at, + })); + db.prepare(`UPDATE events + SET previous_hash = ?, event_hash = ? + WHERE sequence = ?`).run(previousHash, eventHash, row.sequence); + previousHash = eventHash; + } + })); + assert.equal(store.verifyEventChain(), true); +} + +test('recovery audits a pristine authority and is idempotent', (t) => { + const context = setup(t); + + const first = recoverKernelAuthority(context); + const eventCount = context.store.events().length; + const second = recoverKernelAuthority(context); + + assert.equal(first.ready, true); + assert.equal(first.repairedIntentCount, 0); + assert.equal(first.repairedReceiptCount, 0); + assert.deepEqual(second, first); + assert.equal(context.store.events().length, eventCount); + assert.ok(Object.isFrozen(first)); +}); + +test('recovery retains each legal enrollment and session authority shape', async (t) => { + await t.test('active enrollment before its first session', (st) => { + const context = setup(st); + createPolicyRepository(context.store).apply(structuredClone(BASE_POLICY), NOW); + const enrollment = createAgentEnrollmentRepository({ + store: context.store, + now: context.now, + }).enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + insertCurrentIsolation( + context, + enrollment.enrollmentHash, + enforcedIsolationReport(enrollment.enrollmentHash), + ); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 0); + assert.equal(context.store.readOne( + "SELECT COUNT(*) AS count FROM agent_enrollments WHERE state = 'active'", + ).count, 1n); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM spend_sessions', + ).count, 0n); + }); + + await t.test('refreshed isolation attestation binds its superseded predecessor', (st) => { + const context = setup(st); + createPolicyRepository(context.store).apply(structuredClone(BASE_POLICY), NOW); + const enrollment = createAgentEnrollmentRepository({ + store: context.store, + now: context.now, + }).enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + let id = 0; + const attestations = createIsolationAttestationRepository({ + store: context.store, + now: context.now, + idFactory: () => `isolation-refresh-${++id}`, + }); + const firstReport = enforcedIsolationReport(enrollment.enrollmentHash); + attestations.importCurrent({ + reportBytes: Buffer.from(`${canonicalJson(firstReport)}\n`), + expectedReportHash: sha256(canonicalJson(firstReport)), + operatorIdHash: OPERATOR_HASH, + }); + CLOCKS.get(context).value = '2026-07-31T12:02:00.000Z'; + const replacementReport = { + ...enforcedIsolationReport(enrollment.enrollmentHash, { + expiresAt: '2026-07-31T12:14:00.000Z', + }), + authorityMetadataHash: sha256('refreshed authority metadata'), + probedAt: '2026-07-31T12:01:00.000Z', + }; + attestations.importCurrent({ + reportBytes: Buffer.from(`${canonicalJson(replacementReport)}\n`), + expectedReportHash: sha256(canonicalJson(replacementReport)), + operatorIdHash: OPERATOR_HASH, + }); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(context.store.readOne( + "SELECT COUNT(*) AS count FROM isolation_attestations WHERE state = 'current'", + ).count, 1n); + assert.equal(context.store.readOne( + "SELECT COUNT(*) AS count FROM isolation_attestations WHERE state = 'superseded'", + ).count, 1n); + }); + + await t.test('guarded closed session with retained active enrollment', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + recoverKernelAuthority(context); + const currentSession = context.intents.getSession(seeded.session.id); + context.store.transaction((token) => context.intents.closeBoundSessionInTransaction(token, { + sessionId: seeded.session.id, + expectedSessionHash: currentSession.sessionHash, + })); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 0); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_sessions WHERE id = ?', [seeded.session.id], + ).state, 'closed'); + }); + + await t.test('policy-blocked session', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + const replacementPolicy = structuredClone(BASE_POLICY); + replacementPolicy.rolling24hMaxAtomic = '9999999'; + createPolicyRepository(context.store).apply(replacementPolicy, NOW); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 1); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_sessions WHERE id = ?', [seeded.session.id], + ).state, 'policy_blocked'); + }); + + await t.test('completed policy transition with replacement session', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + recoverKernelAuthority(context); + const replacementPolicy = structuredClone(BASE_POLICY); + replacementPolicy.rolling24hMaxAtomic = '9999999'; + const target = createPolicyRepository(context.store).apply( + replacementPolicy, + NOW, + ).policyVersion; + const blocked = context.intents.getSession(seeded.session.id); + const transition = context.store.transaction((token) => ( + context.intents.transitionBlockedSessionInTransaction(token, { + sessionId: seeded.session.id, + targetPolicyVersionId: target.id, + expectedSessionHash: blocked.sessionHash, + }) + )); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 0); + assert.equal(transition.previousSession.state, 'closed'); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_sessions WHERE id = ?', [transition.replacementSession.id], + ).state, 'open'); + }); + + await t.test('revoked enrollment with a retained open binding', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + createAgentEnrollmentRepository({ store: context.store, now: context.now }).revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + }); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 1); + assert.equal(context.store.readOne( + 'SELECT state FROM agent_enrollments WHERE agent_instance_id = ?', + [DESCRIPTOR.agentInstanceId], + ).state, 'revoked'); + assert.equal(context.store.readOne( + 'SELECT state FROM agent_session_bindings WHERE session_id = ?', [seeded.session.id], + ).state, 'open'); + }); +}); + +test('file-backed recovery survives two reopen boundaries without replaying signed work', (t) => { + const fileAuthority = temporaryFileAuthority(t); + const signer = createReceiptSigner(); + const first = setup(t, { fileAuthority, signer }); + const seeded = seedPaymentCrashGap(first, 'signed'); + const persistedPayload = first.store.readOne( + 'SELECT payment_payload_json FROM payment_attempts WHERE intent_id = ?', + [seeded.intent.id], + ).payment_payload_json; + first.store.close(); + + const reopened = setup(t, { fileAuthority, signer }); + const repaired = recoverKernelAuthority(reopened); + assert.equal(repaired.ready, true); + assert.equal(repaired.repairedIntentCount, 1); + assert.equal(repaired.repairedReceiptCount, 1); + assert.equal(reopened.store.readOne( + 'SELECT payment_payload_json FROM payment_attempts WHERE intent_id = ?', + [seeded.intent.id], + ).payment_payload_json, persistedPayload); + assert.equal(reopened.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', + [seeded.intent.id], + ).state, 'unresolved'); + assert.equal(reopened.store.readOne( + 'SELECT COUNT(*) AS count FROM payment_attempts WHERE intent_id = ?', + [seeded.intent.id], + ).count, 1n); + reopened.store.close(); + + const secondReopen = setup(t, { fileAuthority, signer }); + const idempotent = recoverKernelAuthority(secondReopen); + assert.equal(idempotent.ready, true); + assert.equal(idempotent.repairedIntentCount, 0); + assert.equal(idempotent.repairedReceiptCount, 0); + assert.equal(secondReopen.receipts.assertParity(), true); + secondReopen.store.close(); +}); + +test('recovery rejects a real foreign-key violation before making repairs', (t) => { + const context = setup(t); + context.store.execForTest('PRAGMA foreign_keys = OFF'); + context.store.execForTest(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at, closed_at) + VALUES ('orphan', 'pi:orphan', '0x1000000000000000000000000000000000000000', + 'missing-policy', 'closed', '${NOW}', '${NOW}')`); + context.store.execForTest('PRAGMA foreign_keys = ON'); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.events().length, 0); +}); + +test('recovery rejects well-formed cross-table and lifecycle corruption with zero repair', async (t) => { + await t.test('enrollment creation event substitution', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + rewriteAndResealEvents(context.store, (db) => { + const row = db.prepare("SELECT * FROM events WHERE event_type = 'agent.enrolled'").get(); + const data = JSON.parse(row.data_json); + data.operatorIdHash = `sha256:${'ef'.repeat(32)}`; + db.prepare('UPDATE events SET data_json = ? WHERE sequence = ?') + .run(canonicalJson(data), row.sequence); + }); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).state, 'captured'); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM buyer_outcomes WHERE intent_id = ?', [seeded.intent.id], + ).count, 0n); + }); + + await t.test('approval request event substitution', (st) => { + const context = setup(st); + const seeded = seedPendingApproval(context); + rewriteAndResealEvents(context.store, (db) => { + const row = db.prepare("SELECT * FROM events WHERE event_type = 'approval.requested'").get(); + const data = JSON.parse(row.data_json); + data.acceptedIndex += 1; + db.prepare('UPDATE events SET data_json = ? WHERE sequence = ?') + .run(canonicalJson(data), row.sequence); + }); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT decision FROM approvals WHERE id = ?', [seeded.approval.approvalId], + ).decision, 'pending'); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).state, 'approval_pending'); + }); + + await t.test('consumed approval without its aggregate reservation', (st) => { + const context = setup(st); + const seeded = seedPendingApproval(context); + const approved = context.approvals.approve({ + approvalId: seeded.approval.approvalId, + expectedIntentHash: seeded.intent.intentHash, + operatorIdHash: OPERATOR_HASH, + }); + context.store.transaction((token) => context.approvals.consumeForInTransaction(token, { + intentId: approved.intentId, + intentHash: approved.intentHash, + challengeHash: approved.challengeHash, + quoteId: approved.quoteId, + amountCeilingAtomic: approved.amountCeilingAtomic, + walletAddress: approved.walletAddress, + policyVersionId: approved.policyVersionId, + acceptedIndex: approved.acceptedIndex, + expiresAt: approved.expiresAt, + })); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT decision FROM approvals WHERE id = ?', [seeded.approval.approvalId], + ).decision, 'consumed'); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM budget_reservations WHERE intent_id = ?', + [seeded.intent.id], + ).count, 0n); + }); + + await t.test('Spend Intent immutable hash projection substitution', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare('UPDATE spend_intents SET body_hash = ? WHERE id = ?') + .run(sha256('substituted request body'), seeded.intent.id); + })); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM buyer_outcomes WHERE intent_id = ?', [seeded.intent.id], + ).count, 0n); + }); + + await t.test('coordinated seller URL rebind cannot replace intent genesis authority', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + const sellerOrigin = 'https://rebound.example'; + const resourcePath = '/paid/rebound'; + const requestUrlHash = sha256(`${sellerOrigin}${resourcePath}`); + const intentHash = sha256(canonicalJson({ + requestId: seeded.intent.requestId, + sessionId: seeded.intent.sessionId, + enrollmentHash: seeded.intent.enrollmentHash, + routeId: seeded.intent.routeId, + method: seeded.intent.method, + requestUrlHash, + sellerOrigin, + resourcePath, + bodyHash: seeded.intent.bodyHash, + headerAllowlistHash: seeded.intent.headerAllowlistHash, + purposeLabel: seeded.intent.purposeLabel, + correlationId: seeded.intent.correlationId, + walletAddress: seeded.intent.walletAddress, + policyVersionId: seeded.policyVersion.id, + })); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE spend_intents + SET seller_origin = ?, resource_path = ?, request_url_hash = ?, intent_hash = ? + WHERE id = ?`).run( + sellerOrigin, + resourcePath, + requestUrlHash, + intentHash, + seeded.intent.id, + ); + })); + rewriteAndResealEvents(context.store, (db) => { + const row = db.prepare("SELECT * FROM events WHERE event_type = 'intent.captured'").get(); + const data = JSON.parse(row.data_json); + data.requestUrlHash = requestUrlHash; + data.intentHash = intentHash; + db.prepare('UPDATE events SET data_json = ? WHERE sequence = ?') + .run(canonicalJson(data), row.sequence); + }); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM buyer_outcomes WHERE intent_id = ?', [seeded.intent.id], + ).count, 0n); + }); + + await t.test('PolicyVersion predecessor substitution', (st) => { + const context = setup(st); + const policies = createPolicyRepository(context.store); + policies.apply(structuredClone(BASE_POLICY), NOW); + const replacement = structuredClone(BASE_POLICY); + replacement.rolling24hMaxAtomic = '9999999'; + const second = policies.apply(replacement, NOW).policyVersion; + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare('UPDATE policy_versions SET predecessor_hash = ? WHERE id = ?') + .run(sha256('substituted predecessor'), second.id); + })); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.events().filter( + (event) => event.event_type === 'policy.applied', + ).length, 2); + }); + + await t.test('current isolation report with a weakened enforced probe', (st) => { + const context = setup(st); + createPolicyRepository(context.store).apply(structuredClone(BASE_POLICY), NOW); + const enrollment = createAgentEnrollmentRepository({ + store: context.store, + now: context.now, + }).enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const report = enforcedIsolationReport(enrollment.enrollmentHash); + report.probeResults.database = 'READABLE'; + insertCurrentIsolation(context, enrollment.enrollmentHash, report); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + }); + + await t.test('expired current isolation report', (st) => { + const context = setup(st); + createPolicyRepository(context.store).apply(structuredClone(BASE_POLICY), NOW); + const enrollment = createAgentEnrollmentRepository({ + store: context.store, + now: context.now, + }).enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + insertCurrentIsolation( + context, + enrollment.enrollmentHash, + enforcedIsolationReport(enrollment.enrollmentHash), + ); + CLOCKS.get(context).value = '2026-07-31T12:15:00.000Z'; + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + }); + + await t.test('replacement supersession without an exact replacement attestation', (st) => { + const context = setup(st); + createPolicyRepository(context.store).apply(structuredClone(BASE_POLICY), NOW); + const enrollments = createAgentEnrollmentRepository({ store: context.store, now: context.now }); + const enrollment = enrollments.enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const report = enforcedIsolationReport(enrollment.enrollmentHash); + createIsolationAttestationRepository({ + store: context.store, + now: context.now, + idFactory: () => 'isolation-revoked-fixture', + }).importCurrent({ + reportBytes: Buffer.from(`${canonicalJson(report)}\n`), + expectedReportHash: sha256(canonicalJson(report)), + operatorIdHash: OPERATOR_HASH, + }); + enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: enrollment.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + rewriteAndResealEvents(context.store, (db) => { + const row = db.prepare( + "SELECT * FROM events WHERE event_type = 'isolation.attestation_superseded'", + ).get(); + const data = JSON.parse(row.data_json); + data.reasonCode = 'ATTESTATION_REPLACED'; + db.prepare('UPDATE events SET data_json = ? WHERE sequence = ?') + .run(canonicalJson(data), row.sequence); + }); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + }); + + await t.test('session-to-enrollment binding substitution', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare('UPDATE spend_sessions SET adapter_id = ? WHERE id = ?').run( + `pi:${Buffer.alloc(16, 2).toString('base64url')}`, + seeded.session.id, + ); + })); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).state, 'captured'); + }); + + await t.test('orphan open session', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at, closed_at) + VALUES ('orphan-open-session', 'pi:orphan-open-agent', ?, ?, 'open', ?, NULL)`) + .run(WALLET, seeded.policyVersion.id, NOW); + })); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM buyer_outcomes WHERE intent_id = ?', [seeded.intent.id], + ).count, 0n); + }); + + await t.test('active unbound enrollment beside retained revoked open authority', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + const enrollments = createAgentEnrollmentRepository({ store: context.store, now: context.now }); + enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: sha256(canonicalJson(DESCRIPTOR)), + operatorIdHash: OPERATOR_HASH, + }); + const descriptor = Object.freeze({ + schemaVersion: 1, + agentInstanceId: Buffer.alloc(16, 1).toString('base64url'), + credentialDigest: `sha256:${'bc'.repeat(32)}`, + agentUid: '503', + agentGid: '504', + }); + const enrollmentHash = sha256(canonicalJson(descriptor)); + context.store.transaction((token) => context.store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, + state, enrolled_by_operator_hash, enrolled_at, revoked_by_operator_hash, revoked_at) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?, NULL, NULL)`).run( + descriptor.agentInstanceId, + descriptor.credentialDigest, + enrollmentHash, + descriptor.agentUid, + descriptor.agentGid, + OPERATOR_HASH, + NOW, + ); + appendEvent({ + entityType: 'agent_enrollment', + entityId: descriptor.agentInstanceId, + eventType: 'agent.enrolled', + data: { + enrollmentHash, + credentialDigest: descriptor.credentialDigest, + agentUid: descriptor.agentUid, + agentGid: descriptor.agentGid, + operatorIdHash: OPERATOR_HASH, + isolation: 'pending_verification', + enrolledAt: NOW, + }, + }); + })); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).state, 'captured'); + }); + + await t.test('conflicting existing receipt projection', (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + recoverKernelAuthority(context); + const beforeEvents = context.store.events().length; + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare('UPDATE signed_receipts SET receipt_hash = ? WHERE intent_id = ?') + .run(sha256('substituted outcome'), seeded.intent.id); + })); + + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.events().length, beforeEvents); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', [seeded.intent.id], + ).count, 1n); + }); +}); + +test('a fault after the first recovery write rolls back the whole aggregate repair', (t) => { + const context = setup(t); + const seeded = seedPaymentCrashGap(context, 'reserved'); + const beforeEvents = context.store.events().length; + const faultingBudgets = { + snapshotInTransaction: (...args) => context.budgets.snapshotInTransaction(...args), + holdUnresolvedInTransaction: (...args) => ( + context.budgets.holdUnresolvedInTransaction(...args) + ), + releaseInTransaction: (...args) => { + context.budgets.releaseInTransaction(...args); + throw new Error('fault after budget release'); + }, + }; + + assert.throws( + () => recoverKernelAuthority({ ...context, budgets: faultingBudgets }), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).state, 'reserved'); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', [seeded.intent.id], + ).state, 'reserved'); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [seeded.intent.id], + ).state, 'reserved'); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM buyer_outcomes WHERE intent_id = ?', [seeded.intent.id], + ).count, 0n); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', [seeded.intent.id], + ).count, 0n); + assert.equal(context.store.events().length, beforeEvents); + assert.equal(context.store.verifyEventChain(), true); +}); + +test('recovery rejects a clock regression after classification with zero repair writes', (t) => { + const context = setup(t); + const seeded = seedCapturedAuthority(context); + const beforeEvents = context.store.events().length; + let calls = 0; + const regressingNow = () => { + calls += 1; + return calls === 1 ? NOW : '2026-07-31T11:59:59.999Z'; + }; + + assert.throws( + () => recoverKernelAuthority({ ...context, now: regressingNow }), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).state, 'captured'); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM buyer_outcomes WHERE intent_id = ?', [seeded.intent.id], + ).count, 0n); + assert.equal(context.store.events().length, beforeEvents); +}); + +test('recovery terminalizes captured unsigned work and repairs its first receipt once', (t) => { + const context = setup(t); + const { intent } = seedCapturedAuthority(context); + + const first = recoverKernelAuthority(context); + const eventCount = context.store.events().length; + const receipt = context.receipts.latest(intent.id); + assert.deepEqual({ + state: context.store.readOne('SELECT state FROM spend_intents WHERE id = ?', [intent.id]).state, + status: context.store.readOne( + 'SELECT status FROM buyer_outcomes WHERE intent_id = ?', [intent.id], + ).status, + reasonCode: receipt.receipt.outcome.reasonCode, + revision: receipt.revision, + }, { + state: 'terminal', + status: 'upstream_failed', + reasonCode: 'RECOVERY_ABANDONED_UNSIGNED', + revision: 1, + }); + assert.equal(first.repairedIntentCount, 1); + assert.equal(first.repairedReceiptCount, 1); + assert.equal(context.receipts.assertParity(), true); + + const second = recoverKernelAuthority(context); + assert.equal(second.repairedIntentCount, 0); + assert.equal(second.repairedReceiptCount, 0); + assert.equal(context.store.events().length, eventCount); +}); + +test('recovery uses persisted challenged decisions without inventing spend authority', async (t) => { + for (const scenario of [ + { + name: 'allow decision', + amount: '50000', + decision: 'allow', + status: 'payment_failed', + reasonCode: 'RECOVERY_ABANDONED_UNSIGNED', + }, + { + name: 'deny decision', + amount: '500001', + decision: 'deny', + status: 'payment_denied', + reasonCode: null, + }, + ]) { + await t.test(scenario.name, (st) => { + const context = setup(st); + const seeded = seedCapturedAuthority(context); + const evaluation = attachDecision(context, seeded, scenario.amount); + assert.equal(evaluation.decision, scenario.decision); + + const report = recoverKernelAuthority(context); + const outcome = context.store.readOne( + 'SELECT * FROM buyer_outcomes WHERE intent_id = ?', [seeded.intent.id], + ); + const expectedReason = scenario.reasonCode ?? evaluation.reasonCode; + assert.equal(outcome.status, scenario.status); + assert.equal(outcome.reason_code, expectedReason); + assert.equal(report.repairedIntentCount, 1); + assert.equal(report.repairedReceiptCount, 1); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM budget_reservations WHERE intent_id = ?', + [seeded.intent.id], + ).count, 0n); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM payment_attempts WHERE intent_id = ?', + [seeded.intent.id], + ).count, 0n); + assert.equal(context.receipts.latest(seeded.intent.id).receipt.outcome.reasonCode, + expectedReason); + assert.equal(context.receipts.assertParity(), true); + }); + } +}); + +test('recovery releases reserved work but retains every claimed signature as unresolved', async (t) => { + for (const state of ['reserved', 'signing', 'signed', 'retrying']) { + await t.test(state, (st) => { + const context = setup(st); + const seeded = seedPaymentCrashGap(context, state); + const before = context.store.readOne( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', [seeded.intent.id], + ); + + const report = recoverKernelAuthority(context); + const intent = context.store.readOne( + 'SELECT * FROM spend_intents WHERE id = ?', [seeded.intent.id], + ); + const attempt = context.store.readOne( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', [seeded.intent.id], + ); + const budget = context.store.readOne( + 'SELECT * FROM budget_reservations WHERE intent_id = ?', [seeded.intent.id], + ); + const receipt = context.receipts.latest(seeded.intent.id); + assert.equal(report.repairedIntentCount, 1); + assert.equal(report.repairedReceiptCount, 1); + if (state === 'reserved') { + assert.equal(intent.state, 'terminal'); + assert.equal(attempt.state, 'rejected'); + assert.equal(budget.state, 'released'); + assert.equal(receipt.receipt.outcome.status, 'payment_failed'); + assert.equal(receipt.receipt.payment.state, 'not_signed'); + } else { + assert.equal(intent.state, 'unresolved'); + assert.equal(intent.retry_matchable, 1n); + assert.equal(attempt.state, 'unresolved'); + assert.equal(attempt.reason_code, 'RECOVERY_PAYMENT_AMBIGUOUS'); + assert.equal(budget.state, 'unresolved'); + assert.equal(receipt.receipt.outcome.status, 'payment_unresolved'); + assert.equal(receipt.receipt.outcome.reasonCode, 'RECOVERY_PAYMENT_AMBIGUOUS'); + assert.equal(receipt.receipt.payment.state, 'unresolved'); + assert.equal(attempt.nonce, before.nonce); + assert.equal(attempt.payment_payload_json, before.payment_payload_json); + assert.equal(attempt.payment_header, before.payment_header); + } + assert.equal(context.receipts.assertParity(), true); + }); + } +}); + +test('recovery retains live approval authority and atomically expires only due work', async (t) => { + await t.test('unexpired pending approval', (st) => { + const context = setup(st); + const seeded = seedPendingApproval(context); + const eventCount = context.store.events().length; + + const report = recoverKernelAuthority(context); + assert.equal(report.repairedIntentCount, 0); + assert.equal(report.pendingApprovalCount, 1); + assert.equal(context.store.readOne( + 'SELECT decision FROM approvals WHERE id = ?', [seeded.approval.approvalId], + ).decision, 'pending'); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).state, 'approval_pending'); + assert.equal(context.store.events().length, eventCount); + }); + + await t.test('expired pending approval', (st) => { + const context = setup(st); + const seeded = seedPendingApproval(context); + CLOCKS.get(context).value = new Date(Date.parse(seeded.approval.expiresAt) + 1).toISOString(); + + const report = recoverKernelAuthority(context); + assert.equal(report.repairedIntentCount, 1); + assert.equal(report.repairedReceiptCount, 1); + assert.equal(context.store.readOne( + 'SELECT decision FROM approvals WHERE id = ?', [seeded.approval.approvalId], + ).decision, 'expired'); + assert.equal(context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).state, 'terminal'); + const receipt = context.receipts.latest(seeded.intent.id); + assert.equal(receipt.receipt.outcome.status, 'payment_denied'); + assert.equal(receipt.receipt.outcome.reasonCode, 'APPROVAL_EXPIRED'); + assert.equal(context.receipts.assertParity(), true); + }); + + await t.test('unexpired approved but unconsumed authority', (st) => { + const context = setup(st); + const seeded = seedPendingApproval(context); + context.approvals.approve({ + approvalId: seeded.approval.approvalId, + expectedIntentHash: seeded.intent.intentHash, + operatorIdHash: OPERATOR_HASH, + }); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 0); + assert.equal(report.pendingApprovalCount, 1); + assert.equal(context.store.readOne( + 'SELECT decision FROM approvals WHERE id = ?', [seeded.approval.approvalId], + ).decision, 'approved'); + }); + + await t.test('expired approved but unconsumed authority', (st) => { + const context = setup(st); + const seeded = seedPendingApproval(context); + context.approvals.approve({ + approvalId: seeded.approval.approvalId, + expectedIntentHash: seeded.intent.intentHash, + operatorIdHash: OPERATOR_HASH, + }); + CLOCKS.get(context).value = new Date(Date.parse(seeded.approval.expiresAt) + 1).toISOString(); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 1); + assert.equal(context.store.readOne( + 'SELECT decision FROM approvals WHERE id = ?', [seeded.approval.approvalId], + ).decision, 'expired'); + assert.equal(context.receipts.latest(seeded.intent.id).receipt.outcome.reasonCode, + 'APPROVAL_EXPIRED'); + assert.equal(context.receipts.assertParity(), true); + }); +}); + +test('recovery opens one blocking execution case for a committed payment with no execution row', (t) => { + const context = setup(t); + const seeded = seedPaymentCrashGap(context, 'retrying'); + const transactionId = `0x${'81'.repeat(32)}`; + const paymentHash = context.store.readOne( + 'SELECT payment_hash FROM payment_attempts WHERE intent_id = ?', [seeded.intent.id], + ).payment_hash; + context.budgets.commit({ + intentId: seeded.intent.id, + settlementEvidence: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256('recovery-settlement-header'), + success: true, + transaction: transactionId, + network: seeded.policyVersion.policy.network, + payer: WALLET, + amountAtomic: '50000', + paymentHash, + }), + }); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM execution_outcomes WHERE intent_id = ?', [seeded.intent.id], + ).count, 0n); + + const report = recoverKernelAuthority(context); + assert.equal(report.repairedIntentCount, 1); + assert.equal(report.repairedReceiptCount, 1); + assert.deepEqual({ + intent: context.store.readOne( + 'SELECT state FROM spend_intents WHERE id = ?', [seeded.intent.id], + ).state, + payment: context.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', [seeded.intent.id], + ).state, + budget: context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', [seeded.intent.id], + ).state, + execution: context.store.readOne( + 'SELECT state FROM execution_outcomes WHERE intent_id = ?', [seeded.intent.id], + ).state, + resolution: context.store.readOne( + 'SELECT state FROM execution_resolutions WHERE intent_id = ?', [seeded.intent.id], + ).state, + }, { + intent: 'terminal', + payment: 'settled', + budget: 'committed', + execution: 'unknown', + resolution: 'reconciliation_required', + }); + const receipt = context.receipts.latest(seeded.intent.id); + assert.equal(receipt.receipt.outcome.status, 'execution_unknown'); + assert.equal(receipt.receipt.outcome.reasonCode, 'RECOVERY_EXECUTION_MISSING'); + assert.equal(receipt.receipt.payment.transactionId, transactionId); + assert.equal(context.budgets.snapshot({ + sessionId: seeded.session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + assert.equal(context.receipts.assertParity(), true); +}); + +test('receipt issuance failure closes authority and recovery repairs only the exact tail gap', async (t) => { + const context = setup(t); + const seeded = seedPaymentCrashGap(context, 'retrying'); + recoverKernelAuthority(context); + const transactionId = `0x${'80'.repeat(32)}`; + const failures = []; + const receiptFailure = new Error('injected receipt signer failure'); + const failingReceipts = Object.freeze({ + assertParityInTransaction: (...args) => context.receipts.assertParityInTransaction(...args), + latest: (...args) => context.receipts.latest(...args), + issueRevisionForTerminal: () => { throw receiptFailure; }, + }); + const reconciler = createReconciler({ + store: context.store, + budgets: context.budgets, + receipts: failingReceipts, + resolver: Object.freeze({ + observePayment: (binding) => Object.freeze({ + kind: 'settled_transfer', + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: seeded.policyVersion.policy.network, + transactionId: binding.candidate.transactionId, + blockHash: `0x${'89'.repeat(32)}`, + blockNumber: '1234583', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 1, + authorizationLogIndex: 2, + tokenContract: seeded.policyVersion.policy.asset, + from: WALLET, + to: seeded.policyVersion.policy.sellers[0].payTo, + valueAtomic: '50000', + authorizationNonce: `0x${'11'.repeat(32)}`, + observedAt: NOW, + }), + }), + observeExecution: () => Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + observeRefund: () => Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + }), + now: context.now, + idFactory: sequenceIds(), + authorityMutationCoordinator: Object.freeze({ + runExclusive(operation) { return operation(); }, + }), + markAuthorityUnhealthy: (reasonCode) => failures.push(reasonCode), + }); + + await assert.rejects( + reconciler.reconcilePayment({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + paymentTransactionId: transactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, seeded.intent.id), + }), + (error) => error === receiptFailure, + ); + assert.deepEqual(failures, ['RECEIPT_PARITY_REQUIRED']); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_attempts WHERE intent_id = ?', [seeded.intent.id], + ).state, 'settled'); + assert.equal(context.store.readOne( + 'SELECT revision FROM buyer_outcomes WHERE intent_id = ?', [seeded.intent.id], + ).revision, 2n); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', [seeded.intent.id], + ).count, 1n); + + const repaired = recoverKernelAuthority(context); + assert.equal(repaired.ready, true); + assert.equal(repaired.repairedIntentCount, 0); + assert.equal(repaired.repairedReceiptCount, 1); + assert.equal(context.store.readOne( + 'SELECT COUNT(*) AS count FROM signed_receipts WHERE intent_id = ?', [seeded.intent.id], + ).count, 2n); + assert.equal(context.receipts.assertParity(), true); +}); + +test('recovery revalidates rejected, abandoned, replacement, and confirmed payment case history', async (t) => { + const context = setup(t); + const seeded = seedPaymentCrashGap(context, 'retrying'); + recoverKernelAuthority(context); + let observationCount = 0; + const reconciler = createReconciler({ + store: context.store, + budgets: context.budgets, + receipts: context.receipts, + resolver: Object.freeze({ + observePayment: (binding) => { + observationCount += 1; + if (observationCount === 1 || observationCount === 3) { + return Object.freeze({ kind: 'unknown', reasonCode: 'RPC_RECEIPT_MISSING' }); + } + if (observationCount === 2) { + return Object.freeze({ + kind: 'payment_candidate_rejected', + rejectionProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: seeded.policyVersion.policy.network, + transactionId: binding.candidate.transactionId, + blockHash: `0x${'8a'.repeat(32)}`, + blockNumber: '1234584', + transactionStatus: 'reverted', + confirmations: 3, + reasonCode: 'TRANSACTION_REVERTED', + observedAt: NOW, + }), + }); + } + return Object.freeze({ + kind: 'settled_transfer', + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: seeded.policyVersion.policy.network, + transactionId: binding.candidate.transactionId, + blockHash: `0x${'8b'.repeat(32)}`, + blockNumber: '1234585', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 1, + authorizationLogIndex: 2, + tokenContract: seeded.policyVersion.policy.asset, + from: WALLET, + to: seeded.policyVersion.policy.sellers[0].payTo, + valueAtomic: '50000', + authorizationNonce: `0x${'11'.repeat(32)}`, + observedAt: NOW, + }), + }); + }, + observeExecution: () => Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + observeRefund: () => Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + }), + now: context.now, + idFactory: sequenceIds(), + authorityMutationCoordinator: Object.freeze({ + runExclusive(operation) { return operation(); }, + }), + markAuthorityUnhealthy: () => undefined, + }); + const firstTransactionId = `0x${'8c'.repeat(32)}`; + const firstPending = await reconciler.reconcilePayment({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + paymentTransactionId: firstTransactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, seeded.intent.id), + }); + const rejected = await reconciler.reconcilePayment({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + paymentTransactionId: firstTransactionId, + expectedPaymentCaseHash: firstPending.paymentCaseHash, + }); + const secondTransactionId = `0x${'8d'.repeat(32)}`; + const secondPending = await reconciler.reconcilePayment({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + paymentTransactionId: secondTransactionId, + expectedPaymentCaseHash: rejected.paymentCaseHash, + }); + const abandoned = await reconciler.abandonCandidate({ + intentId: seeded.intent.id, + kind: 'payment', + operatorIdHash: OPERATOR_HASH, + expectedCaseHash: secondPending.paymentCaseHash, + }); + const confirmedTransactionId = `0x${'8e'.repeat(32)}`; + const settled = await reconciler.reconcilePayment({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + paymentTransactionId: confirmedTransactionId, + expectedPaymentCaseHash: abandoned.caseHash, + }); + assert.equal(settled.status, 'execution_unknown'); + assert.deepEqual(context.store.readAll(`SELECT transaction_id, state + FROM payment_reconciliation_candidates WHERE intent_id = ? ORDER BY rowid`, + [seeded.intent.id]).map((row) => ({ + transactionId: row.transaction_id, + state: row.state, + })), [ + { transactionId: firstTransactionId, state: 'rejected' }, + { transactionId: secondTransactionId, state: 'abandoned' }, + { transactionId: confirmedTransactionId, state: 'confirmed' }, + ]); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 0); + assert.equal(context.receipts.assertParity(), true); +}); + +test('recovery accepts an unused-authorization resolution that rejects a pending candidate', async (t) => { + const context = setup(t); + const seeded = seedPaymentCrashGap(context, 'retrying'); + recoverKernelAuthority(context); + let observationCount = 0; + const reconciler = createReconciler({ + store: context.store, + budgets: context.budgets, + receipts: context.receipts, + resolver: Object.freeze({ + observePayment: () => { + observationCount += 1; + if (observationCount === 1) { + return Object.freeze({ kind: 'unknown', reasonCode: 'RPC_RECEIPT_MISSING' }); + } + return Object.freeze({ + kind: 'authorization_unused_after_expiry', + network: seeded.policyVersion.policy.network, + asset: seeded.policyVersion.policy.asset, + payer: WALLET, + nonce: `0x${'11'.repeat(32)}`, + validBefore: '1785502860', + authorizationState: false, + observedBlockNumber: '1234586', + observedBlockHash: `0x${'8f'.repeat(32)}`, + observedBlockTimestamp: '1785502920', + confirmations: 3, + }); + }, + observeExecution: () => Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + observeRefund: () => Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + }), + now: context.now, + idFactory: sequenceIds(), + authorityMutationCoordinator: Object.freeze({ + runExclusive(operation) { return operation(); }, + }), + markAuthorityUnhealthy: () => undefined, + }); + const transactionId = `0x${'90'.repeat(32)}`; + const pending = await reconciler.reconcilePayment({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + paymentTransactionId: transactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, seeded.intent.id), + }); + CLOCKS.get(context).value = '2026-07-31T13:02:00.000Z'; + const rejected = await reconciler.reconcilePayment({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + expectedPaymentCaseHash: pending.paymentCaseHash, + }); + assert.equal(rejected.status, 'payment_rejected'); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_reconciliation_candidates WHERE transaction_id = ?', + [transactionId], + ).state, 'rejected'); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 0); + assert.equal(context.receipts.assertParity(), true); +}); + +test('recovery accepts an exact confirmed payment candidate that owns the settled transaction', async (t) => { + const context = setup(t); + const seeded = seedPaymentCrashGap(context, 'retrying'); + recoverKernelAuthority(context); + const transactionId = `0x${'82'.repeat(32)}`; + const resolver = Object.freeze({ + observePayment: (binding) => Object.freeze({ + kind: 'settled_transfer', + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: seeded.policyVersion.policy.network, + transactionId: binding.candidate.transactionId, + blockHash: `0x${'83'.repeat(32)}`, + blockNumber: '1234580', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 1, + authorizationLogIndex: 2, + tokenContract: seeded.policyVersion.policy.asset, + from: WALLET, + to: seeded.policyVersion.policy.sellers[0].payTo, + valueAtomic: '50000', + authorizationNonce: `0x${'11'.repeat(32)}`, + observedAt: NOW, + }), + }), + observeExecution: (binding) => { + const attestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.execution.v1', + network: seeded.policyVersion.policy.network, + sellerOrigin: SELLER, + intentHash: binding.intentHash, + transactionId: binding.transactionId, + outcome: 'succeeded', + httpStatus: 200, + responseHash: sha256(Buffer.from('recovered execution evidence')), + issuedAt: '2026-07-31T11:59:00.000Z', + expiresAt: '2026-07-31T12:05:00.000Z', + signer: seeded.policyVersion.policy.sellers[0].executionSigner, + }); + return Object.freeze({ + kind: 'execution_attested', + attestation, + attestationHash: sha256(canonicalJson(attestation)), + }); + }, + observeRefund: () => Object.freeze({ + kind: 'unknown', + reasonCode: 'SELLER_EVIDENCE_FETCH_FAILED', + }), + }); + const reconciler = createReconciler({ + store: context.store, + budgets: context.budgets, + receipts: context.receipts, + resolver, + now: context.now, + idFactory: sequenceIds(), + authorityMutationCoordinator: Object.freeze({ + runExclusive(operation) { return operation(); }, + }), + markAuthorityUnhealthy: () => undefined, + }); + const settled = await reconciler.reconcilePayment({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + paymentTransactionId: transactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, seeded.intent.id), + }); + assert.equal(settled.status, 'execution_unknown'); + assert.equal(context.store.readOne( + 'SELECT state FROM payment_reconciliation_candidates WHERE transaction_id = ?', [transactionId], + ).state, 'confirmed'); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 0); + assert.equal(context.receipts.assertParity(), true); + + const execution = await reconciler.reconcileExecution({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + expectedExecutionCaseHash: settled.executionCaseHash, + }); + assert.equal(execution.status, 'completed'); + const completedReport = recoverKernelAuthority(context); + assert.equal(completedReport.ready, true); + assert.equal(completedReport.repairedIntentCount, 0); + assert.equal(context.receipts.assertParity(), true); + + const paymentReconciliation = context.store.readOne(`SELECT * FROM reconciliations + WHERE intent_id = ? AND kind = 'payment' AND outcome = 'settled'`, [seeded.intent.id]); + const originalExecutionMetadata = canonicalJson({ + reasonCode: 'PAYMENT_RECONCILED_EXECUTION_UNKNOWN', + reconciliationEvidenceId: paymentReconciliation.id, + }); + const executionRecordedEvent = context.store.events().find((event) => ( + event.entity_type === 'execution_outcome' + && event.entity_id === seeded.intent.id + && event.event_type === 'execution.recorded' + )); + assert.equal( + JSON.parse(executionRecordedEvent.data_json).metadataHash, + sha256(originalExecutionMetadata), + ); + + const substitutedCaseHash = sha256('substituted execution case'); + rewriteAndResealEvents(context.store, (db) => { + const row = db.prepare(`SELECT * FROM events + WHERE entity_type = 'reconciliation' AND event_type = 'reconciliation.recorded' + AND json_extract(data_json, '$.kind') = 'execution'`).get(); + const data = JSON.parse(row.data_json); + data.requestCaseHash = substitutedCaseHash; + data.observedCaseHash = substitutedCaseHash; + db.prepare('UPDATE events SET data_json = ? WHERE sequence = ?') + .run(canonicalJson(data), row.sequence); + }); + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); + rewriteAndResealEvents(context.store, (db) => { + const row = db.prepare(`SELECT * FROM events + WHERE entity_type = 'reconciliation' AND event_type = 'reconciliation.recorded' + AND json_extract(data_json, '$.kind') = 'execution'`).get(); + const data = JSON.parse(row.data_json); + data.requestCaseHash = settled.executionCaseHash; + data.observedCaseHash = settled.executionCaseHash; + db.prepare('UPDATE events SET data_json = ? WHERE sequence = ?') + .run(canonicalJson(data), row.sequence); + }); + assert.equal(recoverKernelAuthority(context).ready, true); + + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE payment_reconciliation_candidates + SET updated_at = '2026-07-31T12:00:01.000Z' + WHERE transaction_id = ?`).run(transactionId); + })); + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); +}); + +test('recovery audits failed execution and confirmed refund reconciliation histories', async (t) => { + const context = setup(t); + const seeded = seedPaymentCrashGap(context, 'retrying'); + recoverKernelAuthority(context); + const seller = seeded.policyVersion.policy.sellers[0]; + const paymentTransactionId = `0x${'84'.repeat(32)}`; + const rejectedRefundTransactionId = `0x${'85'.repeat(32)}`; + const abandonedRefundTransactionId = `0x${'91'.repeat(32)}`; + const refundTransactionId = `0x${'92'.repeat(32)}`; + let refundObservationCount = 0; + const resolver = Object.freeze({ + observePayment: (binding) => Object.freeze({ + kind: 'settled_transfer', + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: seeded.policyVersion.policy.network, + transactionId: binding.candidate.transactionId, + blockHash: `0x${'86'.repeat(32)}`, + blockNumber: '1234581', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 1, + authorizationLogIndex: 2, + tokenContract: seeded.policyVersion.policy.asset, + from: WALLET, + to: seller.payTo, + valueAtomic: '50000', + authorizationNonce: `0x${'11'.repeat(32)}`, + observedAt: NOW, + }), + }), + observeExecution: (binding) => { + const attestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.execution.v1', + network: seeded.policyVersion.policy.network, + sellerOrigin: SELLER, + intentHash: binding.intentHash, + transactionId: binding.transactionId, + outcome: 'failed', + httpStatus: 503, + responseHash: null, + issuedAt: '2026-07-31T11:59:00.000Z', + expiresAt: '2026-07-31T12:05:00.000Z', + signer: seller.executionSigner, + }); + return Object.freeze({ + kind: 'execution_attested', + attestation, + attestationHash: sha256(canonicalJson(attestation)), + }); + }, + observeRefund: (binding) => { + refundObservationCount += 1; + if (refundObservationCount === 1 || refundObservationCount === 3) { + return Object.freeze({ + kind: 'unknown', + reasonCode: 'RPC_RECEIPT_MISSING', + }); + } + if (refundObservationCount === 2) { + return Object.freeze({ + kind: 'refund_candidate_rejected', + rejectionProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: seeded.policyVersion.policy.network, + transactionId: binding.refundTransactionId, + blockHash: `0x${'93'.repeat(32)}`, + blockNumber: '1234582', + transactionStatus: 'reverted', + confirmations: 3, + reasonCode: 'TRANSACTION_REVERTED', + observedAt: NOW, + }), + }); + } + const attestation = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.refund.v1', + network: seeded.policyVersion.policy.network, + sellerOrigin: SELLER, + intentHash: binding.intentHash, + originalTransactionId: binding.originalTransactionId, + refundTransactionId: binding.refundTransactionId, + asset: seeded.policyVersion.policy.asset, + originalPayer: WALLET, + originalPayee: seller.payTo, + refundSource: seller.refundSource, + amountAtomic: '50000', + issuedAt: '2026-07-31T11:59:00.000Z', + expiresAt: '2026-07-31T12:05:00.000Z', + signer: seller.refundSigner, + }); + return Object.freeze({ + kind: 'refund_attested_and_confirmed', + attestation, + attestationHash: sha256(canonicalJson(attestation)), + rpcTransferProof: Object.freeze({ + source: 'base-sepolia-rpc', + network: seeded.policyVersion.policy.network, + transactionId: binding.refundTransactionId, + blockHash: `0x${'87'.repeat(32)}`, + blockNumber: '1234582', + transactionStatus: 'success', + confirmations: 3, + transferLogIndex: 3, + tokenContract: seeded.policyVersion.policy.asset, + from: seller.refundSource, + to: WALLET, + valueAtomic: '50000', + observedAt: NOW, + }), + }); + }, + }); + const reconciler = createReconciler({ + store: context.store, + budgets: context.budgets, + receipts: context.receipts, + resolver, + now: context.now, + idFactory: sequenceIds(), + authorityMutationCoordinator: Object.freeze({ + runExclusive(operation) { return operation(); }, + }), + markAuthorityUnhealthy: () => undefined, + }); + const payment = await reconciler.reconcilePayment({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + paymentTransactionId, + expectedPaymentCaseHash: currentPaymentCaseHash(context.store, seeded.intent.id), + }); + const execution = await reconciler.reconcileExecution({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + expectedExecutionCaseHash: payment.executionCaseHash, + }); + const firstPending = await reconciler.observeRefund({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + refundTransactionId: rejectedRefundTransactionId, + expectedRefundCaseHash: execution.refundCaseHash, + }); + const rejected = await reconciler.observeRefund({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + refundTransactionId: rejectedRefundTransactionId, + expectedRefundCaseHash: firstPending.refundCaseHash, + }); + const secondPending = await reconciler.observeRefund({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + refundTransactionId: abandonedRefundTransactionId, + expectedRefundCaseHash: rejected.refundCaseHash, + }); + const abandoned = await reconciler.abandonCandidate({ + intentId: seeded.intent.id, + kind: 'refund-observation', + operatorIdHash: OPERATOR_HASH, + expectedCaseHash: secondPending.refundCaseHash, + }); + const refund = await reconciler.observeRefund({ + intentId: seeded.intent.id, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: seeded.intent.intentHash, + refundTransactionId, + expectedRefundCaseHash: abandoned.caseHash, + }); + assert.equal(refund.status, 'refunded'); + assert.deepEqual(context.store.readAll(`SELECT refund_transaction_id, state + FROM refunds WHERE intent_id = ? ORDER BY rowid`, [seeded.intent.id]).map((row) => ({ + transactionId: row.refund_transaction_id, + state: row.state, + })), [ + { transactionId: rejectedRefundTransactionId, state: 'rejected' }, + { transactionId: abandonedRefundTransactionId, state: 'abandoned' }, + { transactionId: refundTransactionId, state: 'confirmed' }, + ]); + + const report = recoverKernelAuthority(context); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, 0); + assert.equal(context.receipts.assertParity(), true); + + context.store.transaction((token) => context.store.within(token, ({ db }) => { + const row = db.prepare(`SELECT * FROM reconciliations + WHERE intent_id = ? AND kind = 'refund' AND outcome = 'refund_confirmed'`) + .get(seeded.intent.id); + const evidence = JSON.parse(row.evidence_json); + evidence.rpcProofHash = sha256('substituted refund RPC proof'); + db.prepare('UPDATE reconciliations SET evidence_json = ? WHERE id = ?') + .run(canonicalJson(evidence), row.id); + })); + assert.throws( + () => recoverKernelAuthority(context), + (error) => error?.code === 'AUTHORITY_SEMANTIC_CORRUPTION', + ); +}); diff --git a/spikes/pi-wielder/tests/kernel-restart.test.mjs b/spikes/pi-wielder/tests/kernel-restart.test.mjs new file mode 100644 index 0000000..4243fa4 --- /dev/null +++ b/spikes/pi-wielder/tests/kernel-restart.test.mjs @@ -0,0 +1,521 @@ +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { createApprovalQueue } from '../src/kernel/approval-queue.mjs'; +import { acquireAuthorityLock } from '../src/kernel/authority-lock.mjs'; +import { createBudgetLedger } from '../src/kernel/budget-ledger.mjs'; +import { createIntentRepository } from '../src/kernel/intent-builder.mjs'; +import { loadOrCreateReceiptSigner } from '../src/kernel/receipt-signing.mjs'; +import { recoverKernelAuthority } from '../src/kernel/recovery.mjs'; +import { createSignedReceiptRepository } from '../src/kernel/signed-receipts.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; +import { KERNEL_FAULT_POINTS } from '../src/kernel/wallet-kernel.mjs'; + +const NOW = '2026-08-01T12:00:00.000Z'; +const SELLER = 'https://seller.example'; +const CURRENT_UID = process.getuid(); +const WORKER_PATH = fileURLToPath(new URL('./fixtures/kernel-crash-worker.mjs', import.meta.url)); +const ABORT_TIMEOUT_MS = 15_000; +const SIGNED_BYTE_POINTS = new Set([ + 'after_signed_payment_commit', + 'after_retry_claim_commit', + 'after_paid_response', + 'after_settlement_commit', + 'before_terminal_receipt_commit', +]); +const AMBIGUOUS_POINTS = new Set([ + 'after_signing_claim_commit', + 'after_signer_return', + 'after_signed_payment_commit', + 'after_retry_claim_commit', + 'after_paid_response', +]); +const SETTLED_POINTS = new Set([ + 'after_settlement_commit', + 'before_terminal_receipt_commit', +]); + +const EXPECTED = Object.freeze({ + after_intent_commit: Object.freeze({ + before: ['captured', null, null, null, 0], + after: ['terminal', null, null, 'upstream_failed', false], + signer: [0, 0], + transport: [0, 0, 0], + repairedIntentCount: 1, + }), + after_challenge_commit: Object.freeze({ + before: ['challenged', null, null, null, 0], + after: ['terminal', null, null, 'payment_failed', false], + signer: [1, 0], + transport: [1, 0, 0], + repairedIntentCount: 1, + }), + after_reservation_commit: Object.freeze({ + before: ['reserved', 'reserved', 'reserved', null, 1], + after: ['terminal', 'released', 'rejected', 'payment_failed', false], + signer: [1, 0], + transport: [1, 0, 0], + repairedIntentCount: 1, + }), + after_signing_claim_commit: Object.freeze({ + before: ['signing', 'reserved', 'signing', null, 1], + after: ['unresolved', 'unresolved', 'unresolved', 'payment_unresolved', true], + signer: [1, 0], + transport: [1, 0, 0], + repairedIntentCount: 1, + }), + after_signer_return: Object.freeze({ + before: ['signing', 'reserved', 'signing', null, 1], + after: ['unresolved', 'unresolved', 'unresolved', 'payment_unresolved', true], + signer: [1, 1], + transport: [1, 0, 0], + repairedIntentCount: 1, + }), + after_signed_payment_commit: Object.freeze({ + before: ['signed', 'reserved', 'signed', null, 1], + after: ['unresolved', 'unresolved', 'unresolved', 'payment_unresolved', true], + signer: [1, 1], + transport: [1, 1, 0], + repairedIntentCount: 1, + }), + after_retry_claim_commit: Object.freeze({ + before: ['retrying', 'reserved', 'retrying', null, 1], + after: ['unresolved', 'unresolved', 'unresolved', 'payment_unresolved', true], + signer: [1, 1], + transport: [1, 1, 0], + repairedIntentCount: 1, + }), + after_paid_response: Object.freeze({ + before: ['retrying', 'reserved', 'retrying', null, 1], + after: ['unresolved', 'unresolved', 'unresolved', 'payment_unresolved', true], + signer: [1, 1], + transport: [1, 1, 1], + repairedIntentCount: 1, + }), + after_settlement_commit: Object.freeze({ + before: ['terminal', 'committed', 'settled', 'completed', 1], + after: ['terminal', 'committed', 'settled', 'completed', false], + signer: [1, 1], + transport: [1, 1, 1], + repairedIntentCount: 0, + }), + before_terminal_receipt_commit: Object.freeze({ + before: ['terminal', 'committed', 'settled', 'completed', 1], + after: ['terminal', 'committed', 'settled', 'completed', false], + signer: [1, 1], + transport: [1, 1, 1], + repairedIntentCount: 0, + }), +}); + +function writeOwnerOnlyJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); +} + +function readOwnerOnlyJson(filePath) { + const stat = fs.statSync(filePath); + assert.equal(stat.isFile(), true, `${filePath} must be a regular file`); + assert.equal(stat.uid, CURRENT_UID, `${filePath} must retain its owner`); + assert.equal(stat.mode & 0o777, 0o600, `${filePath} must remain owner-only`); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function createFixture(t, faultPoint) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), `wallet-kernel-restart-${faultPoint}-`)); + fs.chmodSync(directory, 0o700); + const fixture = Object.freeze({ + databasePath: path.join(directory, 'kernel.sqlite'), + directory, + receiptKeyPath: path.join(directory, 'receipt-key.pem'), + signerCountPath: path.join(directory, 'signer-count.json'), + transportCountPath: path.join(directory, 'transport-count.json'), + }); + writeOwnerOnlyJson(fixture.signerCountPath, { + walletIdentity: 0, + signX402Exact: 0, + }); + writeOwnerOnlyJson(fixture.transportCountPath, { + probe: 0, + encodePayment: 0, + retryPaid: 0, + }); + t.after(() => fs.rmSync(directory, { force: true, recursive: true })); + return fixture; +} + +function pathTrust(directory) { + return Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: CURRENT_UID, + agentUid: CURRENT_UID, + }); +} + +function startWorker(t, fixture, faultPoint) { + const child = fork(WORKER_PATH, [JSON.stringify({ + ...fixture, + faultPoint, + })], { + serialization: 'json', + silent: true, + }); + const messages = []; + const messageWaiters = []; + let exitResult = null; + let processError = null; + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr = `${stderr}${chunk}`.slice(-16_384); + }); + child.on('message', (message) => { + if (message?.type === 'error') { + const error = new Error(`crash worker setup failed: ${JSON.stringify(message)}`); + processError = error; + for (const waiter of messageWaiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(error); + } + return; + } + const index = messageWaiters.findIndex((waiter) => waiter.predicate(message)); + if (index === -1) messages.push(message); + else { + const [waiter] = messageWaiters.splice(index, 1); + clearTimeout(waiter.timer); + waiter.resolve(message); + } + }); + child.on('error', (error) => { + processError = error; + for (const waiter of messageWaiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(error); + } + }); + child.on('exit', (code, signal) => { + exitResult = { code, signal }; + for (const waiter of messageWaiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(new Error( + `crash worker exited before the expected message: ${JSON.stringify(exitResult)}; stderr=${stderr}`, + )); + } + }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }); + + return Object.freeze({ + child, + get stderr() { return stderr; }, + next(predicate, timeoutMilliseconds = ABORT_TIMEOUT_MS) { + const index = messages.findIndex(predicate); + if (index !== -1) return Promise.resolve(messages.splice(index, 1)[0]); + if (processError) return Promise.reject(processError); + if (exitResult) { + return Promise.reject(new Error( + `crash worker already exited: ${JSON.stringify(exitResult)}; stderr=${stderr}`, + )); + } + return new Promise((resolve, reject) => { + const waiter = { predicate, resolve, reject }; + waiter.timer = setTimeout(() => { + const waiterIndex = messageWaiters.indexOf(waiter); + if (waiterIndex !== -1) messageWaiters.splice(waiterIndex, 1); + reject(new Error(`timed out waiting for crash-worker readiness; stderr=${stderr}`)); + }, timeoutMilliseconds); + messageWaiters.push(waiter); + }); + }, + waitForExit(timeoutMilliseconds = ABORT_TIMEOUT_MS) { + if (exitResult) return Promise.resolve(exitResult); + if (processError) return Promise.reject(processError); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`timed out waiting for crash-worker abort; stderr=${stderr}`)); + }, timeoutMilliseconds); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); + }, + }); +} + +function readState(store) { + return Object.freeze({ + attempt: store.readOne('SELECT * FROM payment_attempts ORDER BY rowid'), + budget: store.readOne('SELECT * FROM budget_reservations ORDER BY rowid'), + decision: store.readOne('SELECT * FROM policy_decisions ORDER BY rowid'), + execution: store.readOne('SELECT * FROM execution_outcomes ORDER BY rowid'), + intent: store.readOne('SELECT * FROM spend_intents ORDER BY rowid'), + outcome: store.readOne('SELECT * FROM buyer_outcomes ORDER BY rowid'), + receiptCount: Number(store.readOne('SELECT COUNT(*) AS count FROM signed_receipts').count), + reservationCount: Number( + store.readOne('SELECT COUNT(*) AS count FROM budget_reservations').count, + ), + session: store.readOne('SELECT * FROM spend_sessions ORDER BY rowid'), + }); +} + +function signedBytes(state) { + return Object.freeze({ + paymentHash: state.attempt?.payment_hash ?? null, + paymentHeader: state.attempt?.payment_header ?? null, + paymentPayloadJson: state.attempt?.payment_payload_json ?? null, + }); +} + +function transactionIds(store) { + return store.readAll(`SELECT transaction_id AS transaction_id + FROM payment_attempts WHERE transaction_id IS NOT NULL + UNION ALL + SELECT transaction_id AS transaction_id + FROM payment_reconciliation_candidates + UNION ALL + SELECT refund_transaction_id AS transaction_id + FROM refunds WHERE refund_transaction_id IS NOT NULL`) + .map((row) => row.transaction_id); +} + +function assertEveryReceiptVerifies(store, receipts) { + for (const row of store.readAll('SELECT * FROM signed_receipts ORDER BY intent_id, revision')) { + assert.equal(receipts.verify({ + id: row.id, + intentId: row.intent_id, + revision: Number(row.revision), + receipt: JSON.parse(row.receipt_json), + receiptHash: row.receipt_hash, + signature: row.signature, + algorithm: row.algorithm, + keyId: row.key_id, + supersedesReceiptHash: row.supersedes_receipt_hash, + createdAt: row.created_at, + }), true); + } +} + +function authorityFingerprint(store) { + const rows = [ + 'spend_intents', + 'budget_reservations', + 'payment_attempts', + 'execution_outcomes', + 'execution_resolutions', + 'buyer_outcomes', + 'signed_receipts', + 'events', + ].map((table) => [table, store.readAll(`SELECT * FROM ${table} ORDER BY rowid`)]); + return JSON.stringify(rows, (_key, value) => ( + typeof value === 'bigint' ? value.toString(10) : value + )); +} + +test('fresh-process recovery classifies every Wallet Kernel abort boundary exactly once', async (t) => { + assert.deepEqual(Object.keys(EXPECTED), [...KERNEL_FAULT_POINTS]); + + for (const faultPoint of KERNEL_FAULT_POINTS) { + await t.test(faultPoint, async (st) => { + const expected = EXPECTED[faultPoint]; + const fixture = createFixture(st, faultPoint); + const trust = pathTrust(fixture.directory); + const worker = startWorker(st, fixture, faultPoint); + assert.deepEqual( + await worker.next((message) => message?.type === 'ready'), + { type: 'ready', faultPoint }, + ); + + assert.throws( + () => acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'prelaunch', + pathTrust: trust, + }), + (error) => error?.code === 'AUTHORITY_BUSY', + 'the worker must hold the cross-process authority lease before fault injection', + ); + + worker.child.send({ type: 'run' }); + const exit = await worker.waitForExit(); + assert.deepEqual(exit, { code: null, signal: 'SIGABRT' }, worker.stderr); + + const countsBeforeRecovery = Object.freeze({ + signer: readOwnerOnlyJson(fixture.signerCountPath), + transport: readOwnerOnlyJson(fixture.transportCountPath), + }); + assert.deepEqual( + [countsBeforeRecovery.signer.walletIdentity, countsBeforeRecovery.signer.signX402Exact], + expected.signer, + ); + assert.deepEqual([ + countsBeforeRecovery.transport.probe, + countsBeforeRecovery.transport.encodePayment, + countsBeforeRecovery.transport.retryPaid, + ], expected.transport); + + const successor = acquireAuthorityLock({ + databasePath: fixture.databasePath, + role: 'prelaunch', + pathTrust: trust, + }); + let store; + try { + const signer = loadOrCreateReceiptSigner(fixture.receiptKeyPath, { pathTrust: trust }); + store = openKernelStore({ + filePath: fixture.databasePath, + pathTrust: trust, + now: () => NOW, + }); + const ids = (() => { + let sequence = 0; + return (kind) => `restart-${kind}-${++sequence}`; + })(); + const intents = createIntentRepository({ + store, + idFactory: ids, + now: () => NOW, + routeMetadata: Object.freeze({ + 'paid-infer': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), + }), + }); + const budgets = createBudgetLedger({ store, now: () => NOW }); + const approvals = createApprovalQueue({ store, idFactory: ids, now: () => NOW }); + const receipts = createSignedReceiptRepository({ + store, + signer, + idFactory: ids, + now: () => NOW, + }); + + const before = readState(store); + assert.deepEqual([ + before.intent.state, + before.budget?.state ?? null, + before.attempt?.state ?? null, + before.outcome?.status ?? null, + before.reservationCount, + ], expected.before, 'abort must expose the exact Task 10 durable state'); + assert.equal(before.decision?.decision ?? null, + faultPoint === 'after_intent_commit' ? null : 'allow'); + assert.equal(before.intent.challenge_hash === null, + faultPoint === 'after_intent_commit'); + assert.equal(before.receiptCount, 0, 'no abort boundary may expose a partial receipt'); + assert.equal(store.verifyEventChain(), true); + const signedMaterialBeforeRecovery = signedBytes(before); + assert.equal( + signedMaterialBeforeRecovery.paymentPayloadJson !== null, + SIGNED_BYTE_POINTS.has(faultPoint), + 'only post-signature-persistence boundaries may expose signed bytes', + ); + const transactionIdsBefore = transactionIds(store); + assert.equal(transactionIdsBefore.length, new Set(transactionIdsBefore).size); + assert.equal( + transactionIdsBefore.length, + faultPoint === 'after_settlement_commit' + || faultPoint === 'before_terminal_receipt_commit' ? 1 : 0, + ); + + const report = recoverKernelAuthority({ + store, + intents, + budgets, + approvals, + receipts, + now: () => NOW, + }); + const after = readState(store); + const budgetSnapshot = budgets.snapshot({ + sessionId: after.session.id, + sellerOrigin: SELLER, + at: NOW, + }); + assert.deepEqual([ + after.intent.state, + after.budget?.state ?? null, + after.attempt?.state ?? null, + after.outcome?.status ?? null, + budgetSnapshot.walletBlocked, + ], expected.after, 'recovery must produce the one planned classification'); + assert.equal(after.outcome.reason_code, + SETTLED_POINTS.has(faultPoint) + ? 'PAYMENT_SETTLED' + : AMBIGUOUS_POINTS.has(faultPoint) + ? 'RECOVERY_PAYMENT_AMBIGUOUS' + : 'RECOVERY_ABANDONED_UNSIGNED'); + assert.equal(after.execution?.state ?? null, + SETTLED_POINTS.has(faultPoint) ? 'succeeded' : null); + assert.equal(after.reservationCount, expected.before[4]); + if (after.budget) { + const conserved = BigInt(after.budget.reserved_atomic) + + BigInt(after.budget.committed_atomic) + + BigInt(after.budget.released_atomic) + + BigInt(after.budget.unresolved_atomic); + assert.equal(conserved, 50_000n); + } + assert.equal(after.receiptCount, 1); + assert.equal(report.ready, true); + assert.equal(report.repairedIntentCount, expected.repairedIntentCount); + assert.equal(report.repairedReceiptCount, 1); + assert.equal(report.unresolvedIntentCount, expected.after[4] ? 1 : 0); + assert.deepEqual(signedBytes(after), signedMaterialBeforeRecovery, + 'recovery must never alter persisted payment bytes'); + + const transactionIdsAfter = transactionIds(store); + assert.deepEqual(transactionIdsAfter, transactionIdsBefore); + assert.equal(transactionIdsAfter.length, new Set(transactionIdsAfter).size, + 'one transaction ID may commit at most once'); + assert.equal(store.verifyEventChain(), true); + assert.equal(receipts.assertParity(), true); + assertEveryReceiptVerifies(store, receipts); + + const countsAfterRecovery = Object.freeze({ + signer: readOwnerOnlyJson(fixture.signerCountPath), + transport: readOwnerOnlyJson(fixture.transportCountPath), + }); + assert.deepEqual(countsAfterRecovery, countsBeforeRecovery, + 'startup recovery must not sign or blindly retry paid transport'); + + const stableFingerprint = authorityFingerprint(store); + const second = recoverKernelAuthority({ + store, + intents, + budgets, + approvals, + receipts, + now: () => NOW, + }); + assert.equal(second.ready, true); + assert.equal(second.repairedIntentCount, 0); + assert.equal(second.repairedReceiptCount, 0); + assert.equal(authorityFingerprint(store), stableFingerprint, + 'a second startup audit must be mutation-free'); + assert.deepEqual({ + signer: readOwnerOnlyJson(fixture.signerCountPath), + transport: readOwnerOnlyJson(fixture.transportCountPath), + }, countsBeforeRecovery); + } finally { + store?.close(); + successor.close(); + } + }); + } +}); diff --git a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs index e3c4507..657ff0b 100644 --- a/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs +++ b/spikes/pi-wielder/tests/kernel-trusted-path.test.mjs @@ -5,10 +5,42 @@ import path from 'node:path'; import test from 'node:test'; import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; -import { openTrustedParent } from '../src/kernel/trusted-path.mjs'; +import { openAgentTrustedParent, openTrustedParent } from '../src/kernel/trusted-path.mjs'; test('exports the trusted parent opener', () => { assert.equal(typeof openTrustedParent, 'function'); + assert.equal(typeof openAgentTrustedParent, 'function'); +}); + +test('Agent trusted roles bind their exact terminal owner and mode', (t) => { + const privateFixture = makeFixture(t); + for (const [role, terminalMode, wrongMode] of [ + ['agent-private', 0o700, 0o755], + ['agent-handoff', 0o755, 0o700], + ]) { + const fixture = role === 'agent-private' + ? privateFixture + : makeFixture(t, { terminalMode: 0o755 }); + const base = { + mode: 'deterministic', + trustedAncestor: fixture.trustedAncestor, + targetFile: fixture.targetFile, + agentUid: CURRENT_UID, + terminalOwnerUid: CURRENT_UID, + terminalMode, + role, + }; + assert.throws( + () => openAgentTrustedParent({ ...base, terminalMode: wrongMode }), + /exact terminal owner and mode/, + ); + assert.throws( + () => openAgentTrustedParent({ ...base, terminalOwnerUid: CURRENT_UID + 1 }), + /exact terminal owner and mode/, + ); + const guard = openAgentTrustedParent(base); + guard.close(); + } }); const CURRENT_UID = process.getuid(); diff --git a/spikes/pi-wielder/tests/no-tracked-secrets.test.mjs b/spikes/pi-wielder/tests/no-tracked-secrets.test.mjs new file mode 100644 index 0000000..d6d5c1b --- /dev/null +++ b/spikes/pi-wielder/tests/no-tracked-secrets.test.mjs @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const NODE = process.execPath; +const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const SCRIPT = path.join(PACKAGE_ROOT, 'scripts/verify-no-tracked-secrets.mjs'); + +function repository(t) { + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'no-secrets-test-'))); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { force: true, recursive: true })); + const initialized = spawnSync('git', ['init', '-q'], { cwd: directory, encoding: 'utf8' }); + assert.equal(initialized.status, 0, initialized.stderr); + return directory; +} + +function track(directory, relativePath, contents) { + const destination = path.join(directory, relativePath); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, contents); + const added = spawnSync('git', ['add', '--', relativePath], { cwd: directory, encoding: 'utf8' }); + assert.equal(added.status, 0, added.stderr); +} + +function run(directory, { env = {}, args = [], cwd = directory } = {}) { + return spawnSync(NODE, [SCRIPT, ...args], { + cwd, + env: { + PATH: process.env.PATH, + ...env, + }, + encoding: 'utf8', + }); +} + +test('CLI scans the whole repository when invoked from a nested package directory', (t) => { + const directory = repository(t); + const nested = path.join(directory, 'packages', 'wallet'); + fs.mkdirSync(nested, { recursive: true }); + track(directory, 'operator-token', 'placeholder-only'); + track(directory, 'packages/wallet/safe.txt', 'safe\n'); + const result = run(directory, { cwd: nested }); + assert.equal(result.status, 1); + assert.match(result.stderr, /TRACKED_SECRET_FILENAME/); + assert.match(result.stderr, /operator-token/); +}); + +test('tracked-secret scan permits variable names and placeholders in example files', (t) => { + const directory = repository(t); + track(directory, '.env.example', [ + 'CDP_API_KEY_SECRET=replace-me', + 'WALLET_KERNEL_OPERATOR_TOKEN_FILE=/path/set/at/install', + 'WALLET_KERNEL_RECEIPT_KEY_FILE=/path/set/at/install', + '', + ].join('\n')); + track(directory, 'src/config.mjs', "export const name = 'CDP_API_KEY_SECRET';\n"); + const result = run(directory, { env: { CDP_API_KEY_SECRET: 'actual-secret-marker' } }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { scannedFiles: 2, valid: true }); + assert.doesNotMatch(result.stdout + result.stderr, /actual-secret-marker/); +}); + +test('exact, multiline, and base64-like configured secret values fail without disclosure', (t) => { + const cases = [ + ['CDP_API_KEY_SECRET', 'exact-secret-marker', 'prefix exact-secret-marker suffix'], + ['CDP_WALLET_SECRET', 'line-one\nline-two', 'before\nline-one\nline-two\nafter'], + ['SERVICE_AUTH_TOKEN', 'YWdlbnQtc2VjcmV0LXZhbHVl', 'YWdlbnQtc2VjcmV0LXZhbHVl'], + ]; + for (const [name, secret, tracked] of cases) { + const directory = repository(t); + track(directory, 'tracked.txt', tracked); + const result = run(directory, { env: { [name]: secret } }); + assert.equal(result.status, 1); + assert.match(result.stderr, new RegExp(name)); + assert.match(result.stderr, /tracked\.txt/); + assert.doesNotMatch(result.stdout + result.stderr, new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } +}); + +test('owner-only Kernel secret files are scanned while Pi credential is untouched by default', (t) => { + const directory = repository(t); + const privateDirectory = path.join(directory, 'private-authority'); + fs.mkdirSync(privateDirectory, { mode: 0o700 }); + const operatorToken = path.join(privateDirectory, 'operator-token'); + const receiptKey = path.join(privateDirectory, 'receipt-key.pem'); + const agentCredential = path.join(privateDirectory, 'agent-credential.json'); + fs.writeFileSync(operatorToken, 'operator-token-marker\n', { mode: 0o600 }); + fs.writeFileSync(receiptKey, 'receipt-key-marker\n', { mode: 0o600 }); + fs.writeFileSync(agentCredential, 'agent-credential-marker\n', { mode: 0o000 }); + track(directory, 'safe.txt', 'nothing sensitive here\n'); + + const result = run(directory, { + env: { + WALLET_KERNEL_OPERATOR_TOKEN_FILE: operatorToken, + WALLET_KERNEL_RECEIPT_KEY_FILE: receiptKey, + }, + }); + assert.equal(result.status, 0, result.stderr); + + fs.chmodSync(agentCredential, 0o600); + track(directory, 'leak.txt', 'agent-credential-marker'); + const piSide = run(directory, { args: ['--agent-credential', agentCredential] }); + assert.equal(piSide.status, 1); + assert.match(piSide.stderr, /AGENT_CREDENTIAL_FILE/); + assert.doesNotMatch(piSide.stdout + piSide.stderr, /agent-credential-marker/); +}); + +test('secret-bearing filenames and complete private-key encodings fail closed', (t) => { + { + const directory = repository(t); + track(directory, 'operator-token', 'placeholder-only'); + const result = run(directory); + assert.equal(result.status, 1); + assert.match(result.stderr, /TRACKED_SECRET_FILENAME/); + } + { + const directory = repository(t); + const begin = ['-----BEGIN ', 'PRIVATE KEY-----'].join(''); + const end = ['-----END ', 'PRIVATE KEY-----'].join(''); + track(directory, 'source.txt', `${begin}\nQUJDREVGR0g=\n${end}\n`); + const result = run(directory); + assert.equal(result.status, 1); + assert.match(result.stderr, /PRIVATE_KEY_ENCODING/); + assert.doesNotMatch(result.stdout + result.stderr, /QUJDREVGR0g/); + } +}); + +test('agent credential option rejects missing value, symlink, permissive mode, and extra arguments', (t) => { + const directory = repository(t); + track(directory, 'safe.txt', 'safe\n'); + const credential = path.join(directory, 'credential'); + fs.writeFileSync(credential, 'credential-marker\n', { mode: 0o600 }); + const link = path.join(directory, 'credential-link'); + fs.symlinkSync(credential, link); + + assert.equal(run(directory, { args: ['--agent-credential'] }).status, 2); + assert.equal(run(directory, { args: ['--agent-credential', credential, 'extra'] }).status, 2); + assert.equal(run(directory, { args: ['--agent-credential', link] }).status, 2); + fs.chmodSync(credential, 0o644); + assert.equal(run(directory, { args: ['--agent-credential', credential] }).status, 2); +}); diff --git a/spikes/pi-wielder/tests/offline-bootstrap.test.mjs b/spikes/pi-wielder/tests/offline-bootstrap.test.mjs new file mode 100644 index 0000000..d1708fc --- /dev/null +++ b/spikes/pi-wielder/tests/offline-bootstrap.test.mjs @@ -0,0 +1,513 @@ +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { acquireAuthorityLock } from '../src/kernel/authority-lock.mjs'; +import { createIntentRepository } from '../src/kernel/intent-builder.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; +import { runOfflineBootstrap } from '../src/offline-bootstrap.mjs'; +import { runOperatorCli } from '../src/operator/cli.mjs'; + +const TOKEN = Buffer.alloc(32, 0x41).toString('base64url'); +const BASE_POLICY = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-offline-')); + fs.chmodSync(root, 0o700); + const authority = path.join(root, 'authority'); + const inputs = path.join(root, 'inputs'); + const enrollmentInbox = path.join(root, 'enrollment-inbox'); + fs.mkdirSync(authority, { mode: 0o700 }); + fs.mkdirSync(inputs, { mode: 0o700 }); + fs.mkdirSync(enrollmentInbox, { mode: 0o755 }); + fs.chmodSync(authority, 0o700); + fs.chmodSync(inputs, 0o700); + fs.chmodSync(enrollmentInbox, 0o755); + const databasePath = path.join(authority, 'kernel.sqlite'); + const receiptKeyPath = path.join(authority, 'receipt-key.pem'); + const operatorTokenPath = path.join(authority, 'operator.token'); + fs.writeFileSync(operatorTokenPath, TOKEN, { flag: 'wx', mode: 0o600 }); + fs.chmodSync(operatorTokenPath, 0o600); + const config = Object.freeze({ + mode: 'deterministic', + databasePath, + receiptKeyPath, + operatorTokenPath, + operatorSocketPath: null, + origin: 'http://127.0.0.1:8405', + trustedAncestor: root, + enrollmentInboxPath: enrollmentInbox, + expectedAgentUid: process.getuid(), + expectedAgentGid: process.getgid(), + kernelUid: process.getuid(), + kernelGid: process.getgid(), + }); + const env = Object.freeze({ + WALLET_KERNEL_MODE: 'deterministic', + WALLET_KERNEL_DB_FILE: databasePath, + WALLET_KERNEL_RECEIPT_KEY_FILE: receiptKeyPath, + WALLET_KERNEL_OPERATOR_TOKEN_FILE: operatorTokenPath, + WALLET_KERNEL_TRUSTED_ANCESTOR: root, + WALLET_KERNEL_EXPECTED_AGENT_UID: String(process.getuid()), + WALLET_KERNEL_EXPECTED_AGENT_GID: String(process.getgid()), + WALLET_KERNEL_ENROLLMENT_INBOX: enrollmentInbox, + WALLET_KERNEL_OPERATOR_PORT: '8405', + }); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return Object.freeze({ + root, + authority, + inputs, + enrollmentInbox, + databasePath, + receiptKeyPath, + operatorTokenPath, + config, + env, + }); +} + +function writeJson(directory, name, value, { mode = 0o600, canonicalLine = false } = {}) { + const filePath = path.join(directory, name); + const bytes = canonicalLine ? `${canonicalJson(value)}\n` : JSON.stringify(value, null, 2); + fs.writeFileSync(filePath, bytes, { flag: 'wx', mode }); + fs.chmodSync(filePath, mode); + return filePath; +} + +function policyFile(value, policy = BASE_POLICY, name = 'policy.json') { + const filePath = writeJson(value.inputs, name, policy); + return Object.freeze({ filePath, hash: sha256(canonicalJson(policy)) }); +} + +function descriptorForCurrentIdentity() { + return Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: String(process.getuid()), + agentGid: String(process.getgid()), + }); +} + +function capture() { + let value = ''; + return Object.freeze({ + stream: Object.freeze({ write(chunk) { value += String(chunk); return true; } }), + read() { return value; }, + }); +} + +function query(databasePath, sql, ...parameters) { + const database = new DatabaseSync(databasePath, { readBigInts: true }); + try { + return database.prepare(sql).all(...parameters); + } finally { + database.close(); + } +} + +test('bootstrap entrypoint accepts only the exact closed command and configuration schemas', async (t) => { + const value = fixture(t); + await assert.rejects( + runOfflineBootstrap({ + command: { name: 'preflight', extra: true }, + config: value.config, + operatorToken: TOKEN, + }), + (error) => error.code === 'BOOTSTRAP_COMMAND_SCHEMA', + ); + await assert.rejects( + runOfflineBootstrap({ + command: { name: 'preflight' }, + config: { ...value.config, surprise: true }, + operatorToken: TOKEN, + }), + (error) => error.code === 'BOOTSTRAP_CONFIG_SCHEMA', + ); + await assert.rejects( + runOfflineBootstrap({ + command: { name: 'preflight' }, + config: value.config, + operatorToken: `${TOKEN}\n`, + }), + (error) => error.code === 'OPERATOR_TOKEN_INVALID', + ); + await assert.rejects( + runOfflineBootstrap({ + command: { name: 'preflight' }, + config: value.config, + operatorToken: Buffer.alloc(32, 0x42).toString('base64url'), + }), + (error) => error.code === 'OPERATOR_TOKEN_INVALID', + ); + let trapped = false; + const command = new Proxy({ name: 'preflight' }, { + getOwnPropertyDescriptor() { + trapped = true; + throw new Error(`must stay inert ${TOKEN}`); + }, + }); + await assert.rejects( + runOfflineBootstrap({ command, config: value.config, operatorToken: TOKEN }), + (error) => error.code === 'BOOTSTRAP_SCHEMA', + ); + assert.equal(trapped, false); + assert.equal(fs.existsSync(value.databasePath), false); + assert.equal(fs.existsSync(value.receiptKeyPath), false); +}); + +test('policy validation is bounded, offline, lock-owning, and does not open authority SQLite', async (t) => { + const value = fixture(t); + const policy = policyFile(value); + const result = await runOfflineBootstrap({ + command: { name: 'policy-validate', policyPath: policy.filePath }, + config: value.config, + operatorToken: TOKEN, + }); + assert.deepEqual(result, Object.freeze({ + policy: structuredClone(BASE_POLICY), + policyHash: policy.hash, + })); + assert.equal(fs.existsSync(value.databasePath), false); + assert.equal(fs.existsSync(value.receiptKeyPath), false); + assert.equal(fs.existsSync(`${value.databasePath}.authority-lock.sqlite`), true); +}); + +test('direct CLI uses the real offline bootstrap for policy apply and preflight', async (t) => { + const value = fixture(t); + const policy = policyFile(value); + for (const argv of [ + ['policy', 'apply', policy.filePath, '--confirm', policy.hash], + ['preflight'], + ]) { + const stdout = capture(); + const stderr = capture(); + const exitCode = await runOperatorCli({ + argv, + env: value.env, + requestImpl: async () => { throw new Error('offline bootstrap must not use HTTP'); }, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(exitCode, 0, `${argv.join(' ')}: ${stderr.read()}`); + assert.equal(stderr.read(), ''); + assert.doesNotMatch(stdout.read(), /OFFLINE_BOOTSTRAP_UNAVAILABLE/); + assert.equal(stdout.read().includes(TOKEN), false); + } + assert.equal(query(value.databasePath, 'SELECT id FROM policy_versions').length, 1); + assert.equal(fs.readFileSync(value.databasePath).includes(Buffer.from(TOKEN)), false); +}); + +test('agent enrollment single-FD reads one canonical public descriptor and persists no raw token', async (t) => { + const value = fixture(t); + const descriptor = descriptorForCurrentIdentity(); + const descriptorPath = writeJson( + value.enrollmentInbox, + 'agent-enrollment.json', + descriptor, + { mode: 0o644, canonicalLine: true }, + ); + const expectedDescriptorHash = sha256(canonicalJson(descriptor)); + const result = await runOfflineBootstrap({ + command: { name: 'agent-enroll', descriptorPath, expectedDescriptorHash }, + config: value.config, + operatorToken: TOKEN, + }); + assert.deepEqual(Object.keys(result), [ + 'agentInstanceId', 'credentialDigest', 'enrollmentHash', 'agentUid', 'agentGid', + 'state', 'isolation', 'enrolledAt', + ]); + assert.equal(result.enrollmentHash, expectedDescriptorHash); + assert.equal(result.state, 'active'); + assert.equal(result.isolation, 'simulated'); + const rows = query(value.databasePath, 'SELECT * FROM agent_enrollments'); + assert.equal(rows.length, 1); + assert.equal(rows[0].enrollment_hash, expectedDescriptorHash); + assert.equal(fs.readFileSync(value.databasePath).includes(Buffer.from(TOKEN)), false); +}); + +test('direct CLI performs offline agent enrollment without an HTTP or listener dependency', async (t) => { + const value = fixture(t); + const descriptor = descriptorForCurrentIdentity(); + const descriptorPath = writeJson( + value.enrollmentInbox, + 'agent-enrollment.json', + descriptor, + { mode: 0o644, canonicalLine: true }, + ); + const stdout = capture(); + const stderr = capture(); + const exitCode = await runOperatorCli({ + argv: [ + 'agent', 'enroll', descriptorPath, + '--confirm', sha256(canonicalJson(descriptor)), + ], + env: value.env, + requestImpl: async () => { throw new Error('offline enrollment must not use HTTP'); }, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(exitCode, 0, stderr.read()); + assert.equal(stderr.read(), ''); + assert.match(stdout.read(), /^agent-enroll: \{/); + assert.equal(stdout.read().includes(TOKEN), false); + assert.equal(query(value.databasePath, 'SELECT * FROM agent_enrollments').length, 1); +}); + +test('descriptor symlinks, hardlinks, permissive paths, and noncanonical bytes fail without enrollment', async (t) => { + for (const kind of ['symlink', 'hardlink', 'permissive', 'permissive-parent', 'noncanonical']) { + await t.test(kind, async (t) => { + const value = fixture(t); + const descriptor = descriptorForCurrentIdentity(); + const original = writeJson( + value.enrollmentInbox, + 'original.json', + descriptor, + { mode: 0o644, canonicalLine: kind !== 'noncanonical' }, + ); + let descriptorPath = original; + if (kind === 'symlink') { + descriptorPath = path.join(value.enrollmentInbox, 'linked.json'); + fs.symlinkSync(original, descriptorPath); + } else if (kind === 'hardlink') { + descriptorPath = path.join(value.enrollmentInbox, 'linked.json'); + fs.linkSync(original, descriptorPath); + } else if (kind === 'permissive') { + fs.chmodSync(original, 0o666); + } else if (kind === 'permissive-parent') { + fs.chmodSync(value.enrollmentInbox, 0o775); + } + await assert.rejects( + runOfflineBootstrap({ + command: { + name: 'agent-enroll', + descriptorPath, + expectedDescriptorHash: sha256(canonicalJson(descriptor)), + }, + config: value.config, + operatorToken: TOKEN, + }), + (error) => error.code === 'AGENT_DESCRIPTOR_PATH' + || error.code === 'AGENT_DESCRIPTOR_BYTES', + ); + if (fs.existsSync(value.databasePath)) { + assert.equal(query(value.databasePath, 'SELECT id FROM agent_enrollments').length, 0); + } + }); + } +}); + +test('a competing Kernel or bootstrap owner returns AUTHORITY_BUSY before any authority write', async (t) => { + const value = fixture(t); + const policy = policyFile(value); + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: value.root, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); + const owner = acquireAuthorityLock({ + databasePath: value.databasePath, + role: 'kernel', + pathTrust, + }); + try { + await assert.rejects( + runOfflineBootstrap({ + command: { + name: 'policy-apply', + policyPath: policy.filePath, + expectedPolicyHash: policy.hash, + }, + config: value.config, + operatorToken: TOKEN, + }), + (error) => error.code === 'AUTHORITY_BUSY', + ); + assert.equal(fs.existsSync(value.databasePath), false); + assert.equal(fs.existsSync(value.receiptKeyPath), false); + } finally { + owner.close(); + } +}); + +test('semantic or event-chain corruption becomes AUTHORITY_RECOVERY_REQUIRED before requested mutation', async (t) => { + const value = fixture(t); + const initial = policyFile(value); + await runOfflineBootstrap({ + command: { + name: 'policy-apply', + policyPath: initial.filePath, + expectedPolicyHash: initial.hash, + }, + config: value.config, + operatorToken: TOKEN, + }); + + const corruptor = new DatabaseSync(value.databasePath); + try { + corruptor.prepare("UPDATE events SET event_hash = ? WHERE event_type = 'policy.applied'") + .run(`sha256:${'ff'.repeat(32)}`); + } finally { + corruptor.close(); + } + const replacementPolicy = structuredClone(BASE_POLICY); + replacementPolicy.sessionMaxAtomic = '1900000'; + const replacement = policyFile(value, replacementPolicy, 'replacement.json'); + + await assert.rejects( + runOfflineBootstrap({ + command: { + name: 'policy-apply', + policyPath: replacement.filePath, + expectedPolicyHash: replacement.hash, + }, + config: value.config, + operatorToken: TOKEN, + }), + (error) => error.code === 'AUTHORITY_RECOVERY_REQUIRED', + ); + assert.equal(query(value.databasePath, 'SELECT id FROM policy_versions').length, 1); +}); + +test('a domain-commit receipt gap is repaired to exact parity before policy mutation', async (t) => { + const value = fixture(t); + const initial = policyFile(value); + await runOfflineBootstrap({ + command: { + name: 'policy-apply', + policyPath: initial.filePath, + expectedPolicyHash: initial.hash, + }, + config: value.config, + operatorToken: TOKEN, + }); + const descriptor = descriptorForCurrentIdentity(); + const descriptorPath = writeJson( + value.enrollmentInbox, + 'agent-enrollment.json', + descriptor, + { mode: 0o644, canonicalLine: true }, + ); + await runOfflineBootstrap({ + command: { + name: 'agent-enroll', + descriptorPath, + expectedDescriptorHash: sha256(canonicalJson(descriptor)), + }, + config: value.config, + operatorToken: TOKEN, + }); + + const pathTrust = Object.freeze({ + mode: 'deterministic', + trustedAncestor: value.root, + kernelUid: process.getuid(), + agentUid: process.getuid(), + }); + const owner = acquireAuthorityLock({ + databasePath: value.databasePath, + role: 'kernel', + pathTrust, + }); + const store = openKernelStore({ + filePath: value.databasePath, + pathTrust, + now: () => new Date().toISOString(), + }); + try { + let sequence = 0; + const intents = createIntentRepository({ + store, + idFactory: (kind) => `${kind}-offline-gap-${++sequence}`, + now: () => new Date().toISOString(), + allowLoopbackHttp: true, + routeMetadata: {}, + }); + const policy = store.readOne('SELECT id FROM policy_versions WHERE policy_hash = ?', [initial.hash]); + const session = intents.openOrResumeSession({ + agentInstanceId: descriptor.agentInstanceId, + walletAddress: BASE_POLICY.wallet, + policyVersionId: policy.id, + }); + intents.captureIntent({ + sessionId: session.id, + routeId: 'paid-infer', + method: 'POST', + requestUrl: 'https://seller.example/paid/infer', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from('{}'), + purposeLabel: 'skill.invoke', + correlationId: 'offline-receipt-gap', + }); + } finally { + store.close(); + owner.close(); + } + + await runOfflineBootstrap({ + command: { name: 'preflight' }, + config: value.config, + operatorToken: TOKEN, + }); + assert.equal(query(value.databasePath, 'SELECT id FROM signed_receipts').length, 1); + + const crashGap = new DatabaseSync(value.databasePath); + try { + const receiptEvent = crashGap.prepare(`SELECT sequence FROM events + WHERE entity_type = 'signed_receipt' AND event_type = 'receipt.issued'`).get(); + const tail = crashGap.prepare('SELECT MAX(sequence) AS sequence FROM events').get(); + assert.equal(receiptEvent.sequence, tail.sequence, 'receipt issuance must be the removable tail'); + crashGap.exec('BEGIN IMMEDIATE'); + crashGap.prepare("DELETE FROM events WHERE entity_type = 'signed_receipt'").run(); + crashGap.prepare('DELETE FROM signed_receipts').run(); + crashGap.exec('COMMIT'); + } finally { + crashGap.close(); + } + + const replacementPolicy = structuredClone(BASE_POLICY); + replacementPolicy.sessionMaxAtomic = '1900000'; + const replacement = policyFile(value, replacementPolicy, 'replacement-after-gap.json'); + const applied = await runOfflineBootstrap({ + command: { + name: 'policy-apply', + policyPath: replacement.filePath, + expectedPolicyHash: replacement.hash, + }, + config: value.config, + operatorToken: TOKEN, + }); + assert.equal(applied.policyVersion.hash, replacement.hash); + assert.equal(query(value.databasePath, 'SELECT id FROM signed_receipts').length, 1); + assert.equal(query(value.databasePath, `SELECT sequence FROM events + WHERE entity_type = 'signed_receipt' AND event_type = 'receipt.issued'`).length, 1); + assert.equal(query(value.databasePath, 'SELECT id FROM policy_versions').length, 2); +}); + +test('input confirmation is canonical and stale policy confirmation cannot mutate authority', async (t) => { + const value = fixture(t); + const policy = policyFile(value); + await assert.rejects( + runOfflineBootstrap({ + command: { + name: 'policy-apply', + policyPath: policy.filePath, + expectedPolicyHash: `sha256:${'00'.repeat(32)}`, + }, + config: value.config, + operatorToken: TOKEN, + }), + (error) => error.code === 'POLICY_HASH_MISMATCH', + ); + assert.equal(fs.existsSync(value.databasePath), false); + assert.equal(fs.existsSync(value.receiptKeyPath), false); +}); diff --git a/spikes/pi-wielder/tests/operator-api.test.mjs b/spikes/pi-wielder/tests/operator-api.test.mjs new file mode 100644 index 0000000..3360c0b --- /dev/null +++ b/spikes/pi-wielder/tests/operator-api.test.mjs @@ -0,0 +1,1123 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createOperatorApp, + projectOperatorPublicResult, +} from '../src/operator/api.mjs'; +import { createOperatorAuth } from '../src/operator/auth.mjs'; +import { + canonicalJson, + KernelError, + sha256, +} from '../src/kernel/canonical.mjs'; +import { validatePolicyDocument } from '../src/kernel/policy-engine.mjs'; + +const ORIGIN = 'http://127.0.0.1:8405'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const OPERATOR_HASH = `sha256:${'11'.repeat(32)}`; +const INTENT_HASH = `sha256:${'22'.repeat(32)}`; +const CASE_HASH = `sha256:${'33'.repeat(32)}`; +const POLICY_HASH = `sha256:${'44'.repeat(32)}`; +const SESSION_HASH = `sha256:${'55'.repeat(32)}`; +const ENROLLMENT_HASH = `sha256:${'66'.repeat(32)}`; +const PAYMENT_TRANSACTION = `0x${'ab'.repeat(32)}`; +const REFUND_TRANSACTION = `0x${'cd'.repeat(32)}`; + +const POLICY = Object.freeze({ + schemaVersion: 1, + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + wallet: WALLET, + methods: Object.freeze(['GET', 'POST']), + sellers: Object.freeze([Object.freeze({ + origin: 'https://seller.example', + pathPrefixes: Object.freeze(['/paid/']), + payTo: '0x2000000000000000000000000000000000000000', + evidencePath: '/.well-known/wallet-kernel/evidence', + executionSigner: '0x2000000000000000000000000000000000000000', + refundSigner: '0x2000000000000000000000000000000000000000', + refundSource: '0x3000000000000000000000000000000000000000', + perRequestMaxAtomic: '500000', + autoApproveAtomic: '100000', + humanApproveAtomic: '500000', + sellerSessionMaxAtomic: '1000000', + })]), + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '5000000', + challengeMaxAgeMs: 60_000, + approvalTtlMs: 300_000, + maxPendingApprovals: 20, + defaultAction: 'deny', +}); + +const SERVICE_NAMES = Object.freeze([ + 'overview', + 'listPolicies', + 'walletIdentity', + 'applyPolicy', + 'revokeAgent', + 'transitionSessionPolicy', + 'closeSession', + 'listApprovals', + 'approvePending', + 'denyPending', + 'listReceipts', + 'getReceipt', + 'reconcilePayment', + 'reconcileExecution', + 'reconcileRefundObservation', + 'abandonCandidate', + 'exportSession', + 'receiptPublicKey', +]); + +function requestHeaders(channel, { mutation = false } = {}) { + if (channel === 'admin') return { authorization: 'Bearer owner-secret' }; + return { + cookie: 'wallet_kernel_session=browser-session', + ...(mutation ? { origin: ORIGIN, 'x-csrf-token': 'csrf-value' } : {}), + }; +} + +function jsonInit(value, channel = 'admin') { + return { + method: 'POST', + headers: { + ...requestHeaders(channel, { mutation: true }), + 'content-type': 'application/json', + }, + body: canonicalJson(value), + }; +} + +function createAuthFake(calls, overrides = {}) { + const principal = Object.freeze({ operatorIdHash: OPERATOR_HASH }); + const unauthorized = () => { + throw new KernelError('OPERATOR_UNAUTHORIZED', 'operator authentication failed'); + }; + return Object.freeze(Object.assign({ + authenticateBearer(request, options) { + calls.push(Object.freeze({ name: 'authenticateBearer', options })); + if (request.headers.get('authorization') !== 'Bearer owner-secret' + || request.headers.has('cookie')) unauthorized(); + return principal; + }, + authenticateBrowser(request, options) { + calls.push(Object.freeze({ name: 'authenticateBrowser', options })); + if (request.headers.get('cookie') !== 'wallet_kernel_session=browser-session' + || request.headers.has('authorization')) unauthorized(); + if (options.mutation + && (request.headers.get('origin') !== ORIGIN + || request.headers.get('x-csrf-token') !== 'csrf-value')) unauthorized(); + return principal; + }, + issueBrowserLaunch(options) { + calls.push(Object.freeze({ name: 'issueBrowserLaunch', options })); + return Object.freeze({ + url: `${ORIGIN}/operator/#launch=${'A'.repeat(43)}`, + expiresAt: '2026-08-01T12:01:00.000Z', + }); + }, + exchangeBrowserSession(request) { + calls.push(Object.freeze({ name: 'exchangeBrowserSession', request })); + return new Response(null, { + status: 204, + headers: { + 'cache-control': 'no-store', + 'set-cookie': 'wallet_kernel_session=session; HttpOnly; SameSite=Strict; Path=/operator', + 'x-csrf-token': 'csrf-value', + }, + }); + }, + revokeBrowserSession(request) { + calls.push(Object.freeze({ name: 'revokeBrowserSession', request })); + return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } }); + }, + }, overrides)); +} + +function createServicesFake(calls, overrides = {}) { + const services = {}; + for (const name of SERVICE_NAMES) { + services[name] = async (input = {}) => { + calls.push(Object.freeze({ name, input })); + return Object.freeze({ operation: name, accepted: true }); + }; + } + services.walletIdentity = async (input = {}) => { + calls.push(Object.freeze({ name: 'walletIdentity', input })); + return Object.freeze({ address: WALLET }); + }; + return Object.freeze(Object.assign(services, overrides)); +} + +function harness({ + mode = 'deterministic', + transport = 'loopback-demo', + serviceOverrides = {}, + authOverrides = {}, + bodyLimits = { jsonBytes: 65_536 }, +} = {}) { + const authCalls = []; + const serviceCalls = []; + const auth = createAuthFake(authCalls, authOverrides); + const services = createServicesFake(serviceCalls, serviceOverrides); + const app = createOperatorApp({ + auth, + services, + bodyLimits, + mode, + transport, + origin: ORIGIN, + }); + return Object.freeze({ app, auth, services, authCalls, serviceCalls }); +} + +async function operatorRequest(app, pathname, init = {}, channel = 'admin') { + const headers = new Headers(init.headers ?? requestHeaders(channel, { + mutation: init.method !== undefined && init.method !== 'GET', + })); + const response = await app.request(`${ORIGIN}${pathname}`, { ...init, headers }); + return response; +} + +async function successData(response, status = 200) { + assert.equal(response.status, status); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('content-type'), 'application/json'); + const payload = await response.json(); + assert.deepEqual(Object.keys(payload), ['ok', 'data']); + assert.equal(payload.ok, true); + return payload.data; +} + +async function errorCode(response, status) { + assert.equal(response.status, status); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const payload = await response.json(); + assert.deepEqual(Object.keys(payload), ['ok', 'error']); + assert.equal(payload.ok, false); + assert.deepEqual(Object.keys(payload.error), ['code', 'message']); + return payload.error.code; +} + +test('constructor accepts only the narrow inert dependency and channel surfaces', () => { + const value = harness(); + assert.equal(typeof value.app.request, 'function'); + assert.equal(typeof createOperatorApp({ + auth: value.auth, + services: value.services, + bodyLimits: { jsonBytes: 65_536 }, + mode: 'deterministic', + transport: 'loopback-demo', + origin: 'http://127.0.0.1:49152', + }).request, 'function'); + assert.throws(() => createOperatorApp({ + auth: value.auth, + services: value.services, + bodyLimits: { jsonBytes: 65_536 }, + mode: 'cdp-testnet', + transport: 'socket-activated-loopback', + origin: 'http://127.0.0.1:49152', + }), TypeError); + + assert.throws(() => createOperatorApp({ + auth: value.auth, + services: { ...value.services, store: {} }, + bodyLimits: { jsonBytes: 65_536 }, + mode: 'deterministic', + transport: 'loopback-demo', + origin: ORIGIN, + }), TypeError); + assert.throws(() => createOperatorApp({ + auth: value.auth, + services: value.services, + bodyLimits: { jsonBytes: 65_536, evidenceBytes: 1 }, + mode: 'deterministic', + transport: 'loopback-demo', + origin: ORIGIN, + }), TypeError); + for (const [mode, transport] of [ + ['cdp-testnet', 'loopback-demo'], + ['deterministic', 'unix'], + ['deterministic', 'socket-activated-loopback'], + ['cdp-testnet', 'tcp'], + ['other', 'socket-activated-loopback'], + ]) { + assert.throws(() => createOperatorApp({ + auth: value.auth, + services: value.services, + bodyLimits: { jsonBytes: 65_536 }, + mode, + transport, + origin: ORIGIN, + }), TypeError); + } +}); + +test('route table contains only the exact documented methods and paths', () => { + const { app } = harness(); + assert.deepEqual( + app.routes.map(({ method, path }) => `${method} ${path}`).sort(), + [ + 'DELETE /operator/v1/session', + 'GET /operator/v1/approvals', + 'GET /operator/v1/exports/:sessionId', + 'GET /operator/v1/overview', + 'GET /operator/v1/policies', + 'GET /operator/v1/receipt-public-key', + 'GET /operator/v1/receipts', + 'GET /operator/v1/receipts/:receiptId', + 'POST /operator/v1/agents/:agentInstanceId/revoke', + 'POST /operator/v1/approvals/:approvalId/approve', + 'POST /operator/v1/approvals/:approvalId/deny', + 'POST /operator/v1/browser-launch', + 'POST /operator/v1/policies/apply', + 'POST /operator/v1/policies/validate', + 'POST /operator/v1/reconciliations/:intentId/:kind', + 'POST /operator/v1/reconciliations/:intentId/:kind/abandon-candidate', + 'POST /operator/v1/session', + 'POST /operator/v1/sessions/:sessionId/close', + 'POST /operator/v1/sessions/:sessionId/transition-policy', + 'ALL /*', + ].sort(), + ); +}); + +test('a malformed principal returned by auth remains an authentication failure', async () => { + const value = harness({ + authOverrides: { + authenticateBearer() { + return Object.freeze({ operatorIdHash: 'not-a-hash' }); + }, + }, + }); + assert.equal(await errorCode(await operatorRequest( + value.app, + '/operator/v1/overview', + ), 401), 'OPERATOR_UNAUTHORIZED'); + assert.deepEqual(value.serviceCalls, []); +}); + +test('admin-only browser launch authenticates bearer first and accepts no body or query', async () => { + const value = harness(); + const response = await operatorRequest(value.app, '/operator/v1/browser-launch', { + method: 'POST', + }); + assert.deepEqual(await successData(response), { + url: `${ORIGIN}/operator/#launch=${'A'.repeat(43)}`, + expiresAt: '2026-08-01T12:01:00.000Z', + }); + assert.deepEqual(value.authCalls.map(({ name }) => name), [ + 'authenticateBearer', + 'issueBrowserLaunch', + ]); + assert.deepEqual(value.authCalls[0].options, { transport: 'loopback-demo' }); + assert.deepEqual(value.authCalls[1].options, { transport: 'loopback-demo' }); + + const cookieOnly = await operatorRequest(value.app, '/operator/v1/browser-launch', { + method: 'POST', + headers: requestHeaders('console', { mutation: true }), + }); + assert.equal(await errorCode(cookieOnly, 401), 'OPERATOR_UNAUTHORIZED'); + assert.equal(await errorCode(await operatorRequest( + value.app, + '/operator/v1/browser-launch?next=x', + { method: 'POST' }, + ), 400), 'OPERATOR_QUERY_SCHEMA'); + assert.equal(await errorCode(await operatorRequest( + value.app, + '/operator/v1/browser-launch', + jsonInit({}, 'admin'), + ), 400), 'OPERATOR_BODY_FORBIDDEN'); +}); + +test('loopback demo carries both channels while live session routes remain console-only', async () => { + const value = harness(); + const exchange = await operatorRequest(value.app, '/operator/v1/session', { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: canonicalJson({ launchToken: 'A'.repeat(43) }), + }, 'console'); + assert.equal(exchange.status, 204); + assert.match(exchange.headers.get('set-cookie'), /^wallet_kernel_session=/); + + const deletion = await operatorRequest(value.app, '/operator/v1/session', { + method: 'DELETE', + headers: requestHeaders('console', { mutation: true }), + }, 'console'); + assert.equal(deletion.status, 204); + assert.deepEqual(value.authCalls.map(({ name }) => name), [ + 'exchangeBrowserSession', + 'revokeBrowserSession', + ]); + + const admin = harness({ mode: 'cdp-testnet', transport: 'unix' }); + assert.equal(await errorCode(await operatorRequest( + admin.app, + '/operator/v1/session', + jsonInit({ launchToken: 'A'.repeat(43) }), + ), 401), 'OPERATOR_UNAUTHORIZED'); + const liveConsole = harness({ + mode: 'cdp-testnet', + transport: 'socket-activated-loopback', + }); + assert.equal(await errorCode(await operatorRequest( + liveConsole.app, + '/operator/v1/browser-launch', + { method: 'POST', headers: requestHeaders('console', { mutation: true }) }, + 'console', + ), 401), 'OPERATOR_UNAUTHORIZED'); +}); + +test('read routes expose only the injected projections with exact query handling', async () => { + const value = harness(); + const cases = [ + ['/operator/v1/overview', 'overview', {}], + ['/operator/v1/policies', 'listPolicies', {}], + ['/operator/v1/approvals', 'listApprovals', { state: null }], + ['/operator/v1/approvals?state=pending', 'listApprovals', { state: 'pending' }], + ['/operator/v1/approvals?state=approved', 'listApprovals', { state: 'approved' }], + ['/operator/v1/approvals?state=denied', 'listApprovals', { state: 'denied' }], + ['/operator/v1/approvals?state=expired', 'listApprovals', { state: 'expired' }], + ['/operator/v1/approvals?state=cancelled', 'listApprovals', { state: 'cancelled' }], + ['/operator/v1/receipts', 'listReceipts', {}], + ['/operator/v1/receipts/receipt-1', 'getReceipt', { receiptId: 'receipt-1' }], + ['/operator/v1/exports/session-1', 'exportSession', { sessionId: 'session-1' }], + ['/operator/v1/receipt-public-key', 'receiptPublicKey', {}], + ]; + for (const [pathname, name, input] of cases) { + value.serviceCalls.length = 0; + const data = await successData(await operatorRequest(value.app, pathname)); + assert.deepEqual(data, { operation: name, accepted: true }); + assert.deepEqual(value.serviceCalls, [{ name, input }]); + } + + for (const pathname of [ + '/operator/v1/overview?state=pending', + '/operator/v1/approvals?state=unknown', + '/operator/v1/approvals?state=pending&state=pending', + '/operator/v1/approvals?limit=10', + '/operator/v1/receipts?page=1', + '/operator/v1/receipts/not%2Fcanonical', + ]) { + value.serviceCalls.length = 0; + assert.equal( + await errorCode(await operatorRequest(value.app, pathname), 400), + pathname.includes('not%2Fcanonical') ? 'OPERATOR_IDENTIFIER' : 'OPERATOR_QUERY_SCHEMA', + pathname, + ); + assert.deepEqual(value.serviceCalls, [], pathname); + } +}); + +test('policy validation normalizes public policy and apply revalidates the confirmed bytes', async () => { + const value = harness(); + const noncanonical = structuredClone(POLICY); + noncanonical.wallet = WALLET.toUpperCase().replace('0X', '0x'); + noncanonical.sellers[0].origin = 'https://seller.example:443'; + const normalized = validatePolicyDocument(noncanonical); + const displayedHash = sha256(canonicalJson(normalized)); + + const validation = await successData(await operatorRequest( + value.app, + '/operator/v1/policies/validate', + jsonInit({ document: noncanonical }), + )); + assert.deepEqual(validation, { policy: normalized, policyHash: displayedHash }); + assert.deepEqual(value.serviceCalls, []); + + const applied = await successData(await operatorRequest( + value.app, + '/operator/v1/policies/apply', + jsonInit({ document: noncanonical, expectedPolicyHash: displayedHash }), + )); + assert.deepEqual(applied, { operation: 'applyPolicy', accepted: true }); + assert.deepEqual(value.serviceCalls, [ + { name: 'walletIdentity', input: {} }, + { + name: 'applyPolicy', + input: { document: normalized, expectedPolicyHash: displayedHash }, + }, + ]); + + value.serviceCalls.length = 0; + assert.equal(await errorCode(await operatorRequest( + value.app, + '/operator/v1/policies/apply', + jsonInit({ document: noncanonical, expectedPolicyHash: POLICY_HASH }), + ), 409), 'POLICY_CONFIRMATION_STALE'); + assert.deepEqual(value.serviceCalls, []); + + const rotated = harness({ + serviceOverrides: { + async walletIdentity() { return { address: '0x9000000000000000000000000000000000000000' }; }, + }, + }); + assert.equal(await errorCode(await operatorRequest( + rotated.app, + '/operator/v1/policies/apply', + jsonInit({ document: POLICY, expectedPolicyHash: sha256(canonicalJson(POLICY)) }), + ), 409), 'WALLET_ROTATION_REQUIRES_OFFLINE_RESTART'); + assert.equal(rotated.serviceCalls.some(({ name }) => name === 'applyPolicy'), false); +}); + +test('agent, session, and approval mutations call only their narrow aggregate services', async () => { + const value = harness(); + const cases = [ + { + path: '/operator/v1/agents/agent-1/revoke', + body: { expectedEnrollmentHash: ENROLLMENT_HASH }, + service: 'revokeAgent', + input: { + agentInstanceId: 'agent-1', + expectedEnrollmentHash: ENROLLMENT_HASH, + operatorIdHash: OPERATOR_HASH, + }, + }, + { + path: '/operator/v1/sessions/session-1/transition-policy', + body: { targetPolicyHash: POLICY_HASH, expectedSessionHash: SESSION_HASH }, + service: 'transitionSessionPolicy', + input: { + sessionId: 'session-1', + targetPolicyHash: POLICY_HASH, + expectedSessionHash: SESSION_HASH, + }, + }, + { + path: '/operator/v1/sessions/session-1/close', + body: { expectedSessionHash: SESSION_HASH }, + service: 'closeSession', + input: { sessionId: 'session-1', expectedSessionHash: SESSION_HASH }, + }, + { + path: '/operator/v1/approvals/approval-1/approve', + body: { expectedIntentHash: INTENT_HASH }, + service: 'approvePending', + input: { + approvalId: 'approval-1', + expectedIntentHash: INTENT_HASH, + operatorIdHash: OPERATOR_HASH, + }, + }, + { + path: '/operator/v1/approvals/approval-1/deny', + body: { expectedIntentHash: INTENT_HASH, reasonCode: 'OPERATOR_DENIED' }, + service: 'denyPending', + input: { + approvalId: 'approval-1', + expectedIntentHash: INTENT_HASH, + operatorIdHash: OPERATOR_HASH, + reasonCode: 'OPERATOR_DENIED', + }, + }, + ]; + + for (const item of cases) { + value.serviceCalls.length = 0; + const data = await successData(await operatorRequest(value.app, item.path, jsonInit(item.body))); + assert.deepEqual(data, { operation: item.service, accepted: true }); + assert.deepEqual(value.serviceCalls, [{ name: item.service, input: item.input }]); + } + + for (const [path, body] of [ + ['/operator/v1/agents/agent-1/revoke', { + expectedEnrollmentHash: ENROLLMENT_HASH, sessionId: 'session-1', + }], + ['/operator/v1/sessions/session-1/transition-policy', { + targetPolicyHash: POLICY_HASH, expectedSessionHash: SESSION_HASH, wallet: WALLET, + }], + ['/operator/v1/sessions/session-1/close', { + expectedSessionHash: SESSION_HASH, replace: true, + }], + ['/operator/v1/approvals/approval-1/approve', { + expectedIntentHash: INTENT_HASH, amountAtomic: '1', + }], + ['/operator/v1/approvals/approval-1/deny', { + expectedIntentHash: INTENT_HASH, reasonCode: 'CUSTOM_REASON', + }], + ]) { + value.serviceCalls.length = 0; + assert.equal( + await errorCode(await operatorRequest(value.app, path, jsonInit(body)), 400), + path.endsWith('/deny') ? 'APPROVAL_DENIAL_REASON' : 'OPERATOR_BODY_SCHEMA', + path, + ); + assert.deepEqual(value.serviceCalls, [], path); + } +}); + +test('reconciliation accepts only displayed hashes and the one allowed public candidate', async () => { + const value = harness(); + const cases = [ + { + kind: 'payment', + body: { expectedIntentHash: INTENT_HASH, expectedCaseHash: CASE_HASH }, + service: 'reconcilePayment', + input: { + intentId: 'intent-1', + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: INTENT_HASH, + expectedPaymentCaseHash: CASE_HASH, + paymentTransactionId: null, + }, + }, + { + kind: 'payment', + body: { + expectedIntentHash: INTENT_HASH, + expectedCaseHash: CASE_HASH, + paymentTransactionId: PAYMENT_TRANSACTION, + }, + service: 'reconcilePayment', + input: { + intentId: 'intent-1', + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: INTENT_HASH, + expectedPaymentCaseHash: CASE_HASH, + paymentTransactionId: PAYMENT_TRANSACTION, + }, + }, + { + kind: 'execution', + body: { expectedIntentHash: INTENT_HASH, expectedCaseHash: CASE_HASH }, + service: 'reconcileExecution', + input: { + intentId: 'intent-1', + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: INTENT_HASH, + expectedExecutionCaseHash: CASE_HASH, + }, + }, + { + kind: 'refund-observation', + body: { + expectedIntentHash: INTENT_HASH, + expectedCaseHash: CASE_HASH, + refundTransactionId: REFUND_TRANSACTION, + }, + service: 'reconcileRefundObservation', + input: { + intentId: 'intent-1', + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: INTENT_HASH, + expectedRefundCaseHash: CASE_HASH, + refundTransactionId: REFUND_TRANSACTION, + }, + }, + ]; + for (const item of cases) { + value.serviceCalls.length = 0; + const data = await successData(await operatorRequest( + value.app, + `/operator/v1/reconciliations/intent-1/${item.kind}`, + jsonInit(item.body), + )); + assert.deepEqual(data, { operation: item.service, accepted: true }); + assert.deepEqual(value.serviceCalls, [{ name: item.service, input: item.input }]); + } + + for (const [kind, body] of [ + ['unknown', { expectedIntentHash: INTENT_HASH, expectedCaseHash: CASE_HASH }], + ['execution', { + expectedIntentHash: INTENT_HASH, + expectedCaseHash: CASE_HASH, + transactionId: PAYMENT_TRANSACTION, + }], + ['payment', { + expectedIntentHash: INTENT_HASH, + expectedCaseHash: CASE_HASH, + evidence: { status: 'success' }, + }], + ['refund-observation', { + expectedIntentHash: INTENT_HASH, + expectedCaseHash: CASE_HASH, + }], + ['payment', { + expectedIntentHash: INTENT_HASH, + expectedCaseHash: CASE_HASH, + paymentTransactionId: PAYMENT_TRANSACTION.toUpperCase().replace('0X', '0x'), + }], + ]) { + value.serviceCalls.length = 0; + const response = await operatorRequest( + value.app, + `/operator/v1/reconciliations/intent-1/${kind}`, + jsonInit(body), + ); + assert.equal(await errorCode(response, 400), + kind === 'unknown' ? 'RECONCILIATION_KIND' : 'OPERATOR_BODY_SCHEMA'); + assert.deepEqual(value.serviceCalls, []); + } +}); + +test('candidate abandonment is closed to payment and refund observation and keeps evidence out', async () => { + const value = harness(); + for (const kind of ['payment', 'refund-observation']) { + value.serviceCalls.length = 0; + const input = { + intentId: 'intent-1', + kind, + operatorIdHash: OPERATOR_HASH, + expectedIntentHash: INTENT_HASH, + expectedCaseHash: CASE_HASH, + }; + const data = await successData(await operatorRequest( + value.app, + `/operator/v1/reconciliations/intent-1/${kind}/abandon-candidate`, + jsonInit({ expectedIntentHash: INTENT_HASH, expectedCaseHash: CASE_HASH }), + )); + assert.deepEqual(data, { operation: 'abandonCandidate', accepted: true }); + assert.deepEqual(value.serviceCalls, [{ name: 'abandonCandidate', input }]); + } + for (const [kind, body] of [ + ['execution', { expectedIntentHash: INTENT_HASH, expectedCaseHash: CASE_HASH }], + ['payment', { + expectedIntentHash: INTENT_HASH, + expectedCaseHash: CASE_HASH, + paymentTransactionId: PAYMENT_TRANSACTION, + }], + ]) { + value.serviceCalls.length = 0; + assert.equal(await errorCode(await operatorRequest( + value.app, + `/operator/v1/reconciliations/intent-1/${kind}/abandon-candidate`, + jsonInit(body), + ), 400), kind === 'execution' ? 'RECONCILIATION_KIND' : 'OPERATOR_BODY_SCHEMA'); + assert.deepEqual(value.serviceCalls, []); + } +}); + +test('live channels are exclusive and deterministic loopback selects bearer or browser auth', async () => { + const admin = harness({ mode: 'cdp-testnet', transport: 'unix' }); + const adminCookie = await operatorRequest(admin.app, '/operator/v1/overview', { + headers: requestHeaders('console'), + }); + assert.equal(await errorCode(adminCookie, 401), 'OPERATOR_UNAUTHORIZED'); + assert.equal(admin.serviceCalls.length, 0); + + const consoleValue = harness({ + mode: 'cdp-testnet', + transport: 'socket-activated-loopback', + }); + const read = await operatorRequest(consoleValue.app, '/operator/v1/overview', { + headers: requestHeaders('console'), + }, 'console'); + await successData(read); + assert.deepEqual(consoleValue.authCalls.at(-1), { + name: 'authenticateBrowser', + options: { mutation: false }, + }); + + consoleValue.serviceCalls.length = 0; + const mutation = await operatorRequest( + consoleValue.app, + '/operator/v1/approvals/approval-1/approve', + jsonInit({ expectedIntentHash: INTENT_HASH }, 'console'), + 'console', + ); + await successData(mutation); + assert.deepEqual(consoleValue.authCalls.at(-1), { + name: 'authenticateBrowser', + options: { mutation: true }, + }); + + const bearer = await operatorRequest(consoleValue.app, '/operator/v1/overview', { + headers: requestHeaders('admin'), + }, 'console'); + assert.equal(await errorCode(bearer, 401), 'OPERATOR_UNAUTHORIZED'); + + const demo = harness(); + await successData(await operatorRequest(demo.app, '/operator/v1/overview')); + assert.deepEqual(demo.authCalls.at(-1), { + name: 'authenticateBearer', + options: { transport: 'loopback-demo' }, + }); + await successData(await operatorRequest(demo.app, '/operator/v1/overview', { + headers: requestHeaders('console'), + }, 'console')); + assert.deepEqual(demo.authCalls.at(-1), { + name: 'authenticateBrowser', + options: { mutation: false }, + }); +}); + +test('mutation JSON is read with a byte bound and must be exact canonical closed data', async () => { + const value = harness({ bodyLimits: { jsonBytes: 256 } }); + for (const [label, init, code] of [ + ['wrong content type', { + method: 'POST', + headers: { ...requestHeaders('admin'), 'content-type': 'text/plain' }, + body: canonicalJson({ expectedIntentHash: INTENT_HASH }), + }, 'OPERATOR_CONTENT_TYPE'], + ['noncanonical whitespace', { + method: 'POST', + headers: { ...requestHeaders('admin'), 'content-type': 'application/json' }, + body: ` ${canonicalJson({ expectedIntentHash: INTENT_HASH })}`, + }, 'OPERATOR_BODY_SCHEMA'], + ['duplicate key', { + method: 'POST', + headers: { ...requestHeaders('admin'), 'content-type': 'application/json' }, + body: `{"expectedIntentHash":"${INTENT_HASH}","expectedIntentHash":"${INTENT_HASH}"}`, + }, 'OPERATOR_BODY_SCHEMA'], + ['oversized declared body', { + method: 'POST', + headers: { + ...requestHeaders('admin'), + 'content-type': 'application/json', + 'content-length': '999', + }, + body: canonicalJson({ expectedIntentHash: INTENT_HASH }), + }, 'OPERATOR_BODY_TOO_LARGE'], + ['oversized streamed body', { + method: 'POST', + headers: { ...requestHeaders('admin'), 'content-type': 'application/json' }, + body: canonicalJson({ + expectedIntentHash: INTENT_HASH, + padding: 'x'.repeat(300), + }), + }, 'OPERATOR_BODY_TOO_LARGE'], + ]) { + value.serviceCalls.length = 0; + const response = await operatorRequest( + value.app, + '/operator/v1/approvals/approval-1/approve', + init, + ); + assert.equal(await errorCode(response, code === 'OPERATOR_BODY_TOO_LARGE' ? 413 : 400), code, label); + assert.deepEqual(value.serviceCalls, [], label); + } +}); + +test('unknown paths, methods, identifiers, and agent/operator credential crossover fail closed', async () => { + const value = harness(); + const changedOrigin = await value.app.request( + 'http://127.0.0.1:8406/operator/v1/overview', + { headers: requestHeaders('admin') }, + ); + assert.equal(await errorCode(changedOrigin, 401), 'OPERATOR_UNAUTHORIZED'); + assert.deepEqual(value.serviceCalls, []); + const implicitHead = await operatorRequest(value.app, '/operator/v1/overview', { + method: 'HEAD', + }); + assert.equal(implicitHead.status, 404); + assert.deepEqual(value.serviceCalls, []); + for (const [pathname, init, expected] of [ + ['/operator/v1/not-a-route', {}, 404], + ['/operator/v1/overview/', {}, 404], + ['/operator/v1/overview', { method: 'POST' }, 404], + ['/operator/v1/receipts/bad%20id', {}, 400], + ['/operator/v1/agents/-bad/revoke', jsonInit({ expectedEnrollmentHash: ENROLLMENT_HASH }), 400], + ['/agent/v1/status', { headers: requestHeaders('admin') }, 404], + ]) { + value.serviceCalls.length = 0; + const response = await operatorRequest(value.app, pathname, init); + assert.equal(response.status, expected, pathname); + const payload = await response.json(); + assert.equal(payload.ok, false, pathname); + assert.deepEqual(value.serviceCalls, [], pathname); + } + + const agentCredential = await operatorRequest(value.app, '/operator/v1/overview', { + headers: { authorization: 'Bearer agent-capability' }, + }); + assert.equal(await errorCode(agentCredential, 401), 'OPERATOR_UNAUTHORIZED'); + assert.equal(value.serviceCalls.length, 0); + + const nodeStyleEmpty = await operatorRequest(value.app, '/operator/v1/browser-launch', { + method: 'POST', + headers: { ...requestHeaders('admin'), 'content-length': '0' }, + }); + assert.equal(nodeStyleEmpty.status, 200); +}); + +test('service KernelErrors preserve stable public codes while unexpected errors are redacted', async () => { + const kernelSecret = 'kernel-provider-secret-that-must-not-escape'; + const conflict = harness({ + serviceOverrides: { + async closeSession() { + throw new KernelError('SESSION_STATE_CONFLICT', kernelSecret); + }, + }, + }); + const response = await operatorRequest( + conflict.app, + '/operator/v1/sessions/session-1/close', + jsonInit({ expectedSessionHash: SESSION_HASH }), + ); + const conflictInspection = response.clone(); + assert.equal(await errorCode(response, 409), 'SESSION_STATE_CONFLICT'); + assert.equal((await conflictInspection.text()).includes(kernelSecret), false); + + const unhealthy = harness({ + serviceOverrides: { + async overview() { + throw new KernelError('AUTHORITY_UNHEALTHY', kernelSecret); + }, + }, + }); + const unavailable = await operatorRequest(unhealthy.app, '/operator/v1/overview'); + const unavailableInspection = unavailable.clone(); + assert.equal(await errorCode(unavailable, 503), 'AUTHORITY_UNHEALTHY'); + assert.equal((await unavailableInspection.text()).includes(kernelSecret), false); + + for (const [code, expectedStatus] of [ + ['SESSION_TRANSITION_BLOCKED', 409], + ['SESSION_CLOSE_BLOCKED', 409], + ['RECOVERY_ONLY_OPERATION_FORBIDDEN', 409], + ['RECONCILIATION_STATE', 409], + ['POLICY_SCHEMA', 400], + ['POLICY_NETWORK', 400], + ['OPERATOR_CAPACITY', 429], + ]) { + const stable = harness({ + serviceOverrides: { async overview() { throw new KernelError(code, kernelSecret); } }, + }); + const rejected = await operatorRequest(stable.app, '/operator/v1/overview'); + const inspection = rejected.clone(); + assert.equal(await errorCode(rejected, expectedStatus), code); + assert.equal((await inspection.text()).includes(kernelSecret), false); + } + + for (const thrown of [ + new KernelError('PROVIDER_SECRET_SENTINEL', kernelSecret), + Object.assign(new KernelError('AUTHORITY_UNHEALTHY', kernelSecret), { code: { secret: true } }), + ]) { + const invalidCode = harness({ + serviceOverrides: { async overview() { throw thrown; } }, + }); + const invalid = await operatorRequest(invalidCode.app, '/operator/v1/overview'); + const invalidInspection = invalid.clone(); + assert.equal(await errorCode(invalid, 500), 'OPERATOR_INTERNAL'); + const bytes = await invalidInspection.text(); + assert.equal(bytes.includes(kernelSecret), false); + assert.equal(bytes.includes('PROVIDER_SECRET_SENTINEL'), false); + } + + const secret = 'provider-secret-that-must-not-escape'; + const failed = harness({ + serviceOverrides: { + async overview() { throw new Error(secret); }, + }, + }); + const failure = await operatorRequest(failed.app, '/operator/v1/overview'); + const inspection = failure.clone(); + assert.equal(await errorCode(failure, 500), 'OPERATOR_INTERNAL'); + assert.equal((await inspection.text()).includes(secret), false); +}); + +test('service results cross a closed public projection boundary before serialization', async () => { + const sentinel = 'RAW_PRIVATE_PROVIDER_RESPONSE'; + for (const result of [ + { items: [{ content: sentinel }] }, + { items: [{ rawEvidence: sentinel }] }, + { operatorIdHash: OPERATOR_HASH }, + new Proxy({ operation: 'overview', accepted: true }, {}), + ]) { + const value = harness({ + serviceOverrides: { async listReceipts() { return result; } }, + }); + const response = await operatorRequest(value.app, '/operator/v1/receipts'); + const inspection = response.clone(); + assert.equal(await errorCode(response, 500), 'OPERATOR_INTERNAL'); + const bytes = await inspection.text(); + assert.equal(bytes.includes(sentinel), false); + assert.equal(bytes.includes(OPERATOR_HASH), false); + } +}); + +test('the public boundary admits the production signed session projection shape', () => { + const hash = (byte) => `sha256:${byte.repeat(64)}`; + const receiptHash = 'aa'.repeat(32); + const signature = Buffer.alloc(64, 0x41).toString('base64'); + const signedReceipt = { + id: 'receipt-1', + intentId: 'intent-1', + revision: 1, + receipt: { + schemaVersion: 1, + receiptId: 'receipt-1', + revision: 1, + issuedAt: '2026-08-01T12:00:00.000Z', + intent: { + id: 'intent-1', + requestId: 'request-1', + intentHash: hash('1'), + sessionId: 'session-1', + sellerOrigin: 'https://seller.example', + resourcePath: '/paid/infer', + purposeLabel: 'commercial-test', + }, + outcome: { status: 'succeeded', reasonCode: 'PAYMENT_SETTLED' }, + policy: { versionId: 'policy-1', decision: 'allow', reasonCode: 'WITHIN_AUTO_LIMIT' }, + approval: { state: 'not_required', operatorIdHash: null }, + payment: { + state: 'settled', + amountAtomic: '1000', + network: 'eip155:84532', + asset: POLICY.asset, + payTo: POLICY.sellers[0].payTo, + transactionId: PAYMENT_TRANSACTION, + }, + execution: { state: 'succeeded', httpStatus: 200, responseHash: hash('2') }, + budget: { disposition: 'committed', amountAtomic: '1000' }, + reconciliation: null, + refund: null, + supersedesReceiptHash: null, + }, + receiptHash, + signature, + algorithm: 'Ed25519', + keyId: hash('3'), + supersedesReceiptHash: null, + createdAt: '2026-08-01T12:00:00.000Z', + }; + const projection = { + schemaVersion: 1, + domain: 'wallet-kernel.sanitized-projection.v1', + authoritySchemaVersion: 1, + sessionHash: hash('4'), + sessionState: 'active', + wallet: { address: WALLET, adapterHash: hash('5') }, + agentEnrollment: { + enrollmentHash: ENROLLMENT_HASH, + identityHash: hash('6'), + state: 'active', + }, + isolation: { status: 'simulated', preflightDigest: null }, + policies: { + activePolicyHash: POLICY_HASH, + sessionPolicyHash: POLICY_HASH, + historyHashes: [POLICY_HASH], + }, + budgets: { + session: { + reservedAtomic: '0', + committedAtomic: '1000', + releasedAtomic: '0', + unresolvedAtomic: '0', + exposureAtomic: '1000', + }, + wallet: { + reservedAtomic: '0', + committedAtomic: '1000', + releasedAtomic: '0', + unresolvedAtomic: '0', + exposureAtomic: '1000', + }, + }, + approvals: { + approved: 0, + cancelled: 0, + consumed: 0, + denied: 0, + expired: 0, + pending: 0, + }, + blockers: { + blockedIntentCount: 0, + execution: { openCount: 0, reasonCodes: [] }, + payment: { openCount: 0, reasonCodes: [] }, + refund: { openCount: 0, reasonCodes: [] }, + walletBlocked: false, + }, + intents: [{ + intentHash: hash('1'), + requestIdHash: hash('7'), + routeHash: hash('8'), + method: 'POST', + sellerOrigin: 'https://seller.example', + requestUrlHash: hash('9'), + resourceHash: hash('a'), + purposeHash: hash('b'), + correlationHash: hash('c'), + state: 'terminal', + outcome: { status: 'succeeded', reasonCode: 'PAYMENT_SETTLED', revision: 1 }, + createdAt: '2026-08-01T12:00:00.000Z', + updatedAt: '2026-08-01T12:00:00.000Z', + }], + signedReceipts: [signedReceipt], + eventHeadHash: hash('d'), + issuedAt: '2026-08-01T12:00:00.000Z', + }; + const bundle = { + schemaVersion: 1, + domain: 'wallet-kernel.projection-export.v1', + projection, + algorithm: 'Ed25519', + keyId: hash('e'), + publicKeyPem: '-----BEGIN PUBLIC KEY-----\nfixture\n-----END PUBLIC KEY-----\n', + projectionHash: hash('f'), + signature, + }; + + assert.deepEqual(projectOperatorPublicResult(bundle), bundle); + assert.throws(() => projectOperatorPublicResult({ + ...bundle, + projection: { ...projection, rawEvidence: 'provider-secret' }, + }), /non-public field/); +}); + +test('real auth completes bearer launch, fragment exchange, browser mutation, and logout', async () => { + const token = Buffer.alloc(32, 0x71).toString('base64url'); + let randomValue = 0x31; + const auth = createOperatorAuth({ + token, + mode: 'deterministic', + origin: ORIGIN, + now: () => 1_785_585_600_000, + randomBytes(size) { + const bytes = Buffer.alloc(size, randomValue); + randomValue += 1; + return bytes; + }, + }); + const serviceCalls = []; + const app = createOperatorApp({ + auth, + services: createServicesFake(serviceCalls), + bodyLimits: { jsonBytes: 65_536 }, + mode: 'deterministic', + transport: 'loopback-demo', + origin: ORIGIN, + }); + + const launch = await successData(await app.request(`${ORIGIN}/operator/v1/browser-launch`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + })); + assert.equal(JSON.stringify(launch).includes(token), false); + const launchToken = new URL(launch.url).hash.slice('#launch='.length); + const session = await app.request(`${ORIGIN}/operator/v1/session`, { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: canonicalJson({ launchToken }), + }); + assert.equal(session.status, 204); + const cookie = session.headers.get('set-cookie').split(';', 1)[0]; + const csrf = session.headers.get('x-csrf-token'); + + await successData(await app.request(`${ORIGIN}/operator/v1/overview`, { + headers: { cookie }, + })); + await successData(await app.request( + `${ORIGIN}/operator/v1/approvals/approval-1/approve`, + { + method: 'POST', + headers: { cookie, origin: ORIGIN, 'x-csrf-token': csrf, 'content-type': 'application/json' }, + body: canonicalJson({ expectedIntentHash: INTENT_HASH }), + }, + )); + assert.match(serviceCalls.at(-1).input.operatorIdHash, /^sha256:[0-9a-f]{64}$/); + + const logout = await app.request(`${ORIGIN}/operator/v1/session`, { + method: 'DELETE', + headers: { cookie, origin: ORIGIN, 'x-csrf-token': csrf }, + }); + assert.equal(logout.status, 204); + assert.equal(await errorCode(await app.request(`${ORIGIN}/operator/v1/overview`, { + headers: { cookie }, + }), 401), 'OPERATOR_UNAUTHORIZED'); +}); diff --git a/spikes/pi-wielder/tests/operator-auth.test.mjs b/spikes/pi-wielder/tests/operator-auth.test.mjs new file mode 100644 index 0000000..3019f2d --- /dev/null +++ b/spikes/pi-wielder/tests/operator-auth.test.mjs @@ -0,0 +1,500 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + createOperatorAuth, + loadOrCreateOperatorToken, +} from '../src/operator/auth.mjs'; +import { KernelError } from '../src/kernel/canonical.mjs'; + +const CURRENT_UID = process.getuid(); +const ORIGIN = 'http://127.0.0.1:8405'; +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-operator-auth-')); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return Object.freeze({ + directory, + filePath: path.join(directory, 'operator.token'), + pathTrust: Object.freeze({ + mode: 'deterministic', + trustedAncestor: directory, + kernelUid: CURRENT_UID, + agentUid: CURRENT_UID, + }), + }); +} + +function tokenValue(byte = 0x11) { + return Buffer.alloc(32, byte).toString('base64url'); +} + +function deterministicRandom(sequence = [0x21, 0x22, 0x23, 0x24]) { + let index = 0; + return (size) => { + const value = sequence[index] ?? (0x40 + index); + index += 1; + return Buffer.alloc(size, value); + }; +} + +function assertUnauthorized(action, forbidden = []) { + return assert.rejects(action, (error) => { + assert.ok(error instanceof KernelError); + assert.equal(error.code, 'OPERATOR_UNAUTHORIZED'); + assert.equal(error.cause, undefined); + const serialized = `${String(error)} ${JSON.stringify(error)}`; + for (const value of forbidden.filter((candidate) => candidate !== '')) { + assert.equal(serialized.includes(value), false); + } + return true; + }); +} + +function request(pathname, { method = 'GET', headers = {}, body } = {}) { + return new Request(`${ORIGIN}${pathname}`, { method, headers, body }); +} + +function launchToken(result) { + const url = new URL(result.url); + assert.equal(url.origin, ORIGIN); + assert.equal(url.pathname, '/operator/'); + assert.match(url.hash, /^#launch=[A-Za-z0-9_-]{43}$/); + return url.hash.slice('#launch='.length); +} + +function sessionExchange(auth, value, origin = ORIGIN) { + return auth.exchangeBrowserSession(request('/operator/v1/session', { + method: 'POST', + headers: { 'content-type': 'application/json', origin }, + body: JSON.stringify({ launchToken: value }), + })); +} + +function sessionHeaders(response) { + const setCookie = response.headers.get('set-cookie'); + assert.match( + setCookie, + /^wallet_kernel_session=[A-Za-z0-9_-]{43}; HttpOnly; SameSite=Strict; Path=\/operator(?:; Secure)?$/, + ); + return Object.freeze({ + cookie: setCookie.split(';', 1)[0], + csrf: response.headers.get('x-csrf-token'), + }); +} + +test('operator token initializes once as exact owner-only base64url bytes', (t) => { + const value = fixture(t); + const calls = []; + const token = loadOrCreateOperatorToken({ + filePath: value.filePath, + pathTrust: value.pathTrust, + randomBytes(size) { + calls.push(size); + return Buffer.alloc(size, size === 32 ? 0x11 : 0x22); + }, + }); + + assert.equal(token, tokenValue(0x11)); + assert.match(token, TOKEN_PATTERN); + assert.equal(Buffer.from(token, 'base64url').length, 32); + assert.equal(Buffer.from(token, 'base64url').toString('base64url'), token); + assert.equal(fs.readFileSync(value.filePath, 'ascii'), token); + const stat = fs.lstatSync(value.filePath); + assert.equal(stat.isFile(), true); + assert.equal(stat.isSymbolicLink(), false); + assert.equal(stat.uid, CURRENT_UID); + assert.equal(stat.mode & 0o777, 0o600); + assert.deepEqual(calls, [32, 16]); + + const before = fs.statSync(value.filePath, { bigint: true }); + const reused = loadOrCreateOperatorToken({ + filePath: value.filePath, + pathTrust: value.pathTrust, + randomBytes() { + throw new Error('existing owner token must not be replaced'); + }, + }); + const after = fs.statSync(value.filePath, { bigint: true }); + assert.equal(reused, token); + assert.equal(after.ino, before.ino); + assert.equal(after.mtimeNs, before.mtimeNs); +}); + +test('operator token rejects malformed existing bytes without trimming or repair', (t) => { + for (const [label, contents] of [ + ['short', 'abc'], + ['newline', `${tokenValue()}\n`], + ['padding', `${tokenValue()}=`], + ['standard base64', `${tokenValue().slice(0, -1)}+`], + ['non-roundtrip', `${'A'.repeat(42)}B`], + ]) { + const value = fixture(t); + fs.writeFileSync(value.filePath, contents, { mode: 0o600 }); + fs.chmodSync(value.filePath, 0o600); + assert.throws( + () => loadOrCreateOperatorToken({ + filePath: value.filePath, + pathTrust: value.pathTrust, + randomBytes() { throw new Error('must not repair'); }, + }), + undefined, + label, + ); + assert.equal(fs.readFileSync(value.filePath, 'ascii'), contents, label); + } + + const value = fixture(t); + const highBitBytes = Buffer.alloc(43, 0xc1); + fs.writeFileSync(value.filePath, highBitBytes, { mode: 0o600 }); + fs.chmodSync(value.filePath, 0o600); + assert.throws(() => loadOrCreateOperatorToken({ + filePath: value.filePath, + pathTrust: value.pathTrust, + })); + assert.deepEqual(fs.readFileSync(value.filePath), highBitBytes); +}); + +test('operator token rejects symlink, permissive, and wrong-identity-like authority', (t) => { + const value = fixture(t); + const target = path.join(value.directory, 'target.token'); + fs.writeFileSync(target, tokenValue(), { mode: 0o600 }); + fs.symlinkSync(target, value.filePath); + assert.throws(() => loadOrCreateOperatorToken({ + filePath: value.filePath, + pathTrust: value.pathTrust, + })); + fs.unlinkSync(value.filePath); + + fs.writeFileSync(value.filePath, tokenValue(), { mode: 0o644 }); + fs.chmodSync(value.filePath, 0o644); + assert.throws(() => loadOrCreateOperatorToken({ + filePath: value.filePath, + pathTrust: value.pathTrust, + })); + fs.chmodSync(value.filePath, 0o600); + + const wrongIdentity = Object.freeze({ + ...value.pathTrust, + kernelUid: CURRENT_UID + 1, + }); + assert.throws(() => loadOrCreateOperatorToken({ + filePath: value.filePath, + pathTrust: wrongIdentity, + })); +}); + +test('owner bearer is exact, constant-shape, channel-bound, and redacted', async () => { + const token = tokenValue(); + const auth = createOperatorAuth({ + token, + mode: 'deterministic', + origin: ORIGIN, + now: () => 1_785_585_600_000, + randomBytes: deterministicRandom(), + }); + assert.deepEqual(Object.keys(auth).sort(), [ + 'authenticateBearer', + 'authenticateBrowser', + 'exchangeBrowserSession', + 'issueBrowserLaunch', + 'revokeBrowserSession', + ]); + + const principal = auth.authenticateBearer(request('/operator/v1/overview', { + headers: { authorization: `Bearer ${token}` }, + }), { transport: 'loopback-demo' }); + assert.match(principal.operatorIdHash, /^sha256:[0-9a-f]{64}$/); + assert.equal(JSON.stringify(principal).includes(token), false); + + for (const authorization of [ + undefined, + '', + 'bearer value', + `Basic ${token}`, + `Bearer ${token.slice(1)}`, + `Bearer ${token}`, + `Bearer ${token}, Bearer ${token}`, + `Bearer ${tokenValue(0x12)}`, + ]) { + const headers = authorization === undefined ? {} : { authorization }; + await assertUnauthorized( + async () => auth.authenticateBearer(request('/operator/v1/overview', { headers }), { + transport: 'loopback-demo', + }), + [token, authorization ?? ''], + ); + } + await assertUnauthorized( + async () => auth.authenticateBearer(request(`/operator/v1/overview?token=${token}`, { + headers: { authorization: `Bearer ${token}` }, + }), { transport: 'loopback-demo' }), + [token], + ); + await assertUnauthorized( + async () => auth.authenticateBearer(request(`/operator/v1/overview?state=${token}`, { + headers: { authorization: `Bearer ${token}` }, + }), { transport: 'loopback-demo' }), + [token], + ); + await assertUnauthorized( + async () => auth.authenticateBearer(request('/operator/v1/overview', { + headers: { authorization: `Bearer ${token}`, cookie: `owner=${token}` }, + }), { transport: 'loopback-demo' }), + [token], + ); + for (const forwarded of ['forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto']) { + await assertUnauthorized( + async () => auth.authenticateBearer(request('/operator/v1/overview', { + headers: { authorization: `Bearer ${token}`, [forwarded]: 'unix' }, + }), { transport: 'loopback-demo' }), + [token], + ); + } + + const live = createOperatorAuth({ + token, + mode: 'cdp-testnet', + origin: ORIGIN, + now: () => 1_785_585_600_000, + randomBytes: deterministicRandom(), + }); + assert.equal(live.authenticateBearer(request('/operator/v1/overview', { + headers: { authorization: `Bearer ${token}` }, + }), { transport: 'unix' }).operatorIdHash, principal.operatorIdHash); + await assertUnauthorized( + async () => live.authenticateBearer(request('/operator/v1/overview', { + headers: { authorization: `Bearer ${token}` }, + }), { transport: 'loopback-demo' }), + [token], + ); +}); + +test('browser launch is single-use, origin-bound, and restart-invalidated', async () => { + let nowMs = 1_785_585_600_000; + const token = tokenValue(); + const options = { + token, + mode: 'deterministic', + origin: ORIGIN, + now: () => nowMs, + randomBytes: deterministicRandom(), + }; + const auth = createOperatorAuth(options); + const launch = auth.issueBrowserLaunch({ transport: 'loopback-demo' }); + const capability = launchToken(launch); + assert.equal(JSON.stringify(auth).includes(capability), false); + assert.equal(launch.expiresAt, '2026-08-01T12:01:00.000Z'); + + const exchange = await sessionExchange(auth, capability); + assert.equal(exchange.status, 204); + assert.equal(exchange.headers.get('cache-control'), 'no-store'); + const browser = sessionHeaders(exchange); + assert.match(browser.csrf, TOKEN_PATTERN); + assert.equal(browser.cookie.includes(token), false); + + await assertUnauthorized(() => sessionExchange(auth, capability), [capability, token]); + const restarted = createOperatorAuth(options); + await assertUnauthorized(() => sessionExchange(restarted, capability), [capability, token]); + + const changedOriginLaunch = launchToken(auth.issueBrowserLaunch({ transport: 'loopback-demo' })); + await assertUnauthorized( + () => sessionExchange(auth, changedOriginLaunch, 'http://127.0.0.1:8406'), + [changedOriginLaunch, token], + ); + + const expired = launchToken(auth.issueBrowserLaunch({ transport: 'loopback-demo' })); + nowMs += 60_000; + await assertUnauthorized(() => sessionExchange(auth, expired), [expired, token]); +}); + +test('browser capabilities reject a regressing authentication clock', async () => { + let nowMs = 1_785_585_600_000; + const token = tokenValue(); + const auth = createOperatorAuth({ + token, + mode: 'deterministic', + origin: ORIGIN, + now: () => nowMs, + randomBytes: deterministicRandom(), + }); + const capability = launchToken(auth.issueBrowserLaunch({ transport: 'loopback-demo' })); + nowMs -= 1; + await assert.rejects( + () => sessionExchange(auth, capability), + (error) => error instanceof KernelError + && error.code === 'OPERATOR_CONFIGURATION_INVALID', + ); +}); + +test('browser session requires exact cookie, Origin, and CSRF for mutations', async () => { + let nowMs = 1_785_585_600_000; + const token = tokenValue(); + const auth = createOperatorAuth({ + token, + mode: 'deterministic', + origin: ORIGIN, + now: () => nowMs, + randomBytes: deterministicRandom(), + sessionTtlMs: 900_000, + }); + const capability = launchToken(auth.issueBrowserLaunch({ transport: 'loopback-demo' })); + const session = sessionHeaders(await sessionExchange(auth, capability)); + + const readPrincipal = auth.authenticateBrowser(request('/operator/v1/overview', { + headers: { cookie: session.cookie }, + }), { mutation: false }); + assert.match(readPrincipal.operatorIdHash, /^sha256:[0-9a-f]{64}$/); + + const mutationRequest = () => request('/operator/v1/policies/apply', { + method: 'POST', + headers: { + cookie: session.cookie, + origin: ORIGIN, + 'x-csrf-token': session.csrf, + }, + }); + assert.equal( + auth.authenticateBrowser(mutationRequest(), { mutation: true }).operatorIdHash, + readPrincipal.operatorIdHash, + ); + + for (const headers of [ + {}, + { cookie: session.cookie, origin: ORIGIN }, + { cookie: session.cookie, origin: ORIGIN, 'x-csrf-token': tokenValue(0x44) }, + { cookie: session.cookie, origin: 'http://127.0.0.1:8406', 'x-csrf-token': session.csrf }, + { cookie: `${session.cookie}; wallet_kernel_session=${tokenValue(0x45)}`, + origin: ORIGIN, 'x-csrf-token': session.csrf }, + { cookie: session.cookie, origin: ORIGIN, 'x-csrf-token': session.csrf, + authorization: `Bearer ${token}` }, + ]) { + await assertUnauthorized( + async () => auth.authenticateBrowser(request('/operator/v1/policies/apply', { + method: 'POST', headers, + }), { mutation: true }), + [token, session.csrf], + ); + } + + nowMs += 900_000; + await assertUnauthorized( + async () => auth.authenticateBrowser(request('/operator/v1/overview', { + headers: { cookie: session.cookie }, + }), { mutation: false }), + [token], + ); +}); + +test('browser session exchange accepts only the exact bounded canonical body', async () => { + const token = tokenValue(); + const auth = createOperatorAuth({ + token, + mode: 'deterministic', + origin: ORIGIN, + now: () => 1_785_585_600_000, + randomBytes: deterministicRandom(), + }); + const bodies = [ + '{}', + '{"launchToken":"value","unknown":true}', + '{"launchToken":"first","launchToken":"second"}', + ` {"launchToken":"${tokenValue(0x51)}"}`, + 'not-json', + JSON.stringify({ launchToken: 'short' }), + JSON.stringify({ launchToken: tokenValue(0x52), padding: 'x'.repeat(1_024) }), + ]; + for (const body of bodies) { + await assertUnauthorized( + () => auth.exchangeBrowserSession(request('/operator/v1/session', { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body, + })), + [token], + ); + } + const capability = launchToken(auth.issueBrowserLaunch({ transport: 'loopback-demo' })); + await assertUnauthorized( + () => auth.exchangeBrowserSession(request('/operator/v1/session', { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'text/plain' }, + body: JSON.stringify({ launchToken: capability }), + })), + [capability, token], + ); +}); + +test('browser logout invalidates server state and clears the cookie', async () => { + const token = tokenValue(); + const auth = createOperatorAuth({ + token, + mode: 'deterministic', + origin: ORIGIN, + now: () => 1_785_585_600_000, + randomBytes: deterministicRandom(), + }); + const capability = launchToken(auth.issueBrowserLaunch({ transport: 'loopback-demo' })); + const session = sessionHeaders(await sessionExchange(auth, capability)); + const logoutRequest = request('/operator/v1/session', { + method: 'DELETE', + headers: { + cookie: session.cookie, + origin: ORIGIN, + 'x-csrf-token': session.csrf, + }, + }); + const response = auth.revokeBrowserSession(logoutRequest); + assert.equal(response.status, 204); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.match(response.headers.get('set-cookie'), /^wallet_kernel_session=;.*Max-Age=0/); + await assertUnauthorized( + async () => auth.authenticateBrowser(request('/operator/v1/overview', { + headers: { cookie: session.cookie }, + }), { mutation: false }), + [token], + ); +}); + +test('live browser launch is Unix-admin-only and secure cookies follow TLS origin', async () => { + const token = tokenValue(); + const live = createOperatorAuth({ + token, + mode: 'cdp-testnet', + origin: ORIGIN, + now: () => 1_785_585_600_000, + randomBytes: deterministicRandom(), + }); + assert.throws( + () => live.issueBrowserLaunch({ transport: 'loopback-demo' }), + (error) => error.code === 'OPERATOR_UNAUTHORIZED', + ); + const capability = launchToken(live.issueBrowserLaunch({ transport: 'unix' })); + const response = await sessionExchange(live, capability); + assert.doesNotMatch(response.headers.get('set-cookie'), /; Secure$/); + + const tlsOrigin = 'https://127.0.0.1:8405'; + const tls = createOperatorAuth({ + token, + mode: 'deterministic', + origin: tlsOrigin, + now: () => 1_785_585_600_000, + randomBytes: deterministicRandom(), + }); + const tlsLaunch = tls.issueBrowserLaunch({ transport: 'loopback-demo' }); + const tlsCapability = new URL(tlsLaunch.url).hash.slice('#launch='.length); + const tlsResponse = await tls.exchangeBrowserSession(new Request( + `${tlsOrigin}/operator/v1/session`, + { + method: 'POST', + headers: { origin: tlsOrigin, 'content-type': 'application/json' }, + body: JSON.stringify({ launchToken: tlsCapability }), + }, + )); + assert.match(tlsResponse.headers.get('set-cookie'), /; Secure$/); +}); diff --git a/spikes/pi-wielder/tests/operator-cli.test.mjs b/spikes/pi-wielder/tests/operator-cli.test.mjs new file mode 100644 index 0000000..e1ee013 --- /dev/null +++ b/spikes/pi-wielder/tests/operator-cli.test.mjs @@ -0,0 +1,721 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { canonicalJson } from '../src/kernel/canonical.mjs'; +import { runOperatorCli } from '../src/operator/cli.mjs'; + +const ORIGIN = 'http://127.0.0.1:8405'; +const TOKEN = Buffer.alloc(32, 0x41).toString('base64url'); +const LAUNCH_TOKEN = Buffer.alloc(32, 0x22).toString('base64url'); +const SHA_A = `sha256:${'a1'.repeat(32)}`; +const SHA_B = `sha256:${'b2'.repeat(32)}`; +const TX_A = `0x${'ab'.repeat(32)}`; +const TX_B = `0x${'cd'.repeat(32)}`; + +function makeFixture(t, { mode = 'deterministic' } = {}) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-kernel-cli-')); + fs.chmodSync(directory, 0o700); + const tokenPath = path.join(directory, 'operator.token'); + fs.writeFileSync(tokenPath, TOKEN, { mode: 0o600, flag: 'wx' }); + fs.chmodSync(tokenPath, 0o600); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return Object.freeze({ + directory, + tokenPath, + socketPath: path.join(directory, 'operator.sock'), + env: Object.freeze({ + WALLET_KERNEL_MODE: mode, + WALLET_KERNEL_DB_FILE: path.join(directory, 'authority.sqlite'), + WALLET_KERNEL_OPERATOR_TOKEN_FILE: tokenPath, + WALLET_KERNEL_OPERATOR_PORT: '8405', + ...(mode === 'cdp-testnet' + ? { WALLET_KERNEL_OPERATOR_SOCKET_FILE: path.join(directory, 'operator.sock') } + : {}), + }), + }); +} + +function capture() { + let value = ''; + return Object.freeze({ + stream: Object.freeze({ write(chunk) { value += String(chunk); return true; } }), + read() { return value; }, + }); +} + +function response(body = { state: 'ok' }, status = 200, headers = {}) { + const payload = status >= 200 && status < 300 ? { ok: true, data: body } : body; + return Object.freeze({ + status, + headers: Object.freeze({ + 'cache-control': 'no-store', + 'content-type': 'application/json', + ...headers, + }), + body: canonicalJson(payload), + }); +} + +function fakeSocketMetadata(t, socketPath) { + const realLstatSync = fs.lstatSync.bind(fs); + t.mock.method(fs, 'lstatSync', (location, options) => { + const stat = realLstatSync(location, options); + if (location !== socketPath) return stat; + return new Proxy(stat, { + get(target, property, receiver) { + if (property === 'isSocket') return () => true; + if (property === 'isFile' || property === 'isSymbolicLink') return () => false; + return Reflect.get(target, property, receiver); + }, + }); + }); +} + +async function invoke(t, argv, { + mode = 'deterministic', + requestImpl = async () => response(), + offlineBootstrap, + env: envOverrides = {}, +} = {}) { + const fixture = makeFixture(t, { mode }); + const stdout = capture(); + const stderr = capture(); + const exitCode = await runOperatorCli({ + argv, + env: { ...fixture.env, ...envOverrides }, + requestImpl, + offlineBootstrap, + stdout: stdout.stream, + stderr: stderr.stream, + }); + return Object.freeze({ exitCode, stdout: stdout.read(), stderr: stderr.read(), fixture }); +} + +test('online commands map to the exact closed operator API request contract', async (t) => { + const cases = [ + { + argv: ['agent', 'revoke', 'agent-1', '--confirm', SHA_A], + method: 'POST', path: '/operator/v1/agents/agent-1/revoke', + body: { expectedEnrollmentHash: SHA_A }, + }, + { + argv: ['console', 'launch'], method: 'POST', path: '/operator/v1/browser-launch', body: null, + result: { url: `${ORIGIN}/operator/#launch=${LAUNCH_TOKEN}`, expiresAt: '2026-08-02T00:01:00.000Z' }, + }, + { + argv: ['sessions', 'transition', 'session-1', '--to-policy', SHA_B, '--confirm', SHA_A], + method: 'POST', path: '/operator/v1/sessions/session-1/transition-policy', + body: { targetPolicyHash: SHA_B, expectedSessionHash: SHA_A }, + }, + { + argv: ['sessions', 'close', 'session-1', '--confirm', SHA_A], + method: 'POST', path: '/operator/v1/sessions/session-1/close', + body: { expectedSessionHash: SHA_A }, + }, + { + argv: ['approvals', 'list'], method: 'GET', path: '/operator/v1/approvals', body: null, + }, + { + argv: ['approvals', 'list', '--state', 'pending'], + method: 'GET', path: '/operator/v1/approvals?state=pending', body: null, + }, + { + argv: ['approvals', 'approve', 'approval-1', '--confirm', SHA_A], + method: 'POST', path: '/operator/v1/approvals/approval-1/approve', + body: { expectedIntentHash: SHA_A }, + }, + { + argv: ['approvals', 'deny', 'approval-1', '--confirm', SHA_A, '--reason', 'OPERATOR_DENIED'], + method: 'POST', path: '/operator/v1/approvals/approval-1/deny', + body: { expectedIntentHash: SHA_A, reasonCode: 'OPERATOR_DENIED' }, + }, + { + argv: ['receipts', 'list'], method: 'GET', path: '/operator/v1/receipts', body: null, + }, + { + argv: ['receipts', 'verify', 'receipt-1'], + method: 'GET', path: '/operator/v1/receipts/receipt-1', body: null, + }, + { + argv: ['reconcile', 'payment', 'intent-1', '--confirm', SHA_A, '--confirm-case', SHA_B], + method: 'POST', path: '/operator/v1/reconciliations/intent-1/payment', + body: { expectedIntentHash: SHA_A, expectedCaseHash: SHA_B }, + }, + { + argv: ['reconcile', 'payment', 'intent-1', '--confirm', SHA_A, '--confirm-case', SHA_B, + '--payment-transaction', TX_A], + method: 'POST', path: '/operator/v1/reconciliations/intent-1/payment', + body: { expectedIntentHash: SHA_A, expectedCaseHash: SHA_B, paymentTransactionId: TX_A }, + }, + { + argv: ['reconcile', 'execution', 'intent-1', '--confirm', SHA_A, '--confirm-case', SHA_B], + method: 'POST', path: '/operator/v1/reconciliations/intent-1/execution', + body: { expectedIntentHash: SHA_A, expectedCaseHash: SHA_B }, + }, + { + argv: ['reconcile', 'refund-observation', 'intent-1', '--confirm', SHA_A, + '--confirm-case', SHA_B, '--refund-transaction', TX_B], + method: 'POST', path: '/operator/v1/reconciliations/intent-1/refund-observation', + body: { expectedIntentHash: SHA_A, expectedCaseHash: SHA_B, refundTransactionId: TX_B }, + }, + { + argv: ['reconcile', 'abandon-candidate', 'intent-1', '--kind', 'payment', + '--confirm', SHA_A, '--confirm-case', SHA_B], + method: 'POST', path: '/operator/v1/reconciliations/intent-1/payment/abandon-candidate', + body: { expectedIntentHash: SHA_A, expectedCaseHash: SHA_B }, + }, + { + argv: ['reconcile', 'abandon-candidate', 'intent-1', '--kind', 'refund-observation', + '--confirm', SHA_A, '--confirm-case', SHA_B], + method: 'POST', path: '/operator/v1/reconciliations/intent-1/refund-observation/abandon-candidate', + body: { expectedIntentHash: SHA_A, expectedCaseHash: SHA_B }, + }, + ]; + + for (const value of cases) { + let captured; + const result = await invoke(t, value.argv, { + requestImpl: async (input) => { + captured = input; + return response(value.result ?? { commandState: 'ok' }); + }, + }); + assert.equal(result.exitCode, 0, `${value.argv.join(' ')}: ${result.stderr}`); + assert.equal(result.stderr, ''); + assert.deepEqual(Object.keys(captured), [ + 'socketPath', 'origin', 'method', 'path', 'headers', 'body', + ]); + assert.equal(captured.socketPath, null); + assert.equal(captured.origin, ORIGIN); + assert.equal(captured.method, value.method); + assert.equal(captured.path, value.path); + assert.deepEqual(captured.body, value.body === null ? null : canonicalJson(value.body)); + assert.deepEqual(captured.headers, Object.freeze({ + accept: 'application/json', + authorization: `Bearer ${TOKEN}`, + ...(value.body === null ? {} : { 'content-type': 'application/json' }), + })); + assert.equal(`${result.stdout}${result.stderr}`.includes(TOKEN), false); + } +}); + +test('offline commands dispatch only through the narrow bootstrap capability', async (t) => { + const cases = [ + { argv: ['preflight'], command: { name: 'preflight' } }, + { + argv: ['agent', 'enroll', '/input/agent.json', '--confirm', SHA_A], + command: { name: 'agent-enroll', descriptorPath: '/input/agent.json', expectedDescriptorHash: SHA_A }, + }, + { + argv: ['isolation', 'attest', '/input/report.json', '--confirm', SHA_A], + command: { name: 'isolation-attest', reportPath: '/input/report.json', expectedReportHash: SHA_A }, + }, + { + argv: ['policy', 'validate', '/input/policy.json'], + command: { name: 'policy-validate', policyPath: '/input/policy.json' }, + }, + { + argv: ['policy', 'apply', '/input/policy.json', '--confirm', SHA_A], + command: { name: 'policy-apply', policyPath: '/input/policy.json', expectedPolicyHash: SHA_A }, + }, + ]; + + for (const value of cases) { + let captured; + const result = await invoke(t, value.argv, { + requestImpl: async () => { throw new Error('offline command must not use HTTP'); }, + offlineBootstrap: async (input) => { + captured = input; + return Object.freeze({ state: 'ok' }); + }, + }); + assert.equal(result.exitCode, 0, `${value.argv.join(' ')}: ${result.stderr}`); + assert.deepEqual(captured.command, value.command); + assert.equal(captured.operatorToken, TOKEN); + assert.deepEqual(captured.config, Object.freeze({ + mode: 'deterministic', + databasePath: result.fixture.env.WALLET_KERNEL_DB_FILE, + receiptKeyPath: null, + operatorTokenPath: result.fixture.tokenPath, + operatorSocketPath: null, + origin: ORIGIN, + trustedAncestor: null, + enrollmentInboxPath: null, + expectedAgentUid: process.getuid(), + expectedAgentGid: process.getgid(), + kernelUid: process.getuid(), + kernelGid: process.getgid(), + })); + assert.equal(Object.isFrozen(captured), true); + assert.equal(Object.isFrozen(captured.command), true); + assert.equal(Object.isFrozen(captured.config), true); + assert.equal(`${result.stdout}${result.stderr}`.includes(TOKEN), false); + } +}); + +test('parser rejects missing operands, unknown or duplicate flags, and noncanonical values with exit 2', async (t) => { + const invalid = [ + [], + ['unknown'], + ['agent', 'revoke'], + ['agent', 'revoke', 'agent-1', '--confirm', SHA_A, '--surprise'], + ['agent', 'revoke', 'agent-1', '--confirm', SHA_A, '--confirm', SHA_A], + ['agent', 'revoke', '../agent', '--confirm', SHA_A], + ['policy', 'validate', 'relative-policy.json'], + ['policy', 'apply', '/input/policy.json'], + ['approvals', 'list', '--state', 'approved'], + ['approvals', 'deny', 'approval-1', '--confirm', SHA_A, '--reason', 'anything'], + ['reconcile', 'payment', 'intent-1', '--confirm', SHA_A, '--confirm-case', SHA_B, + '--payment-transaction', TX_A.toUpperCase()], + ['reconcile', 'execution', 'intent-1', '--confirm', SHA_A, '--confirm-case', SHA_B, + '--payment-transaction', TX_A], + ['reconcile', 'refund-observation', 'intent-1', '--confirm', SHA_A, '--confirm-case', SHA_B], + ['reconcile', 'abandon-candidate', 'intent-1', '--kind', 'execution', + '--confirm', SHA_A, '--confirm-case', SHA_B], + ['receipts', 'list', '--json', '--json'], + ['export', 'session-1'], + ]; + for (const argv of invalid) { + let calls = 0; + const result = await invoke(t, argv, { + requestImpl: async () => { calls += 1; return response(); }, + offlineBootstrap: async () => { calls += 1; return {}; }, + }); + assert.equal(result.exitCode, 2, `${argv.join(' ')}: ${result.stderr}`); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /^error: CLI_USAGE\nusage: wallet-kernel /); + assert.equal(calls, 0); + } +}); + +test('parser rejects proxy argv without invoking its traps', async (t) => { + const fixture = makeFixture(t); + let trapped = false; + const argv = new Proxy(['receipts', 'list'], { + getPrototypeOf() { + trapped = true; + throw new Error(`must stay inert ${TOKEN}`); + }, + }); + const stdout = capture(); + const stderr = capture(); + let calls = 0; + const exitCode = await runOperatorCli({ + argv, + env: fixture.env, + requestImpl: async () => { calls += 1; return response(); }, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(exitCode, 2); + assert.equal(trapped, false); + assert.equal(calls, 0); + assert.equal(stdout.read(), ''); + assert.equal(stderr.read(), `error: CLI_USAGE\nusage: wallet-kernel [options]\n`); + assert.equal(stderr.read().includes(TOKEN), false); +}); + +test('--json returns one closed success or usage object', async (t) => { + const success = await invoke(t, ['receipts', 'list', '--json'], { + requestImpl: async () => response({ receipts: [] }), + }); + assert.equal(success.exitCode, 0); + assert.deepEqual(JSON.parse(success.stdout), { + command: 'receipts-list', + ok: true, + result: { receipts: [] }, + }); + assert.equal(success.stdout.trimEnd().split('\n').length, 1); + assert.equal(success.stderr, ''); + + const usage = await invoke(t, ['receipts', 'list', '--bogus', '--json']); + assert.equal(usage.exitCode, 2); + assert.equal(usage.stdout, ''); + assert.deepEqual(JSON.parse(usage.stderr), { + error: { code: 'CLI_USAGE' }, + ok: false, + }); +}); + +test('authenticated API failures and transport exceptions are stable, bounded, and redacted', async (t) => { + for (const [label, requestImpl, expected] of [ + ['api', async () => response({ ok: false, error: { code: 'APPROVAL_STALE', message: TOKEN } }, 409), 'APPROVAL_STALE'], + ['transport', async () => { const error = new Error(`provider ${TOKEN}`); error.code = 'ECONNREFUSED'; throw error; }, 'OPERATOR_REQUEST_FAILED'], + ['malformed', async () => ({ status: 500, headers: {}, body: `{\"error\":\"${TOKEN}` }), 'OPERATOR_RESPONSE_INVALID'], + ]) { + const result = await invoke(t, ['receipts', 'list'], { requestImpl }); + assert.equal(result.exitCode, 1, label); + assert.equal(result.stdout, '', label); + assert.equal(result.stderr, `error: ${expected}\n`, label); + assert.equal(result.stderr.includes(TOKEN), false, label); + } +}); + +test('successful response projections are bounded and fail closed on secret-bearing fields', async (t) => { + for (const body of [ + { operatorToken: TOKEN }, + { nested: { paymentSignature: '0x1234' } }, + { rows: [{ credential: 'agent-secret' }] }, + { value: TOKEN }, + { nested: { rawEvidence: 'provider material' } }, + { nested: { rawPrompt: 'private prompt' } }, + { nested: { providerSecretValue: 'provider secret' } }, + { nested: { privateKeyHex: '0x1234' } }, + { nested: { accessToken: 'opaque access' } }, + { nested: { seedPhrase: 'wallet words' } }, + { nested: { signature: 'unbound signature' } }, + { tokenContract: 'provider secret' }, + { authorizationState: 'provider secret' }, + { privateKeyHash: SHA_A }, + { operatorTokenHash: SHA_A }, + { content: 'RAW_PRIVATE_PROVIDER_RESPONSE' }, + { + domain: 'wallet-kernel.projection-export.v1', + algorithm: 'Ed25519', + projectionHash: SHA_A, + signature: Buffer.alloc(64, 0x41).toString('base64'), + }, + ]) { + const result = await invoke(t, ['receipts', 'list'], { + requestImpl: async () => response(body), + }); + assert.equal(result.exitCode, 1, JSON.stringify(body)); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, 'error: OPERATOR_RESPONSE_UNSAFE\n'); + assert.equal(`${result.stdout}${result.stderr}`.includes(TOKEN), false); + } + + const oversized = await invoke(t, ['receipts', 'list'], { + requestImpl: async () => ({ + status: 200, + headers: { 'cache-control': 'no-store', 'content-type': 'application/json' }, + body: `{"ok":true,"data":{"value":"${'a'.repeat(1_048_577)}"}}`, + }), + }); + assert.equal(oversized.exitCode, 1); + assert.equal(oversized.stdout, ''); + assert.equal(oversized.stderr, 'error: OPERATOR_RESPONSE_INVALID\n'); + + let tooDeep = { leaf: true }; + for (let index = 0; index < 80; index += 1) tooDeep = { nested: tooDeep }; + const deep = await invoke(t, ['receipts', 'list'], { + requestImpl: async () => response(tooDeep), + }); + assert.equal(deep.exitCode, 1); + assert.equal(deep.stdout, ''); + assert.equal(deep.stderr, 'error: OPERATOR_RESPONSE_UNSAFE\n'); + + const publicProof = { + authorizationState: false, + credentialHash: SHA_A, + tokenContract: `0x${'12'.repeat(20)}`, + }; + const publicResult = await invoke(t, ['receipts', 'list', '--json'], { + requestImpl: async () => response(publicProof), + }); + assert.equal(publicResult.exitCode, 0, publicResult.stderr); + assert.deepEqual(JSON.parse(publicResult.stdout).result, publicProof); +}); + +test('console launch prints only a validated one-time fragment URL', async (t) => { + const launch = { + url: `${ORIGIN}/operator/#launch=${Buffer.alloc(32, 0x22).toString('base64url')}`, + expiresAt: '2026-08-02T00:01:00.000Z', + }; + const textResult = await invoke(t, ['console', 'launch'], { + requestImpl: async () => response(launch), + }); + assert.equal(textResult.exitCode, 0); + assert.equal(textResult.stdout, `${launch.url}\n`); + assert.equal(textResult.stderr, ''); + + const jsonResult = await invoke(t, ['console', 'launch', '--json'], { + requestImpl: async () => response(launch), + }); + assert.deepEqual(JSON.parse(jsonResult.stdout), { + command: 'console-launch', + expiresAt: launch.expiresAt, + ok: true, + url: launch.url, + }); + + const hostile = await invoke(t, ['console', 'launch'], { + requestImpl: async () => response({ ...launch, url: `https://evil.example/#launch=${LAUNCH_TOKEN}` }), + }); + assert.equal(hostile.exitCode, 1); + assert.equal(hostile.stdout, ''); + assert.equal(hostile.stderr, 'error: OPERATOR_RESPONSE_INVALID\n'); + assert.equal(hostile.stderr.includes(TOKEN), false); +}); + +test('live mode sends bearer only through a validated owner-only Unix socket', async (t) => { + const fixture = makeFixture(t, { mode: 'cdp-testnet' }); + fs.writeFileSync(fixture.socketPath, '', { mode: 0o600, flag: 'wx' }); + fs.chmodSync(fixture.socketPath, 0o600); + fakeSocketMetadata(t, fixture.socketPath); + + const stdout = capture(); + const stderr = capture(); + let captured; + const exitCode = await runOperatorCli({ + argv: ['receipts', 'list'], + env: fixture.env, + requestImpl: async (input) => { captured = input; return response({ receipts: [] }); }, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(exitCode, 0, stderr.read()); + assert.equal(captured.socketPath, fixture.socketPath); + assert.equal(captured.origin, ORIGIN); + assert.equal(captured.headers.authorization, `Bearer ${TOKEN}`); + assert.deepEqual(Object.keys(captured), [ + 'socketPath', 'origin', 'method', 'path', 'headers', 'body', + ]); +}); + +test('built-in live HTTP adapter pins Host to the authenticated loopback origin over UDS', async (t) => { + const fixture = makeFixture(t, { mode: 'cdp-testnet' }); + fs.writeFileSync(fixture.socketPath, '', { mode: 0o600, flag: 'wx' }); + fs.chmodSync(fixture.socketPath, 0o600); + fakeSocketMetadata(t, fixture.socketPath); + let requestOptions; + t.mock.method(http, 'request', (options, onResponse) => { + requestOptions = options; + const request = new EventEmitter(); + request.write = () => { throw new Error('GET must not have a body'); }; + request.destroy = (error) => request.emit('error', error); + request.end = () => { + const result = response({ receipts: [] }); + const incoming = new EventEmitter(); + incoming.statusCode = result.status; + incoming.headers = result.headers; + queueMicrotask(() => { + onResponse(incoming); + incoming.emit('data', Buffer.from(result.body)); + incoming.emit('end'); + }); + }; + return request; + }); + const stdout = capture(); + const stderr = capture(); + const exitCode = await runOperatorCli({ + argv: ['receipts', 'list'], + env: fixture.env, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(exitCode, 0, stderr.read()); + assert.equal(requestOptions.socketPath, fixture.socketPath); + assert.equal(requestOptions.hostname, undefined); + assert.equal(requestOptions.port, undefined); + assert.equal(requestOptions.headers.host, '127.0.0.1:8405'); + assert.equal(requestOptions.headers.authorization, `Bearer ${TOKEN}`); +}); + +test('live mode rejects missing, regular, symlink, and permissive socket paths before request', async (t) => { + for (const socketCase of ['missing', 'regular', 'symlink']) { + const fixture = makeFixture(t, { mode: 'cdp-testnet' }); + if (socketCase === 'regular') { + fs.writeFileSync(fixture.socketPath, 'not-a-socket', { mode: 0o600 }); + } else if (socketCase === 'symlink') { + const target = path.join(fixture.directory, 'target'); + fs.writeFileSync(target, 'not-a-socket', { mode: 0o600 }); + fs.symlinkSync(target, fixture.socketPath); + } + let calls = 0; + const stdout = capture(); + const stderr = capture(); + const exitCode = await runOperatorCli({ + argv: ['receipts', 'list'], + env: fixture.env, + requestImpl: async () => { calls += 1; return response(); }, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(exitCode, 1, socketCase); + assert.equal(stdout.read(), '', socketCase); + assert.equal(stderr.read(), 'error: OPERATOR_CHANNEL_INVALID\n', socketCase); + assert.equal(calls, 0, socketCase); + } + + await t.test('permissive socket metadata', async (t) => { + const fixture = makeFixture(t, { mode: 'cdp-testnet' }); + fs.writeFileSync(fixture.socketPath, '', { mode: 0o600, flag: 'wx' }); + fs.chmodSync(fixture.socketPath, 0o666); + fakeSocketMetadata(t, fixture.socketPath); + let calls = 0; + const stdout = capture(); + const stderr = capture(); + const exitCode = await runOperatorCli({ + argv: ['receipts', 'list'], + env: fixture.env, + requestImpl: async () => { calls += 1; return response(); }, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(exitCode, 1); + assert.equal(stdout.read(), ''); + assert.equal(stderr.read(), 'error: OPERATOR_CHANNEL_INVALID\n'); + assert.equal(calls, 0); + }); +}); + +test('owner token authority is strict and never repaired or disclosed', async (t) => { + for (const [label, mutate] of [ + ['newline', (fixture) => fs.writeFileSync(fixture.tokenPath, `${TOKEN}\n`, { mode: 0o600 })], + ['non-ASCII alias', (fixture) => { + const bytes = Buffer.from(TOKEN, 'ascii'); + bytes[0] |= 0x80; + fs.writeFileSync(fixture.tokenPath, bytes, { mode: 0o600 }); + }], + ['permissive', (fixture) => fs.chmodSync(fixture.tokenPath, 0o644)], + ['symlink', (fixture) => { + fs.unlinkSync(fixture.tokenPath); + const target = path.join(fixture.directory, 'target-token'); + fs.writeFileSync(target, TOKEN, { mode: 0o600 }); + fs.symlinkSync(target, fixture.tokenPath); + }], + ]) { + const fixture = makeFixture(t); + mutate(fixture); + const stdout = capture(); + const stderr = capture(); + let calls = 0; + const exitCode = await runOperatorCli({ + argv: ['receipts', 'list'], + env: fixture.env, + requestImpl: async () => { calls += 1; return response(); }, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(exitCode, 1, label); + assert.equal(stdout.read(), '', label); + assert.equal(stderr.read(), 'error: OPERATOR_TOKEN_INVALID\n', label); + assert.equal(`${stdout.read()}${stderr.read()}`.includes(TOKEN), false, label); + assert.equal(calls, 0, label); + } +}); + +test('export exclusive-creates one owner-only canonical file and never overwrites or follows symlinks', async (t) => { + const projection = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.projection-export.v1', + projection: Object.freeze({ sessionId: 'session-1', eventHeadHash: SHA_A }), + projectionHash: SHA_B, + algorithm: 'Ed25519', + keyId: SHA_A, + publicKeyPem: 'PUBLIC KEY', + signature: Buffer.alloc(64, 0x41).toString('base64'), + }); + const fixture = makeFixture(t); + const outputPath = path.join(fixture.directory, 'projection.json'); + const stdout = capture(); + const stderr = capture(); + let calls = 0; + const run = () => runOperatorCli({ + argv: ['export', 'session-1', '--output', outputPath], + env: fixture.env, + requestImpl: async (input) => { + calls += 1; + assert.equal(input.path, '/operator/v1/exports/session-1'); + return response(projection); + }, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(await run(), 0, stderr.read()); + assert.equal(fs.readFileSync(outputPath, 'utf8'), canonicalJson(projection)); + const stat = fs.lstatSync(outputPath); + assert.equal(stat.isFile(), true); + assert.equal(stat.isSymbolicLink(), false); + assert.equal(stat.mode & 0o777, 0o600); + assert.equal(calls, 1); + + const secondOut = capture(); + const secondErr = capture(); + const second = await runOperatorCli({ + argv: ['export', 'session-1', '--output', outputPath], + env: fixture.env, + requestImpl: async () => { calls += 1; return response({ changed: true }); }, + stdout: secondOut.stream, + stderr: secondErr.stream, + }); + assert.equal(second, 1); + assert.equal(secondOut.read(), ''); + assert.equal(secondErr.read(), 'error: EXPORT_OUTPUT_UNSAFE\n'); + assert.equal(calls, 1, 'overwrite refusal happens before requesting the export'); + assert.equal(fs.readFileSync(outputPath, 'utf8'), canonicalJson(projection)); + + const target = path.join(fixture.directory, 'target.json'); + const symlink = path.join(fixture.directory, 'symlink.json'); + fs.writeFileSync(target, 'unchanged', { mode: 0o600 }); + fs.symlinkSync(target, symlink); + const symlinkOut = capture(); + const symlinkErr = capture(); + const symlinkExit = await runOperatorCli({ + argv: ['export', 'session-1', '--output', symlink], + env: fixture.env, + requestImpl: async () => { calls += 1; return response(projection); }, + stdout: symlinkOut.stream, + stderr: symlinkErr.stream, + }); + assert.equal(symlinkExit, 1); + assert.equal(symlinkErr.read(), 'error: EXPORT_OUTPUT_UNSAFE\n'); + assert.equal(fs.readFileSync(target, 'utf8'), 'unchanged'); + assert.equal(calls, 1); +}); + +test('offline bootstrap and API failures share stable exit 1 without leaking authority', async (t) => { + const result = await invoke(t, ['policy', 'apply', '/input/policy.json', '--confirm', SHA_A], { + offlineBootstrap: async ({ operatorToken }) => { + const error = new Error(`busy ${operatorToken}`); + error.code = 'AUTHORITY_BUSY'; + throw error; + }, + }); + assert.equal(result.exitCode, 1); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, 'error: AUTHORITY_BUSY\n'); + assert.equal(result.stderr.includes(TOKEN), false); +}); + +test('environment capture accepts known fields but rejects accessors and unknown wallet-kernel fields', async (t) => { + const fixture = makeFixture(t); + const unknown = await runWithEnvironment( + ['receipts', 'list'], + { ...fixture.env, WALLET_KERNEL_SURPRISE: 'value' }, + ); + assert.equal(unknown.exitCode, 1); + assert.equal(unknown.stderr, 'error: CLI_CONFIG_INVALID\n'); + + const accessor = { ...fixture.env }; + Object.defineProperty(accessor, 'WALLET_KERNEL_MODE', { + enumerable: true, + get() { throw new Error(`do not execute ${TOKEN}`); }, + }); + const captured = await runWithEnvironment(['receipts', 'list'], accessor); + assert.equal(captured.exitCode, 1); + assert.equal(captured.stderr, 'error: CLI_CONFIG_INVALID\n'); + assert.equal(captured.stderr.includes(TOKEN), false); + + async function runWithEnvironment(argv, env) { + const stdout = capture(); + const stderr = capture(); + let calls = 0; + const exitCode = await runOperatorCli({ + argv, + env, + requestImpl: async () => { calls += 1; return response(); }, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(calls, 0); + return { exitCode, stdout: stdout.read(), stderr: stderr.read() }; + } +}); diff --git a/spikes/pi-wielder/tests/operator-console.test.mjs b/spikes/pi-wielder/tests/operator-console.test.mjs new file mode 100644 index 0000000..7302327 --- /dev/null +++ b/spikes/pi-wielder/tests/operator-console.test.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { Hono } from 'hono'; + +import { createOperatorConsoleApp } from '../src/operator/console.mjs'; + +const INDEX = fs.readFileSync(new URL('../operator-console/index.html', import.meta.url), 'utf8'); +const SCRIPT = fs.readFileSync(new URL('../operator-console/app.mjs', import.meta.url), 'utf8'); +const STYLES = fs.readFileSync(new URL('../operator-console/styles.css', import.meta.url), 'utf8'); + +const SECURITY_HEADERS = Object.freeze({ + 'content-security-policy': "default-src 'self'; connect-src 'self'; frame-ancestors 'none'", + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + 'cache-control': 'no-store', +}); + +test('operator console is exactly four local authority views', () => { + const views = [...INDEX.matchAll(/data-view="([a-z]+)"/g)].map((match) => match[1]); + assert.deepEqual([...new Set(views)], ['overview', 'policies', 'approvals', 'receipts']); + for (const view of views) assert.match(INDEX, new RegExp(`id="view-${view}"`)); + assert.match(INDEX, /Wallet Kernel/); + assert.match(INDEX, /reserved/i); + assert.match(INDEX, /unresolved/i); + assert.match(INDEX, /policy/i); + assert.match(INDEX, /approval/i); + assert.match(INDEX, /receipt/i); + assert.match(INDEX, /case hash/i); + + assert.doesNotMatch(INDEX, / { + const fragmentRead = SCRIPT.indexOf('window.location.hash'); + const fragmentRemoval = SCRIPT.indexOf('history.replaceState'); + const sessionExchange = SCRIPT.indexOf("'/operator/v1/session'"); + assert.ok(fragmentRead >= 0); + assert.ok(fragmentRemoval > fragmentRead); + assert.ok(sessionExchange > fragmentRemoval); + assert.match(SCRIPT, /launchToken\s*=\s*null/); + assert.match(SCRIPT, /x-csrf-token/i); + assert.match(SCRIPT, /textContent/); + assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|eval\s*\(|localStorage|sessionStorage/); + assert.doesNotMatch(SCRIPT, /owner.?token|private.?key|payment-signature/i); + assert.doesNotMatch(SCRIPT, /https?:\/\//); + assert.match(SCRIPT, /body:\s*body === undefined \? undefined : canonicalJson\(body\)/); +}); + +test('console exposes only the guarded operator mutations required for recovery', () => { + for (const routeFragment of [ + '/agents/', + '/revoke', + '/sessions/', + '/transition-policy', + '/close', + '/reconciliations/', + '/abandon-candidate', + ]) assert.match(SCRIPT, new RegExp(routeFragment.replaceAll('/', '\\/'))); + assert.match(SCRIPT, /hold remains/i); + assert.match(SCRIPT, /fresh case hash/i); + assert.match(SCRIPT, /expectedEnrollmentHash/); + assert.match(SCRIPT, /expectedSessionHash/); + assert.match(SCRIPT, /expectedIntentHash/); + assert.match(SCRIPT, /expectedCaseHash/); + assert.match(SCRIPT, /paymentTransactionId/); + assert.match(SCRIPT, /refundTransactionId/); + assert.match(SCRIPT, /resourcePath/); + assert.match(SCRIPT, /requestHash/); + assert.match(SCRIPT, /policyVersionId/); + assert.match(SCRIPT, /authorizationNonce/); + assert.match(SCRIPT, /canonicalIdForPath/); + assert.doesNotMatch(SCRIPT, /encodeURIComponent/); + assert.match(SCRIPT, /api\('\/operator\/v1\/approvals'\)/); + assert.doesNotMatch(SCRIPT, /approvals\?state=pending/); + assert.doesNotMatch(SCRIPT, /paymentPayload|paymentHeader|signature|privateKey|rawEvidence/); +}); + +test('console styling is local, responsive, keyboard-visible, and motion-safe', () => { + assert.match(STYLES, /--ledger-blue:/); + assert.match(STYLES, /receipt-tape/); + assert.match(STYLES, /:focus-visible/); + assert.match(STYLES, /prefers-reduced-motion/); + assert.match(STYLES, /@media\s*\([^)]*max-width/); + assert.doesNotMatch(STYLES, /@import|url\s*\(|https?:\/\//); +}); + +test('console app serves only local assets and mounts protected operator JSON', async () => { + const operatorApp = new Hono(); + operatorApp.get('/operator/v1/overview', (context) => context.json({ status: 'ready' })); + const app = createOperatorConsoleApp({ operatorApp }); + + for (const [pathname, contentType, marker] of [ + ['/operator/', 'text/html; charset=UTF-8', 'Wallet Kernel'], + ['/operator/app.mjs', 'text/javascript; charset=UTF-8', 'history.replaceState'], + ['/operator/styles.css', 'text/css; charset=UTF-8', '--ledger-blue:'], + ]) { + const response = await app.request(pathname); + assert.equal(response.status, 200, pathname); + assert.equal(response.headers.get('content-type'), contentType, pathname); + assert.match(await response.text(), new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + for (const [name, value] of Object.entries(SECURITY_HEADERS)) { + assert.equal(response.headers.get(name), value, `${pathname} ${name}`); + } + } + + const api = await app.request('/operator/v1/overview'); + assert.equal(api.status, 200); + assert.deepEqual(await api.json(), { status: 'ready' }); + for (const [name, value] of Object.entries(SECURITY_HEADERS)) { + assert.equal(api.headers.get(name), value, name); + } + + for (const pathname of [ + '/operator', + '/operator/unknown.js', + '/operator/%2e%2e/package.json', + '/agent/v1/overview', + ]) { + const response = await app.request(pathname); + assert.equal(response.status, 404, pathname); + } +}); + +test('console factory rejects capability-bearing or extension-shaped dependencies', () => { + const operatorApp = new Hono(); + for (const value of [ + null, + {}, + { operatorApp, token: 'forbidden' }, + { operatorApp, walletAdapter: {} }, + ]) { + assert.throws(() => createOperatorConsoleApp(value)); + } +}); diff --git a/spikes/pi-wielder/tests/pi-extension-contract.test.mjs b/spikes/pi-wielder/tests/pi-extension-contract.test.mjs index fbd25e8..f8135ea 100644 --- a/spikes/pi-wielder/tests/pi-extension-contract.test.mjs +++ b/spikes/pi-wielder/tests/pi-extension-contract.test.mjs @@ -1,21 +1,610 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -const extensionUrl = new URL('../pi-extension/x402.ts', import.meta.url); +import activate, { + loadPiExtensionConfiguration, + readPiAgentCredential, + renderWalletKernelOutcome, +} from '../pi-extension/x402.ts'; -test('Pi invoke_skill exposes only input and uses one fixed encoded Collar route', () => { - const source = fs.readFileSync(extensionUrl, 'utf8'); - assert.equal(/\bskillId\b/.test(source), false); - assert.match( - source, - /const HOSTED_SKILL_ID = "optimizing-claude-code-prompts";/, +const RAW_PROMPT_SENTINEL = 'RAW_PROMPT_SENTINEL'; +const PROVIDER_EXCEPTION_SENTINEL = 'PROVIDER_EXCEPTION_SENTINEL'; +const HASH = 'a'.repeat(64); +const TOKEN = Buffer.alloc(32, 0x22).toString('base64url'); +const CREDENTIAL = Object.freeze({ + agentInstanceId: Buffer.alloc(16, 0x11).toString('base64url'), + schemaVersion: 1, + token: TOKEN, +}); +const CREDENTIAL_TEXT = `{"agentInstanceId":"${CREDENTIAL.agentInstanceId}","schemaVersion":1,"token":"${TOKEN}"}\n`; + +function toolAgentCallId(toolCallId) { + return crypto.createHash('sha256') + .update('wallet-kernel.pi-tool-call.v1\0', 'utf8') + .update(toolCallId, 'utf8') + .digest('base64url'); +} + +function temporaryCredential(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'pi-agent-extension-')), ); - assert.match( + fs.chmodSync(directory, 0o700); + const filePath = path.join(directory, 'agent.json'); + fs.writeFileSync(filePath, CREDENTIAL_TEXT, { mode: 0o600 }); + fs.chmodSync(filePath, 0o600); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return filePath; +} + +function environment(credentialPath, overrides = {}) { + return Object.freeze({ + WALLET_KERNEL_ORIGIN: 'http://127.0.0.1:8402', + WALLET_KERNEL_AGENT_CREDENTIAL_FILE: credentialPath, + WALLET_KERNEL_PROVIDER_NAME: 'wallet-kernel-e2e', + WALLET_KERNEL_MODEL_NAME: 'scripted-local', + WALLET_KERNEL_MODEL_ROUTE: 'example-model', + WALLET_KERNEL_SKILL_ROUTE: 'example-skill', + ...overrides, + }); +} + +function receipt(overrides = {}) { + return Object.freeze({ + id: 'receipt_public_1', + hash: HASH, + sellerOrigin: 'https://seller.example', + chargedAtomic: '1200', + remainingSessionAtomic: '8800', + terminalState: 'completed', + transactionPrefix: '0x1234567890', + ...overrides, + }); +} + +test('Pi extension statically exposes only fixed Wallet Kernel routes and ordinary auth', () => { + const source = fs.readFileSync(new URL('../pi-extension/x402.ts', import.meta.url), 'utf8'); + assert.match(source, /\/agent\/v1\/openai\//); + assert.match(source, /\/agent\/v1\/invoke\//); + assert.match(source, /WalletKernelAgent/); + assert.match(source, /O_NOFOLLOW/); + assert.doesNotMatch(source, /@coinbase|@x402|awal|src\/proxy|src\/gateway/i); + for (const forbiddenHeader of [ + 'x-payment', + 'payment-signature', + 'x-idempotency-key', + 'idempotency-key', + 'x-session-id', + 'x-approval-id', + 'x-wallet-address', + 'x-payee', + ]) { + assert.equal(source.toLowerCase().includes(`"${forbiddenHeader}"`), false); + } + assert.doesNotMatch( source, - /const HOSTED_SKILL_PATH = `\/invoke\/\$\{encodeURIComponent\(HOSTED_SKILL_ID\)\}`;/, + /setInterval|window\.open|\/operator\/|\/ledger|appendEntry|registerCommand/, + ); + + const envSource = fs.readFileSync( + new URL('../pi-extension/agent.env.example', import.meta.url), + 'utf8', + ); + assert.equal(envSource, [ + 'WALLET_KERNEL_ORIGIN=', + 'WALLET_KERNEL_AGENT_CREDENTIAL_FILE=', + 'WALLET_KERNEL_PROVIDER_NAME=', + 'WALLET_KERNEL_MODEL_NAME=', + 'WALLET_KERNEL_MODEL_ROUTE=', + 'WALLET_KERNEL_SKILL_ROUTE=', + '', + ].join('\n')); +}); + +test('hostile or noncanonical origins fail before credential authority is read', () => { + let reads = 0; + for (const origin of [ + 'https://127.0.0.1:8402', + 'http://localhost:8402', + 'http://127.0.0.2:8402', + 'http://user:password@127.0.0.1:8402', + 'http://127.0.0.1:8402/path', + 'http://127.0.0.1:8402?query=1', + 'http://127.0.0.1:8402#fragment', + 'http://127.0.0.1', + 'http://127.0.0.1:080', + ]) { + assert.throws(() => loadPiExtensionConfiguration({ + env: environment('/private/credential.json', { WALLET_KERNEL_ORIGIN: origin }), + readCredential() { + reads += 1; + return CREDENTIAL; + }, + }), (error) => error?.code === 'PI_KERNEL_ORIGIN_INVALID'); + } + assert.equal(reads, 0); + + for (const origin of ['http://127.0.0.1:8402', 'http://[::1]:8402']) { + const config = loadPiExtensionConfiguration({ + env: environment('/private/credential.json', { WALLET_KERNEL_ORIGIN: origin }), + readCredential() { + reads += 1; + return CREDENTIAL; + }, + }); + assert.equal(config.origin, origin); + } + assert.equal(reads, 2); +}); + +test('route, provider, and model names are bounded tokens and never URLs', () => { + let reads = 0; + for (const [field, invalid] of [ + ['WALLET_KERNEL_PROVIDER_NAME', 'https://provider.example'], + ['WALLET_KERNEL_MODEL_NAME', '../model'], + ['WALLET_KERNEL_MODEL_ROUTE', 'example-model/path'], + ['WALLET_KERNEL_SKILL_ROUTE', 'example-skill?target=https://seller.example'], + ['WALLET_KERNEL_SKILL_ROUTE', 'x'.repeat(65)], + ]) { + assert.throws(() => loadPiExtensionConfiguration({ + env: environment('/private/credential.json', { [field]: invalid }), + readCredential() { + reads += 1; + return CREDENTIAL; + }, + }), (error) => error?.code === 'PI_KERNEL_TOKEN_INVALID'); + } + assert.equal(reads, 0); +}); + +test('credential is opened once with NOFOLLOW and parsed from the held owner-only inode', (t) => { + const filePath = temporaryCredential(t); + const calls = []; + const fileSystem = Object.freeze({ + constants: fs.constants, + openSync(target, flags) { + calls.push({ operation: 'open', target, flags }); + return fs.openSync(target, flags); + }, + fstatSync(descriptor, options) { + calls.push({ operation: 'fstat', descriptor }); + return fs.fstatSync(descriptor, options); + }, + readSync(...args) { + calls.push({ operation: 'read', descriptor: args[0] }); + return fs.readSync(...args); + }, + closeSync(descriptor) { + calls.push({ operation: 'close', descriptor }); + return fs.closeSync(descriptor); + }, + }); + + const credential = readPiAgentCredential({ + filePath, + fileSystem, + getuid: () => process.getuid(), + }); + assert.deepEqual(credential, CREDENTIAL); + const opens = calls.filter((call) => call.operation === 'open'); + assert.equal(opens.length, 1); + assert.equal(opens[0].target, filePath); + assert.notEqual(opens[0].flags & fs.constants.O_NOFOLLOW, 0); + const descriptors = new Set( + calls.filter((call) => ['fstat', 'read', 'close'].includes(call.operation)) + .map((call) => call.descriptor), + ); + assert.equal(descriptors.size, 1); +}); + +test('credential reader rejects root, wrong owner, permissive files, symlinks, and noncanonical bytes', (t) => { + const filePath = temporaryCredential(t); + assert.throws(() => readPiAgentCredential({ + filePath, + getuid: () => 0, + }), (error) => error?.code === 'PI_AGENT_IDENTITY_INVALID'); + assert.throws(() => readPiAgentCredential({ + filePath, + getuid: () => process.getuid() + 1, + }), (error) => error?.code === 'PI_AGENT_CREDENTIAL_AUTHORITY'); + + fs.chmodSync(filePath, 0o640); + assert.throws(() => readPiAgentCredential({ filePath }), + (error) => error?.code === 'PI_AGENT_CREDENTIAL_AUTHORITY'); + fs.chmodSync(filePath, 0o600); + + const linkPath = `${filePath}.link`; + fs.symlinkSync(filePath, linkPath); + assert.throws(() => readPiAgentCredential({ filePath: linkPath }), + (error) => error?.code === 'PI_AGENT_CREDENTIAL_OPEN'); + + fs.writeFileSync(filePath, `${JSON.stringify({ + schemaVersion: 1, + agentInstanceId: CREDENTIAL.agentInstanceId, + token: CREDENTIAL.token, + })}\n`, { mode: 0o600 }); + assert.throws(() => readPiAgentCredential({ filePath }), + (error) => error?.code === 'PI_AGENT_CREDENTIAL_SCHEMA'); +}); + +test('activation registers one fixed provider and one fixed Skill route with only ordinary headers', async (t) => { + const credentialPath = temporaryCredential(t); + const providers = []; + const tools = []; + const commands = []; + const requests = []; + const handlers = new Map(); + const pi = { + registerProvider(name, config) { providers.push({ name, config }); }, + registerTool(tool) { tools.push(tool); }, + registerCommand(name, command) { commands.push({ name, command }); }, + on(name, handler) { handlers.set(name, handler); }, + }; + const completed = { + status: 'completed', + requestId: 'request_public_1', + resource: { + httpStatus: 200, + contentType: 'application/json', + body: { output: 'optimized' }, + }, + receipt: receipt(), + }; + + activate(pi, { + env: environment(credentialPath), + fetchFn: async (url, options) => { + requests.push({ url, options }); + return new Response(JSON.stringify(completed), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + assert.equal(providers.length, 1); + assert.equal(providers[0].name, 'wallet-kernel-e2e'); + assert.equal(providers[0].config.baseUrl, 'http://127.0.0.1:8402/agent/v1/openai/example-model'); + assert.equal(providers[0].config.api, 'openai-completions'); + assert.equal(providers[0].config.authHeader, false); + assert.deepEqual(providers[0].config.headers, { + Authorization: `WalletKernelAgent ${TOKEN}`, + 'Content-Type': 'application/json', + Prefer: 'wait=300', + }); + assert.equal(providers[0].config.models.length, 1); + assert.equal(providers[0].config.models[0].id, 'scripted-local'); + assert.deepEqual(providers[0].config.models[0].compat, { + sendSessionAffinityHeaders: false, + }); + assert.equal(tools.length, 1); + assert.equal(commands.length, 0); + assert.deepEqual([...handlers.keys()].sort(), [ + 'agent_settled', 'before_provider_headers', 'message_end', + ]); + assert.equal(/\bskillId\b/.test(tools[0].execute.toString()), false); + assert.equal(tools[0].execute.length, 5); + + const controller = new AbortController(); + const toolCallId = 'call_invoke_skill_1'; + const output = await tools[0].execute( + toolCallId, + { input: 'ordinary input' }, + controller.signal, + undefined, + Object.freeze({}), + ); + assert.equal(requests.length, 1); + assert.equal(requests[0].url, 'http://127.0.0.1:8402/agent/v1/invoke/example-skill'); + assert.equal(requests[0].options.method, 'POST'); + assert.deepEqual(requests[0].options.headers, { + Authorization: `WalletKernelAgent ${TOKEN}`, + 'Content-Type': 'application/json', + Prefer: 'wait=300', + 'x-agent-call-id': toolAgentCallId(toolCallId), + }); + assert.equal(requests[0].options.signal, controller.signal); + assert.deepEqual(JSON.parse(requests[0].options.body), { input: 'ordinary input' }); + assert.deepEqual(output, { + content: [{ + type: 'text', + text: `optimized\n\n[completed · receipt receipt_public_1 · sha256:${'a'.repeat(12)}… · charged 1200 atomic · remaining 8800 atomic · tx 0x1234567890]`, + }], + details: { boundaryStatus: 'returned' }, + }); +}); + +test('model request keys survive transient retry and rotate only on success or final failure', (t) => { + const credentialPath = temporaryCredential(t); + const handlers = new Map(); + activate({ + registerProvider() {}, + registerTool() {}, + on(name, handler) { handlers.set(name, handler); }, + }, { env: environment(credentialPath) }); + + const providerHeaders = () => ({ + type: 'before_provider_headers', + headers: { Authorization: `WalletKernelAgent ${TOKEN}` }, + }); + const first = providerHeaders(); + handlers.get('before_provider_headers')(first, Object.freeze({})); + const firstId = first.headers['x-agent-call-id']; + assert.match(firstId, /^[A-Za-z0-9_-]{43}$/u); + assert.equal(Buffer.from(firstId, 'base64url').length, 32); + assert.equal(first.headers.Prefer, 'wait=300'); + + handlers.get('message_end')({ + type: 'message_end', + message: { + role: 'assistant', + provider: 'wallet-kernel-e2e', + model: 'scripted-local', + stopReason: 'error', + }, + }, Object.freeze({})); + const transientRetry = providerHeaders(); + handlers.get('before_provider_headers')(transientRetry, Object.freeze({})); + assert.equal(transientRetry.headers['x-agent-call-id'], firstId); + + handlers.get('message_end')({ + type: 'message_end', + message: { + role: 'assistant', + provider: 'another-provider', + model: 'scripted-local', + stopReason: 'stop', + }, + }, Object.freeze({})); + const afterUnrelatedSuccess = providerHeaders(); + handlers.get('before_provider_headers')(afterUnrelatedSuccess, Object.freeze({})); + assert.equal(afterUnrelatedSuccess.headers['x-agent-call-id'], firstId); + + handlers.get('message_end')({ + type: 'message_end', + message: { + role: 'assistant', + provider: 'wallet-kernel-e2e', + model: 'scripted-local', + stopReason: 'toolUse', + }, + }, Object.freeze({})); + const afterSuccess = providerHeaders(); + handlers.get('before_provider_headers')(afterSuccess, Object.freeze({})); + const secondId = afterSuccess.headers['x-agent-call-id']; + assert.notEqual(secondId, firstId); + + handlers.get('agent_settled')({ type: 'agent_settled' }, Object.freeze({})); + const afterFinalFailure = providerHeaders(); + handlers.get('before_provider_headers')(afterFinalFailure, Object.freeze({})); + assert.notEqual(afterFinalFailure.headers['x-agent-call-id'], secondId); + + const unrelated = { + type: 'before_provider_headers', + headers: { Authorization: 'Bearer external-provider-secret' }, + }; + handlers.get('before_provider_headers')(unrelated, Object.freeze({})); + assert.equal(Object.hasOwn(unrelated.headers, 'x-agent-call-id'), false); +}); + +test('tool call IDs are validated before fetch and cancellation propagates through the real ABI', async (t) => { + const credentialPath = temporaryCredential(t); + const tools = []; + let fetchCalls = 0; + activate({ + registerProvider() {}, + registerTool(tool) { tools.push(tool); }, + on() {}, + }, { + env: environment(credentialPath), + fetchFn: async () => { + fetchCalls += 1; + throw new Error('fetch must not run'); + }, + }); + + const rejected = await tools[0].execute( + 'duplicate,call-id', + { input: 'ordinary input' }, + undefined, + undefined, + Object.freeze({}), + ); + assert.deepEqual(rejected, { + content: [{ type: 'text', text: 'invoke_skill call rejected.' }], + details: { boundaryStatus: 'rejected' }, + }); + assert.equal(fetchCalls, 0); + + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + tools[0].execute( + 'call_invoke_skill_aborted', + { input: 'ordinary input' }, + controller.signal, + undefined, + Object.freeze({}), + ), + (error) => error?.name === 'AbortError', + ); + assert.equal(fetchCalls, 0); +}); + +test('one controlled tool retry reuses its call key and renders terminal replay without spending again', async (t) => { + const credentialPath = temporaryCredential(t); + const tools = []; + const requests = []; + const replay = { + status: 'completed_replay', + terminalStatus: 'completed', + requestId: 'request_public_1', + reasonCode: 'PAYMENT_SETTLED', + projections: { + request: '/agent/v1/intents/request_public_1', + receipt: '/agent/v1/receipts/receipt_public_1', + }, + receipt: receipt(), + }; + activate({ + registerProvider() {}, + registerTool(tool) { tools.push(tool); }, + on() {}, + }, { + env: environment(credentialPath), + fetchFn: async (url, options) => { + requests.push({ url, options }); + if (requests.length === 1) { + throw new TypeError('simulated response transport loss'); + } + return new Response(JSON.stringify(replay), { + status: 409, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + const output = await tools[0].execute( + 'call_paid_response_lost', + { input: 'ordinary input' }, + undefined, + undefined, + Object.freeze({}), + ); + + assert.equal(requests.length, 2); + assert.equal( + requests[0].options.headers['x-agent-call-id'], + requests[1].options.headers['x-agent-call-id'], + ); + assert.equal( + requests[0].options.headers['x-agent-call-id'], + toolAgentCallId('call_paid_response_lost'), ); - assert.match(source, /async execute\(args: \{ input: string \}\)/); - assert.match(source, /fetch\(`\$\{PROXY\}\$\{HOSTED_SKILL_PATH\}`/); - assert.doesNotMatch(source, /properties:\s*\{[^}]*skill/i); + assert.deepEqual(output, { + content: [{ + type: 'text', + text: `Completed replay: the charge is already recorded, provider output was not retained, and retrying this same call key will not spend again. Inspect /agent/v1/intents/request_public_1 and /agent/v1/receipts/receipt_public_1. receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`, + }], + details: { boundaryStatus: 'returned' }, + }); +}); + +test('stable outcome rendering separates approval, denial, expiry, failure, refund, and uncertainty', () => { + const publicReceipt = receipt(); + const cases = [ + [{ + status: 'payment_approval_required', + requestId: 'request_public_1', + approval: { + expiresAt: '2026-08-01T12:00:00.000Z', + amountAtomic: '1200', + sellerOrigin: 'https://seller.example', + purposeLabel: 'skill.invoke', + }, + }, 'Approval required: https://seller.example · 1200 atomic · skill.invoke · expires 2026-08-01T12:00:00.000Z. Retry the same tool call after an operator decision.'], + [{ status: 'payment_denied', requestId: 'request_public_1', reasonCode: 'POLICY_DENIED', receipt: publicReceipt }, `Payment denied: POLICY_DENIED · receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`], + [{ status: 'payment_rejected', requestId: 'request_public_1', reasonCode: 'APPROVAL_EXPIRED', receipt: publicReceipt }, `Payment rejected or expired: APPROVAL_EXPIRED · receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`], + [{ status: 'payment_failed', requestId: 'request_public_1', reasonCode: 'SIGNER_FAILED', receipt: publicReceipt }, `Payment failed before settlement: SIGNER_FAILED · receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`], + [{ status: 'upstream_failed', requestId: 'request_public_1', reasonCode: 'SELLER_UNAVAILABLE', receipt: publicReceipt }, `Upstream failed before payment: SELLER_UNAVAILABLE · receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`], + [{ status: 'execution_failed', requestId: 'request_public_1', reasonCode: 'UPSTREAM_HTTP_500', receipt: publicReceipt }, `Execution failed after settlement: UPSTREAM_HTTP_500 · receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`], + [{ status: 'execution_unknown', requestId: 'request_public_1', reasonCode: 'RESPONSE_BODY_LOST', receipt: publicReceipt }, `Execution outcome unknown after settlement: RESPONSE_BODY_LOST · receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`], + [{ status: 'payment_unresolved', requestId: 'request_public_1', reasonCode: 'SETTLEMENT_UNRESOLVED', receipt: publicReceipt }, `Payment unresolved: SETTLEMENT_UNRESOLVED · receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`], + [{ status: 'refunded', requestId: 'request_public_1', reasonCode: 'REFUND_CONFIRMED', receipt: publicReceipt }, `Refunded: REFUND_CONFIRMED · receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`], + [{ + status: 'completed_replay', + terminalStatus: 'completed', + requestId: 'request_public_1', + reasonCode: 'PAYMENT_SETTLED', + projections: { + request: '/agent/v1/intents/request_public_1', + receipt: '/agent/v1/receipts/receipt_public_1', + }, + receipt: publicReceipt, + }, `Completed replay: the charge is already recorded, provider output was not retained, and retrying this same call key will not spend again. Inspect /agent/v1/intents/request_public_1 and /agent/v1/receipts/receipt_public_1. receipt receipt_public_1 · sha256:${'a'.repeat(12)}…`], + ]; + for (const [outcome, expected] of cases) { + assert.equal(renderWalletKernelOutcome(outcome), expected); + } +}); + +test('credential and provider exception sentinels never reach output or diagnostics', async (t) => { + const credentialPath = temporaryCredential(t); + const outputs = []; + const diagnostics = []; + const tools = []; + let kernelRequests = 0; + const pi = { + registerProvider() {}, + registerTool(tool) { tools.push(tool); }, + registerCommand() { throw new Error('must not register console access'); }, + on() {}, + }; + const originalError = console.error; + const originalWarn = console.warn; + console.error = (...values) => diagnostics.push(values.join(' ')); + console.warn = (...values) => diagnostics.push(values.join(' ')); + t.after(() => { + console.error = originalError; + console.warn = originalWarn; + }); + + activate(pi, { + env: environment(credentialPath), + fetchFn: async () => { + kernelRequests += 1; + return new Response(JSON.stringify({ + status: 'payment_unresolved', + requestId: 'request_public_1', + reasonCode: 'SETTLEMENT_UNRESOLVED', + receipt: receipt(), + challengeFreeText: PROVIDER_EXCEPTION_SENTINEL, + }), { status: 503, headers: { 'content-type': 'application/json' } }); + }, + }); + outputs.push(await tools[0].execute( + 'call_sentinel_1', + { input: RAW_PROMPT_SENTINEL }, + undefined, + undefined, + Object.freeze({}), + )); + assert.equal(kernelRequests, 1); + + const throwingTools = []; + activate({ + registerProvider() {}, + registerTool(tool) { throwingTools.push(tool); }, + registerCommand() { throw new Error('must not register console access'); }, + on() {}, + }, { + env: environment(credentialPath), + fetchFn: async () => { + kernelRequests += 1; + throw new Error(PROVIDER_EXCEPTION_SENTINEL); + }, + }); + outputs.push(await throwingTools[0].execute( + 'call_sentinel_2', + { input: RAW_PROMPT_SENTINEL }, + undefined, + undefined, + Object.freeze({}), + )); + assert.equal(kernelRequests, 3); + + const rendered = JSON.stringify({ outputs, diagnostics }); + assert.equal(rendered.includes(TOKEN), false); + assert.equal(rendered.includes(RAW_PROMPT_SENTINEL), false); + assert.equal(rendered.includes(PROVIDER_EXCEPTION_SENTINEL), false); + assert.deepEqual(outputs[1], { + content: [{ + type: 'text', + text: 'Wallet Kernel unavailable after one same-key retry.', + }], + details: { boundaryStatus: 'unavailable' }, + }); }); diff --git a/spikes/pi-wielder/tests/projection-exporter.test.mjs b/spikes/pi-wielder/tests/projection-exporter.test.mjs new file mode 100644 index 0000000..f46c0b9 --- /dev/null +++ b/spikes/pi-wielder/tests/projection-exporter.test.mjs @@ -0,0 +1,1506 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +import { encodePaymentSignatureHeader } from '@x402/core/http'; +import { authorizationTypes } from '@x402/evm'; +import { getAddress, keccak256, toBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { + canonicalJson, + KernelError, + sha256, +} from '../src/kernel/canonical.mjs'; +import { + createReceiptSigner, +} from '../src/kernel/receipt-signing.mjs'; +import { createSignedReceiptRepository } from '../src/kernel/signed-receipts.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; +import { createProjectionExporter } from '../src/kernel/projection-exporter.mjs'; + +const NOW = '2026-07-31T12:00:01.000Z'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const PAYMENT_ACCOUNT = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-projection-exporter-test-only')), +); +const WALLET = PAYMENT_ACCOUNT.address.toLowerCase(); +const SELLER = 'https://seller.example'; +const AGENT_INSTANCE_ID = Buffer.alloc(16, 7).toString('base64url'); +const VALID_AFTER = '0'; +const VALID_BEFORE = String(Math.floor(Date.parse(NOW) / 1_000) + 60); +const APPROVAL_EXPIRES_AT = new Date(Date.parse(NOW) + 60_000).toISOString(); +const FORBIDDEN_EXPORT_TERMS = /prompt|body|authorization|payment.signature|private|secret|token|stack|file.path/i; +const AUTHORITY_TABLES = Object.freeze([ + 'metadata', + 'policy_versions', + 'spend_sessions', + 'agent_enrollments', + 'isolation_attestations', + 'agent_session_bindings', + 'spend_intents', + 'policy_decisions', + 'budget_reservations', + 'approvals', + 'payment_attempts', + 'payment_reconciliation_candidates', + 'execution_outcomes', + 'execution_resolutions', + 'refunds', + 'reconciliations', + 'buyer_outcomes', + 'signed_receipts', + 'events', +]); + +const POLICY = Object.freeze({ + schemaVersion: 1, + network: NETWORK, + asset: ASSET, + wallet: WALLET, + methods: ['GET', 'POST'], + sellers: [{ + origin: SELLER, + pathPrefixes: ['/paid/'], + payTo: PAY_TO, + evidencePath: '/.well-known/wallet-kernel/evidence', + executionSigner: PAY_TO, + refundSigner: PAY_TO, + refundSource: '0x3000000000000000000000000000000000000000', + perRequestMaxAtomic: '500000', + autoApproveAtomic: '100000', + humanApproveAtomic: '500000', + sellerSessionMaxAtomic: '1000000', + }], + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '5000000', + challengeMaxAgeMs: 60000, + approvalTtlMs: 300000, + maxPendingApprovals: 20, + defaultAction: 'deny', +}); + +function assertDeepFrozen(value, seen = new WeakSet()) { + if (!value || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + assert.equal(Object.isFrozen(value), true); + for (const child of Object.values(value)) assertDeepFrozen(child, seen); +} + +function scanForbidden(value, path = '$') { + assert.doesNotMatch(path, FORBIDDEN_EXPORT_TERMS); + if (typeof value === 'string') { + assert.doesNotMatch(value, FORBIDDEN_EXPORT_TERMS); + assert.doesNotMatch(value, /(?:file:\/\/|\/(?:Users|home|private|tmp|var|etc|opt|root|proc|sys|dev)\/|[A-Za-z]:\\)/i); + return; + } + if (!value || typeof value !== 'object') return; + for (const [key, child] of Object.entries(value)) { + assert.doesNotMatch(key, FORBIDDEN_EXPORT_TERMS); + scanForbidden(child, `${path}.${key}`); + } +} + +function authorityRows(store) { + return Object.fromEntries(AUTHORITY_TABLES.map((table) => [ + table, + store.readAll(`SELECT * FROM ${table} ORDER BY rowid`), + ])); +} + +function challenge(amountAtomic) { + return { + x402Version: 2, + resource: { + urlHash: sha256(`${SELLER}/paid/infer`), + description: 'safe commercial fixture', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: NETWORK, + asset: ASSET, + amount: amountAtomic, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], + }; +} + +async function exactPaymentFixture(amountAtomic, suffix) { + const nonce = `0x${sha256(canonicalJson({ + domain: 'projection-exporter.payment-nonce.v1', + suffix, + })).slice('sha256:'.length)}`; + const accepted = challenge(amountAtomic).accepts[0]; + const typedData = { + domain: { + name: 'USDC', + version: '2', + chainId: 84532, + verifyingContract: getAddress(ASSET), + }, + types: authorizationTypes, + primaryType: 'TransferWithAuthorization', + message: { + from: getAddress(WALLET), + to: getAddress(PAY_TO), + value: BigInt(amountAtomic), + validAfter: BigInt(VALID_AFTER), + validBefore: BigInt(VALID_BEFORE), + nonce, + }, + }; + const signature = await PAYMENT_ACCOUNT.signTypedData(typedData); + const payload = { + x402Version: 2, + resource: { + url: `${SELLER}/paid/infer`, + description: 'safe commercial fixture', + mimeType: 'application/json', + }, + accepted, + payload: { + signature, + authorization: { + from: WALLET, + to: PAY_TO, + value: amountAtomic, + validAfter: VALID_AFTER, + validBefore: VALID_BEFORE, + nonce, + }, + }, + }; + const header = encodePaymentSignatureHeader(payload); + return Object.freeze({ + header, + hash: sha256(Buffer.from(header, 'ascii')), + json: canonicalJson(payload), + nonce, + payload: Object.freeze(payload), + }); +} + +const EXACT_PAYMENTS = Object.freeze({ + failed: await exactPaymentFixture('70000', 'failed'), + gap: await exactPaymentFixture('50000', 'gap'), + success: await exactPaymentFixture('50000', 'success'), + unresolved: await exactPaymentFixture('50000', 'unresolved'), +}); + +function directSettlement(payment, transactionId, suffix) { + return Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(`settlement-header-${suffix}`), + success: true, + transaction: transactionId, + network: NETWORK, + payer: WALLET, + amountAtomic: payment.payload.accepted.amount, + paymentHash: payment.hash, + }); +} + +function appendReserveAndPaymentEvents(appendEvent, intent, paymentId, payment) { + appendEvent({ + entityType: 'budget_reservation', + entityId: intent.id, + eventType: 'budget.reserved', + data: { + sessionId: 'session-1', + sellerOrigin: SELLER, + amountAtomic: intent.projection.accepts[0].amount, + previousState: null, + nextState: 'reserved', + updatedAt: NOW, + }, + }); + if (paymentId === null) return; + appendEvent({ + entityType: 'payment_attempt', + entityId: paymentId, + eventType: 'payment.reserved', + data: { + intentId: intent.id, + policyVersionId: 'policy-1', + quoteId: intent.quoteId, + createdAt: NOW, + }, + }); + if (!payment) return; + appendEvent({ + entityType: 'payment_attempt', + entityId: intent.id, + eventType: 'payment.signing_claimed', + data: { + nonce: payment.nonce, + validAfter: VALID_AFTER, + validBefore: VALID_BEFORE, + signingClaimedAt: NOW, + }, + }); + appendEvent({ + entityType: 'payment_attempt', + entityId: intent.id, + eventType: 'payment.signed', + data: { paymentHash: payment.hash, signedAt: NOW }, + }); + appendEvent({ + entityType: 'payment_attempt', + entityId: intent.id, + eventType: 'payment.retrying', + data: { retryStartedAt: NOW }, + }); +} + +function appendCommitEvent(appendEvent, intent, payment, settlement) { + appendEvent({ + entityType: 'budget_reservation', + entityId: intent.id, + eventType: 'budget.committed', + data: { + amountAtomic: intent.projection.accepts[0].amount, + transactionId: settlement.transaction, + paymentHash: payment.hash, + headerHash: settlement.headerHash, + previousState: 'reserved', + nextState: 'committed', + committedAt: NOW, + }, + }); +} + +function addIntent(db, { + id, + state, + amountAtomic, + suffix, +}) { + const projection = challenge(amountAtomic); + const projectionJson = canonicalJson(projection); + const challengeHash = sha256(projectionJson); + const quoteId = sha256(canonicalJson({ challengeHash, acceptedIndex: 0 })); + const intentHash = sha256(canonicalJson({ domain: 'fixture.intent.v1', id })); + db.prepare(`INSERT INTO spend_intents + (id, request_id, session_id, enrollment_hash, route_id, method, request_url_hash, + seller_origin, resource_path, body_hash, header_allowlist_hash, ordinary_fingerprint, + purpose_label, correlation_id, idempotency_key, wallet_address, intent_hash, + challenge_projection_json, challenge_hash, challenge_received_at, state, created_at, updated_at) + VALUES (?, ?, 'session-1', ?, ?, 'POST', ?, ?, '/paid/infer', ?, ?, ?, + 'skill.invoke', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + id, + `request-${suffix}`, + enrollmentHash(), + `route-${suffix}`, + sha256(`${SELLER}/paid/infer`), + SELLER, + sha256(`raw prompt body ${suffix}`), + sha256(`authorization header ${suffix}`), + sha256(`ordinary-${suffix}`), + `correlation-${suffix}`, + `idempotency-${suffix}`, + WALLET, + intentHash, + projectionJson, + challengeHash, + NOW, + state, + NOW, + NOW, + ); + db.prepare(`INSERT INTO policy_decisions + (intent_id, policy_version_id, decision, reason_code, challenge_hash, + accepted_index, quote_id, amount_ceiling_atomic, decided_at) + VALUES (?, 'policy-1', ?, ?, ?, 0, ?, ?, ?)`) + .run( + id, + state === 'approval_pending' ? 'approval_required' : 'allow', + state === 'approval_pending' ? 'HUMAN_APPROVAL_REQUIRED' : 'WITHIN_AUTO_LIMIT', + challengeHash, + quoteId, + amountAtomic, + NOW, + ); + return { id, intentHash, projection, projectionJson, quoteId }; +} + +function enrollmentDescriptor() { + return { + schemaVersion: 1, + agentInstanceId: AGENT_INSTANCE_ID, + credentialDigest: sha256('fixture-agent-capability'), + agentUid: '501', + agentGid: '20', + }; +} + +function enrollmentHash() { + return sha256(canonicalJson(enrollmentDescriptor())); +} + +function countedSigner() { + const base = createReceiptSigner(); + const counter = { calls: 0 }; + const signer = Object.freeze({ + algorithm: base.algorithm, + keyId: base.keyId, + publicKeyPem: base.publicKeyPem, + persistent: base.persistent, + signHash(hashHex) { + counter.calls += 1; + return base.signHash(hashHex); + }, + }); + return { counter, signer }; +} + +function setup(t, { + isolation = 'simulated', + currentAttestation = null, + attestationImportedAt = NOW, +} = {}) { + const store = openKernelStore({ + filePath: ':memory:', + allowMemory: true, + now: () => NOW, + }); + t.after(() => store.close()); + const { counter, signer } = countedSigner(); + let receiptSequence = 0; + const receipts = createSignedReceiptRepository({ + store, + signer, + idFactory: () => `receipt-${++receiptSequence}`, + now: () => NOW, + }); + const descriptor = enrollmentDescriptor(); + const descriptorHash = enrollmentHash(); + const policyJson = canonicalJson(POLICY); + + store.transaction((token) => store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO policy_versions + (id, schema_version, canonical_json, policy_hash, predecessor_hash, applied_at) + VALUES ('policy-1', 1, ?, ?, NULL, ?)`) + .run(policyJson, sha256(policyJson), NOW); + db.prepare("INSERT INTO metadata(key, value) VALUES ('active_policy_id', 'policy-1')").run(); + db.prepare(`INSERT INTO agent_enrollments + (agent_instance_id, credential_digest, enrollment_hash, agent_uid, agent_gid, + state, enrolled_by_operator_hash, enrolled_at) + VALUES (?, ?, ?, '501', '20', 'active', ?, ?)`) + .run( + AGENT_INSTANCE_ID, + descriptor.credentialDigest, + descriptorHash, + sha256('operator raw identity must stay hashed'), + NOW, + ); + appendEvent({ + entityType: 'agent_enrollment', + entityId: AGENT_INSTANCE_ID, + eventType: 'agent.enrolled', + data: { + enrollmentHash: descriptorHash, + credentialDigest: descriptor.credentialDigest, + agentUid: '501', + agentGid: '20', + operatorIdHash: sha256('operator raw identity must stay hashed'), + isolation, + enrolledAt: NOW, + }, + }); + db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at, closed_at) + VALUES ('session-1', ?, ?, 'policy-1', 'open', ?, NULL)`) + .run(`pi:${AGENT_INSTANCE_ID}`, WALLET, NOW); + db.prepare(`INSERT INTO agent_session_bindings + (id, agent_instance_id, credential_digest, enrollment_hash, session_id, state, + created_at, last_seen_at, closed_at) + VALUES ('binding-1', ?, ?, ?, 'session-1', 'open', ?, ?, NULL)`) + .run(AGENT_INSTANCE_ID, descriptor.credentialDigest, descriptorHash, NOW, NOW); + + if (currentAttestation) { + const reportJson = canonicalJson(currentAttestation); + db.prepare(`INSERT INTO isolation_attestations + (id, report_hash, enrollment_hash, report_json, state, + imported_by_operator_hash, probed_at, expires_at, imported_at, superseded_at) + VALUES ('attestation-current', ?, ?, ?, 'current', ?, ?, ?, ?, NULL)`) + .run( + sha256(reportJson), + descriptorHash, + reportJson, + sha256('attestation operator'), + currentAttestation.probedAt ?? NOW, + currentAttestation.expiresAt ?? '2026-07-31T12:15:00.000Z', + attestationImportedAt, + ); + } else { + const dangerousReport = canonicalJson({ + providerError: 'stack trace in /Users/alice/private/wallet.key', + rawCredential: 'top-secret-token', + }); + db.prepare(`INSERT INTO isolation_attestations + (id, report_hash, enrollment_hash, report_json, state, + imported_by_operator_hash, probed_at, expires_at, imported_at, superseded_at) + VALUES ('attestation-old', ?, ?, ?, 'superseded', ?, ?, ?, ?, ?)`) + .run( + sha256(dangerousReport), + descriptorHash, + dangerousReport, + sha256('attestation operator'), + NOW, + '2026-07-31T12:15:00.000Z', + NOW, + NOW, + ); + } + + const success = addIntent(db, { + id: 'intent-success', state: 'terminal', amountAtomic: '50000', suffix: 'success', + }); + const successPayment = EXACT_PAYMENTS.success; + const successTransaction = `0x${'ab'.repeat(32)}`; + const successSettlement = directSettlement( + successPayment, + successTransaction, + 'success', + ); + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + payment_payload_json, payment_header, payment_hash, quote_id, nonce, + valid_after, valid_before, settlement_json, transaction_id, signing_claimed_at, + signed_at, retry_started_at, settled_at, created_at, updated_at) + VALUES ('payment-success', ?, 'settled', ?, 0, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + success.id, + success.projectionJson, + successPayment.json, + successPayment.header, + successPayment.hash, + success.quoteId, + successPayment.nonce, + VALID_AFTER, + VALID_BEFORE, + canonicalJson(successSettlement), + successTransaction, + NOW, + NOW, + NOW, + NOW, + NOW, + NOW, + ); + db.prepare(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, committed_at, updated_at) + VALUES (?, 'session-1', ?, '0', '50000', '0', '0', 'committed', ?, ?)`) + .run(success.id, SELLER, NOW, NOW); + appendReserveAndPaymentEvents( + appendEvent, + success, + 'payment-success', + successPayment, + ); + appendCommitEvent(appendEvent, success, successPayment, successSettlement); + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'succeeded', 200, ?, ?, ?)`) + .run( + success.id, + sha256('{"ok":true}'), + canonicalJson({ providerError: 'stack at /private/tmp/provider.js', responseBody: 'secret' }), + NOW, + ); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'completed', 'PAYMENT_SETTLED', 1, ?)`) + .run(success.id, NOW); + + const pending = addIntent(db, { + id: 'intent-pending', state: 'approval_pending', amountAtomic: '150000', suffix: 'pending', + }); + db.prepare(`INSERT INTO approvals + (id, intent_id, decision, operator_id_hash, intent_hash, challenge_hash, + quote_id, accepted_index, amount_ceiling_atomic, wallet_address, + policy_version_id, expires_at, reason_code, decided_at, consumed_at) + VALUES ('approval-1', ?, 'pending', NULL, ?, ?, ?, 0, + '150000', ?, 'policy-1', ?, NULL, NULL, NULL)`) + .run( + pending.id, + pending.intentHash, + sha256(pending.projectionJson), + pending.quoteId, + WALLET, + APPROVAL_EXPIRES_AT, + ); + appendEvent({ + entityType: 'approval', + entityId: 'approval-1', + eventType: 'approval.requested', + data: { + intentId: pending.id, + intentHash: pending.intentHash, + challengeHash: sha256(pending.projectionJson), + quoteId: pending.quoteId, + amountCeilingAtomic: '150000', + walletAddress: WALLET, + policyVersionId: 'policy-1', + acceptedIndex: 0, + expiresAt: APPROVAL_EXPIRES_AT, + requestedAt: NOW, + }, + }); + + const failed = addIntent(db, { + id: 'intent-failed', state: 'terminal', amountAtomic: '70000', suffix: 'failed', + }); + const failedPayment = EXACT_PAYMENTS.failed; + const failedTransaction = `0x${'cd'.repeat(32)}`; + const failedSettlement = directSettlement( + failedPayment, + failedTransaction, + 'failed', + ); + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + payment_payload_json, payment_header, payment_hash, quote_id, nonce, + valid_after, valid_before, settlement_json, transaction_id, signing_claimed_at, + signed_at, retry_started_at, settled_at, created_at, updated_at) + VALUES ('payment-failed', ?, 'settled', ?, 0, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + failed.id, + failed.projectionJson, + failedPayment.json, + failedPayment.header, + failedPayment.hash, + failed.quoteId, + failedPayment.nonce, + VALID_AFTER, + VALID_BEFORE, + canonicalJson(failedSettlement), + failedTransaction, + NOW, + NOW, + NOW, + NOW, + NOW, + NOW, + ); + db.prepare(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, committed_at, updated_at) + VALUES (?, 'session-1', ?, '0', '70000', '0', '0', 'committed', ?, ?)`) + .run(failed.id, SELLER, NOW, NOW); + appendReserveAndPaymentEvents( + appendEvent, + failed, + 'payment-failed', + failedPayment, + ); + appendCommitEvent(appendEvent, failed, failedPayment, failedSettlement); + db.prepare(`INSERT INTO execution_outcomes + (intent_id, state, http_status, response_hash, metadata_json, recorded_at) + VALUES (?, 'failed', 500, ?, ?, ?)`) + .run(failed.id, sha256('provider failure'), canonicalJson({ stack: '/tmp/error.js' }), NOW); + db.prepare(`INSERT INTO execution_resolutions + (intent_id, state, reason_code, blocks_wallet, opened_at, resolved_at) + VALUES (?, 'refund_pending', 'UPSTREAM_HTTP_FAILURE', 1, ?, NULL)`) + .run(failed.id, NOW); + db.prepare(`INSERT INTO refunds + (id, intent_id, original_transaction_id, amount_atomic, state, evidence_json, + refund_transaction_id, created_at, updated_at) + VALUES ('refund-1', ?, ?, '70000', 'pending', NULL, NULL, ?, ?)`) + .run(failed.id, `0x${'cd'.repeat(32)}`, NOW, NOW); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'execution_failed', 'UPSTREAM_HTTP_FAILURE', 1, ?)`) + .run(failed.id, NOW); + })); + + receipts.issueMissingTerminalReceipts(); + const exporter = createProjectionExporter({ store, receipts, signer, now: () => NOW }); + return { counter, exporter, receipts, signer, store }; +} + +function validIsolationReport() { + return { + schemaVersion: 1, + enrollmentHash: enrollmentHash(), + kernelUid: '502', + kernelGid: '20', + agentUid: '501', + agentGid: '20', + authorityMetadataHash: sha256('authority metadata'), + credentialMetadataHash: sha256('credential metadata'), + releaseManifestHash: sha256('release manifest'), + releaseTreeHash: sha256('release tree'), + nodeExecutableHash: sha256('node executable'), + serviceArtifactsHash: sha256('service artifacts'), + systemdEffectiveConfigHash: sha256('systemd effective config'), + environmentMetadataHash: sha256('environment metadata'), + probeResults: { + authorityDirectory: 'EACCES', + database: 'EACCES', + operatorToken: 'EACCES', + receiptKey: 'EACCES', + kernelEnvironment: 'EACCES', + agentCredential: 'READABLE', + releaseTreeWrite: 'EACCES', + dependencyTreeWrite: 'EACCES', + serviceArtifactsWrite: 'EACCES', + kernelEnvironmentParentWrite: 'EACCES', + }, + probedAt: '2026-07-31T12:00:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + }; +} + +function assertProjectionCorruption(operation) { + assert.throws( + operation, + (error) => error instanceof KernelError && error.code === 'PROJECTION_CORRUPTION', + ); +} + +function parityBypassExporter(context) { + return createProjectionExporter({ + store: context.store, + receipts: Object.freeze({ + assertParityInTransaction() { return true; }, + verify: (record) => context.receipts.verify(record), + }), + signer: context.signer, + now: () => NOW, + }); +} + +function rewritePayment(store, intentId, mutate, { arbitraryHeader = null } = {}) { + store.transaction((token) => store.within(token, ({ db }) => { + const row = db.prepare('SELECT * FROM payment_attempts WHERE intent_id = ?').get(intentId); + assert.ok(row); + const payload = JSON.parse(row.payment_payload_json); + mutate(payload, row); + const payloadJson = canonicalJson(payload); + const header = arbitraryHeader ?? encodePaymentSignatureHeader(payload); + db.prepare(`UPDATE payment_attempts + SET payment_payload_json = ?, payment_header = ?, payment_hash = ?, + nonce = ?, valid_after = ?, valid_before = ? + WHERE intent_id = ?`).run( + payloadJson, + header, + sha256(Buffer.from(header, 'ascii')), + row.nonce, + row.valid_after, + row.valid_before, + intentId, + ); + })); +} + +function appendPendingApproval(db, appendEvent, intent, { + approvalId, + includeEvent = true, +} = {}) { + db.prepare(`INSERT INTO approvals + (id, intent_id, decision, operator_id_hash, intent_hash, challenge_hash, + quote_id, accepted_index, amount_ceiling_atomic, wallet_address, + policy_version_id, expires_at, reason_code, decided_at, consumed_at) + VALUES (?, ?, 'pending', NULL, ?, ?, ?, 0, ?, ?, 'policy-1', ?, NULL, NULL, NULL)`) + .run( + approvalId, + intent.id, + intent.intentHash, + sha256(intent.projectionJson), + intent.quoteId, + intent.projection.accepts[0].amount, + WALLET, + APPROVAL_EXPIRES_AT, + ); + if (!includeEvent) return; + appendEvent({ + entityType: 'approval', + entityId: approvalId, + eventType: 'approval.requested', + data: { + intentId: intent.id, + intentHash: intent.intentHash, + challengeHash: sha256(intent.projectionJson), + quoteId: intent.quoteId, + amountCeilingAtomic: intent.projection.accepts[0].amount, + walletAddress: WALLET, + policyVersionId: 'policy-1', + acceptedIndex: 0, + expiresAt: APPROVAL_EXPIRES_AT, + requestedAt: NOW, + }, + }); +} + +function seedReservedBudget(store, { + suffix, + reservedAtomic = '50000', + includeAttempt = true, + includePaymentEvents = true, +} = {}) { + store.transaction((token) => store.within(token, ({ db, appendEvent }) => { + const intent = addIntent(db, { + id: `intent-${suffix}`, + state: 'reserved', + amountAtomic: '50000', + suffix, + }); + db.prepare(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, committed_at, updated_at) + VALUES (?, 'session-1', ?, ?, '0', '0', '0', 'reserved', NULL, ?)`) + .run(intent.id, SELLER, reservedAtomic, NOW); + if (includeAttempt) { + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, created_at, updated_at) + VALUES (?, ?, 'reserved', ?, 0, ?, ?, ?)`) + .run( + `payment-${suffix}`, + intent.id, + intent.projectionJson, + intent.quoteId, + NOW, + NOW, + ); + } + appendReserveAndPaymentEvents( + appendEvent, + intent, + includeAttempt && includePaymentEvents ? `payment-${suffix}` : null, + null, + ); + })); +} + +function seedUnresolvedPaymentBlocker(context, suffix = 'unresolved') { + context.store.transaction((token) => context.store.within(token, ({ db, appendEvent }) => { + const intent = addIntent(db, { + id: `intent-${suffix}`, + state: 'unresolved', + amountAtomic: '50000', + suffix, + }); + db.prepare(`INSERT INTO budget_reservations + (intent_id, session_id, seller_origin, reserved_atomic, committed_atomic, + released_atomic, unresolved_atomic, state, committed_at, updated_at) + VALUES (?, 'session-1', ?, '0', '0', '0', '50000', 'unresolved', NULL, ?)`) + .run(intent.id, SELLER, NOW); + const payment = EXACT_PAYMENTS.unresolved; + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + payment_payload_json, payment_header, payment_hash, quote_id, nonce, + valid_after, valid_before, reason_code, signing_claimed_at, signed_at, + retry_started_at, created_at, updated_at) + VALUES (?, ?, 'unresolved', ?, 0, ?, ?, ?, ?, ?, ?, ?, + 'PAID_RESPONSE_AMBIGUOUS', ?, ?, ?, ?, ?)`) + .run( + `payment-${suffix}`, + intent.id, + intent.projectionJson, + payment.json, + payment.header, + payment.hash, + intent.quoteId, + payment.nonce, + VALID_AFTER, + VALID_BEFORE, + NOW, + NOW, + NOW, + NOW, + NOW, + ); + db.prepare(`INSERT INTO payment_reconciliation_candidates + (id, intent_id, transaction_id, state, evidence_json, created_at, updated_at) + VALUES (?, ?, ?, 'pending', NULL, ?, ?)`) + .run( + `candidate-${suffix}`, + intent.id, + `0x${'ef'.repeat(32)}`, + NOW, + NOW, + ); + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_unresolved', 'PAID_RESPONSE_AMBIGUOUS', 1, ?)`) + .run(intent.id, NOW); + appendReserveAndPaymentEvents( + appendEvent, + intent, + `payment-${suffix}`, + payment, + ); + appendEvent({ + entityType: 'budget_reservation', + entityId: intent.id, + eventType: 'budget.held_unresolved', + data: { + amountAtomic: '50000', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + previousState: 'reserved', + nextState: 'unresolved', + heldAt: NOW, + }, + }); + appendEvent({ + entityType: 'payment_attempt', + entityId: intent.id, + eventType: 'payment.unresolved', + data: { reasonCode: 'PAID_RESPONSE_AMBIGUOUS', recordedAt: NOW }, + }); + })); + context.receipts.issueForTerminal({ intentId: `intent-${suffix}` }); +} + +function leaves(value, path = []) { + if (!value || typeof value !== 'object') return [{ path, value }]; + return Object.entries(value).flatMap(([key, child]) => leaves(child, [...path, key])); +} + +function tamperAt(value, path) { + const copy = structuredClone(value); + let target = copy; + for (const key of path.slice(0, -1)) target = target[key]; + const key = path.at(-1); + const original = target[key]; + if (typeof original === 'string') target[key] = `${original}x`; + else if (typeof original === 'number') target[key] = original + 1; + else if (typeof original === 'boolean') target[key] = !original; + else if (original === null) target[key] = 'changed'; + else throw new Error(`unsupported test leaf at ${path.join('.')}`); + return copy; +} + +function verifyInFreshProcess(values) { + const verifier = String.raw` + import crypto from 'node:crypto'; + const canonicalize = (value) => Array.isArray(value) + ? value.map(canonicalize) + : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])) + : value; + const canonicalJson = (value) => JSON.stringify(canonicalize(value)); + const verify = (bundle) => { + try { + if (!bundle || Object.getPrototypeOf(bundle) !== Object.prototype + || Object.keys(bundle).sort().join(',') !== [ + 'algorithm', 'domain', 'keyId', 'projection', 'projectionHash', + 'publicKeyPem', 'schemaVersion', 'signature', + ].sort().join(',')) return false; + if (bundle.schemaVersion !== 1 + || bundle.domain !== 'wallet-kernel.projection-export.v1' + || bundle.algorithm !== 'Ed25519' + || !/^sha256:[0-9a-f]{64}$/.test(bundle.projectionHash)) return false; + const key = crypto.createPublicKey(bundle.publicKeyPem); + const keyId = 'sha256:' + crypto.createHash('sha256') + .update(key.export({ type: 'spki', format: 'der' })).digest('hex'); + if (key.asymmetricKeyType !== 'ed25519' || keyId !== bundle.keyId) return false; + const unsigned = { + schemaVersion: bundle.schemaVersion, + domain: bundle.domain, + projection: bundle.projection, + algorithm: bundle.algorithm, + keyId: bundle.keyId, + publicKeyPem: bundle.publicKeyPem, + }; + const hash = crypto.createHash('sha256').update(canonicalJson(unsigned)).digest('hex'); + if (bundle.projectionHash !== 'sha256:' + hash) return false; + const signature = Buffer.from(bundle.signature, 'base64'); + return signature.length === 64 + && signature.toString('base64') === bundle.signature + && crypto.verify(null, Buffer.from(hash, 'hex'), key, signature); + } catch { return false; } + }; + let text = ''; + for await (const chunk of process.stdin) text += chunk; + process.stdout.write(JSON.stringify(JSON.parse(text).map(verify))); + `; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', verifier], { + encoding: 'utf8', + input: JSON.stringify(values), + }); + assert.equal(child.status, 0, child.stderr); + return JSON.parse(child.stdout); +} + +test('snapshot is a closed frozen one-way sanitized authority projection', (t) => { + const { counter, exporter, store } = setup(t); + const callsBefore = counter.calls; + const rowsBefore = authorityRows(store); + + assert.deepEqual(Object.keys(exporter), ['snapshot', 'exportSigned']); + assert.equal(Object.isFrozen(exporter), true); + const snapshot = exporter.snapshot({ sessionId: 'session-1' }); + + assert.equal(counter.calls, callsBefore); + assert.deepEqual(authorityRows(store), rowsBefore); + assert.equal(snapshot.schemaVersion, 1); + assert.equal(snapshot.domain, 'wallet-kernel.sanitized-projection.v1'); + assert.equal(snapshot.authoritySchemaVersion, 1); + assert.deepEqual(snapshot.wallet, { + address: WALLET, + adapterHash: sha256(canonicalJson({ + domain: 'wallet-kernel.adapter-identity.v1', + adapterId: `pi:${AGENT_INSTANCE_ID}`, + })), + }); + assert.equal(snapshot.agentEnrollment.state, 'active'); + assert.equal(snapshot.agentEnrollment.enrollmentHash, enrollmentHash()); + assert.equal(snapshot.agentEnrollment.identityHash, sha256(canonicalJson({ + domain: 'wallet-kernel.agent-identity.v1', + agentUid: '501', + agentGid: '20', + }))); + assert.deepEqual(snapshot.isolation, { status: 'simulated', preflightDigest: null }); + assert.deepEqual(snapshot.budgets.session, { + reservedAtomic: '0', + committedAtomic: '120000', + releasedAtomic: '0', + unresolvedAtomic: '0', + exposureAtomic: '120000', + }); + assert.deepEqual(snapshot.budgets.wallet, snapshot.budgets.session); + assert.deepEqual(snapshot.approvals, { + approved: 0, + cancelled: 0, + consumed: 0, + denied: 0, + expired: 0, + pending: 1, + }); + assert.deepEqual(snapshot.blockers, { + blockedIntentCount: 1, + execution: { openCount: 1, reasonCodes: ['UPSTREAM_HTTP_FAILURE'] }, + payment: { openCount: 0, reasonCodes: [] }, + refund: { openCount: 1, reasonCodes: ['REFUND_PENDING'] }, + walletBlocked: true, + }); + assert.equal(snapshot.intents.length, 3); + assert.equal(snapshot.signedReceipts.length, 2); + assert.equal(snapshot.eventHeadHash, store.events().at(-1).event_hash); + assert.equal(snapshot.issuedAt, NOW); + assertDeepFrozen(snapshot); + scanForbidden(snapshot); + + const serialized = JSON.stringify(snapshot); + for (const sensitive of [ + 'Bearer top-secret-token', + 'raw body', + 'rawPaymentPayload', + '/Users/alice/private/wallet.key', + '/private/tmp/provider.js', + '/tmp/error.js', + 'providerError', + ]) assert.equal(serialized.includes(sensitive), false); +}); + +test('exportSigned binds every field and verifies in one fresh process', (t) => { + const { counter, exporter, store } = setup(t); + const callsBefore = counter.calls; + const rowsBefore = authorityRows(store); + const signed = exporter.exportSigned({ sessionId: 'session-1' }); + + assert.equal(counter.calls, callsBefore + 1); + assert.deepEqual(authorityRows(store), rowsBefore); + assertDeepFrozen(signed); + scanForbidden(signed); + const variants = [signed, ...leaves(signed).map(({ path }) => tamperAt(signed, path))]; + const results = verifyInFreshProcess(variants); + assert.equal(results[0], true); + assert.equal(results.slice(1).every((valid) => valid === false), true); +}); + +test('only an exact current unexpired isolation proof can claim enforced', (t) => { + const report = validIsolationReport(); + const { exporter } = setup(t, { + isolation: 'pending_verification', + currentAttestation: report, + }); + const snapshot = exporter.snapshot({ sessionId: 'session-1' }); + assert.deepEqual(snapshot.isolation, { + status: 'enforced', + preflightDigest: sha256(canonicalJson(report)), + }); + const projectedKeys = []; + const collectKeys = (value) => { + if (!value || typeof value !== 'object') return; + for (const [key, child] of Object.entries(value)) { + projectedKeys.push(key); + collectKeys(child); + } + }; + collectKeys(snapshot); + for (const rawIdentityKey of ['agentUid', 'agentGid', 'kernelUid', 'kernelGid']) { + assert.equal(projectedKeys.includes(rawIdentityKey), false); + } +}); + +test('opaque current attestation cannot be mislabeled enforced', (t) => { + const { exporter } = setup(t, { + isolation: 'pending_verification', + currentAttestation: { + probedAt: '2026-07-31T12:00:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + result: 'current is not proof', + }, + }); + assert.throws( + () => exporter.snapshot({ sessionId: 'session-1' }), + (error) => error instanceof KernelError && error.code === 'PROJECTION_CORRUPTION', + ); +}); + +test('an exact expired preflight keeps its digest but falls back to pending verification', (t) => { + const report = { + ...validIsolationReport(), + probedAt: '2026-07-31T11:45:00.000Z', + expiresAt: '2026-07-31T12:00:00.000Z', + }; + const { exporter } = setup(t, { + isolation: 'pending_verification', + currentAttestation: report, + attestationImportedAt: '2026-07-31T11:45:01.000Z', + }); + assert.deepEqual(exporter.snapshot({ sessionId: 'session-1' }).isolation, { + status: 'pending_verification', + preflightDigest: sha256(canonicalJson(report)), + }); +}); + +test('a current preflight forged onto a revoked enrollment is corruption', (t) => { + const { exporter, store } = setup(t, { + isolation: 'pending_verification', + currentAttestation: validIsolationReport(), + }); + store.transaction((token) => store.within(token, ({ db }) => { + db.prepare(`UPDATE agent_enrollments + SET state = 'revoked', revoked_by_operator_hash = ?, revoked_at = ? + WHERE enrollment_hash = ?`) + .run(sha256('revoking operator'), NOW, enrollmentHash()); + })); + assert.throws( + () => exporter.snapshot({ sessionId: 'session-1' }), + (error) => error instanceof KernelError && error.code === 'PROJECTION_CORRUPTION', + ); +}); + +test('future-dated isolation import can never be projected as enforced', (t) => { + const { exporter } = setup(t, { + isolation: 'pending_verification', + currentAttestation: validIsolationReport(), + attestationImportedAt: '2026-07-31T12:00:02.000Z', + }); + assertProjectionCorruption(() => exporter.snapshot({ sessionId: 'session-1' })); +}); + +test('parity, all reads, and event-head capture share one transaction token', (t) => { + const context = setup(t); + let nestedCommitBlocked = false; + const guardedReceipts = Object.freeze({ + assertParity() { + throw new Error('independent receipt parity read escaped the snapshot transaction'); + }, + assertParityInTransaction(token) { + context.receipts.assertParityInTransaction(token); + assert.throws( + () => context.store.mutate({ + entityType: 'race_probe', + entityId: 'race-probe-1', + eventType: 'race.commit', + data: { shouldCommit: false }, + }, () => null), + /nested authority transaction is forbidden/, + ); + nestedCommitBlocked = true; + return true; + }, + verify: (record) => context.receipts.verify(record), + }); + const transactionOnlyStore = Object.freeze({ + transaction: (operation) => context.store.transaction(operation), + within: (token, operation) => context.store.within(token, operation), + readOne() { throw new Error('independent projection read escaped its transaction'); }, + readAll() { throw new Error('independent projection read escaped its transaction'); }, + verifyEventChain() { throw new Error('event verification escaped its transaction'); }, + pragma() { throw new Error('schema verification escaped its transaction'); }, + }); + const exporter = createProjectionExporter({ + store: transactionOnlyStore, + receipts: guardedReceipts, + signer: context.signer, + now: () => NOW, + }); + const before = authorityRows(context.store); + const snapshot = exporter.snapshot({ sessionId: 'session-1' }); + assert.equal(nestedCommitBlocked, true); + assert.equal(snapshot.eventHeadHash, context.store.events().at(-1).event_hash); + assert.deepEqual(authorityRows(context.store), before); +}); + +test('export signing begins only after the frozen snapshot transaction closes', (t) => { + const context = setup(t); + let signerEnteredAfterSnapshot = false; + const transactionCheckingSigner = Object.freeze({ + algorithm: context.signer.algorithm, + keyId: context.signer.keyId, + publicKeyPem: context.signer.publicKeyPem, + persistent: context.signer.persistent, + signHash(hashHex) { + const eventCount = context.store.transaction((token) => context.store.within( + token, + ({ db }) => Number(db.prepare('SELECT COUNT(*) AS count FROM events').get().count), + )); + assert.ok(eventCount > 0); + signerEnteredAfterSnapshot = true; + return context.signer.signHash(hashHex); + }, + }); + const exporter = createProjectionExporter({ + store: context.store, + receipts: context.receipts, + signer: transactionCheckingSigner, + now: () => NOW, + }); + const before = authorityRows(context.store); + const signed = exporter.exportSigned({ sessionId: 'session-1' }); + assert.equal(signerEnteredAfterSnapshot, true); + assert.equal(Object.isFrozen(signed.projection), true); + assert.deepEqual(authorityRows(context.store), before); +}); + +test('corrupt nonterminal reservations fail before aggregate budget projection', async (t) => { + await t.test('conservation differs from the PolicyDecision ceiling', () => { + const context = setup(t); + seedReservedBudget(context.store, { suffix: 'bad-conservation', reservedAtomic: '49999' }); + assertProjectionCorruption(() => context.exporter.snapshot({ sessionId: 'session-1' })); + }); + await t.test('reservation has no exact PaymentAttempt', () => { + const context = setup(t); + seedReservedBudget(context.store, { suffix: 'missing-attempt', includeAttempt: false }); + assertProjectionCorruption(() => context.exporter.snapshot({ sessionId: 'session-1' })); + }); +}); + +test('wallet blockers cover payment, execution, and refund classes without double counting', (t) => { + const context = setup(t); + seedUnresolvedPaymentBlocker(context); + const { blockers } = context.exporter.snapshot({ sessionId: 'session-1' }); + assert.deepEqual(blockers, { + blockedIntentCount: 2, + execution: { openCount: 1, reasonCodes: ['UPSTREAM_HTTP_FAILURE'] }, + payment: { openCount: 1, reasonCodes: ['PAID_RESPONSE_AMBIGUOUS'] }, + refund: { openCount: 1, reasonCodes: ['REFUND_PENDING'] }, + walletBlocked: true, + }); +}); + +test('wallet, session policy, intent, and reservation authority cannot be rebound', async (t) => { + const OTHER_WALLET = '0x4000000000000000000000000000000000000000'; + await t.test('PolicyVersion wallet', () => { + const context = setup(t); + const changed = canonicalJson({ ...structuredClone(POLICY), wallet: OTHER_WALLET }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE policy_versions SET canonical_json = ?, policy_hash = ? + WHERE id = 'policy-1'`).run(changed, sha256(changed)); + })); + assertProjectionCorruption(() => context.exporter.snapshot({ sessionId: 'session-1' })); + }); + await t.test('Spend Session wallet', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("UPDATE spend_sessions SET wallet_address = ? WHERE id = 'session-1'") + .run(OTHER_WALLET); + })); + assertProjectionCorruption(() => context.exporter.snapshot({ sessionId: 'session-1' })); + }); + await t.test('Spend Session PolicyVersion', () => { + const context = setup(t); + const policy1 = context.store.readOne( + "SELECT policy_hash FROM policy_versions WHERE id = 'policy-1'", + ).policy_hash; + const changed = canonicalJson({ ...structuredClone(POLICY), sessionMaxAtomic: '2000001' }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO policy_versions + (id, schema_version, canonical_json, policy_hash, predecessor_hash, applied_at) + VALUES ('policy-2', 1, ?, ?, ?, ?)`) + .run(changed, sha256(changed), policy1, NOW); + db.prepare("UPDATE metadata SET value = 'policy-2' WHERE key = 'active_policy_id'").run(); + db.prepare("UPDATE spend_sessions SET policy_version_id = 'policy-2' WHERE id = 'session-1'") + .run(); + })); + assertProjectionCorruption(() => context.exporter.snapshot({ sessionId: 'session-1' })); + }); + await t.test('Spend Intent wallet', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("UPDATE spend_intents SET wallet_address = ? WHERE id = 'intent-pending'") + .run(OTHER_WALLET); + })); + assertProjectionCorruption(() => context.exporter.snapshot({ sessionId: 'session-1' })); + }); + await t.test('BudgetReservation session', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO spend_sessions + (id, adapter_id, wallet_address, policy_version_id, state, created_at, closed_at) + VALUES ('session-2', ?, ?, 'policy-1', 'closed', ?, ?)`) + .run(`pi:${AGENT_INSTANCE_ID}`, WALLET, NOW, NOW); + db.prepare(`INSERT INTO agent_session_bindings + (id, agent_instance_id, credential_digest, enrollment_hash, session_id, state, + created_at, last_seen_at, closed_at) + VALUES ('binding-2', ?, ?, ?, 'session-2', 'closed', ?, ?, ?)`) + .run( + AGENT_INSTANCE_ID, + enrollmentDescriptor().credentialDigest, + enrollmentHash(), + NOW, + NOW, + NOW, + ); + db.prepare(`UPDATE budget_reservations SET session_id = 'session-2' + WHERE intent_id = 'intent-success'`).run(); + })); + assertProjectionCorruption(() => context.exporter.snapshot({ sessionId: 'session-1' })); + }); +}); + +test('canonical x402 PaymentAttempt bytes remain exactly bound to persisted authority', async (t) => { + await t.test('resource URL substitution', () => { + const context = setup(t); + rewritePayment(context.store, 'intent-success', (payload) => { + payload.resource.url = `${SELLER}/paid/other`; + }); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('accepted network substitution', () => { + const context = setup(t); + rewritePayment(context.store, 'intent-success', (payload) => { + payload.accepted.network = 'eip155:1'; + }); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('authorization payer substitution', () => { + const context = setup(t); + rewritePayment(context.store, 'intent-success', (payload) => { + payload.payload.authorization.from = '0x4000000000000000000000000000000000000000'; + }); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('authorization nonce and row nonce move together', () => { + const context = setup(t); + rewritePayment(context.store, 'intent-success', (payload, row) => { + const changed = `0x${'44'.repeat(32)}`; + payload.payload.authorization.nonce = changed; + row.nonce = changed; + }); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('authorization window and row window move together', () => { + const context = setup(t); + rewritePayment(context.store, 'intent-success', (payload, row) => { + const changed = String(BigInt(VALID_BEFORE) - 1n); + payload.payload.authorization.validBefore = changed; + row.valid_before = changed; + }); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('noncanonical Circle signature recovery byte', () => { + const context = setup(t); + rewritePayment(context.store, 'intent-success', (payload) => { + payload.payload.signature = `${payload.payload.signature.slice(0, -2)}00`; + }); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('arbitrary header with a recomputed hash', () => { + const context = setup(t); + rewritePayment( + context.store, + 'intent-success', + () => {}, + { arbitraryHeader: 'arbitrary-payment-header' }, + ); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('settlement proof substitution', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + const row = db.prepare( + "SELECT settlement_json FROM payment_attempts WHERE intent_id = 'intent-success'", + ).get(); + const settlement = JSON.parse(row.settlement_json); + settlement.payer = '0x4000000000000000000000000000000000000000'; + db.prepare("UPDATE payment_attempts SET settlement_json = ? WHERE intent_id = 'intent-success'") + .run(canonicalJson(settlement)); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); +}); + +test('joined spend lifecycle rejects impossible final-serving projections', async (t) => { + await t.test('captured intent cannot own a reservation', () => { + const context = setup(t); + seedReservedBudget(context.store, { suffix: 'captured-reservation' }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("UPDATE spend_intents SET state = 'captured' WHERE id = 'intent-captured-reservation'") + .run(); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('retrying intent cannot own settled payment authority', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("UPDATE spend_intents SET state = 'retrying' WHERE id = 'intent-success'").run(); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('committed payment requires an execution outcome', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("DELETE FROM execution_outcomes WHERE intent_id = 'intent-success'").run(); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('succeeded execution requires a 2xx status', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("UPDATE execution_outcomes SET http_status = 500 WHERE intent_id = 'intent-success'") + .run(); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('succeeded execution requires a response hash', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("UPDATE execution_outcomes SET response_hash = NULL WHERE intent_id = 'intent-success'") + .run(); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('succeeded execution requires the completed buyer outcome', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`UPDATE buyer_outcomes + SET status = 'execution_failed', reason_code = 'UPSTREAM_HTTP_FAILURE' + WHERE intent_id = 'intent-success'`).run(); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); +}); + +test('Approval rows require approval_required authority, exact expiry, and event provenance', async (t) => { + await t.test('allow decision cannot gain an Approval row', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db, appendEvent }) => { + const row = db.prepare("SELECT * FROM spend_intents WHERE id = 'intent-success'").get(); + appendPendingApproval(db, appendEvent, { + id: row.id, + intentHash: row.intent_hash, + projectionJson: row.challenge_projection_json, + quoteId: db.prepare( + "SELECT quote_id FROM policy_decisions WHERE intent_id = 'intent-success'", + ).get().quote_id, + projection: JSON.parse(row.challenge_projection_json), + }, { approvalId: 'approval-allow' }); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('approved row cannot omit operator authority', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("UPDATE approvals SET decision = 'approved', decided_at = ? WHERE id = 'approval-1'") + .run(NOW); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('immutable expiry is derived exactly', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("UPDATE approvals SET expires_at = ? WHERE id = 'approval-1'") + .run(new Date(Date.parse(APPROVAL_EXPIRES_AT) + 1).toISOString()); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('approval.requested event is mandatory', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db, appendEvent }) => { + const intent = addIntent(db, { + id: 'intent-missing-approval-event', + state: 'approval_pending', + amountAtomic: '150000', + suffix: 'missing-approval-event', + }); + appendPendingApproval(db, appendEvent, intent, { + approvalId: 'approval-missing-event', + includeEvent: false, + }); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); +}); + +test('Spend Session state and active PolicyVersion lifecycle remain aligned', async (t) => { + await t.test('open session must pin the active policy', () => { + const context = setup(t); + const policy1 = context.store.readOne( + "SELECT policy_hash FROM policy_versions WHERE id = 'policy-1'", + ).policy_hash; + const changed = canonicalJson({ ...structuredClone(POLICY), sessionMaxAtomic: '2000001' }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO policy_versions + (id, schema_version, canonical_json, policy_hash, predecessor_hash, applied_at) + VALUES ('policy-2', 1, ?, ?, ?, ?)`) + .run(changed, sha256(changed), policy1, NOW); + db.prepare("UPDATE metadata SET value = 'policy-2' WHERE key = 'active_policy_id'").run(); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); + await t.test('policy_blocked session cannot still pin active policy', () => { + const context = setup(t); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare("UPDATE spend_sessions SET state = 'policy_blocked' WHERE id = 'session-1'").run(); + })); + assertProjectionCorruption(() => parityBypassExporter(context).snapshot({ sessionId: 'session-1' })); + }); +}); + +test('closed input, receipt parity, and event-chain corruption fail before export', (t) => { + const first = setup(t); + assert.throws( + () => first.exporter.snapshot({ sessionId: 'session-1', restore: true }), + (error) => error instanceof KernelError && error.code === 'PROJECTION_INPUT', + ); + assert.throws( + () => first.exporter.snapshot({ sessionId: 'missing-session' }), + (error) => error instanceof KernelError && error.code === 'SESSION_UNKNOWN', + ); + + const second = setup(t); + second.store.transaction((token) => second.store.within(token, ({ db }) => { + db.prepare("UPDATE signed_receipts SET signature = 'AAAA' WHERE id = 'receipt-1'").run(); + })); + assert.throws( + () => second.exporter.exportSigned({ sessionId: 'session-1' }), + (error) => error instanceof KernelError && error.code === 'RECEIPT_PARITY_REQUIRED', + ); + + const third = setup(t); + third.store.transaction((token) => third.store.within(token, ({ db }) => { + db.prepare("UPDATE events SET event_hash = ? WHERE sequence = 1") + .run(sha256('tampered event head')); + })); + assert.throws( + () => third.exporter.snapshot({ sessionId: 'session-1' }), + (error) => error instanceof KernelError && error.code === 'PROJECTION_EVENT_CHAIN', + ); +}); diff --git a/spikes/pi-wielder/tests/release-integrity.test.mjs b/spikes/pi-wielder/tests/release-integrity.test.mjs new file mode 100644 index 0000000..eafe5d5 --- /dev/null +++ b/spikes/pi-wielder/tests/release-integrity.test.mjs @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + assertClosedLoaderEnvironment, + buildReleaseManifest, + captureInheritedConsoleSocket, + computeServiceArtifactsHash, + validateReleaseManifest, + verifyReleaseIntegrity, +} from '../src/kernel/release-integrity.mjs'; + +const HASH = `sha256:${'a'.repeat(64)}`; + +function fixture() { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-release-')); + fs.chmodSync(parent, 0o700); + const releaseRoot = path.join(parent, 'release'); + fs.mkdirSync(path.join(releaseRoot, 'src'), { recursive: true, mode: 0o755 }); + fs.mkdirSync(path.join(releaseRoot, 'scripts'), { mode: 0o755 }); + fs.writeFileSync(path.join(releaseRoot, 'src', 'control-plane.mjs'), 'export default 1;\n', { + mode: 0o644, + }); + fs.writeFileSync(path.join(releaseRoot, 'scripts', 'preflight-live-deployment.mjs'), + '#!/usr/bin/env node\n', { mode: 0o755 }); + fs.writeFileSync(path.join(releaseRoot, 'package-lock.json'), '{"lockfileVersion":3}\n', { + mode: 0o644, + }); + fs.writeFileSync(path.join(releaseRoot, 'package.json'), '{"type":"module"}\n', { + mode: 0o644, + }); + const nodePath = path.join(parent, 'node'); + fs.writeFileSync(nodePath, 'synthetic-node\n', { mode: 0o755 }); + const environmentPath = path.join(parent, 'kernel.env'); + fs.writeFileSync(environmentPath, 'WALLET_KERNEL_MODE=cdp-testnet\n', { mode: 0o600 }); + const servicePath = path.join(parent, 'wallet-kernel.service'); + const socketPath = path.join(parent, 'wallet-kernel-console.socket'); + fs.writeFileSync(servicePath, '[Service]\n', { mode: 0o644 }); + fs.writeFileSync(socketPath, '[Socket]\n', { mode: 0o644 }); + return { parent, releaseRoot, nodePath, environmentPath, servicePath, socketPath }; +} + +function systemdFixture() { + return { + managerVersion: 'systemd 255 (255.4-1ubuntu8)', + systemctlVersion: 'systemd 255 (255.4-1ubuntu8)', + systemctlExecutablePathHash: HASH, + systemctlExecutableSha256: HASH, + effectiveConfigHash: HASH, + }; +} + +test('builds and verifies a closed synthetic release manifest', () => { + const f = fixture(); + try { + const manifest = buildReleaseManifest({ + mode: 'deterministic', + releaseRoot: f.releaseRoot, + manifestPath: path.join(f.releaseRoot, 'manifest.json'), + commit: '1'.repeat(40), + createdAt: '2026-07-31T12:00:00.000Z', + kernelUid: '501', + kernelGid: '20', + node: { path: f.nodePath, version: 'v24.18.1' }, + environmentPath: f.environmentPath, + serviceArtifacts: [ + { role: 'kernel-service', path: f.servicePath }, + { role: 'console-socket', path: f.socketPath }, + ], + systemd: systemdFixture(), + expectedOwnerUid: process.getuid(), + }); + assert.deepEqual(manifest.entries.map((entry) => entry.path), [ + 'package-lock.json', + 'package.json', + 'scripts', + 'scripts/preflight-live-deployment.mjs', + 'src', + 'src/control-plane.mjs', + ]); + assert.equal(manifest.node.version, 'v24.18.1'); + assert.equal(manifest.serviceArtifacts.length, 2); + assert.equal(validateReleaseManifest(manifest).commit, '1'.repeat(40)); + const verified = verifyReleaseIntegrity({ + mode: 'deterministic', + releaseRoot: f.releaseRoot, + manifest, + expectedOwnerUid: process.getuid(), + expectedKernelUid: '501', + expectedKernelGid: '20', + nodePath: f.nodePath, + nodeVersion: 'v24.18.1', + environmentPath: f.environmentPath, + serviceArtifactPaths: { + 'kernel-service': f.servicePath, + 'console-socket': f.socketPath, + }, + }); + assert.equal(verified.releaseManifestHash.startsWith('sha256:'), true); + } finally { + fs.rmSync(f.parent, { recursive: true, force: true }); + } +}); + +test('tree mutation, extra entries, mutable files, hardlinks, and escaping links fail closed', () => { + const cases = [ + (f) => fs.appendFileSync(path.join(f.releaseRoot, 'package.json'), ' '), + (f) => fs.writeFileSync(path.join(f.releaseRoot, 'extra'), 'x', { mode: 0o644 }), + (f) => fs.chmodSync(path.join(f.releaseRoot, 'package.json'), 0o666), + (f) => fs.linkSync(path.join(f.releaseRoot, 'package.json'), path.join(f.releaseRoot, 'alias')), + (f) => fs.symlinkSync('../../escape', path.join(f.releaseRoot, 'escape')), + ]; + for (const mutate of cases) { + const f = fixture(); + try { + const manifest = buildReleaseManifest({ + mode: 'deterministic', releaseRoot: f.releaseRoot, + manifestPath: path.join(f.releaseRoot, 'manifest.json'), commit: '2'.repeat(40), + createdAt: '2026-07-31T12:00:00.000Z', kernelUid: '501', kernelGid: '20', + node: { path: f.nodePath, version: 'v24.18.1' }, environmentPath: f.environmentPath, + serviceArtifacts: [ + { role: 'kernel-service', path: f.servicePath }, + { role: 'console-socket', path: f.socketPath }, + ], systemd: systemdFixture(), expectedOwnerUid: process.getuid(), + }); + mutate(f); + assert.throws(() => verifyReleaseIntegrity({ + mode: 'deterministic', releaseRoot: f.releaseRoot, manifest, + expectedOwnerUid: process.getuid(), expectedKernelUid: '501', expectedKernelGid: '20', + nodePath: f.nodePath, nodeVersion: 'v24.18.1', environmentPath: f.environmentPath, + serviceArtifactPaths: { + 'kernel-service': f.servicePath, 'console-socket': f.socketPath, + }, + })); + } finally { + fs.rmSync(f.parent, { recursive: true, force: true }); + } + } +}); + +test('manifest rejects unknown fields, duplicate roles, and a non-pinned runtime', () => { + const f = fixture(); + try { + const common = { + mode: 'deterministic', releaseRoot: f.releaseRoot, + manifestPath: path.join(f.releaseRoot, 'manifest.json'), commit: '3'.repeat(40), + createdAt: '2026-07-31T12:00:00.000Z', kernelUid: '501', kernelGid: '20', + environmentPath: f.environmentPath, systemd: systemdFixture(), + expectedOwnerUid: process.getuid(), + }; + assert.throws(() => buildReleaseManifest({ + ...common, node: { path: f.nodePath, version: 'v24.18.0' }, + serviceArtifacts: [ + { role: 'kernel-service', path: f.servicePath }, + { role: 'console-socket', path: f.socketPath }, + ], + }), /24\.18\.1/); + assert.throws(() => buildReleaseManifest({ + ...common, node: { path: f.nodePath, version: 'v24.18.1' }, surprise: true, + serviceArtifacts: [ + { role: 'kernel-service', path: f.servicePath }, + { role: 'console-socket', path: f.socketPath }, + ], + }), /closed schema/); + assert.throws(() => buildReleaseManifest({ + ...common, node: { path: f.nodePath, version: 'v24.18.1' }, + serviceArtifacts: [ + { role: 'kernel-service', path: f.servicePath }, + { role: 'kernel-service', path: f.socketPath }, + ], + }), /roles/); + } finally { + fs.rmSync(f.parent, { recursive: true, force: true }); + } +}); + +test('service artifact aggregate is role ordered and domain separated', () => { + const left = computeServiceArtifactsHash([ + { role: 'kernel-service', pathHash: HASH, sha256: HASH, uid: '0', gid: '0', mode: '644' }, + { role: 'console-socket', pathHash: HASH, sha256: HASH, uid: '0', gid: '0', mode: '644' }, + ]); + const right = computeServiceArtifactsHash([ + { role: 'console-socket', pathHash: HASH, sha256: HASH, uid: '0', gid: '0', mode: '644' }, + { role: 'kernel-service', pathHash: HASH, sha256: HASH, uid: '0', gid: '0', mode: '644' }, + ]); + assert.equal(left, right); + assert.notEqual(left, HASH); +}); + +test('loader environment is a closed allowlist and rejects every loader-control family', () => { + assert.deepEqual(assertClosedLoaderEnvironment({ + PATH: '/usr/bin:/bin', WALLET_KERNEL_MODE: 'cdp-testnet', + }, { allowedWalletKernelFields: ['WALLET_KERNEL_MODE'] }), { + PATH: '/usr/bin:/bin', WALLET_KERNEL_MODE: 'cdp-testnet', + }); + for (const name of [ + 'NODE_OPTIONS', 'NODE_PATH', 'LD_PRELOAD', 'LD_FOO', 'DYLD_INSERT_LIBRARIES', + 'GCONV_PATH', 'GLIBC_TUNABLES', 'WALLET_KERNEL_UNKNOWN', + ]) { + assert.throws(() => assertClosedLoaderEnvironment({ [name]: 'x' }, { + allowedWalletKernelFields: ['WALLET_KERNEL_MODE'], + }), /environment/); + } +}); + +test('inherited console activation validates exact PID, count, name, descriptor, and clears variables', () => { + const env = { LISTEN_PID: '123', LISTEN_FDS: '1', LISTEN_FDNAMES: 'wallet-kernel-console' }; + const result = captureInheritedConsoleSocket({ + env, processId: 123, + inspectDescriptor: (fd) => ({ + fd, family: 'AF_INET', type: 'SOCK_STREAM', listening: true, + address: '127.0.0.1', port: 8405, + }), + }); + assert.equal(result.fd, 3); + assert.deepEqual(env, {}); + for (const changed of [ + { LISTEN_PID: '122' }, { LISTEN_FDS: '2' }, { LISTEN_FDNAMES: 'wrong' }, + ]) { + assert.throws(() => captureInheritedConsoleSocket({ + env: { LISTEN_PID: '123', LISTEN_FDS: '1', LISTEN_FDNAMES: 'wallet-kernel-console', ...changed }, + processId: 123, inspectDescriptor: () => ({ + family: 'AF_INET', type: 'SOCK_STREAM', listening: true, + address: '127.0.0.1', port: 8405, + }), + }), /activation/); + } + assert.throws(() => captureInheritedConsoleSocket({ + env: { LISTEN_PID: '123', LISTEN_FDS: '1', LISTEN_FDNAMES: 'wallet-kernel-console' }, + processId: 123, inspectDescriptor: () => ({ + family: 'AF_INET6', type: 'SOCK_STREAM', listening: true, + address: '::1', port: 8405, + }), + }), /socket/); +}); diff --git a/spikes/pi-wielder/tests/seller-evidence-resolver.test.mjs b/spikes/pi-wielder/tests/seller-evidence-resolver.test.mjs new file mode 100644 index 0000000..afe5550 --- /dev/null +++ b/spikes/pi-wielder/tests/seller-evidence-resolver.test.mjs @@ -0,0 +1,954 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { keccak256, toBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { createSellerEvidenceResolver } from '../src/adapters/seller-evidence-resolver.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { validatePolicyDocument } from '../src/kernel/policy-engine.mjs'; + +const NOW = '2026-07-31T12:10:00.000Z'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const SELLER_ORIGIN = 'https://seller.example'; +const RESOURCE_PATH = '/paid/infer'; +const EVIDENCE_PATH = '/.well-known/wallet-kernel/evidence'; +const TRANSACTION_ID = `0x${'ab'.repeat(32)}`; +const REFUND_TRANSACTION_ID = `0x${'34'.repeat(32)}`; +const INTENT_HASH = `sha256:${'11'.repeat(32)}`; +const CASE_HASH = `sha256:${'22'.repeat(32)}`; +const RESPONSE_HASH = `sha256:${'44'.repeat(32)}`; +const PAYER = '0x1000000000000000000000000000000000000000'; +const PAYEE = '0x2000000000000000000000000000000000000000'; +const REFUND_SOURCE = '0x3000000000000000000000000000000000000000'; + +const executionAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-seller-execution-test-only')), +); +const refundAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-seller-refund-test-only')), +); + +const POLICY = validatePolicyDocument({ + schemaVersion: 1, + network: NETWORK, + asset: ASSET, + wallet: PAYER, + methods: ['POST'], + sellers: [{ + origin: SELLER_ORIGIN, + pathPrefixes: ['/paid/'], + payTo: PAYEE, + evidencePath: EVIDENCE_PATH, + executionSigner: executionAccount.address.toLowerCase(), + refundSigner: refundAccount.address.toLowerCase(), + refundSource: REFUND_SOURCE, + perRequestMaxAtomic: '500000', + autoApproveAtomic: '100000', + humanApproveAtomic: '500000', + sellerSessionMaxAtomic: '1000000', + }], + sessionMaxAtomic: '2000000', + rolling24hMaxAtomic: '5000000', + challengeMaxAgeMs: 60000, + approvalTtlMs: 300000, + maxPendingApprovals: 20, + defaultAction: 'deny', +}); +const POLICY_VERSION = Object.freeze({ + id: 'policy-1', + hash: sha256(canonicalJson(POLICY)), + policy: POLICY, +}); +const SELLER = POLICY.sellers[0]; + +function executionBinding(overrides = {}) { + return { + schemaVersion: 1, + domain: 'wallet-kernel.execution-observation.v1', + intentId: 'intent-1', + intentHash: INTENT_HASH, + policyVersion: POLICY_VERSION, + seller: SELLER, + resourcePath: RESOURCE_PATH, + network: NETWORK, + sellerOrigin: SELLER_ORIGIN, + transactionId: TRANSACTION_ID, + executionSigner: executionAccount.address.toLowerCase(), + persistedHttpStatus: null, + persistedResponseHash: null, + resolutionReasonCode: 'PAID_RESPONSE_BODY_LOST', + caseHash: CASE_HASH, + ...overrides, + }; +} + +function refundBinding(overrides = {}) { + const binding = { + schemaVersion: 1, + domain: 'wallet-kernel.refund-observation.v1', + intentId: 'intent-1', + intentHash: INTENT_HASH, + policyVersion: POLICY_VERSION, + seller: SELLER, + resourcePath: RESOURCE_PATH, + network: NETWORK, + sellerOrigin: SELLER_ORIGIN, + originalTransactionId: TRANSACTION_ID, + refundTransactionId: REFUND_TRANSACTION_ID, + asset: ASSET, + originalPayer: PAYER, + originalPayee: PAYEE, + refundSource: REFUND_SOURCE, + refundSigner: refundAccount.address.toLowerCase(), + amountAtomic: '50000', + localRefundBindingHash: null, + refundId: 'refund-1', + caseHash: CASE_HASH, + ...overrides, + }; + if (!Object.hasOwn(overrides, 'localRefundBindingHash')) { + binding.localRefundBindingHash = sha256(canonicalJson({ + schemaVersion: 1, + domain: 'wallet-kernel.refund-binding.v1', + intentHash: binding.intentHash, + originalTransactionId: binding.originalTransactionId, + refundTransactionId: binding.refundTransactionId, + network: binding.network, + sellerOrigin: binding.sellerOrigin, + asset: binding.asset, + originalPayer: binding.originalPayer, + originalPayee: binding.originalPayee, + refundSource: binding.refundSource, + refundSigner: binding.refundSigner, + amountAtomic: binding.amountAtomic, + })); + } + return binding; +} + +function unsignedExecution(overrides = {}) { + return { + schemaVersion: 1, + domain: 'wallet-kernel.execution.v1', + network: NETWORK, + sellerOrigin: SELLER_ORIGIN, + intentHash: INTENT_HASH, + transactionId: TRANSACTION_ID, + outcome: 'succeeded', + httpStatus: 200, + responseHash: RESPONSE_HASH, + issuedAt: '2026-07-31T12:09:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + signer: executionAccount.address.toLowerCase(), + ...overrides, + }; +} + +function unsignedRefund(overrides = {}) { + return { + schemaVersion: 1, + domain: 'wallet-kernel.refund.v1', + network: NETWORK, + sellerOrigin: SELLER_ORIGIN, + intentHash: INTENT_HASH, + originalTransactionId: TRANSACTION_ID, + refundTransactionId: REFUND_TRANSACTION_ID, + asset: ASSET, + originalPayer: PAYER, + originalPayee: PAYEE, + refundSource: REFUND_SOURCE, + amountAtomic: '50000', + issuedAt: '2026-07-31T12:09:00.000Z', + expiresAt: '2026-07-31T12:15:00.000Z', + signer: refundAccount.address.toLowerCase(), + ...overrides, + }; +} + +async function signedResponse(unsigned, account) { + const signature = await account.signMessage({ + message: { raw: Buffer.from(canonicalJson(unsigned), 'utf8') }, + }); + return new Response(JSON.stringify({ ...unsigned, signature }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function createResolver(fetchImpl, options = {}) { + return createSellerEvidenceResolver({ + fetchImpl, + mode: 'cdp-testnet', + now: () => NOW, + limits: { + requestTimeoutMs: 5_000, + maximumResponseBytes: 16_384, + }, + ...options, + }); +} + +function policyForOrigin(origin) { + const document = structuredClone(POLICY); + document.sellers[0].origin = origin; + return validatePolicyDocument(document); +} + +function versionFor(policy) { + return Object.freeze({ + id: 'policy-1', + hash: sha256(canonicalJson(policy)), + policy, + }); +} + +function bindingForPolicy(kind, policy, overrides = {}) { + const common = { + policyVersion: versionFor(policy), + seller: policy.sellers[0], + sellerOrigin: policy.sellers[0].origin, + }; + return kind === 'execution' + ? executionBinding({ ...common, ...overrides }) + : refundBinding({ ...common, ...overrides }); +} + +function assertUnknown(result, reasonCode) { + assert.deepEqual(result, { kind: 'unknown', reasonCode }); + assert.ok(Object.isFrozen(result)); + assert.equal(Object.getPrototypeOf(result), Object.prototype); +} + +async function responseWithSignature(unsigned, account, { + signedBytes = canonicalJson(unsigned), + mutateSignature = (value) => value, + status = 200, + contentType = 'application/json', + extra = {}, +} = {}) { + const signature = mutateSignature(await account.signMessage({ + message: { raw: Buffer.from(signedBytes, 'utf8') }, + })); + return new Response(JSON.stringify({ ...unsigned, signature, ...extra }), { + status, + headers: contentType === null ? {} : { 'content-type': contentType }, + }); +} + +async function observeStatic(kind, { + binding = kind === 'execution' ? executionBinding() : refundBinding(), + unsigned = kind === 'execution' ? unsignedExecution() : unsignedRefund(), + account = kind === 'execution' ? executionAccount : refundAccount, + responseOptions, + resolverOptions, +} = {}) { + let calls = 0; + const response = await responseWithSignature(unsigned, account, responseOptions); + const resolver = createResolver(async () => { + calls += 1; + return response; + }, resolverOptions); + const result = kind === 'execution' + ? await resolver.observeExecution(binding) + : await resolver.observeRefund(binding); + return { result, calls }; +} + +test('execution evidence is requested from the persisted policy endpoint and verified', async () => { + const calls = []; + const resolver = createResolver(async (url, init) => { + calls.push({ url, init }); + return await signedResponse(unsignedExecution(), executionAccount); + }); + + const result = await resolver.observeExecution(executionBinding()); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, `${SELLER_ORIGIN}${EVIDENCE_PATH}`); + assert.deepEqual(JSON.parse(calls[0].init.body), { + schemaVersion: 1, + kind: 'execution', + sellerOrigin: SELLER_ORIGIN, + intentHash: INTENT_HASH, + transactionId: TRANSACTION_ID, + }); + assert.equal(calls[0].init.method, 'POST'); + assert.equal(calls[0].init.redirect, 'manual'); + assert.deepEqual(calls[0].init.headers, { + accept: 'application/json', + 'content-type': 'application/json', + }); + assert.deepEqual(result, { + kind: 'execution_attested', + attestation: unsignedExecution(), + attestationHash: sha256(canonicalJson(unsignedExecution())), + }); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.attestation)); + assert.equal(JSON.stringify(result).includes('signature'), false); +}); + +test('refund evidence is requested with only persisted transaction bindings and verified', async () => { + const calls = []; + const resolver = createResolver(async (url, init) => { + calls.push({ url, init }); + return await signedResponse(unsignedRefund(), refundAccount); + }); + + const result = await resolver.observeRefund(refundBinding()); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, `${SELLER_ORIGIN}${EVIDENCE_PATH}`); + assert.deepEqual(JSON.parse(calls[0].init.body), { + schemaVersion: 1, + kind: 'refund', + sellerOrigin: SELLER_ORIGIN, + intentHash: INTENT_HASH, + originalTransactionId: TRANSACTION_ID, + refundTransactionId: REFUND_TRANSACTION_ID, + }); + assert.deepEqual(result, { + kind: 'refund_attested', + attestation: unsignedRefund(), + attestationHash: sha256(canonicalJson(unsignedRefund())), + }); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.attestation)); + assert.equal(JSON.stringify(result).includes('signature'), false); +}); + +test('the resolver factory is closed, bounded, injected, and frozen', () => { + const base = { + fetchImpl: async () => { throw new Error('not called'); }, + mode: 'cdp-testnet', + now: () => NOW, + limits: { requestTimeoutMs: 5_000, maximumResponseBytes: 16_384 }, + }; + const resolver = createSellerEvidenceResolver(base); + assert.ok(Object.isFrozen(resolver)); + assert.deepEqual(Object.keys(resolver), ['observeExecution', 'observeRefund']); + + for (const value of [ + { ...base, extra: true }, + { ...base, mode: 'production' }, + { ...base, limits: { ...base.limits, extra: true } }, + { ...base, limits: { ...base.limits, requestTimeoutMs: 0 } }, + { ...base, limits: { ...base.limits, requestTimeoutMs: 5_001 } }, + { ...base, limits: { ...base.limits, maximumResponseBytes: 0 } }, + { ...base, limits: { ...base.limits, maximumResponseBytes: 16_385 } }, + { ...base, fetchImpl: new Proxy(base.fetchImpl, {}) }, + { ...base, now: new Proxy(base.now, {}) }, + { ...base, limits: new Proxy(base.limits, {}) }, + ]) { + assert.throws( + () => createSellerEvidenceResolver(value), + (error) => error?.code === 'SELLER_EVIDENCE_CONFIG' + && !JSON.stringify(error).includes('not called'), + ); + } +}); + +test('the request uses a no-credential fetch surface and no caller URL', async () => { + const calls = []; + const secretSentinels = [ + 'BEARER_SECRET_SENTINEL', + 'COOKIE_SECRET_SENTINEL', + 'PAYMENT_SIGNATURE_SENTINEL', + '/private/secret/path', + ]; + const resolver = createResolver(async (url, init) => { + calls.push({ url, init }); + return await signedResponse(unsignedExecution(), executionAccount); + }); + const result = await resolver.observeExecution(executionBinding()); + + assert.equal(result.kind, 'execution_attested'); + assert.equal(calls.length, 1); + assert.deepEqual(Object.keys(calls[0].init).sort(), [ + 'body', 'cache', 'credentials', 'headers', 'method', 'redirect', + 'referrerPolicy', 'signal', + ]); + assert.equal(calls[0].init.credentials, 'omit'); + assert.equal(calls[0].init.cache, 'no-store'); + assert.equal(calls[0].init.referrerPolicy, 'no-referrer'); + assert.ok(calls[0].init.signal instanceof AbortSignal); + const serialized = JSON.stringify(calls[0]); + for (const sentinel of secretSentinels) assert.equal(serialized.includes(sentinel), false); + assert.deepEqual(Object.keys(JSON.parse(calls[0].init.body)).sort(), [ + 'intentHash', 'kind', 'schemaVersion', 'sellerOrigin', 'transactionId', + ]); + + let rejectedCalls = 0; + const rejectedResolver = createResolver(async () => { + rejectedCalls += 1; + return await signedResponse(unsignedExecution(), executionAccount); + }); + const resultWithCallerUrl = await rejectedResolver.observeExecution({ + ...executionBinding(), + evidenceUrl: `https://attacker.example/${secretSentinels[0]}`, + }); + assertUnknown(resultWithCallerUrl, 'SELLER_EVIDENCE_BINDING_INVALID'); + assert.equal(rejectedCalls, 0); +}); + +for (const origin of ['http://127.0.0.1:9080', 'http://[::1]:9080']) { + test(`deterministic mode accepts only the canonical literal loopback origin ${origin}`, async () => { + const policy = policyForOrigin(origin); + const binding = bindingForPolicy('execution', policy); + const unsigned = unsignedExecution({ sellerOrigin: origin }); + let endpoint; + const resolver = createResolver(async (url) => { + endpoint = url; + return await responseWithSignature(unsigned, executionAccount); + }, { mode: 'deterministic' }); + + const result = await resolver.observeExecution(binding); + + assert.equal(result.kind, 'execution_attested'); + assert.equal(endpoint, `${origin}${EVIDENCE_PATH}`); + }); +} + +test('live mode rejects loopback HTTP before fetch', async () => { + const policy = policyForOrigin('http://127.0.0.1:9080'); + let calls = 0; + const resolver = createResolver(async () => { + calls += 1; + return await signedResponse(unsignedExecution(), executionAccount); + }); + + const result = await resolver.observeExecution(bindingForPolicy('execution', policy)); + + assertUnknown(result, 'SELLER_EVIDENCE_ENDPOINT_INVALID'); + assert.equal(calls, 0); +}); + +test('invalid or hostile persisted authority is rejected before fetch without invoking accessors', async () => { + const cases = []; + const noncanonicalPolicy = { + ...POLICY, + sellers: [{ + ...SELLER, + executionSigner: executionAccount.address, + }], + }; + assert.notEqual(canonicalJson(noncanonicalPolicy), canonicalJson(POLICY)); + cases.push({ + ...executionBinding(), + policyVersion: { + ...POLICY_VERSION, + policy: noncanonicalPolicy, + }, + }); + cases.push({ ...executionBinding(), policyVersion: { + ...POLICY_VERSION, + hash: `sha256:${'99'.repeat(32)}`, + } }); + cases.push({ ...executionBinding(), seller: { ...SELLER, evidencePath: '/attacker' } }); + cases.push(executionBinding({ resourcePath: '/untrusted/infer' })); + cases.push(executionBinding({ sellerOrigin: 'https://attacker.example' })); + cases.push(executionBinding({ executionSigner: refundAccount.address.toLowerCase() })); + cases.push(executionBinding({ network: 'eip155:1' })); + cases.push(executionBinding({ transactionId: `0x${'AB'.repeat(32)}` })); + cases.push(executionBinding({ persistedHttpStatus: 99 })); + cases.push(executionBinding({ persistedResponseHash: 'not-a-hash' })); + cases.push(executionBinding({ resolutionReasonCode: 'secret reason' })); + cases.push({ ...executionBinding(), unexpected: true }); + cases.push(new Proxy(executionBinding(), {})); + + let getterCalls = 0; + const hostile = executionBinding(); + Object.defineProperty(hostile, 'intentHash', { + enumerable: true, + get() { + getterCalls += 1; + return INTENT_HASH; + }, + }); + cases.push(hostile); + + let fetchCalls = 0; + const resolver = createResolver(async () => { + fetchCalls += 1; + return await signedResponse(unsignedExecution(), executionAccount); + }); + for (const binding of cases) { + assertUnknown( + await resolver.observeExecution(binding), + 'SELLER_EVIDENCE_BINDING_INVALID', + ); + } + assert.equal(fetchCalls, 0); + assert.equal(getterCalls, 0); +}); + +test('refund authority revalidates every redundant policy and local binding field', async () => { + const mutations = [ + { policyVersion: { ...POLICY_VERSION, id: '' } }, + { seller: { ...SELLER, refundSource: PAYEE } }, + { resourcePath: '/untrusted/infer' }, + { network: 'eip155:1' }, + { sellerOrigin: 'https://attacker.example' }, + { asset: PAYEE }, + { originalPayer: PAYEE }, + { originalPayee: REFUND_SOURCE }, + { refundSource: PAYEE }, + { refundSigner: executionAccount.address.toLowerCase() }, + { amountAtomic: '0' }, + { localRefundBindingHash: `sha256:${'77'.repeat(32)}` }, + { refundTransactionId: `0x${'AB'.repeat(32)}` }, + { caseHash: 'bad' }, + ]; + let fetchCalls = 0; + const resolver = createResolver(async () => { + fetchCalls += 1; + return await signedResponse(unsignedRefund(), refundAccount); + }); + for (const mutation of mutations) { + assertUnknown( + await resolver.observeRefund(refundBinding(mutation)), + 'SELLER_EVIDENCE_BINDING_INVALID', + ); + } + assert.equal(fetchCalls, 0); +}); + +const executionAttestationMutations = [ + ['schemaVersion', { schemaVersion: 2 }, 'SELLER_EVIDENCE_ATTESTATION_INVALID'], + ['domain', { domain: 'wallet-kernel.refund.v1' }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['network', { network: 'eip155:1' }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['sellerOrigin', { sellerOrigin: 'https://attacker.example' }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['intentHash', { intentHash: `sha256:${'55'.repeat(32)}` }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['transactionId', { transactionId: `0x${'56'.repeat(32)}` }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['outcome', { outcome: 'unknown' }, 'SELLER_EVIDENCE_ATTESTATION_INVALID'], + ['httpStatus', { httpStatus: 99 }, 'SELLER_EVIDENCE_ATTESTATION_INVALID'], + ['responseHash', { responseHash: 'not-a-hash' }, 'SELLER_EVIDENCE_ATTESTATION_INVALID'], + ['issuedAt', { issuedAt: '2026-07-31T12:11:00.000Z' }, 'SELLER_EVIDENCE_TIME_INVALID'], + ['expiresAt', { expiresAt: '2026-07-31T12:09:59.999Z' }, 'SELLER_EVIDENCE_TIME_INVALID'], + ['signer', { signer: refundAccount.address.toLowerCase() }, 'SELLER_EVIDENCE_SIGNATURE_INVALID'], +]; + +for (const [field, mutation, reasonCode] of executionAttestationMutations) { + test(`execution attestation rejects a signed mutation of ${field}`, async () => { + const { result, calls } = await observeStatic('execution', { + unsigned: unsignedExecution(mutation), + }); + assert.equal(calls, 1); + assertUnknown(result, reasonCode); + }); +} + +const refundAttestationMutations = [ + ['schemaVersion', { schemaVersion: 2 }, 'SELLER_EVIDENCE_ATTESTATION_INVALID'], + ['domain', { domain: 'wallet-kernel.execution.v1' }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['network', { network: 'eip155:1' }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['sellerOrigin', { sellerOrigin: 'https://attacker.example' }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['intentHash', { intentHash: `sha256:${'61'.repeat(32)}` }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['originalTransactionId', { originalTransactionId: `0x${'62'.repeat(32)}` }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['refundTransactionId', { refundTransactionId: `0x${'63'.repeat(32)}` }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['asset', { asset: PAYEE }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['originalPayer', { originalPayer: PAYEE }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['originalPayee', { originalPayee: REFUND_SOURCE }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['refundSource', { refundSource: PAYEE }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['amountAtomic', { amountAtomic: '49999' }, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'], + ['issuedAt', { issuedAt: '2026-07-31T12:11:00.000Z' }, 'SELLER_EVIDENCE_TIME_INVALID'], + ['expiresAt', { expiresAt: '2026-07-31T12:09:59.999Z' }, 'SELLER_EVIDENCE_TIME_INVALID'], + ['signer', { signer: executionAccount.address.toLowerCase() }, 'SELLER_EVIDENCE_SIGNATURE_INVALID'], +]; + +for (const [field, mutation, reasonCode] of refundAttestationMutations) { + test(`refund attestation rejects a signed mutation of ${field}`, async () => { + const { result, calls } = await observeStatic('refund', { + unsigned: unsignedRefund(mutation), + }); + assert.equal(calls, 1); + assertUnknown(result, reasonCode); + }); +} + +test('signature verification uses canonical raw bytes, not JSON transport bytes', async () => { + const unsigned = unsignedExecution(); + const { result } = await observeStatic('execution', { + unsigned, + responseOptions: { signedBytes: JSON.stringify(unsigned) }, + }); + assertUnknown(result, 'SELLER_EVIDENCE_SIGNATURE_INVALID'); +}); + +test('declared and recovered signers must independently equal the policy signer', async () => { + const declaredWrong = await observeStatic('execution', { + unsigned: unsignedExecution({ signer: refundAccount.address.toLowerCase() }), + account: refundAccount, + }); + assertUnknown(declaredWrong.result, 'SELLER_EVIDENCE_SIGNATURE_INVALID'); + + const recoveredWrong = await observeStatic('execution', { + unsigned: unsignedExecution(), + account: refundAccount, + }); + assertUnknown(recoveredWrong.result, 'SELLER_EVIDENCE_SIGNATURE_INVALID'); +}); + +test('mutated signatures and cross-kind replay remain unknown', async () => { + const mutatedSignature = await observeStatic('refund', { + responseOptions: { + mutateSignature: (signature) => `${signature.slice(0, -1)}${ + signature.endsWith('0') ? '1' : '0' + }`, + }, + }); + assertUnknown(mutatedSignature.result, 'SELLER_EVIDENCE_SIGNATURE_INVALID'); + + const refundAsExecution = await observeStatic('execution', { + unsigned: unsignedRefund(), + account: refundAccount, + }); + assertUnknown(refundAsExecution.result, 'SELLER_EVIDENCE_ATTESTATION_INVALID'); + + const executionAsRefund = await observeStatic('refund', { + unsigned: unsignedExecution(), + account: executionAccount, + }); + assertUnknown(executionAsRefund.result, 'SELLER_EVIDENCE_ATTESTATION_INVALID'); +}); + +test('attestation schemas reject missing, unknown, array, and prototype-bearing shapes', async () => { + const missing = unsignedExecution(); + delete missing.responseHash; + assertUnknown( + (await observeStatic('execution', { unsigned: missing })).result, + 'SELLER_EVIDENCE_ATTESTATION_INVALID', + ); + + assertUnknown( + (await observeStatic('execution', { + responseOptions: { extra: { providerSecret: 'RAW_PROVIDER_BODY_SENTINEL' } }, + })).result, + 'SELLER_EVIDENCE_ATTESTATION_INVALID', + ); + + let calls = 0; + const resolver = createResolver(async () => { + calls += 1; + return new Response('[]', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + assertUnknown( + await resolver.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_ATTESTATION_INVALID', + ); + assert.equal(calls, 1); + + const pollution = createResolver(async () => new Response( + '{"__proto__":{"polluted":true}}', + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + assertUnknown( + await pollution.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_ATTESTATION_INVALID', + ); + assert.equal(Object.prototype.polluted, undefined); +}); + +test('execution evidence honors persisted status and response-hash authority', async () => { + const binding = executionBinding({ + persistedHttpStatus: 200, + persistedResponseHash: RESPONSE_HASH, + }); + const valid = await observeStatic('execution', { binding }); + assert.equal(valid.result.kind, 'execution_attested'); + + const wrongStatus = await observeStatic('execution', { + binding, + unsigned: unsignedExecution({ httpStatus: 201 }), + }); + assertUnknown(wrongStatus.result, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'); + + const wrongHash = await observeStatic('execution', { + binding, + unsigned: unsignedExecution({ responseHash: `sha256:${'45'.repeat(32)}` }), + }); + assertUnknown(wrongHash.result, 'SELLER_EVIDENCE_ATTESTATION_MISMATCH'); +}); + +test('execution outcome and HTTP status semantics are closed', async () => { + const validFailure = await observeStatic('execution', { + unsigned: unsignedExecution({ outcome: 'failed', httpStatus: 500, responseHash: null }), + }); + assert.equal(validFailure.result.kind, 'execution_attested'); + + const failedSuccessStatus = await observeStatic('execution', { + unsigned: unsignedExecution({ outcome: 'failed', httpStatus: 200 }), + }); + assertUnknown(failedSuccessStatus.result, 'SELLER_EVIDENCE_ATTESTATION_INVALID'); + + const succeededFailureStatus = await observeStatic('execution', { + unsigned: unsignedExecution({ outcome: 'succeeded', httpStatus: 500 }), + }); + assertUnknown(succeededFailureStatus.result, 'SELLER_EVIDENCE_ATTESTATION_INVALID'); +}); + +test('attestation lifetime is current, canonical, and at most fifteen minutes', async () => { + const tooLong = await observeStatic('execution', { + unsigned: unsignedExecution({ + issuedAt: '2026-07-31T12:00:00.000Z', + expiresAt: '2026-07-31T12:15:00.001Z', + }), + }); + assertUnknown(tooLong.result, 'SELLER_EVIDENCE_TIME_INVALID'); + + const noncanonical = await observeStatic('refund', { + unsigned: unsignedRefund({ issuedAt: '2026-07-31T12:09:00Z' }), + }); + assertUnknown(noncanonical.result, 'SELLER_EVIDENCE_TIME_INVALID'); + + for (const kind of ['execution', 'refund']) { + const atExactExpiry = await observeStatic(kind, { + resolverOptions: { now: () => '2026-07-31T12:15:00.000Z' }, + }); + assertUnknown( + atExactExpiry.result, + 'SELLER_EVIDENCE_TIME_INVALID', + ); + } +}); + +test('provider exceptions are redacted, attempted once, and never returned as causes', async () => { + const secret = 'RAW_PROVIDER_EXCEPTION_SENTINEL'; + let calls = 0; + const resolver = createResolver(async () => { + calls += 1; + throw Object.freeze({ secret, response: { authorization: secret } }); + }); + + const result = await resolver.observeExecution(executionBinding()); + + assertUnknown(result, 'SELLER_EVIDENCE_FETCH_FAILED'); + assert.equal(calls, 1); + assert.equal(JSON.stringify(result).includes(secret), false); + assert.equal(Object.hasOwn(result, 'cause'), false); +}); + +test('the total deadline aborts the one provider attempt without retry', async () => { + let calls = 0; + let observedSignal; + const resolver = createResolver((_url, init) => { + calls += 1; + observedSignal = init.signal; + return new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('late provider rejection')), 80); + }); + }, { + limits: { requestTimeoutMs: 10, maximumResponseBytes: 16_384 }, + }); + + const result = await resolver.observeExecution(executionBinding()); + + assertUnknown(result, 'SELLER_EVIDENCE_TIMEOUT'); + assert.equal(calls, 1); + assert.ok(observedSignal instanceof AbortSignal); + assert.equal(observedSignal.aborted, true); +}); + +test('redirects and non-success response statuses are stable unknowns', async () => { + let redirectCalls = 0; + const redirectResolver = createResolver(async () => { + redirectCalls += 1; + return new Response(null, { + status: 302, + headers: { location: 'https://attacker.example/evidence' }, + }); + }); + assertUnknown( + await redirectResolver.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_REDIRECT', + ); + assert.equal(redirectCalls, 1); + + const statusResolver = createResolver(async () => new Response('{}', { + status: 503, + headers: { 'content-type': 'application/json' }, + })); + assertUnknown( + await statusResolver.observeRefund(refundBinding()), + 'SELLER_EVIDENCE_HTTP_STATUS', + ); +}); + +test('only a JSON content type is accepted and canonical UTF-8 charset is supported', async () => { + const wrongType = await observeStatic('execution', { + responseOptions: { contentType: 'text/html' }, + }); + assertUnknown(wrongType.result, 'SELLER_EVIDENCE_CONTENT_TYPE'); + + const missingType = await observeStatic('execution', { + responseOptions: { contentType: null }, + }); + assertUnknown(missingType.result, 'SELLER_EVIDENCE_CONTENT_TYPE'); + + const charset = await observeStatic('execution', { + responseOptions: { contentType: 'application/json; charset=utf-8' }, + }); + assert.equal(charset.result.kind, 'execution_attested'); +}); + +test('declared and streamed response bytes are both capped before JSON parsing', async () => { + const declared = createResolver(async () => new Response('{}', { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': '16385', + }, + })); + assertUnknown( + await declared.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_TOO_LARGE', + ); + + const streamed = createResolver(async () => new Response( + JSON.stringify({ oversized: 'x'.repeat(1_000) }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), { + limits: { requestTimeoutMs: 5_000, maximumResponseBytes: 128 }, + }); + assertUnknown( + await streamed.observeRefund(refundBinding()), + 'SELLER_EVIDENCE_TOO_LARGE', + ); +}); + +test('invalid JSON, invalid UTF-8, and body failures are redacted', async () => { + const malformed = createResolver(async () => new Response('{', { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + assertUnknown( + await malformed.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_JSON_INVALID', + ); + + const invalidUtf8 = createResolver(async () => new Response( + new Uint8Array([0xc3, 0x28]), + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + assertUnknown( + await invalidUtf8.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_JSON_INVALID', + ); + + const secret = 'RAW_BODY_STREAM_ERROR_SENTINEL'; + const broken = createResolver(async () => new Response(new ReadableStream({ + pull(controller) { + controller.error(new Error(secret)); + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + const result = await broken.observeRefund(refundBinding()); + assertUnknown(result, 'SELLER_EVIDENCE_RESPONSE_INVALID'); + assert.equal(JSON.stringify(result).includes(secret), false); +}); + +test('hostile response surfaces are rejected without invoking traps or accessors', async () => { + let getterCalls = 0; + const accessorResponse = {}; + Object.defineProperty(accessorResponse, 'status', { + enumerable: true, + get() { + getterCalls += 1; + return 200; + }, + }); + const accessorResolver = createResolver(async () => accessorResponse); + assertUnknown( + await accessorResolver.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_RESPONSE_INVALID', + ); + assert.equal(getterCalls, 0); + + const shadowedResponse = new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + Object.defineProperty(shadowedResponse, 'status', { + enumerable: true, + get() { + getterCalls += 1; + return 200; + }, + }); + const shadowedResolver = createResolver(async () => shadowedResponse); + assertUnknown( + await shadowedResolver.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_RESPONSE_INVALID', + ); + assert.equal(getterCalls, 0); + + const proxyTraps = []; + const proxiedResponse = new Proxy(new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), { + get(target, property, receiver) { + proxyTraps.push(property); + return Reflect.get(target, property, receiver); + }, + }); + const proxyResolver = createResolver(async () => proxiedResponse); + assertUnknown( + await proxyResolver.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_RESPONSE_INVALID', + ); + // Promise/await assimilation performs the language-mandated `then` lookup; + // the resolver itself must reject the proxy before touching response authority. + assert.deepEqual(proxyTraps, ['then']); + + class HostileResponse extends Response { + get status() { + getterCalls += 1; + return 200; + } + } + const hostileResponse = new HostileResponse('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + // Undici's constructor itself consults `status`; count only resolver access. + getterCalls = 0; + const subclassResolver = createResolver(async () => hostileResponse); + assertUnknown( + await subclassResolver.observeExecution(executionBinding()), + 'SELLER_EVIDENCE_RESPONSE_INVALID', + ); + assert.equal(getterCalls, 0); +}); + +test('seller body, signature, provider error, and filesystem sentinels never cross the result boundary', async () => { + const sentinels = [ + 'RAW_SELLER_BODY_SENTINEL', + 'RAW_SIGNATURE_SENTINEL', + 'BEARER_CREDENTIAL_SENTINEL', + '/private/wallet-kernel/evidence', + ]; + const resolver = createResolver(async () => new Response(JSON.stringify({ + ...unsignedExecution(), + signature: sentinels[1], + body: sentinels[0], + credential: sentinels[2], + path: sentinels[3], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const result = await resolver.observeExecution(executionBinding()); + + assertUnknown(result, 'SELLER_EVIDENCE_ATTESTATION_INVALID'); + const serialized = JSON.stringify(result); + for (const sentinel of sentinels) assert.equal(serialized.includes(sentinel), false); +}); diff --git a/spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs b/spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs new file mode 100644 index 0000000..5b8c384 --- /dev/null +++ b/spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import test from 'node:test'; + +import { runSpendControlProcessAcceptance } from '../scripts/lib/spend-control-process-runner.mjs'; + +const execFileAsync = promisify(execFile); +const ROOT = path.resolve(import.meta.dirname, '..'); +const NODE = process.execPath; +const PI = path.join(ROOT, 'node_modules', '.bin', 'pi'); +const PRELOAD = path.join(import.meta.dirname, 'fixtures', 'loopback-only-preload.cjs'); + +const EXPECTED_INVARIANTS = Object.freeze([ + 'allowed-payment-settles-once', + 'policy-denials-never-sign', + 'approval-survives-restart', + 'denial-and-expiry-never-sign', + 'changed-challenge-terminalizes-approval', + 'settled-http-failures-commit-and-block', + 'body-loss-execution-reconciliation', + 'pre-settlement-loss-holds-budget', + 'post-signature-ambiguity-is-unresolved', + 'trusted-settlement-needs-execution-evidence', + 'refund-releases-only-after-full-proof', + 'fresh-process-verifies-authority', + 'pi-carries-no-authority-headers', + 'all-egress-is-loopback', + 'unauthorized-calls-fail-before-body', + 'credential-reattaches-session', + 'tighter-policy-requires-guarded-transition', + 'revocation-recovery-and-replacement', +]); + +async function supportsLoopbackListener() { + const server = net.createServer(); + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + return true; + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') return false; + throw error; + } finally { + if (server.listening) { + await new Promise((resolve) => server.close(resolve)); + } + } +} + +function assertNoSensitiveEvidence(value) { + const serialized = JSON.stringify(value); + const forbiddenFields = new Set([ + 'authorization', 'cookie', 'paymentsignature', 'paymentpayload', + 'agentcredential', 'agenttoken', 'operatortoken', 'providererror', + 'providerexception', 'privatekey', 'requestbody', 'responsebody', + ]); + const visit = (item) => { + if (!item || typeof item !== 'object') return; + for (const [key, child] of Object.entries(item)) { + const compact = key.toLowerCase().replaceAll(/[^a-z0-9]/gu, ''); + assert.equal(forbiddenFields.has(compact), false, key); + visit(child); + } + }; + visit(value); + for (const forbidden of ['payment-signature:', 'private key', 'bearer ']) { + assert.equal(serialized.toLowerCase().includes(forbidden), false, forbidden); + } + assert.equal(/\/(?:Users|home|private|tmp|var)\//u.test(serialized), false); +} + +test('repository-local Pi is pinned to exactly 0.80.6', async () => { + const { stdout, stderr } = await execFileAsync(PI, ['--version'], { + cwd: ROOT, + env: { PATH: path.dirname(NODE) }, + timeout: 5_000, + }); + assert.equal(stderr, ''); + assert.equal(stdout.trim(), '0.80.6'); +}); + +test('loopback preload rejects external sockets and records only a sanitized destination', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-egress-test-')); + fs.chmodSync(directory, 0o700); + const logFile = path.join(directory, 'egress.jsonl'); + fs.writeFileSync(logFile, '', { mode: 0o600, flag: 'wx' }); + try { + const script = [ + "const net = require('node:net');", + "try { net.connect({ host: '203.0.113.7', port: 443 }); }", + "catch (error) { process.stdout.write(error.code + '\\n'); }", + ].join(' '); + const { stdout, stderr } = await execFileAsync(NODE, ['-e', script], { + cwd: ROOT, + env: { + NODE_OPTIONS: `--require=${PRELOAD}`, + WALLET_KERNEL_EGRESS_LOG_FILE: logFile, + }, + timeout: 5_000, + }); + assert.equal(stderr, ''); + assert.equal(stdout, 'EXTERNAL_EGRESS_FORBIDDEN\n'); + const records = fs.readFileSync(logFile, 'utf8').trim().split('\n').map(JSON.parse); + assert.deepEqual(records, [{ destination: '203.0.113.7', operation: 'net.connect' }]); + } finally { + fs.rmSync(directory, { recursive: true }); + } +}); + +test('real pinned-Pi process acceptance proves all eighteen invariants', async (t) => { + if (!await supportsLoopbackListener()) { + t.skip('loopback listener creation is denied by this sandbox (EPERM)'); + return; + } + + const authorityDirectory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-process-authority-')), + ); + fs.chmodSync(authorityDirectory, 0o700); + let cleanup = async () => {}; + t.after(async () => { + await cleanup(); + fs.rmSync(authorityDirectory, { recursive: true }); + }); + + const result = await runSpendControlProcessAcceptance({ + authorityDirectory, + piExecutable: PI, + nodeExecutable: NODE, + }); + cleanup = result.cleanup; + + assert.deepEqual(result.summary, { + mode: 'offline-deterministic', + piVersion: '0.80.6', + x402Version: 2, + network: 'eip155:84532', + isolation: 'simulated', + tests: 18, + passed: 18, + liveCdp: 'not-run', + testnetTransaction: 'not-run', + }); + assert.deepEqual( + result.evidenceInput.acceptance.invariants.map(({ id }) => id), + EXPECTED_INVARIANTS, + ); + assert.equal( + result.evidenceInput.acceptance.invariants.every(({ passed }) => passed === true), + true, + ); + assert.equal( + Object.values(result.evidenceInput.acceptance.processExitCodes) + .every((code) => code === 0), + true, + ); + assert.equal(new Set(result.evidenceInput.acceptance.transactionIds).size, + result.evidenceInput.acceptance.transactionIds.length); + assert.equal(result.evidenceInput.acceptance.rawSettlementTransactionIds.length > 1, true); + assert.equal( + result.evidenceInput.acceptance.rawSettlementTransactionIds.length, + new Set(result.evidenceInput.acceptance.rawSettlementTransactionIds).size, + ); + assert.equal(result.evidenceInput.acceptance.nonLoopbackEgressAttempts, 0); + assert.equal(result.evidenceInput.acceptance.forbiddenPiAuthorityHeaderCount, 0); + assert.equal(result.evidenceInput.acceptance.piOutputObserved, 'PI_WALLET_OK'); + for (const kind of ['tool', 'model']) { + assert.deepEqual(result.evidenceInput.acceptance.piApprovalResume[kind], { + pendingObserved: true, + originalRequestHeld: true, + operatorApprovalStatus: 200, + signerDelta: 1, + paidRequestDelta: 1, + duplicatePaymentSignatureDelta: 0, + outputObserved: 'PI_WALLET_OK', + processExitCode: 0, + }); + } + assert.equal(result.evidenceInput.freshVerification.authorityEventChain, true); + assert.equal(result.evidenceInput.freshVerification.projection, true); + assert.equal(result.evidenceInput.freshVerification.receipts, true); + assert.equal(result.evidenceInput.sessionProjections.length > 1, true); + assert.equal( + result.evidenceInput.sessionProjections.length, + new Set(result.evidenceInput.sessionProjections.map((bundle) => ( + bundle.projection.sessionHash + ))).size, + ); + assert.equal(result.evidenceInput.authorityReceipts.length > Math.max( + ...result.evidenceInput.sessionProjections.map((bundle) => ( + bundle.projection.signedReceipts.length + )), + ), true); + assert.equal( + result.evidenceInput.authorityReceipts.length, + new Set(result.evidenceInput.authorityReceipts.map(({ receiptHash }) => receiptHash)).size, + ); + const receiptEvents = result.evidenceInput.events.filter( + ({ eventType }) => eventType === 'receipt.issued', + ); + assert.equal(receiptEvents.length, result.evidenceInput.authorityReceipts.length); + assert.equal(receiptEvents.every(({ receiptHash, receiptSignature }) => ( + typeof receiptHash === 'string' && typeof receiptSignature === 'string' + )), true); + assertNoSensitiveEvidence(result.evidenceInput); +}); diff --git a/spikes/pi-wielder/tests/spend-control-proxy.test.mjs b/spikes/pi-wielder/tests/spend-control-proxy.test.mjs new file mode 100644 index 0000000..cb5242f --- /dev/null +++ b/spikes/pi-wielder/tests/spend-control-proxy.test.mjs @@ -0,0 +1,1792 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { validateRouteMap } from '../src/config.mjs'; +import { createSpendControlProxy } from '../src/spend-control-proxy.mjs'; +import { canonicalJson, KernelError } from '../src/kernel/canonical.mjs'; + +const ORIGIN = 'http://127.0.0.1:8505'; +const TOKEN = Buffer.alloc(32, 0x41).toString('base64url'); +const AGENT_CALL_ID = Buffer.alloc(32, 0x42).toString('base64url'); +const TRANSACTION = `0x${'ab'.repeat(32)}`; +const RECEIPT_HASH = 'cd'.repeat(32); + +function correlationForAgentCall(agentCallId) { + return `agent-call:${crypto.createHash('sha256') + .update('wallet-kernel.agent-call.v1\0', 'utf8') + .update(agentCallId, 'ascii') + .digest('base64url')}`; +} + +function signedReceipt({ + status = 'completed', + reasonCode = 'PAYMENT_SETTLED', + paymentState = 'settled', + amountAtomic = '100000', + transactionId = TRANSACTION, + budgetDisposition = 'committed', + revision = 1, + receiptId = 'receipt-1', + requestId = 'request-1', + resourcePath = '/paid/chat/completions', + purposeLabel = 'model.infer', + executionState = 'succeeded', + httpStatus = 200, +} = {}) { + return Object.freeze({ + id: receiptId, + intentId: 'internal-intent-1', + revision, + receipt: Object.freeze({ + schemaVersion: 1, + receiptId, + revision, + issuedAt: '2026-08-01T12:00:00.000Z', + intent: Object.freeze({ + id: 'internal-intent-1', + requestId, + intentHash: `sha256:${'11'.repeat(32)}`, + sessionId: 'session-1', + sellerOrigin: 'https://seller.example', + resourcePath, + purposeLabel, + }), + outcome: Object.freeze({ status, reasonCode }), + policy: Object.freeze({ + versionId: 'policy-1', decision: 'allow', reasonCode: 'WITHIN_AUTO_LIMIT', + }), + approval: Object.freeze({ state: 'not_required', operatorIdHash: null }), + payment: paymentState === 'none' + ? Object.freeze({ state: 'none' }) + : Object.freeze({ + state: paymentState, + amountAtomic, + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + payTo: '0x2000000000000000000000000000000000000000', + transactionId, + }), + execution: Object.freeze({ + state: executionState, + httpStatus, + responseHash: executionState === 'none' ? null : `sha256:${'22'.repeat(32)}`, + }), + budget: paymentState === 'none' + ? null + : Object.freeze({ disposition: budgetDisposition, amountAtomic }), + reconciliation: null, + refund: null, + supersedesReceiptHash: revision === 1 ? null : 'ef'.repeat(32), + }), + receiptHash: RECEIPT_HASH, + signature: Buffer.alloc(64, 0x55).toString('base64'), + algorithm: 'Ed25519', + keyId: `sha256:${'33'.repeat(32)}`, + supersedesReceiptHash: revision === 1 ? null : 'ef'.repeat(32), + createdAt: '2026-08-01T12:00:00.000Z', + }); +} + +function statusView(receipt, { + requestId = 'request-1', + approval = null, + outcome = receipt === null ? null : { + status: receipt.receipt.outcome.status, + reasonCode: receipt.receipt.outcome.reasonCode, + revision: receipt.receipt.revision, + }, + intentState = outcome === null ? 'approval_pending' : 'terminal', + remainingSessionAtomic = '1900000', + sellerOrigin = 'https://seller.example', + purposeLabel = receipt?.receipt.intent.purposeLabel ?? 'skill.invoke', +} = {}) { + return Object.freeze({ + requestId, + sellerOrigin, + purposeLabel, + intentState, + approval: approval === null ? null : Object.freeze(approval), + outcome: outcome === null ? null : Object.freeze(outcome), + receipt, + remainingSessionAtomic, + }); +} + +const ROUTE_DOCUMENT = JSON.parse(fs.readFileSync( + new URL('../routes/base-sepolia.example.json', import.meta.url), + 'utf8', +)); + +function dependencies({ auth = {}, kernel = {}, maximumRequestBytes = 262_144 } = {}) { + const calls = []; + return { + calls, + agentAuth: Object.freeze({ + authenticate(request) { + calls.push({ name: 'authenticate', request }); + return Object.freeze({ agentInstanceId: 'agent-1' }); + }, + resolveBoundSession(principal) { + calls.push({ name: 'resolveBoundSession', principal }); + return Object.freeze({ id: 'session-1' }); + }, + ...auth, + }), + kernel: Object.freeze({ + async execute(input) { + calls.push({ name: 'execute', input }); + throw new Error('not used by construction test'); + }, + statusByRequestId(input) { + calls.push({ name: 'statusByRequestId', input }); + return null; + }, + receiptById(input) { + calls.push({ name: 'receiptById', input }); + return null; + }, + ...kernel, + }), + routes: validateRouteMap({ document: ROUTE_DOCUMENT, mode: 'cdp-testnet' }), + maximumRequestBytes, + }; +} + +function create(overrides) { + const value = dependencies(overrides); + return { + ...value, + app: createSpendControlProxy({ + agentAuth: value.agentAuth, + kernel: value.kernel, + routes: value.routes, + maximumRequestBytes: value.maximumRequestBytes, + }), + }; +} + +function agentHeaders(extra = {}) { + return { + authorization: `WalletKernelAgent ${TOKEN}`, + 'content-type': 'application/json', + accept: 'application/json', + 'user-agent': 'pi-agent/1', + 'x-agent-call-id': AGENT_CALL_ID, + ...extra, + }; +} + +async function json(response) { + return await response.json(); +} + +test('proxy construction exposes only the four agent route capabilities', () => { + const { app, calls } = create(); + + assert.deepEqual(app.routes.map(({ method, path }) => `${method} ${path}`).sort(), [ + 'GET /agent/v1/intents/:requestId', + 'GET /agent/v1/receipts/:receiptId', + 'POST /agent/v1/invoke/:routeId', + 'POST /agent/v1/openai/:routeId/chat/completions', + ].sort()); + assert.deepEqual(calls, []); +}); + +test('proxy construction rejects ambient, mutable, accessor, and incomplete authority', () => { + const value = dependencies(); + const valid = { + agentAuth: value.agentAuth, + kernel: value.kernel, + routes: value.routes, + maximumRequestBytes: value.maximumRequestBytes, + }; + const getter = {}; + Object.defineProperty(getter, 'authenticate', { enumerable: true, get() { return () => {}; } }); + Object.defineProperty(getter, 'resolveBoundSession', { + enumerable: true, value() {}, + }); + for (const invalid of [ + { ...valid, environment: process.env }, + { ...valid, maximumRequestBytes: 0 }, + { ...valid, maximumRequestBytes: 1_048_577 }, + { ...valid, agentAuth: getter }, + { ...valid, kernel: { execute() {}, statusByRequestId() {} } }, + { ...valid, routes: ROUTE_DOCUMENT }, + new Proxy(valid, {}), + ]) { + assert.throws(() => createSpendControlProxy(invalid), TypeError); + } +}); + +test('agent authentication fails before session resolution, route authority, or body parsing', async () => { + let bodyReaderCalls = 0; + const { app, calls } = create({ + auth: { + authenticate(request) { + calls.push({ name: 'authenticate', request }); + throw new KernelError('AGENT_UNAUTHORIZED', 'private authentication detail'); + }, + }, + }); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(canonicalJson({ prompt: 'secret' }))); + controller.close(); + }, + }); + const getReader = body.getReader.bind(body); + Object.defineProperty(body, 'getReader', { + value(...args) { + bodyReaderCalls += 1; + return getReader(...args); + }, + }); + const response = await app.request(new Request( + `${ORIGIN}/agent/v1/openai/example-model/chat/completions`, + { method: 'POST', headers: agentHeaders(), body, duplex: 'half' }, + )); + + assert.equal(response.status, 401); + assert.deepEqual(await json(response), { + error: { code: 'AGENT_UNAUTHORIZED', message: 'Agent authentication failed' }, + }); + assert.equal(bodyReaderCalls, 0); + assert.deepEqual(calls.map(({ name }) => name), ['authenticate']); +}); + +test('OpenAI route executes only the immutable route and returns a compact receipt header projection', async () => { + const receipt = signedReceipt(); + const upstreamBody = Buffer.from(canonicalJson({ + choices: [{ message: { role: 'assistant', content: 'hello' } }], + id: 'completion-1', + object: 'chat.completion', + })); + const { app, calls } = create({ + kernel: { + async execute(input) { + calls.push({ name: 'execute', input }); + return Object.freeze({ + requestId: 'request-1', + status: 'completed', + reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, + body: upstreamBody, + receipt, + }); + }, + statusByRequestId(input) { + calls.push({ name: 'statusByRequestId', input }); + return Object.freeze({ + requestId: 'request-1', + sellerOrigin: 'https://seller.example', + purposeLabel: 'model.infer', + intentState: 'terminal', + approval: null, + outcome: Object.freeze({ + status: 'completed', reasonCode: 'PAYMENT_SETTLED', revision: 1, + }), + receipt, + remainingSessionAtomic: '1900000', + }); + }, + }, + }); + const requestBody = canonicalJson({ messages: [{ role: 'user', content: 'hello' }] }); + const response = await app.request(`${ORIGIN}/agent/v1/openai/example-model/chat/completions`, { + method: 'POST', + headers: agentHeaders({ + cookie: 'must-not-reach-seller', + connection: 'keep-alive', + host: 'attacker.example', + 'x-custom-secret': 'must-not-reach-seller', + }), + body: requestBody, + }); + + assert.equal(response.status, 200, await response.clone().text()); + assert.deepEqual(await json(response), JSON.parse(upstreamBody)); + assert.equal(response.headers.get('content-type'), 'application/json'); + assert.equal(response.headers.get('x-wallet-receipt-id'), 'receipt-1'); + assert.equal(response.headers.get('x-wallet-terminal-state'), 'completed'); + assert.equal(response.headers.get('x-wallet-charged-atomic'), '100000'); + assert.equal(response.headers.get('x-wallet-session-remaining-atomic'), '1900000'); + assert.equal(response.headers.get('x-wallet-transaction-prefix'), '0xabababab'); + assert.equal(response.headers.has('set-cookie'), false); + assert.equal(response.headers.has('location'), false); + + assert.deepEqual(calls.map(({ name }) => name), [ + 'authenticate', 'resolveBoundSession', 'execute', 'statusByRequestId', + ]); + const execution = calls.find(({ name }) => name === 'execute').input; + assert.equal(execution.sessionId, 'session-1'); + assert.equal(execution.routeId, 'example-model'); + assert.equal(execution.purposeLabel, 'model.infer'); + assert.equal(execution.correlationId, correlationForAgentCall(AGENT_CALL_ID)); + assert.deepEqual(execution.request, { + requestUrl: 'https://seller.example/paid/chat/completions', + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'user-agent': 'pi-agent/1', + }, + bodyBytes: Buffer.from(requestBody), + }); + assert.equal(JSON.stringify(execution).includes(TOKEN), false); + assert.equal(JSON.stringify(execution).includes('must-not-reach-seller'), false); + assert.deepEqual(calls.find(({ name }) => name === 'statusByRequestId').input, { + sessionId: 'session-1', requestId: 'request-1', + }); +}); + +test('Pi streaming completions cross the agent boundary only as bounded OpenAI SSE', async () => { + const receipt = signedReceipt(); + const upstreamBody = Buffer.from([ + 'data: {"id":"completion-1","object":"chat.completion.chunk","choices":[]}', + '', + 'data: [DONE]', + '', + '', + ].join('\n')); + const { app } = create({ + kernel: { + async execute() { + return Object.freeze({ + requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, body: upstreamBody, receipt, + }); + }, + statusByRequestId() { return statusView(receipt); }, + }, + }); + + const response = await app.request( + `${ORIGIN}/agent/v1/openai/example-model/chat/completions`, + { method: 'POST', headers: agentHeaders(), body: '{"messages":[],"stream":true}' }, + ); + + assert.equal(response.status, 200, await response.clone().text()); + assert.equal(response.headers.get('content-type'), 'text/event-stream; charset=utf-8'); + assert.deepEqual(Buffer.from(await response.arrayBuffer()), upstreamBody); + assert.equal(response.headers.get('x-wallet-receipt-id'), 'receipt-1'); +}); + +test('streaming completion responses reject malformed SSE and format confusion', async (t) => { + const cases = [ + ['JSON for stream request', Buffer.from('{"choices":[]}'), true], + ['SSE for buffered request', Buffer.from('data: {"choices":[]}\n\ndata: [DONE]\n\n'), false], + ['missing done', Buffer.from('data: {"choices":[]}\n\n'), true], + ['array event', Buffer.from('data: []\n\ndata: [DONE]\n\n'), true], + ['invalid event JSON', Buffer.from('data: PROVIDER_EXCEPTION_SENTINEL\n\ndata: [DONE]\n\n'), true], + ['unsupported SSE field', Buffer.from('event: message\ndata: {"choices":[]}\n\ndata: [DONE]\n\n'), true], + ['bytes after done', Buffer.from('data: {"choices":[]}\n\ndata: [DONE]\n\ndata: {"private":true}\n\n'), true], + ['invalid UTF-8', Buffer.from([0xff]), true], + ]; + for (const [label, body, stream] of cases) { + await t.test(label, async () => { + const receipt = signedReceipt(); + const { app } = create({ + kernel: { + async execute() { + return Object.freeze({ + requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, body, receipt, + }); + }, + statusByRequestId() { return statusView(receipt); }, + }, + }); + const response = await app.request( + `${ORIGIN}/agent/v1/openai/example-model/chat/completions`, + { + method: 'POST', + headers: agentHeaders(), + body: JSON.stringify({ messages: [], stream }), + }, + ); + assert.equal(response.status, 502); + const serialized = JSON.stringify(await json(response)); + assert.equal(serialized.includes('PROVIDER_EXCEPTION_SENTINEL'), false); + assert.equal(serialized.includes(receipt.signature), false); + }); + } +}); + +test('tool completion returns the stable resource envelope without signed receipt bytes', async () => { + const receipt = signedReceipt({ + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + httpStatus: 201, + }); + const { app } = create({ + kernel: { + async execute() { + return Object.freeze({ + requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 201, body: Buffer.from('{"answer":42}'), receipt, + }); + }, + statusByRequestId() { return statusView(receipt); }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders(), body: '{"input":"hello"}', + }); + + assert.equal(response.status, 200); + const value = await json(response); + assert.deepEqual(value, { + status: 'completed', + requestId: 'request-1', + resource: { httpStatus: 201, contentType: 'application/json', body: { answer: 42 } }, + receipt: { + id: 'receipt-1', + hash: RECEIPT_HASH, + sellerOrigin: 'https://seller.example', + chargedAtomic: '100000', + remainingSessionAtomic: '1900000', + terminalState: 'completed', + transactionPrefix: '0xabababab', + }, + }); + const serialized = JSON.stringify(value); + assert.equal(serialized.includes(receipt.signature), false); + assert.equal(serialized.includes('internal-intent-1'), false); + assert.equal(serialized.includes('session-1'), false); +}); + +test('one Agent call ID replays the persisted terminal result without another payment', async () => { + const receipt = signedReceipt({ + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + }); + const executions = new Map(); + let paymentAttempts = 0; + const { app, calls } = create({ + kernel: { + async execute(input) { + calls.push({ name: 'execute', input }); + const prior = executions.get(input.correlationId); + if (prior !== undefined) { + return Object.freeze({ + requestId: prior.requestId, + status: prior.status, + reasonCode: prior.reasonCode, + receipt: prior.receipt, + }); + } + paymentAttempts += 1; + const result = Object.freeze({ + requestId: 'request-1', + status: 'completed', + reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, + body: Buffer.from('{"answer":42}'), + receipt, + }); + executions.set(input.correlationId, result); + return result; + }, + statusByRequestId(input) { + calls.push({ name: 'statusByRequestId', input }); + return statusView(receipt); + }, + }, + }); + const request = () => app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', + headers: agentHeaders(), + body: '{"input":"hello"}', + }); + + const first = await request(); + assert.equal(first.status, 200); + assert.equal((await json(first)).status, 'completed'); + + const replay = await request(); + assert.equal(replay.status, 409); + assert.deepEqual(await json(replay), { + status: 'completed_replay', + terminalStatus: 'completed', + requestId: 'request-1', + reasonCode: 'PAYMENT_SETTLED', + projections: { + request: '/agent/v1/intents/request-1', + receipt: '/agent/v1/receipts/receipt-1', + }, + receipt: { + id: 'receipt-1', + hash: RECEIPT_HASH, + sellerOrigin: 'https://seller.example', + chargedAtomic: '100000', + remainingSessionAtomic: '1900000', + terminalState: 'completed', + transactionPrefix: '0xabababab', + }, + }); + assert.equal(paymentAttempts, 1); + const correlations = calls + .filter(({ name }) => name === 'execute') + .map(({ input }) => input.correlationId); + assert.deepEqual(correlations, [ + correlationForAgentCall(AGENT_CALL_ID), + correlationForAgentCall(AGENT_CALL_ID), + ]); +}); + +test('streaming completion replay is explicit bounded JSON with a non-success status', async () => { + const receipt = signedReceipt(); + const completed = Object.freeze({ + requestId: 'request-1', + status: 'completed', + reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, + body: Buffer.from('data: {"choices":[]}\n\ndata: [DONE]\n\n'), + receipt, + }); + let executions = 0; + const { app } = create({ + kernel: { + async execute() { + executions += 1; + return executions === 1 + ? completed + : Object.freeze({ + requestId: completed.requestId, + status: completed.status, + reasonCode: completed.reasonCode, + receipt: completed.receipt, + }); + }, + statusByRequestId() { + return statusView(receipt, { purposeLabel: 'model.infer' }); + }, + }, + }); + const request = () => app.request( + `${ORIGIN}/agent/v1/openai/example-model/chat/completions`, + { + method: 'POST', + headers: agentHeaders(), + body: '{"messages":[],"stream":true}', + }, + ); + + const first = await request(); + assert.equal(first.status, 200); + assert.equal(first.headers.get('content-type'), 'text/event-stream; charset=utf-8'); + const replay = await request(); + assert.equal(replay.status, 409); + assert.match(replay.headers.get('content-type') ?? '', /^application\/json\b/u); + const value = await json(replay); + assert.equal(value.status, 'completed_replay'); + assert.equal(Object.hasOwn(value, 'resource'), false); + assert.equal(Object.hasOwn(value, 'body'), false); + assert.deepEqual(value.projections, { + request: '/agent/v1/intents/request-1', + receipt: '/agent/v1/receipts/receipt-1', + }); +}); + +test('one Agent call ID cannot be rebound to a different canonical request', async () => { + const receipt = signedReceipt({ + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + }); + const executions = new Map(); + let paymentAttempts = 0; + const { app, calls } = create({ + kernel: { + async execute(input) { + calls.push({ name: 'execute', input }); + const fingerprint = input.request.bodyBytes.toString('base64url'); + const prior = executions.get(input.correlationId); + if (prior !== undefined && prior.fingerprint !== fingerprint) { + throw new KernelError( + 'CORRELATION_CONFLICT', + 'private canonical request mismatch detail', + ); + } + if (prior !== undefined) return prior.result; + paymentAttempts += 1; + const result = Object.freeze({ + requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, body: Buffer.from('{"answer":42}'), receipt, + }); + executions.set(input.correlationId, { fingerprint, result }); + return result; + }, + statusByRequestId() { return statusView(receipt); }, + }, + }); + + const first = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders(), body: '{"input":"first"}', + }); + assert.equal(first.status, 200); + const mismatch = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders(), body: '{"input":"different"}', + }); + + assert.equal(mismatch.status, 409); + assert.deepEqual(await json(mismatch), { + error: { + code: 'CORRELATION_CONFLICT', + message: 'Agent call ID is already bound to a different request', + }, + }); + assert.equal(paymentAttempts, 1); +}); + +test('completed output requires one signed bounded upstream status', async (t) => { + for (const upstreamStatus of ['PROVIDER_EXCEPTION_SENTINEL', 202]) { + await t.test(String(upstreamStatus), async () => { + const receipt = signedReceipt({ + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + httpStatus: 201, + }); + const { app } = create({ + kernel: { + async execute() { + return Object.freeze({ + requestId: 'request-1', + status: 'completed', + reasonCode: 'PAYMENT_SETTLED', + upstreamStatus, + body: Buffer.from('{"answer":42}'), + receipt, + }); + }, + statusByRequestId() { return statusView(receipt); }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders(), body: '{"input":"hello"}', + }); + assert.equal(response.status, 502); + const serialized = JSON.stringify(await json(response)); + assert.equal(serialized.includes('PROVIDER_EXCEPTION_SENTINEL'), false); + assert.equal(serialized.includes('202'), false); + assert.equal(serialized.includes('201'), false); + }); + } +}); + +test('connected Pi model and Skill calls wait for approval and resume exact authority once', async (t) => { + for (const fixture of [ + { + label: 'model', + path: '/agent/v1/openai/example-model/chat/completions', + body: '{"messages":[{"role":"user","content":"hello"}]}', + resourcePath: '/paid/chat/completions', + purposeLabel: 'model.infer', + upstreamBody: Buffer.from('{"choices":[{"message":{"role":"assistant","content":"ok"}}]}'), + }, + { + label: 'Skill', + path: '/agent/v1/invoke/example-skill', + body: '{"input":"hello"}', + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + upstreamBody: Buffer.from('{"output":"ok"}'), + }, + ]) { + await t.test(fixture.label, async () => { + const approval = Object.freeze({ + state: 'pending', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + amountAtomic: '250000', + }); + const approved = Object.freeze({ ...approval, state: 'approved' }); + const receipt = signedReceipt({ + resourcePath: fixture.resourcePath, + purposeLabel: fixture.purposeLabel, + amountAtomic: approval.amountAtomic, + }); + const executions = []; + let statusReads = 0; + let signerCalls = 0; + const { app } = create({ + kernel: { + async execute(input) { + executions.push(input); + if (executions.length === 1) { + return Object.freeze({ + requestId: 'request-1', + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, + }); + } + signerCalls += 1; + return Object.freeze({ + requestId: 'request-1', + status: 'completed', + reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, + body: fixture.upstreamBody, + receipt, + }); + }, + statusByRequestId() { + statusReads += 1; + if (executions.length > 1) { + return statusView(receipt, { purposeLabel: fixture.purposeLabel }); + } + return statusView(null, { + approval: statusReads === 1 ? approval : approved, + purposeLabel: fixture.purposeLabel, + }); + }, + }, + }); + + const response = await app.request(`${ORIGIN}${fixture.path}`, { + method: 'POST', headers: agentHeaders({ Prefer: 'wait=1' }), body: fixture.body, + }); + + assert.equal(response.status, 200, await response.clone().text()); + assert.equal(executions.length, 2); + assert.equal(signerCalls, 1); + assert.ok(statusReads >= 3); + assert.deepEqual(executions[1], executions[0]); + assert.notEqual(executions[1], executions[0]); + assert.notEqual(executions[1].request.bodyBytes, executions[0].request.bodyBytes); + assert.equal(Object.isFrozen(executions[0]), true); + assert.equal(Object.isFrozen(executions[0].request), true); + assert.equal(executions[0].correlationId, correlationForAgentCall(AGENT_CALL_ID)); + assert.equal(Object.hasOwn(executions[0].request.headers, 'prefer'), false); + }); + } +}); + +test('ordinary raw callers receive bounded approval state unless they opt into waiting', async () => { + const approval = { + state: 'pending', + expiresAt: new Date(Date.now() + 20).toISOString(), + amountAtomic: '250000', + }; + let executeCalls = 0; + const { app } = create({ + kernel: { + async execute() { + executeCalls += 1; + return Object.freeze({ + requestId: 'request-1', + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, + }); + }, + statusByRequestId() { return statusView(null, { approval }); }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders(), body: '{"input":"hello"}', + }); + + assert.equal(response.status, 409); + assert.deepEqual(await json(response), { + status: 'payment_approval_required', + requestId: 'request-1', + approval: { + expiresAt: approval.expiresAt, + amountAtomic: '250000', + sellerOrigin: 'https://seller.example', + purposeLabel: 'skill.invoke', + }, + }); + assert.equal(executeCalls, 1); +}); + +test('approval wait preference uses one exact bounded grammar before Kernel execution', async (t) => { + for (const preference of [ + 'wait=0', + 'wait=01', + 'wait=301', + 'wait=1.5', + 'wait=1, respond-async', + 'respond-async', + ]) { + await t.test(preference, async () => { + const { app, calls } = create(); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', + headers: agentHeaders({ Prefer: preference }), + body: '{"input":"hello"}', + }); + assert.equal(response.status, 400); + assert.equal((await json(response)).error.code, 'AGENT_PREFER_INVALID'); + assert.equal(calls.some(({ name }) => name === 'execute'), false); + }); + } +}); + +test('an operator denial racing the first approval status read returns its terminal receipt', async () => { + const approval = Object.freeze({ + state: 'pending', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + amountAtomic: '250000', + }); + const receipt = signedReceipt({ + status: 'payment_denied', + reasonCode: 'OPERATOR_DENIED', + paymentState: 'none', + transactionId: null, + budgetDisposition: 'released', + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + executionState: 'none', + httpStatus: null, + }); + let executeCalls = 0; + let signerCalls = 0; + const { app } = create({ + kernel: { + async execute() { + executeCalls += 1; + if (executeCalls === 1) { + return Object.freeze({ + requestId: 'request-1', + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, + }); + } + return Object.freeze({ + requestId: 'request-1', + status: 'payment_denied', + reasonCode: 'OPERATOR_DENIED', + receipt, + }); + }, + statusByRequestId() { + return statusView(receipt, { + outcome: { status: 'payment_denied', reasonCode: 'OPERATOR_DENIED', revision: 1 }, + purposeLabel: 'skill.invoke', + }); + }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', + headers: agentHeaders({ Prefer: 'wait=1' }), + body: '{"input":"hello"}', + }); + + assert.equal(response.status, 403, await response.clone().text()); + assert.equal((await json(response)).reasonCode, 'OPERATOR_DENIED'); + assert.equal(executeCalls, 2); + assert.equal(signerCalls, 0); +}); + +test('approval expiry resumes the exact request only to terminalize without signing', async () => { + const approval = Object.freeze({ + state: 'pending', + expiresAt: new Date(Date.now() - 1).toISOString(), + amountAtomic: '250000', + }); + const receipt = signedReceipt({ + status: 'payment_denied', + reasonCode: 'APPROVAL_EXPIRED', + paymentState: 'none', + transactionId: null, + budgetDisposition: 'released', + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + executionState: 'none', + httpStatus: null, + }); + const executions = []; + let signerCalls = 0; + const { app } = create({ + kernel: { + async execute(input) { + executions.push(input); + if (executions.length === 1) { + return Object.freeze({ + requestId: 'request-1', + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, + }); + } + return Object.freeze({ + requestId: 'request-1', + status: 'payment_denied', + reasonCode: 'APPROVAL_EXPIRED', + receipt, + }); + }, + statusByRequestId() { + return executions.length === 1 + ? statusView(null, { approval, purposeLabel: 'skill.invoke' }) + : statusView(receipt, { + outcome: { status: 'payment_denied', reasonCode: 'APPROVAL_EXPIRED', revision: 1 }, + purposeLabel: 'skill.invoke', + }); + }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', + headers: agentHeaders({ Prefer: 'wait=1' }), + body: '{"input":"hello"}', + }); + + assert.equal(response.status, 403, await response.clone().text()); + assert.equal((await json(response)).reasonCode, 'APPROVAL_EXPIRED'); + assert.equal(executions.length, 2); + assert.deepEqual(executions[1], executions[0]); + assert.equal(signerCalls, 0); +}); + +test('disconnect aborts an opted-in approval wait before any resume or signer authority', async () => { + const approval = Object.freeze({ + state: 'pending', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + amountAtomic: '250000', + }); + let observedPending; + const pendingObserved = new Promise((resolve) => { observedPending = resolve; }); + let executeCalls = 0; + const { app } = create({ + kernel: { + async execute() { + executeCalls += 1; + return Object.freeze({ + requestId: 'request-1', + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, + }); + }, + statusByRequestId() { + observedPending(); + return statusView(null, { approval, purposeLabel: 'skill.invoke' }); + }, + }, + }); + const controller = new AbortController(); + const responsePromise = app.request(new Request( + `${ORIGIN}/agent/v1/invoke/example-skill`, + { + method: 'POST', + headers: agentHeaders({ Prefer: 'wait=1' }), + body: '{"input":"hello"}', + signal: controller.signal, + }, + )); + await pendingObserved; + controller.abort(); + const response = await responsePromise; + + assert.equal(response.status, 503); + assert.equal((await json(response)).error.code, 'AGENT_REQUEST_ABORTED'); + assert.equal(executeCalls, 1); +}); + +test('disconnect racing an approved status prevents authority consumption', async () => { + const approval = Object.freeze({ + state: 'approved', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + amountAtomic: '250000', + }); + const controller = new AbortController(); + let executeCalls = 0; + let signerCalls = 0; + const { app } = create({ + kernel: { + async execute() { + executeCalls += 1; + if (executeCalls > 1) signerCalls += 1; + return Object.freeze({ + requestId: 'request-1', + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, + }); + }, + statusByRequestId() { + controller.abort(); + return statusView(null, { approval, purposeLabel: 'skill.invoke' }); + }, + }, + }); + const response = await app.request(new Request( + `${ORIGIN}/agent/v1/invoke/example-skill`, + { + method: 'POST', + headers: agentHeaders({ Prefer: 'wait=1' }), + body: '{"input":"hello"}', + signal: controller.signal, + }, + )); + + assert.equal(response.status, 503); + assert.equal((await json(response)).error.code, 'AGENT_REQUEST_ABORTED'); + assert.equal(executeCalls, 1); + assert.equal(signerCalls, 0); +}); + +test('duplicate opted-in retries permit one signer and fail closed for followers', async () => { + const approval = Object.freeze({ + state: 'pending', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + amountAtomic: '250000', + }); + const approvedApproval = Object.freeze({ ...approval, state: 'approved' }); + const receipt = signedReceipt({ + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + amountAtomic: approval.amountAtomic, + }); + const executions = []; + let approved = false; + let leaderInFlight = false; + let completed = false; + let signerCalls = 0; + let pendingReads = 0; + let bothPending; + const bothPendingObserved = new Promise((resolve) => { bothPending = resolve; }); + const { app } = create({ + kernel: { + async execute(input) { + executions.push(input); + if (completed) { + return Object.freeze({ + requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', receipt, + }); + } + if (!approved) { + return Object.freeze({ + requestId: 'request-1', + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, + }); + } + if (leaderInFlight) { + return Object.freeze({ + requestId: 'request-1', + status: 'request_in_flight', + reasonCode: 'REQUEST_IN_FLIGHT', + receipt: null, + }); + } + leaderInFlight = true; + signerCalls += 1; + await new Promise((resolve) => setTimeout(resolve, 40)); + completed = true; + return Object.freeze({ + requestId: 'request-1', + status: 'completed', + reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, + body: Buffer.from('{"output":"ok"}'), + receipt, + }); + }, + statusByRequestId() { + if (completed) return statusView(receipt, { purposeLabel: 'skill.invoke' }); + if (!approved) { + pendingReads += 1; + if (pendingReads >= 2) bothPending(); + return statusView(null, { approval, purposeLabel: 'skill.invoke' }); + } + return statusView(null, { approval: approvedApproval, purposeLabel: 'skill.invoke' }); + }, + }, + }); + const request = () => app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', + headers: agentHeaders({ Prefer: 'wait=1' }), + body: '{"input":"hello"}', + }); + const responses = [request(), request()]; + await bothPendingObserved; + approved = true; + const settled = await Promise.all(responses); + + assert.deepEqual(settled.map(({ status }) => status).sort(), [200, 409]); + assert.equal(signerCalls, 1); + assert.equal(executions.every( + ({ correlationId }) => correlationId === correlationForAgentCall(AGENT_CALL_ID), + ), true); + const replay = await request(); + assert.equal(replay.status, 409); + assert.equal((await json(replay)).status, 'completed_replay'); + assert.equal(signerCalls, 1); +}); + +test('changed challenge re-approval stays inside one bounded exact-request wait', async () => { + const firstApproval = Object.freeze({ + state: 'pending', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + amountAtomic: '250000', + }); + const secondApproval = Object.freeze({ + state: 'pending', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + amountAtomic: '260000', + }); + const receipt = signedReceipt({ + requestId: 'request-2', + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + amountAtomic: secondApproval.amountAtomic, + }); + const executions = []; + const readsByRequest = new Map(); + let signerCalls = 0; + const { app } = create({ + kernel: { + async execute(input) { + executions.push(input); + if (executions.length <= 2) { + const current = executions.length === 1 ? firstApproval : secondApproval; + return Object.freeze({ + requestId: `request-${executions.length}`, + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: current.expiresAt, + receipt: null, + }); + } + signerCalls += 1; + return Object.freeze({ + requestId: 'request-2', + status: 'completed', + reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, + body: Buffer.from('{"output":"ok"}'), + receipt, + }); + }, + statusByRequestId({ requestId }) { + if (executions.length > 2) { + return statusView(receipt, { requestId, purposeLabel: 'skill.invoke' }); + } + const reads = (readsByRequest.get(requestId) ?? 0) + 1; + readsByRequest.set(requestId, reads); + const pending = requestId === 'request-1' ? firstApproval : secondApproval; + return statusView(null, { + requestId, + approval: reads === 1 ? pending : { ...pending, state: 'approved' }, + purposeLabel: 'skill.invoke', + }); + }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', + headers: agentHeaders({ Prefer: 'wait=1' }), + body: '{"input":"hello"}', + }); + + assert.equal(response.status, 200, await response.clone().text()); + assert.equal(executions.length, 3); + assert.equal(signerCalls, 1); + assert.equal(executions.every((input) => Object.isFrozen(input)), true); + assert.deepEqual(executions[1], executions[0]); + assert.deepEqual(executions[2], executions[0]); +}); + +test('terminal buyer outcomes use the fixed public HTTP mapping and never return seller bodies', async (t) => { + const cases = [ + ['payment_denied', 'PER_REQUEST_LIMIT', 403, 'none', null, 'released', null], + ['payment_failed', 'WALLET_PRE_SIGN_REJECTED', 502, 'not_signed', null, 'released', null], + ['payment_unresolved', 'PAID_RESPONSE_AMBIGUOUS', 503, 'unresolved', null, 'unresolved', null], + ['payment_rejected', 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', 402, 'rejected', null, 'released', null], + ['upstream_failed', 'UPSTREAM_TRANSPORT_FAILURE', 502, 'none', null, 'released', null], + ['execution_failed', 'UPSTREAM_HTTP_FAILURE', 429, 'settled', TRANSACTION, 'committed', 429], + ['execution_failed', 'UPSTREAM_HTTP_FAILURE', 502, 'settled', TRANSACTION, 'committed', 302], + ['execution_unknown', 'PAID_RESPONSE_AMBIGUOUS', 502, 'settled', TRANSACTION, 'committed', null], + ['refunded', 'REFUND_CONFIRMED', 200, 'settled', TRANSACTION, 'released', 500], + ]; + for (const [status, reasonCode, expectedHttp, paymentState, transactionId, + budgetDisposition, upstreamStatus] of cases) { + await t.test(`${status} maps to ${expectedHttp}`, async () => { + const receipt = signedReceipt({ + status, + reasonCode, + paymentState, + transactionId, + budgetDisposition, + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + executionState: status === 'execution_failed' || status === 'refunded' + ? 'failed' + : (status === 'execution_unknown' ? 'unknown' : 'none'), + httpStatus: upstreamStatus, + }); + const { app } = create({ + kernel: { + async execute() { + return Object.freeze({ + requestId: 'request-1', status, reasonCode, receipt, + ...(upstreamStatus === undefined ? {} : { upstreamStatus }), + body: Buffer.from('PROVIDER_EXCEPTION_SENTINEL'), + }); + }, + statusByRequestId() { return statusView(receipt); }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders(), body: '{"input":"hello"}', + }); + const value = await json(response); + + assert.equal(response.status, expectedHttp); + assert.equal(value.status, status); + assert.equal(value.requestId, 'request-1'); + assert.equal(value.reasonCode, reasonCode); + assert.equal(value.receipt.terminalState, status); + assert.equal(Object.hasOwn(value, 'resource'), false); + assert.equal(JSON.stringify(value).includes('PROVIDER_EXCEPTION_SENTINEL'), false); + assert.equal(JSON.stringify(value).includes(receipt.signature), false); + }); + } +}); + +test('agent intent and receipt reads are session-scoped compact projections', async () => { + const receipt = signedReceipt({ + status: 'payment_rejected', + reasonCode: 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + paymentState: 'rejected', + transactionId: null, + budgetDisposition: 'released', + revision: 2, + receiptId: 'receipt-2', + executionState: 'none', + httpStatus: null, + }); + const view = statusView(receipt, { + outcome: { + status: 'payment_rejected', + reasonCode: 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + revision: 2, + }, + remainingSessionAtomic: '2000000', + }); + const { app, calls } = create({ + kernel: { + statusByRequestId(input) { + calls.push({ name: 'statusByRequestId', input }); + return input.requestId === 'request-1' ? view : null; + }, + receiptById(input) { + calls.push({ name: 'receiptById', input }); + return input.receiptId === 'receipt-2' ? view : null; + }, + }, + }); + + for (const path of [ + '/agent/v1/intents/request-1', + '/agent/v1/receipts/receipt-2', + ]) { + const response = await app.request(`${ORIGIN}${path}`, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + }); + assert.equal(response.status, 200); + assert.deepEqual(await json(response), { + status: 'payment_rejected', + requestId: 'request-1', + reasonCode: 'AUTHORIZATION_UNUSED_AFTER_EXPIRY', + receipt: { + id: 'receipt-2', + hash: RECEIPT_HASH, + sellerOrigin: 'https://seller.example', + chargedAtomic: '0', + remainingSessionAtomic: '2000000', + terminalState: 'payment_rejected', + transactionPrefix: null, + }, + }); + } + assert.deepEqual( + calls.filter(({ name }) => name === 'statusByRequestId').at(-1).input, + { sessionId: 'session-1', requestId: 'request-1' }, + ); + assert.deepEqual( + calls.find(({ name }) => name === 'receiptById').input, + { sessionId: 'session-1', receiptId: 'receipt-2' }, + ); +}); + +test('agent reads hide guessed identifiers from other sessions and reject noncanonical paths', async () => { + const { app, calls } = create({ + kernel: { + statusByRequestId(input) { + calls.push({ name: 'statusByRequestId', input }); + return null; + }, + receiptById(input) { + calls.push({ name: 'receiptById', input }); + throw new KernelError('RECEIPT_SESSION_MISMATCH', 'private cross-session detail'); + }, + }, + }); + for (const path of [ + '/agent/v1/intents/request-other', + '/agent/v1/receipts/receipt-other', + '/agent/v1/intents/request-1?sessionId=session-other', + '/agent/v1/receipts/https%3A%2F%2Fattacker.example', + ]) { + const response = await app.request(`${ORIGIN}${path}`, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + }); + assert.equal([400, 404].includes(response.status), true); + const serialized = JSON.stringify(await json(response)); + assert.equal(serialized.includes('session-other'), false); + assert.equal(serialized.includes('private cross-session detail'), false); + } + assert.equal(calls.some(({ name }) => name === 'execute'), false); +}); + +test('pending intent lookup returns the same bounded approval projection', async () => { + const approval = { + state: 'pending', + expiresAt: '2026-08-01T12:05:00.000Z', + amountAtomic: '250000', + }; + const { app } = create({ + kernel: { + statusByRequestId() { return statusView(null, { approval }); }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/intents/request-1`, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await json(response), { + status: 'payment_approval_required', + requestId: 'request-1', + approval: { + expiresAt: approval.expiresAt, + amountAtomic: approval.amountAtomic, + sellerOrigin: 'https://seller.example', + purposeLabel: 'skill.invoke', + }, + }); +}); + +test('approved intent lookup tells the agent to retry without exposing approval authority', async () => { + const approval = { + state: 'approved', + expiresAt: '2026-08-01T12:05:00.000Z', + amountAtomic: '250000', + }; + const { app } = create({ + kernel: { + statusByRequestId() { return statusView(null, { approval }); }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/intents/request-1`, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await json(response), { + status: 'request_in_flight', + requestId: 'request-1', + reasonCode: 'APPROVAL_GRANTED_RETRY_REQUIRED', + }); +}); + +test('fixed route file is accepted only through the shared validator in both modes', () => { + const cdp = validateRouteMap({ document: ROUTE_DOCUMENT, mode: 'cdp-testnet' }); + const deterministic = validateRouteMap({ document: ROUTE_DOCUMENT, mode: 'deterministic' }); + assert.deepEqual(cdp.routes, deterministic.routes); + assert.deepEqual(cdp.routes.map(({ id, kind, method, upstreamUrl }) => ({ + id, kind, method, upstreamUrl, + })), [ + { + id: 'example-model', + kind: 'openai-chat', + method: 'POST', + upstreamUrl: 'https://seller.example/paid/chat/completions', + }, + { + id: 'example-skill', + kind: 'tool', + method: 'POST', + upstreamUrl: 'https://seller.example/paid/skill', + }, + ]); +}); + +test('unknown, cross-kind, URL-like, extended, queried, wrong-method, and operator paths never execute', async (t) => { + const cases = [ + ['POST', '/agent/v1/invoke/unknown', 404], + ['POST', '/agent/v1/invoke/example-model', 404], + ['POST', '/agent/v1/openai/example-skill/chat/completions', 404], + ['POST', '/agent/v1/invoke/https%3A%2F%2Fattacker.example', 400], + ['POST', '/agent/v1/invoke/example-skill/extra', 404], + ['POST', '/agent/v1/invoke/example-skill?target=https://attacker.example', 400], + ['GET', '/agent/v1/invoke/example-skill', 404], + ['HEAD', '/agent/v1/intents/request-1', 404], + ['POST', '/operator/v1/approvals/approval-1/approve', 404], + ]; + for (const [method, path, expectedStatus] of cases) { + await t.test(`${method} ${path}`, async () => { + const { app, calls } = create(); + const response = await app.request(`${ORIGIN}${path}`, { + method, + headers: method === 'POST' ? agentHeaders() : { + authorization: `WalletKernelAgent ${TOKEN}`, + }, + ...(method === 'POST' ? { body: '{"input":"hello"}' } : {}), + }); + assert.equal(response.status, expectedStatus); + assert.equal(calls.some(({ name }) => name === 'execute'), false); + assert.equal(calls.some(({ name }) => name === 'statusByRequestId'), false); + assert.equal(calls.some(({ name }) => name === 'receiptById'), false); + }); + } +}); + +test('payment, policy, session, target, and idempotency headers are rejected before Kernel execution', async (t) => { + const forbidden = [ + 'payment-required', 'payment-signature', 'payment-response', + 'x-payment', 'x-payment-required', 'x-payment-response', + 'idempotency-key', 'x-idempotency-key', 'x-approval-id', 'x-spend-session', + 'x-session-id', 'x-wallet-address', 'x-wallet-policy', 'x-wallet-payee', + 'x-wallet-amount', 'x-target-url', 'x-http-method', 'x-correlation-id', 'x-request-id', + ]; + assert.equal(forbidden.includes('x-agent-call-id'), false); + for (const name of forbidden) { + await t.test(name, async () => { + const { app, calls } = create(); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders({ [name]: 'attacker-choice' }), + body: '{"input":"hello"}', + }); + assert.equal(response.status, 400); + assert.equal((await json(response)).error.code, 'AGENT_FORBIDDEN_HEADER'); + assert.equal(calls.some(({ name: callName }) => callName === 'execute'), false); + }); + } +}); + +test('missing, malformed, and duplicate-ish Agent call IDs fail before body read or execution', async (t) => { + const validHeaders = agentHeaders(); + const missingHeaders = { ...validHeaders }; + delete missingHeaders['x-agent-call-id']; + const duplicateHeaders = new Headers(validHeaders); + duplicateHeaders.append('x-agent-call-id', Buffer.alloc(32, 0x43).toString('base64url')); + const cases = [ + ['missing', missingHeaders], + ['empty', agentHeaders({ 'x-agent-call-id': '' })], + ['noncanonical', agentHeaders({ 'x-agent-call-id': `${AGENT_CALL_ID}=` })], + ['wrong length', agentHeaders({ 'x-agent-call-id': AGENT_CALL_ID.slice(1) })], + ['duplicate-ish', duplicateHeaders], + ]; + for (const [label, headers] of cases) { + await t.test(label, async () => { + let bodyReaderCalls = 0; + const { app, calls } = create(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"input":"hello"}')); + controller.close(); + }, + }); + const getReader = body.getReader.bind(body); + Object.defineProperty(body, 'getReader', { + value(...args) { + bodyReaderCalls += 1; + return getReader(...args); + }, + }); + const response = await app.request(new Request( + `${ORIGIN}/agent/v1/invoke/example-skill`, + { method: 'POST', headers, body, duplex: 'half' }, + )); + + assert.equal(response.status, 400); + assert.deepEqual(await json(response), { + error: { + code: 'AGENT_CALL_ID_INVALID', + message: 'Agent call ID must be one canonical 32-byte token', + }, + }); + assert.equal(bodyReaderCalls, 0); + assert.equal(calls.some(({ name }) => name === 'execute'), false); + }); + } +}); + +test('only normalized accept, content-type, and user-agent reach the Kernel ordinary request', async () => { + const receipt = signedReceipt({ resourcePath: '/paid/skill', purposeLabel: 'skill.invoke' }); + const { app, calls } = create({ + kernel: { + async execute(input) { + calls.push({ name: 'execute', input }); + return Object.freeze({ + requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, body: Buffer.from('{"ok":true}'), receipt, + }); + }, + statusByRequestId() { return statusView(receipt); }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', + headers: agentHeaders({ + host: 'attacker.example', + connection: 'keep-alive', + cookie: 'secret-cookie', + forwarded: 'for=attacker', + 'x-forwarded-for': '203.0.113.8', + 'proxy-authorization': 'Basic secret', + 'x-api-key': 'provider-secret', + }), + body: '{"input":"hello"}', + }); + assert.equal(response.status, 200); + const headers = calls.find(({ name }) => name === 'execute').input.request.headers; + assert.deepEqual(headers, { + accept: 'application/json', + 'content-type': 'application/json', + 'user-agent': 'pi-agent/1', + }); + assert.equal(JSON.stringify(headers).includes('secret'), false); + assert.equal(JSON.stringify(headers).includes('attacker'), false); +}); + +test('request bodies are bounded duplicate-free JSON objects and validation occurs before execution', async (t) => { + const cases = [ + ['wrong content type', { headers: agentHeaders({ 'content-type': 'application/json; charset=utf-8' }), body: '{"a":1}' }, 415, 'AGENT_CONTENT_TYPE'], + ['missing body', { headers: agentHeaders() }, 400, 'AGENT_BODY_REQUIRED'], + ['duplicate top-level key', { headers: agentHeaders(), body: '{"a":1,"a":2}' }, 400, 'AGENT_BODY_SCHEMA'], + ['duplicate decoded nested key', { headers: agentHeaders(), body: '{"nested":{"a":1,"\\u0061":2}}' }, 400, 'AGENT_BODY_SCHEMA'], + ['array body', { headers: agentHeaders(), body: '[1,2,3]' }, 400, 'AGENT_BODY_SCHEMA'], + ['oversized declared body', { headers: agentHeaders({ 'content-length': '100' }), body: '{"a":1}' }, 413, 'AGENT_BODY_TOO_LARGE'], + ]; + for (const [label, init, expectedStatus, expectedCode] of cases) { + await t.test(label, async () => { + const { app, calls } = create({ maximumRequestBytes: 32 }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', ...init, + }); + assert.equal(response.status, expectedStatus); + assert.equal((await json(response)).error.code, expectedCode); + assert.equal(calls.some(({ name }) => name === 'execute'), false); + }); + } + + await t.test('streamed overflow', async () => { + const { app, calls } = create({ maximumRequestBytes: 16 }); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(10).fill(0x20)); + controller.enqueue(new Uint8Array(10).fill(0x20)); + controller.close(); + }, + }); + const response = await app.request(new Request( + `${ORIGIN}/agent/v1/invoke/example-skill`, + { method: 'POST', headers: agentHeaders(), body, duplex: 'half' }, + )); + assert.equal(response.status, 413); + assert.equal((await json(response)).error.code, 'AGENT_BODY_TOO_LARGE'); + assert.equal(calls.some(({ name }) => name === 'execute'), false); + }); +}); + +test('ordinary Pi JSON is canonicalized once before intent hashing and upstream forwarding', async (t) => { + for (const body of [ + '{"stream":true,"model":"scripted-local","messages":[{"role":"user","content":"hello"}]}', + ' { "stream": true, "model": "scripted-local", "messages": [ { "role": "user", "content": "hello" } ] }\n', + ]) { + await t.test(body.startsWith(' ') ? 'whitespace' : 'Pi key order', async () => { + const receipt = signedReceipt(); + let execution; + const { app } = create({ + kernel: { + async execute(input) { + execution = input; + return Object.freeze({ + requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, + body: Buffer.from([ + 'data: {"choices":[],"id":"completion-1","object":"chat.completion.chunk"}', + '', + 'data: [DONE]', + '', + '', + ].join('\n')), + receipt, + }); + }, + statusByRequestId() { + return statusView(receipt, { purposeLabel: 'model.infer' }); + }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/openai/example-model/chat/completions`, { + method: 'POST', headers: agentHeaders(), body, + }); + assert.equal(response.status, 200, await response.clone().text()); + assert.deepEqual( + execution.request.bodyBytes, + Buffer.from(canonicalJson(JSON.parse(body))), + ); + }); + } +}); + +test('closed or policy-blocked session fails before route and body parsing', async () => { + let bodyReaderCalls = 0; + const { app, calls } = create({ + auth: { + resolveBoundSession(principal) { + calls.push({ name: 'resolveBoundSession', principal }); + throw new KernelError('POLICY_TRANSITION_REQUIRED', 'private policy detail'); + }, + }, + }); + const body = new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array([0x7b])); controller.close(); }, + }); + const getReader = body.getReader.bind(body); + Object.defineProperty(body, 'getReader', { + value(...args) { bodyReaderCalls += 1; return getReader(...args); }, + }); + const response = await app.request(new Request( + `${ORIGIN}/agent/v1/invoke/unknown?target=https://attacker.example`, + { method: 'POST', headers: agentHeaders(), body, duplex: 'half' }, + )); + assert.equal(response.status, 409); + assert.equal((await json(response)).error.code, 'POLICY_TRANSITION_REQUIRED'); + assert.equal(bodyReaderCalls, 0); + assert.deepEqual(calls.map(({ name }) => name), ['authenticate', 'resolveBoundSession']); +}); + +test('request body authority-like names cannot alter the fixed Kernel invocation', async () => { + const receipt = signedReceipt({ resourcePath: '/paid/skill', purposeLabel: 'skill.invoke' }); + const { app, calls } = create({ + kernel: { + async execute(input) { + calls.push({ name: 'execute', input }); + return Object.freeze({ + requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, body: Buffer.from('{"ok":true}'), receipt, + }); + }, + statusByRequestId() { return statusView(receipt); }, + }, + }); + const body = canonicalJson({ + amount: '999999999', + approvalId: 'approval-attacker', + headers: { 'payment-signature': 'forged' }, + idempotencyKey: 'attacker-key', + method: 'DELETE', + payee: '0x9999999999999999999999999999999999999999', + policy: 'allow-all', + sessionId: 'session-attacker', + targetUrl: 'https://attacker.example/drain', + wallet: '0x9999999999999999999999999999999999999999', + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders(), body, + }); + assert.equal(response.status, 200); + const execution = calls.find(({ name }) => name === 'execute').input; + assert.deepEqual(Object.keys(execution).sort(), [ + 'correlationId', 'purposeLabel', 'request', 'routeId', 'sessionId', + ]); + assert.equal(execution.sessionId, 'session-1'); + assert.equal(execution.routeId, 'example-skill'); + assert.equal(execution.request.method, 'POST'); + assert.equal(execution.request.requestUrl, 'https://seller.example/paid/skill'); + assert.deepEqual(execution.request.headers, { + accept: 'application/json', + 'content-type': 'application/json', + 'user-agent': 'pi-agent/1', + }); + assert.deepEqual(execution.request.bodyBytes, Buffer.from(body)); +}); + +test('invalid or oversized upstream JSON never crosses the agent response boundary', async (t) => { + for (const [label, body] of [ + ['invalid JSON', Buffer.from('PROVIDER_EXCEPTION_SENTINEL')], + ['JSON array', Buffer.from('["private"]')], + ['oversized JSON', Buffer.from(`{"value":"${'x'.repeat(1_048_577)}"}`)], + ]) { + await t.test(label, async () => { + const receipt = signedReceipt(); + const { app } = create({ + kernel: { + async execute() { + return Object.freeze({ + requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', + upstreamStatus: 200, body, receipt, + }); + }, + statusByRequestId() { return statusView(receipt); }, + }, + }); + const response = await app.request( + `${ORIGIN}/agent/v1/openai/example-model/chat/completions`, + { method: 'POST', headers: agentHeaders(), body: '{"messages":[]}' }, + ); + assert.equal(response.status, 502); + const serialized = JSON.stringify(await json(response)); + assert.equal(serialized.includes('PROVIDER_EXCEPTION_SENTINEL'), false); + assert.equal(serialized.includes(receipt.signature), false); + }); + } +}); + +test('arbitrary Kernel exceptions are redacted to one stable internal failure', async () => { + const { app } = create({ + kernel: { + async execute() { + const error = new KernelError('PROVIDER_PRIVATE_FAILURE', 'provider-secret-value'); + error.privateResponse = 'PROVIDER_EXCEPTION_SENTINEL'; + throw error; + }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders(), body: '{"input":"hello"}', + }); + assert.equal(response.status, 500); + const serialized = JSON.stringify(await json(response)); + assert.equal(serialized, JSON.stringify({ + error: { code: 'AGENT_INTERNAL', message: 'Agent request failed' }, + })); + assert.equal(serialized.includes('provider-secret-value'), false); + assert.equal(serialized.includes('PROVIDER_EXCEPTION_SENTINEL'), false); +}); + +test('receipt route rejects a signed projection that is not bound to the current session', async () => { + const receipt = signedReceipt(); + const forged = structuredClone(receipt); + forged.receipt.intent.sessionId = 'session-other'; + const { app } = create({ + kernel: { + receiptById() { return statusView(Object.freeze(forged)); }, + }, + }); + const response = await app.request(`${ORIGIN}/agent/v1/receipts/receipt-1`, { + headers: { authorization: `WalletKernelAgent ${TOKEN}` }, + }); + assert.equal(response.status, 502); + assert.equal((await json(response)).error.code, 'AGENT_RESPONSE_INVALID'); +}); diff --git a/spikes/pi-wielder/tests/systemd-units.test.mjs b/spikes/pi-wielder/tests/systemd-units.test.mjs new file mode 100644 index 0000000..e867c1f --- /dev/null +++ b/spikes/pi-wielder/tests/systemd-units.test.mjs @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { canonicalJson } from '../src/kernel/canonical.mjs'; +import { + inspectEffectiveSystemd, + parseSystemctlShow, + validateEffectiveProjection, +} from '../scripts/inspect-systemd-effective.mjs'; +import { LIVE_LAUNCH_GATE } from '../scripts/preflight-live-deployment.mjs'; +import { renderSystemdUnits } from '../scripts/render-systemd-units.mjs'; + +function installFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wallet-systemd-')); + const releaseRoot = path.join(root, 'release-aaaaaaaa'); + const dirs = {}; + for (const name of ['authority', 'evidence', 'runtime', 'outbox', 'inbox']) { + dirs[name] = path.join(root, name); + fs.mkdirSync(dirs[name], { mode: 0o700 }); + } + fs.mkdirSync(path.join(releaseRoot, 'scripts'), { recursive: true, mode: 0o755 }); + fs.mkdirSync(path.join(releaseRoot, 'src'), { mode: 0o755 }); + const nodePath = path.join(root, 'node-v24.18.1'); + const environmentPath = path.join(root, 'kernel.env'); + fs.writeFileSync(nodePath, 'node', { mode: 0o755 }); + fs.writeFileSync(environmentPath, 'WALLET_KERNEL_MODE=cdp-testnet\n', { mode: 0o600 }); + return { + root, releaseRoot, nodePath, environmentPath, + serviceOutputPath: path.join(root, 'wallet-kernel.service'), + socketOutputPath: path.join(root, 'wallet-kernel-console.socket'), + authorityRoot: dirs.authority, evidenceRoot: dirs.evidence, runtimeRoot: dirs.runtime, + agentRunOutboxPath: dirs.outbox, enrollmentInboxPath: dirs.inbox, + }; +} + +function render(f, overrides = {}) { + return renderSystemdUnits({ + schemaVersion: 1, + kernelUid: '501', kernelGid: '20', agentUid: '502', agentGid: '20', + releaseRoot: f.releaseRoot, nodePath: f.nodePath, + environmentPath: f.environmentPath, + authorityRoot: f.authorityRoot, evidenceRoot: f.evidenceRoot, + runtimeRoot: f.runtimeRoot, agentRunOutboxPath: f.agentRunOutboxPath, + enrollmentInboxPath: f.enrollmentInboxPath, + serviceOutputPath: f.serviceOutputPath, socketOutputPath: f.socketOutputPath, + ...overrides, + }); +} + +test('renderer emits the exact hardened numeric-identity service and retained socket contract', () => { + const f = installFixture(); + try { + const result = render(f); + const service = result.serviceBytes.toString('utf8'); + const socket = result.socketBytes.toString('utf8'); + for (const line of [ + 'Requires=wallet-kernel-console.socket', + 'After=network-online.target wallet-kernel-console.socket', + 'User=501', 'Group=20', 'SupplementaryGroups=', + `Environment=WALLET_KERNEL_ENV_FILE=${f.environmentPath}`, + `ExecStartPre=+${f.nodePath} ${f.releaseRoot}/scripts/preflight-live-deployment.mjs --release-manifest ${f.releaseRoot}/manifest.json --kernel-uid 501 --kernel-gid 20`, + `ExecStart=${f.nodePath} ${f.releaseRoot}/src/control-plane.mjs`, + 'Restart=no', + 'NoNewPrivileges=yes', 'CapabilityBoundingSet=', 'AmbientCapabilities=', + 'ProtectSystem=strict', 'ProtectHome=yes', 'PrivateTmp=yes', 'PrivateDevices=yes', + 'ProtectKernelTunables=yes', 'ProtectKernelModules=yes', 'ProtectControlGroups=yes', + 'LockPersonality=yes', 'RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6', + `ReadWritePaths=${f.authorityRoot} ${f.evidenceRoot} ${f.runtimeRoot} ${f.agentRunOutboxPath}`, + 'UnsetEnvironment=NODE_OPTIONS NODE_PATH LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT LD_DEBUG LD_PROFILE GLIBC_TUNABLES GCONV_PATH PRIVATE_KEY ANTHROPIC_API_KEY OPENAI_API_KEY CDP_API_KEY_ID CDP_API_KEY_SECRET CDP_WALLET_SECRET CDP_WALLET_NAME WALLET_KERNEL_BASE_SEPOLIA_RPC_URL', + ]) assert.equal(service.includes(`${line}\n`), true, line); + assert.equal(service.includes('EnvironmentFile='), false, + 'the root-prefixed preflight must never inherit the secret environment file'); + assert.equal(socket.includes('ListenStream=127.0.0.1:8405\n'), true); + assert.equal(socket.includes('Accept=no\n'), true); + assert.equal(socket.includes('FileDescriptorName=wallet-kernel-console\n'), true); + assert.equal(socket.includes('ReusePort=no\n'), true); + assert.equal(socket.includes('WantedBy=sockets.target\n'), true); + assert.equal(socket.includes('PartOf='), false); + assert.equal(result.service.sha256.startsWith('sha256:'), true); + assert.equal(result.socket.sha256.startsWith('sha256:'), true); + } finally { fs.rmSync(f.root, { recursive: true, force: true }); } +}); + +test('renderer rejects names, root/shared identities, whitespace, unknown fields, and overwrite', () => { + const f = installFixture(); + try { + for (const overrides of [ + { kernelUid: 'wallet-kernel' }, { kernelUid: '0' }, { agentUid: '501' }, + { nodePath: `${f.nodePath} bad` }, { releaseRoot: `${f.releaseRoot}\nBad=yes` }, + { surprise: true }, + ]) assert.throws(() => render(f, overrides)); + renderSystemdUnits({ + schemaVersion: 1, kernelUid: '501', kernelGid: '20', agentUid: '502', agentGid: '20', + releaseRoot: f.releaseRoot, nodePath: f.nodePath, environmentPath: f.environmentPath, + authorityRoot: f.authorityRoot, evidenceRoot: f.evidenceRoot, runtimeRoot: f.runtimeRoot, + agentRunOutboxPath: f.agentRunOutboxPath, enrollmentInboxPath: f.enrollmentInboxPath, + serviceOutputPath: f.serviceOutputPath, socketOutputPath: f.socketOutputPath, + install: true, expectedOwnerUid: process.getuid(), + }); + assert.throws(() => renderSystemdUnits({ + schemaVersion: 1, kernelUid: '501', kernelGid: '20', agentUid: '502', agentGid: '20', + releaseRoot: f.releaseRoot, nodePath: f.nodePath, environmentPath: f.environmentPath, + authorityRoot: f.authorityRoot, evidenceRoot: f.evidenceRoot, runtimeRoot: f.runtimeRoot, + agentRunOutboxPath: f.agentRunOutboxPath, enrollmentInboxPath: f.enrollmentInboxPath, + serviceOutputPath: f.serviceOutputPath, socketOutputPath: f.socketOutputPath, + install: true, expectedOwnerUid: process.getuid(), + }), /exist/); + } finally { fs.rmSync(f.root, { recursive: true, force: true }); } +}); + +test('systemctl show parser splits first equals and rejects duplicate, missing, and oversized data', () => { + assert.deepEqual(parseSystemctlShow('Id=a.service\nEnvironmentFiles=/a=x (ignore_errors=no)\n', + ['Id', 'EnvironmentFiles']), { + Id: 'a.service', EnvironmentFiles: '/a=x (ignore_errors=no)', + }); + assert.throws(() => parseSystemctlShow('Id=a\nId=b\n', ['Id']), /duplicate/); + assert.throws(() => parseSystemctlShow('Id=a\n', ['Id', 'LoadState']), /missing/); + assert.throws(() => parseSystemctlShow(`Id=${'x'.repeat(70_000)}\n`, ['Id']), /bounded/); +}); + +test('effective projection validates exact loaded static service and enabled socket', () => { + const f = installFixture(); + try { + const rendered = render(f); + const projection = validateEffectiveProjection({ + service: { + Id: 'wallet-kernel.service', LoadState: 'loaded', FragmentPath: f.serviceOutputPath, + DropInPaths: '', NeedDaemonReload: 'no', Transient: 'no', UnitFileState: 'static', + User: '501', Group: '20', SupplementaryGroups: '', + Environment: `WALLET_KERNEL_ENV_FILE=${f.environmentPath}`, + EnvironmentFiles: '', PassEnvironment: '', + ExecStartPreEx: `{ path=${f.nodePath} ; argv[]=${f.nodePath} ${f.releaseRoot}/scripts/preflight-live-deployment.mjs --release-manifest ${f.releaseRoot}/manifest.json --kernel-uid 501 --kernel-gid 20 ; flags=privileged ; }`, + ExecStartEx: `{ path=${f.nodePath} ; argv[]=${f.nodePath} ${f.releaseRoot}/src/control-plane.mjs ; flags= ; }`, + Restart: 'no', RestartUSec: '2s', RestartPreventExitStatus: '', + UMask: '0077', NoNewPrivileges: 'yes', + CapabilityBoundingSet: '', AmbientCapabilities: '', ProtectSystem: 'strict', + ProtectHome: 'yes', PrivateTmp: 'yes', PrivateDevices: 'yes', + ProtectKernelTunables: 'yes', ProtectKernelModules: 'yes', + ProtectControlGroups: 'yes', LockPersonality: 'yes', + RestrictAddressFamilies: 'AF_UNIX AF_INET AF_INET6', + ReadWritePaths: `${f.authorityRoot} ${f.evidenceRoot} ${f.runtimeRoot} ${f.agentRunOutboxPath}`, + UnsetEnvironment: 'NODE_OPTIONS NODE_PATH LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT LD_DEBUG LD_PROFILE GLIBC_TUNABLES GCONV_PATH PRIVATE_KEY ANTHROPIC_API_KEY OPENAI_API_KEY CDP_API_KEY_ID CDP_API_KEY_SECRET CDP_WALLET_SECRET CDP_WALLET_NAME WALLET_KERNEL_BASE_SEPOLIA_RPC_URL', + Requires: 'wallet-kernel-console.socket', + After: 'network-online.target wallet-kernel-console.socket', + }, + socket: { + Id: 'wallet-kernel-console.socket', LoadState: 'loaded', FragmentPath: f.socketOutputPath, + DropInPaths: '', NeedDaemonReload: 'no', Transient: 'no', UnitFileState: 'enabled', + Listen: '127.0.0.1:8405 (Stream)', Accept: 'no', Service: 'wallet-kernel.service', + FileDescriptorName: 'wallet-kernel-console', ReusePort: 'no', + }, + expected: rendered.expectedEffectiveConfig, + }); + assert.equal(projection.effectiveConfigHash.startsWith('sha256:'), true); + for (const mutate of [ + (p) => { p.service.DropInPaths = '/etc/systemd/system/x.conf'; }, + (p) => { p.service.NeedDaemonReload = 'yes'; }, + (p) => { p.service.User = 'wallet-kernel'; }, + (p) => { p.service.CapabilityBoundingSet = 'CAP_NET_ADMIN'; }, + (p) => { p.service.Restart = 'on-failure'; }, + (p) => { p.service.RestartPreventExitStatus = '78'; }, + (p) => { p.service.EnvironmentFiles = f.environmentPath; }, + (p) => { p.service.Environment = `${p.service.Environment} CDP_API_KEY_SECRET=sentinel`; }, + (p) => { p.service.PassEnvironment = 'CDP_API_KEY_SECRET'; }, + (p) => { p.socket.Listen = '0.0.0.0:8405 (Stream)'; }, + (p) => { p.socket.ReusePort = 'yes'; }, + ]) { + const candidate = structuredClone({ service: projection.service, socket: projection.socket }); + mutate(candidate); + assert.throws(() => validateEffectiveProjection({ ...candidate, expected: rendered.expectedEffectiveConfig })); + } + } finally { fs.rmSync(f.root, { recursive: true, force: true }); } +}); + +test('installed service remains an explicit blocked gate until all live compositions are present', () => { + assert.deepEqual(LIVE_LAUNCH_GATE, { + schemaVersion: 1, + status: 'blocked', + code: 'LIVE_LAUNCH_NOT_READY', + exitStatus: 78, + blockers: [ + 'LIVE_PREFLIGHT_COMPOSITION_REQUIRED', + 'CONTROL_PLANE_COMPOSITION_REQUIRED', + 'LIVE_SECRET_DELIVERY_COMPOSITION_REQUIRED', + 'LIVE_LISTENER_RESPONSE_COMPATIBILITY_REQUIRED', + 'LIVE_SYSTEMD_LIFECYCLE_EVIDENCE_REQUIRED', + ], + }); + + const preflightPath = fileURLToPath(new URL('../scripts/preflight-live-deployment.mjs', import.meta.url)); + const preflight = spawnSync(process.execPath, [ + preflightPath, + '--release-manifest', '/opt/wallet/releases/blocked/manifest.json', + '--kernel-uid', '501', '--kernel-gid', '20', + ], { + encoding: 'utf8', + env: { + PATH: '/usr/bin:/bin', + WALLET_KERNEL_ENV_FILE: '/etc/wallet-kernel/kernel.env', + }, + }); + assert.equal(preflight.status, LIVE_LAUNCH_GATE.exitStatus); + assert.equal(preflight.stdout, ''); + assert.equal(preflight.stderr, `${canonicalJson(LIVE_LAUNCH_GATE)}\n`); + + const controlPlanePath = fileURLToPath(new URL('../src/control-plane.mjs', import.meta.url)); + const controlPlane = spawnSync(process.execPath, [controlPlanePath], { + encoding: 'utf8', + env: { PATH: '/usr/bin:/bin' }, + }); + assert.equal(controlPlane.status, 1); + assert.equal(controlPlane.stdout, ''); + assert.equal(controlPlane.stderr, 'CONTROL_PLANE_COMPOSITION_REQUIRED\n'); +}); + +test('live systemd static verification is explicit and never green on non-Linux', async (t) => { + if (process.platform !== 'linux' || process.getuid?.() !== 0 || process.env.WALLET_KERNEL_SYSTEMD_INTEGRATION !== '1') { + t.skip('requires Linux root and WALLET_KERNEL_SYSTEMD_INTEGRATION=1'); + return; + } + const f = installFixture(); + try { + renderSystemdUnits({ + schemaVersion: 1, + kernelUid: process.env.WALLET_KERNEL_TEST_KERNEL_UID, + kernelGid: process.env.WALLET_KERNEL_TEST_KERNEL_GID, + agentUid: process.env.WALLET_KERNEL_TEST_AGENT_UID, + agentGid: process.env.WALLET_KERNEL_TEST_AGENT_GID, + releaseRoot: f.releaseRoot, nodePath: f.nodePath, + environmentPath: f.environmentPath, + authorityRoot: f.authorityRoot, evidenceRoot: f.evidenceRoot, + runtimeRoot: f.runtimeRoot, agentRunOutboxPath: f.agentRunOutboxPath, + enrollmentInboxPath: f.enrollmentInboxPath, + serviceOutputPath: f.serviceOutputPath, socketOutputPath: f.socketOutputPath, + install: true, expectedOwnerUid: 0, + }); + const verification = spawnSync('/usr/bin/systemd-analyze', [ + 'verify', f.serviceOutputPath, f.socketOutputPath, + ], { encoding: 'utf8', env: { PATH: '/usr/bin:/bin' } }); + assert.equal(verification.status, 0, `${verification.stdout}\n${verification.stderr}`); + } finally { fs.rmSync(f.root, { recursive: true, force: true }); } +}); diff --git a/spikes/pi-wielder/tests/testnet-agent-runner.test.mjs b/spikes/pi-wielder/tests/testnet-agent-runner.test.mjs new file mode 100644 index 0000000..18eee35 --- /dev/null +++ b/spikes/pi-wielder/tests/testnet-agent-runner.test.mjs @@ -0,0 +1,383 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { + readTestnetAgentCredential, + readTestnetRunIntent, + requestTestnetAgentRoute, + runTestnetAgent, + testnetAgentCallId, + testnetRunIntentDigest, + validateTestnetRunIntent, +} from '../scripts/run-testnet-agent.mjs'; + +const NOW = '2026-08-01T12:00:00.000Z'; +const AGENT_CALL_ID = Buffer.alloc(32, 0x63).toString('base64url'); +const INTENT = Object.freeze({ + schemaVersion: 1, + domain: 'wallet-kernel.testnet-agent-run.v1', + runId: 'acceptance-20260801-a', + createdAt: NOW, + expiresAt: '2026-08-01T12:10:00.000Z', + network: 'eip155:84532', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + gitCommit: 'a'.repeat(40), + deployment: Object.freeze({ + releaseManifestDigest: `sha256:${'1'.repeat(64)}`, + releaseTreeHash: `sha256:${'2'.repeat(64)}`, + serviceArtifactsHash: `sha256:${'3'.repeat(64)}`, + systemdEffectiveConfigHash: `sha256:${'4'.repeat(64)}`, + }), + walletAddress: '0x1000000000000000000000000000000000000000', + policyHash: `sha256:${'5'.repeat(64)}`, + routeMapHash: `sha256:${'6'.repeat(64)}`, + maximumTotalAtomic: '100000', + kernelOrigin: 'http://127.0.0.1:8402', + kernelIdentity: Object.freeze({ uid: '991', gid: '991' }), + agentIdentity: Object.freeze({ uid: '992', gid: '992' }), + credentialDigest: `sha256:${'7'.repeat(64)}`, + sellerRoutes: Object.freeze([ + Object.freeze({ + routeId: 'example-model', + kind: 'openai-chat', + sellerOrigin: 'https://seller.example', + resourcePath: '/paid/chat/completions', + model: 'scripted-testnet', + }), + Object.freeze({ + routeId: 'example-skill', + kind: 'tool', + sellerOrigin: 'https://seller.example', + resourcePath: '/paid/skill', + model: null, + }), + ]), +}); + +function temporaryDirectory(t) { + const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'testnet-agent-runner-'))); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function writeIntent(t) { + const root = temporaryDirectory(t); + const outbox = path.join(root, 'agent-run-outbox'); + fs.mkdirSync(outbox, { mode: 0o755 }); + fs.chmodSync(outbox, 0o755); + const intent = { + ...INTENT, + kernelIdentity: { uid: String(process.getuid()), gid: String(process.getgid()) }, + agentIdentity: { uid: String(process.getuid() + 1), gid: String(process.getgid() + 1) }, + }; + const filePath = path.join(outbox, 'acceptance-20260801-a.json'); + fs.writeFileSync(filePath, `${canonicalJson(intent)}\n`, { flag: 'wx', mode: 0o644 }); + fs.chmodSync(filePath, 0o644); + return { root, outbox, filePath, intent }; +} + +function writeCredential(t) { + const root = temporaryDirectory(t); + const privateDirectory = path.join(root, 'agent-private'); + fs.mkdirSync(privateDirectory, { mode: 0o700 }); + fs.chmodSync(privateDirectory, 0o700); + const tokenBytes = Buffer.alloc(32, 0x42); + const credential = { + agentInstanceId: Buffer.alloc(16, 0x24).toString('base64url'), + schemaVersion: 1, + token: tokenBytes.toString('base64url'), + }; + const credentialDigest = sha256(tokenBytes); + tokenBytes.fill(0); + const filePath = path.join(privateDirectory, 'agent-credential.json'); + fs.writeFileSync(filePath, `${canonicalJson(credential)}\n`, { flag: 'wx', mode: 0o600 }); + fs.chmodSync(filePath, 0o600); + return { privateDirectory, filePath, credential, credentialDigest }; +} + +test('testnet run intent is closed, Base Sepolia-only, and bound to canonical confirmation', () => { + const validated = validateTestnetRunIntent(INTENT, { now: NOW }); + assert.deepEqual(validated, INTENT); + assert.equal(testnetRunIntentDigest(validated), sha256(canonicalJson(INTENT))); + + assert.throws( + () => validateTestnetRunIntent({ ...INTENT, network: 'eip155:8453' }, { now: NOW }), + { code: 'TESTNET_RUN_INTENT_NETWORK' }, + ); + assert.throws( + () => validateTestnetRunIntent({ ...INTENT, unexpected: true }, { now: NOW }), + { code: 'TESTNET_RUN_INTENT_SCHEMA' }, + ); +}); + +test('testnet route call IDs are stable per confirmed run and distinct per route', () => { + const digest = testnetRunIntentDigest(INTENT); + const model = testnetAgentCallId(digest, 'example-model'); + assert.match(model, /^[A-Za-z0-9_-]{43}$/); + assert.equal(testnetAgentCallId(digest, 'example-model'), model); + assert.notEqual(testnetAgentCallId(digest, 'example-skill'), model); + assert.throws(() => testnetAgentCallId(digest, '../escape'), { + code: 'TESTNET_AGENT_REQUEST', + }); +}); + +test('Agent reads one canonical Kernel-owned run intent from the exact public outbox', (t) => { + const fixture = writeIntent(t); + const result = readTestnetRunIntent({ + filePath: fixture.filePath, + outboxPath: fixture.outbox, + expectedDigest: testnetRunIntentDigest(fixture.intent), + now: NOW, + }); + assert.deepEqual(result, { + intent: fixture.intent, + intentDigest: testnetRunIntentDigest(fixture.intent), + }); +}); + +test('Agent rejects a stale human confirmation before using a run intent', (t) => { + const fixture = writeIntent(t); + assert.throws(() => readTestnetRunIntent({ + filePath: fixture.filePath, + outboxPath: fixture.outbox, + expectedDigest: `sha256:${'0'.repeat(64)}`, + now: NOW, + }), { code: 'TESTNET_RUN_INTENT_CONFIRMATION' }); +}); + +test('external confirmation authenticates the declared Kernel owner before it is trusted', (t) => { + const fixture = writeIntent(t); + const substituted = { + ...fixture.intent, + kernelIdentity: { + uid: String(process.getuid() + 100), + gid: String(process.getgid() + 100), + }, + }; + fs.writeFileSync(fixture.filePath, `${canonicalJson(substituted)}\n`, { mode: 0o644 }); + + assert.throws(() => readTestnetRunIntent({ + filePath: fixture.filePath, + outboxPath: fixture.outbox, + expectedDigest: testnetRunIntentDigest(fixture.intent), + now: NOW, + }), { code: 'TESTNET_RUN_INTENT_CONFIRMATION' }); + assert.throws(() => readTestnetRunIntent({ + filePath: fixture.filePath, + outboxPath: fixture.outbox, + expectedDigest: testnetRunIntentDigest(substituted), + now: NOW, + }), { code: 'TESTNET_RUN_INTENT_AUTHORITY' }); +}); + +test('Agent rejects a writable run-intent outbox', (t) => { + const fixture = writeIntent(t); + fs.chmodSync(fixture.outbox, 0o775); + assert.throws(() => readTestnetRunIntent({ + filePath: fixture.filePath, + outboxPath: fixture.outbox, + expectedDigest: testnetRunIntentDigest(fixture.intent), + now: NOW, + }), { code: 'TESTNET_RUN_INTENT_AUTHORITY' }); +}); + +test('Agent rejects a run-intent file with private or mutable publication mode', (t) => { + const fixture = writeIntent(t); + fs.chmodSync(fixture.filePath, 0o600); + assert.throws(() => readTestnetRunIntent({ + filePath: fixture.filePath, + outboxPath: fixture.outbox, + expectedDigest: testnetRunIntentDigest(fixture.intent), + now: NOW, + }), { code: 'TESTNET_RUN_INTENT_AUTHORITY' }); +}); + +test('Agent rejects a symlink in place of the Kernel run-intent file', (t) => { + const fixture = writeIntent(t); + const backing = path.join(fixture.root, 'backing-intent.json'); + fs.renameSync(fixture.filePath, backing); + fs.symlinkSync(backing, fixture.filePath); + assert.throws(() => readTestnetRunIntent({ + filePath: fixture.filePath, + outboxPath: fixture.outbox, + expectedDigest: testnetRunIntentDigest(fixture.intent), + now: NOW, + }), { code: 'TESTNET_RUN_INTENT_FILE' }); +}); + +test('Agent reads only its own bound credential from a separate private parent', (t) => { + const fixture = writeCredential(t); + const result = readTestnetAgentCredential({ + filePath: fixture.filePath, + expectedDigest: fixture.credentialDigest, + expectedAgentUid: process.getuid(), + expectedAgentGid: process.getgid(), + }); + assert.deepEqual(result, fixture.credential); +}); + +test('Agent rejects a persisted credential instance ID beginning with punctuation', (t) => { + const fixture = writeCredential(t); + const credential = { + ...fixture.credential, + agentInstanceId: `_${fixture.credential.agentInstanceId.slice(1)}`, + }; + fs.writeFileSync(fixture.filePath, `${canonicalJson(credential)}\n`, { mode: 0o600 }); + assert.throws(() => readTestnetAgentCredential({ + filePath: fixture.filePath, + expectedDigest: fixture.credentialDigest, + expectedAgentUid: process.getuid(), + expectedAgentGid: process.getgid(), + }), { code: 'TESTNET_AGENT_CREDENTIAL' }); +}); + +test('enrolled Agent drives only confirmed ordinary routes and returns no credential', async () => { + const credential = { + agentInstanceId: Buffer.alloc(16, 0x24).toString('base64url'), + schemaVersion: 1, + token: Buffer.alloc(32, 0x42).toString('base64url'), + }; + const observed = []; + const result = await runTestnetAgent({ + argv: ['--run-intent', '/kernel-outbox/run.json', '--confirm-sha256', + testnetRunIntentDigest(INTENT)], + environment: { + WALLET_KERNEL_AGENT_RUN_OUTBOX: '/kernel-outbox', + WALLET_KERNEL_AGENT_CREDENTIAL_FILE: '/agent-private/credential.json', + }, + now: () => NOW, + platform: 'linux', + getuid: () => 992, + getgid: () => 992, + getgroups: () => [992], + readRunIntent: () => ({ intent: INTENT, intentDigest: testnetRunIntentDigest(INTENT) }), + readCredential: () => credential, + requestRoute: async (request) => { + observed.push(request); + return { httpStatus: 200, outcome: 'completed' }; + }, + }); + + assert.deepEqual(result, { + status: 'completed', + mode: 'base-sepolia-testnet', + runId: INTENT.runId, + intentDigest: testnetRunIntentDigest(INTENT), + routeCount: 2, + routes: [ + { routeId: 'example-model', kind: 'openai-chat', httpStatus: 200, outcome: 'completed' }, + { routeId: 'example-skill', kind: 'tool', httpStatus: 200, outcome: 'completed' }, + ], + }); + assert.deepEqual(observed.map(({ origin, route, token }) => ({ + origin, + routeId: route.routeId, + tokenMatches: token === credential.token, + })), [ + { origin: INTENT.kernelOrigin, routeId: 'example-model', tokenMatches: true }, + { origin: INTENT.kernelOrigin, routeId: 'example-skill', tokenMatches: true }, + ]); + assert.deepEqual(observed.map(({ route, agentCallId }) => ({ + routeId: route.routeId, + agentCallId, + })), INTENT.sellerRoutes.map((route) => ({ + routeId: route.routeId, + agentCallId: testnetAgentCallId(testnetRunIntentDigest(INTENT), route.routeId), + }))); + assert.equal(JSON.stringify(result).includes(credential.token), false); +}); + +test('Agent identity mismatch fails before credential access or route execution', async () => { + let credentialReads = 0; + let routeRequests = 0; + await assert.rejects(runTestnetAgent({ + argv: ['--run-intent', '/kernel-outbox/run.json', '--confirm-sha256', + testnetRunIntentDigest(INTENT)], + environment: { + WALLET_KERNEL_AGENT_RUN_OUTBOX: '/kernel-outbox', + WALLET_KERNEL_AGENT_CREDENTIAL_FILE: '/agent-private/credential.json', + }, + now: () => NOW, + platform: 'linux', + getuid: () => 993, + getgid: () => 992, + getgroups: () => [992], + readRunIntent: () => ({ intent: INTENT, intentDigest: testnetRunIntentDigest(INTENT) }), + readCredential: () => { credentialReads += 1; }, + requestRoute: async () => { routeRequests += 1; }, + }), { code: 'TESTNET_AGENT_IDENTITY' }); + assert.equal(credentialReads, 0); + assert.equal(routeRequests, 0); +}); + +test('Agent refuses wallet authority material in its environment before reading files', async () => { + let runIntentReads = 0; + await assert.rejects(runTestnetAgent({ + argv: ['--run-intent', '/kernel-outbox/run.json', '--confirm-sha256', + testnetRunIntentDigest(INTENT)], + environment: { + CDP_API_KEY_SECRET: 'must-not-cross-the-Agent-boundary', + WALLET_KERNEL_AGENT_RUN_OUTBOX: '/kernel-outbox', + WALLET_KERNEL_AGENT_CREDENTIAL_FILE: '/agent-private/credential.json', + }, + readRunIntent: () => { runIntentReads += 1; }, + }), { code: 'TESTNET_AGENT_ENVIRONMENT' }); + assert.equal(runIntentReads, 0); +}); + +test('ordinary Agent route calls only the loopback Kernel once with a bounded fixed request', async () => { + const requests = []; + const token = Buffer.alloc(32, 0x42).toString('base64url'); + const outcome = await requestTestnetAgentRoute({ + origin: INTENT.kernelOrigin, + token, + route: INTENT.sellerRoutes[0], + agentCallId: AGENT_CALL_ID, + fetchFn: async (url, options) => { + requests.push({ url, options }); + return new Response(canonicalJson({ status: 'completed' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + assert.deepEqual(outcome, { httpStatus: 200, outcome: 'completed' }); + assert.equal(requests.length, 1); + assert.equal( + requests[0].url, + 'http://127.0.0.1:8402/agent/v1/openai/example-model/chat/completions', + ); + assert.equal(requests[0].url.includes(INTENT.sellerRoutes[0].sellerOrigin), false); + assert.equal(requests[0].options.redirect, 'manual'); + assert.equal(requests[0].options.credentials, 'omit'); + assert.deepEqual(JSON.parse(requests[0].options.body), { + messages: [{ content: 'Reply exactly WALLET_KERNEL_TESTNET_OK.', role: 'user' }], + model: 'scripted-testnet', + stream: false, + }); + assert.equal(requests[0].options.headers.authorization, `WalletKernelAgent ${token}`); + assert.equal(requests[0].options.headers['x-agent-call-id'], AGENT_CALL_ID); +}); + +test('Agent route failure is returned after one attempt and never retried', async () => { + let requests = 0; + await assert.rejects(requestTestnetAgentRoute({ + origin: INTENT.kernelOrigin, + token: Buffer.alloc(32, 0x42).toString('base64url'), + route: INTENT.sellerRoutes[1], + agentCallId: AGENT_CALL_ID, + fetchFn: async () => { + requests += 1; + return new Response(canonicalJson({ status: 'payment_failed' }), { + status: 502, + headers: { 'content-type': 'application/json' }, + }); + }, + }), { code: 'TESTNET_AGENT_RESPONSE' }); + assert.equal(requests, 1); +}); diff --git a/spikes/pi-wielder/tests/wallet-adapter-cdp.test.mjs b/spikes/pi-wielder/tests/wallet-adapter-cdp.test.mjs new file mode 100644 index 0000000..af7bf51 --- /dev/null +++ b/spikes/pi-wielder/tests/wallet-adapter-cdp.test.mjs @@ -0,0 +1,333 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { keccak256, toBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { createCdpWalletAdapter } from '../src/adapters/cdp-wallet-adapter.mjs'; +import { WalletSigningError } from '../src/adapters/wallet-adapter-contract.mjs'; +import { createPermitAuthority } from '../src/kernel/authorized-permit.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { + createWalletContractPaymentRequired, + createWalletContractSigningBinding, + walletAdapterContract, +} from './wallet-adapter-contract.test.mjs'; + +const FIXED_NOW_MS = 1_785_502_800_000; +const NETWORK = 'eip155:84532'; +const WALLET_NAME = 'pilot-wallet'; +const SECP256K1_N = BigInt( + '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141', +); +const fixtureAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-cdp-adapter-test-only')), +); +const otherAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-cdp-adapter-other-test-only')), +); + +function highSSignature(signature) { + const s = BigInt(`0x${signature.slice(66, 130)}`); + const v = Number.parseInt(signature.slice(130, 132), 16); + return `0x${signature.slice(2, 66)}${(SECP256K1_N - s).toString(16).padStart(64, '0')}${ + (v === 27 ? 28 : 27).toString(16) + }`; +} + +function zeroOneVSignature(signature) { + const v = Number.parseInt(signature.slice(130, 132), 16); + return `${signature.slice(0, 130)}${(v - 27).toString(16).padStart(2, '0')}`; +} + +function compactSignature(signature) { + const r = signature.slice(2, 66); + const s = BigInt(`0x${signature.slice(66, 130)}`); + const v = Number.parseInt(signature.slice(130, 132), 16); + const yParityAndS = s | (BigInt(v - 27) << 255n); + return `0x${r}${yParityAndS.toString(16).padStart(64, '0')}`; +} + +function changedBinding(binding, field) { + const replacements = { + challengeHash: `sha256:${'22'.repeat(32)}`, + acceptedIndex: 1, + amountAtomic: '50001', + payTo: '0x3000000000000000000000000000000000000000', + network: 'eip155:1', + asset: '0x5000000000000000000000000000000000000000', + walletAddress: '0x4000000000000000000000000000000000000000', + validBefore: '1785502859', + nonce: `0x${'02'.repeat(32)}`, + }; + const next = { ...binding, [field]: replacements[field] }; + if (field === 'challengeHash' || field === 'acceptedIndex') { + next.quoteId = sha256(canonicalJson({ + challengeHash: next.challengeHash, + acceptedIndex: next.acceptedIndex, + })); + } + return next; +} + +function createContractFixture({ failureMode } = {}) { + const paymentRequired = createWalletContractPaymentRequired(); + const binding = createWalletContractSigningBinding({ + walletAddress: fixtureAccount.address.toLowerCase(), + }, paymentRequired); + const permitAuthority = createPermitAuthority(); + const permit = permitAuthority.issue(binding); + let accountCalls = 0; + let signCalls = 0; + + const account = Object.freeze({ + address: fixtureAccount.address.toLowerCase(), + signTypedData(typedData) { + signCalls += 1; + if (failureMode === 'untyped-error') { + throw Object.freeze({ secret: 'provider-value' }); + } + if (failureMode === 'sync-throw') throw new Error('provider sync failure'); + if (failureMode === 'async-reject') { + return Promise.reject(new Error('provider rejection')); + } + if (failureMode === 'never-settle') return new Promise(() => {}); + if (failureMode === 'malformed-signature') return 'not-a-signature'; + if (failureMode === 'high-s-signature') { + return fixtureAccount.signTypedData(typedData).then(highSSignature); + } + if (failureMode === 'zero-one-v-signature') { + return fixtureAccount.signTypedData(typedData).then(zeroOneVSignature); + } + if (failureMode === 'compact-signature') { + return fixtureAccount.signTypedData(typedData).then(compactSignature); + } + if (failureMode === 'assemble-failure') { + return otherAccount.signTypedData(typedData); + } + if (failureMode === 'post-sign-mismatch') { + return fixtureAccount.signTypedData({ + ...typedData, + message: { ...typedData.message, value: typedData.message.value + 1n }, + }); + } + return fixtureAccount.signTypedData(typedData); + }, + }); + const cdpClient = Object.freeze({ + evm: Object.freeze({ + async getAccount({ name }) { + accountCalls += 1; + assert.equal(name, WALLET_NAME); + if (failureMode === 'account-reject') { + throw new Error('provider response containing secret material'); + } + return account; + }, + }), + }); + const runWithDeadline = async ({ phase, operation }) => { + if (phase !== 'signer' || failureMode !== 'never-settle') return await operation(); + void operation(); + await Promise.resolve(); + throw new Error('CDP signer deadline'); + }; + const adapter = createCdpWalletAdapter({ + cdpClient, + walletName: WALLET_NAME, + verifyAndConsume(value) { + if (failureMode === 'pre-sign') throw new Error('fixture pre-sign rejection'); + return permitAuthority.verifyAndConsume(value); + }, + runWithDeadline, + nowMs: () => FIXED_NOW_MS, + }); + + return { + adapter, + provider: 'coinbase-cdp', + walletId: WALLET_NAME, + address: fixtureAccount.address, + paymentRequired, + permit, + account, + cdpClient, + accountCalls: () => accountCalls, + signCalls: () => signCalls, + signAuthorized: () => adapter.signX402Exact(permit, paymentRequired), + genuineMismatchedPermit(field) { + if (field === 'network' || field === 'asset') { + const mismatchedPayment = structuredClone(paymentRequired); + mismatchedPayment.accepts[0][field] = field === 'network' + ? 'eip155:1' + : '0x5000000000000000000000000000000000000000'; + return { + permit: permitAuthority.issue(binding), + paymentRequired: mismatchedPayment, + }; + } + return createPermitAuthority().issue(changedBinding(binding, field)); + }, + }; +} + +walletAdapterContract('cdp', createContractFixture); + +test('CDP adapter signs exactly the Kernel-issued EIP-3009 authorization', async () => { + const fixture = createContractFixture(); + const result = await fixture.signAuthorized(); + + assert.equal(fixture.accountCalls(), 1); + assert.equal(fixture.signCalls(), 1); + assert.deepEqual(result.paymentPayload.payload.authorization, { + from: fixtureAccount.address.toLowerCase(), + to: '0x2000000000000000000000000000000000000000', + value: '50000', + validAfter: '0', + validBefore: '1785502860', + nonce: `0x${'01'.repeat(32)}`, + }); +}); + +test('CDP account initialization is shared across concurrent callers', async () => { + const paymentRequired = createWalletContractPaymentRequired(); + const binding = createWalletContractSigningBinding({ + walletAddress: fixtureAccount.address.toLowerCase(), + }, paymentRequired); + const permits = createPermitAuthority(); + let releaseAccount; + let accountCalls = 0; + const pendingAccount = new Promise((resolve) => { releaseAccount = resolve; }); + const adapter = createCdpWalletAdapter({ + cdpClient: { + evm: { + getAccount({ name }) { + accountCalls += 1; + assert.equal(name, WALLET_NAME); + return pendingAccount; + }, + }, + }, + walletName: WALLET_NAME, + verifyAndConsume: permits.verifyAndConsume, + nowMs: () => FIXED_NOW_MS, + }); + + const requests = Array.from({ length: 8 }, () => adapter.walletIdentity()); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(accountCalls, 1); + releaseAccount({ + address: fixtureAccount.address, + signTypedData: (typedData) => fixtureAccount.signTypedData(typedData), + }); + const identities = await Promise.all(requests); + assert.equal(accountCalls, 1); + assert.ok(identities.every((identity) => identity.walletId === WALLET_NAME)); + + const result = await adapter.signX402Exact(permits.issue(binding), paymentRequired); + assert.equal(result.paymentPayload.payload.authorization.value, '50000'); + assert.equal(accountCalls, 1); +}); + +test('CDP adapter exposes neither provider handles nor account provisioning methods', async () => { + const fixture = createContractFixture(); + const identity = await fixture.adapter.walletIdentity(); + const result = await fixture.signAuthorized(); + + assert.deepEqual(Object.keys(fixture.cdpClient.evm), ['getAccount']); + assert.equal('createAccount' in fixture.cdpClient.evm, false); + assert.equal('getOrCreateAccount' in fixture.cdpClient.evm, false); + assert.notEqual(identity, fixture.account); + assert.notEqual(result, fixture.account); + assert.notEqual(result, fixture.cdpClient); + assert.doesNotMatch(JSON.stringify({ identity, result }), /provider response|secret material/i); +}); + +test('CDP account lookup rejection is definitely pre-sign and redacted', async () => { + const fixture = createContractFixture({ failureMode: 'account-reject' }); + await assert.rejects( + () => fixture.signAuthorized(), + (error) => { + assert.ok(error instanceof WalletSigningError); + assert.equal(error.code, 'WALLET_PRE_SIGN_REJECTED'); + assert.equal(error.signatureMayExist, false); + assert.equal(error.cause, undefined); + assert.doesNotMatch(JSON.stringify(error), /provider response|secret material/i); + return true; + }, + ); + assert.equal(fixture.accountCalls(), 1); + assert.equal(fixture.signCalls(), 0); +}); + +test('CDP adapter rejects a live account address mismatch before signing', async () => { + const paymentRequired = createWalletContractPaymentRequired(); + const binding = createWalletContractSigningBinding({ + walletAddress: fixtureAccount.address.toLowerCase(), + }, paymentRequired); + const permits = createPermitAuthority(); + let signCalls = 0; + const adapter = createCdpWalletAdapter({ + cdpClient: { + evm: { + async getAccount() { + return { + address: otherAccount.address, + signTypedData() { + signCalls += 1; + throw new Error('must not be reached'); + }, + }; + }, + }, + }, + walletName: WALLET_NAME, + verifyAndConsume: permits.verifyAndConsume, + nowMs: () => FIXED_NOW_MS, + }); + + await assert.rejects( + () => adapter.signX402Exact(permits.issue(binding), paymentRequired), + (error) => error.code === 'WALLET_PRE_SIGN_REJECTED' + && error.signatureMayExist === false, + ); + assert.equal(signCalls, 0); +}); + +test('CDP identity and signing operations use their injected bounded phases', async () => { + const paymentRequired = createWalletContractPaymentRequired(); + const binding = createWalletContractSigningBinding({ + walletAddress: fixtureAccount.address.toLowerCase(), + }, paymentRequired); + const permits = createPermitAuthority(); + const calls = []; + const adapter = createCdpWalletAdapter({ + cdpClient: { + evm: { + async getAccount() { + return { + address: fixtureAccount.address, + signTypedData: (typedData) => fixtureAccount.signTypedData(typedData), + }; + }, + }, + }, + walletName: WALLET_NAME, + verifyAndConsume: permits.verifyAndConsume, + async runWithDeadline({ phase, timeoutMs, operation }) { + calls.push([phase, timeoutMs]); + return await operation(); + }, + preSignTimeoutMs: 111, + signerTimeoutMs: 222, + nowMs: () => FIXED_NOW_MS, + }); + + await adapter.walletIdentity(); + await adapter.signX402Exact(permits.issue(binding), paymentRequired); + assert.deepEqual(calls, [ + ['wallet_identity', 111], + ['pre-sign', 111], + ['signer', 222], + ]); +}); diff --git a/spikes/pi-wielder/tests/wallet-adapter-contract.test.mjs b/spikes/pi-wielder/tests/wallet-adapter-contract.test.mjs new file mode 100644 index 0000000..98f9cf9 --- /dev/null +++ b/spikes/pi-wielder/tests/wallet-adapter-contract.test.mjs @@ -0,0 +1,1157 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { authorizationTypes } from '@x402/evm'; +import { getAddress, keccak256, toBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { projectPaymentRequired } from '../src/kernel/policy-engine.mjs'; +import { + assertPermitMatchesPayment, + WalletSigningError, + createDeadlineRunner, + executeAuthorizedSigning, + validatePaymentPayload, + validateWalletIdentity, +} from '../src/adapters/wallet-adapter-contract.mjs'; + +const WALLET_ADDRESS = '0x918B63a7fD486d8BD57FECF388CafbfB5dd8D84C'; +const PERSISTED_WALLET_ADDRESS = WALLET_ADDRESS.toLowerCase(); +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'); +const SECP256K1_HALF_N = BigInt( + '0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0', +); + +function paymentRequired() { + return { + x402Version: 2, + resource: { + url: 'https://seller.example/paid/infer', + description: 'offline fixture', + mimeType: 'application/json', + }, + accepts: [{ + scheme: 'exact', + network: 'eip155:84532', + asset: ASSET, + amount: '50000', + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + }], + }; +} + +function signingBinding(overrides = {}, challenge = paymentRequired()) { + const challengeHash = sha256(canonicalJson(projectPaymentRequired(challenge))); + return { + intentId: 'intent-1', + intentHash: `sha256:${'11'.repeat(32)}`, + challengeHash, + quoteId: sha256(canonicalJson({ challengeHash, acceptedIndex: 0 })), + acceptedIndex: 0, + requestUrl: challenge.resource.url, + resourceDescription: challenge.resource.description, + resourceMimeType: challenge.resource.mimeType, + scheme: 'exact', + network: 'eip155:84532', + asset: ASSET, + walletAddress: PERSISTED_WALLET_ADDRESS, + payTo: PAY_TO, + amountAtomic: '50000', + validAfter: '0', + validBefore: '1785502860', + nonce: `0x${'01'.repeat(32)}`, + policyVersionId: 'policy-1', + ...overrides, + }; +} + +const fixtureAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-contract-test-only')), +); + +function typedDataFixture(binding) { + return { + domain: { + name: 'USDC', + version: '2', + chainId: 84532, + verifyingContract: getAddress(binding.asset), + }, + types: authorizationTypes, + primaryType: 'TransferWithAuthorization', + message: { + from: getAddress(binding.walletAddress), + to: getAddress(binding.payTo), + value: BigInt(binding.amountAtomic), + validAfter: BigInt(binding.validAfter), + validBefore: BigInt(binding.validBefore), + nonce: binding.nonce, + }, + }; +} + +function paymentPayloadFixture(signature, binding, challenge = paymentRequired()) { + return { + x402Version: 2, + resource: { + url: binding.requestUrl, + description: binding.resourceDescription, + mimeType: binding.resourceMimeType, + }, + accepted: structuredClone(challenge.accepts[binding.acceptedIndex]), + payload: { + signature, + authorization: { + from: binding.walletAddress, + to: binding.payTo, + value: binding.amountAtomic, + validAfter: binding.validAfter, + validBefore: binding.validBefore, + nonce: binding.nonce, + }, + }, + }; +} + +function signatureWith(signature, { r, s, v }) { + const nextR = (r ?? BigInt(`0x${signature.slice(2, 66)}`)).toString(16).padStart(64, '0'); + const nextS = (s ?? BigInt(`0x${signature.slice(66, 130)}`)).toString(16).padStart(64, '0'); + const nextV = (v ?? Number.parseInt(signature.slice(130, 132), 16)) + .toString(16) + .padStart(2, '0'); + return `0x${nextR}${nextS}${nextV}`; +} + +export { + paymentRequired as createWalletContractPaymentRequired, + paymentPayloadFixture as createWalletContractPaymentPayload, + signingBinding as createWalletContractSigningBinding, + typedDataFixture as createWalletContractTypedData, +}; + +/** + * Reusable contract for every Wallet Adapter implementation. + * + * `factory({ failureMode } = {})` returns the base fixture documented in Task 8: + * adapter/identity fields, paymentRequired, permit, signCalls(), signAuthorized(), + * and genuineMismatchedPermit(field). Failure modes let each implementation inject + * failures without exposing provider exceptions through the adapter boundary. + */ +export function walletAdapterContract(name, factory) { + test(`${name}: exposes identity and exact signing only`, async () => { + const fixture = factory(); + assert.deepEqual(Object.keys(fixture.adapter).sort(), ['signX402Exact', 'walletIdentity']); + assert.deepEqual(await fixture.adapter.walletIdentity(), { + provider: fixture.provider, + walletId: fixture.walletId, + address: fixture.address.toLowerCase(), + network: 'eip155:84532', + }); + }); + + test(`${name}: rejects forged, consumed, and mismatched permits before signing`, async () => { + const forgedFixture = factory(); + await assert.rejects( + () => forgedFixture.adapter.signX402Exact( + { kind: 'AuthorizedPermit', intentId: 'intent-1' }, + forgedFixture.paymentRequired, + ), + /forged|rejected|invalid/i, + ); + assert.equal(forgedFixture.signCalls(), 0); + + const consumedFixture = factory(); + await consumedFixture.signAuthorized(); + assert.equal(consumedFixture.signCalls(), 1); + await assert.rejects( + () => consumedFixture.adapter.signX402Exact( + consumedFixture.permit, + consumedFixture.paymentRequired, + ), + /consumed|rejected|invalid/i, + ); + assert.equal(consumedFixture.signCalls(), 1); + + for (const field of [ + 'challengeHash', + 'acceptedIndex', + 'amountAtomic', + 'payTo', + 'network', + 'asset', + 'walletAddress', + 'validBefore', + 'nonce', + ]) { + const fixture = factory(); + const mismatch = fixture.genuineMismatchedPermit(field); + const mismatchedPermit = Object.hasOwn(mismatch, 'permit') ? mismatch.permit : mismatch; + const mismatchedPayment = Object.hasOwn(mismatch, 'paymentRequired') + ? mismatch.paymentRequired + : fixture.paymentRequired; + await assert.rejects( + () => fixture.adapter.signX402Exact(mismatchedPermit, mismatchedPayment), + /forged|mismatch|rejected|invalid/i, + field, + ); + assert.equal(fixture.signCalls(), 0, field); + } + }); + + test(`${name}: never returns or serializes key material`, async () => { + const fixture = factory(); + const result = await fixture.signAuthorized(); + assert.deepEqual(Object.keys(result), ['paymentPayload']); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.paymentPayload)); + assert.doesNotMatch( + JSON.stringify({ identity: await fixture.adapter.walletIdentity(), result }), + /private|secret|seed|mnemonic|api.key/i, + ); + }); + + test(`${name}: classifies pre-sign rejection as definitely unsigned`, async () => { + const fixture = factory({ failureMode: 'pre-sign' }); + await assert.rejects( + () => fixture.adapter.signX402Exact(fixture.permit, fixture.paymentRequired), + (error) => { + assert.ok(error instanceof WalletSigningError); + assert.equal(error.code, 'WALLET_PRE_SIGN_REJECTED'); + assert.equal(error.signatureMayExist, false); + assert.equal(error.cause, undefined); + return true; + }, + ); + assert.equal(fixture.signCalls(), 0); + }); + + for (const failureMode of [ + 'untyped-error', + 'sync-throw', + 'async-reject', + 'never-settle', + 'malformed-signature', + 'high-s-signature', + 'zero-one-v-signature', + 'compact-signature', + 'assemble-failure', + 'post-sign-mismatch', + ]) { + test(`${name}: ${failureMode} after entering the signer is ambiguous`, async () => { + const fixture = factory({ failureMode }); + await assert.rejects( + () => fixture.adapter.signX402Exact(fixture.permit, fixture.paymentRequired), + (error) => { + assert.ok(error instanceof WalletSigningError); + assert.equal(error.code, 'WALLET_SIGNATURE_AMBIGUOUS'); + assert.equal(error.signatureMayExist, true); + assert.equal(error.cause, undefined); + assert.doesNotMatch(JSON.stringify(error), /private|secret|provider/i); + return true; + }, + ); + assert.equal(fixture.signCalls(), 1); + }); + } +} + +test('WalletSigningError exposes only the stable signing boundary classification', () => { + const error = new WalletSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'wallet rejected before signing', + { signatureMayExist: false }, + ); + + assert.equal(error.name, 'WalletSigningError'); + assert.equal(error.code, 'WALLET_PRE_SIGN_REJECTED'); + assert.equal(error.signatureMayExist, false); + assert.equal(error.cause, undefined); + assert.equal(WalletSigningError.isExact( + error, + 'WALLET_PRE_SIGN_REJECTED', + false, + ), true); + + const nativePrototypeForgery = Object.assign(new Error('forged native error'), { + code: 'WALLET_PRE_SIGN_REJECTED', + signatureMayExist: false, + }); + Object.setPrototypeOf(nativePrototypeForgery, WalletSigningError.prototype); + assert.equal(WalletSigningError.isExact( + nativePrototypeForgery, + 'WALLET_PRE_SIGN_REJECTED', + false, + ), false); + + class DerivedSigningError extends WalletSigningError {} + const derived = new DerivedSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'derived error', + { signatureMayExist: false }, + ); + assert.equal(WalletSigningError.isExact( + derived, + 'WALLET_PRE_SIGN_REJECTED', + false, + ), false); + Object.setPrototypeOf(derived, WalletSigningError.prototype); + assert.equal(WalletSigningError.isExact( + derived, + 'WALLET_PRE_SIGN_REJECTED', + false, + ), false); + + let proxyTraps = 0; + const proxy = new Proxy(error, { + getPrototypeOf() { + proxyTraps += 1; + throw new Error('must not inspect hostile proxy'); + }, + }); + assert.equal(WalletSigningError.isExact( + proxy, + 'WALLET_PRE_SIGN_REJECTED', + false, + ), false); + assert.equal(proxyTraps, 0); + assert.equal(Object.isFrozen(WalletSigningError), true); +}); + +test('createDeadlineRunner arms the deadline before invoking and clears it on settlement', async () => { + const events = []; + const timer = Object.freeze({ id: 1 }); + const runWithDeadline = createDeadlineRunner({ + setTimeoutImpl(callback, timeoutMs) { + events.push(['armed', timeoutMs, callback]); + return timer; + }, + clearTimeoutImpl(value) { + events.push(['cleared', value]); + }, + }); + + const result = await runWithDeadline({ + phase: 'pre-sign', + timeoutMs: 5_000, + operation() { + events.push(['operation']); + return 'prepared'; + }, + }); + + assert.equal(result, 'prepared'); + assert.deepEqual(events.slice(0, 2), [ + ['armed', 5_000, events[0][2]], + ['operation'], + ]); + assert.deepEqual(events.at(-1), ['cleared', timer]); +}); + +test('createDeadlineRunner never invokes an operation when the armed deadline already fired', async () => { + let operationCalls = 0; + const runWithDeadline = createDeadlineRunner({ + setTimeoutImpl(callback) { + callback(); + return Object.freeze({ id: 'expired' }); + }, + clearTimeoutImpl() {}, + }); + + await assert.rejects( + () => runWithDeadline({ + phase: 'signer', + timeoutMs: 15_000, + operation() { + operationCalls += 1; + }, + }), + (error) => error.code === 'WALLET_SIGNER_TIMEOUT', + ); + await Promise.resolve(); + assert.equal(operationCalls, 0); +}); + +test('createDeadlineRunner bounds a never-settling promise and clears its one-shot timer', async () => { + let fireDeadline; + const cleared = []; + const timer = Object.freeze({ id: 'signer-timer' }); + const runWithDeadline = createDeadlineRunner({ + setTimeoutImpl(callback, timeoutMs) { + assert.equal(timeoutMs, 15_000); + fireDeadline = callback; + return timer; + }, + clearTimeoutImpl(value) { + cleared.push(value); + }, + }); + + const pending = runWithDeadline({ + phase: 'signer', + timeoutMs: 15_000, + operation: () => new Promise(() => {}), + }); + await Promise.resolve(); + fireDeadline(); + + await assert.rejects(pending, (error) => { + assert.equal(error.code, 'WALLET_SIGNER_TIMEOUT'); + assert.doesNotMatch(error.message, /private|secret|provider/i); + return true; + }); + assert.deepEqual(cleared, [timer]); +}); + +test('createDeadlineRunner clears the timer when an operation rejects', async () => { + const timer = Object.freeze({ id: 'rejection-timer' }); + const cleared = []; + const providerFailure = Object.freeze({ secret: 'opaque-provider-value' }); + const runWithDeadline = createDeadlineRunner({ + setTimeoutImpl() { return timer; }, + clearTimeoutImpl(value) { cleared.push(value); }, + }); + + await assert.rejects( + () => runWithDeadline({ + phase: 'pre-sign', + timeoutMs: 5_000, + operation: async () => { throw providerFailure; }, + }), + (error) => { + assert.notEqual(error, providerFailure); + assert.equal(error.code, 'WALLET_PRE_SIGN_OPERATION_FAILED'); + assert.equal(error.cause, undefined); + assert.doesNotMatch(JSON.stringify(error), /opaque-provider-value|secret/); + return true; + }, + ); + assert.deepEqual(cleared, [timer]); +}); + +test('deadline configuration and requests reject proxies and accessors inertly', async () => { + let configGetterCalls = 0; + const config = { clearTimeoutImpl() {} }; + Object.defineProperty(config, 'setTimeoutImpl', { + enumerable: true, + get() { + configGetterCalls += 1; + return setTimeout; + }, + }); + assert.throws( + () => createDeadlineRunner(config), + (error) => error.code === 'WALLET_DEADLINE_CONFIG', + ); + assert.equal(configGetterCalls, 0); + + const runWithDeadline = createDeadlineRunner(); + let requestGetterCalls = 0; + const request = { phase: 'pre-sign', timeoutMs: 5_000 }; + Object.defineProperty(request, 'operation', { + enumerable: true, + get() { + requestGetterCalls += 1; + return () => 'unsafe'; + }, + }); + await assert.rejects( + () => runWithDeadline(request), + (error) => error.code === 'WALLET_DEADLINE_REQUEST', + ); + assert.equal(requestGetterCalls, 0); +}); + +test('deadline phases use an own-property allowlist rather than inherited object names', async () => { + const runWithDeadline = createDeadlineRunner(); + for (const phase of ['__proto__', 'constructor', 'toString']) { + let operationCalls = 0; + await assert.rejects( + () => runWithDeadline({ + phase, + timeoutMs: 5_000, + operation() { + operationCalls += 1; + return 'unsafe'; + }, + }), + (error) => error.code === 'WALLET_DEADLINE_PHASE', + phase, + ); + assert.equal(operationCalls, 0, phase); + } +}); + +test('deadline setup and cleanup failures cannot leak values or leave work unbounded', async () => { + let operationCalls = 0; + const setupFailure = createDeadlineRunner({ + setTimeoutImpl() { throw Object.freeze({ providerSecret: 'setup-leak' }); }, + clearTimeoutImpl() {}, + }); + await assert.rejects( + () => setupFailure({ + phase: 'pre-sign', + timeoutMs: 5_000, + operation() { + operationCalls += 1; + }, + }), + (error) => { + assert.equal(error.code, 'WALLET_DEADLINE_SETUP_FAILED'); + assert.equal(error.cause, undefined); + assert.doesNotMatch(JSON.stringify(error), /setup-leak|providerSecret/); + return true; + }, + ); + assert.equal(operationCalls, 0); + + const cleanupFailure = createDeadlineRunner({ + setTimeoutImpl() { return Object.freeze({ id: 'timer' }); }, + clearTimeoutImpl() { throw new Error('cleanup failure'); }, + }); + assert.equal(await cleanupFailure({ + phase: 'pre-sign', + timeoutMs: 5_000, + operation: async () => 'settled', + }), 'settled'); +}); + +test('createDeadlineRunner preserves the first timeout when an injected timer fires then throws', async () => { + let operationCalls = 0; + const runWithDeadline = createDeadlineRunner({ + setTimeoutImpl(callback) { + callback(); + throw new Error('late timer setup failure'); + }, + clearTimeoutImpl() {}, + }); + + await assert.rejects( + () => runWithDeadline({ + phase: 'signer', + timeoutMs: 15_000, + operation() { + operationCalls += 1; + }, + }), + (error) => error.code === 'WALLET_SIGNER_TIMEOUT', + ); + assert.equal(operationCalls, 0); +}); + +test('executeAuthorizedSigning maps prepare failures to definite pre-sign rejection', async () => { + const providerFailure = Object.freeze({ privateKey: 'must-not-leak' }); + + await assert.rejects( + () => executeAuthorizedSigning({ + prepare: async () => { throw providerFailure; }, + invokeSigner: async () => 'not-called', + finalize: async () => 'not-called', + runWithDeadline: createDeadlineRunner(), + preSignTimeoutMs: 5_000, + signerTimeoutMs: 15_000, + }), + (error) => { + assert.ok(error instanceof WalletSigningError); + assert.equal(error.code, 'WALLET_PRE_SIGN_REJECTED'); + assert.equal(error.signatureMayExist, false); + assert.equal(error.cause, undefined); + assert.doesNotMatch(JSON.stringify(error), /must-not-leak|privateKey/); + return true; + }, + ); +}); + +test('executeAuthorizedSigning supplies bounded defaults and completes both zones', async () => { + const events = []; + const result = await executeAuthorizedSigning({ + prepare: async () => { + events.push('prepared'); + return Object.freeze({ typedData: 'ready' }); + }, + invokeSigner: async (prepared) => { + events.push(`signed:${prepared.typedData}`); + return '0xsignature'; + }, + finalize: async (prepared, signature) => { + events.push(`finalized:${prepared.typedData}`); + return Object.freeze({ signature }); + }, + }); + + assert.deepEqual(result, { signature: '0xsignature' }); + assert.deepEqual(events, ['prepared', 'signed:ready', 'finalized:ready']); +}); + +test('executeAuthorizedSigning uses the stable 5s pre-sign and 15s may-exist deadlines', async () => { + const calls = []; + await executeAuthorizedSigning({ + prepare: async () => 'prepared', + invokeSigner: async () => 'signature', + finalize: async () => 'done', + async runWithDeadline(input) { + calls.push([input.phase, input.timeoutMs]); + return await input.operation(); + }, + }); + assert.deepEqual(calls, [['pre-sign', 5_000], ['signer', 15_000]]); +}); + +test('executeAuthorizedSigning treats every signer and post-sign failure as ambiguous', async () => { + const failureModes = [ + ['synchronous signer throw', () => { throw Object.freeze({ secret: 'sync-provider-value' }); }, + async () => 'unreachable'], + ['asynchronous signer rejection', async () => { + throw Object.freeze({ secret: 'async-provider-value' }); + }, async () => 'unreachable'], + ['malformed returned signature', async () => 'malformed', async () => { + throw new Error('malformed signature'); + }], + ['assemble failure', async () => '0xsignature', async () => { + throw new Error('assemble provider secret'); + }], + ['post-sign payload mismatch', async () => '0xsignature', async () => { + throw new Error('post-sign mismatch'); + }], + ]; + + for (const [label, invokeSigner, finalize] of failureModes) { + let signCalls = 0; + await assert.rejects( + () => executeAuthorizedSigning({ + prepare: async () => Object.freeze({ ready: true }), + invokeSigner: async (prepared) => { + assert.equal(prepared.ready, true); + signCalls += 1; + return await invokeSigner(); + }, + finalize, + }), + (error) => { + assert.ok(error instanceof WalletSigningError, label); + assert.equal(error.code, 'WALLET_SIGNATURE_AMBIGUOUS', label); + assert.equal(error.signatureMayExist, true, label); + assert.equal(error.cause, undefined, label); + assert.doesNotMatch(JSON.stringify(error), /provider|secret|malformed|mismatch/i, label); + return true; + }, + ); + assert.equal(signCalls, 1, label); + } +}); + +test('executeAuthorizedSigning makes a never-settling signer ambiguous after its deadline', async () => { + let fireSignerDeadline; + let signCalls = 0; + const runWithDeadline = createDeadlineRunner({ + setTimeoutImpl(callback, timeoutMs) { + if (timeoutMs === 15_000) fireSignerDeadline = callback; + return Object.freeze({ timeoutMs }); + }, + clearTimeoutImpl() {}, + }); + const pending = executeAuthorizedSigning({ + prepare: async () => 'prepared', + invokeSigner: () => { + signCalls += 1; + return new Promise(() => {}); + }, + finalize: async () => 'unreachable', + runWithDeadline, + }); + for (let turn = 0; turn < 10 + && (typeof fireSignerDeadline !== 'function' || signCalls !== 1); turn += 1) { + await Promise.resolve(); + } + assert.equal(typeof fireSignerDeadline, 'function'); + assert.equal(signCalls, 1); + fireSignerDeadline(); + + await assert.rejects(pending, (error) => { + assert.equal(error.code, 'WALLET_SIGNATURE_AMBIGUOUS'); + assert.equal(error.signatureMayExist, true); + return true; + }); + assert.equal(signCalls, 1); +}); + +test('executeAuthorizedSigning keeps finalization inside the bounded may-exist zone', async () => { + let fireSignerDeadline; + let finalizeCalls = 0; + const runWithDeadline = createDeadlineRunner({ + setTimeoutImpl(callback, timeoutMs) { + if (timeoutMs === 15_000) fireSignerDeadline = callback; + return Object.freeze({ timeoutMs }); + }, + clearTimeoutImpl() {}, + }); + const pending = executeAuthorizedSigning({ + prepare: async () => 'prepared', + invokeSigner: async () => 'signature', + finalize: () => { + finalizeCalls += 1; + return new Promise(() => {}); + }, + runWithDeadline, + }); + for (let turn = 0; turn < 10 + && (typeof fireSignerDeadline !== 'function' || finalizeCalls !== 1); turn += 1) { + await Promise.resolve(); + } + assert.equal(finalizeCalls, 1); + fireSignerDeadline(); + await assert.rejects(pending, (error) => error.code === 'WALLET_SIGNATURE_AMBIGUOUS' + && error.signatureMayExist === true); +}); + +test('executeAuthorizedSigning rejects hostile outer input before signer entry', async () => { + let prepareGetterCalls = 0; + let signCalls = 0; + const request = { + invokeSigner: async () => { + signCalls += 1; + return 'signature'; + }, + finalize: async () => 'done', + }; + Object.defineProperty(request, 'prepare', { + enumerable: true, + get() { + prepareGetterCalls += 1; + return async () => 'prepared'; + }, + }); + + await assert.rejects( + () => executeAuthorizedSigning(request), + (error) => error instanceof WalletSigningError + && error.code === 'WALLET_PRE_SIGN_REJECTED' + && error.signatureMayExist === false, + ); + assert.equal(prepareGetterCalls, 0); + assert.equal(signCalls, 0); +}); + +test('executeAuthorizedSigning never lets an injected deadline runner invoke the signer twice', async () => { + let signCalls = 0; + let secondInvocationError; + const result = await executeAuthorizedSigning({ + prepare: async () => 'prepared', + invokeSigner: async () => { + signCalls += 1; + return 'signature'; + }, + finalize: async (_prepared, signature) => ({ signature }), + async runWithDeadline({ phase, operation }) { + if (phase === 'pre-sign') return await operation(); + const first = await operation(); + try { + await operation(); + } catch (error) { + secondInvocationError = error; + } + return first; + }, + }); + + assert.deepEqual(result, { signature: 'signature' }); + assert.equal(signCalls, 1); + assert.equal(secondInvocationError?.code, 'WALLET_SIGNER_REENTRY'); +}); + +test('validateWalletIdentity enforces a closed Base Sepolia identity and returns an inert copy', () => { + const source = { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: WALLET_ADDRESS, + network: 'eip155:84532', + }; + const identity = validateWalletIdentity(source); + + assert.deepEqual(identity, { ...source, address: source.address.toLowerCase() }); + assert.notEqual(identity, source); + assert.ok(Object.isFrozen(identity)); + const persistedBinding = assertPermitMatchesPayment(signingBinding(), paymentRequired(), 0); + assert.equal(persistedBinding.walletAddress, WALLET_ADDRESS.toLowerCase()); + assert.equal(getAddress(identity.address), getAddress(persistedBinding.walletAddress)); + + for (const candidate of [ + { ...source, network: 'eip155:1' }, + { ...source, address: '0x0000000000000000000000000000000000000000' }, + { ...source, injected: true }, + { provider: source.provider, walletId: source.walletId, address: source.address }, + ]) { + assert.throws( + () => validateWalletIdentity(candidate), + (error) => error.code === 'WALLET_IDENTITY', + ); + } +}); + +test('validateWalletIdentity rejects proxies and accessors without invoking attacker code', () => { + let getterCalls = 0; + const accessor = { walletId: 'wallet-1', address: WALLET_ADDRESS, network: 'eip155:84532' }; + Object.defineProperty(accessor, 'provider', { + enumerable: true, + get() { + getterCalls += 1; + return 'deterministic-test'; + }, + }); + + assert.throws(() => validateWalletIdentity(accessor), (error) => error.code === 'WALLET_IDENTITY'); + assert.equal(getterCalls, 0); + assert.throws( + () => validateWalletIdentity(new Proxy({}, { get() { throw new Error('trap'); } })), + (error) => error.code === 'WALLET_IDENTITY', + ); +}); + +test('assertPermitMatchesPayment validates the full closed binding and canonical challenge', () => { + const challenge = paymentRequired(); + const binding = signingBinding({}, challenge); + const normalized = assertPermitMatchesPayment(binding, challenge, 0); + + assert.deepEqual(normalized, binding); + assert.notEqual(normalized, binding); + assert.ok(Object.isFrozen(normalized)); + + const mutations = [ + ['challengeHash', `sha256:${'22'.repeat(32)}`], + ['quoteId', `sha256:${'33'.repeat(32)}`], + ['acceptedIndex', 1], + ['requestUrl', 'https://seller.example/paid/other'], + ['resourceDescription', 'different'], + ['resourceMimeType', 'text/plain'], + ['scheme', 'upto'], + ['network', 'eip155:1'], + ['asset', '0x1000000000000000000000000000000000000000'], + ['payTo', '0x3000000000000000000000000000000000000000'], + ['amountAtomic', '50001'], + ['validAfter', '1'], + ['validBefore', '01785502860'], + ['nonce', `0x${'AA'.repeat(32)}`], + ]; + for (const [field, value] of mutations) { + assert.throws( + () => assertPermitMatchesPayment({ ...binding, [field]: value }, challenge, 0), + (error) => error.code === 'WALLET_BINDING', + field, + ); + } + + assert.throws( + () => assertPermitMatchesPayment({ ...binding, injected: true }, challenge, 0), + (error) => error.code === 'WALLET_BINDING', + ); +}); + +test('assertPermitMatchesPayment rejects hostile binding and challenge values inertly', () => { + let getterCalls = 0; + const binding = signingBinding(); + const accessor = { ...binding }; + Object.defineProperty(accessor, 'nonce', { + enumerable: true, + get() { + getterCalls += 1; + return binding.nonce; + }, + }); + + assert.throws( + () => assertPermitMatchesPayment(accessor, paymentRequired(), 0), + (error) => error.code === 'WALLET_BINDING', + ); + assert.equal(getterCalls, 0); + assert.throws( + () => assertPermitMatchesPayment(binding, new Proxy({}, { get() { throw new Error('trap'); } }), 0), + (error) => error.code === 'WALLET_BINDING', + ); +}); + +test('validatePaymentPayload enforces closed x402 v2 equality and recovers the bound signer', async () => { + const challenge = paymentRequired(); + const binding = signingBinding({ walletAddress: fixtureAccount.address.toLowerCase() }, challenge); + const typedData = typedDataFixture(binding); + const signature = await fixtureAccount.signTypedData(typedData); + const source = paymentPayloadFixture(signature, binding, challenge); + + const validated = await validatePaymentPayload({ + paymentPayload: source, + binding, + paymentRequired: challenge, + typedData, + }); + + assert.deepEqual(validated, source); + assert.notEqual(validated, source); + assert.ok(Object.isFrozen(validated)); + assert.ok(Object.isFrozen(validated.resource)); + assert.ok(Object.isFrozen(validated.accepted.extra)); + assert.ok(Object.isFrozen(validated.payload.authorization)); +}); + +test('validatePaymentPayload enforces Circle canonical EOA signature boundaries', async () => { + const challenge = paymentRequired(); + const fixtureForParity = async (targetV) => { + for (let marker = 1; marker <= 255; marker += 1) { + const binding = signingBinding({ + walletAddress: fixtureAccount.address.toLowerCase(), + nonce: `0x${marker.toString(16).padStart(2, '0').repeat(32)}`, + }, challenge); + const typedData = typedDataFixture(binding); + const signature = await fixtureAccount.signTypedData(typedData); + if (Number.parseInt(signature.slice(130, 132), 16) === targetV) { + return { binding, signature, typedData }; + } + } + throw new Error(`could not construct deterministic v=${targetV} fixture`); + }; + const validateSignature = async ({ binding, signature, typedData }) => await validatePaymentPayload({ + paymentPayload: paymentPayloadFixture(signature, binding, challenge), + binding, + paymentRequired: challenge, + typedData, + }); + + const v27 = await fixtureForParity(27); + const v28 = await fixtureForParity(28); + assert.equal((await validateSignature(v27)).payload.signature, v27.signature); + assert.equal((await validateSignature(v28)).payload.signature, v28.signature); + + const base = v27; + const baseS = BigInt(`0x${base.signature.slice(66, 130)}`); + const highS = signatureWith(base.signature, { + s: SECP256K1_N - baseS, + v: 28, + }); + const zeroOneV = signatureWith(base.signature, { v: 0 }); + const compact = `0x${base.signature.slice(2, 66)}${( + baseS | (0n << 255n) + ).toString(16).padStart(64, '0')}`; + + for (const [label, fixture, signature] of [ + ['r zero', base, signatureWith(base.signature, { r: 0n })], + ['r at curve order', base, signatureWith(base.signature, { r: SECP256K1_N })], + ['s zero', base, signatureWith(base.signature, { s: 0n })], + ['s one above half order', base, signatureWith(base.signature, { s: SECP256K1_HALF_N + 1n })], + ['high-s malleation', base, highS], + ['v zero', base, zeroOneV], + ['v one', v28, signatureWith(v28.signature, { v: 1 })], + ['v other', base, signatureWith(base.signature, { v: 2 })], + ]) { + await assert.rejects( + () => validateSignature({ ...fixture, signature }), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD' + && /canonical EOA signature/.test(error.message), + label, + ); + } + + await assert.rejects( + () => validateSignature({ + ...base, + signature: signatureWith(base.signature, { s: SECP256K1_HALF_N }), + }), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD' + && error.message === 'signed payment was not produced by the authorized wallet', + ); + await assert.rejects( + () => validateSignature({ ...base, signature: compact }), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD', + ); +}); + +test('validatePaymentPayload rejects every post-sign field substitution', async () => { + const challenge = paymentRequired(); + const binding = signingBinding({ walletAddress: fixtureAccount.address.toLowerCase() }, challenge); + const typedData = typedDataFixture(binding); + const signature = await fixtureAccount.signTypedData(typedData); + const base = paymentPayloadFixture(signature, binding, challenge); + const cases = [ + ['resource.url', { ...base, resource: { ...base.resource, url: 'https://seller.example/other' } }], + ['resource.description', { ...base, resource: { ...base.resource, description: 'changed' } }], + ['resource.mimeType', { ...base, resource: { ...base.resource, mimeType: 'text/plain' } }], + ['accepted.scheme', { ...base, accepted: { ...base.accepted, scheme: 'upto' } }], + ['accepted.network', { ...base, accepted: { ...base.accepted, network: 'eip155:1' } }], + ['accepted.asset', { ...base, accepted: { ...base.accepted, asset: PAY_TO } }], + ['accepted.amount', { ...base, accepted: { ...base.accepted, amount: '50001' } }], + ['accepted.payTo', { ...base, accepted: { ...base.accepted, payTo: ASSET } }], + ['accepted.maxTimeoutSeconds', { + ...base, + accepted: { ...base.accepted, maxTimeoutSeconds: 61 }, + }], + ['accepted.extra.name', { + ...base, + accepted: { ...base.accepted, extra: { ...base.accepted.extra, name: 'USD Coin' } }, + }], + ['accepted.extra.version', { + ...base, + accepted: { ...base.accepted, extra: { ...base.accepted.extra, version: '1' } }, + }], + ['authorization.from', { + ...base, + payload: { + ...base.payload, + authorization: { ...base.payload.authorization, from: PAY_TO }, + }, + }], + ['authorization.to', { + ...base, + payload: { + ...base.payload, + authorization: { ...base.payload.authorization, to: ASSET }, + }, + }], + ['authorization.value', { + ...base, + payload: { + ...base.payload, + authorization: { ...base.payload.authorization, value: '50001' }, + }, + }], + ['authorization.validAfter', { + ...base, + payload: { + ...base.payload, + authorization: { ...base.payload.authorization, validAfter: '1' }, + }, + }], + ['authorization.validBefore', { + ...base, + payload: { + ...base.payload, + authorization: { ...base.payload.authorization, validBefore: '1785502861' }, + }, + }], + ['authorization.nonce', { + ...base, + payload: { + ...base.payload, + authorization: { ...base.payload.authorization, nonce: `0x${'02'.repeat(32)}` }, + }, + }], + ['signature', { + ...base, + payload: { ...base.payload, signature: `0x${'00'.repeat(65)}` }, + }], + ]; + + for (const [label, paymentPayload] of cases) { + await assert.rejects( + () => validatePaymentPayload({ paymentPayload, binding, paymentRequired: challenge, typedData }), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD', + label, + ); + } +}); + +test('validatePaymentPayload rejects unknown fields, mismatched typed data, proxies, and accessors', async () => { + const challenge = paymentRequired(); + const binding = signingBinding({ walletAddress: fixtureAccount.address.toLowerCase() }, challenge); + const typedData = typedDataFixture(binding); + const signature = await fixtureAccount.signTypedData(typedData); + const base = paymentPayloadFixture(signature, binding, challenge); + const unknownShapes = [ + { ...base, injected: true }, + { ...base, resource: { ...base.resource, injected: true } }, + { ...base, accepted: { ...base.accepted, injected: true } }, + { ...base, accepted: { ...base.accepted, extra: { ...base.accepted.extra, injected: true } } }, + { ...base, payload: { ...base.payload, injected: true } }, + { + ...base, + payload: { + ...base.payload, + authorization: { ...base.payload.authorization, injected: true }, + }, + }, + ]; + for (const paymentPayload of unknownShapes) { + await assert.rejects( + () => validatePaymentPayload({ paymentPayload, binding, paymentRequired: challenge, typedData }), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD', + ); + } + + await assert.rejects( + () => validatePaymentPayload({ + paymentPayload: base, + binding, + paymentRequired: challenge, + typedData: { ...typedData, message: { ...typedData.message, value: 50001n } }, + }), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD', + ); + + let getterCalls = 0; + const accessor = { ...base }; + Object.defineProperty(accessor, 'payload', { + enumerable: true, + get() { + getterCalls += 1; + return base.payload; + }, + }); + await assert.rejects( + () => validatePaymentPayload({ + paymentPayload: accessor, + binding, + paymentRequired: challenge, + typedData, + }), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD', + ); + assert.equal(getterCalls, 0); + await assert.rejects( + () => validatePaymentPayload({ + paymentPayload: new Proxy({}, { get() { throw new Error('provider secret'); } }), + binding, + paymentRequired: challenge, + typedData, + }), + (error) => { + assert.equal(error.code, 'WALLET_PAYMENT_PAYLOAD'); + assert.doesNotMatch(error.message, /provider secret/); + return true; + }, + ); + + let bindingGetterCalls = 0; + const hostileBinding = { ...binding }; + Object.defineProperty(hostileBinding, 'acceptedIndex', { + enumerable: true, + get() { + bindingGetterCalls += 1; + return binding.acceptedIndex; + }, + }); + await assert.rejects( + () => validatePaymentPayload({ + paymentPayload: base, + binding: hostileBinding, + paymentRequired: challenge, + typedData, + }), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD', + ); + assert.equal(bindingGetterCalls, 0); + + let requestGetterCalls = 0; + const hostileRequest = { binding, paymentRequired: challenge, typedData }; + Object.defineProperty(hostileRequest, 'paymentPayload', { + enumerable: true, + get() { + requestGetterCalls += 1; + return base; + }, + }); + await assert.rejects( + () => validatePaymentPayload(hostileRequest), + (error) => error.code === 'WALLET_PAYMENT_PAYLOAD', + ); + assert.equal(requestGetterCalls, 0); +}); diff --git a/spikes/pi-wielder/tests/wallet-adapter-deterministic.test.mjs b/spikes/pi-wielder/tests/wallet-adapter-deterministic.test.mjs new file mode 100644 index 0000000..b5bd637 --- /dev/null +++ b/spikes/pi-wielder/tests/wallet-adapter-deterministic.test.mjs @@ -0,0 +1,566 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { getAddress, keccak256, toBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { createDeterministicWalletAdapter } from '../src/adapters/deterministic-wallet-adapter.mjs'; +import { createAgentEnrollmentRepository } from '../src/kernel/agent-enrollment.mjs'; +import { createPermitAuthority, deriveAuthorizationWindow } from '../src/kernel/authorized-permit.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { createIntentRepository } from '../src/kernel/intent-builder.mjs'; +import { evaluateSpendPolicy } from '../src/kernel/policy-engine.mjs'; +import { createPolicyRepository } from '../src/kernel/policy-repository.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; +import { walletAdapterContract } from './wallet-adapter-contract.test.mjs'; + +const FIXED_NOW_MS = 1_785_502_800_000; +const NOW = '2026-07-31T13:00:00.000Z'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'); +const ROUTE_URL = 'https://seller.example/paid/infer'; +const ROUTE_METADATA = Object.freeze({ + 'example-skill': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), +}); +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const DESCRIPTOR_HASH = sha256(canonicalJson(DESCRIPTOR)); +const OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; +const fixtureAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-deterministic-adapter-test-only')), +); +const otherAccount = privateKeyToAccount( + keccak256(toBytes('wallet-kernel-deterministic-adapter-other-test-only')), +); +const PERSISTED_WALLET_ADDRESS = fixtureAccount.address.toLowerCase(); +const BASE_POLICY = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); + +function highSSignature(signature) { + const s = BigInt(`0x${signature.slice(66, 130)}`); + const v = Number.parseInt(signature.slice(130, 132), 16); + return `0x${signature.slice(2, 66)}${(SECP256K1_N - s).toString(16).padStart(64, '0')}${ + (v === 27 ? 28 : 27).toString(16) + }`; +} + +function zeroOneVSignature(signature) { + const v = Number.parseInt(signature.slice(130, 132), 16); + return `${signature.slice(0, 130)}${(v - 27).toString(16).padStart(2, '0')}`; +} + +function compactSignature(signature) { + const r = signature.slice(2, 66); + const s = BigInt(`0x${signature.slice(66, 130)}`); + const v = Number.parseInt(signature.slice(130, 132), 16); + const yParityAndS = s | (BigInt(v - 27) << 255n); + return `0x${r}${yParityAndS.toString(16).padStart(64, '0')}`; +} + +function deepFrozen(value, seen = new Set()) { + if (!value || typeof value !== 'object' || seen.has(value)) return true; + seen.add(value); + return Object.isFrozen(value) + && Reflect.ownKeys(value).every((key) => deepFrozen(value[key], seen)); +} + +function sequenceIds() { + const counts = new Map(); + return (kind) => { + const next = (counts.get(kind) ?? 0) + 1; + counts.set(kind, next); + return `${kind}-${next}`; + }; +} + +function acceptedRequirement(overrides = {}) { + return { + scheme: 'exact', + network: NETWORK, + asset: ASSET, + amount: '50000', + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, + ...overrides, + }; +} + +function paymentChallenge(accepted = acceptedRequirement()) { + return { + x402Version: 2, + error: 'seller prose is outside the signed projection', + resource: { + url: ROUTE_URL, + description: ROUTE_METADATA['example-skill'].description, + mimeType: ROUTE_METADATA['example-skill'].mimeType, + }, + accepts: [accepted], + }; +} + +function createRealAuthorityFixture({ paymentRequired = paymentChallenge() } = {}) { + const store = openKernelStore({ + filePath: ':memory:', + allowMemory: true, + now: () => NOW, + }); + try { + const policyDocument = structuredClone(BASE_POLICY); + policyDocument.wallet = PERSISTED_WALLET_ADDRESS; + const policies = createPolicyRepository(store); + const activePolicy = policies.apply(policyDocument, NOW).policyVersion; + createAgentEnrollmentRepository({ store, now: () => NOW }).enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: DESCRIPTOR_HASH, + operatorIdHash: OPERATOR_HASH, + mode: 'deterministic', + kernelUid: 501, + kernelGid: 20, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const intents = createIntentRepository({ + store, + idFactory: sequenceIds(), + now: () => NOW, + routeMetadata: ROUTE_METADATA, + }); + const session = intents.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: PERSISTED_WALLET_ADDRESS, + policyVersionId: activePolicy.id, + }); + const persistedIntent = intents.captureIntent({ + sessionId: session.id, + routeId: 'example-skill', + method: 'POST', + requestUrl: ROUTE_URL, + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from('{"prompt":"hash-only fixture"}'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-001', + }); + const attachedIntent = intents.attachChallenge({ + intentId: persistedIntent.id, + paymentRequired, + challengeReceivedAt: NOW, + }); + const policyDecision = evaluateSpendPolicy({ + policy: activePolicy.policy, + policyVersion: { id: activePolicy.id, hash: activePolicy.hash }, + intent: { + id: attachedIntent.id, + method: attachedIntent.method, + requestUrl: ROUTE_URL, + sellerOrigin: attachedIntent.sellerOrigin, + resourcePath: attachedIntent.resourcePath, + walletAddress: attachedIntent.walletAddress, + }, + wallet: { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: PERSISTED_WALLET_ADDRESS, + network: NETWORK, + }, + paymentRequired, + challengeReceivedAtMs: FIXED_NOW_MS, + nowMs: FIXED_NOW_MS, + budgetSnapshot: { + sellerSessionExposureAtomic: '0', + sessionExposureAtomic: '0', + rolling24hExposureAtomic: '0', + pendingApprovalCount: 0, + }, + }); + assert.equal(policyDecision.decision, 'allow'); + const persistedDecision = store.transaction((token) => policies.recordDecisionInTransaction( + token, + { + intentId: attachedIntent.id, + policyVersionId: activePolicy.id, + evaluation: policyDecision, + decidedAt: NOW, + }, + )); + const authorizationWindow = deriveAuthorizationWindow({ + nowMs: FIXED_NOW_MS, + challengeReceivedAtMs: FIXED_NOW_MS, + challengeMaxAgeMs: activePolicy.policy.challengeMaxAgeMs, + approvalExpiresAt: null, + maxTimeoutSeconds: paymentRequired.accepts[persistedDecision.acceptedIndex] + .maxTimeoutSeconds, + randomBytes(size) { + assert.equal(size, 32); + return Buffer.alloc(32, 0x01); + }, + }); + const binding = Object.freeze({ + intentId: attachedIntent.id, + intentHash: attachedIntent.intentHash, + challengeHash: persistedDecision.challengeHash, + quoteId: persistedDecision.quoteId, + acceptedIndex: persistedDecision.acceptedIndex, + requestUrl: ROUTE_URL, + resourceDescription: ROUTE_METADATA['example-skill'].description, + resourceMimeType: ROUTE_METADATA['example-skill'].mimeType, + scheme: 'exact', + network: NETWORK, + asset: ASSET, + walletAddress: PERSISTED_WALLET_ADDRESS, + payTo: PAY_TO, + amountAtomic: '50000', + validAfter: authorizationWindow.validAfter, + validBefore: authorizationWindow.validBefore, + nonce: authorizationWindow.nonce, + policyVersionId: activePolicy.id, + }); + const permitAuthority = createPermitAuthority(); + const permit = permitAuthority.issue(binding); + return Object.freeze({ + attachedIntent, + binding, + paymentRequired, + permit, + permitAuthority, + persistedDecision, + }); + } finally { + store.close(); + } +} + +function changedBinding(binding, field) { + const changes = { + challengeHash: `sha256:${'22'.repeat(32)}`, + acceptedIndex: 1, + amountAtomic: '50001', + payTo: '0x3000000000000000000000000000000000000000', + walletAddress: '0x4000000000000000000000000000000000000000', + validBefore: '1785502859', + nonce: `0x${'02'.repeat(32)}`, + }; + const result = { ...binding, [field]: changes[field] }; + if (field === 'challengeHash' || field === 'acceptedIndex') { + result.quoteId = sha256(canonicalJson({ + challengeHash: result.challengeHash, + acceptedIndex: result.acceptedIndex, + })); + } + return result; +} + +function createContractFixture({ failureMode } = {}) { + const authority = createRealAuthorityFixture(); + let signCalls = 0; + const signTypedData = (typedData) => { + signCalls += 1; + if (failureMode === 'untyped-error') throw Object.freeze({ secret: 'provider-value' }); + if (failureMode === 'sync-throw') throw new Error('provider sync failure'); + if (failureMode === 'async-reject') return Promise.reject(new Error('provider rejection')); + if (failureMode === 'never-settle') return new Promise(() => {}); + if (failureMode === 'malformed-signature') return 'not-a-signature'; + if (failureMode === 'high-s-signature') { + return fixtureAccount.signTypedData(typedData).then(highSSignature); + } + if (failureMode === 'zero-one-v-signature') { + return fixtureAccount.signTypedData(typedData).then(zeroOneVSignature); + } + if (failureMode === 'compact-signature') { + return fixtureAccount.signTypedData(typedData).then(compactSignature); + } + if (failureMode === 'assemble-failure') { + return otherAccount.signTypedData(typedData); + } + if (failureMode === 'post-sign-mismatch') { + return fixtureAccount.signTypedData({ + ...typedData, + message: { ...typedData.message, value: typedData.message.value + 1n }, + }); + } + return fixtureAccount.signTypedData(typedData); + }; + const runWithDeadline = async ({ phase, operation }) => { + if (phase !== 'signer' || failureMode !== 'never-settle') return await operation(); + void operation(); + await Promise.resolve(); + throw new Error('deterministic signer deadline'); + }; + const adapter = createDeterministicWalletAdapter({ + identity: { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: fixtureAccount.address, + network: NETWORK, + }, + verifyAndConsume(permit) { + if (failureMode === 'pre-sign') throw new Error('fixture pre-sign rejection'); + return authority.permitAuthority.verifyAndConsume(permit); + }, + signTypedData, + runWithDeadline, + nowMs: () => FIXED_NOW_MS, + }); + + return { + adapter, + provider: 'deterministic-test', + walletId: 'wallet-1', + address: fixtureAccount.address.toLowerCase(), + paymentRequired: authority.paymentRequired, + permit: authority.permit, + signCalls: () => signCalls, + signAuthorized: () => adapter.signX402Exact(authority.permit, authority.paymentRequired), + genuineMismatchedPermit(field) { + const foreignAuthority = createPermitAuthority(); + if (field === 'network' || field === 'asset') { + const permit = authority.permitAuthority.issue(authority.binding); + const accepted = { + ...acceptedRequirement(), + [field]: field === 'network' + ? 'eip155:1' + : '0x5000000000000000000000000000000000000000', + }; + return { permit, paymentRequired: paymentChallenge(accepted) }; + } + return foreignAuthority.issue(changedBinding(authority.binding, field)); + }, + }; +} + +walletAdapterContract('deterministic', createContractFixture); + +test('deterministic adapter signs exact Kernel-issued authority offline and deeply freezes output', async (t) => { + const originalFetch = globalThis.fetch; + let networkCalls = 0; + globalThis.fetch = async () => { + networkCalls += 1; + throw new Error('network forbidden in deterministic adapter'); + }; + t.after(() => { globalThis.fetch = originalFetch; }); + + const fixture = createContractFixture(); + const result = await fixture.signAuthorized(); + + assert.equal(fixture.signCalls(), 1); + assert.equal(result.paymentPayload.payload.authorization.from, PERSISTED_WALLET_ADDRESS); + assert.equal(result.paymentPayload.payload.authorization.value, '50000'); + assert.equal(result.paymentPayload.payload.authorization.nonce, `0x${'01'.repeat(32)}`); + assert.ok(deepFrozen(result)); + assert.equal(networkCalls, 0); +}); + +test('deterministic adapter forwards injected pre-sign and signer deadlines exactly once', async () => { + const authority = createRealAuthorityFixture(); + const calls = []; + let signerCalls = 0; + const adapter = createDeterministicWalletAdapter({ + identity: { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: fixtureAccount.address, + network: NETWORK, + }, + verifyAndConsume: authority.permitAuthority.verifyAndConsume, + async signTypedData(typedData) { + signerCalls += 1; + return await fixtureAccount.signTypedData(typedData); + }, + async runWithDeadline({ phase, timeoutMs, operation }) { + calls.push([phase, timeoutMs]); + return await operation(); + }, + preSignTimeoutMs: 111, + signerTimeoutMs: 222, + nowMs: () => FIXED_NOW_MS, + }); + + await adapter.signX402Exact(authority.permit, authority.paymentRequired); + + assert.deepEqual(calls, [['pre-sign', 111], ['signer', 222]]); + assert.equal(signerCalls, 1); +}); + +test('deterministic adapter rejects expired and beyond-timeout permits before signing', async () => { + for (const [label, validBefore] of [ + ['expired', '1785502800'], + ['beyond timeout', '1785502861'], + ['far future', '999999999999999999999999'], + ]) { + const authority = createRealAuthorityFixture(); + const permit = authority.permitAuthority.issue({ ...authority.binding, validBefore }); + let signerCalls = 0; + const adapter = createDeterministicWalletAdapter({ + identity: { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: fixtureAccount.address, + network: NETWORK, + }, + verifyAndConsume: authority.permitAuthority.verifyAndConsume, + signTypedData: async () => { + signerCalls += 1; + return await fixtureAccount.signTypedData({}); + }, + nowMs: () => FIXED_NOW_MS, + }); + + await assert.rejects( + () => adapter.signX402Exact(permit, authority.paymentRequired), + (error) => error.code === 'WALLET_PRE_SIGN_REJECTED' + && error.signatureMayExist === false, + label, + ); + assert.equal(signerCalls, 0, label); + assert.throws( + () => authority.permitAuthority.verifyAndConsume(permit), + /consumed/, + label, + ); + } +}); + +test('deterministic adapter validates its clock before consuming a permit', async () => { + const invalidClocks = [ + () => '1785502800000', + () => -1, + () => 1.5, + () => Number.MAX_SAFE_INTEGER + 1, + () => { throw new Error('clock failure'); }, + null, + ]; + for (const nowMs of invalidClocks) { + const authority = createRealAuthorityFixture(); + let consumeCalls = 0; + let signerCalls = 0; + const adapter = createDeterministicWalletAdapter({ + identity: { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: fixtureAccount.address, + network: NETWORK, + }, + verifyAndConsume(permit) { + consumeCalls += 1; + return authority.permitAuthority.verifyAndConsume(permit); + }, + signTypedData: async () => { + signerCalls += 1; + return 'unreachable'; + }, + nowMs, + }); + + await assert.rejects( + () => adapter.signX402Exact(authority.permit, authority.paymentRequired), + (error) => error.code === 'WALLET_PRE_SIGN_REJECTED' + && error.signatureMayExist === false, + ); + assert.equal(consumeCalls, 0); + assert.equal(signerCalls, 0); + assert.deepEqual( + authority.permitAuthority.verifyAndConsume(authority.permit), + authority.binding, + ); + } +}); + +test('deterministic adapter captures one signing-time snapshot for build and assembly', async () => { + const authority = createRealAuthorityFixture(); + let clockCalls = 0; + const adapter = createDeterministicWalletAdapter({ + identity: { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: fixtureAccount.address, + network: NETWORK, + }, + verifyAndConsume: authority.permitAuthority.verifyAndConsume, + signTypedData: (typedData) => fixtureAccount.signTypedData(typedData), + nowMs() { + clockCalls += 1; + if (clockCalls > 1) throw new Error('signing clock was resampled'); + return FIXED_NOW_MS; + }, + }); + + await adapter.signX402Exact(authority.permit, authority.paymentRequired); + assert.equal(clockCalls, 1); +}); + +test('deterministic adapter keeps mode selection and CDP dependencies outside its module', () => { + const source = fs.readFileSync( + new URL('../src/adapters/deterministic-wallet-adapter.mjs', import.meta.url), + 'utf8', + ); + + assert.doesNotMatch(source, /WALLET_KERNEL_MODE|process\.env|coinbase|cdp-sdk/i); + assert.match(source, /createDeterministicWalletAdapter/); +}); + +test('deterministic identity comparison accepts checksum-equivalent public address only', async () => { + const authority = createRealAuthorityFixture(); + const adapter = createDeterministicWalletAdapter({ + identity: { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: getAddress(PERSISTED_WALLET_ADDRESS), + network: NETWORK, + }, + verifyAndConsume: authority.permitAuthority.verifyAndConsume, + signTypedData: (typedData) => fixtureAccount.signTypedData(typedData), + nowMs: () => FIXED_NOW_MS, + }); + + const result = await adapter.signX402Exact(authority.permit, authority.paymentRequired); + assert.equal(result.paymentPayload.payload.authorization.from, PERSISTED_WALLET_ADDRESS); +}); + +test('deterministic adapter validates hostile verifier output without invoking accessors or signer', async () => { + const authority = createRealAuthorityFixture(); + let acceptedIndexGetterCalls = 0; + let signerCalls = 0; + const hostileBinding = { ...authority.binding }; + Object.defineProperty(hostileBinding, 'acceptedIndex', { + enumerable: true, + get() { + acceptedIndexGetterCalls += 1; + return 0; + }, + }); + const adapter = createDeterministicWalletAdapter({ + identity: { + provider: 'deterministic-test', + walletId: 'wallet-1', + address: fixtureAccount.address, + network: NETWORK, + }, + verifyAndConsume: () => hostileBinding, + signTypedData: async () => { + signerCalls += 1; + return 'unreachable'; + }, + nowMs: () => FIXED_NOW_MS, + }); + + await assert.rejects( + () => adapter.signX402Exact(authority.permit, authority.paymentRequired), + (error) => error.code === 'WALLET_PRE_SIGN_REJECTED' + && error.signatureMayExist === false, + ); + assert.equal(acceptedIndexGetterCalls, 0); + assert.equal(signerCalls, 0); +}); diff --git a/spikes/pi-wielder/tests/wallet-kernel.test.mjs b/spikes/pi-wielder/tests/wallet-kernel.test.mjs new file mode 100644 index 0000000..4e679f9 --- /dev/null +++ b/spikes/pi-wielder/tests/wallet-kernel.test.mjs @@ -0,0 +1,4189 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { + createWalletKernel, + KERNEL_FAULT_POINTS, +} from '../src/kernel/wallet-kernel.mjs'; +import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; +import { createAgentEnrollmentRepository } from '../src/kernel/agent-enrollment.mjs'; +import { WalletSigningError } from '../src/adapters/wallet-adapter-contract.mjs'; +import { createApprovalQueue } from '../src/kernel/approval-queue.mjs'; +import { createAuthorityMutationCoordinator } from '../src/kernel/authority-mutation-coordinator.mjs'; +import { createPermitAuthority } from '../src/kernel/authorized-permit.mjs'; +import { createBudgetLedger } from '../src/kernel/budget-ledger.mjs'; +import { createIntentRepository } from '../src/kernel/intent-builder.mjs'; +import { createPolicyRepository } from '../src/kernel/policy-repository.mjs'; +import { createReceiptSigner } from '../src/kernel/receipt-signing.mjs'; +import { recoverKernelAuthority } from '../src/kernel/recovery.mjs'; +import { createSignedReceiptRepository } from '../src/kernel/signed-receipts.mjs'; +import { openKernelStore } from '../src/kernel/sqlite-store.mjs'; + +const METHOD_NAMES = Object.freeze([ + 'openOrResumeSession', + 'applyPolicy', + 'revokeAgent', + 'transitionSessionPolicy', + 'closeSession', + 'approvePending', + 'denyPending', + 'expireDueApprovals', + 'execute', + 'status', + 'statusByRequestId', + 'receiptById', +]); + +const NOW = '2026-08-01T12:00:00.000Z'; +const WALLET = '0x1000000000000000000000000000000000000000'; +const ROTATED_WALLET = '0x3000000000000000000000000000000000000000'; +const SELLER = 'https://seller.example'; +const NETWORK = 'eip155:84532'; +const ASSET = '0x036cbd53842c5426634e7929541ec2318f3dcf7e'; +const PAY_TO = '0x2000000000000000000000000000000000000000'; +const DESCRIPTOR = Object.freeze({ + schemaVersion: 1, + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + credentialDigest: `sha256:${'ab'.repeat(32)}`, + agentUid: '501', + agentGid: '20', +}); +const DESCRIPTOR_HASH = sha256(canonicalJson(DESCRIPTOR)); +const OPERATOR_HASH = `sha256:${'cd'.repeat(32)}`; +const ROUTE_METADATA = Object.freeze({ + 'paid-infer': Object.freeze({ + description: 'offline fixture', + mimeType: 'application/json', + }), +}); +const BASE_POLICY = JSON.parse(fs.readFileSync( + new URL('../policies/base-sepolia.example.json', import.meta.url), + 'utf8', +)); + +function dependencies(overrides = {}) { + const noCall = () => { + throw new Error('unexpected dependency call'); + }; + return { + store: Object.freeze({ transaction: noCall, within: noCall }), + policies: Object.freeze({ apply: noCall }), + enrollments: Object.freeze({ revoke: noCall }), + intents: Object.freeze({ openOrResumeSession: noCall }), + budgets: Object.freeze({ snapshot: noCall }), + approvals: Object.freeze({ approve: noCall }), + receipts: Object.freeze({ assertParity: noCall }), + permitAuthority: Object.freeze({ issue: noCall }), + walletAdapter: Object.freeze({ walletIdentity: noCall, signX402Exact: noCall }), + transport: Object.freeze({ probe: noCall, encodePayment: noCall, retryPaid: noCall }), + authorityMutationCoordinator: Object.freeze({ runExclusive: noCall }), + markAuthorityUnhealthy: noCall, + now: () => '2026-08-01T12:00:00.000Z', + idFactory: (kind) => `${kind}-1`, + randomBytes: (size) => Buffer.alloc(size, 0x11), + faultInjector: () => {}, + ...overrides, + }; +} + +function sequenceIds() { + const counts = new Map(); + return (kind) => { + const next = (counts.get(kind) ?? 0) + 1; + counts.set(kind, next); + return `${kind}-${next}`; + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return Object.freeze({ promise, resolve, reject }); +} + +function ordinaryRequest(label = 'ordinary') { + return { + requestUrl: `${SELLER}/paid/infer`, + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from(canonicalJson({ label })), + }; +} + +function paymentRequired(amountAtomic) { + return Object.freeze({ + x402Version: 2, + resource: Object.freeze({ + url: `${SELLER}/paid/infer`, + description: 'offline fixture', + mimeType: 'application/json', + }), + accepts: Object.freeze([Object.freeze({ + scheme: 'exact', + network: NETWORK, + asset: ASSET, + amount: amountAtomic, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: Object.freeze({ name: 'USDC', version: '2' }), + })]), + }); +} + +function signedPaymentPayload(challenge, amountAtomic = challenge.accepts[0].amount) { + return Object.freeze({ + x402Version: 2, + resource: challenge.resource, + accepted: challenge.accepts[0], + payload: Object.freeze({ + signature: `0x${'11'.repeat(65)}`, + authorization: Object.freeze({ + from: WALLET, + to: PAY_TO, + value: amountAtomic, + validAfter: '0', + validBefore: String(Math.floor(Date.parse(NOW) / 1_000) + 60), + nonce: `0x${'11'.repeat(32)}`, + }), + }), + }); +} + +function setupKernel(t, { + transport, + walletAdapter, + autoApproveAtomic = '1000000', + faultInjector = () => {}, + clock = () => NOW, + wrapReceipts = (repository) => repository, + idFactory = sequenceIds(), +} = {}) { + const now = clock; + const ids = idFactory; + const store = openKernelStore({ filePath: ':memory:', allowMemory: true, now }); + t.after(() => store.close()); + const policies = createPolicyRepository(store); + const policy = structuredClone(BASE_POLICY); + policy.sellers[0] = { + ...policy.sellers[0], + perRequestMaxAtomic: '1000000', + autoApproveAtomic, + humanApproveAtomic: '1000000', + sellerSessionMaxAtomic: '2000000', + }; + policy.sessionMaxAtomic = '2000000'; + policy.rolling24hMaxAtomic = '5000000'; + const activePolicy = policies.apply(policy, NOW).policyVersion; + const enrollments = createAgentEnrollmentRepository({ store, now }); + const enrollment = enrollments.enroll({ + descriptor: DESCRIPTOR, + expectedDescriptorHash: DESCRIPTOR_HASH, + operatorIdHash: OPERATOR_HASH, + mode: 'cdp-testnet', + kernelUid: 502, + kernelGid: 502, + expectedAgentUid: 501, + expectedAgentGid: 20, + }); + const intents = createIntentRepository({ + store, + idFactory: ids, + now, + routeMetadata: ROUTE_METADATA, + }); + const budgets = createBudgetLedger({ store, now }); + const approvals = createApprovalQueue({ store, idFactory: ids, now }); + const receiptRepository = createSignedReceiptRepository({ + store, + signer: createReceiptSigner(), + idFactory: ids, + now, + }); + const receipts = wrapReceipts(receiptRepository); + let healthy = true; + const markAuthorityUnhealthy = () => { healthy = false; }; + const authorityMutationCoordinator = createAuthorityMutationCoordinator({ + assertAdmissionOpen() { + if (!healthy) { + const error = new Error('receipt parity is required'); + error.code = 'RECEIPT_PARITY_REQUIRED'; + throw error; + } + }, + markAuthorityUnhealthy, + }); + const configuredWalletAdapter = walletAdapter ?? Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { throw new Error('unexpected signing'); }, + }); + const configuredTransport = transport ?? Object.freeze({ + async probe() { return { kind: 'response', status: 200, body: Buffer.from('ok') }; }, + encodePayment() { throw new Error('unexpected encoding'); }, + async retryPaid() { throw new Error('unexpected retry'); }, + }); + const createKernel = ({ + nextWalletAdapter = configuredWalletAdapter, + nextTransport = configuredTransport, + } = {}) => createWalletKernel({ + store, + policies, + enrollments, + intents, + budgets, + approvals, + receipts, + permitAuthority: createPermitAuthority(), + walletAdapter: nextWalletAdapter, + transport: nextTransport, + authorityMutationCoordinator, + markAuthorityUnhealthy, + now, + idFactory: ids, + randomBytes: (size) => Buffer.alloc(size, 0x11), + faultInjector, + }); + const kernel = createKernel(); + return { + activePolicy, + approvals, + budgets, + createKernel, + enrollment, + enrollments, + intents, + kernel, + policy, + receipts: receiptRepository, + store, + }; +} + +test('Slice A RED: exports the exact frozen twelve-method API and ten fault points', () => { + const kernel = createWalletKernel(dependencies()); + assert.ok(Object.isFrozen(kernel)); + assert.deepEqual(Object.keys(kernel), METHOD_NAMES); + for (const name of METHOD_NAMES) assert.equal(typeof kernel[name], 'function'); + assert.deepEqual(KERNEL_FAULT_POINTS, Object.freeze([ + 'after_intent_commit', + 'after_challenge_commit', + 'after_reservation_commit', + 'after_signing_claim_commit', + 'after_signer_return', + 'after_signed_payment_commit', + 'after_retry_claim_commit', + 'after_paid_response', + 'after_settlement_commit', + 'before_terminal_receipt_commit', + ])); + assert.ok(Object.isFrozen(KERNEL_FAULT_POINTS)); +}); + +test('Slice A RED: constructor dependencies form one closed injected authority graph', () => { + const valid = dependencies(); + assert.throws(() => createWalletKernel(), TypeError); + assert.throws(() => createWalletKernel({ ...valid, environment: process.env }), TypeError); + assert.throws(() => createWalletKernel({ ...valid, randomBytes: null }), TypeError); + assert.throws(() => createWalletKernel({ ...valid, idFactory: new Proxy(() => '', {}) }), TypeError); +}); + +test('Slice A RED: session and operator mutations are FIFO coordinator-held repository wrappers', async () => { + const trace = []; + const document = { schemaVersion: 1, wallet: 'fixture' }; + const expectedPolicyHash = sha256(canonicalJson(document)); + const operatorIdHash = `sha256:${'22'.repeat(32)}`; + const expectedEnrollmentHash = `sha256:${'33'.repeat(32)}`; + const expectedIntentHash = `sha256:${'44'.repeat(32)}`; + const deps = dependencies({ + authorityMutationCoordinator: Object.freeze({ + async runExclusive(operation) { + trace.push('lease'); + try { + return await operation(); + } finally { + trace.push('release'); + } + }, + }), + walletAdapter: Object.freeze({ + async walletIdentity() { + trace.push('wallet'); + return { address: 'fixture' }; + }, + async signX402Exact() { throw new Error('must not sign'); }, + }), + intents: Object.freeze({ + openOrResumeSession(input) { + trace.push(['session', input]); + return Object.freeze({ id: 'session-1' }); + }, + }), + policies: Object.freeze({ + active() { return null; }, + apply(received, at) { + trace.push(['policy', received, at]); + return Object.freeze({ policyVersion: { id: 'policy-1' } }); + }, + }), + enrollments: Object.freeze({ + revoke(input) { + trace.push(['revoke', input]); + return Object.freeze({ boundSessionIds: ['session-1'] }); + }, + }), + approvals: Object.freeze({ + approve(input) { + trace.push(['approve', input]); + return Object.freeze({ approvalId: input.approvalId, decision: 'approved' }); + }, + listDue() { return Object.freeze([]); }, + }), + }); + const kernel = createWalletKernel(deps); + + assert.deepEqual(await kernel.openOrResumeSession({ + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + walletAddress: '0x1000000000000000000000000000000000000000', + policyVersionId: 'policy-1', + }), { id: 'session-1' }); + assert.deepEqual(await kernel.applyPolicy({ document, expectedPolicyHash }), { + policyVersion: { id: 'policy-1' }, + }); + assert.deepEqual(await kernel.revokeAgent({ + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + expectedEnrollmentHash, + operatorIdHash, + }), { boundSessionIds: ['session-1'] }); + assert.deepEqual(await kernel.approvePending({ + approvalId: 'approval-1', + expectedIntentHash, + operatorIdHash, + }), { approvalId: 'approval-1', decision: 'approved' }); + + assert.deepEqual(trace, [ + 'lease', + ['session', { + agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', + walletAddress: '0x1000000000000000000000000000000000000000', + policyVersionId: 'policy-1', + }], + 'release', + 'wallet', + 'lease', + ['policy', document, '2026-08-01T12:00:00.000Z'], + 'release', + 'lease', + ['revoke', { agentInstanceId: 'AAAAAAAAAAAAAAAAAAAAAA', expectedEnrollmentHash, operatorIdHash }], + 'release', + 'lease', + ['approve', { approvalId: 'approval-1', expectedIntentHash, operatorIdHash }], + 'release', + ]); +}); + +test('Slice A RED: policy hash confirmation and all wrapper schemas fail closed before a lease', async () => { + let leases = 0; + const kernel = createWalletKernel(dependencies({ + authorityMutationCoordinator: Object.freeze({ + runExclusive(operation) { + leases += 1; + return Promise.resolve(operation()); + }, + }), + })); + await assert.rejects( + kernel.applyPolicy({ + document: { schemaVersion: 1 }, + expectedPolicyHash: `sha256:${'00'.repeat(32)}`, + }), + (error) => error?.code === 'POLICY_CONFIRMATION_STALE', + ); + await assert.rejects( + kernel.openOrResumeSession({ sessionId: 'attacker-chosen' }), + (error) => error?.code === 'SESSION_SCHEMA', + ); + await assert.rejects( + kernel.approvePending({ approvalId: 'approval-1' }), + (error) => error?.code === 'APPROVAL_DECISION_SCHEMA', + ); + assert.equal(leases, 0); +}); + +test('Slice B RED: ordinary 2xx captures once and returns only after terminal outcome and receipt', async (t) => { + const context = setupKernel(t); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-ordinary', + }); + + assert.equal(result.status, 'completed'); + assert.equal(result.reasonCode, 'ORDINARY_SUCCESS'); + assert.match(result.requestId, /^request-/); + assert.equal(result.upstreamStatus, 200); + assert.deepEqual(result.body, Buffer.from('ok')); + assert.equal(result.receipt.receipt.outcome.status, 'completed'); + assert.equal(result.receipt.receipt.intent.requestId, result.requestId); + assert.equal(context.receipts.assertParity(), true); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length, 0); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + const outcome = context.store.readOne( + 'SELECT status, reason_code, revision FROM buyer_outcomes', + ); + assert.equal(outcome.status, 'completed'); + assert.equal(outcome.reason_code, 'ORDINARY_SUCCESS'); + assert.equal(outcome.revision, 1n); +}); + +test('Slice B RED: ordinary non-402 HTTP failure is terminal and never creates spend authority', async (t) => { + const context = setupKernel(t, { + transport: Object.freeze({ + async probe() { return { kind: 'response', status: 503, body: Buffer.from('unavailable') }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('ordinary-http-failure'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-ordinary-http-failure', + }); + + assert.equal(result.status, 'upstream_failed'); + assert.equal(result.reasonCode, 'ORDINARY_HTTP_FAILURE'); + assert.equal(result.upstreamStatus, 503); + assert.deepEqual(result.body, Buffer.from('unavailable')); + assert.equal(result.receipt.receipt.execution.state, 'failed'); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length, 0); + assert.equal(context.store.readOne('SELECT status FROM buyer_outcomes').status, + 'upstream_failed'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice B RED: unpaid transport failure terminalizes without budget or signing', async (t) => { + const context = setupKernel(t, { + transport: Object.freeze({ + async probe() { + const error = new Error('private transport detail'); + error.code = 'UNPAID_FETCH_FAILED'; + throw error; + }, + encodePayment() { throw new Error('unexpected encoding'); }, + async retryPaid() { throw new Error('unexpected retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('transport-failure'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-transport-failure', + }); + + assert.equal(result.status, 'upstream_failed'); + assert.equal(result.reasonCode, 'UPSTREAM_TRANSPORT_FAILURE'); + assert.equal(result.upstreamStatus, null); + assert.equal(result.body, null); + assert.equal(result.receipt.receipt.execution.state, 'unknown'); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length, 0); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice B RED: an impossible transport result fails closed without inventing authority', async (t) => { + const context = setupKernel(t, { + transport: Object.freeze({ + async probe() { return { kind: 'unexpected_internal_variant' }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + await assert.rejects( + context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('invalid-transport-result'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-invalid-transport-result', + }), + (error) => error?.code === 'TRANSPORT_RESULT_SCHEMA', + ); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'captured'); + assert.equal(context.store.readAll('SELECT * FROM policy_decisions').length, 0); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(context.store.readAll('SELECT * FROM buyer_outcomes').length, 0); +}); + +test('Slice B RED: policy denial persists challenge and decision before a no-spend receipt', async (t) => { + let signerCalls = 0; + const challenge = paymentRequired('1000001'); + const context = setupKernel(t, { + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('unexpected encoding'); }, + async retryPaid() { throw new Error('unexpected retry'); }, + }), + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('policy-deny'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-policy-deny', + }); + + assert.equal(result.status, 'payment_denied'); + assert.equal(result.reasonCode, 'PER_REQUEST_LIMIT'); + assert.equal(result.receipt.receipt.policy.decision, 'deny'); + assert.equal(result.receipt.receipt.payment.state, 'none'); + assert.equal(signerCalls, 0); + assert.equal(context.store.readAll('SELECT * FROM policy_decisions').length, 1); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice B RED: pending approval is idempotent by ordinary request and hides approval authority', async (t) => { + let probes = 0; + const challenge = paymentRequired('50000'); + const context = setupKernel(t, { + autoApproveAtomic: '10000', + transport: Object.freeze({ + async probe() { + probes += 1; + return { kind: 'payment_required', paymentRequired: challenge }; + }, + encodePayment() { throw new Error('unexpected encoding'); }, + async retryPaid() { throw new Error('unexpected retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('approval'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-approval', + }; + const first = await context.kernel.execute(call); + const [second, third] = await Promise.all([ + context.kernel.execute(call), + context.kernel.execute(call), + ]); + + assert.equal(first.status, 'payment_approval_required'); + assert.equal(first.reasonCode, 'HUMAN_APPROVAL_REQUIRED'); + assert.equal(first.receipt, null); + assert.equal(second.requestId, first.requestId); + assert.equal(second.status, first.status); + assert.equal(probes, 1); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.readAll('SELECT * FROM approvals').length, 1); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + const approvalId = context.store.readOne('SELECT id FROM approvals').id; + assert.equal(JSON.stringify(first).includes(approvalId), false); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'approval_pending'); +}); + +test('challenge persistence never backdates its policy decision when the clock advances', async (t) => { + const base = Date.parse(NOW); + let tick = 0; + const clock = () => new Date(base + tick++).toISOString(); + const challenge = paymentRequired('50000'); + const context = setupKernel(t, { + autoApproveAtomic: '10000', + clock, + transport: Object.freeze({ + async probe() { + return { kind: 'payment_required', paymentRequired: challenge }; + }, + encodePayment() { throw new Error('unexpected encoding'); }, + async retryPaid() { throw new Error('unexpected retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('advancing-clock-approval'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-advancing-clock-approval', + }); + + assert.equal(result.status, 'payment_approval_required'); + const intent = context.store.readOne('SELECT id FROM spend_intents'); + const challengeEvent = context.store.readOne( + "SELECT data_json FROM events WHERE entity_id = ? AND event_type = 'intent.challenge_attached'", + [intent.id], + ); + const decision = context.store.readOne( + 'SELECT decided_at FROM policy_decisions WHERE intent_id = ?', + [intent.id], + ); + assert.ok( + Date.parse(decision.decided_at) >= Date.parse(JSON.parse(challengeEvent.data_json).updatedAt), + ); + assert.equal(recoverKernelAuthority({ + store: context.store, + intents: context.intents, + budgets: context.budgets, + approvals: context.approvals, + receipts: context.receipts, + now: clock, + }).ready, true); +}); + +test('Slices C-D RED: auto-approved settlement signs once, commits once, and receipts exact terminal facts', async (t) => { + const trace = []; + const base = Date.parse(NOW); + let tick = 0; + const clock = () => new Date(base + tick++).toISOString(); + const challenge = paymentRequired('50000'); + const validBefore = String(Math.floor(Date.parse(NOW) / 1_000) + 60); + const nonce = `0x${'11'.repeat(32)}`; + const paymentPayload = Object.freeze({ + x402Version: 2, + resource: challenge.resource, + accepted: challenge.accepts[0], + payload: Object.freeze({ + signature: `0x${'11'.repeat(65)}`, + authorization: Object.freeze({ + from: WALLET, + to: PAY_TO, + value: '50000', + validAfter: '0', + validBefore, + nonce, + }), + }), + }); + let retryBinding; + const context = setupKernel(t, { + clock, + faultInjector(point) { trace.push(point); }, + walletAdapter: Object.freeze({ + async walletIdentity() { + trace.push('wallet_identity'); + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact(permit) { + trace.push('sign'); + assert.equal(permit.kind, 'AuthorizedPermit'); + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { + trace.push('probe'); + return { kind: 'payment_required', paymentRequired: challenge }; + }, + encodePayment(received) { + trace.push('encode'); + assert.equal(received, paymentPayload); + return 'fixture-payment-header'; + }, + async retryPaid({ binding }) { + trace.push('retry'); + retryBinding = binding; + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('fixture-payment-response', 'ascii')), + success: true, + transaction: `0x${'aa'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 200, + body: Buffer.from('{"ok":true}'), + executionState: 'succeeded', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('settled'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-settled', + }); + + assert.equal(result.status, 'completed'); + assert.equal(result.reasonCode, 'PAYMENT_SETTLED'); + assert.equal(result.receipt.receipt.payment.transactionId, `0x${'aa'.repeat(32)}`); + assert.equal(result.receipt.receipt.budget.disposition, 'committed'); + assert.equal(retryBinding.paymentHash, sha256(Buffer.from('fixture-payment-header', 'ascii'))); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'settled'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'committed'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + const chronology = context.store.readOne(`SELECT + payment_attempts.settled_at, + execution_outcomes.recorded_at + FROM payment_attempts + JOIN execution_outcomes ON execution_outcomes.intent_id = payment_attempts.intent_id`); + assert.ok(Date.parse(chronology.recorded_at) >= Date.parse(chronology.settled_at)); + assert.equal(context.receipts.assertParity(), true); + assert.deepEqual(trace, [ + 'after_intent_commit', + 'probe', + 'wallet_identity', + 'after_challenge_commit', + 'after_reservation_commit', + 'after_signing_claim_commit', + 'sign', + 'after_signer_return', + 'encode', + 'after_signed_payment_commit', + 'after_retry_claim_commit', + 'retry', + 'after_paid_response', + 'after_settlement_commit', + 'before_terminal_receipt_commit', + ]); +}); + +test('Slices B-D RED: every declared fault point leaves one classified durable state', async (t) => { + const expected = Object.freeze({ + after_intent_commit: Object.freeze({ + intent: 'captured', budget: null, payment: null, signer: 0, retry: 0, outcome: false, + }), + after_challenge_commit: Object.freeze({ + intent: 'challenged', budget: null, payment: null, signer: 0, retry: 0, outcome: false, + }), + after_reservation_commit: Object.freeze({ + intent: 'reserved', budget: 'reserved', payment: 'reserved', signer: 0, retry: 0, + outcome: false, + }), + after_signing_claim_commit: Object.freeze({ + intent: 'signing', budget: 'reserved', payment: 'signing', signer: 0, retry: 0, + outcome: false, + }), + after_signer_return: Object.freeze({ + intent: 'signing', budget: 'reserved', payment: 'signing', signer: 1, retry: 0, + outcome: false, + }), + after_signed_payment_commit: Object.freeze({ + intent: 'signed', budget: 'reserved', payment: 'signed', signer: 1, retry: 0, + outcome: false, + }), + after_retry_claim_commit: Object.freeze({ + intent: 'retrying', budget: 'reserved', payment: 'retrying', signer: 1, retry: 0, + outcome: false, + }), + after_paid_response: Object.freeze({ + intent: 'retrying', budget: 'reserved', payment: 'retrying', signer: 1, retry: 1, + outcome: false, + }), + after_settlement_commit: Object.freeze({ + intent: 'terminal', budget: 'committed', payment: 'settled', signer: 1, retry: 1, + outcome: true, + }), + before_terminal_receipt_commit: Object.freeze({ + intent: 'terminal', budget: 'committed', payment: 'settled', signer: 1, retry: 1, + outcome: true, + }), + }); + + for (const point of KERNEL_FAULT_POINTS) { + await t.test(point, async (st) => { + const challenge = paymentRequired('50000'); + const paymentPayload = signedPaymentPayload(challenge); + const crash = new Error(`fault:${point}`); + let signerCalls = 0; + let retryCalls = 0; + const context = setupKernel(st, { + faultInjector(observed) { + if (observed === point) throw crash; + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + signerCalls += 1; + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return 'fault-matrix-payment-header'; }, + async retryPaid({ binding }) { + retryCalls += 1; + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('fault-matrix-settlement', 'ascii')), + success: true, + transaction: `0x${'ef'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 200, + body: Buffer.from('fault-matrix-ok'), + executionState: 'succeeded', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await assert.rejects(context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(`fault-${point}`), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-fault-${point}`, + }), (error) => error === crash); + + const classification = expected[point]; + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, + classification.intent); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations')?.state ?? null, + classification.budget); + const attempt = context.store.readOne('SELECT * FROM payment_attempts'); + assert.equal(attempt?.state ?? null, classification.payment); + assert.equal(signerCalls, classification.signer); + assert.equal(retryCalls, classification.retry); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length <= 1, true); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length <= 1, true); + assert.equal(context.store.readAll('SELECT * FROM buyer_outcomes').length, + classification.outcome ? 1 : 0); + assert.equal(context.store.readAll('SELECT * FROM signed_receipts').length, 0); + if (new Set(['signed', 'retrying', 'settled']).has(classification.payment)) { + assert.equal(attempt.payment_payload_json, canonicalJson(paymentPayload)); + assert.equal(attempt.payment_header, 'fault-matrix-payment-header'); + assert.equal(attempt.payment_hash, + sha256(Buffer.from('fault-matrix-payment-header', 'ascii'))); + } else if (attempt) { + assert.equal(attempt.payment_payload_json, null); + assert.equal(attempt.payment_header, null); + assert.equal(attempt.payment_hash, null); + } + assert.equal(attempt?.transaction_id ?? null, + classification.outcome ? `0x${'ef'.repeat(32)}` : null); + assert.equal(context.store.verifyEventChain(), true); + if (classification.outcome) { + assert.throws(() => context.receipts.assertParity(), + (error) => error?.code === 'RECEIPT_PARITY_REQUIRED'); + } else { + assert.equal(context.receipts.assertParity(), true); + } + }); + } +}); + +test('post-signer storage faults close admission before a second authorization can be signed', async (t) => { + const stages = Object.freeze([ + Object.freeze({ name: 'signed payment persistence', failOnParityCall: 1, state: 'signing', retries: 0 }), + Object.freeze({ name: 'paid retry claim', failOnParityCall: 2, state: 'signed', retries: 0 }), + Object.freeze({ name: 'terminal settlement', failOnParityCall: 3, state: 'retrying', retries: 1 }), + ]); + + for (const stage of stages) { + await t.test(stage.name, async (st) => { + const challenge = paymentRequired('50000'); + const paymentPayload = signedPaymentPayload(challenge); + const storageFailure = new Error(`storage fault:${stage.name}`); + let signerReturned = false; + let postSignerParityCalls = 0; + let signerCalls = 0; + let retryCalls = 0; + const context = setupKernel(st, { + wrapReceipts(repository) { + return Object.freeze({ + ...repository, + assertParityInTransaction(token) { + if (signerReturned) { + postSignerParityCalls += 1; + if (postSignerParityCalls === stage.failOnParityCall) throw storageFailure; + } + return repository.assertParityInTransaction(token); + }, + }); + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + signerCalls += 1; + signerReturned = true; + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return 'post-signer-storage-fault-header'; }, + async retryPaid({ binding }) { + retryCalls += 1; + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('post-signer-storage-fault-settlement', 'ascii')), + success: true, + transaction: `0x${'aa'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 200, + body: Buffer.from('settled-before-storage-fault'), + executionState: 'succeeded', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + await assert.rejects(context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(`post-signer-fault-${stage.failOnParityCall}`), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-post-signer-fault-${stage.failOnParityCall}`, + }), (error) => error === storageFailure); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, stage.state); + + await assert.rejects(context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(`post-signer-second-${stage.failOnParityCall}`), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-post-signer-second-${stage.failOnParityCall}`, + }), (error) => error?.code === 'RECEIPT_PARITY_REQUIRED'); + assert.equal(signerCalls, 1); + assert.equal(retryCalls, stage.retries); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length, 1); + }); + } +}); + +test('Slice D RED: ambiguous paid response holds once and exact retries reuse its signed outcome', async (t) => { + const challenge = paymentRequired('50000'); + const validBefore = String(Math.floor(Date.parse(NOW) / 1_000) + 60); + const paymentPayload = Object.freeze({ + x402Version: 2, + resource: challenge.resource, + accepted: challenge.accepts[0], + payload: Object.freeze({ + signature: `0x${'11'.repeat(65)}`, + authorization: Object.freeze({ + from: WALLET, + to: PAY_TO, + value: '50000', + validAfter: '0', + validBefore, + nonce: `0x${'11'.repeat(32)}`, + }), + }), + }); + let signerCalls = 0; + let retries = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + signerCalls += 1; + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return 'fixture-payment-header'; }, + async retryPaid() { + retries += 1; + return Object.freeze({ + kind: 'paid_response_ambiguous', + reasonCode: 'SECOND_PAYMENT_REQUIRED', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('ambiguous'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-ambiguous', + }; + const first = await context.kernel.execute(call); + const [second, third] = await Promise.all([ + context.kernel.execute(call), + context.kernel.execute(call), + ]); + + assert.equal(first.status, 'payment_unresolved'); + assert.equal(first.reasonCode, 'SECOND_PAYMENT_REQUIRED'); + assert.equal(first.receipt.receipt.payment.state, 'unresolved'); + assert.equal(first.receipt.receipt.budget.disposition, 'unresolved'); + assert.equal(second.requestId, first.requestId); + assert.equal(second.receipt.receiptHash, first.receipt.receiptHash); + assert.equal(third.requestId, first.requestId); + assert.equal(third.receipt.receiptHash, first.receipt.receiptHash); + assert.equal(signerCalls, 1); + assert.equal(retries, 1); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'unresolved'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice D RED: a thrown paid retry is one receipt-backed unresolved hold', async (t) => { + const challenge = paymentRequired('50000'); + const paymentPayload = signedPaymentPayload(challenge); + let signerCalls = 0; + let retries = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + signerCalls += 1; + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return 'thrown-paid-retry-header'; }, + async retryPaid() { + retries += 1; + const error = new Error('seller delivery is unknowable'); + error.code = 'PAID_FETCH_FAILED'; + throw error; + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('thrown-paid-retry'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-thrown-paid-retry', + }; + + const first = await context.kernel.execute(call); + const second = await context.kernel.execute(call); + + assert.equal(first.status, 'payment_unresolved'); + assert.equal(first.reasonCode, 'PAID_RESPONSE_AMBIGUOUS'); + assert.equal(second.requestId, first.requestId); + assert.equal(second.receipt.receiptHash, first.receipt.receiptHash); + assert.equal(signerCalls, 1); + assert.equal(retries, 1); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT reason_code FROM buyer_outcomes').reason_code, + 'PAID_RESPONSE_AMBIGUOUS'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice D RED: settled HTTP failure atomically commits spend and opens a full refund blocker', async (t) => { + const challenge = paymentRequired('50000'); + const paymentPayload = Object.freeze({ + x402Version: 2, + resource: challenge.resource, + accepted: challenge.accepts[0], + payload: Object.freeze({ + signature: `0x${'11'.repeat(65)}`, + authorization: Object.freeze({ + from: WALLET, + to: PAY_TO, + value: '50000', + validAfter: '0', + validBefore: String(Math.floor(Date.parse(NOW) / 1_000) + 60), + nonce: `0x${'11'.repeat(32)}`, + }), + }), + }); + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { return { paymentPayload }; }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return 'fixture-payment-header'; }, + async retryPaid({ binding }) { + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('fixture-payment-response', 'ascii')), + success: true, + transaction: `0x${'bb'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + paymentHash: binding.paymentHash, + }), + status: 500, + body: null, + executionState: 'failed', + deliveryReason: 'HTTP_STATUS_FAILURE', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('settled-failure'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-settled-failure', + }); + + assert.equal(result.status, 'execution_failed'); + assert.equal(result.reasonCode, 'UPSTREAM_HTTP_FAILURE'); + assert.equal(result.receipt.receipt.budget.disposition, 'committed'); + assert.equal(result.receipt.receipt.refund.state, 'pending'); + const refund = context.store.readOne('SELECT * FROM refunds'); + assert.equal(refund.amount_atomic, '50000'); + assert.equal(refund.original_transaction_id, `0x${'bb'.repeat(32)}`); + assert.equal(context.store.readOne('SELECT state FROM execution_resolutions').state, 'refund_pending'); + const resolutionEvent = context.store.events().find( + (event) => event.entity_type === 'execution_resolution' + && event.event_type === 'execution_resolution.opened', + ); + assert.deepEqual({ + entityId: resolutionEvent?.entity_id, + data: resolutionEvent === undefined ? null : JSON.parse(resolutionEvent.data_json), + }, { + entityId: context.store.readOne('SELECT id FROM spend_intents').id, + data: { + intentId: context.store.readOne('SELECT id FROM spend_intents').id, + state: 'refund_pending', + reasonCode: 'UPSTREAM_HTTP_FAILURE', + blocksWallet: true, + openedAt: NOW, + }, + }); + const refundEvent = context.store.events().find( + (event) => event.entity_type === 'refund' && event.event_type === 'refund.opened', + ); + assert.deepEqual({ + entityId: refundEvent?.entity_id, + data: refundEvent === undefined ? null : JSON.parse(refundEvent.data_json), + }, { + entityId: refund.id, + data: { + refundId: refund.id, + intentId: refund.intent_id, + originalTransactionId: refund.original_transaction_id, + amountAtomic: refund.amount_atomic, + state: 'pending', + createdAt: NOW, + }, + }); + assert.equal(context.budgets.snapshot({ + sessionId: session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice D RED: a write failure inside settlement rolls back payment, execution, and refund together', async (t) => { + const challenge = paymentRequired('50000'); + const paymentPayload = signedPaymentPayload(challenge); + const ids = sequenceIds(); + const settlementWriteFailure = new Error('refund ID persistence unavailable'); + const context = setupKernel(t, { + idFactory(kind) { + if (kind === 'refund') throw settlementWriteFailure; + return ids(kind); + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { return { paymentPayload }; }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return 'atomic-settlement-payment-header'; }, + async retryPaid({ binding }) { + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('atomic-settlement-response', 'ascii')), + success: true, + transaction: `0x${'83'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 500, + body: null, + executionState: 'failed', + deliveryReason: 'HTTP_STATUS_FAILURE', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + await assert.rejects( + context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('atomic-settlement-rollback'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-atomic-settlement-rollback', + }), + (error) => error === settlementWriteFailure, + ); + + const attempt = context.store.readOne('SELECT * FROM payment_attempts'); + assert.equal(attempt.state, 'retrying'); + assert.equal(attempt.transaction_id, null); + assert.equal(attempt.settlement_json, null); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'reserved'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'retrying'); + assert.equal(context.store.readAll('SELECT * FROM execution_outcomes').length, 0); + assert.equal(context.store.readAll('SELECT * FROM execution_resolutions').length, 0); + assert.equal(context.store.readAll('SELECT * FROM refunds').length, 0); + assert.equal(context.store.readAll('SELECT * FROM buyer_outcomes').length, 0); + assert.equal(context.store.readAll('SELECT * FROM signed_receipts').length, 0); + assert.equal(context.store.events().filter( + (event) => event.event_type === 'execution_resolution.opened' + || event.event_type === 'refund.opened', + ).length, 0); + assert.equal(context.store.verifyEventChain(), true); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice B RED: approved exact retry freshly probes then consumes approval with its reservation', async (t) => { + const challenge = paymentRequired('50000'); + const paymentPayload = Object.freeze({ + x402Version: 2, + resource: challenge.resource, + accepted: challenge.accepts[0], + payload: Object.freeze({ + signature: `0x${'11'.repeat(65)}`, + authorization: Object.freeze({ + from: WALLET, + to: PAY_TO, + value: '50000', + validAfter: '0', + validBefore: String(Math.floor(Date.parse(NOW) / 1_000) + 60), + nonce: `0x${'11'.repeat(32)}`, + }), + }), + }); + let probes = 0; + let signerCalls = 0; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + signerCalls += 1; + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + return { kind: 'payment_required', paymentRequired: challenge }; + }, + encodePayment() { return 'fixture-payment-header'; }, + async retryPaid({ binding }) { + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('fixture-payment-response', 'ascii')), + success: true, + transaction: `0x${'cc'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 200, + body: Buffer.from('approved'), + executionState: 'succeeded', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('approved-retry'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-approved-retry', + }; + const pending = await context.kernel.execute(call); + const approval = context.store.readOne('SELECT id, intent_hash FROM approvals'); + await context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + const completed = await context.kernel.execute(call); + + assert.equal(completed.requestId, pending.requestId); + assert.equal(completed.status, 'completed'); + assert.equal(probes, 2); + assert.equal(signerCalls, 1); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'consumed'); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 1); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'committed'); + assert.equal(completed.receipt.receipt.approval.state, 'consumed'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice C RED: exact branded pre-signer rejection releases the claimed reservation', async (t) => { + const challenge = paymentRequired('50000'); + let retryCalls = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + throw new WalletSigningError( + 'WALLET_PRE_SIGN_REJECTED', + 'fixture rejected before signer', + { signatureMayExist: false }, + ); + }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { retryCalls += 1; throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('pre-sign-rejected'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-pre-sign-rejected', + }); + + assert.equal(result.status, 'payment_failed'); + assert.equal(result.reasonCode, 'WALLET_PRE_SIGN_REJECTED'); + assert.equal(retryCalls, 0); + const attempt = context.store.readOne('SELECT * FROM payment_attempts'); + assert.equal(attempt.state, 'rejected'); + assert.equal(attempt.nonce, `0x${'11'.repeat(32)}`); + assert.equal(attempt.payment_payload_json, null); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'released'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(result.receipt.receipt.payment.state, 'not_signed'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice C RED: untyped signer throw holds full unresolved exposure without retry', async (t) => { + const challenge = paymentRequired('50000'); + let retryCalls = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { throw new Error('untyped signer failure'); }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { retryCalls += 1; throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('signer-ambiguous'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-signer-ambiguous', + }); + + assert.equal(result.status, 'payment_unresolved'); + assert.equal(result.reasonCode, 'WALLET_SIGNATURE_AMBIGUOUS'); + assert.equal(retryCalls, 0); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT payment_payload_json FROM payment_attempts') + .payment_payload_json, null); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'unresolved'); + assert.equal(result.receipt.receipt.payment.state, 'unresolved'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice A RED: operator denial atomically terminalizes approval and issues its receipt', async (t) => { + const challenge = paymentRequired('50000'); + const context = setupKernel(t, { + autoApproveAtomic: '10000', + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const pending = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('operator-denied'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-operator-denied', + }); + const approval = context.store.readOne('SELECT id, intent_id, intent_hash FROM approvals'); + const denied = await context.kernel.denyPending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + reasonCode: 'OPERATOR_DENIED', + }); + + assert.equal(denied.status, 'payment_denied'); + assert.equal(denied.reasonCode, 'OPERATOR_DENIED'); + assert.equal(denied.requestId, pending.requestId); + assert.equal(denied.receipt.receipt.approval.state, 'denied'); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'denied'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readOne('SELECT status FROM buyer_outcomes').status, 'payment_denied'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice A RED: due pending and approved approvals expire in stable receipt-backed aggregates', async (t) => { + let currentTime = NOW; + const challenge = paymentRequired('50000'); + const context = setupKernel(t, { + autoApproveAtomic: '10000', + clock: () => currentTime, + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + for (const label of ['expiry-pending', 'expiry-approved']) { + await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(label), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-${label}`, + }); + } + const approvals = context.store.readAll( + 'SELECT id, intent_id, intent_hash, expires_at FROM approvals ORDER BY id', + ); + await context.kernel.approvePending({ + approvalId: approvals[1].id, + expectedIntentHash: approvals[1].intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + currentTime = approvals[0].expires_at; + + const expired = await context.kernel.expireDueApprovals({ limit: 10 }); + + assert.deepEqual(expired.map((entry) => entry.intentId), approvals.map((entry) => entry.intent_id)); + assert.deepEqual(expired.map((entry) => entry.status), ['payment_denied', 'payment_denied']); + assert.deepEqual(expired.map((entry) => entry.reasonCode), ['APPROVAL_EXPIRED', 'APPROVAL_EXPIRED']); + assert.deepEqual( + context.store.readAll('SELECT decision FROM approvals ORDER BY id').map((row) => row.decision), + ['expired', 'expired'], + ); + assert.deepEqual( + context.store.readAll('SELECT state FROM spend_intents ORDER BY id').map((row) => row.state), + ['terminal', 'terminal'], + ); + assert.deepEqual( + context.store.readAll('SELECT status, reason_code FROM buyer_outcomes ORDER BY intent_id') + .map((row) => [row.status, row.reason_code]), + [ + ['payment_denied', 'APPROVAL_EXPIRED'], + ['payment_denied', 'APPROVAL_EXPIRED'], + ], + ); + assert.equal(context.store.readAll('SELECT * FROM signed_receipts').length, 2); + assert.equal(context.receipts.assertParity(), true); + assert.deepEqual(await context.kernel.expireDueApprovals({ limit: 10 }), []); +}); + +test('Slice C RED: malformed signer output becomes one unresolved hold without a paid retry', async (t) => { + const challenge = paymentRequired('50000'); + let signerCalls = 0; + let retryCalls = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + signerCalls += 1; + return { paymentPayload: Object.freeze({ x402Version: 2 }) }; + }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode malformed payload'); }, + async retryPaid() { retryCalls += 1; throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('malformed-signer-output'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-malformed-signer-output', + }); + + assert.equal(result.status, 'payment_unresolved'); + assert.equal(result.reasonCode, 'WALLET_SIGNATURE_AMBIGUOUS'); + assert.equal(signerCalls, 1); + assert.equal(retryCalls, 0); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT payment_payload_json FROM payment_attempts') + .payment_payload_json, null); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'unresolved'); + assert.equal(result.receipt.receipt.payment.state, 'unresolved'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice C RED: a durable nonce collision releases unsigned work without a second draw or signer call', async (t) => { + const challenge = paymentRequired('50000'); + let signerCalls = 0; + let retryCalls = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { retryCalls += 1; throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const seededIntent = context.intents.captureIntent({ + sessionId: session.id, + routeId: 'paid-infer', + method: 'POST', + requestUrl: `${SELLER}/paid/infer`, + headers: { accept: 'application/json', 'content-type': 'application/json' }, + bodyBytes: Buffer.from(canonicalJson({ label: 'nonce-seed' })), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-nonce-seed', + }); + context.store.transaction((token) => context.store.within(token, ({ db }) => { + db.prepare(`INSERT INTO payment_attempts + (id, intent_id, state, payment_required_projection_json, accepted_index, + quote_id, nonce, created_at, updated_at) + VALUES (?, ?, 'rejected', '{}', 0, ?, ?, ?, ?)`).run( + 'payment-nonce-seed', + seededIntent.id, + `sha256:${'ef'.repeat(32)}`, + `0x${'11'.repeat(32)}`, + NOW, + NOW, + ); + })); + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('nonce-collision'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-nonce-collision', + }); + + assert.equal(result.status, 'payment_failed'); + assert.equal(result.reasonCode, 'NONCE_COLLISION'); + assert.equal(signerCalls, 0); + assert.equal(retryCalls, 0); + const targetAttempt = context.store.readOne( + "SELECT * FROM payment_attempts WHERE id != 'payment-nonce-seed'", + ); + assert.equal(targetAttempt.state, 'rejected'); + assert.equal(targetAttempt.nonce, null); + assert.equal(targetAttempt.signing_claimed_at, null); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'released'); + assert.equal(context.store.readOne("SELECT state FROM spend_intents WHERE id != ?", [seededIntent.id]) + .state, 'terminal'); + assert.equal(result.receipt.receipt.payment.state, 'not_signed'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice C RED: revocation before the signing claim releases and denies the unsigned reservation', async (t) => { + const challenge = paymentRequired('50000'); + let signerCalls = 0; + let context; + context = setupKernel(t, { + faultInjector(point) { + if (point !== 'after_reservation_commit') return; + context.enrollments.revoke({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrollment.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('revoked-before-claim'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-revoked-before-claim', + }); + + assert.equal(result.status, 'payment_denied'); + assert.equal(result.reasonCode, 'AGENT_REVOKED'); + assert.equal(signerCalls, 0); + const attempt = context.store.readOne('SELECT * FROM payment_attempts'); + assert.equal(attempt.state, 'rejected'); + assert.equal(attempt.nonce, null); + assert.equal(attempt.signing_claimed_at, null); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'released'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(result.receipt.receipt.payment.state, 'not_signed'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice C RED: authorization-window expiry after reservation releases before signing', async (t) => { + const challenge = paymentRequired('50000'); + let currentTime = NOW; + let signerCalls = 0; + const context = setupKernel(t, { + clock: () => currentTime, + faultInjector(point) { + if (point === 'after_reservation_commit') { + currentTime = new Date(Date.parse(NOW) + 60_000).toISOString(); + } + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('authorization-window-expired'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-authorization-window-expired', + }); + + assert.equal(result.status, 'payment_denied'); + assert.equal(result.reasonCode, 'CHALLENGE_EXPIRED'); + assert.equal(signerCalls, 0); + const attempt = context.store.readOne('SELECT * FROM payment_attempts'); + assert.equal(attempt.state, 'rejected'); + assert.equal(attempt.nonce, null); + assert.equal(attempt.signing_claimed_at, null); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'released'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readOne('SELECT reason_code FROM buyer_outcomes').reason_code, + 'CHALLENGE_EXPIRED'); + assert.equal(result.receipt.receipt.payment.state, 'not_signed'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slices A-C RED: FIFO revocation queued after reservation wins the signing claim', async (t) => { + const challenge = paymentRequired('50000'); + let signerCalls = 0; + let revocationPromise; + let context; + context = setupKernel(t, { + faultInjector(point) { + if (point !== 'after_reservation_commit' || revocationPromise !== undefined) return; + revocationPromise = context.kernel.revokeAgent({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrollment.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('fifo-revoke-before-claim'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-fifo-revoke-before-claim', + }); + const revocation = await revocationPromise; + + assert.equal(revocation.enrollment.state, 'revoked'); + assert.equal(result.status, 'payment_denied'); + assert.equal(result.reasonCode, 'AGENT_REVOKED'); + assert.equal(signerCalls, 0); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'rejected'); + assert.equal(context.store.readOne('SELECT nonce FROM payment_attempts').nonce, null); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'released'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slices A-C RED: FIFO policy rotation queued after reservation blocks the signing claim', async (t) => { + const challenge = paymentRequired('50000'); + let signerCalls = 0; + let policyPromise; + let targetPolicyId; + let context; + context = setupKernel(t, { + faultInjector(point) { + if (point !== 'after_reservation_commit' || policyPromise !== undefined) return; + const nextPolicy = structuredClone(context.policy); + nextPolicy.sellers[0].autoApproveAtomic = '20000'; + policyPromise = context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }).then((applied) => { + targetPolicyId = applied.policyVersion.id; + return applied; + }); + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + await assert.rejects( + context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('fifo-policy-before-claim'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-fifo-policy-before-claim', + }), + (error) => error?.code === 'SESSION_POLICY_BLOCKED', + ); + await policyPromise; + + assert.equal(signerCalls, 0); + assert.equal(context.intents.getSession(session.id).state, 'policy_blocked'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'reserved'); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'reserved'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'reserved'); + assert.equal(context.store.readOne('SELECT nonce FROM payment_attempts').nonce, null); + + const blocked = context.intents.getSession(session.id); + const transitioned = await context.kernel.transitionSessionPolicy({ + sessionId: session.id, + targetPolicyVersionId: targetPolicyId, + expectedSessionHash: blocked.sessionHash, + }); + assert.equal(transitioned.previousSession.state, 'closed'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'rejected'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'released'); + assert.equal(context.store.readOne('SELECT reason_code FROM buyer_outcomes').reason_code, + 'POLICY_SUPERSEDED'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slices A-C RED: revocation at the unpaid-probe barrier blocks all later authority', async (t) => { + const challenge = paymentRequired('50000'); + const probeEntered = deferred(); + const releaseProbe = deferred(); + let signerCalls = 0; + let probes = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + probeEntered.resolve(); + await releaseProbe.promise; + return { kind: 'payment_required', paymentRequired: challenge }; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const execution = context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('revoke-at-probe'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-revoke-at-probe', + }); + await probeEntered.promise; + await context.kernel.revokeAgent({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrollment.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + releaseProbe.resolve(); + + await assert.rejects(execution, (error) => error?.code === 'AGENT_REVOKED'); + assert.equal(probes, 1); + assert.equal(signerCalls, 0); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'captured'); + assert.equal(context.store.readAll('SELECT * FROM policy_decisions').length, 0); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length, 0); +}); + +test('Slices A-D RED: a signing claim that wins revocation may finish only its persisted spend', async (t) => { + const challenge = paymentRequired('50000'); + const signerEntered = deferred(); + const releaseSigner = deferred(); + const paymentPayload = signedPaymentPayload(challenge); + let signerCalls = 0; + let retries = 0; + let probes = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + signerCalls += 1; + signerEntered.resolve(); + await releaseSigner.promise; + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + return { kind: 'payment_required', paymentRequired: challenge }; + }, + encodePayment() { return 'claim-won-payment-header'; }, + async retryPaid({ binding }) { + retries += 1; + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('claim-won-settlement', 'ascii')), + success: true, + transaction: `0x${'81'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 200, + body: Buffer.from('claim won'), + executionState: 'succeeded', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const execution = context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('claim-wins-revocation'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-claim-wins-revocation', + }); + await signerEntered.promise; + await context.kernel.revokeAgent({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrollment.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + }); + releaseSigner.resolve(); + const completed = await execution; + + assert.equal(completed.status, 'completed'); + assert.equal(signerCalls, 1); + assert.equal(retries, 1); + assert.equal(context.store.readOne('SELECT state FROM agent_enrollments').state, 'revoked'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'committed'); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'settled'); + assert.equal(context.receipts.assertParity(), true); + + await assert.rejects( + context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('post-revocation-new-work'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-post-revocation-new-work', + }), + (error) => error?.code === 'AGENT_REVOKED', + ); + assert.equal(probes, 1); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); +}); + +test('Slice C RED: a wallet blocker committed after reservation wins the final signing admission check', async (t) => { + const challenge = paymentRequired('50000'); + const stopAfterReservation = new Error('stop after seed reservation'); + let phase = 'seed'; + let seedIntentId; + let signerCalls = 0; + let context; + context = setupKernel(t, { + faultInjector(point, details) { + if (point !== 'after_reservation_commit') return; + if (phase === 'seed') { + seedIntentId = details.intentId; + throw stopAfterReservation; + } + context.store.transaction((token) => { + context.store.within(token, ({ db, appendEvent }) => { + db.prepare(`UPDATE payment_attempts + SET state = 'signing', nonce = ?, valid_after = '0', valid_before = ?, + signing_claimed_at = ?, updated_at = ? + WHERE intent_id = ? AND state = 'reserved'`).run( + `0x${'22'.repeat(32)}`, + String(Math.floor(Date.parse(NOW) / 1_000) + 60), + NOW, + NOW, + seedIntentId, + ); + appendEvent({ + entityType: 'payment_attempt', + entityId: seedIntentId, + eventType: 'payment.signing_claimed', + data: { + nonce: `0x${'22'.repeat(32)}`, + validAfter: '0', + validBefore: String(Math.floor(Date.parse(NOW) / 1_000) + 60), + signingClaimedAt: NOW, + }, + }); + }); + context.intents.transitionInTransaction(token, { + intentId: seedIntentId, + expectedState: 'reserved', + nextState: 'signing', + reasonCode: 'SIGNING_CLAIMED', + }); + context.budgets.holdUnresolvedInTransaction(token, { + intentId: seedIntentId, + reasonCode: 'WALLET_SIGNATURE_AMBIGUOUS', + }); + const heldAt = context.store.within(token, ({ db }) => db.prepare( + 'SELECT updated_at FROM budget_reservations WHERE intent_id = ?', + ).get(seedIntentId).updated_at); + context.store.within(token, ({ db, appendEvent }) => { + db.prepare(`UPDATE payment_attempts + SET state = 'unresolved', reason_code = 'WALLET_SIGNATURE_AMBIGUOUS', + updated_at = ? + WHERE intent_id = ? AND state = 'signing'`).run(heldAt, seedIntentId); + appendEvent({ + entityType: 'payment_attempt', + entityId: seedIntentId, + eventType: 'payment.unresolved', + data: { reasonCode: 'WALLET_SIGNATURE_AMBIGUOUS', recordedAt: heldAt }, + }); + }); + context.intents.transitionInTransaction(token, { + intentId: seedIntentId, + expectedState: 'signing', + nextState: 'unresolved', + reasonCode: 'WALLET_SIGNATURE_AMBIGUOUS', + }); + context.store.within(token, ({ db, appendEvent }) => { + db.prepare(`INSERT INTO buyer_outcomes + (intent_id, status, reason_code, revision, recorded_at) + VALUES (?, 'payment_unresolved', 'WALLET_SIGNATURE_AMBIGUOUS', 1, ?)`).run( + seedIntentId, + heldAt, + ); + appendEvent({ + entityType: 'buyer_outcome', + entityId: seedIntentId, + eventType: 'buyer_outcome.recorded', + data: { + status: 'payment_unresolved', + reasonCode: 'WALLET_SIGNATURE_AMBIGUOUS', + revision: 1, + recordedAt: heldAt, + }, + }); + }); + }); + context.receipts.issueForTerminal({ intentId: seedIntentId }); + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await assert.rejects( + context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('wallet-blocker-seed'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-wallet-blocker-seed', + }), + (error) => error === stopAfterReservation, + ); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', + [seedIntentId], + ).state, 'reserved'); + phase = 'target'; + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('wallet-blocker-target'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-wallet-blocker-target', + }); + + assert.equal(result.status, 'payment_denied'); + assert.equal(result.reasonCode, 'WALLET_RECOVERY_REQUIRED'); + assert.equal(signerCalls, 0); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', + [seedIntentId], + ).state, 'unresolved'); + const targetIntentId = context.store.readOne( + "SELECT intent_id FROM buyer_outcomes WHERE reason_code = 'WALLET_RECOVERY_REQUIRED'", + ).intent_id; + const targetAttempt = context.store.readOne( + 'SELECT * FROM payment_attempts WHERE intent_id = ?', + [targetIntentId], + ); + assert.equal(targetAttempt.state, 'rejected'); + assert.equal(targetAttempt.nonce, null); + assert.equal(context.store.readOne( + 'SELECT state FROM budget_reservations WHERE intent_id = ?', + [targetIntentId], + ).state, 'released'); + assert.equal(result.receipt.receipt.payment.state, 'not_signed'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice B RED: a changed approved challenge cancels the old authority and atomically replaces approval', async (t) => { + const originalChallenge = paymentRequired('50000'); + const changedChallenge = paymentRequired('60000'); + const base = Date.parse(NOW); + let tick = 0; + const clock = () => new Date(base + tick++).toISOString(); + let probes = 0; + let signerCalls = 0; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + clock, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + return { + kind: 'payment_required', + paymentRequired: probes === 1 ? originalChallenge : changedChallenge, + }; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('changed-approved-challenge'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-changed-approved-challenge', + }; + const pending = await context.kernel.execute(call); + const oldApproval = context.store.readOne('SELECT id, intent_id, intent_hash FROM approvals'); + await context.kernel.approvePending({ + approvalId: oldApproval.id, + expectedIntentHash: oldApproval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + + const changed = await context.kernel.execute(call); + + assert.equal(changed.requestId, pending.requestId); + assert.equal(changed.status, 'payment_denied'); + assert.equal(changed.reasonCode, 'APPROVAL_CHALLENGE_CHANGED'); + assert.equal(changed.receipt.receipt.approval.state, 'cancelled'); + assert.equal(signerCalls, 0); + assert.equal(probes, 2); + const approvals = context.store.readAll('SELECT * FROM approvals ORDER BY id'); + assert.equal(approvals.length, 2); + assert.equal(approvals[0].decision, 'cancelled'); + assert.equal(approvals[0].reason_code, 'APPROVAL_CHALLENGE_CHANGED'); + assert.equal(approvals[1].decision, 'pending'); + assert.notEqual(approvals[1].intent_id, oldApproval.intent_id); + const intents = context.store.readAll('SELECT * FROM spend_intents ORDER BY id'); + assert.equal(intents.length, 2); + assert.equal(intents[0].state, 'terminal'); + assert.equal(intents[0].retry_matchable, 0n); + assert.equal(intents[1].state, 'approval_pending'); + assert.equal(intents[1].retry_matchable, 1n); + assert.notEqual(intents[1].request_id, pending.requestId); + assert.notEqual(intents[1].correlation_id, call.correlationId); + assert.equal(context.store.readAll('SELECT * FROM policy_decisions').length, 2); + assert.equal(context.store.readAll('SELECT * FROM signed_receipts').length, 1); + assert.equal(context.receipts.assertParity(), true); + + const replacement = await context.kernel.execute({ + ...call, + correlationId: 'pi-call-changed-approved-challenge-replacement', + }); + assert.equal(replacement.requestId, intents[1].request_id); + assert.equal(replacement.status, 'payment_approval_required'); + assert.equal(probes, 2); + assert.equal(recoverKernelAuthority({ + store: context.store, + intents: context.intents, + budgets: context.budgets, + approvals: context.approvals, + receipts: context.receipts, + now: clock, + }).ready, true); +}); + +test('Slice B RED: a missing challenge on approved retry cancels old authority without executing', async (t) => { + const challenge = paymentRequired('50000'); + let probes = 0; + let signerCalls = 0; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + if (probes === 1) return { kind: 'payment_required', paymentRequired: challenge }; + return { kind: 'response', status: 200, body: Buffer.from('challenge disappeared') }; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('approved-missing-challenge'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-approved-missing-challenge', + }; + const pending = await context.kernel.execute(call); + const approval = context.store.readOne('SELECT id, intent_hash FROM approvals'); + await context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + + const cancelled = await context.kernel.execute(call); + + assert.equal(cancelled.requestId, pending.requestId); + assert.equal(cancelled.status, 'payment_denied'); + assert.equal(cancelled.reasonCode, 'APPROVAL_CHALLENGE_CHANGED'); + assert.equal(signerCalls, 0); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'cancelled'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.readAll('SELECT * FROM approvals').length, 1); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(cancelled.receipt.receipt.outcome.reasonCode, 'APPROVAL_CHALLENGE_CHANGED'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice B RED: an approved-retry probe failure terminalizes with its signed receipt', async (t) => { + const challenge = paymentRequired('50000'); + let clockValue = NOW; + let probes = 0; + let signerCalls = 0; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + clock: () => clockValue, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + if (probes === 1) return { kind: 'payment_required', paymentRequired: challenge }; + const error = new Error('transient unpaid timeout'); + error.code = 'UNPAID_FETCH_FAILED'; + throw error; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('approved-probe-timeout'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-approved-probe-timeout', + }; + const pending = await context.kernel.execute(call); + const approval = context.store.readOne('SELECT id, intent_hash, expires_at FROM approvals'); + await context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + const eventsBefore = context.store.events().length; + + const failedProbe = await context.kernel.execute(call); + + assert.equal(failedProbe.requestId, pending.requestId); + assert.equal(failedProbe.status, 'upstream_failed'); + assert.equal(failedProbe.reasonCode, 'UPSTREAM_TRANSPORT_FAILURE'); + assert.equal(failedProbe.receipt.receipt.outcome.status, 'upstream_failed'); + assert.equal(failedProbe.receipt.receipt.outcome.reasonCode, 'UPSTREAM_TRANSPORT_FAILURE'); + assert.equal(failedProbe.receipt.receipt.approval.state, 'approved'); + assert.equal(failedProbe.receipt.receipt.payment.state, 'none'); + assert.equal(failedProbe.receipt.receipt.budget, null); + assert.deepEqual(failedProbe.receipt.receipt.execution, { + state: 'unknown', + httpStatus: null, + responseHash: null, + }); + assert.equal(signerCalls, 0); + assert.ok(context.store.events().length > eventsBefore); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'approved'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.deepEqual({ ...context.store.readOne( + 'SELECT status, reason_code FROM buyer_outcomes', + ) }, { + status: 'upstream_failed', + reason_code: 'UPSTREAM_TRANSPORT_FAILURE', + }); + assert.equal(context.receipts.assertParity(), true); + + const terminalEvents = context.store.events().length; + const replay = await context.kernel.execute(call); + assert.equal(replay.requestId, failedProbe.requestId); + assert.equal(replay.receipt.receiptHash, failedProbe.receipt.receiptHash); + assert.equal(probes, 2); + assert.equal(context.store.events().length, terminalEvents); + + clockValue = new Date(Date.parse(approval.expires_at) + 1).toISOString(); + assert.deepEqual(await context.kernel.expireDueApprovals({ limit: 100 }), []); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'approved'); + assert.equal(context.store.events().length, terminalEvents); + + const closed = await context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + }); + assert.equal(closed.closedSession.state, 'closed'); + assert.deepEqual(closed.terminalReceipts, []); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'approved'); +}); + +test('Slice B RED: approval expiry wins every paused approved-probe continuation', async (t) => { + const challenge = paymentRequired('50000'); + const scenarios = [ + Object.freeze({ + name: 'transport failure', + release(gate) { + const error = new Error('late unpaid transport failure'); + error.code = 'UNPAID_FETCH_FAILED'; + gate.reject(error); + }, + }), + Object.freeze({ + name: 'changed ordinary response', + release(gate) { + gate.resolve({ kind: 'response', status: 200, body: Buffer.from('late ordinary response') }); + }, + }), + Object.freeze({ + name: 'still-valid payment requirement', + release(gate) { + gate.resolve({ kind: 'payment_required', paymentRequired: challenge }); + }, + }), + ]; + + for (const [index, scenario] of scenarios.entries()) { + await t.test(scenario.name, async (scenarioTest) => { + let clockValue = NOW; + let probes = 0; + let signerCalls = 0; + let paidRetries = 0; + const probeEntered = deferred(); + const probeRelease = deferred(); + const context = setupKernel(scenarioTest, { + autoApproveAtomic: '10000', + clock: () => clockValue, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { + provider: 'deterministic', + walletId: 'wallet-1', + address: WALLET, + network: NETWORK, + }; + }, + async signX402Exact() { + signerCalls += 1; + throw new Error('must not sign after expiry wins'); + }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + if (probes === 1) { + return { kind: 'payment_required', paymentRequired: challenge }; + } + probeEntered.resolve(); + return await probeRelease.promise; + }, + encodePayment() { throw new Error('must not encode after expiry wins'); }, + async retryPaid() { + paidRetries += 1; + throw new Error('must not retry after expiry wins'); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(`expiry-probe-race-${index}`), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-expiry-probe-race-${index}`, + }; + const pending = await context.kernel.execute(call); + const approval = context.store.readOne( + 'SELECT id, intent_id, intent_hash, expires_at FROM approvals', + ); + await context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + + const racingExecution = context.kernel.execute(call); + await probeEntered.promise; + clockValue = approval.expires_at; + const expired = await context.kernel.expireDueApprovals({ limit: 100 }); + assert.equal(expired.length, 1); + const expiryWinner = expired[0]; + assert.equal(expiryWinner.intentId, approval.intent_id); + assert.equal(expiryWinner.requestId, pending.requestId); + assert.equal(expiryWinner.status, 'payment_denied'); + assert.equal(expiryWinner.reasonCode, 'APPROVAL_EXPIRED'); + assert.equal(expiryWinner.receipt.receipt.approval.state, 'expired'); + const eventsAfterExpiry = context.store.events().length; + + scenario.release(probeRelease); + const raced = await racingExecution; + + assert.deepEqual(Object.keys(raced), ['requestId', 'status', 'reasonCode', 'receipt']); + assert.equal(raced.requestId, expiryWinner.requestId); + assert.equal(raced.status, expiryWinner.status); + assert.equal(raced.reasonCode, expiryWinner.reasonCode); + assert.equal(raced.receipt.receiptHash, expiryWinner.receipt.receiptHash); + assert.equal(context.store.events().length, eventsAfterExpiry); + + const replay = await context.kernel.execute(call); + assert.equal(replay.requestId, expiryWinner.requestId); + assert.equal(replay.status, expiryWinner.status); + assert.equal(replay.reasonCode, expiryWinner.reasonCode); + assert.equal(replay.receipt.receiptHash, expiryWinner.receipt.receiptHash); + assert.equal(context.store.events().length, eventsAfterExpiry); + assert.equal(probes, 2); + assert.equal(signerCalls, 0); + assert.equal(paidRetries, 0); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'expired'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.deepEqual({ ...context.store.readOne( + 'SELECT status, reason_code FROM buyer_outcomes', + ) }, { + status: 'payment_denied', + reason_code: 'APPROVAL_EXPIRED', + }); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.readAll('SELECT * FROM approvals').length, 1); + assert.equal(context.store.readAll('SELECT * FROM buyer_outcomes').length, 1); + assert.equal(context.store.readAll('SELECT * FROM signed_receipts').length, 1); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length, 0); + assert.equal(context.receipts.assertParity(), true); + }); + } +}); + +test('Slices A-B RED: policy transition ignores terminal approved history', async (t) => { + const challenge = paymentRequired('50000'); + let probes = 0; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + transport: Object.freeze({ + async probe() { + probes += 1; + if (probes === 1) return { kind: 'payment_required', paymentRequired: challenge }; + const error = new Error('unpaid connection reset'); + error.code = 'UNPAID_FETCH_FAILED'; + throw error; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('approved-history-transition'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-approved-history-transition', + }; + await context.kernel.execute(call); + const approval = context.store.readOne('SELECT id, intent_hash FROM approvals'); + await context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + const terminal = await context.kernel.execute(call); + assert.equal(terminal.status, 'upstream_failed'); + assert.equal(terminal.reasonCode, 'UPSTREAM_TRANSPORT_FAILURE'); + + const nextPolicy = structuredClone(context.policy); + nextPolicy.sellers[0].autoApproveAtomic = '20000'; + const applied = await context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }); + const blocked = context.intents.getSession(session.id); + assert.equal(blocked.state, 'policy_blocked'); + + const transitioned = await context.kernel.transitionSessionPolicy({ + sessionId: session.id, + targetPolicyVersionId: applied.policyVersion.id, + expectedSessionHash: blocked.sessionHash, + }); + + assert.equal(transitioned.previousSession.state, 'closed'); + assert.equal(transitioned.replacementSession.state, 'open'); + assert.deepEqual(transitioned.terminalReceipts, []); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'approved'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice B RED: freshly allowed or denied changed challenges create no replacement authority', async (t) => { + for (const scenario of [ + { name: 'allow', freshAmount: '5000' }, + { name: 'deny', freshAmount: '1000001' }, + ]) { + await t.test(scenario.name, async (st) => { + const initialChallenge = paymentRequired('50000'); + const freshChallenge = paymentRequired(scenario.freshAmount); + let probes = 0; + let signerCalls = 0; + const context = setupKernel(st, { + autoApproveAtomic: '10000', + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + return { + kind: 'payment_required', + paymentRequired: probes === 1 ? initialChallenge : freshChallenge, + }; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(`changed-${scenario.name}`), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-changed-${scenario.name}`, + }; + await context.kernel.execute(call); + const approval = context.store.readOne('SELECT id, intent_hash FROM approvals'); + await context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + + const cancelled = await context.kernel.execute(call); + + assert.equal(cancelled.status, 'payment_denied'); + assert.equal(cancelled.reasonCode, 'APPROVAL_CHALLENGE_CHANGED'); + assert.equal(Object.hasOwn(cancelled, 'replacementRequestId'), false); + assert.equal(signerCalls, 0); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.readAll('SELECT * FROM approvals').length, 1); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'cancelled'); + assert.equal(context.receipts.assertParity(), true); + }); + } +}); + +test('Slice B RED: an invalid fresh challenge cancels approved authority without replacement', async (t) => { + const challenge = paymentRequired('50000'); + let probes = 0; + let signerCalls = 0; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + if (probes === 1) return { kind: 'payment_required', paymentRequired: challenge }; + const error = new Error('malformed fresh challenge'); + error.code = 'PAYMENT_REQUIRED_DECODE'; + throw error; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('invalid-fresh-challenge'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-invalid-fresh-challenge', + }; + await context.kernel.execute(call); + const approval = context.store.readOne('SELECT id, intent_hash FROM approvals'); + await context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + + const result = await context.kernel.execute(call); + + assert.equal(result.status, 'payment_denied'); + assert.equal(result.reasonCode, 'APPROVAL_CHALLENGE_CHANGED'); + assert.equal(Object.hasOwn(result, 'replacementRequestId'), false); + assert.equal(signerCalls, 0); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.readAll('SELECT * FROM approvals').length, 1); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'cancelled'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice A: receipt failure closes FIFO admission before queued agent and operator writes', async (t) => { + const challenge = paymentRequired('50000'); + const receiptFailure = new Error('fixture receipt signer unavailable'); + const queued = []; + let queueAfterDomainCommit = () => {}; + let context; + let probes = 0; + context = setupKernel(t, { + autoApproveAtomic: '10000', + wrapReceipts(repository) { + return Object.freeze({ + ...repository, + issueForTerminal(input) { + queueAfterDomainCommit(); + throw receiptFailure; + }, + }); + }, + transport: Object.freeze({ + async probe(request) { + probes += 1; + return request.bodyBytes.toString().includes('fail-stop-pending') + ? { kind: 'payment_required', paymentRequired: challenge } + : { kind: 'response', status: 200, body: Buffer.from('ok') }; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('fail-stop-pending'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-fail-stop-pending', + }); + const approval = context.store.readOne('SELECT id, intent_hash FROM approvals'); + queueAfterDomainCommit = () => { + queueAfterDomainCommit = () => {}; + queued.push(context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('fail-stop-queued-agent'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-fail-stop-queued-agent', + })); + queued.push(context.kernel.applyPolicy({ + document: context.policy, + expectedPolicyHash: sha256(canonicalJson(context.policy)), + })); + queued.push(context.kernel.revokeAgent({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + expectedEnrollmentHash: context.enrollment.enrollmentHash, + operatorIdHash: OPERATOR_HASH, + })); + queued.push(context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + })); + queued.push(context.kernel.denyPending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + reasonCode: 'OPERATOR_DENIED', + })); + queued.push(context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + })); + queued.push(context.kernel.transitionSessionPolicy({ + sessionId: session.id, + targetPolicyVersionId: 'policy-queued', + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + })); + }; + + await assert.rejects( + context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('fail-stop-terminal'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-fail-stop-terminal', + }), + (error) => error === receiptFailure, + ); + assert.equal(queued.length, 7); + for (const operation of queued) { + await assert.rejects(operation, (error) => error?.code === 'RECEIPT_PARITY_REQUIRED'); + } + assert.equal(probes, 2); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 2); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'pending'); + assert.equal(context.store.readOne('SELECT state FROM agent_enrollments').state, 'active'); + assert.equal(context.store.readAll('SELECT * FROM policy_versions').length, 1); + assert.equal(context.store.readAll('SELECT * FROM buyer_outcomes').length, 1); + assert.equal(context.store.readAll('SELECT * FROM signed_receipts').length, 0); +}); + +test('Slice D: valid settlement with unknown body delivery commits spend and opens reconciliation', async (t) => { + const challenge = paymentRequired('50000'); + const paymentPayload = signedPaymentPayload(challenge); + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { return { paymentPayload }; }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return 'fixture-payment-header'; }, + async retryPaid({ binding }) { + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('fixture-payment-response', 'ascii')), + success: true, + transaction: `0x${'dd'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 200, + body: null, + executionState: 'unknown', + deliveryReason: 'RESPONSE_BODY_TIMEOUT', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('execution-unknown'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-execution-unknown', + }); + + assert.equal(result.status, 'execution_unknown'); + assert.equal(result.reasonCode, 'PAID_RESPONSE_AMBIGUOUS'); + assert.equal(result.body, null); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'settled'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'committed'); + assert.equal(context.store.readOne('SELECT state FROM execution_outcomes').state, 'unknown'); + assert.equal(context.store.readOne('SELECT state FROM execution_resolutions') + .state, 'reconciliation_required'); + assert.equal(context.store.readAll('SELECT * FROM refunds').length, 0); + const resolutionEvent = context.store.events().find( + (event) => event.entity_type === 'execution_resolution' + && event.event_type === 'execution_resolution.opened', + ); + const intentId = context.store.readOne('SELECT id FROM spend_intents').id; + assert.deepEqual({ + entityId: resolutionEvent?.entity_id, + data: resolutionEvent === undefined ? null : JSON.parse(resolutionEvent.data_json), + }, { + entityId: intentId, + data: { + intentId, + state: 'reconciliation_required', + reasonCode: 'PAID_RESPONSE_AMBIGUOUS', + blocksWallet: true, + openedAt: NOW, + }, + }); + assert.equal(context.store.events().filter( + (event) => event.event_type === 'refund.opened', + ).length, 0); + assert.equal(context.budgets.snapshot({ + sessionId: session.id, + sellerOrigin: SELLER, + at: NOW, + }).walletBlocked, true); + assert.equal(result.receipt.receipt.outcome.status, 'execution_unknown'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice D: settlement-reported failure preserves unresolved exposure and never retries twice', async (t) => { + const challenge = paymentRequired('50000'); + const paymentPayload = signedPaymentPayload(challenge); + let signerCalls = 0; + let retryCalls = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; return { paymentPayload }; }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return 'fixture-payment-header'; }, + async retryPaid() { + retryCalls += 1; + return Object.freeze({ + kind: 'paid_response_ambiguous', + reasonCode: 'SETTLEMENT_REPORTED_FAILURE', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('settlement-false'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-settlement-false', + }; + + const first = await context.kernel.execute(call); + const second = await context.kernel.execute(call); + + assert.equal(first.status, 'payment_unresolved'); + assert.equal(first.reasonCode, 'SETTLEMENT_EVIDENCE_INVALID'); + assert.equal(second.receipt.receiptHash, first.receipt.receiptHash); + assert.equal(signerCalls, 1); + assert.equal(retryCalls, 1); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'unresolved'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'unresolved'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice C-D: concurrent exact followers at signing, signed, and retrying never duplicate money work', async (t) => { + const challenge = paymentRequired('50000'); + const paymentPayload = signedPaymentPayload(challenge); + const signerStarted = deferred(); + const signerRelease = deferred(); + const retryStarted = deferred(); + const retryRelease = deferred(); + const signedFollowers = []; + let signerCalls = 0; + let retryCalls = 0; + let context; + let call; + context = setupKernel(t, { + faultInjector(point) { + if (point !== 'after_signed_payment_commit') return; + signedFollowers.push(context.kernel.execute(call), context.kernel.execute(call)); + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + signerCalls += 1; + signerStarted.resolve(); + await signerRelease.promise; + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return 'fixture-payment-header'; }, + async retryPaid({ binding }) { + retryCalls += 1; + retryStarted.resolve(); + await retryRelease.promise; + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('fixture-payment-response', 'ascii')), + success: true, + transaction: `0x${'ee'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 200, + body: Buffer.from('concurrency-ok'), + executionState: 'succeeded', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('barriered-followers'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-barriered-followers', + }; + + const leader = context.kernel.execute(call); + await signerStarted.promise; + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'signing'); + const signingFollowers = await Promise.all([ + context.kernel.execute(call), + context.kernel.execute(call), + ]); + assert.deepEqual(signingFollowers.map((result) => result.status), [ + 'request_in_flight', + 'request_in_flight', + ]); + signerRelease.resolve(); + await retryStarted.promise; + assert.equal(signedFollowers.length, 2); + const signedResults = await Promise.all(signedFollowers); + assert.deepEqual(signedResults.map((result) => result.status), [ + 'request_in_flight', + 'request_in_flight', + ]); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'retrying'); + const retryingFollowers = await Promise.all([ + context.kernel.execute(call), + context.kernel.execute(call), + ]); + assert.deepEqual(retryingFollowers.map((result) => result.status), [ + 'request_in_flight', + 'request_in_flight', + ]); + retryRelease.resolve(); + const completed = await leader; + + assert.equal(completed.status, 'completed'); + assert.equal(signerCalls, 1); + assert.equal(retryCalls, 1); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.readAll('SELECT * FROM approvals').length, 0); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 1); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length, 1); + for (const follower of [...signingFollowers, ...signedResults, ...retryingFollowers]) { + assert.equal(follower.requestId, completed.requestId); + } + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice B RED: malformed and oversized payment challenges deny without spend authority', async (t) => { + const scenarios = [ + { + name: 'oversized header', + reasonCode: 'PAYMENT_CHALLENGE_OVERSIZED', + async probe() { + const error = new Error('PAYMENT-REQUIRED exceeds its byte ceiling'); + error.code = 'PAYMENT_REQUIRED_TOO_LARGE'; + throw error; + }, + }, + { + name: 'malformed header', + reasonCode: 'PAYMENT_CHALLENGE_MALFORMED', + async probe() { + const error = new Error('PAYMENT-REQUIRED is malformed'); + error.code = 'PAYMENT_REQUIRED_MALFORMED'; + throw error; + }, + }, + { + name: 'malformed decoded value', + reasonCode: 'PAYMENT_CHALLENGE_MALFORMED', + async probe() { return { kind: 'payment_required', paymentRequired: {} }; }, + }, + ]; + for (const scenario of scenarios) { + await t.test(scenario.name, async (subtest) => { + let signerCalls = 0; + const context = setupKernel(subtest, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { + provider: 'deterministic', + walletId: 'wallet-1', + address: WALLET, + network: NETWORK, + }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + probe: scenario.probe, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + + const result = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(`invalid-challenge-${scenario.name}`), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-invalid-challenge-${scenario.name.replaceAll(' ', '-')}`, + }); + + assert.equal(result.status, 'payment_denied'); + assert.equal(result.reasonCode, scenario.reasonCode); + assert.equal(signerCalls, 0); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readAll('SELECT * FROM policy_decisions').length, 0); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length, 0); + assert.equal(result.receipt.receipt.payment.state, 'none'); + assert.equal(context.receipts.assertParity(), true); + }); + } +}); + +test('Slice B RED: an exact retry at approval expiry terminalizes before a fresh probe', async (t) => { + const challenge = paymentRequired('50000'); + let currentTime = NOW; + let probes = 0; + let signerCalls = 0; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + clock: () => currentTime, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + return { kind: 'payment_required', paymentRequired: challenge }; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('approval-expired-retry'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-approval-expired-retry', + }; + const pending = await context.kernel.execute(call); + const approval = context.store.readOne('SELECT id, intent_hash, expires_at FROM approvals'); + await context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + currentTime = approval.expires_at; + + const expired = await context.kernel.execute(call); + + assert.equal(expired.requestId, pending.requestId); + assert.equal(expired.status, 'payment_denied'); + assert.equal(expired.reasonCode, 'APPROVAL_EXPIRED'); + assert.equal(probes, 1); + assert.equal(signerCalls, 0); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'expired'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readAll('SELECT * FROM budget_reservations').length, 0); + assert.equal(expired.receipt.receipt.approval.state, 'expired'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice A RED: policy transition cancels pending authority and opens one receipt-backed replacement session', async (t) => { + const challenge = paymentRequired('50000'); + const context = setupKernel(t, { + autoApproveAtomic: '10000', + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('policy-transition-pending'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-policy-transition-pending', + }); + const nextPolicy = structuredClone(context.policy); + nextPolicy.sellers[0].autoApproveAtomic = '20000'; + const applied = await context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }); + const blocked = context.intents.getSession(session.id); + assert.equal(blocked.state, 'policy_blocked'); + + const transitioned = await context.kernel.transitionSessionPolicy({ + sessionId: session.id, + targetPolicyVersionId: applied.policyVersion.id, + expectedSessionHash: blocked.sessionHash, + }); + + assert.equal(transitioned.previousSession.state, 'closed'); + assert.equal(transitioned.replacementSession.state, 'open'); + assert.equal(transitioned.replacementSession.policyVersionId, applied.policyVersion.id); + assert.notEqual(transitioned.replacementSession.id, session.id); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'cancelled'); + assert.equal(context.store.readOne('SELECT reason_code FROM approvals') + .reason_code, 'POLICY_SUPERSEDED'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readOne('SELECT reason_code FROM buyer_outcomes') + .reason_code, 'POLICY_SUPERSEDED'); + assert.equal(transitioned.terminalReceipts.length, 1); + assert.equal(transitioned.terminalReceipts[0].receipt.receipt.outcome.reasonCode, + 'POLICY_SUPERSEDED'); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice A RED: guarded close latches the old Kernel and a fresh instance may restart', async (t) => { + const challenge = paymentRequired('50000'); + const context = setupKernel(t, { + autoApproveAtomic: '10000', + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('session-close-pending'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-session-close-pending', + }); + + const closed = await context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + }); + + assert.equal(closed.closedSession.state, 'closed'); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'cancelled'); + assert.equal(context.store.readOne('SELECT reason_code FROM approvals').reason_code, 'SESSION_CLOSED'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readOne('SELECT reason_code FROM buyer_outcomes') + .reason_code, 'SESSION_CLOSED'); + assert.equal(closed.terminalReceipts.length, 1); + assert.equal(context.store.readOne('SELECT state FROM agent_enrollments').state, 'active'); + assert.equal(context.receipts.assertParity(), true); + + await assert.rejects( + context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }), + (error) => error?.code === 'AGENT_SESSION_UNAVAILABLE', + ); + await assert.rejects( + context.kernel.applyPolicy({ + document: context.policy, + expectedPolicyHash: sha256(canonicalJson(context.policy)), + }), + (error) => error?.code === 'AGENT_SESSION_UNAVAILABLE', + ); + + const restarted = await context.createKernel().openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + assert.notEqual(restarted.id, session.id); + assert.equal(restarted.state, 'open'); +}); + +test('Slice A RED: wallet rotation requires a fresh Kernel configured for the new wallet', async (t) => { + let configuredAddress = WALLET; + const oldAdapter = Object.freeze({ + async walletIdentity() { + return { + provider: 'deterministic', + walletId: 'wallet-old', + address: configuredAddress, + network: NETWORK, + }; + }, + async signX402Exact() { throw new Error('must not sign'); }, + }); + const context = setupKernel(t, { walletAdapter: oldAdapter }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + }); + const rotatedPolicy = structuredClone(context.policy); + rotatedPolicy.wallet = ROTATED_WALLET; + const request = { + document: rotatedPolicy, + expectedPolicyHash: sha256(canonicalJson(rotatedPolicy)), + }; + + await assert.rejects( + context.kernel.applyPolicy(request), + (error) => error?.code === 'AGENT_SESSION_UNAVAILABLE', + ); + const freshOldWalletKernel = context.createKernel({ nextWalletAdapter: oldAdapter }); + await assert.rejects( + freshOldWalletKernel.applyPolicy(request), + (error) => error?.code === 'WALLET_ROTATION_REQUIRES_OFFLINE_RESTART', + ); + assert.equal(configuredAddress, WALLET); + + const rotatedAdapter = Object.freeze({ + async walletIdentity() { + return { + provider: 'deterministic', + walletId: 'wallet-rotated', + address: ROTATED_WALLET, + network: NETWORK, + }; + }, + async signX402Exact() { throw new Error('must not sign'); }, + }); + const freshRotatedKernel = context.createKernel({ nextWalletAdapter: rotatedAdapter }); + const applied = await freshRotatedKernel.applyPolicy(request); + const restarted = await freshRotatedKernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: ROTATED_WALLET, + policyVersionId: applied.policyVersion.id, + }); + assert.equal(restarted.walletAddress, ROTATED_WALLET); + assert.equal(configuredAddress, WALLET); +}); + +test('Slice A RED: stale session confirmations roll back every pending-authority write', async (t) => { + const challenge = paymentRequired('50000'); + const context = setupKernel(t, { + autoApproveAtomic: '10000', + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('stale-session-confirmation'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-stale-session-confirmation', + }); + const nextPolicy = structuredClone(context.policy); + nextPolicy.sellers[0].autoApproveAtomic = '20000'; + const applied = await context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }); + const eventsBefore = context.store.events().length; + const staleHash = `sha256:${'00'.repeat(32)}`; + + await assert.rejects( + context.kernel.transitionSessionPolicy({ + sessionId: session.id, + targetPolicyVersionId: applied.policyVersion.id, + expectedSessionHash: staleHash, + }), + (error) => error?.code === 'SESSION_CONFIRMATION_STALE', + ); + await assert.rejects( + context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: staleHash, + }), + (error) => error?.code === 'SESSION_CONFIRMATION_STALE', + ); + + assert.equal(context.store.events().length, eventsBefore); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'pending'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'approval_pending'); + assert.equal(context.store.readAll('SELECT * FROM buyer_outcomes').length, 0); + assert.equal(context.store.readAll('SELECT * FROM signed_receipts').length, 0); + assert.equal(context.intents.getSession(session.id).state, 'policy_blocked'); +}); + +test('Slice A RED: session transition and close release only definitely unsigned reservations', async (t) => { + for (const command of ['close', 'transition']) { + await t.test(command, async (st) => { + const challenge = paymentRequired('50000'); + const crash = new Error(`stop after reservation for ${command}`); + const context = setupKernel(st, { + faultInjector(point) { + if (point === 'after_reservation_commit') throw crash; + }, + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await assert.rejects(context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(`unsigned-${command}`), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-unsigned-${command}`, + }), (error) => error === crash); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'reserved'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'reserved'); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'reserved'); + + let result; + let reasonCode; + if (command === 'close') { + reasonCode = 'SESSION_CLOSED'; + result = await context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + }); + assert.equal(result.closedSession.state, 'closed'); + } else { + reasonCode = 'POLICY_SUPERSEDED'; + const nextPolicy = structuredClone(context.policy); + nextPolicy.sellers[0].autoApproveAtomic = '20000'; + const applied = await context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }); + const blocked = context.intents.getSession(session.id); + result = await context.kernel.transitionSessionPolicy({ + sessionId: session.id, + targetPolicyVersionId: applied.policyVersion.id, + expectedSessionHash: blocked.sessionHash, + }); + assert.equal(result.previousSession.state, 'closed'); + assert.equal(result.replacementSession.state, 'open'); + } + + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'released'); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'rejected'); + assert.equal(context.store.readOne('SELECT reason_code FROM buyer_outcomes').reason_code, + reasonCode); + assert.equal(result.terminalReceipts.length, 1); + assert.equal(result.terminalReceipts[0].receipt.receipt.outcome.reasonCode, reasonCode); + assert.equal(context.receipts.assertParity(), true); + }); + } +}); + +test('Slices A-B RED: session aggregates preserve a faulted authoritative deny decision', async (t) => { + for (const command of ['close', 'transition']) { + await t.test(command, async (st) => { + const challenge = paymentRequired('1000001'); + const crash = new Error(`stop after deny decision for ${command}`); + const context = setupKernel(st, { + faultInjector(point) { + if (point === 'after_challenge_commit') throw crash; + }, + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await assert.rejects(context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(`faulted-deny-${command}`), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-faulted-deny-${command}`, + }), (error) => error === crash); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'challenged'); + const decision = context.store.readOne('SELECT decision, reason_code FROM policy_decisions'); + assert.equal(decision.decision, 'deny'); + assert.equal(decision.reason_code, 'PER_REQUEST_LIMIT'); + + let result; + if (command === 'close') { + result = await context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + }); + assert.equal(result.closedSession.state, 'closed'); + } else { + const nextPolicy = structuredClone(context.policy); + nextPolicy.sellers[0].autoApproveAtomic = '20000'; + const applied = await context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }); + const blocked = context.intents.getSession(session.id); + result = await context.kernel.transitionSessionPolicy({ + sessionId: session.id, + targetPolicyVersionId: applied.policyVersion.id, + expectedSessionHash: blocked.sessionHash, + }); + assert.equal(result.previousSession.state, 'closed'); + } + + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + const outcome = context.store.readOne('SELECT status, reason_code FROM buyer_outcomes'); + assert.equal(outcome.status, 'payment_denied'); + assert.equal(outcome.reason_code, 'PER_REQUEST_LIMIT'); + assert.equal(result.terminalReceipts.length, 1); + assert.equal(result.terminalReceipts[0].receipt.receipt.policy.decision, 'deny'); + assert.equal(result.terminalReceipts[0].receipt.receipt.outcome.reasonCode, + 'PER_REQUEST_LIMIT'); + assert.equal(context.receipts.assertParity(), true); + }); + } +}); + +test('Slice A RED: signing authority blocks both close and policy transition without mutation', async (t) => { + const challenge = paymentRequired('50000'); + const crash = new Error('stop after signing claim'); + const context = setupKernel(t, { + faultInjector(point) { + if (point === 'after_signing_claim_commit') throw crash; + }, + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await assert.rejects(context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('session-signing-blocker'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-session-signing-blocker', + }), (error) => error === crash); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'signing'); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, 'signing'); + let eventsBefore = context.store.events().length; + + await assert.rejects( + context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + }), + (error) => error?.code === 'SESSION_CLOSE_BLOCKED', + ); + assert.equal(context.store.events().length, eventsBefore); + + const nextPolicy = structuredClone(context.policy); + nextPolicy.sellers[0].autoApproveAtomic = '20000'; + const applied = await context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }); + const blocked = context.intents.getSession(session.id); + eventsBefore = context.store.events().length; + await assert.rejects( + context.kernel.transitionSessionPolicy({ + sessionId: session.id, + targetPolicyVersionId: applied.policyVersion.id, + expectedSessionHash: blocked.sessionHash, + }), + (error) => error?.code === 'SESSION_TRANSITION_BLOCKED', + ); + assert.equal(context.store.events().length, eventsBefore); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'signing'); + assert.equal(context.store.readOne('SELECT state FROM budget_reservations').state, 'reserved'); + assert.equal(context.store.readAll('SELECT * FROM buyer_outcomes').length, 0); +}); + +test('Slice A RED: all signed, ambiguous, and resolution states block session mutation', async (t) => { + for (const scenario of [ + { name: 'signed', faultPoint: 'after_signed_payment_commit', paymentState: 'signed' }, + { name: 'retrying', faultPoint: 'after_retry_claim_commit', paymentState: 'retrying' }, + { name: 'unresolved', signerFailure: true, paymentState: 'unresolved' }, + { name: 'refund-pending', executionState: 'failed', paymentState: 'settled' }, + { name: 'execution-unknown', executionState: 'unknown', paymentState: 'settled' }, + ]) { + await t.test(scenario.name, async (st) => { + const challenge = paymentRequired('50000'); + const paymentPayload = signedPaymentPayload(challenge); + const crash = new Error(`stop at ${scenario.name}`); + const context = setupKernel(st, { + faultInjector(point) { + if (point === scenario.faultPoint) throw crash; + }, + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + if (scenario.signerFailure) throw new Error('ambiguous signer failure'); + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { return `session-blocker-${scenario.name}`; }, + async retryPaid({ binding }) { + const executionState = scenario.executionState ?? 'succeeded'; + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from(`session-blocker-${scenario.name}`, 'ascii')), + success: true, + transaction: `0x${'82'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: executionState === 'failed' ? 500 : 200, + body: executionState === 'unknown' ? null : Buffer.from(scenario.name), + executionState, + ...(executionState === 'failed' ? { deliveryReason: 'HTTP_STATUS_FAILURE' } : {}), + ...(executionState === 'unknown' ? { deliveryReason: 'BODY_DELIVERY_UNKNOWN' } : {}), + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const execution = context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest(`session-blocker-${scenario.name}`), + purposeLabel: 'skill.invoke', + correlationId: `pi-call-session-blocker-${scenario.name}`, + }); + if (scenario.faultPoint) { + await assert.rejects(execution, (error) => error === crash); + } else { + await execution; + } + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, + scenario.paymentState); + let eventsBefore = context.store.events().length; + + await assert.rejects( + context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + }), + (error) => error?.code === (scenario.faultPoint + ? 'RECEIPT_PARITY_REQUIRED' + : 'SESSION_CLOSE_BLOCKED'), + ); + assert.equal(context.store.events().length, eventsBefore); + + const nextPolicy = structuredClone(context.policy); + nextPolicy.sellers[0].autoApproveAtomic = '20000'; + if (scenario.faultPoint) { + await assert.rejects( + context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }), + (error) => error?.code === 'RECEIPT_PARITY_REQUIRED', + ); + assert.equal(context.store.events().length, eventsBefore); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, + scenario.paymentState); + return; + } + const applied = await context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }); + const blocked = context.intents.getSession(session.id); + eventsBefore = context.store.events().length; + await assert.rejects( + context.kernel.transitionSessionPolicy({ + sessionId: session.id, + targetPolicyVersionId: applied.policyVersion.id, + expectedSessionHash: blocked.sessionHash, + }), + (error) => error?.code === 'SESSION_TRANSITION_BLOCKED', + ); + assert.equal(context.store.events().length, eventsBefore); + assert.equal(context.store.readOne('SELECT state FROM payment_attempts').state, + scenario.paymentState); + }); + } +}); + +test('Slice A RED: a policy-blocked session rejects new admission before any probe', async (t) => { + let probes = 0; + const context = setupKernel(t, { + transport: Object.freeze({ + async probe() { probes += 1; return { kind: 'response', status: 200, body: Buffer.from('no') }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const nextPolicy = structuredClone(context.policy); + nextPolicy.sellers[0].autoApproveAtomic = '20000'; + await context.kernel.applyPolicy({ + document: nextPolicy, + expectedPolicyHash: sha256(canonicalJson(nextPolicy)), + }); + + await assert.rejects( + context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('blocked-before-probe'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-blocked-before-probe', + }), + (error) => error?.code === 'SESSION_POLICY_BLOCKED', + ); + assert.equal(probes, 0); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 0); +}); + +test('Slice A RED: operator approval sweeps an expired target before attempting its decision', async (t) => { + const challenge = paymentRequired('50000'); + let currentTime = NOW; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + clock: () => currentTime, + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('operator-expiry-sweep'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-operator-expiry-sweep', + }); + const approval = context.store.readOne('SELECT id, intent_hash, expires_at FROM approvals'); + currentTime = approval.expires_at; + + await assert.rejects( + context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }), + (error) => error?.code === 'APPROVAL_STATE_CONFLICT', + ); + + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'expired'); + assert.equal(context.store.readOne('SELECT state FROM spend_intents').state, 'terminal'); + assert.equal(context.store.readOne('SELECT reason_code FROM buyer_outcomes') + .reason_code, 'APPROVAL_EXPIRED'); + assert.equal(context.store.readAll('SELECT * FROM signed_receipts').length, 1); + assert.equal(context.receipts.assertParity(), true); +}); + +test('Slice A RED: status is sanitized, receipt-aware, and never sweeps an expired approval', async (t) => { + const challenge = paymentRequired('50000'); + let currentTime = NOW; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + clock: () => currentTime, + transport: Object.freeze({ + async probe() { return { kind: 'payment_required', paymentRequired: challenge }; }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('status-read-only'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-status-read-only', + }); + const approval = context.store.readOne('SELECT intent_id, expires_at FROM approvals'); + currentTime = approval.expires_at; + const eventsBefore = context.store.events().length; + + const status = context.kernel.status({ + sessionId: session.id, + intentId: approval.intent_id, + }); + + assert.ok(Object.isFrozen(status)); + assert.deepEqual(Object.keys(status), [ + 'sessionId', + 'intentId', + 'sessionState', + 'intentState', + 'approvalState', + 'budgetState', + 'paymentState', + 'outcome', + 'receipt', + ]); + assert.equal(status.sessionState, 'open'); + assert.equal(status.intentState, 'approval_pending'); + assert.equal(status.approvalState, 'pending'); + assert.equal(status.budgetState, null); + assert.equal(status.paymentState, null); + assert.equal(status.outcome, null); + assert.equal(status.receipt, null); + assert.equal(context.store.readOne('SELECT decision FROM approvals').decision, 'pending'); + assert.equal(context.store.events().length, eventsBefore); +}); + +test('Slice A RED: terminal status returns the exact receipt and rejects cross-session lookup', async (t) => { + const context = setupKernel(t); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const completed = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('terminal-status'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-terminal-status', + }); + const intentId = context.store.readOne('SELECT id FROM spend_intents').id; + const eventsBefore = context.store.events().length; + + const status = context.kernel.status({ sessionId: session.id, intentId }); + + assert.equal(status.intentState, 'terminal'); + assert.equal(status.approvalState, null); + assert.equal(status.budgetState, null); + assert.equal(status.paymentState, null); + assert.ok(Object.isFrozen(status.outcome)); + assert.deepEqual(status.outcome, { + status: 'completed', + reasonCode: 'ORDINARY_SUCCESS', + revision: 1, + }); + assert.equal(status.receipt.receiptHash, completed.receipt.receiptHash); + assert.equal(context.store.events().length, eventsBefore); + + await context.kernel.closeSession({ + sessionId: session.id, + expectedSessionHash: context.intents.getSession(session.id).sessionHash, + }); + const restarted = await context.createKernel().openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + assert.throws( + () => context.kernel.status({ sessionId: restarted.id, intentId }), + (error) => error?.code === 'INTENT_SESSION_MISMATCH', + ); + assert.throws( + () => context.kernel.status({ sessionId: session.id, intentId: 'intent-missing' }), + (error) => error?.code === 'INTENT_UNKNOWN', + ); +}); + +test('agent-scoped public status resolves request and receipt IDs without internal authority IDs', async (t) => { + const context = setupKernel(t); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const completed = await context.kernel.execute({ + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('public-status'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-public-status', + }); + + const byRequest = context.kernel.statusByRequestId({ + sessionId: session.id, + requestId: completed.requestId, + }); + assert.deepEqual(Object.keys(byRequest), [ + 'requestId', 'sellerOrigin', 'purposeLabel', 'intentState', + 'approval', 'outcome', 'receipt', 'remainingSessionAtomic', + ]); + assert.equal(byRequest.requestId, completed.requestId); + assert.equal(byRequest.sellerOrigin, SELLER); + assert.equal(byRequest.purposeLabel, 'skill.invoke'); + assert.equal(byRequest.intentState, 'terminal'); + assert.equal(byRequest.approval, null); + assert.equal(byRequest.outcome.status, 'completed'); + assert.equal(byRequest.receipt.receiptHash, completed.receipt.receiptHash); + assert.equal(byRequest.remainingSessionAtomic, context.activePolicy.policy.sessionMaxAtomic); + assert.equal(Object.hasOwn(byRequest, 'sessionId'), false); + assert.equal(Object.hasOwn(byRequest, 'intentId'), false); + + const byReceipt = context.kernel.receiptById({ + sessionId: session.id, + receiptId: completed.receipt.receipt.receiptId, + }); + assert.deepEqual(byReceipt, byRequest); + assert.equal(context.kernel.statusByRequestId({ + sessionId: session.id, + requestId: 'request-missing', + }), null); + assert.equal(context.kernel.receiptById({ + sessionId: session.id, + receiptId: 'receipt-missing', + }), null); +}); + +test('Slice B RED: exact terminal correlation replays only persisted sanitized facts', async (t) => { + let probes = 0; + let signerCalls = 0; + let paidRetries = 0; + const context = setupKernel(t, { + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { signerCalls += 1; throw new Error('must not sign'); }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + return { kind: 'response', status: 200, body: Buffer.from(`ok-${probes}`) }; + }, + encodePayment() { throw new Error('must not encode'); }, + async retryPaid() { paidRetries += 1; throw new Error('must not retry'); }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const call = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('terminal-correlation'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-terminal-correlation', + }; + const first = await context.kernel.execute(call); + const eventsBeforeReplay = context.store.events().length; + + const replay = await context.kernel.execute(call); + + assert.deepEqual(Object.keys(replay), ['requestId', 'status', 'reasonCode', 'receipt']); + assert.equal(replay.requestId, first.requestId); + assert.equal(replay.status, first.status); + assert.equal(replay.reasonCode, first.reasonCode); + assert.equal(replay.receipt.receiptHash, first.receipt.receiptHash); + assert.equal(probes, 1); + assert.equal(signerCalls, 0); + assert.equal(paidRetries, 0); + assert.equal(context.store.events().length, eventsBeforeReplay); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + + await assert.rejects( + context.kernel.execute({ + ...call, + request: ordinaryRequest('terminal-correlation-mismatch'), + }), + (error) => error?.code === 'CORRELATION_CONFLICT', + ); + assert.equal(probes, 1); + assert.equal(context.store.events().length, eventsBeforeReplay); + + const fresh = await context.kernel.execute({ + ...call, + correlationId: 'pi-call-terminal-correlation-fresh', + }); + assert.notEqual(fresh.requestId, first.requestId); + assert.equal(fresh.status, 'completed'); + assert.equal(probes, 2); + assert.equal(signerCalls, 0); + assert.equal(paidRetries, 0); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 2); + assert.equal(context.receipts.assertParity(), true); +}); diff --git a/spikes/pi-wielder/tests/x402-v2-transport.test.mjs b/spikes/pi-wielder/tests/x402-v2-transport.test.mjs new file mode 100644 index 0000000..1be051e --- /dev/null +++ b/spikes/pi-wielder/tests/x402-v2-transport.test.mjs @@ -0,0 +1,753 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import test from 'node:test'; + +import { + encodePaymentRequiredHeader, + encodePaymentResponseHeader, +} from '@x402/core/http'; + +import { + classifyX402PaymentResponse, + createX402V2Transport, + X402TransportError, +} from '../src/adapters/x402-v2-transport.mjs'; +import { + createResourceFetch, + PAYMENT_HASH, + PAYMENT_PAYLOAD, + PAYMENT_REQUIRED, + PAYMENT_REQUIRED_HEADER, + PAYMENT_RESPONSE, + PAYMENT_RESPONSE_HEADER, + PAYMENT_SIGNATURE_HEADER, + SETTLEMENT, +} from './fixtures/x402-v2-resource.mjs'; + +const URL = PAYMENT_REQUIRED.resource.url; +const WALLET = PAYMENT_PAYLOAD.payload.authorization.from; +const DEFAULT_LIMITS = Object.freeze({ + requestTimeoutMs: 5_000, + maximumResponseBytes: 1_048_576, + maximumPaymentHeaderBytes: 16_384, +}); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function requestSnapshot(overrides = {}) { + return { + requestUrl: URL, + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'idempotency-key': 'wk_fixture_1', + }, + bodyBytes: Buffer.from('{"prompt":"fixture"}'), + ...overrides, + }; +} + +function binding(overrides = {}) { + return { + network: PAYMENT_REQUIRED.accepts[0].network, + walletAddress: WALLET, + amountAtomic: PAYMENT_REQUIRED.accepts[0].amount, + paymentHash: PAYMENT_HASH, + ...overrides, + }; +} + +function transport(fetchImpl, overrides = {}) { + return createX402V2Transport({ + fetchImpl, + mode: 'cdp-testnet', + limits: DEFAULT_LIMITS, + ...overrides, + }); +} + +async function rejectsCode(operation, code) { + await assert.rejects(operation, (error) => { + assert.ok(error instanceof X402TransportError); + assert.equal(error.code, code); + return true; + }); +} + +function challengeResponse(paymentRequired = PAYMENT_REQUIRED, { + body = Buffer.alloc(0), + header = encodePaymentRequiredHeader(paymentRequired), + headers, +} = {}) { + return new Response(body, { + status: 402, + headers: headers ?? { 'PAYMENT-REQUIRED': header }, + }); +} + +function hangingBody(onCancel = () => {}) { + return new ReadableStream({ + cancel(reason) { onCancel(reason); }, + }, { highWaterMark: 0 }); +} + +function responseFetch(responseOrFactory) { + const calls = []; + return { + calls, + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return typeof responseOrFactory === 'function' + ? responseOrFactory(url, init, calls.length) + : responseOrFactory; + }, + }; +} + +test('constructor, mode, limits, request, and binding use closed schemas', async () => { + for (const value of [ + null, + {}, + { fetchImpl: async () => {}, mode: 'cdp-testnet', limits: DEFAULT_LIMITS, extra: true }, + { fetchImpl: new Proxy(async () => {}, {}), mode: 'cdp-testnet', limits: DEFAULT_LIMITS }, + { fetchImpl: async () => {}, mode: 'mainnet', limits: DEFAULT_LIMITS }, + { fetchImpl: async () => {}, mode: 'cdp-testnet', limits: { ...DEFAULT_LIMITS, extra: 1 } }, + { fetchImpl: async () => {}, mode: 'cdp-testnet', limits: { ...DEFAULT_LIMITS, requestTimeoutMs: 0 } }, + { fetchImpl: async () => {}, mode: 'cdp-testnet', limits: { ...DEFAULT_LIMITS, maximumResponseBytes: 1.5 } }, + ]) { + assert.throws(() => createX402V2Transport(value), X402TransportError); + } + + const recording = responseFetch(new Response(null, { status: 204 })); + const client = transport(recording.fetchImpl); + for (const request of [ + { ...requestSnapshot(), unknown: true }, + { ...requestSnapshot(), requestUrl: 'http://seller.example/paid/infer' }, + { ...requestSnapshot(), method: 'post' }, + { ...requestSnapshot(), bodyBytes: 'not bytes' }, + { ...requestSnapshot(), headers: { authorization: 'secret' } }, + { ...requestSnapshot(), headers: { cookie: 'session=secret' } }, + { ...requestSnapshot(), headers: { 'payment-signature': PAYMENT_SIGNATURE_HEADER } }, + ]) { + await rejectsCode(() => client.probe(request), 'REQUEST_SCHEMA'); + } + assert.equal(recording.calls.length, 0); + + const resource = createResourceFetch(); + const paidClient = transport(resource.fetchImpl); + const request = requestSnapshot(); + await paidClient.probe(request); + for (const invalid of [ + { ...binding(), extra: true }, + { ...binding(), network: '' }, + { ...binding(), network: 'eip155:84532 ' }, + { ...binding(), walletAddress: '0xnot-an-address' }, + { ...binding(), amountAtomic: '050000' }, + { ...binding(), amountAtomic: '9'.repeat(101) }, + { ...binding(), paymentHash: `sha256:${'A'.repeat(64)}` }, + ]) { + await rejectsCode(() => paidClient.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: invalid, + }), 'SETTLEMENT_BINDING'); + } + assert.equal(resource.callCount(), 1); +}); + +test('mode admits only HTTPS in cdp-testnet and literal loopback HTTP in deterministic mode', async () => { + const urls = []; + const fetchImpl = async (url) => { + urls.push(String(url)); + return new Response(null, { status: 204 }); + }; + const deterministic = createX402V2Transport({ + fetchImpl, + mode: 'deterministic', + limits: DEFAULT_LIMITS, + }); + await deterministic.probe(requestSnapshot({ requestUrl: 'http://127.0.0.1:4402/paid' })); + await deterministic.probe(requestSnapshot({ requestUrl: 'http://[::1]:4402/paid' })); + await deterministic.probe(requestSnapshot()); + for (const requestUrl of [ + 'http://localhost:4402/paid', + 'http://127.0.0.2:4402/paid', + 'http://user:pass@127.0.0.1:4402/paid', + 'http://127.0.0.1:4402/paid#fragment', + ]) { + await rejectsCode( + () => deterministic.probe(requestSnapshot({ requestUrl })), + 'REQUEST_SCHEMA', + ); + } + assert.deepEqual(urls, [ + 'http://127.0.0.1:4402/paid', + 'http://[::1]:4402/paid', + URL, + ]); +}); + +test('deterministic mode accepts a matching literal-loopback x402 challenge resource', async () => { + const requestUrl = 'http://127.0.0.1:4402/paid'; + const paymentRequired = clone(PAYMENT_REQUIRED); + paymentRequired.resource.url = requestUrl; + const client = createX402V2Transport({ + fetchImpl: async () => challengeResponse(paymentRequired), + mode: 'deterministic', + limits: DEFAULT_LIMITS, + }); + const result = await client.probe(requestSnapshot({ requestUrl })); + assert.equal(result.kind, 'payment_required'); + assert.equal(result.paymentRequired.resource.url, requestUrl); +}); + +test('a later approval flow may freshly probe the same ordinary request snapshot', async () => { + let calls = 0; + const client = transport(async () => { + calls += 1; + return challengeResponse(); + }); + const request = requestSnapshot(); + assert.equal((await client.probe(request)).kind, 'payment_required'); + assert.equal((await client.probe(request)).kind, 'payment_required'); + assert.equal(calls, 2); +}); + +test('official codecs, one cloned body, and exactly one unpaid plus one paid call interoperate', async () => { + const resource = createResourceFetch(); + const client = transport(resource.fetchImpl); + const body = Buffer.from('{"prompt":"immutable"}'); + const request = requestSnapshot({ bodyBytes: body }); + + const challenge = await client.probe(request); + assert.equal(challenge.kind, 'payment_required'); + assert.deepEqual(challenge.paymentRequired, PAYMENT_REQUIRED); + assert.ok(Object.isFrozen(challenge)); + assert.ok(Object.isFrozen(challenge.paymentRequired)); + assert.equal(client.encodePayment(PAYMENT_PAYLOAD), PAYMENT_SIGNATURE_HEADER); + const paymentHash = `sha256:${crypto.createHash('sha256') + .update(Buffer.from(PAYMENT_SIGNATURE_HEADER, 'ascii')).digest('hex')}`; + assert.equal(paymentHash, PAYMENT_HASH); + + body.fill(0x78); + request.bodyBytes = Buffer.from('caller replacement'); + const paid = await client.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding({ paymentHash }), + }); + assert.equal(paid.kind, 'settled_response'); + assert.deepEqual(paid.settlement, SETTLEMENT); + assert.equal(paid.status, 200); + assert.equal(paid.executionState, 'succeeded'); + assert.deepEqual(paid.body, Buffer.from('{"ok":true}')); + assert.equal(resource.callCount(), 2); + + const [unpaid, paidCall] = resource.calls; + assert.equal(unpaid.redirect, 'manual'); + assert.equal(paidCall.redirect, 'manual'); + assert.equal(unpaid.credentials, 'omit'); + assert.equal(paidCall.credentials, 'omit'); + assert.equal(unpaid.headers['payment-signature'], undefined); + assert.equal(paidCall.headers['payment-signature'], PAYMENT_SIGNATURE_HEADER); + const withoutPayment = { ...paidCall.headers }; + delete withoutPayment['payment-signature']; + assert.deepEqual(withoutPayment, unpaid.headers); + assert.equal(unpaid.url, paidCall.url); + assert.equal(unpaid.method, paidCall.method); + assert.deepEqual(unpaid.bodyBytes, Buffer.from('{"prompt":"immutable"}')); + assert.deepEqual(paidCall.bodyBytes, unpaid.bodyBytes); + + await rejectsCode(() => client.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding(), + }), 'REQUEST_ALREADY_RETRIED'); + assert.equal(resource.callCount(), 2); +}); + +test('encodePayment uses the official codec and enforces the signature header byte ceiling', () => { + const client = createX402V2Transport({ + fetchImpl: async () => new Response(null, { status: 204 }), + mode: 'cdp-testnet', + limits: { ...DEFAULT_LIMITS, maximumPaymentHeaderBytes: 64 }, + }); + assert.throws(() => client.encodePayment(PAYMENT_PAYLOAD), (error) => ( + error instanceof X402TransportError && error.code === 'PAYMENT_SIGNATURE_TOO_LARGE' + )); + assert.throws(() => client.encodePayment({ ...PAYMENT_PAYLOAD, extra: () => {} }), (error) => ( + error instanceof X402TransportError && error.code === 'PAYMENT_PAYLOAD_SCHEMA' + )); +}); + +test('probe returns bounded ordinary responses and never follows redirects', async () => { + const ordinary = responseFetch(new Response('ordinary', { status: 201 })); + const result = await transport(ordinary.fetchImpl).probe(requestSnapshot()); + assert.deepEqual(result, { + kind: 'response', + status: 201, + body: Buffer.from('ordinary'), + }); + assert.equal(ordinary.calls.length, 1); + assert.equal(ordinary.calls[0].init.redirect, 'manual'); + + const redirected = responseFetch(new Response(null, { + status: 302, + headers: { location: 'https://other.example/steal' }, + })); + await rejectsCode( + () => transport(redirected.fetchImpl).probe(requestSnapshot()), + 'REDIRECT_FORBIDDEN', + ); + assert.equal(redirected.calls.length, 1); +}); + +test('probe rejects missing, duplicate, malformed, oversized, or non-v2 challenge headers', async () => { + const duplicateHeaders = new Headers([ + ['PAYMENT-REQUIRED', PAYMENT_REQUIRED_HEADER], + ['PAYMENT-REQUIRED', PAYMENT_REQUIRED_HEADER], + ]); + const cases = [ + ['missing', new Response(null, { status: 402 }), 'PAYMENT_REQUIRED_MISSING'], + ['legacy header only', new Response(null, { + status: 402, + headers: { 'X-PAYMENT-REQUIRED': PAYMENT_REQUIRED_HEADER }, + }), 'PAYMENT_REQUIRED_MISSING'], + ['duplicate', challengeResponse(PAYMENT_REQUIRED, { headers: duplicateHeaders }), 'PAYMENT_REQUIRED_DUPLICATE'], + ['malformed base64', challengeResponse(PAYMENT_REQUIRED, { header: '***' }), 'PAYMENT_REQUIRED_MALFORMED'], + ['malformed JSON', challengeResponse(PAYMENT_REQUIRED, { + header: Buffer.from('{bad json', 'utf8').toString('base64'), + }), 'PAYMENT_REQUIRED_MALFORMED'], + ['over ceiling', challengeResponse(PAYMENT_REQUIRED, { header: 'A'.repeat(16_385) }), 'PAYMENT_REQUIRED_TOO_LARGE'], + ['v1', challengeResponse({ ...PAYMENT_REQUIRED, x402Version: 1 }), 'PAYMENT_REQUIRED_SCHEMA'], + ]; + for (const [name, response, code] of cases) { + const recording = responseFetch(response); + await rejectsCode( + () => transport(recording.fetchImpl).probe(requestSnapshot()), + code, + ); + assert.equal(recording.calls.length, 1, name); + } +}); + +test('probe applies closed structural validation without selecting or filtering candidates', async () => { + const malformed = []; + const add = (name, mutate) => { + const value = clone(PAYMENT_REQUIRED); + mutate(value); + malformed.push([name, value]); + }; + add('unknown top-level key', (value) => { value.unknown = true; }); + add('missing resource', (value) => { delete value.resource; }); + add('unknown resource key', (value) => { value.resource.serviceName = 'nope'; }); + add('empty description', (value) => { value.resource.description = ''; }); + add('malformed MIME type', (value) => { value.resource.mimeType = 'not a MIME type'; }); + add('empty accepts', (value) => { value.accepts = []; }); + add('missing requirement key', (value) => { delete value.accepts[0].payTo; }); + add('unknown requirement key', (value) => { value.accepts[0].price = '50000'; }); + add('empty scheme', (value) => { value.accepts[0].scheme = ''; }); + add('noncanonical scheme', (value) => { value.accepts[0].scheme = 'exact scheme'; }); + add('noncanonical network', (value) => { value.accepts[0].network = 'eip155 84532'; }); + add('invalid amount', (value) => { value.accepts[0].amount = '050000'; }); + add('invalid timeout', (value) => { value.accepts[0].maxTimeoutSeconds = 0; }); + add('non-object extra', (value) => { value.accepts[0].extra = []; }); + for (const [name, value] of malformed) { + const recording = responseFetch(challengeResponse(value)); + await rejectsCode( + () => transport(recording.fetchImpl).probe(requestSnapshot()), + 'PAYMENT_REQUIRED_SCHEMA', + ); + assert.equal(recording.calls.length, 1, name); + } + + const options = clone(PAYMENT_REQUIRED); + options.accepts.push({ + scheme: 'different-scheme', + network: 'different:network', + asset: 'different-asset', + amount: '90000', + payTo: 'different-payee', + maxTimeoutSeconds: 30, + extra: { vendor: 'unselected' }, + }); + const client = transport(async () => challengeResponse(options)); + const result = await client.probe(requestSnapshot()); + assert.deepEqual(result.paymentRequired.accepts, options.accepts); + assert.equal(result.paymentRequired.accepts.length, 2); +}); + +test('probe rejects a mismatched resource URL and an oversized 402 body', async () => { + const mismatched = clone(PAYMENT_REQUIRED); + mismatched.resource.url = 'https://seller.example/paid/other'; + await rejectsCode( + () => transport(async () => challengeResponse(mismatched)).probe(requestSnapshot()), + 'RESOURCE_URL_MISMATCH', + ); + + const client = createX402V2Transport({ + fetchImpl: async () => challengeResponse(PAYMENT_REQUIRED, { body: Buffer.alloc(65) }), + mode: 'cdp-testnet', + limits: { ...DEFAULT_LIMITS, maximumResponseBytes: 64 }, + }); + await rejectsCode(() => client.probe(requestSnapshot()), 'RESPONSE_TOO_LARGE'); +}); + +test('retryPaid validates exact persisted header/hash and requires the probed request identity', async () => { + const resource = createResourceFetch(); + const client = transport(resource.fetchImpl); + const request = requestSnapshot(); + await client.probe(request); + await rejectsCode(() => client.retryPaid({ + request: requestSnapshot(), + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding(), + }), 'REQUEST_NOT_PROBED'); + await rejectsCode(() => client.retryPaid({ + request, + paymentHeader: `${PAYMENT_SIGNATURE_HEADER[0] === 'A' ? 'B' : 'A'}${PAYMENT_SIGNATURE_HEADER.slice(1)}`, + binding: binding(), + }), 'PAYMENT_HASH_MISMATCH'); + await rejectsCode(() => client.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding({ paymentHash: `sha256:${'f'.repeat(64)}` }), + }), 'PAYMENT_HASH_MISMATCH'); + assert.equal(resource.callCount(), 1); +}); + +test('oversized paid header is rejected before Base64 allocation, fetch, or state mutation', async () => { + const resource = createResourceFetch(); + let fetchCalls = 0; + let paidFetchCalls = 0; + const client = transport(async (...arguments_) => { + fetchCalls += 1; + if (fetchCalls > 1) paidFetchCalls += 1; + return await resource.fetchImpl(...arguments_); + }); + const request = requestSnapshot(); + await client.probe(request); + + const oversized = 'A'.repeat(DEFAULT_LIMITS.maximumPaymentHeaderBytes + 1); + const originalFrom = Buffer.from; + let base64DecodeCalls = 0; + Buffer.from = function instrumentedBufferFrom(value, encoding, ...rest) { + if (encoding === 'base64') { + base64DecodeCalls += 1; + throw new Error('unsafe Base64 allocation reached'); + } + return Reflect.apply(originalFrom, Buffer, [value, encoding, ...rest]); + }; + try { + await rejectsCode(() => client.retryPaid({ + request, + paymentHeader: oversized, + binding: binding(), + }), 'PAYMENT_HEADER_SCHEMA'); + } finally { + Buffer.from = originalFrom; + } + assert.equal(base64DecodeCalls, 0); + assert.equal(paidFetchCalls, 0); + + const paid = await client.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding(), + }); + assert.equal(paid.kind, 'settled_response'); + assert.equal(paidFetchCalls, 1); +}); + +test('classifier returns only frozen Task5-compatible settlement evidence', () => { + const responseWithIgnoredMetadata = { + ...PAYMENT_RESPONSE, + extensions: { trace: 'RAW_EXTENSION_SENTINEL' }, + extra: { facilitatorLatencyMs: 1 }, + }; + const classified = classifyX402PaymentResponse({ + rawHeader: encodePaymentResponseHeader(responseWithIgnoredMetadata), + decoded: responseWithIgnoredMetadata, + binding: binding(), + }); + assert.equal(classified.kind, 'settled'); + assert.deepEqual({ + ...classified.settlement, + headerHash: SETTLEMENT.headerHash, + }, SETTLEMENT); + assert.ok(Object.isFrozen(classified)); + assert.ok(Object.isFrozen(classified.settlement)); + assert.deepEqual(Object.keys(classified.settlement).sort(), [ + 'amountAtomic', + 'headerHash', + 'network', + 'payer', + 'paymentHash', + 'source', + 'success', + 'transaction', + ]); + assert.equal(JSON.stringify(classified).includes(PAYMENT_RESPONSE_HEADER), false); + assert.equal(JSON.stringify(classified).includes('errorMessage'), false); + assert.equal(JSON.stringify(classified).includes('extensions'), false); + assert.equal(JSON.stringify(classified).includes('extra'), false); + assert.equal(JSON.stringify(classified).includes('RAW_EXTENSION_SENTINEL'), false); +}); + +test('classifier allows exact success with absent amount but requires payer', () => { + const absentAmount = { ...PAYMENT_RESPONSE }; + delete absentAmount.amount; + const rawHeader = encodePaymentResponseHeader(absentAmount); + const result = classifyX402PaymentResponse({ + rawHeader, + decoded: absentAmount, + binding: binding(), + }); + assert.equal(result.kind, 'settled'); + assert.equal(Object.hasOwn(result.settlement, 'amountAtomic'), false); + + const absentPayer = { ...PAYMENT_RESPONSE }; + delete absentPayer.payer; + const missing = classifyX402PaymentResponse({ + rawHeader: encodePaymentResponseHeader(absentPayer), + decoded: absentPayer, + binding: binding(), + }); + assert.deepEqual(missing, { kind: 'unresolved', reasonCode: 'SETTLEMENT_PAYER_MISSING' }); +}); + +test('classifier fails closed for every recognized-field mutation and never trusts seller text', () => { + const mutations = [ + ['success false empty transaction', (value) => { value.success = false; value.transaction = ''; }, 'SETTLEMENT_REPORTED_FAILURE'], + ['success false reverted transaction', (value) => { value.success = false; value.transaction = `0x${'0'.repeat(64)}`; }, 'SETTLEMENT_REPORTED_FAILURE'], + ['success type', (value) => { value.success = 1; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['transaction missing', (value) => { delete value.transaction; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['transaction type', (value) => { value.transaction = 1; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['transaction malformed', (value) => { value.transaction = '0x1234'; }, 'SETTLEMENT_TRANSACTION_INVALID'], + ['network missing', (value) => { delete value.network; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['network type', (value) => { value.network = 1; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['network mismatch', (value) => { value.network = 'eip155:8453'; }, 'SETTLEMENT_NETWORK_MISMATCH'], + ['payer type', (value) => { value.payer = 1; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['payer malformed', (value) => { value.payer = 'payer'; }, 'SETTLEMENT_PAYER_INVALID'], + ['payer mismatch', (value) => { value.payer = `0x${'2'.repeat(40)}`; }, 'SETTLEMENT_PAYER_MISMATCH'], + ['amount type', (value) => { value.amount = 50_000; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['amount noncanonical', (value) => { value.amount = '050000'; }, 'SETTLEMENT_AMOUNT_INVALID'], + ['amount mismatch', (value) => { value.amount = '50001'; }, 'SETTLEMENT_AMOUNT_MISMATCH'], + ['errorReason type', (value) => { value.errorReason = 1; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['errorReason on success', (value) => { value.errorReason = 'seller-controlled'; }, 'SETTLEMENT_SUCCESS_HAS_ERROR'], + ['errorMessage type', (value) => { value.errorMessage = 1; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['errorMessage on success', (value) => { value.errorMessage = 'RAW_SELLER_SENTINEL'; }, 'SETTLEMENT_SUCCESS_HAS_ERROR'], + ['extensions type', (value) => { value.extensions = []; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['extra type', (value) => { value.extra = []; }, 'SETTLEMENT_SCHEMA_INVALID'], + ['unknown key', (value) => { value.authorizationId = 'invented'; }, 'SETTLEMENT_SCHEMA_INVALID'], + ]; + for (const [name, mutate, reasonCode] of mutations) { + const decoded = clone(PAYMENT_RESPONSE); + mutate(decoded); + const result = classifyX402PaymentResponse({ + rawHeader: encodePaymentResponseHeader(decoded), + decoded, + binding: binding(), + }); + assert.deepEqual(result, { kind: 'unresolved', reasonCode }, name); + assert.equal(JSON.stringify(result).includes('RAW_SELLER_SENTINEL'), false, name); + } +}); + +test('classifier binds decoded bytes and the exact ASCII payment hash', () => { + const substituted = { ...PAYMENT_RESPONSE, amount: '50001' }; + assert.deepEqual(classifyX402PaymentResponse({ + rawHeader: PAYMENT_RESPONSE_HEADER, + decoded: substituted, + binding: binding(), + }), { kind: 'unresolved', reasonCode: 'SETTLEMENT_DECODE_MISMATCH' }); + assert.deepEqual(classifyX402PaymentResponse({ + rawHeader: '***', + decoded: PAYMENT_RESPONSE, + binding: binding(), + }), { kind: 'unresolved', reasonCode: 'SETTLEMENT_HEADER_INVALID' }); + assert.deepEqual(classifyX402PaymentResponse({ + rawHeader: PAYMENT_RESPONSE_HEADER, + decoded: PAYMENT_RESPONSE, + binding: binding({ paymentHash: `sha256:${'F'.repeat(64)}` }), + }), { kind: 'unresolved', reasonCode: 'SETTLEMENT_BINDING_INVALID' }); +}); + +test('loss or timeout before trustworthy paid response headers is ambiguous and makes no third call', async () => { + for (const [name, second, expected] of [ + ['connection loss', async () => { throw new Error('ECONNRESET'); }, 'PAID_FETCH_FAILED'], + ['timeout', async () => await new Promise(() => {}), 'PAID_RESPONSE_TIMEOUT'], + ]) { + let calls = 0; + const client = createX402V2Transport({ + fetchImpl: async (...args) => { + calls += 1; + if (calls === 1) return challengeResponse(); + return await second(...args); + }, + mode: 'cdp-testnet', + limits: { ...DEFAULT_LIMITS, requestTimeoutMs: 25 }, + }); + const request = requestSnapshot(); + await client.probe(request); + const result = await client.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding(), + }); + assert.deepEqual(result, { kind: 'paid_response_ambiguous', reasonCode: expected }, name); + assert.equal(calls, 2, name); + } +}); + +test('second 402, changed challenge, or absent settlement always retains the hold', async () => { + const changed = clone(PAYMENT_REQUIRED); + changed.accepts[0].amount = '50001'; + for (const [name, paidResponse, reasonCode] of [ + ['second 402', challengeResponse(), 'SECOND_PAYMENT_REQUIRED'], + ['changed second 402', challengeResponse(changed), 'SECOND_PAYMENT_REQUIRED'], + ['missing settlement', new Response('paid?', { status: 200 }), 'PAYMENT_RESPONSE_MISSING'], + ]) { + let calls = 0; + const client = transport(async () => { + calls += 1; + return calls === 1 ? challengeResponse() : paidResponse; + }); + const request = requestSnapshot(); + await client.probe(request); + assert.deepEqual(await client.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding(), + }), { kind: 'paid_response_ambiguous', reasonCode }, name); + assert.equal(calls, 2, name); + } +}); + +test('missing, duplicate, malformed, oversized, or mismatched settlement is ambiguous for every status', async () => { + const duplicate = new Headers([ + ['PAYMENT-RESPONSE', PAYMENT_RESPONSE_HEADER], + ['PAYMENT-RESPONSE', PAYMENT_RESPONSE_HEADER], + ]); + const malformedCases = [ + ['legacy header only', { + 'X-PAYMENT-RESPONSE': PAYMENT_RESPONSE_HEADER, + }, 'PAYMENT_RESPONSE_MISSING'], + ['duplicate', duplicate, 'PAYMENT_RESPONSE_DUPLICATE'], + ['malformed base64', { 'PAYMENT-RESPONSE': '***' }, 'PAYMENT_RESPONSE_MALFORMED'], + ['malformed json', { + 'PAYMENT-RESPONSE': Buffer.from('{bad json').toString('base64'), + }, 'PAYMENT_RESPONSE_MALFORMED'], + ['oversized', { 'PAYMENT-RESPONSE': 'A'.repeat(16_385) }, 'PAYMENT_RESPONSE_TOO_LARGE'], + ['success false', { + 'PAYMENT-RESPONSE': encodePaymentResponseHeader({ + ...PAYMENT_RESPONSE, + success: false, + errorReason: 'rejected', + }), + }, 'SETTLEMENT_REPORTED_FAILURE'], + ]; + for (const status of [200, 302, 404, 500]) { + for (const [name, headers, reasonCode] of malformedCases) { + let calls = 0; + const client = transport(async () => { + calls += 1; + return calls === 1 + ? challengeResponse() + : new Response('untrusted body', { status, headers }); + }); + const request = requestSnapshot(); + await client.probe(request); + assert.deepEqual(await client.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding(), + }), { kind: 'paid_response_ambiguous', reasonCode }, `${status} ${name}`); + assert.equal(calls, 2, `${status} ${name}`); + } + } +}); + +test('a valid settlement commits payment while 2xx timeout, overflow, or disconnect is execution unknown', async () => { + const cases = [ + ['timeout', () => hangingBody(), 'BODY_TIMEOUT'], + ['overflow', () => Buffer.alloc(65), 'BODY_TOO_LARGE'], + ['disconnect', () => new ReadableStream({ + pull(controller) { controller.error(new Error('disconnect')); }, + }), 'BODY_READ_FAILED'], + ]; + for (const [name, makeBody, deliveryReason] of cases) { + let calls = 0; + const client = createX402V2Transport({ + fetchImpl: async () => { + calls += 1; + return calls === 1 + ? challengeResponse() + : new Response(makeBody(), { + status: 200, + headers: { 'PAYMENT-RESPONSE': PAYMENT_RESPONSE_HEADER }, + }); + }, + mode: 'cdp-testnet', + limits: { + requestTimeoutMs: 25, + maximumResponseBytes: 64, + maximumPaymentHeaderBytes: 16_384, + }, + }); + const request = requestSnapshot(); + await client.probe(request); + assert.deepEqual(await client.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding(), + }), { + kind: 'settled_response', + settlement: SETTLEMENT, + status: 200, + body: null, + executionState: 'unknown', + deliveryReason, + }, name); + assert.equal(calls, 2, name); + } +}); + +test('validly settled 3xx, 4xx, and 5xx are execution failed immediately with no follow or third call', async () => { + for (const status of [302, 404, 500]) { + let calls = 0; + let bodyCancelled = false; + const client = transport(async () => { + calls += 1; + return calls === 1 + ? challengeResponse() + : new Response(hangingBody(() => { bodyCancelled = true; }), { + status, + headers: { + 'PAYMENT-RESPONSE': PAYMENT_RESPONSE_HEADER, + location: 'https://other.example/never-followed', + }, + }); + }); + const request = requestSnapshot(); + await client.probe(request); + assert.deepEqual(await client.retryPaid({ + request, + paymentHeader: PAYMENT_SIGNATURE_HEADER, + binding: binding(), + }), { + kind: 'settled_response', + settlement: SETTLEMENT, + status, + body: null, + executionState: 'failed', + deliveryReason: 'HTTP_STATUS_FAILURE', + }); + assert.equal(calls, 2); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(bodyCancelled, true); + } +}); From a6620a693eb195c3edb09e984b0078d815f24d2e Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 2 Aug 2026 14:02:54 -0400 Subject: [PATCH 164/165] fix: make wallet approvals deliberate and replay-safe --- .../2026-07-31-agent-spend-control-plane.md | 85 +++- ...-07-31-agent-spend-control-plane-design.md | 59 ++- spikes/pi-wielder/README.md | 35 +- spikes/pi-wielder/RUNBOOK.md | 43 +- spikes/pi-wielder/pi-extension/x402.ts | 37 +- .../lib/spend-control-process-runner.mjs | 225 +++++++--- spikes/pi-wielder/scripts/run-evidence.mjs | 40 +- .../pi-wielder/src/kernel/wallet-kernel.mjs | 12 +- spikes/pi-wielder/src/spend-control-proxy.mjs | 136 ++---- .../pi-wielder/tests/evidence-runner.test.mjs | 41 +- .../tests/fixtures/pi-client-process.mjs | 38 +- .../tests/fixtures/pi-model-process.mjs | 36 +- .../tests/pi-extension-contract.test.mjs | 172 ++++++- .../tests/spend-control-process-e2e.test.mjs | 68 ++- .../tests/spend-control-proxy.test.mjs | 419 +++++++++--------- .../pi-wielder/tests/wallet-kernel.test.mjs | 89 ++++ 16 files changed, 1056 insertions(+), 479 deletions(-) diff --git a/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md b/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md index 6619dd9..3b3ae39 100644 --- a/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md +++ b/docs/superpowers/plans/2026-07-31-agent-spend-control-plane.md @@ -12,6 +12,16 @@ `@coinbase/cdp-sdk` 1.54.0, `@x402/core` 2.19.0, `@x402/evm` 2.19.0, viem, Pi 0.80.6; Base Sepolia and test USDC only. +**Approved protocol amendment (2026-08-02):** Approval is asynchronous at the +application boundary. The proxy immediately returns `payment_approval_required`; it +does not hold the request open or poll. If the Operator approves, the Wielder repeats +the ordinary request, preferably with the retained non-authorizing `x-agent-call-id`. +A fresh call ID must be atomically aliased to the active exact fingerprint before +signing. Exactly one same-key automatic retry is allowed only when the local Skill +fetch throws or reading its response body fails. This amendment supersedes any +implementation behavior that uses a +connected approval wait or application-level automatic replay. + --- ## Scope, ordering, and baseline @@ -115,7 +125,7 @@ prelaunch contract before it can claim `cdp-testnet` support. digest enrollment, revocation, and distinct-identity binding. - `spikes/pi-wielder/src/kernel/intent-builder.mjs` — kernel-issued Spend Sessions, exact request capture, intent hashing, and retry matching without caller-owned - payment headers. + payment headers; Agent call IDs are non-authorizing correlation inputs. - `spikes/pi-wielder/src/kernel/budget-ledger.mjs` — atomic reserve, commit, release, unresolved hold, per-seller/session, full-session, and rolling-24-hour accounting. - `spikes/pi-wielder/src/kernel/approval-queue.mjs` — exact one-time approvals, @@ -2115,10 +2125,18 @@ assert.equal(intents.matchRetry({ sessionId: 'another-session', request }), null assert.equal(intents.matchRetry({ sessionId: session.id, request: changedBody }), null); ``` -Race two initial captures with the same session, normalized ordinary request, and -purpose. Assert both resolve to the same persisted intent/request ID, exactly one -`intent.captured` event exists, and no second approval can later be created. A -different session or fingerprint still creates its own intent. +Race two initial captures with the same session, correlation ID derived from one +`x-agent-call-id`, normalized ordinary request, and purpose. Assert both resolve to the +same persisted intent/request ID, exactly one `intent.captured` event exists, and no +second approval can later be created. The correlation input grants no authority: a +different fingerprint in that credential/session scope fails closed, and presenting +the key through another credential/session cannot access the prior intent. While that +intent is retry-matchable, repeat the exact request with a fresh call ID and assert the +same transaction binds the ID as a correlation alias to the existing intent before any +signing can begin. Same-key replay through that alias returns the existing result. Once +no matching retryable intent remains, a fresh unbound call ID creates a new intent for +a later legitimate call even when its ordinary payload is identical; either old bound +ID continues to resolve to the prior terminal result. - [ ] **Step 2: Run the focused test and observe the missing module** @@ -2262,8 +2280,11 @@ the full URL or search text. The allowlist is exactly `accept`, `content-type`, and `user-agent`; values are trimmed, but order is canonicalized. Retry matching recomputes the URL hash. Generate the session ID, intent ID, public request ID, correlation identifier fallback, and `wk_` idempotency key -inside the repository. The request ID is opaque and safe to return to Pi; it is not an -approval capability and cannot authorize a retry by itself. +inside the repository. For the production Agent API, the proxy supplies a bounded +internal correlation ID derived from the authenticated request's canonical +`x-agent-call-id`; the raw key is not a payment-idempotency key and never reaches a +seller. The request ID and call ID are opaque and safe to return to or receive from Pi, +but neither is an approval capability nor can either authorize spending by itself. Derive and persist `ordinaryFingerprint` from route ID, canonical method, normalized URL, allowlisted-header hash, body hash, and purpose label; it deliberately excludes the Kernel-issued request ID and correlation ID. The partial unique index permits at most @@ -2277,6 +2298,10 @@ late identical follower always resolves to the existing request ID and receives terminal result or `REQUEST_IN_FLIGHT`; it can never create a second signing path. Only terminalization after settlement/rejection, a safe unsigned failure/denial, or trusted reconciliation may release the fingerprint for a later identical request. +Before that release, a fresh `x-agent-call-id` for the exact fingerprint is atomically +recorded as a correlation alias to the active intent, not captured as a second intent. +After release, a later legitimate request must use a fresh ID with no prior alias; +reusing any old bound ID continues to resolve to its prior terminal result. Derive `intentHash` from: ```js @@ -6156,7 +6181,11 @@ GET /agent/v1/receipts/:receiptId The proxy owns one durable Kernel Spend Session for the authenticated Pi identity. Assert Pi cannot choose a target URL, method, upstream headers, wallet, session ID, approval ID, -idempotency key, payment header, policy, amount, or payee. Reject unknown route IDs, +payment idempotency key, payment header, policy, amount, or payee. Require one +canonical 32-byte `x-agent-call-id` as a correlation/deduplication key and bind it to +the authenticated Agent credential, Kernel-owned Spend Session, and exact normalized +request fingerprint. It cannot convey Approval, wallet, payment, or routing authority, +and it never reaches the seller. Reject unknown route IDs, wrong methods/content types, oversized bodies, forbidden headers, URL-like path segments, and operator paths. @@ -6209,9 +6238,14 @@ settled-but-undeliverable `execution_unknown` to `502`. Both execution outcomes retain the committed-payment receipt. The resource body is the byte-bounded upstream output held only in process memory; return only its declared content type and no upstream cookies, authorization, or hop-by-hop headers. Never journal that body or return raw signed bytes, approval ID, -operator identity, provider error, or internal database identifier. An exact ordinary -retry after approval resolves via the proxy’s durable credential-bound session and request -fingerprint. Agent receipt/status lookup is restricted to the proxy-owned current +operator identity, provider error, or internal database identifier. Return +`payment_approval_required` immediately; never honor `Prefer: wait`, keep the request +connected, or poll the Operator plane. An exact ordinary request repeated by the +Wielder after approval resolves via the proxy's durable credential-bound Spend +Session and request fingerprint. Reusing its prior call ID is preferred. If the repeat +uses a fresh ID, the Kernel atomically binds it as a correlation alias to that active +fingerprint before signing; response-loss replay with the new ID is then safe. Agent +receipt/status lookup is restricted to the proxy-owned current Spend Session and uses opaque identifiers. The OpenAI-compatible route passes through a bounded valid OpenAI response body only @@ -6370,7 +6404,9 @@ ordinary OpenAI/tool JSON plus `Content-Type` and the local-only `Authorization: WalletKernelAgent ` loaded from the owner-only credential file; the proxy authenticates then strips that header before seller contact. Neither sets any forbidden payment, idempotency, approval, session, or wallet header. The extension -receives only `WALLET_KERNEL_AGENT_CREDENTIAL_FILE`, opens it once with `O_NOFOLLOW`, +also assigns one canonical `x-agent-call-id` to each logical execution request; the +proxy consumes that non-authorizing key locally and never forwards it upstream. The +extension receives only `WALLET_KERNEL_AGENT_CREDENTIAL_FILE`, opens it once with `O_NOFOLLOW`, validates the credential file's regular-file/current-owner/`0600` state, parses the closed credential from those bounded bytes, and keeps the token in memory. It never validates a path and then reopens it. @@ -6385,10 +6421,23 @@ memory. Contract tests prove a hostile origin cannot receive or cause a read of the credential. Render `payment_approval_required` with seller, amount, purpose, and expiry, then tell -the Wielder to retry the same tool call after an operator decision. Render denial and -definite payment failure separately from unresolved reason codes, with a compact -receipt ID/hash. Never auto-poll, auto-retry, or -open the operator console. +the Wielder to repeat the same ordinary tool call after an operator approval, reusing +its prior call ID when retained. A fresh ID remains valid through the Kernel's atomic +active-fingerprint aliasing. Return that result immediately: never set a connected-wait +preference, auto-poll, perform an application-level automatic retry, or open the +operator console. Render denial and definite payment failure separately from +unresolved reason codes, with a compact receipt ID/hash. + +The Skill tool derives one stable `x-agent-call-id` from the validated Pi `toolCallId`. +It may perform exactly one automatic same-key replay only when `fetch` throws or +response-body reading fails. It never retries a received response with an invalid +content type or malformed JSON, nor a received approval, denial, failure, or other +application result. The model hook +generates a fresh key for a new logical model call, retains it across an error until Pi +reports a terminal outcome, and then rotates it. Every later legitimate Skill or model +call gets a fresh key even if its ordinary request bytes are identical to an earlier +terminal call. That key creates a new intent only when no active retry-matchable exact +fingerprint and no prior binding for the key exists. Extend `pi-extension-contract.test.mjs` to scan the extension source for all forbidden headers and imports, assert the fixed `/agent/v1/openai/` and `/agent/v1/invoke/` @@ -6596,7 +6645,9 @@ separate children on dynamically assigned loopback ports. Drive and assert: ```text 1. policy-allowed exact payment settles once and returns a verifiable receipt 2. untrusted seller and over-budget request deny before signer call -3. approval-needed survives Kernel restart; operator approves; exact Pi retry settles +3. approval-needed returns immediately and survives Kernel restart; operator approves; + a deliberate exact Pi request settles through the active fingerprint, both with its + retained call ID and with a fresh ID atomically bound as an alias before signing 4. operator denial and approval expiry never sign 5. a changed challenge after approval terminalizes the old approval and never signs it 6. table-driven settled HTTP 302/404/500 commits spend, opens refund-pending, never follows redirect, and blocks new wallet spend diff --git a/docs/superpowers/specs/2026-07-31-agent-spend-control-plane-design.md b/docs/superpowers/specs/2026-07-31-agent-spend-control-plane-design.md index 1f20ae9..9e0ef79 100644 --- a/docs/superpowers/specs/2026-07-31-agent-spend-control-plane-design.md +++ b/docs/superpowers/specs/2026-07-31-agent-spend-control-plane-design.md @@ -1,6 +1,7 @@ # Agent Spend Control Plane — Design - **Status:** Approved in design review on 2026-07-31 +- **Approval-flow amendment:** Approved on 2026-08-02 - **Implementation base:** `codex/prd-execution` at `6f7006055cd14ca0b5c5961c7d0a3d09eff044ef` - **Initial network:** Base Sepolia (`eip155:84532`) @@ -161,6 +162,23 @@ The proxy returns one of: - a stable policy or protocol denial; - a stable unresolved-payment or reconciliation error. +Every Agent execution request carries a stable, canonical 32-byte +`x-agent-call-id`. It is only a deduplication and correlation key. The proxy scopes it +beneath the authenticated Agent credential and resulting Spend Session, then binds it +to the exact ordinary-request fingerprint. Within that scope, reuse for a different +fingerprint fails closed; presenting the same key through another credential or session +cannot access the prior intent. The key cannot select or convey an Approval, wallet, +seller, payee, amount, PolicyVersion, Spend Session, payment idempotency key, payment +signature, or other spending authority, and it is never forwarded to the resource +server. + +If an exact request arrives under a fresh call ID while a retry-matchable intent for +that fingerprint remains active, the Kernel atomically binds the fresh ID as a +correlation alias to the existing intent before any signing transition. It does not +create a second intent. A fresh ID creates a later legitimate identical intent only +when no active retry-matchable fingerprint exists and that ID has no prior binding or +alias. + The agent cannot supply `PAYMENT-SIGNATURE`, payment identity, idempotency, or approval headers. The proxy exclusively owns those fields. @@ -368,9 +386,16 @@ received boundary. 4. Persist the policy decision. 5. For `deny`, terminate without reservation or signature. -6. For `approval_required`, persist the challenge and wait for an exact operator - decision. Expiry terminates the approval; it does not create reusable authority. -7. For `allow` or exact approval, atomically reserve the maximum amount. +6. For `approval_required`, persist the challenge and immediately return + `payment_approval_required`. Do not keep the Agent request connected and do not + poll for a decision. Expiry terminates the approval; it does not create reusable + authority. +7. For `allow`, atomically reserve the maximum amount. After an exact operator + approval, the Wielder deliberately repeats the same ordinary request, preferably + retaining its `x-agent-call-id`. If it arrives with a fresh call ID, the Kernel + atomically binds that ID as an alias to the active exact fingerprint before any + signing transition. It then revalidates the approved intent and atomically reserves + the maximum amount. 8. Claim the one-time signing transition and call the Wallet Adapter. 9. Persist the exact signature and payment payload before any paid retry. 10. Perform exactly one paid retry. @@ -389,6 +414,9 @@ received unresolved. - Missing response, timeout, or crash never causes a replacement signature or blind paid retry. +- An Agent-level response loss may repeat the exact request with the same + `x-agent-call-id`; this resolves to the existing intent and cannot create a second + signing path. A different request with that key fails closed. - A settled payment followed by failed execution remains settled and enters `refund_pending` or `reconciliation_required`. - An execution response without matching settlement evidence is withheld. @@ -427,10 +455,27 @@ ID. Pi receives `payment_approval_required` with a request ID and expiry. The local console shows seller, resource, request hash, amount ceiling, wallet, policy -mismatch, and expiry. The operator approves once or denies. Pi then repeats only -the exact ordinary request while the approval remains valid. The proxy resolves -that retry against the exact approved intent using its kernel-owned session and -idempotency mapping; Pi does not send an approval or idempotency header. +mismatch, and expiry. The response is immediate: neither Pi nor the proxy keeps the +request connected, polls the approval, or performs an application-level automatic +retry. The operator approves once or denies. If approved, the Wielder makes a new +ordinary request with the exact prior fingerprint while the approval remains valid. It +should reuse the prior `x-agent-call-id` when retained, but a fresh ID is accepted: the +Kernel atomically records it as an alias to the active exact intent before signing. The +proxy resolves either form using the bound Agent credential, kernel-owned Spend +Session, request fingerprint, and idempotency mapping; Pi does not send an approval or +payment-idempotency header. + +The sole automatic replay exception is inside the local Skill fetch: a thrown fetch or +a failure while reading the response body may trigger exactly one retry with the same +`x-agent-call-id`. A received response with an invalid content type, malformed JSON, +or any application result—including `payment_approval_required`—is not retryable. +This is transport-loss recovery, not approval handling or application-level retry. The +Kernel's durable deduplication means a settled response loss cannot spend twice. The +model-provider hook likewise retains one call key across an error until Pi reports a +terminal outcome; it does not poll or replay an approval response itself. A later +legitimate invocation uses a fresh `x-agent-call-id`; for an identical payload, it +creates a new Spend Intent only when no active retry-matchable intent or binding for +that fresh key exists. ### Local console diff --git a/spikes/pi-wielder/README.md b/spikes/pi-wielder/README.md index 346099e..249960d 100644 --- a/spikes/pi-wielder/README.md +++ b/spikes/pi-wielder/README.md @@ -53,6 +53,37 @@ x402 authorizations, injected adapters verify and synthesize settlement, and can model responses avoid external APIs. Mock execution is fail-closed and remains the default when `MOCK_LLM` is unset. +## Agent approval and call identity + +Approval is asynchronous at the Agent boundary. Pi receives +`payment_approval_required` immediately, and the Operator decides through the separate +Operator interface. If approved, the Wielder repeats the ordinary request. Neither +the proxy nor Pi keeps the original HTTP request connected, polls for approval, or +performs an application-level automatic replay. + +Each logical call carries a canonical 32-byte `x-agent-call-id`. It is only a durable +deduplication/correlation key, scoped beneath the authenticated Agent credential and +Kernel-owned Spend Session, then bound to the exact request fingerprint. It cannot +identify or authorize an Approval, wallet, seller, payee, amount, policy, payment +idempotency key, signature, or payment header, and the proxy never forwards it to the +seller. The approval continuation repeats the exact ordinary request and preferably +reuses its call ID when Pi retains it. A fresh continuation ID is also valid: while the +exact fingerprint remains retry-matchable, the Kernel atomically binds that ID as a +correlation alias to the active intent before signing. Changing the fingerprint under +an already bound ID fails closed, while another credential/session cannot use the key +to access the prior intent. + +The only automatic same-key replay is one lower-level retry when the local Skill's +`fetch` throws or reading the response body fails. This lets a settled response loss +resolve to the existing receipt instead of spending twice. A received invalid content +type, malformed JSON, `payment_approval_required`, or any other application result is +never automatically retried. The model hook retains its call key across an error until +Pi reports a terminal outcome. Every later legitimate Skill or model call receives a +fresh key, even when its ordinary payload is identical to an earlier terminal call. +For an identical payload, that key creates a new intent only when no active +retry-matchable intent exists and the key has no prior binding or alias; otherwise it +resolves to the existing intent. + ## Legacy Collar accounting authority The Collar's append-only Invocation journal is authoritative for hosted Skill @@ -377,8 +408,8 @@ detail. - The proxy trusts an operator-pinned public key file and one SHA-256 key ID of its SPKI DER. A key ID or key embedded in a receipt cannot authenticate that receipt. - The Pi extension is pinned to Pi `0.80.6`; offline tests import its TypeScript, - exercise the real five-argument tool ABI, and pin fixed model/Skill routes plus - same-key retry behavior. Installing it into a Pi host remains manual. + exercise the real five-argument tool ABI, and pin fixed model/Skill routes plus the + single transport-loss same-key retry. Installing it into a Pi host remains manual. - Successful mock accounting records synthetic-config provider usage and allocates execution COGS and settlement cost before the Royalty pool. It is executable evidence of ordering and conservation, not a validated production margin model or current diff --git a/spikes/pi-wielder/RUNBOOK.md b/spikes/pi-wielder/RUNBOOK.md index 03e8498..ddb95e9 100644 --- a/spikes/pi-wielder/RUNBOOK.md +++ b/spikes/pi-wielder/RUNBOOK.md @@ -314,12 +314,19 @@ cp /spikes/pi-wielder/pi-extension/x402.ts .pi/extensions/ With the Wallet Kernel's loopback Agent API running, start the exact compatible Pi version and reload extensions. The extension points model calls and `invoke_skill` at -fixed local routes. Model retries retain one logical call ID until success or final -failure. The tool uses its real `toolCallId` to derive a stable call ID and performs at -most one same-key retry after a transport/read failure. A completed replay returns a -non-success JSON envelope: the charge and signed receipt remain available, but provider -output is not fabricated or retained. `npm run e2e:spend-control` exercises this path -fully offline without external providers or chain access. +fixed local routes. A new logical model call gets a fresh call ID; its hook retains +that ID across an error until Pi reports a terminal outcome, then rotates it. The tool +uses its real `toolCallId` to derive a stable call ID and performs at most one automatic +same-key retry only when its local `fetch` throws or response-body reading fails. It +never automatically retries a received invalid content type, malformed JSON, or any +HTTP/application result, including `payment_approval_required`. A completed replay +returns a non-success JSON envelope: the charge and signed receipt remain available, +but provider output is not fabricated or retained. Later legitimate model and Skill +calls use fresh IDs even when their ordinary payloads are identical. For an identical +payload, a fresh ID creates a new intent only when no active retry-matchable intent or +prior alias exists; otherwise the Kernel resolves it to the active or previously bound +intent. `npm run e2e:spend-control` exercises this path fully offline without external +providers or chain access. ## 7. Manual-only boundaries @@ -340,9 +347,15 @@ HTTP method, seller, payee, amount, policy, Spend Session, approval ID, payment idempotency key, or payment header. Those values come from the active PolicyVersion, fixed route map, durable intent, and wallet adapter. Pi must provide a separate 32-byte `x-agent-call-id`; it grants no spend authority, never reaches the seller, and -is bound by the Kernel to the exact session, route, method, body, allowlisted headers, -and purpose. Reusing it for the same completed call cannot pay again; reusing it for a -different request fails with `CORRELATION_CONFLICT`. +is bound by the Kernel to the authenticated Agent credential, exact Spend Session, +route, method, body, allowlisted headers, and purpose. It cannot convey an Approval, +wallet, payee, amount, policy, payment idempotency key, signature, or payment header. +Reusing it for the same completed call cannot pay again; reusing it for a different +request fails with `CORRELATION_CONFLICT`. A later legitimate call, including an +identical one, must use a fresh key. While an exact fingerprint remains active, a fresh +key is atomically recorded as its correlation alias before signing rather than creating +a second intent. Only a fresh unbound key with no active exact fingerprint can create a +later identical intent. The policy decision is made before signing and enforces all of these independent limits: @@ -569,9 +582,19 @@ npm run operator -- approvals deny \ --confirm sha256: --reason OPERATOR_DENIED --json ``` +The Agent request that creates the approval returns +`payment_approval_required` immediately. Do not add `Prefer: wait`, keep that request +connected, poll the Operator API from Pi, or automatically replay the application +request. After Operator approval, the Wielder deliberately repeats the exact +ordinary request, preferably reusing its `x-agent-call-id`. If Pi supplies a fresh call +ID, the Kernel atomically binds it as a correlation alias to the active exact +fingerprint before signing, so a same-ID response-loss replay cannot spend twice. Pi +never sends the approval ID, wallet identity, or payment authority. + Use only IDs and confirmation hashes from a fresh authenticated projection. Approval is compare-and-swap, scoped to the exact intent, and bounded by its expiry. An expired -approval is not renewed or widened; the Agent must submit a fresh ordinary request. +approval is not renewed or widened; the Agent must submit a fresh ordinary request with +a fresh call ID. ### Receipt and session observation diff --git a/spikes/pi-wielder/pi-extension/x402.ts b/spikes/pi-wielder/pi-extension/x402.ts index 95c9074..a7c88dd 100644 --- a/spikes/pi-wielder/pi-extension/x402.ts +++ b/spikes/pi-wielder/pi-extension/x402.ts @@ -21,7 +21,6 @@ const DEFAULT_SKILL_ROUTE = "example-skill"; const MAXIMUM_CREDENTIAL_BYTES = 256; const MAXIMUM_TOOL_INPUT_BYTES = 262_144; const MAXIMUM_RESPONSE_BYTES = 1_048_576; -const APPROVAL_WAIT_PREFERENCE = "wait=300"; const TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const INSTANCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{21}$/; const CREDENTIAL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; @@ -452,7 +451,12 @@ async function readBoundedOutcome(response: Response): Promise { if (!/^application\/json(?:;[ \t]*charset=[A-Za-z0-9._-]+)?$/i.test(contentType)) { fail("PI_KERNEL_RESPONSE_INVALID", "Wallet Kernel response is not JSON"); } - const text = await response.text(); + let text: string; + try { + text = await response.text(); + } catch { + fail("PI_KERNEL_RESPONSE_READ_FAILED", "Wallet Kernel response body could not be read"); + } if (Buffer.byteLength(text, "utf8") > MAXIMUM_RESPONSE_BYTES) { fail("PI_KERNEL_RESPONSE_INVALID", "Wallet Kernel response is oversized"); } @@ -508,6 +512,12 @@ function replaceHeader( headers[name] = value; } +function removeHeader(headers: Record, name: string) { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === name) delete headers[key]; + } +} + function throwIfAborted(signal: AbortSignal | undefined) { if (signal?.aborted) { throw new DOMException("Wallet Kernel call aborted", "AbortError"); @@ -535,7 +545,6 @@ export default function activate( const headers = Object.freeze({ Authorization: `WalletKernelAgent ${config.credential.token}`, "Content-Type": "application/json", - Prefer: APPROVAL_WAIT_PREFERENCE, }); const walletAuthorization = headers.Authorization; let pendingModelCallId: string | null = null; @@ -543,7 +552,7 @@ export default function activate( pi.on("before_provider_headers", (event) => { if (exactHeader(event.headers, "authorization") !== walletAuthorization) return; pendingModelCallId ??= randomAgentCallId(); - replaceHeader(event.headers, "Prefer", APPROVAL_WAIT_PREFERENCE); + removeHeader(event.headers, "prefer"); replaceHeader(event.headers, "x-agent-call-id", pendingModelCallId); }); pi.on("message_end", (event) => { @@ -616,8 +625,9 @@ export default function activate( }); for (let attempt = 0; attempt < 2; attempt += 1) { throwIfAborted(signal); + let response: Response; try { - const response = await fetchFn( + response = await fetchFn( `${config.origin}/agent/v1/invoke/${config.skillRoute}`, { method: "POST", @@ -626,9 +636,24 @@ export default function activate( signal, }, ); + } catch { + throwIfAborted(signal); + if (attempt === 1) { + return agentToolText( + "Wallet Kernel unavailable after one same-key retry.", + "unavailable", + ); + } + continue; + } + try { const outcome = await readBoundedOutcome(response); return agentToolText(renderWalletKernelOutcome(outcome), "returned"); - } catch { + } catch (error) { + if (!(error instanceof PiBoundaryError) + || error.code !== "PI_KERNEL_RESPONSE_READ_FAILED") { + throw error; + } throwIfAborted(signal); if (attempt === 1) { return agentToolText( diff --git a/spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs b/spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs index b01d6c2..209c188 100644 --- a/spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs +++ b/spikes/pi-wielder/scripts/lib/spend-control-process-runner.mjs @@ -61,13 +61,21 @@ export const SPEND_CONTROL_PROCESS_CHILD_NAMES = Object.freeze([ 'bootstrap', 'control-initial', 'control-restarted', - 'pi-tool-approval', - 'pi-model-approval', + 'pi-tool-approval-first', + 'pi-tool-approval-second', + 'pi-model-approval-first', + 'pi-model-approval-second', 'control-recovery', 'bootstrap-replacement', 'control-replacement', 'control-verifier', ]); +export const SPEND_CONTROL_PROCESS_EXPECTED_EXIT_CODES = Object.freeze(Object.fromEntries( + SPEND_CONTROL_PROCESS_CHILD_NAMES.map((name) => Object.freeze([ + name, + name === 'pi-model-approval-first' ? 1 : 0, + ])), +)); const buyerAccount = privateKeyToAccount( keccak256(toBytes('wallet-kernel-deterministic-adapter-test-only')), @@ -1637,40 +1645,71 @@ export async function runSpendControlProcessAcceptance({ name, modelRoute, skillRoute, - modelRequestsBeforeApproval, - modelRequestsAfterCompletion, + promptMode, + expectedFirstExitCode, + expectedFirstOutput, + modelRequestsFirstAttempt, + modelRequestsSecondAttempt, }) => { const baseline = await processOverview(); const knownApprovalIds = new Set( (baseline.data?.approvals ?? []).map(({ approvalId }) => approvalId), ); + const knownIntentHashes = new Set( + (baseline.projection?.intents ?? []).map(({ intentHash }) => intentHash), + ); const beforeSigner = signerCount(kernelStatePath); const beforeSeller = sellerState(sellerStatePath); const beforeModel = modelState(modelStatePath); - const pi = start({ - name, - nodeExecutable: node, - script: PI_PROCESS, - env: childEnvironment(node, PRELOAD, egressLogs[name], { - WALLET_KERNEL_FIXTURE_PI_DIRECTORY: piDirectory, - WALLET_KERNEL_AGENT_CREDENTIAL_FILE: credentialPath, - WALLET_KERNEL_FIXTURE_PRELOAD: PRELOAD, - WALLET_KERNEL_ORIGIN: running.ready.agentOrigin, - WALLET_KERNEL_PROVIDER_NAME: 'wallet-kernel-e2e', - WALLET_KERNEL_MODEL_NAME: 'scripted-local', - WALLET_KERNEL_MODEL_ROUTE: modelRoute, - WALLET_KERNEL_SKILL_ROUTE: skillRoute, - }), - }); - let exited = false; - void pi.exited.then(() => { exited = true; }); + const startPiAttempt = (attempt, expectedOutput) => { + const childName = `${name}-${attempt}`; + return start({ + name: childName, + nodeExecutable: node, + script: PI_PROCESS, + env: childEnvironment(node, PRELOAD, egressLogs[childName], { + WALLET_KERNEL_FIXTURE_PI_DIRECTORY: piDirectory, + WALLET_KERNEL_AGENT_CREDENTIAL_FILE: credentialPath, + WALLET_KERNEL_FIXTURE_PRELOAD: PRELOAD, + WALLET_KERNEL_ORIGIN: running.ready.agentOrigin, + WALLET_KERNEL_PROVIDER_NAME: 'wallet-kernel-e2e', + WALLET_KERNEL_MODEL_NAME: 'scripted-local', + WALLET_KERNEL_MODEL_ROUTE: modelRoute, + WALLET_KERNEL_SKILL_ROUTE: skillRoute, + WALLET_KERNEL_PI_PROMPT_MODE: promptMode, + WALLET_KERNEL_PI_EXPECTED_OUTPUT: expectedOutput, + }), + }); + }; + const firstPi = startPiAttempt('first', expectedFirstOutput); + let firstResult; + try { + firstResult = await firstPi.waitMessage((message) => message.type === 'result'); + } catch { + firstResult = Object.freeze({ + exitCode: 1, + piVersion: '0.80.6', + outputObserved: 'missing', + }); + } + const firstExit = await firstPi.waitExit(); + processExitCodes[`${name}-first`] = firstExit.code; const observed = await waitForFreshPendingApproval(knownApprovalIds); - await delay(150); - const modelWhilePending = modelState(modelStatePath); - const originalRequestHeld = observed.pending !== null - && exited === false - && modelWhilePending.requestCount - === beforeModel.requestCount + modelRequestsBeforeApproval; + const overviewAfterFirst = observed.overview ?? await processOverview(); + const afterFirstSigner = signerCount(kernelStatePath); + const afterFirstSeller = sellerState(sellerStatePath); + const afterFirstModel = modelState(modelStatePath); + const firstIntent = (overviewAfterFirst.projection?.intents ?? []).find( + ({ intentHash }) => intentHash === observed.pending?.intentHash, + ) ?? null; + const firstAttempt = Object.freeze({ + pendingObserved: observed.pending !== null, + exitedBeforeOperatorApproval: firstExit.code === expectedFirstExitCode, + signerDelta: afterFirstSigner - beforeSigner, + paidRequestDelta: afterFirstSeller.paidRequestCount - beforeSeller.paidRequestCount, + outputObserved: firstResult.outputObserved ?? 'missing', + processExitCode: firstExit.code, + }); let operatorApprovalStatus = 0; if (observed.pending !== null) { const approved = await operatorRequest( @@ -1684,36 +1723,54 @@ export async function runSpendControlProcessAcceptance({ ); operatorApprovalStatus = approved.status; } - let result; + const secondPi = startPiAttempt('second', 'PI_WALLET_OK'); + let secondResult; try { - result = await pi.waitMessage((message) => message.type === 'result'); + secondResult = await secondPi.waitMessage((message) => message.type === 'result'); } catch { - result = Object.freeze({ + secondResult = Object.freeze({ exitCode: 1, piVersion: '0.80.6', outputObserved: 'missing', }); } - const exit = await pi.waitExit(); - processExitCodes[name] = exit.code; + const secondExit = await secondPi.waitExit(); + processExitCodes[`${name}-second`] = secondExit.code; + const overviewAfterSecond = await processOverview(); const afterSeller = sellerState(sellerStatePath); const afterModel = modelState(modelStatePath); + const matchingIntent = (overviewAfterSecond.projection?.intents ?? []).find( + ({ intentHash }) => intentHash === firstIntent?.intentHash, + ) ?? null; + const retryRouteIntentCount = (overviewAfterSecond.projection?.intents ?? []).filter( + ({ intentHash, routeHash }) => !knownIntentHashes.has(intentHash) + && routeHash === firstIntent?.routeHash, + ).length; const publicResult = Object.freeze({ - pendingObserved: observed.pending !== null, - originalRequestHeld, + firstAttempt, operatorApprovalStatus, - signerDelta: signerCount(kernelStatePath) - beforeSigner, - paidRequestDelta: afterSeller.paidRequestCount - beforeSeller.paidRequestCount, - duplicatePaymentSignatureDelta: afterSeller.duplicatePaymentSignatureCount - - beforeSeller.duplicatePaymentSignatureCount, - outputObserved: result.outputObserved ?? 'missing', - processExitCode: exit.code, + secondAttempt: Object.freeze({ + sameRequestFingerprint: firstIntent !== null + && firstIntent.intentHash === observed.pending?.intentHash + && matchingIntent !== null + && matchingIntent.outcome?.status === 'completed' + && retryRouteIntentCount === 1, + signerDelta: signerCount(kernelStatePath) - afterFirstSigner, + paidRequestDelta: afterSeller.paidRequestCount - afterFirstSeller.paidRequestCount, + duplicatePaymentSignatureDelta: afterSeller.duplicatePaymentSignatureCount + - afterFirstSeller.duplicatePaymentSignatureCount, + outputObserved: secondResult.outputObserved ?? 'missing', + processExitCode: secondExit.code, + }), }); return Object.freeze({ publicResult, - modelRequestDelta: afterModel.requestCount - beforeModel.requestCount, - expectedModelRequestDelta: modelRequestsAfterCompletion, - result, + firstModelRequestDelta: afterFirstModel.requestCount - beforeModel.requestCount, + secondModelRequestDelta: afterModel.requestCount - afterFirstModel.requestCount, + modelRequestsFirstAttempt, + modelRequestsSecondAttempt, + firstResult, + secondResult, }); }; @@ -1721,30 +1778,44 @@ export async function runSpendControlProcessAcceptance({ name: 'pi-tool-approval', modelRoute: 'free-model', skillRoute: 'approval', - modelRequestsBeforeApproval: 1, - modelRequestsAfterCompletion: 2, + promptMode: 'tool', + expectedFirstExitCode: 0, + expectedFirstOutput: 'PI_APPROVAL_REQUIRED', + modelRequestsFirstAttempt: 2, + modelRequestsSecondAttempt: 2, }); const piModelApproval = await runPinnedPiApproval({ name: 'pi-model-approval', modelRoute: 'approval-model', skillRoute: 'example-skill', - modelRequestsBeforeApproval: 0, - modelRequestsAfterCompletion: 1, + promptMode: 'model', + expectedFirstExitCode: 1, + expectedFirstOutput: 'approval-required-error', + modelRequestsFirstAttempt: 0, + modelRequestsSecondAttempt: 1, }); piApprovalResume = Object.freeze({ tool: piToolApproval.publicResult, model: piModelApproval.publicResult, }); const piApprovalProved = [piToolApproval, piModelApproval].every((entry) => ( - entry.publicResult.pendingObserved === true - && entry.publicResult.originalRequestHeld === true + entry.publicResult.firstAttempt.pendingObserved === true + && entry.publicResult.firstAttempt.exitedBeforeOperatorApproval === true + && entry.publicResult.firstAttempt.signerDelta === 0 + && entry.publicResult.firstAttempt.paidRequestDelta === 0 + && entry.publicResult.firstAttempt.outputObserved + === (entry === piToolApproval ? 'PI_APPROVAL_REQUIRED' : 'approval-required-error') + && entry.publicResult.firstAttempt.processExitCode + === (entry === piToolApproval ? 0 : 1) && entry.publicResult.operatorApprovalStatus === 200 - && entry.publicResult.signerDelta === 1 - && entry.publicResult.paidRequestDelta === 1 - && entry.publicResult.duplicatePaymentSignatureDelta === 0 - && entry.publicResult.outputObserved === 'PI_WALLET_OK' - && entry.publicResult.processExitCode === 0 - && entry.modelRequestDelta === entry.expectedModelRequestDelta + && entry.publicResult.secondAttempt.sameRequestFingerprint === true + && entry.publicResult.secondAttempt.signerDelta === 1 + && entry.publicResult.secondAttempt.paidRequestDelta === 1 + && entry.publicResult.secondAttempt.duplicatePaymentSignatureDelta === 0 + && entry.publicResult.secondAttempt.outputObserved === 'PI_WALLET_OK' + && entry.publicResult.secondAttempt.processExitCode === 0 + && entry.firstModelRequestDelta === entry.modelRequestsFirstAttempt + && entry.secondModelRequestDelta === entry.modelRequestsSecondAttempt )); const approvalObservation = observations.get('approval-survives-restart'); recordObservation( @@ -1754,17 +1825,24 @@ export async function runSpendControlProcessAcceptance({ [ ...(approvalObservation?.facts ?? ['not_exercised']), ...Object.values(piApprovalResume).flatMap((entry) => [ - entry.pendingObserved, - entry.originalRequestHeld, + entry.firstAttempt.pendingObserved, + entry.firstAttempt.exitedBeforeOperatorApproval, + entry.firstAttempt.signerDelta, + entry.firstAttempt.paidRequestDelta, + entry.firstAttempt.outputObserved, + entry.firstAttempt.processExitCode, entry.operatorApprovalStatus, - entry.signerDelta, - entry.paidRequestDelta, - entry.duplicatePaymentSignatureDelta, - entry.outputObserved, - entry.processExitCode, + entry.secondAttempt.sameRequestFingerprint, + entry.secondAttempt.signerDelta, + entry.secondAttempt.paidRequestDelta, + entry.secondAttempt.duplicatePaymentSignatureDelta, + entry.secondAttempt.outputObserved, + entry.secondAttempt.processExitCode, ]), - piToolApproval.modelRequestDelta, - piModelApproval.modelRequestDelta, + piToolApproval.firstModelRequestDelta, + piToolApproval.secondModelRequestDelta, + piModelApproval.firstModelRequestDelta, + piModelApproval.secondModelRequestDelta, ], ); piResult = Object.freeze({ @@ -2146,13 +2224,15 @@ export async function runSpendControlProcessAcceptance({ const finalModelState = modelState(modelStatePath); const exactChildProcessSet = canonicalJson(Object.keys(processExitCodes)) === canonicalJson(SPEND_CONTROL_PROCESS_CHILD_NAMES); - const allChildProcessesExitedCleanly = exactChildProcessSet - && SPEND_CONTROL_PROCESS_CHILD_NAMES.every((name) => processExitCodes[name] === 0); + const allChildProcessesExitedAsExpected = exactChildProcessSet + && SPEND_CONTROL_PROCESS_CHILD_NAMES.every((name) => ( + processExitCodes[name] === SPEND_CONTROL_PROCESS_EXPECTED_EXIT_CODES[name] + )); const freshProcessObservation = observations.get('fresh-process-verifies-authority'); recordObservation( observations, 'fresh-process-verifies-authority', - freshProcessObservation?.passed === true && allChildProcessesExitedCleanly, + freshProcessObservation?.passed === true && allChildProcessesExitedAsExpected, [ ...(freshProcessObservation?.facts ?? ['not_exercised']), exactChildProcessSet, @@ -2191,12 +2271,17 @@ export async function runSpendControlProcessAcceptance({ 'pi-carries-no-authority-headers', piResult.exitCode === 0 && piResult.outputObserved === 'PI_WALLET_OK' - && processExitCodes['pi-tool-approval'] === 0 - && processExitCodes['pi-model-approval'] === 0 + && processExitCodes['pi-tool-approval-first'] === 0 + && processExitCodes['pi-tool-approval-second'] === 0 + && processExitCodes['pi-model-approval-first'] === 1 + && processExitCodes['pi-model-approval-second'] === 0 && finalModelState.forbiddenAuthorityHeaderCount === 0 && finalSellerState.forbiddenForwardedHeaderCount === 0, [piResult.exitCode, piResult.outputObserved, - processExitCodes['pi-tool-approval'], processExitCodes['pi-model-approval'], + processExitCodes['pi-tool-approval-first'], + processExitCodes['pi-tool-approval-second'], + processExitCodes['pi-model-approval-first'], + processExitCodes['pi-model-approval-second'], finalModelState.forbiddenAuthorityHeaderCount, finalSellerState.forbiddenForwardedHeaderCount], ); diff --git a/spikes/pi-wielder/scripts/run-evidence.mjs b/spikes/pi-wielder/scripts/run-evidence.mjs index 4d20624..1c9d28d 100644 --- a/spikes/pi-wielder/scripts/run-evidence.mjs +++ b/spikes/pi-wielder/scripts/run-evidence.mjs @@ -15,6 +15,7 @@ import { canonicalJson, sha256 } from '../src/kernel/canonical.mjs'; import { runSpendControlProcessAcceptance, SPEND_CONTROL_PROCESS_CHILD_NAMES, + SPEND_CONTROL_PROCESS_EXPECTED_EXIT_CODES, SPEND_CONTROL_PROCESS_INVARIANT_IDS, } from './lib/spend-control-process-runner.mjs'; @@ -219,8 +220,10 @@ function validateAcceptanceOutput(result) { if (!exits || typeof exits !== 'object' || Array.isArray(exits) || canonicalJson(Object.keys(exits).sort()) !== canonicalJson([...SPEND_CONTROL_PROCESS_CHILD_NAMES].sort()) - || SPEND_CONTROL_PROCESS_CHILD_NAMES.some((name) => exits[name] !== 0)) { - fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance child set did not exit cleanly'); + || SPEND_CONTROL_PROCESS_CHILD_NAMES.some((name) => ( + exits[name] !== SPEND_CONTROL_PROCESS_EXPECTED_EXIT_CODES[name] + ))) { + fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance child exits did not match policy'); } const rawSettlementIds = acceptance.rawSettlementTransactionIds; const normalizedTransactionIds = acceptance.transactionIds; @@ -234,23 +237,34 @@ function validateAcceptanceOutput(result) { || new Set(normalizedTransactionIds).size !== normalizedTransactionIds.length) { fail('EVIDENCE_ACCEPTANCE_RESULT', 'process acceptance transaction authority was reused'); } - const expectedPiApproval = canonicalJson({ - pendingObserved: true, - originalRequestHeld: true, - operatorApprovalStatus: 200, - signerDelta: 1, - paidRequestDelta: 1, - duplicatePaymentSignatureDelta: 0, - outputObserved: 'PI_WALLET_OK', - processExitCode: 0, - }); const piApprovalResume = acceptance.piApprovalResume; if (!piApprovalResume || typeof piApprovalResume !== 'object' || Array.isArray(piApprovalResume) || ['tool', 'model'].some((kind) => { const entry = piApprovalResume[kind]; + const expected = { + firstAttempt: { + pendingObserved: true, + exitedBeforeOperatorApproval: true, + signerDelta: 0, + paidRequestDelta: 0, + outputObserved: kind === 'tool' + ? 'PI_APPROVAL_REQUIRED' + : 'approval-required-error', + processExitCode: kind === 'tool' ? 0 : 1, + }, + operatorApprovalStatus: 200, + secondAttempt: { + sameRequestFingerprint: true, + signerDelta: 1, + paidRequestDelta: 1, + duplicatePaymentSignatureDelta: 0, + outputObserved: 'PI_WALLET_OK', + processExitCode: 0, + }, + }; return !entry || typeof entry !== 'object' || Array.isArray(entry) - || canonicalJson(entry) !== expectedPiApproval; + || canonicalJson(entry) !== canonicalJson(expected); })) { fail('EVIDENCE_ACCEPTANCE_RESULT', 'pinned Pi approval resumption was not proven'); } diff --git a/spikes/pi-wielder/src/kernel/wallet-kernel.mjs b/spikes/pi-wielder/src/kernel/wallet-kernel.mjs index 9ddfa45..4c3ae4e 100644 --- a/spikes/pi-wielder/src/kernel/wallet-kernel.mjs +++ b/spikes/pi-wielder/src/kernel/wallet-kernel.mjs @@ -1982,16 +1982,10 @@ export function createWalletKernel(value) { purposeLabel: invocation.purposeLabel, correlationId: invocation.correlationId, }; - const matchedIntentId = intents.matchRetry({ + const intent = await runMutation(() => intents.captureIntent({ sessionId: invocation.sessionId, - request: intentRequest, - }); - const intent = matchedIntentId === null - ? await runMutation(() => intents.captureIntent({ - sessionId: invocation.sessionId, - ...intentRequest, - })) - : intents.getIntent(matchedIntentId); + ...intentRequest, + })); if (intent.state === 'terminal') { const existing = persistedResult(intent); if (existing === null) { diff --git a/spikes/pi-wielder/src/spend-control-proxy.mjs b/spikes/pi-wielder/src/spend-control-proxy.mjs index 665bb57..da40ca7 100644 --- a/spikes/pi-wielder/src/spend-control-proxy.mjs +++ b/spikes/pi-wielder/src/spend-control-proxy.mjs @@ -15,9 +15,6 @@ const SHA256 = /^sha256:[0-9a-f]{64}$/; const RECEIPT_HASH = /^[0-9a-f]{64}$/; const EVM_TRANSACTION = /^0x[0-9a-f]{64}$/; const ATOMIC = /^(0|[1-9][0-9]*)$/; -const APPROVAL_POLL_INTERVAL_MS = 25; -const MAXIMUM_CONNECTED_APPROVAL_WAIT_MS = 300_000; -const MAXIMUM_APPROVAL_TRANSITIONS = 8; const PUBLIC_OUTCOMES = new Set([ 'completed', 'upstream_failed', @@ -62,8 +59,6 @@ const PUBLIC_ERROR_STATUS = Object.freeze({ CORRELATION_CONFLICT: 409, AGENT_RESPONSE_INVALID: 502, AGENT_READ_NOT_FOUND: 404, - AGENT_REQUEST_ABORTED: 503, - AGENT_APPROVAL_WAIT_TIMEOUT: 503, }); const DEPENDENCY_FIELDS = Object.freeze([ @@ -205,13 +200,11 @@ function publicError(error) { AGENT_BODY_SCHEMA: 'Agent request body must be one valid JSON object', AGENT_BODY_TOO_LARGE: 'Agent request body exceeds its byte limit', AGENT_CALL_ID_INVALID: 'Agent call ID must be one canonical 32-byte token', - AGENT_PREFER_INVALID: 'Agent approval wait preference is invalid', + AGENT_PREFER_INVALID: 'Agent approval wait preference is forbidden', AGENT_FORBIDDEN_HEADER: 'Agent request contains a forbidden authority header', CORRELATION_CONFLICT: 'Agent call ID is already bound to a different request', AGENT_RESPONSE_INVALID: 'Upstream response could not be delivered safely', AGENT_READ_NOT_FOUND: 'Agent resource was not found', - AGENT_REQUEST_ABORTED: 'Agent request ended before approval completed', - AGENT_APPROVAL_WAIT_TIMEOUT: 'Agent approval wait reached its safety bound', AGENT_INTERNAL: 'Agent request failed', }[code]; return Object.freeze({ code, message }); @@ -318,19 +311,10 @@ function requiredAgentCallId(request) { return value; } -function requestedApprovalWaitMs(request) { - const value = request.headers.get('prefer'); - if (value === null) return 0; - if (!/^wait=(?:[1-9]|[1-9][0-9]|[12][0-9]{2}|300)$/u.test(value)) { - fail('AGENT_PREFER_INVALID', 'approval wait preference is malformed or out of bounds'); +function requireNoApprovalWaitPreference(request) { + if (request.headers.has('prefer')) { + fail('AGENT_PREFER_INVALID', 'approval wait preference is forbidden'); } - const milliseconds = Number(value.slice('wait='.length)) * 1_000; - if (!Number.isSafeInteger(milliseconds) - || milliseconds < 1_000 - || milliseconds > MAXIMUM_CONNECTED_APPROVAL_WAIT_MS) { - fail('AGENT_PREFER_INVALID', 'approval wait preference is outside its safety bound'); - } - return milliseconds; } function correlationIdForAgentCall(agentCallId) { @@ -737,26 +721,6 @@ function validOpenAiEventStreamBody(value, maximum) { return bytes; } -function approvalWaitDelay(milliseconds, signal) { - if (signal.aborted) { - fail('AGENT_REQUEST_ABORTED', 'agent disconnected during approval wait'); - } - return new Promise((resolve, reject) => { - const onAbort = () => { - clearTimeout(timer); - reject(new KernelError( - 'AGENT_REQUEST_ABORTED', - 'agent disconnected during approval wait', - )); - }; - const timer = setTimeout(() => { - signal.removeEventListener('abort', onAbort); - resolve(); - }, milliseconds); - signal.addEventListener('abort', onAbort, { once: true }); - }); -} - export function createSpendControlProxy(value) { const dependencies = captureDependencies(value); const app = new Hono({ strict: true }); @@ -795,7 +759,7 @@ export function createSpendControlProxy(value) { const session = await authorize(context); requireNoQueryOrEncoding(context); const route = requireRoute(dependencies, context.req.param('routeId'), kind); - const approvalWaitMs = requestedApprovalWaitMs(context.req.raw); + requireNoApprovalWaitPreference(context.req.raw); const headers = forwardHeaders(context.req.raw); const agentCallId = requiredAgentCallId(context.req.raw); const bodyBytes = await readJsonObjectBody( @@ -826,22 +790,13 @@ export function createSpendControlProxy(value) { } return status; }; - const executeExact = async () => { - const result = capturedExecutionResult( - await dependencies.execute(executionInput()), - ); - canonicalToken(result.requestId, 'Kernel request ID'); - return Object.freeze({ result, status: await readStatus(result.requestId) }); - }; - let { result, status } = await executeExact(); + const result = capturedExecutionResult( + await dependencies.execute(executionInput()), + ); + canonicalToken(result.requestId, 'Kernel request ID'); + const status = await readStatus(result.requestId); - const approvalHardDeadline = approvalWaitMs === 0 ? null : Date.now() + approvalWaitMs; - let approvalTransitions = 0; - while (result.status === 'payment_approval_required') { - approvalTransitions += 1; - if (approvalTransitions > MAXIMUM_APPROVAL_TRANSITIONS) { - fail('AGENT_APPROVAL_WAIT_TIMEOUT', 'approval transition count reached its safety bound'); - } + if (result.status === 'payment_approval_required') { if (result.receipt !== null || result.reasonCode !== 'HUMAN_APPROVAL_REQUIRED') { fail('AGENT_RESPONSE_INVALID', 'Kernel approval projections disagree'); } @@ -850,49 +805,56 @@ export function createSpendControlProxy(value) { } catch { fail('AGENT_RESPONSE_INVALID', 'Kernel approval result expiry is invalid'); } - const requestId = result.requestId; if (status.outcome !== null) { if (status.approval !== null || status.receipt === null) { fail('AGENT_RESPONSE_INVALID', 'Kernel raced terminal approval projection is invalid'); } - } else { - const approval = approvalProjection(status.approval, { allowApproved: true }); - if (status.receipt !== null || result.expiresAt !== approval.expiresAt) { - fail('AGENT_RESPONSE_INVALID', 'Kernel approval projections disagree'); + const outcome = canonicalPublicOutcome(status.outcome); + const projected = projectReceipt(status.receipt, status.remainingSessionAtomic, { + sessionId: session.id, + route, + }); + if (projected.requestId !== result.requestId + || projected.reasonCode !== outcome.reasonCode + || projected.revision !== outcome.revision + || projected.compact.terminalState !== outcome.status) { + fail('AGENT_RESPONSE_INVALID', 'Kernel raced terminal approval projection disagrees'); } - if (approvalWaitMs === 0) { + const receipt = projected.compact; + if (outcome.status === 'completed') { return context.json({ - status: 'payment_approval_required', + status: 'completed_replay', + terminalStatus: 'completed', requestId: result.requestId, - approval: { - expiresAt: approval.expiresAt, - amountAtomic: approval.amountAtomic, - sellerOrigin: new URL(route.upstreamUrl).origin, - purposeLabel: route.purposeLabel, + reasonCode: outcome.reasonCode, + projections: { + request: `/agent/v1/intents/${encodeURIComponent(result.requestId)}`, + receipt: `/agent/v1/receipts/${encodeURIComponent(receipt.id)}`, }, + receipt, }, 409); } - while (status.approval?.state === 'pending') { - const now = Date.now(); - if (now >= Date.parse(approval.expiresAt) - || now >= approvalHardDeadline) break; - await approvalWaitDelay(Math.min( - APPROVAL_POLL_INTERVAL_MS, - Date.parse(approval.expiresAt) - now, - approvalHardDeadline - now, - ), context.req.raw.signal); - status = await readStatus(requestId); - } - if (Date.now() >= approvalHardDeadline - && Date.now() < Date.parse(approval.expiresAt) - && status.outcome === null) { - fail('AGENT_APPROVAL_WAIT_TIMEOUT', 'connected approval wait reached its bound'); - } + return context.json({ + status: outcome.status, + requestId: result.requestId, + reasonCode: outcome.reasonCode, + receipt, + }, terminalHttpStatus(outcome.status, projected.httpStatus)); } - if (context.req.raw.signal.aborted) { - fail('AGENT_REQUEST_ABORTED', 'agent disconnected before approval resume'); + const approval = approvalProjection(status.approval, { allowApproved: true }); + if (status.receipt !== null || result.expiresAt !== approval.expiresAt) { + fail('AGENT_RESPONSE_INVALID', 'Kernel approval projections disagree'); } - ({ result, status } = await executeExact()); + return context.json({ + status: 'payment_approval_required', + requestId: result.requestId, + approval: { + expiresAt: approval.expiresAt, + amountAtomic: approval.amountAtomic, + sellerOrigin: new URL(route.upstreamUrl).origin, + purposeLabel: route.purposeLabel, + }, + }, 409); } if (result.status === 'request_in_flight') { diff --git a/spikes/pi-wielder/tests/evidence-runner.test.mjs b/spikes/pi-wielder/tests/evidence-runner.test.mjs index ac2fef8..bbfd63c 100644 --- a/spikes/pi-wielder/tests/evidence-runner.test.mjs +++ b/spikes/pi-wielder/tests/evidence-runner.test.mjs @@ -35,10 +35,15 @@ const INVARIANT_IDS = Object.freeze([ ]); const CHILD_NAMES = Object.freeze([ 'model', 'seller', 'bootstrap', 'control-initial', 'control-restarted', - 'pi-tool-approval', 'pi-model-approval', 'control-recovery', + 'pi-tool-approval-first', 'pi-tool-approval-second', + 'pi-model-approval-first', 'pi-model-approval-second', 'control-recovery', 'bootstrap-replacement', 'control-replacement', 'control-verifier', ]); +const EXPECTED_PROCESS_EXIT_CODES = Object.freeze(Object.fromEntries( + CHILD_NAMES.map((name) => [name, name === 'pi-model-approval-first' ? 1 : 0]), +)); + function temporaryDirectory(t) { const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'evidence-runner-'))); fs.chmodSync(directory, 0o700); @@ -83,18 +88,29 @@ function acceptanceResult(sequence, anchorOutput) { passed: true, evidenceHash: sha256(`accepted:${id}`), })), - processExitCodes: Object.fromEntries(CHILD_NAMES.map((name) => [name, 0])), + processExitCodes: { ...EXPECTED_PROCESS_EXIT_CODES }, transactionIds: [`0x${'ab'.repeat(32)}`], rawSettlementTransactionIds: [`0x${'ab'.repeat(32)}`], piApprovalResume: Object.fromEntries(['tool', 'model'].map((kind) => [kind, { - pendingObserved: true, - originalRequestHeld: true, + firstAttempt: { + pendingObserved: true, + exitedBeforeOperatorApproval: true, + signerDelta: 0, + paidRequestDelta: 0, + outputObserved: kind === 'tool' + ? 'PI_APPROVAL_REQUIRED' + : 'approval-required-error', + processExitCode: kind === 'tool' ? 0 : 1, + }, operatorApprovalStatus: 200, - signerDelta: 1, - paidRequestDelta: 1, - duplicatePaymentSignatureDelta: 0, - outputObserved: 'PI_WALLET_OK', - processExitCode: 0, + secondAttempt: { + sameRequestFingerprint: true, + signerDelta: 1, + paidRequestDelta: 1, + duplicatePaymentSignatureDelta: 0, + outputObserved: 'PI_WALLET_OK', + processExitCode: 0, + }, }])), }, sessionProjections: [signedProjection], @@ -299,6 +315,7 @@ test('offline evidence rejects a diluted self-consistent acceptance result', asy test('offline evidence rejects nonzero or incomplete child-process authority before build', async (t) => { for (const mutate of [ (result) => { result.evidenceInput.acceptance.processExitCodes.seller = 1; }, + (result) => { result.evidenceInput.acceptance.processExitCodes['pi-model-approval-first'] = 0; }, (result) => { delete result.evidenceInput.acceptance.processExitCodes['control-verifier']; }, ]) { const parent = temporaryDirectory(t); @@ -350,7 +367,11 @@ test('offline evidence rejects missing or altered pinned Pi approval proof befor const mutations = [ (result) => { delete result.evidenceInput.acceptance.piApprovalResume.model; }, (result) => { - result.evidenceInput.acceptance.piApprovalResume.tool.originalRequestHeld = false; + result.evidenceInput.acceptance.piApprovalResume.tool.firstAttempt.signerDelta = 1; + }, + (result) => { + result.evidenceInput.acceptance.piApprovalResume.model.secondAttempt.sameRequestFingerprint + = false; }, ]; for (const mutate of mutations) { diff --git a/spikes/pi-wielder/tests/fixtures/pi-client-process.mjs b/spikes/pi-wielder/tests/fixtures/pi-client-process.mjs index 39b0d32..867a3e7 100644 --- a/spikes/pi-wielder/tests/fixtures/pi-client-process.mjs +++ b/spikes/pi-wielder/tests/fixtures/pi-client-process.mjs @@ -9,6 +9,12 @@ const EXTENSION = path.resolve(ROOT, 'pi-extension/x402.ts'); const TOTAL_DEADLINE_MS = 30_000; const TERMINATION_GRACE_MS = 2_000; const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const PI_PROMPT_MODES = new Set(['tool', 'model']); +const PI_EXPECTED_OUTPUTS = new Set([ + 'PI_APPROVAL_REQUIRED', + 'approval-required-error', + 'PI_WALLET_OK', +]); function fail(code) { const error = new Error(code); @@ -135,7 +141,14 @@ async function runPi() { canonicalPath(EXTENSION, 'PI_EXTENSION_INVALID', { executable: true }); const env = childEnvironment(); const piVersion = exactVersion(env); - const scriptedPrompt = 'Use invoke_skill once with input commercial acceptance, then report the final result.'; + const promptMode = process.env.WALLET_KERNEL_PI_PROMPT_MODE; + const expectedOutput = process.env.WALLET_KERNEL_PI_EXPECTED_OUTPUT; + if (!PI_PROMPT_MODES.has(promptMode) || !PI_EXPECTED_OUTPUTS.has(expectedOutput)) { + fail('PI_EXPECTATION_INVALID'); + } + const scriptedPrompt = promptMode === 'tool' + ? 'Use invoke_skill once with input commercial acceptance, then report the final result.' + : 'Report PI_WALLET_OK without invoking any tool.'; const child = spawn(PI_BIN, [ '-p', scriptedPrompt, '--no-session', @@ -193,25 +206,36 @@ async function runPi() { const stdoutBytesValue = Buffer.concat(stdout); const stderrBytesValue = Buffer.concat(stderr); const output = new TextDecoder('utf-8', { fatal: false }).decode(stdoutBytesValue); - const outputObserved = output.includes('PI_WALLET_OK') ? 'PI_WALLET_OK' : 'missing'; - const success = !deadlineExpired && result.code === 0 && result.signal === null - && outputObserved === 'PI_WALLET_OK'; + const errorOutput = new TextDecoder('utf-8', { fatal: false }).decode(stderrBytesValue); + const outputObserved = output.includes('PI_WALLET_OK') + ? 'PI_WALLET_OK' + : output.includes('PI_APPROVAL_REQUIRED') + ? 'PI_APPROVAL_REQUIRED' + : /payment_approval_required|Approval required:|\b409 status code\b/u.test(errorOutput) + ? 'approval-required-error' + : 'missing'; + const rawExitCode = deadlineExpired ? 124 : (result.code ?? 1); + const expectedExitCode = expectedOutput === 'approval-required-error' ? 1 : 0; + const expectationMatched = !deadlineExpired && result.signal === null + && rawExitCode === expectedExitCode && outputObserved === expectedOutput; const message = Object.freeze({ type: 'result', - exitCode: success ? 0 : (deadlineExpired ? 124 : (result.code ?? 1)), + exitCode: rawExitCode, signal: result.signal, piVersion, outputObserved, stdoutHash: sha256(stdoutBytesValue), stderrHash: sha256(stderrBytesValue), - failureCode: success ? null : (deadlineExpired ? 'PROCESS_DEADLINE' : 'PI_PROCESS_FAILED'), + failureCode: expectationMatched + ? null + : (deadlineExpired ? 'PROCESS_DEADLINE' : 'PI_PROCESS_FAILED'), }); stdoutBytesValue.fill(0); stderrBytesValue.fill(0); for (const chunk of stdout) chunk.fill(0); for (const chunk of stderr) chunk.fill(0); if (typeof process.send === 'function') process.send(message); - if (!success) process.stderr.write(`${message.failureCode}\n`); + if (!expectationMatched) process.stderr.write(`${message.failureCode}\n`); process.exitCode = message.exitCode; if (typeof process.disconnect === 'function' && process.connected) process.disconnect(); } diff --git a/spikes/pi-wielder/tests/fixtures/pi-model-process.mjs b/spikes/pi-wielder/tests/fixtures/pi-model-process.mjs index 2deb699..e577cea 100644 --- a/spikes/pi-wielder/tests/fixtures/pi-model-process.mjs +++ b/spikes/pi-wielder/tests/fixtures/pi-model-process.mjs @@ -89,7 +89,32 @@ function chunk(id, delta, finishReason = null) { }); } -function sendStreaming(response, ordinal) { +function messageText(message) { + if (!message || typeof message !== 'object') return ''; + if (typeof message.content === 'string') return message.content; + if (!Array.isArray(message.content)) return ''; + return message.content + .map((part) => (part && typeof part === 'object' && typeof part.text === 'string' + ? part.text + : '')) + .join(''); +} + +function responseKind(messages) { + const toolText = messages + .filter((message) => message?.role === 'tool') + .map(messageText) + .join('\n'); + if (toolText.includes('Approval required:')) return 'approval-required'; + if (toolText.length > 0) return 'completed'; + const userText = messages + .filter((message) => message?.role === 'user') + .map(messageText) + .join('\n'); + return userText.includes('without invoking any tool') ? 'model-only' : 'tool-call'; +} + +function sendStreaming(response, ordinal, kind) { const id = `chatcmpl-wallet-kernel-${ordinal}`; response.writeHead(200, { 'cache-control': 'no-store', @@ -97,7 +122,7 @@ function sendStreaming(response, ordinal) { 'content-type': 'text/event-stream; charset=utf-8', 'x-content-type-options': 'nosniff', }); - if (ordinal === 1) { + if (kind === 'tool-call') { response.write(`data: ${chunk(id, { role: 'assistant', tool_calls: [{ @@ -112,7 +137,10 @@ function sendStreaming(response, ordinal) { })}\n\n`); response.write(`data: ${chunk(id, {}, 'tool_calls')}\n\n`); } else { - response.write(`data: ${chunk(id, { role: 'assistant', content: 'PI_WALLET_OK' })}\n\n`); + const content = kind === 'approval-required' + ? 'PI_APPROVAL_REQUIRED' + : 'PI_WALLET_OK'; + response.write(`data: ${chunk(id, { role: 'assistant', content })}\n\n`); response.write(`data: ${chunk(id, {}, 'stop')}\n\n`); } response.end('data: [DONE]\n\n'); @@ -160,7 +188,7 @@ const server = http.createServer(async (request, response) => { toolResultObserved: state.toolResultObserved || toolResultObserved, }); persistState(); - sendStreaming(response, ordinal); + sendStreaming(response, ordinal, responseKind(parsed.messages)); } catch (error) { sendJson(response, error?.code === 'FIXTURE_BODY_TOO_LARGE' ? 413 : 400, { error: { code: typeof error?.code === 'string' ? error.code : 'MODEL_REQUEST_FAILED' }, diff --git a/spikes/pi-wielder/tests/pi-extension-contract.test.mjs b/spikes/pi-wielder/tests/pi-extension-contract.test.mjs index f8135ea..a0afd32 100644 --- a/spikes/pi-wielder/tests/pi-extension-contract.test.mjs +++ b/spikes/pi-wielder/tests/pi-extension-contract.test.mjs @@ -274,7 +274,6 @@ test('activation registers one fixed provider and one fixed Skill route with onl assert.deepEqual(providers[0].config.headers, { Authorization: `WalletKernelAgent ${TOKEN}`, 'Content-Type': 'application/json', - Prefer: 'wait=300', }); assert.equal(providers[0].config.models.length, 1); assert.equal(providers[0].config.models[0].id, 'scripted-local'); @@ -304,7 +303,6 @@ test('activation registers one fixed provider and one fixed Skill route with onl assert.deepEqual(requests[0].options.headers, { Authorization: `WalletKernelAgent ${TOKEN}`, 'Content-Type': 'application/json', - Prefer: 'wait=300', 'x-agent-call-id': toolAgentCallId(toolCallId), }); assert.equal(requests[0].options.signal, controller.signal); @@ -329,14 +327,17 @@ test('model request keys survive transient retry and rotate only on success or f const providerHeaders = () => ({ type: 'before_provider_headers', - headers: { Authorization: `WalletKernelAgent ${TOKEN}` }, + headers: { + Authorization: `WalletKernelAgent ${TOKEN}`, + Prefer: 'wait=999', + }, }); const first = providerHeaders(); handlers.get('before_provider_headers')(first, Object.freeze({})); const firstId = first.headers['x-agent-call-id']; assert.match(firstId, /^[A-Za-z0-9_-]{43}$/u); assert.equal(Buffer.from(firstId, 'base64url').length, 32); - assert.equal(first.headers.Prefer, 'wait=300'); + assert.equal(Object.hasOwn(first.headers, 'Prefer'), false); handlers.get('message_end')({ type: 'message_end', @@ -493,6 +494,169 @@ test('one controlled tool retry reuses its call key and renders terminal replay }); }); +test('returned invalid Kernel content is never retried as a transport loss', async (t) => { + for (const fixture of [ + { + label: 'invalid content type', + response: () => new Response('not-json', { + status: 502, + headers: { 'content-type': 'text/plain' }, + }), + }, + { + label: 'malformed JSON', + response: () => new Response('not-json', { + status: 502, + headers: { 'content-type': 'application/json' }, + }), + }, + ]) { + await t.test(fixture.label, async (st) => { + const credentialPath = temporaryCredential(st); + const tools = []; + let fetchCalls = 0; + activate({ + registerProvider() {}, + registerTool(tool) { tools.push(tool); }, + on() {}, + }, { + env: environment(credentialPath), + fetchFn: async () => { + fetchCalls += 1; + return fixture.response(); + }, + }); + + await assert.rejects( + tools[0].execute( + `call_invalid_kernel_response_${fetchCalls}`, + { input: 'ordinary input' }, + undefined, + undefined, + Object.freeze({}), + ), + (error) => error?.code === 'PI_KERNEL_RESPONSE_INVALID', + ); + assert.equal(fetchCalls, 1); + }); + } +}); + +test('a Kernel response-body transport loss receives one same-key retry', async (t) => { + const credentialPath = temporaryCredential(t); + const tools = []; + const requests = []; + const replay = { + status: 'completed_replay', + terminalStatus: 'completed', + requestId: 'request_public_1', + reasonCode: 'PAYMENT_SETTLED', + projections: { + request: '/agent/v1/intents/request_public_1', + receipt: '/agent/v1/receipts/receipt_public_1', + }, + receipt: receipt(), + }; + activate({ + registerProvider() {}, + registerTool(tool) { tools.push(tool); }, + on() {}, + }, { + env: environment(credentialPath), + fetchFn: async (url, options) => { + requests.push({ url, options }); + if (requests.length === 1) { + return { + headers: new Headers({ 'content-type': 'application/json' }), + async text() { throw new TypeError('simulated body transport loss'); }, + }; + } + return new Response(JSON.stringify(replay), { + status: 409, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const output = await tools[0].execute( + 'call_paid_body_lost', + { input: 'ordinary input' }, + undefined, + undefined, + Object.freeze({}), + ); + + assert.equal(requests.length, 2); + assert.equal( + requests[0].options.headers['x-agent-call-id'], + requests[1].options.headers['x-agent-call-id'], + ); + assert.deepEqual(output.details, { boundaryStatus: 'returned' }); + assert.match(output.content[0].text, /^Completed replay:/u); +}); + +test('application approval and denial responses never trigger a Skill transport retry', async (t) => { + for (const fixture of [ + { + label: 'approval', + httpStatus: 409, + outcome: { + status: 'payment_approval_required', + requestId: 'request_public_1', + approval: { + expiresAt: '2026-08-01T12:00:00.000Z', + amountAtomic: '1200', + sellerOrigin: 'https://seller.example', + purposeLabel: 'skill.invoke', + }, + }, + output: /^Approval required:/u, + }, + { + label: 'denial', + httpStatus: 403, + outcome: { + status: 'payment_denied', + requestId: 'request_public_1', + reasonCode: 'OPERATOR_DENIED', + receipt: receipt(), + }, + output: /^Payment denied:/u, + }, + ]) { + await t.test(fixture.label, async (st) => { + const credentialPath = temporaryCredential(st); + const tools = []; + let fetchCalls = 0; + activate({ + registerProvider() {}, + registerTool(tool) { tools.push(tool); }, + on() {}, + }, { + env: environment(credentialPath), + fetchFn: async () => { + fetchCalls += 1; + return new Response(JSON.stringify(fixture.outcome), { + status: fixture.httpStatus, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const output = await tools[0].execute( + `call_application_${fixture.label}`, + { input: 'ordinary input' }, + undefined, + undefined, + Object.freeze({}), + ); + assert.equal(fetchCalls, 1); + assert.deepEqual(output.details, { boundaryStatus: 'returned' }); + assert.match(output.content[0].text, fixture.output); + }); + } +}); + test('stable outcome rendering separates approval, denial, expiry, failure, refund, and uncertainty', () => { const publicReceipt = receipt(); const cases = [ diff --git a/spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs b/spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs index 5b8c384..2f893b1 100644 --- a/spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs +++ b/spikes/pi-wielder/tests/spend-control-process-e2e.test.mjs @@ -7,7 +7,10 @@ import path from 'node:path'; import { promisify } from 'node:util'; import test from 'node:test'; -import { runSpendControlProcessAcceptance } from '../scripts/lib/spend-control-process-runner.mjs'; +import { + runSpendControlProcessAcceptance, + SPEND_CONTROL_PROCESS_CHILD_NAMES, +} from '../scripts/lib/spend-control-process-runner.mjs'; const execFileAsync = promisify(execFile); const ROOT = path.resolve(import.meta.dirname, '..'); @@ -36,6 +39,26 @@ const EXPECTED_INVARIANTS = Object.freeze([ 'revocation-recovery-and-replacement', ]); +const EXPECTED_CHILD_PROCESSES = Object.freeze([ + 'model', + 'seller', + 'bootstrap', + 'control-initial', + 'control-restarted', + 'pi-tool-approval-first', + 'pi-tool-approval-second', + 'pi-model-approval-first', + 'pi-model-approval-second', + 'control-recovery', + 'bootstrap-replacement', + 'control-replacement', + 'control-verifier', +]); + +test('process acceptance tracks both ordinary Pi attempts for each approval path', () => { + assert.deepEqual(SPEND_CONTROL_PROCESS_CHILD_NAMES, EXPECTED_CHILD_PROCESSES); +}); + async function supportsLoopbackListener() { const server = net.createServer(); try { @@ -156,11 +179,21 @@ test('real pinned-Pi process acceptance proves all eighteen invariants', async ( result.evidenceInput.acceptance.invariants.every(({ passed }) => passed === true), true, ); - assert.equal( - Object.values(result.evidenceInput.acceptance.processExitCodes) - .every((code) => code === 0), - true, - ); + assert.deepEqual(result.evidenceInput.acceptance.processExitCodes, { + model: 0, + seller: 0, + bootstrap: 0, + 'control-initial': 0, + 'control-restarted': 0, + 'pi-tool-approval-first': 0, + 'pi-tool-approval-second': 0, + 'pi-model-approval-first': 1, + 'pi-model-approval-second': 0, + 'control-recovery': 0, + 'bootstrap-replacement': 0, + 'control-replacement': 0, + 'control-verifier': 0, + }); assert.equal(new Set(result.evidenceInput.acceptance.transactionIds).size, result.evidenceInput.acceptance.transactionIds.length); assert.equal(result.evidenceInput.acceptance.rawSettlementTransactionIds.length > 1, true); @@ -173,14 +206,23 @@ test('real pinned-Pi process acceptance proves all eighteen invariants', async ( assert.equal(result.evidenceInput.acceptance.piOutputObserved, 'PI_WALLET_OK'); for (const kind of ['tool', 'model']) { assert.deepEqual(result.evidenceInput.acceptance.piApprovalResume[kind], { - pendingObserved: true, - originalRequestHeld: true, + firstAttempt: { + pendingObserved: true, + exitedBeforeOperatorApproval: true, + signerDelta: 0, + paidRequestDelta: 0, + outputObserved: kind === 'tool' ? 'PI_APPROVAL_REQUIRED' : 'approval-required-error', + processExitCode: kind === 'tool' ? 0 : 1, + }, operatorApprovalStatus: 200, - signerDelta: 1, - paidRequestDelta: 1, - duplicatePaymentSignatureDelta: 0, - outputObserved: 'PI_WALLET_OK', - processExitCode: 0, + secondAttempt: { + sameRequestFingerprint: true, + signerDelta: 1, + paidRequestDelta: 1, + duplicatePaymentSignatureDelta: 0, + outputObserved: 'PI_WALLET_OK', + processExitCode: 0, + }, }); } assert.equal(result.evidenceInput.freshVerification.authorityEventChain, true); diff --git a/spikes/pi-wielder/tests/spend-control-proxy.test.mjs b/spikes/pi-wielder/tests/spend-control-proxy.test.mjs index cb5242f..4ab846f 100644 --- a/spikes/pi-wielder/tests/spend-control-proxy.test.mjs +++ b/spikes/pi-wielder/tests/spend-control-proxy.test.mjs @@ -182,6 +182,16 @@ async function json(response) { return await response.json(); } +function deferred() { + let resolve; + let reject; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return Object.freeze({ promise, resolve, reject }); +} + test('proxy construction exposes only the four agent route capabilities', () => { const { app, calls } = create(); @@ -675,7 +685,7 @@ test('completed output requires one signed bounded upstream status', async (t) = } }); -test('connected Pi model and Skill calls wait for approval and resume exact authority once', async (t) => { +test('model and Skill approvals return immediately and resume only on a later same-key request', async (t) => { for (const fixture of [ { label: 'model', @@ -700,7 +710,6 @@ test('connected Pi model and Skill calls wait for approval and resume exact auth expiresAt: new Date(Date.now() + 5_000).toISOString(), amountAtomic: '250000', }); - const approved = Object.freeze({ ...approval, state: 'approved' }); const receipt = signedReceipt({ resourcePath: fixture.resourcePath, purposeLabel: fixture.purposeLabel, @@ -709,11 +718,12 @@ test('connected Pi model and Skill calls wait for approval and resume exact auth const executions = []; let statusReads = 0; let signerCalls = 0; + let approved = false; const { app } = create({ kernel: { async execute(input) { executions.push(input); - if (executions.length === 1) { + if (!approved) { return Object.freeze({ requestId: 'request-1', status: 'payment_approval_required', @@ -734,25 +744,34 @@ test('connected Pi model and Skill calls wait for approval and resume exact auth }, statusByRequestId() { statusReads += 1; - if (executions.length > 1) { + if (approved) { return statusView(receipt, { purposeLabel: fixture.purposeLabel }); } return statusView(null, { - approval: statusReads === 1 ? approval : approved, + approval, purposeLabel: fixture.purposeLabel, }); }, }, }); - const response = await app.request(`${ORIGIN}${fixture.path}`, { - method: 'POST', headers: agentHeaders({ Prefer: 'wait=1' }), body: fixture.body, + const request = () => app.request(`${ORIGIN}${fixture.path}`, { + method: 'POST', headers: agentHeaders(), body: fixture.body, }); - assert.equal(response.status, 200, await response.clone().text()); + const first = await request(); + assert.equal(first.status, 409, await first.clone().text()); + assert.equal((await json(first)).status, 'payment_approval_required'); + assert.equal(executions.length, 1); + assert.equal(signerCalls, 0); + assert.equal(statusReads, 1); + + approved = true; + const second = await request(); + assert.equal(second.status, 200, await second.clone().text()); assert.equal(executions.length, 2); assert.equal(signerCalls, 1); - assert.ok(statusReads >= 3); + assert.equal(statusReads, 2); assert.deepEqual(executions[1], executions[0]); assert.notEqual(executions[1], executions[0]); assert.notEqual(executions[1].request.bodyBytes, executions[0].request.bodyBytes); @@ -764,13 +783,14 @@ test('connected Pi model and Skill calls wait for approval and resume exact auth } }); -test('ordinary raw callers receive bounded approval state unless they opt into waiting', async () => { +test('approval-required is bounded after one Kernel execution and one status read', async () => { const approval = { state: 'pending', expiresAt: new Date(Date.now() + 20).toISOString(), amountAtomic: '250000', }; let executeCalls = 0; + let statusReads = 0; const { app } = create({ kernel: { async execute() { @@ -783,7 +803,10 @@ test('ordinary raw callers receive bounded approval state unless they opt into w receipt: null, }); }, - statusByRequestId() { return statusView(null, { approval }); }, + statusByRequestId() { + statusReads += 1; + return statusView(null, { approval }); + }, }, }); const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { @@ -802,10 +825,13 @@ test('ordinary raw callers receive bounded approval state unless they opt into w }, }); assert.equal(executeCalls, 1); + assert.equal(statusReads, 1); }); -test('approval wait preference uses one exact bounded grammar before Kernel execution', async (t) => { +test('every approval wait preference is forbidden before Kernel execution', async (t) => { for (const preference of [ + 'wait=1', + 'wait=300', 'wait=0', 'wait=01', 'wait=301', @@ -827,7 +853,62 @@ test('approval wait preference uses one exact bounded grammar before Kernel exec } }); -test('an operator denial racing the first approval status read returns its terminal receipt', async () => { +test('denial and expiry races return the signed terminal projection without another execute', async (t) => { + for (const reasonCode of ['OPERATOR_DENIED', 'APPROVAL_EXPIRED']) { + await t.test(reasonCode, async () => { + const approval = Object.freeze({ + state: 'pending', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + amountAtomic: '250000', + }); + const receipt = signedReceipt({ + status: 'payment_denied', + reasonCode, + paymentState: 'none', + transactionId: null, + budgetDisposition: 'released', + resourcePath: '/paid/skill', + purposeLabel: 'skill.invoke', + executionState: 'none', + httpStatus: null, + }); + let executeCalls = 0; + let statusReads = 0; + const { app } = create({ + kernel: { + async execute() { + executeCalls += 1; + return Object.freeze({ + requestId: 'request-1', + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, + }); + }, + statusByRequestId() { + statusReads += 1; + return statusView(receipt, { purposeLabel: 'skill.invoke' }); + }, + }, + }); + + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', headers: agentHeaders(), body: '{"input":"hello"}', + }); + + assert.equal(response.status, 403, await response.clone().text()); + const outcome = await json(response); + assert.equal(outcome.status, 'payment_denied'); + assert.equal(outcome.reasonCode, reasonCode); + assert.equal(outcome.receipt.id, 'receipt-1'); + assert.equal(executeCalls, 1); + assert.equal(statusReads, 1); + }); + } +}); + +test('a raced terminal approval rejects an outcome that disagrees with its signed receipt state', async () => { const approval = Object.freeze({ state: 'pending', expiresAt: new Date(Date.now() + 5_000).toISOString(), @@ -844,163 +925,44 @@ test('an operator denial racing the first approval status read returns its termi executionState: 'none', httpStatus: null, }); - let executeCalls = 0; - let signerCalls = 0; const { app } = create({ kernel: { async execute() { - executeCalls += 1; - if (executeCalls === 1) { - return Object.freeze({ - requestId: 'request-1', - status: 'payment_approval_required', - reasonCode: 'HUMAN_APPROVAL_REQUIRED', - expiresAt: approval.expiresAt, - receipt: null, - }); - } return Object.freeze({ requestId: 'request-1', - status: 'payment_denied', - reasonCode: 'OPERATOR_DENIED', - receipt, + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: approval.expiresAt, + receipt: null, }); }, statusByRequestId() { return statusView(receipt, { - outcome: { status: 'payment_denied', reasonCode: 'OPERATOR_DENIED', revision: 1 }, purposeLabel: 'skill.invoke', + outcome: { + status: 'payment_failed', + reasonCode: 'OPERATOR_DENIED', + revision: 1, + }, }); }, }, }); - const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { - method: 'POST', - headers: agentHeaders({ Prefer: 'wait=1' }), - body: '{"input":"hello"}', - }); - assert.equal(response.status, 403, await response.clone().text()); - assert.equal((await json(response)).reasonCode, 'OPERATOR_DENIED'); - assert.equal(executeCalls, 2); - assert.equal(signerCalls, 0); -}); - -test('approval expiry resumes the exact request only to terminalize without signing', async () => { - const approval = Object.freeze({ - state: 'pending', - expiresAt: new Date(Date.now() - 1).toISOString(), - amountAtomic: '250000', - }); - const receipt = signedReceipt({ - status: 'payment_denied', - reasonCode: 'APPROVAL_EXPIRED', - paymentState: 'none', - transactionId: null, - budgetDisposition: 'released', - resourcePath: '/paid/skill', - purposeLabel: 'skill.invoke', - executionState: 'none', - httpStatus: null, - }); - const executions = []; - let signerCalls = 0; - const { app } = create({ - kernel: { - async execute(input) { - executions.push(input); - if (executions.length === 1) { - return Object.freeze({ - requestId: 'request-1', - status: 'payment_approval_required', - reasonCode: 'HUMAN_APPROVAL_REQUIRED', - expiresAt: approval.expiresAt, - receipt: null, - }); - } - return Object.freeze({ - requestId: 'request-1', - status: 'payment_denied', - reasonCode: 'APPROVAL_EXPIRED', - receipt, - }); - }, - statusByRequestId() { - return executions.length === 1 - ? statusView(null, { approval, purposeLabel: 'skill.invoke' }) - : statusView(receipt, { - outcome: { status: 'payment_denied', reasonCode: 'APPROVAL_EXPIRED', revision: 1 }, - purposeLabel: 'skill.invoke', - }); - }, - }, - }); const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { - method: 'POST', - headers: agentHeaders({ Prefer: 'wait=1' }), - body: '{"input":"hello"}', - }); - - assert.equal(response.status, 403, await response.clone().text()); - assert.equal((await json(response)).reasonCode, 'APPROVAL_EXPIRED'); - assert.equal(executions.length, 2); - assert.deepEqual(executions[1], executions[0]); - assert.equal(signerCalls, 0); -}); - -test('disconnect aborts an opted-in approval wait before any resume or signer authority', async () => { - const approval = Object.freeze({ - state: 'pending', - expiresAt: new Date(Date.now() + 5_000).toISOString(), - amountAtomic: '250000', - }); - let observedPending; - const pendingObserved = new Promise((resolve) => { observedPending = resolve; }); - let executeCalls = 0; - const { app } = create({ - kernel: { - async execute() { - executeCalls += 1; - return Object.freeze({ - requestId: 'request-1', - status: 'payment_approval_required', - reasonCode: 'HUMAN_APPROVAL_REQUIRED', - expiresAt: approval.expiresAt, - receipt: null, - }); - }, - statusByRequestId() { - observedPending(); - return statusView(null, { approval, purposeLabel: 'skill.invoke' }); - }, - }, + method: 'POST', headers: agentHeaders(), body: '{"input":"hello"}', }); - const controller = new AbortController(); - const responsePromise = app.request(new Request( - `${ORIGIN}/agent/v1/invoke/example-skill`, - { - method: 'POST', - headers: agentHeaders({ Prefer: 'wait=1' }), - body: '{"input":"hello"}', - signal: controller.signal, - }, - )); - await pendingObserved; - controller.abort(); - const response = await responsePromise; - assert.equal(response.status, 503); - assert.equal((await json(response)).error.code, 'AGENT_REQUEST_ABORTED'); - assert.equal(executeCalls, 1); + assert.equal(response.status, 502); + assert.equal((await json(response)).error.code, 'AGENT_RESPONSE_INVALID'); }); -test('disconnect racing an approved status prevents authority consumption', async () => { +test('an approval decision racing the first status read cannot trigger connected replay', async () => { const approval = Object.freeze({ state: 'approved', expiresAt: new Date(Date.now() + 5_000).toISOString(), amountAtomic: '250000', }); - const controller = new AbortController(); let executeCalls = 0; let signerCalls = 0; const { app } = create({ @@ -1017,28 +979,25 @@ test('disconnect racing an approved status prevents authority consumption', asyn }); }, statusByRequestId() { - controller.abort(); return statusView(null, { approval, purposeLabel: 'skill.invoke' }); }, }, }); - const response = await app.request(new Request( - `${ORIGIN}/agent/v1/invoke/example-skill`, - { - method: 'POST', - headers: agentHeaders({ Prefer: 'wait=1' }), - body: '{"input":"hello"}', - signal: controller.signal, - }, - )); + const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + method: 'POST', + headers: agentHeaders(), + body: '{"input":"hello"}', + }); - assert.equal(response.status, 503); - assert.equal((await json(response)).error.code, 'AGENT_REQUEST_ABORTED'); + assert.equal(response.status, 409, await response.clone().text()); + assert.equal((await json(response)).status, 'payment_approval_required'); assert.equal(executeCalls, 1); assert.equal(signerCalls, 0); }); -test('duplicate opted-in retries permit one signer and fail closed for followers', async () => { +test('concurrent fresh-call retries remain separate requests and one alias replays terminal state', async () => { + const callB = Buffer.alloc(32, 0x43).toString('base64url'); + const callC = Buffer.alloc(32, 0x44).toString('base64url'); const approval = Object.freeze({ state: 'pending', expiresAt: new Date(Date.now() + 5_000).toISOString(), @@ -1050,18 +1009,17 @@ test('duplicate opted-in retries permit one signer and fail closed for followers purposeLabel: 'skill.invoke', amountAtomic: approval.amountAtomic, }); - const executions = []; + const leaderStarted = deferred(); + const leaderRelease = deferred(); + const correlations = []; let approved = false; let leaderInFlight = false; let completed = false; let signerCalls = 0; - let pendingReads = 0; - let bothPending; - const bothPendingObserved = new Promise((resolve) => { bothPending = resolve; }); const { app } = create({ kernel: { async execute(input) { - executions.push(input); + correlations.push(input.correlationId); if (completed) { return Object.freeze({ requestId: 'request-1', status: 'completed', reasonCode: 'PAYMENT_SETTLED', receipt, @@ -1086,7 +1044,8 @@ test('duplicate opted-in retries permit one signer and fail closed for followers } leaderInFlight = true; signerCalls += 1; - await new Promise((resolve) => setTimeout(resolve, 40)); + leaderStarted.resolve(); + await leaderRelease.promise; completed = true; return Object.freeze({ requestId: 'request-1', @@ -1099,107 +1058,127 @@ test('duplicate opted-in retries permit one signer and fail closed for followers }, statusByRequestId() { if (completed) return statusView(receipt, { purposeLabel: 'skill.invoke' }); - if (!approved) { - pendingReads += 1; - if (pendingReads >= 2) bothPending(); - return statusView(null, { approval, purposeLabel: 'skill.invoke' }); - } - return statusView(null, { approval: approvedApproval, purposeLabel: 'skill.invoke' }); + return statusView(null, { + approval: approved ? approvedApproval : approval, + purposeLabel: 'skill.invoke', + }); }, }, }); - const request = () => app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + const request = (agentCallId) => app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { method: 'POST', - headers: agentHeaders({ Prefer: 'wait=1' }), + headers: agentHeaders({ 'x-agent-call-id': agentCallId }), body: '{"input":"hello"}', }); - const responses = [request(), request()]; - await bothPendingObserved; - approved = true; - const settled = await Promise.all(responses); - assert.deepEqual(settled.map(({ status }) => status).sort(), [200, 409]); - assert.equal(signerCalls, 1); - assert.equal(executions.every( - ({ correlationId }) => correlationId === correlationForAgentCall(AGENT_CALL_ID), - ), true); - const replay = await request(); + const pending = await request(AGENT_CALL_ID); + assert.equal(pending.status, 409); + approved = true; + const leader = request(callB); + await leaderStarted.promise; + const follower = await request(callC); + assert.equal(follower.status, 409); + assert.equal((await json(follower)).status, 'request_in_flight'); + leaderRelease.resolve(); + assert.equal((await leader).status, 200); + const replay = await request(callC); assert.equal(replay.status, 409); assert.equal((await json(replay)).status, 'completed_replay'); assert.equal(signerCalls, 1); + assert.deepEqual(correlations, [ + correlationForAgentCall(AGENT_CALL_ID), + correlationForAgentCall(callB), + correlationForAgentCall(callC), + correlationForAgentCall(callC), + ]); }); -test('changed challenge re-approval stays inside one bounded exact-request wait', async () => { +test('a changed challenge requires separate ordinary requests for replacement approval', async () => { + const callB = Buffer.alloc(32, 0x45).toString('base64url'); + const callC = Buffer.alloc(32, 0x46).toString('base64url'); const firstApproval = Object.freeze({ state: 'pending', expiresAt: new Date(Date.now() + 5_000).toISOString(), amountAtomic: '250000', }); - const secondApproval = Object.freeze({ + const replacementApproval = Object.freeze({ state: 'pending', expiresAt: new Date(Date.now() + 5_000).toISOString(), amountAtomic: '260000', }); - const receipt = signedReceipt({ - requestId: 'request-2', + const changedReceipt = signedReceipt({ + status: 'payment_denied', + reasonCode: 'APPROVAL_CHALLENGE_CHANGED', + paymentState: 'none', + transactionId: null, + budgetDisposition: 'released', resourcePath: '/paid/skill', purposeLabel: 'skill.invoke', - amountAtomic: secondApproval.amountAtomic, + executionState: 'none', + httpStatus: null, }); - const executions = []; - const readsByRequest = new Map(); - let signerCalls = 0; + let executeCalls = 0; const { app } = create({ kernel: { - async execute(input) { - executions.push(input); - if (executions.length <= 2) { - const current = executions.length === 1 ? firstApproval : secondApproval; + async execute() { + executeCalls += 1; + if (executeCalls === 1) { return Object.freeze({ - requestId: `request-${executions.length}`, + requestId: 'request-1', status: 'payment_approval_required', reasonCode: 'HUMAN_APPROVAL_REQUIRED', - expiresAt: current.expiresAt, + expiresAt: firstApproval.expiresAt, receipt: null, }); } - signerCalls += 1; + if (executeCalls === 2) { + return Object.freeze({ + requestId: 'request-1', + status: 'payment_denied', + reasonCode: 'APPROVAL_CHALLENGE_CHANGED', + receipt: changedReceipt, + replacementRequestId: 'request-2', + replacementExpiresAt: replacementApproval.expiresAt, + }); + } return Object.freeze({ requestId: 'request-2', - status: 'completed', - reasonCode: 'PAYMENT_SETTLED', - upstreamStatus: 200, - body: Buffer.from('{"output":"ok"}'), - receipt, + status: 'payment_approval_required', + reasonCode: 'HUMAN_APPROVAL_REQUIRED', + expiresAt: replacementApproval.expiresAt, + receipt: null, }); }, statusByRequestId({ requestId }) { - if (executions.length > 2) { - return statusView(receipt, { requestId, purposeLabel: 'skill.invoke' }); + if (requestId === 'request-2') { + return statusView(null, { requestId, approval: replacementApproval }); } - const reads = (readsByRequest.get(requestId) ?? 0) + 1; - readsByRequest.set(requestId, reads); - const pending = requestId === 'request-1' ? firstApproval : secondApproval; - return statusView(null, { - requestId, - approval: reads === 1 ? pending : { ...pending, state: 'approved' }, - purposeLabel: 'skill.invoke', - }); + return executeCalls === 1 + ? statusView(null, { approval: firstApproval }) + : statusView(changedReceipt, { purposeLabel: 'skill.invoke' }); }, }, }); - const response = await app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { + const request = (agentCallId) => app.request(`${ORIGIN}/agent/v1/invoke/example-skill`, { method: 'POST', - headers: agentHeaders({ Prefer: 'wait=1' }), + headers: agentHeaders({ 'x-agent-call-id': agentCallId }), body: '{"input":"hello"}', }); - assert.equal(response.status, 200, await response.clone().text()); - assert.equal(executions.length, 3); - assert.equal(signerCalls, 1); - assert.equal(executions.every((input) => Object.isFrozen(input)), true); - assert.deepEqual(executions[1], executions[0]); - assert.deepEqual(executions[2], executions[0]); + const first = await request(AGENT_CALL_ID); + const changed = await request(callB); + const replacement = await request(callC); + + assert.equal(first.status, 409); + assert.equal((await json(first)).status, 'payment_approval_required'); + assert.equal(changed.status, 403); + assert.equal((await json(changed)).reasonCode, 'APPROVAL_CHALLENGE_CHANGED'); + assert.equal(replacement.status, 409); + const replacementOutcome = await json(replacement); + assert.equal(replacementOutcome.status, 'payment_approval_required'); + assert.equal(replacementOutcome.requestId, 'request-2'); + assert.equal(replacementOutcome.approval.amountAtomic, '260000'); + assert.equal(executeCalls, 3); }); test('terminal buyer outcomes use the fixed public HTTP mapping and never return seller bodies', async (t) => { diff --git a/spikes/pi-wielder/tests/wallet-kernel.test.mjs b/spikes/pi-wielder/tests/wallet-kernel.test.mjs index 4e679f9..ca47ee4 100644 --- a/spikes/pi-wielder/tests/wallet-kernel.test.mjs +++ b/spikes/pi-wielder/tests/wallet-kernel.test.mjs @@ -1444,6 +1444,95 @@ test('Slice B RED: approved exact retry freshly probes then consumes approval wi assert.equal(context.receipts.assertParity(), true); }); +test('approved fresh-call retry is durably aliased before settlement replay', async (t) => { + const challenge = paymentRequired('50000'); + const paymentPayload = signedPaymentPayload(challenge); + let probes = 0; + let signerCalls = 0; + let paidRetries = 0; + const context = setupKernel(t, { + autoApproveAtomic: '10000', + walletAdapter: Object.freeze({ + async walletIdentity() { + return { provider: 'deterministic', walletId: 'wallet-1', address: WALLET, network: NETWORK }; + }, + async signX402Exact() { + signerCalls += 1; + return { paymentPayload }; + }, + }), + transport: Object.freeze({ + async probe() { + probes += 1; + return { kind: 'payment_required', paymentRequired: challenge }; + }, + encodePayment() { return 'fixture-payment-header'; }, + async retryPaid({ binding }) { + paidRetries += 1; + return Object.freeze({ + kind: 'settled_response', + settlement: Object.freeze({ + source: 'x402-payment-response', + headerHash: sha256(Buffer.from('fresh-call-payment-response', 'ascii')), + success: true, + transaction: `0x${'ce'.repeat(32)}`, + network: NETWORK, + payer: WALLET, + amountAtomic: '50000', + paymentHash: binding.paymentHash, + }), + status: 200, + body: Buffer.from('{"output":"paid"}'), + executionState: 'succeeded', + }); + }, + }), + }); + const session = await context.kernel.openOrResumeSession({ + agentInstanceId: DESCRIPTOR.agentInstanceId, + walletAddress: WALLET, + policyVersionId: context.activePolicy.id, + }); + const callA = { + sessionId: session.id, + routeId: 'paid-infer', + request: ordinaryRequest('approved-fresh-call-replay'), + purposeLabel: 'skill.invoke', + correlationId: 'pi-call-approved-alias-a', + }; + const pending = await context.kernel.execute(callA); + const approval = context.store.readOne('SELECT id, intent_hash FROM approvals'); + await context.kernel.approvePending({ + approvalId: approval.id, + expectedIntentHash: approval.intent_hash, + operatorIdHash: OPERATOR_HASH, + }); + + const callB = { ...callA, correlationId: 'pi-call-approved-alias-b' }; + const completed = await context.kernel.execute(callB); + const eventsAfterSettlement = context.store.events().length; + const replay = await context.kernel.execute(callB); + + assert.equal(completed.requestId, pending.requestId); + assert.equal(completed.status, 'completed'); + assert.deepEqual(Object.keys(replay), ['requestId', 'status', 'reasonCode', 'receipt']); + assert.equal(replay.requestId, completed.requestId); + assert.equal(replay.status, completed.status); + assert.equal(replay.reasonCode, completed.reasonCode); + assert.equal(replay.receipt.receiptHash, completed.receipt.receiptHash); + assert.equal(probes, 2); + assert.equal(signerCalls, 1); + assert.equal(paidRetries, 1); + assert.equal(context.store.events().length, eventsAfterSettlement); + assert.equal(context.store.readAll('SELECT * FROM spend_intents').length, 1); + assert.equal(context.store.events().filter( + (event) => event.event_type === 'intent.correlation_bound', + ).length, 1); + assert.equal(context.store.readAll('SELECT * FROM payment_attempts').length, 1); + assert.equal(context.store.readAll('SELECT * FROM signed_receipts').length, 1); + assert.equal(context.receipts.assertParity(), true); +}); + test('Slice C RED: exact branded pre-signer rejection releases the claimed reservation', async (t) => { const challenge = paymentRequired('50000'); let retryCalls = 0; From 106822f814af55cf7c2759409a0be1ad63241201 Mon Sep 17 00:00:00 2001 From: Antony Zaki Date: Sun, 2 Aug 2026 15:14:03 -0400 Subject: [PATCH 165/165] fix: pin resolvable setup-node action --- .github/workflows/pi-wielder-systemd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pi-wielder-systemd.yml b/.github/workflows/pi-wielder-systemd.yml index f9ec562..8aa6cb2 100644 --- a/.github/workflows/pi-wielder-systemd.yml +++ b/.github/workflows/pi-wielder-systemd.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Install exact Node runtime - uses: actions/setup-node@1e60f620b9541dca1515407b8e2b6c3026562c9e + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.18.1 cache: npm